feat(square): 新增发布广场功能,智能体/技能按广场分类展示
- 后端新增 PublishSquare/PublishSquaresSettings 配置与读写接口 - agents/skills 增加 square_id 字段及对应迁移脚本 - 前端新增发布广场设置页、技能转移选择器,画廊按广场分 Tab 展示 - lead_agent prompt 及若干持久化层适配调整 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3845d5090b
commit
f0aec4cd7f
39 files changed
+1707
-226
No files matched your search
@@ -320,12 +320,36 @@ def _extract_text(payload) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _clean_markdown(text: str) -> str:
|
def _clean_markdown(text: str) -> str:
|
||||||
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏。"""
|
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏,并规整 YAML frontmatter。"""
|
||||||
t = text.strip()
|
t = text.strip()
|
||||||
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
|
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
|
||||||
if m:
|
if m:
|
||||||
t = m.group(1).strip()
|
t = m.group(1).strip()
|
||||||
return t
|
return _normalize_frontmatter(t)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_frontmatter(text: str) -> str:
|
||||||
|
"""修复模型常见的 frontmatter 跑偏,让 YAML 能正常解析。
|
||||||
|
|
||||||
|
模型经常把字段名写成 markdown 加粗(``**name:**``)、加空行、留行尾双空格,
|
||||||
|
这些都会让 frontmatter 解析失败。这里把开头 ``---`` 到下一个 ``---`` 之间的内容
|
||||||
|
规整为干净的 ``key: value`` 形式。正文(第二个 ``---`` 之后)保持原样。
|
||||||
|
"""
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return text
|
||||||
|
# 拆出 frontmatter 块:开头 --- 与下一行单独的 --- 之间
|
||||||
|
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n?(.*)$", text, re.S)
|
||||||
|
if not m:
|
||||||
|
return text
|
||||||
|
body_fm, rest = m.group(1), m.group(2)
|
||||||
|
out_lines = []
|
||||||
|
for line in body_fm.splitlines():
|
||||||
|
s = line.rstrip() # 去行尾双空格
|
||||||
|
s = s.replace("**", "") # 去 markdown 加粗标记
|
||||||
|
if not s.strip():
|
||||||
|
continue # 丢掉空行
|
||||||
|
out_lines.append(s)
|
||||||
|
return "---\n" + "\n".join(out_lines) + "\n---\n\n" + rest.lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
# ================================ 名单读取 ================================
|
# ================================ 名单读取 ================================
|
||||||
@@ -454,6 +478,13 @@ def main():
|
|||||||
if not read_people(excel):
|
if not read_people(excel):
|
||||||
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
|
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
|
||||||
|
|
||||||
|
# --redo:一次性清掉名单里这些人的旧记录,让他们重做一遍;
|
||||||
|
# 之后本次会话内跑成功的会正常计为「已完成」,循环才会往后推进
|
||||||
|
# (否则每轮 pending() 都把已完成的人当未完成,永远卡在第 1 个人)
|
||||||
|
if args.redo:
|
||||||
|
for p in read_people(excel):
|
||||||
|
manifest.pop(p["name"], None)
|
||||||
|
|
||||||
client = DeerFlowClient(BASE_URL)
|
client = DeerFlowClient(BASE_URL)
|
||||||
if not client.login():
|
if not client.login():
|
||||||
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
|
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
|
||||||
@@ -469,7 +500,7 @@ def main():
|
|||||||
out = []
|
out = []
|
||||||
for p in people:
|
for p in people:
|
||||||
r = manifest.get(p["name"], {})
|
r = manifest.get(p["name"], {})
|
||||||
done = (not args.redo) and r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
|
done = r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
|
||||||
if not done:
|
if not done:
|
||||||
out.append(p)
|
out.append(p)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -13,11 +13,40 @@ import { useMemo, useState } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { type Agent } from "@/core/agents";
|
import { type Agent, normalizeAgentQuestions } from "@/core/agents";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
|
||||||
|
* for the narrow sidebar. The sidebar is a profile card, not a document view —
|
||||||
|
* so we strip markdown syntax (headings like `###`, emphasis `**`/`*`, inline
|
||||||
|
* code, list bullets, blockquotes) rather than rendering it, while keeping the
|
||||||
|
* paragraph/line structure intact.
|
||||||
|
*/
|
||||||
|
function cleanProfileText(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((line) =>
|
||||||
|
line
|
||||||
|
// Drop heading markers (`#`, `##`, `### …`) — the main "###不要出现" fix.
|
||||||
|
.replace(/^\s{0,3}#{1,6}\s*/, "")
|
||||||
|
// Blockquote markers.
|
||||||
|
.replace(/^\s{0,3}>\s?/, "")
|
||||||
|
// List bullets `- ` / `* ` / `+ ` → a bullet glyph.
|
||||||
|
.replace(/^(\s*)[-*+]\s+/, "$1• ")
|
||||||
|
// Bold / italic / inline-code markers.
|
||||||
|
.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
||||||
|
.replace(/(\*|_)(.*?)\1/g, "$2")
|
||||||
|
.replace(/`([^`]+)`/g, "$1"),
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
// Collapse 3+ blank lines into a single blank line.
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapsible right-hand "智能体简介" panel on the agent chat page. It is a
|
* Collapsible right-hand "智能体简介" panel on the agent chat page. It is a
|
||||||
* read-only window onto the agent's profile — avatar, name, slogan/description,
|
* read-only window onto the agent's profile — avatar, name, slogan/description,
|
||||||
@@ -50,7 +79,9 @@ export function AgentInfoSidebar({
|
|||||||
const description = agent?.description
|
const description = agent?.description
|
||||||
? desensitize(agent.description)
|
? desensitize(agent.description)
|
||||||
: undefined;
|
: undefined;
|
||||||
const soul = agent?.soul?.trim() ? desensitize(agent.soul) : undefined;
|
const soul = agent?.soul?.trim()
|
||||||
|
? cleanProfileText(desensitize(agent.soul))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Map a stored skill name to its Chinese display label (falls back to name).
|
// Map a stored skill name to its Chinese display label (falls back to name).
|
||||||
const skillLabel = useMemo(() => {
|
const skillLabel = useMemo(() => {
|
||||||
@@ -62,7 +93,7 @@ export function AgentInfoSidebar({
|
|||||||
}, [skills]);
|
}, [skills]);
|
||||||
|
|
||||||
const questions = useMemo(
|
const questions = useMemo(
|
||||||
() => (agent?.common_questions ?? []).filter((q) => q.trim().length > 0),
|
() => normalizeAgentQuestions(agent?.common_questions),
|
||||||
[agent?.common_questions],
|
[agent?.common_questions],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,10 +147,11 @@ export function AgentInfoSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Soul — the agent's long-form personality blurb. */}
|
{/* Description — the agent's long-form blurb, sourced from SOUL.md but
|
||||||
|
stripped of markdown syntax so it reads as plain profile copy. */}
|
||||||
{soul && (
|
{soul && (
|
||||||
<section className="space-y-3 border-t px-6 py-5">
|
<section className="space-y-3 border-t px-6 py-5">
|
||||||
<h4 className="text-foreground text-sm font-semibold">Soul</h4>
|
<h4 className="text-foreground text-sm font-semibold">描述</h4>
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{soul}
|
{soul}
|
||||||
</p>
|
</p>
|
||||||
@@ -165,14 +197,14 @@ export function AgentInfoSidebar({
|
|||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onAskQuestion(question)}
|
onClick={() => onAskQuestion(question.prompt)}
|
||||||
title={question}
|
title={desensitize(question.prompt)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{desensitize(question)}
|
{desensitize(question.title)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { BotIcon } from "lucide-react";
|
import { BotIcon } from "lucide-react";
|
||||||
|
|
||||||
import { type Agent } from "@/core/agents";
|
import { type Agent, normalizeAgentQuestions } from "@/core/agents";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -30,9 +30,7 @@ export function AgentWelcome({
|
|||||||
const description = agent?.description
|
const description = agent?.description
|
||||||
? desensitize(agent.description)
|
? desensitize(agent.description)
|
||||||
: undefined;
|
: undefined;
|
||||||
const questions = (agent?.common_questions ?? []).filter(
|
const questions = normalizeAgentQuestions(agent?.common_questions);
|
||||||
(q) => q.trim().length > 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -59,14 +57,14 @@ export function AgentWelcome({
|
|||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onAskQuestion(question)}
|
onClick={() => onAskQuestion(question.prompt)}
|
||||||
title={question}
|
title={desensitize(question.prompt)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border/70 bg-card/60 hover:bg-accent hover:border-border max-w-[280px] truncate rounded-full border px-4 py-2 text-sm transition-colors",
|
"border-border/70 bg-card/60 hover:bg-accent hover:border-border max-w-[280px] truncate rounded-full border px-4 py-2 text-sm transition-colors",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{desensitize(question)}
|
{desensitize(question.title)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import {
|
|||||||
BotIcon,
|
BotIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SearchIcon,
|
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
@@ -49,12 +47,22 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||||
|
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||||
import {
|
import {
|
||||||
|
normalizeAgentQuestions,
|
||||||
useAgent,
|
useAgent,
|
||||||
useCreateAgent,
|
useCreateAgent,
|
||||||
useDeleteAgent,
|
useDeleteAgent,
|
||||||
@@ -65,7 +73,7 @@ import {
|
|||||||
useUpdateAgent,
|
useUpdateAgent,
|
||||||
} from "@/core/agents";
|
} from "@/core/agents";
|
||||||
import { getAgent } from "@/core/agents/api";
|
import { getAgent } from "@/core/agents/api";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent, AgentQuestion } from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
@@ -210,17 +218,18 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||||
// Common questions (常用问题) are authored here in the edit dialog and read
|
// Common questions (常用问题) are authored here in the edit dialog and read
|
||||||
// back read-only on the agent chat page. Keep one trailing blank row so there
|
// back read-only on the agent chat page. Each row has a short title (shown on
|
||||||
// is always an empty input to type the next question into.
|
// the chip) and the full prompt sent when clicked.
|
||||||
const [editCommonQuestions, setEditCommonQuestions] = useState<string[]>(
|
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
|
||||||
agent.common_questions ?? [],
|
() => normalizeAgentQuestions(agent.common_questions),
|
||||||
);
|
);
|
||||||
const [editPublished, setEditPublished] = useState(agent.published);
|
const [editPublished, setEditPublished] = useState(agent.published);
|
||||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||||
|
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
|
||||||
|
const { data: publishSquares } = usePublishSquares();
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||||
agent.skills ?? [],
|
agent.skills ?? [],
|
||||||
);
|
);
|
||||||
const [skillSearch, setSkillSearch] = useState("");
|
|
||||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||||
|
|
||||||
// The /api/agents list response intentionally omits `soul` (perf optimization
|
// The /api/agents list response intentionally omits `soul` (perf optimization
|
||||||
@@ -257,15 +266,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
setEditDescription(agent.description ?? "");
|
setEditDescription(agent.description ?? "");
|
||||||
setEditPublished(agent.published);
|
setEditPublished(agent.published);
|
||||||
setEditIsPrivate(agent.is_private);
|
setEditIsPrivate(agent.is_private);
|
||||||
|
setEditSquareId(agent.square_id ?? "");
|
||||||
setSelectedSkills(agent.skills ?? []);
|
setSelectedSkills(agent.skills ?? []);
|
||||||
setEditCommonQuestions(agent.common_questions ?? []);
|
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
|
||||||
setSkillSearch("");
|
|
||||||
}, [
|
}, [
|
||||||
agent.common_questions,
|
agent.common_questions,
|
||||||
agent.description,
|
agent.description,
|
||||||
agent.is_private,
|
agent.is_private,
|
||||||
agent.name,
|
agent.name,
|
||||||
agent.published,
|
agent.published,
|
||||||
|
agent.square_id,
|
||||||
agent.skills,
|
agent.skills,
|
||||||
editOpen,
|
editOpen,
|
||||||
]);
|
]);
|
||||||
@@ -279,7 +289,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
// include `soul`) keep working.
|
// include `soul`) keep working.
|
||||||
if (agentDetail) {
|
if (agentDetail) {
|
||||||
setEditSoul(agentDetail.soul ?? "");
|
setEditSoul(agentDetail.soul ?? "");
|
||||||
setEditCommonQuestions(agentDetail.common_questions ?? []);
|
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
|
||||||
} else {
|
} else {
|
||||||
setEditSoul(agent.soul ?? "");
|
setEditSoul(agent.soul ?? "");
|
||||||
}
|
}
|
||||||
@@ -355,10 +365,14 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
// backend will see, regardless of any stale state in editPublished.
|
// backend will see, regardless of any stale state in editPublished.
|
||||||
published: editIsPrivate ? false : editPublished,
|
published: editIsPrivate ? false : editPublished,
|
||||||
is_private: editIsPrivate,
|
is_private: editIsPrivate,
|
||||||
|
square_id: editSquareId,
|
||||||
skills: selectedSkills,
|
skills: selectedSkills,
|
||||||
common_questions: editCommonQuestions
|
common_questions: editCommonQuestions
|
||||||
.map((q) => q.trim())
|
.map((q) => ({
|
||||||
.filter((q) => q.length > 0),
|
title: q.title.trim() || q.prompt.trim(),
|
||||||
|
prompt: q.prompt.trim() || q.title.trim(),
|
||||||
|
}))
|
||||||
|
.filter((q) => q.prompt.length > 0),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success("智能体已更新");
|
toast.success("智能体已更新");
|
||||||
@@ -448,34 +462,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSkill(skillName: string) {
|
|
||||||
setSelectedSkills((prev) => {
|
|
||||||
if (prev.includes(skillName)) {
|
|
||||||
return prev.filter((name) => name !== skillName);
|
|
||||||
}
|
|
||||||
return [...prev, skillName];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledSkills = useMemo(
|
const enabledSkills = useMemo(
|
||||||
() => skills.filter((skill) => skill.enabled),
|
() => skills.filter((skill) => skill.enabled),
|
||||||
[skills],
|
[skills],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Search over English name, Chinese name (name_zh) and description, so both
|
|
||||||
// 中英文 work. Already-selected skills stay searchable too (selection is
|
|
||||||
// independent of this filter).
|
|
||||||
const filteredSkills = useMemo(() => {
|
|
||||||
const q = skillSearch.trim().toLowerCase();
|
|
||||||
if (!q) return enabledSkills;
|
|
||||||
return enabledSkills.filter(
|
|
||||||
(skill) =>
|
|
||||||
skill.name.toLowerCase().includes(q) ||
|
|
||||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
|
||||||
(skill.description ?? "").toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [enabledSkills, skillSearch]);
|
|
||||||
|
|
||||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||||
// both the card header and the compact list row.
|
// both the card header and the compact list row.
|
||||||
const statusBadges = (
|
const statusBadges = (
|
||||||
@@ -873,65 +864,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
已选 {selectedSkills.length} 个
|
已选 {selectedSkills.length} 个
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{enabledSkills.length > 0 && (
|
<SkillTransferSelector
|
||||||
<div className="relative">
|
skills={enabledSkills}
|
||||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
selected={selectedSkills}
|
||||||
<Input
|
onChange={setSelectedSkills}
|
||||||
placeholder="搜索技能(支持中英文名称、描述)…"
|
isLoading={isSkillsLoading}
|
||||||
value={skillSearch}
|
onViewDetail={setDetailSkillName}
|
||||||
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
/>
|
||||||
className="h-8 pl-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
|
||||||
{isSkillsLoading ? (
|
|
||||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
|
||||||
) : enabledSkills.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
当前没有可用技能
|
|
||||||
</p>
|
|
||||||
) : filteredSkills.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
{filteredSkills.map((skill) => {
|
|
||||||
const checked = selectedSkills.includes(skill.name);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={skill.name}
|
|
||||||
className="flex w-full min-w-0 items-center"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={checked ? "default" : "outline"}
|
|
||||||
onClick={() => toggleSkill(skill.name)}
|
|
||||||
title={desensitize(skill.description ?? "")}
|
|
||||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{skill.name_zh?.trim()
|
|
||||||
? skill.name_zh
|
|
||||||
: desensitize(skill.name)}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant={checked ? "default" : "outline"}
|
|
||||||
onClick={() => setDetailSkillName(skill.name)}
|
|
||||||
title={t.settings.skills.viewDetailAction}
|
|
||||||
className="h-8 w-7 rounded-l-none border-l-0"
|
|
||||||
>
|
|
||||||
<InfoIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
不选任何技能会保存为无技能(skills = [])。
|
不选任何技能会保存为无技能(skills = [])。
|
||||||
</p>
|
</p>
|
||||||
@@ -954,32 +893,53 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{editCommonQuestions.map((value, index) => (
|
{editCommonQuestions.map((item, index) => (
|
||||||
<div key={index} className="flex items-center gap-1.5">
|
<div
|
||||||
<Input
|
key={index}
|
||||||
value={value}
|
className="space-y-1.5 rounded-md border p-2.5"
|
||||||
placeholder={`问题 ${index + 1}`}
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={item.title}
|
||||||
|
placeholder={`标题(如:能蒸馏哪些人)`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...editCommonQuestions];
|
||||||
|
next[index] = {
|
||||||
|
...next[index]!,
|
||||||
|
title: e.currentTarget.value,
|
||||||
|
};
|
||||||
|
setEditCommonQuestions(next);
|
||||||
|
}}
|
||||||
|
className="h-8 text-sm font-medium"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setEditCommonQuestions(
|
||||||
|
editCommonQuestions.filter((_, i) => i !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="删除问题"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={item.prompt}
|
||||||
|
placeholder="点击后实际发送的完整问题内容…"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...editCommonQuestions];
|
const next = [...editCommonQuestions];
|
||||||
next[index] = e.currentTarget.value;
|
next[index] = {
|
||||||
|
...next[index]!,
|
||||||
|
prompt: e.currentTarget.value,
|
||||||
|
};
|
||||||
setEditCommonQuestions(next);
|
setEditCommonQuestions(next);
|
||||||
}}
|
}}
|
||||||
className="h-9 text-sm"
|
className="min-h-16 resize-none text-sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() =>
|
|
||||||
setEditCommonQuestions(
|
|
||||||
editCommonQuestions.filter((_, i) => i !== index),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label="删除问题"
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
@@ -988,7 +948,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setEditCommonQuestions([...editCommonQuestions, ""])
|
setEditCommonQuestions([
|
||||||
|
...editCommonQuestions,
|
||||||
|
{ title: "", prompt: "" },
|
||||||
|
])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" /> 添加问题
|
<PlusIcon className="size-3.5" /> 添加问题
|
||||||
@@ -1016,6 +979,32 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">发布广场</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={editSquareId || publishSquares?.default_square_id || ""}
|
||||||
|
onValueChange={setEditSquareId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-44 text-sm">
|
||||||
|
<SelectValue placeholder="默认广场" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{publishSquares?.squares.map((sq) => (
|
||||||
|
<SelectItem key={sq.id} value={sq.id}>
|
||||||
|
{sq.name}
|
||||||
|
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between rounded-md border p-3">
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">加入私密广场</p>
|
<p className="text-sm font-medium">加入私密广场</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
|||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
|
|
||||||
@@ -36,6 +36,9 @@ export function AgentGallery() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||||
|
// Publish-square sub-filter for the 广场 tab. "" / undefined = all squares.
|
||||||
|
const [squareFilter, setSquareFilter] = useState<string>("");
|
||||||
|
const { data: publishSquares } = usePublishSquares();
|
||||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||||
@@ -53,7 +56,7 @@ export function AgentGallery() {
|
|||||||
const tagKey = tagIds.join(",");
|
const tagKey = tagIds.join(",");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPages({ all: 1, mine: 1, private: 1 });
|
setPages({ all: 1, mine: 1, private: 1 });
|
||||||
}, [search, tagKey, sort, publisher?.id]);
|
}, [search, tagKey, sort, publisher?.id, squareFilter]);
|
||||||
|
|
||||||
const { data: privateStatus } = usePrivateSquareStatus();
|
const { data: privateStatus } = usePrivateSquareStatus();
|
||||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||||
@@ -84,6 +87,8 @@ export function AgentGallery() {
|
|||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
enabled: activeTab !== "private",
|
enabled: activeTab !== "private",
|
||||||
owner: publisher?.id,
|
owner: publisher?.id,
|
||||||
|
// Square sub-filter only applies to the public 广场 tab.
|
||||||
|
square: activeTab === "all" && squareFilter ? squareFilter : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -378,23 +383,49 @@ export function AgentGallery() {
|
|||||||
tagIds={tagIds}
|
tagIds={tagIds}
|
||||||
onTagIdsChange={setTagIds}
|
onTagIdsChange={setTagIds}
|
||||||
actions={
|
actions={
|
||||||
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
<>
|
||||||
<SelectTrigger
|
{activeTab === "all" &&
|
||||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
(publishSquares?.squares.length ?? 0) > 0 && (
|
||||||
title="排序"
|
<Select
|
||||||
>
|
value={squareFilter || "__all__"}
|
||||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
onValueChange={(v) =>
|
||||||
<SelectValue />
|
setSquareFilter(v === "__all__" ? "" : v)
|
||||||
</SelectTrigger>
|
}
|
||||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
>
|
||||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
<SelectTrigger
|
||||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
className="h-9 w-full gap-2 px-3 text-xs sm:w-[140px]"
|
||||||
<SelectItem value="updated_desc">最近更新</SelectItem>
|
title="发布广场"
|
||||||
<SelectItem value="updated_asc">最早更新</SelectItem>
|
>
|
||||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
<SelectValue placeholder="全部广场" />
|
||||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||||
</Select>
|
<SelectItem value="__all__">全部广场</SelectItem>
|
||||||
|
{publishSquares?.squares.map((sq) => (
|
||||||
|
<SelectItem key={sq.id} value={sq.id}>
|
||||||
|
{sq.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||||
|
title="排序"
|
||||||
|
>
|
||||||
|
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||||
|
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||||
|
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||||
|
<SelectItem value="updated_desc">最近更新</SelectItem>
|
||||||
|
<SelectItem value="updated_asc">最早更新</SelectItem>
|
||||||
|
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||||
|
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
InboxIcon,
|
||||||
|
InfoIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { Skill } from "@/core/skills";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
const ALL_CATEGORIES = "__all__";
|
||||||
|
|
||||||
|
interface SkillTransferSelectorProps {
|
||||||
|
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
|
||||||
|
skills: Skill[];
|
||||||
|
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||||
|
selected: string[];
|
||||||
|
/** Emits the next selection (always a `string[]`, `[]` when empty). */
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Open the skill detail dialog for a given skill name. */
|
||||||
|
onViewDetail?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillLabel(skill: Skill) {
|
||||||
|
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(skill: Skill, q: string) {
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
skill.name.toLowerCase().includes(q) ||
|
||||||
|
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||||
|
(skill.description ?? "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillTransferSelector({
|
||||||
|
skills,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
isLoading,
|
||||||
|
onViewDetail,
|
||||||
|
}: SkillTransferSelectorProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
|
||||||
|
const [availablePage, setAvailablePage] = useState(0);
|
||||||
|
const [selectedPage, setSelectedPage] = useState(0);
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||||
|
|
||||||
|
// Distinct categories for the filter dropdown (skip blank ones).
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [skills]);
|
||||||
|
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Left pane: unselected skills, filtered by search + category.
|
||||||
|
const availableSkills = useMemo(() => {
|
||||||
|
return skills.filter(
|
||||||
|
(skill) =>
|
||||||
|
!selectedSet.has(skill.name) &&
|
||||||
|
matchesQuery(skill, query) &&
|
||||||
|
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||||
|
);
|
||||||
|
}, [skills, selectedSet, query, category]);
|
||||||
|
|
||||||
|
// Right pane: selected skills, filtered by search only (category filtering
|
||||||
|
// the chosen set would just hide active selections, which is confusing).
|
||||||
|
const selectedSkills = useMemo(() => {
|
||||||
|
const byName = new Map(skills.map((s) => [s.name, s]));
|
||||||
|
return selected
|
||||||
|
.map((name) => byName.get(name))
|
||||||
|
.filter((s): s is Skill => Boolean(s) && matchesQuery(s!, query));
|
||||||
|
}, [skills, selected, query]);
|
||||||
|
|
||||||
|
// Clamp pagination whenever the underlying lists shrink (search/category
|
||||||
|
// changes, items moved between panes).
|
||||||
|
const availablePages = Math.max(1, Math.ceil(availableSkills.length / PAGE_SIZE));
|
||||||
|
const selectedPages = Math.max(1, Math.ceil(selectedSkills.length / PAGE_SIZE));
|
||||||
|
useEffect(() => {
|
||||||
|
if (availablePage > availablePages - 1) setAvailablePage(availablePages - 1);
|
||||||
|
}, [availablePage, availablePages]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPage > selectedPages - 1) setSelectedPage(selectedPages - 1);
|
||||||
|
}, [selectedPage, selectedPages]);
|
||||||
|
|
||||||
|
const availableView = availableSkills.slice(
|
||||||
|
availablePage * PAGE_SIZE,
|
||||||
|
availablePage * PAGE_SIZE + PAGE_SIZE,
|
||||||
|
);
|
||||||
|
const selectedView = selectedSkills.slice(
|
||||||
|
selectedPage * PAGE_SIZE,
|
||||||
|
selectedPage * PAGE_SIZE + PAGE_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
function add(name: string) {
|
||||||
|
if (selectedSet.has(name)) return;
|
||||||
|
onChange([...selected, name]);
|
||||||
|
}
|
||||||
|
function remove(name: string) {
|
||||||
|
onChange(selected.filter((n) => n !== name));
|
||||||
|
}
|
||||||
|
function addAllFiltered() {
|
||||||
|
if (availableSkills.length === 0) return;
|
||||||
|
const merged = [...selected];
|
||||||
|
for (const skill of availableSkills) {
|
||||||
|
if (!selectedSet.has(skill.name)) merged.push(skill.name);
|
||||||
|
}
|
||||||
|
onChange(merged);
|
||||||
|
}
|
||||||
|
function clearSelected() {
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
|
||||||
|
技能加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
|
||||||
|
当前没有可用技能。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{/* Toolbar: search + category filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索全部技能(中英文名称、描述)…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
setAvailablePage(0);
|
||||||
|
setSelectedPage(0);
|
||||||
|
}}
|
||||||
|
className="h-8 pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setCategory(v);
|
||||||
|
setAvailablePage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"border-input bg-background h-8 w-32 px-2 text-xs shadow-xs outline-none",
|
||||||
|
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
||||||
|
"[&_svg]:dark:text-[#AACEE2]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="全部分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
position="popper"
|
||||||
|
className="dark:border-[#1B477E] dark:bg-[#022248]"
|
||||||
|
>
|
||||||
|
<SelectItem
|
||||||
|
value={ALL_CATEGORIES}
|
||||||
|
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||||
|
>
|
||||||
|
全部分类
|
||||||
|
</SelectItem>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<SelectItem
|
||||||
|
key={c}
|
||||||
|
value={c}
|
||||||
|
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={availableSkills.length === 0}
|
||||||
|
onClick={addAllFiltered}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
添加当前搜索结果{availableSkills.length > 0 ? `(${availableSkills.length})` : ""}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground h-7 text-xs"
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={clearSelected}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
清空已选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column transfer panes */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<SkillPane
|
||||||
|
title="可用技能"
|
||||||
|
count={availableSkills.length}
|
||||||
|
items={availableView}
|
||||||
|
variant="available"
|
||||||
|
page={availablePage}
|
||||||
|
totalPages={availablePages}
|
||||||
|
onPageChange={setAvailablePage}
|
||||||
|
onPick={add}
|
||||||
|
onViewDetail={onViewDetail}
|
||||||
|
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
|
||||||
|
/>
|
||||||
|
<SkillPane
|
||||||
|
title="已启用技能"
|
||||||
|
count={selectedSkills.length}
|
||||||
|
items={selectedView}
|
||||||
|
variant="selected"
|
||||||
|
page={selectedPage}
|
||||||
|
totalPages={selectedPages}
|
||||||
|
onPageChange={setSelectedPage}
|
||||||
|
onPick={remove}
|
||||||
|
onViewDetail={onViewDetail}
|
||||||
|
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillPaneProps {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
items: Skill[];
|
||||||
|
variant: "available" | "selected";
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPick: (name: string) => void;
|
||||||
|
onViewDetail?: (name: string) => void;
|
||||||
|
emptyHint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillPane({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
items,
|
||||||
|
variant,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onPick,
|
||||||
|
onViewDetail,
|
||||||
|
emptyHint,
|
||||||
|
}: SkillPaneProps) {
|
||||||
|
const isSelected = variant === "selected";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col overflow-hidden rounded-md border dark:border-[#1B477E] dark:bg-[#022248]">
|
||||||
|
<div className="bg-muted/40 flex items-center justify-between border-b px-2.5 py-1.5 text-xs font-medium dark:border-[#1B477E] dark:bg-[#02305f]">
|
||||||
|
<span>{title}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[240px] space-y-1.5 overflow-y-auto p-2">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1.5 text-xs">
|
||||||
|
<InboxIcon className="size-5 opacity-50" />
|
||||||
|
<span>{emptyHint}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((skill) => (
|
||||||
|
<SkillItemCard
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onPick={() => onPick(skill.name)}
|
||||||
|
onViewDetail={
|
||||||
|
onViewDetail ? () => onViewDetail(skill.name) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-2 py-1 text-xs dark:border-[#1B477E]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-6"
|
||||||
|
disabled={page <= 0}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{page + 1}/{totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-6"
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillItemCardProps {
|
||||||
|
skill: Skill;
|
||||||
|
isSelected: boolean;
|
||||||
|
onPick: () => void;
|
||||||
|
onViewDetail?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillItemCard({
|
||||||
|
skill,
|
||||||
|
isSelected,
|
||||||
|
onPick,
|
||||||
|
onViewDetail,
|
||||||
|
}: SkillItemCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onPick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onPick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={isSelected ? "点击移除" : "点击添加"}
|
||||||
|
className={cn(
|
||||||
|
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||||
|
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center rounded-full text-[10px]",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground group-hover/item:bg-primary/15 group-hover/item:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
) : (
|
||||||
|
<PlusIcon className="size-3" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className="truncate text-xs font-medium"
|
||||||
|
title={skillLabel(skill)}
|
||||||
|
>
|
||||||
|
{skillLabel(skill)}
|
||||||
|
</div>
|
||||||
|
{skill.description?.trim() && (
|
||||||
|
<div className="text-muted-foreground truncate text-[11px]">
|
||||||
|
{desensitize(skill.description)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onViewDetail && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewDetail();
|
||||||
|
}}
|
||||||
|
title="查看技能详情"
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute right-1.5 top-1.5 opacity-60 transition-opacity group-hover/item:opacity-100"
|
||||||
|
>
|
||||||
|
<InfoIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,8 +86,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||||
import { useUserPreferences } from "@/core/user-preferences";
|
import { useUserPreferences } from "@/core/user-preferences";
|
||||||
import {
|
import {
|
||||||
isThreadRagModeLocked,
|
isThreadRagModeLocked,
|
||||||
lockThreadRagMode,
|
lockThreadRagMode,
|
||||||
@@ -133,6 +133,7 @@ export function InputBox({
|
|||||||
promptPrefix,
|
promptPrefix,
|
||||||
agentSelector,
|
agentSelector,
|
||||||
assistantId,
|
assistantId,
|
||||||
|
hideAdminRecommendedQuestions,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -149,6 +150,9 @@ export function InputBox({
|
|||||||
};
|
};
|
||||||
extraHeader?: React.ReactNode;
|
extraHeader?: React.ReactNode;
|
||||||
isNewThread?: boolean;
|
isNewThread?: boolean;
|
||||||
|
/** Suppress the global admin "recommended questions" row. The agent chat page
|
||||||
|
* sets this so only the agent's own common questions surface on its home. */
|
||||||
|
hideAdminRecommendedQuestions?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
onContextChange?: (
|
onContextChange?: (
|
||||||
@@ -205,11 +209,12 @@ export function InputBox({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||||
const userPreferences = useUserPreferences();
|
const userPreferences = useUserPreferences();
|
||||||
const showAdminRecommendedQuestions =
|
const showAdminRecommendedQuestions =
|
||||||
!userPreferences.hide_admin_prompt_prefix &&
|
!hideAdminRecommendedQuestions &&
|
||||||
recommendedQuestions.length > 0;
|
!userPreferences.hide_admin_prompt_prefix &&
|
||||||
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
@@ -516,7 +521,7 @@ export function InputBox({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||||
{recommendedQuestions.map((q) => (
|
{recommendedQuestions.map((q) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||||
|
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||||
|
|
||||||
@@ -349,6 +350,8 @@ export function AppearanceSettingsPage() {
|
|||||||
<PromptPrefixSettingsPage embedded />
|
<PromptPrefixSettingsPage embedded />
|
||||||
<Separator />
|
<Separator />
|
||||||
<PrivateSquareSettingsPage embedded />
|
<PrivateSquareSettingsPage embedded />
|
||||||
|
<Separator />
|
||||||
|
<PublishSquaresSettingsPage embedded />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
import { useViewMode } from "@/core/system-settings/hooks";
|
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -164,6 +164,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [viewMode, setViewMode] = useViewMode("skill");
|
const [viewMode, setViewMode] = useViewMode("skill");
|
||||||
|
// Publish-square sub-filter for the 广场 tab. "" = all squares.
|
||||||
|
const [squareFilter, setSquareFilter] = useState<string>("");
|
||||||
|
const { data: publishSquares } = usePublishSquares();
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||||
@@ -215,13 +218,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
}, [skills, currentUserId]);
|
}, [skills, currentUserId]);
|
||||||
|
|
||||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||||
const filteredSkills = useMemo(
|
const filteredSkills = useMemo(() => {
|
||||||
() =>
|
let list = publisher
|
||||||
publisher
|
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
: scopedSkills;
|
||||||
: scopedSkills,
|
// Square sub-filter only applies to the public 广场 tab. The default square
|
||||||
[scopedSkills, publisher],
|
// also sweeps in legacy/unassigned published skills (square_id === "").
|
||||||
);
|
if (filter === "all" && squareFilter) {
|
||||||
|
const isDefault = squareFilter === publishSquares?.default_square_id;
|
||||||
|
list = list.filter((s) => {
|
||||||
|
const sid = s.square_id ?? "";
|
||||||
|
return sid === squareFilter || (isDefault && !sid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [scopedSkills, publisher, filter, squareFilter, publishSquares?.default_square_id]);
|
||||||
const orderedSkills = useMemo(
|
const orderedSkills = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredSkills
|
filteredSkills
|
||||||
@@ -413,6 +424,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
onTagIdsChange={setTagIds}
|
onTagIdsChange={setTagIds}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{filter === "all" && (publishSquares?.squares.length ?? 0) > 0 && (
|
||||||
|
<Select
|
||||||
|
value={squareFilter || "__all__"}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSquareFilter(v === "__all__" ? "" : v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-9 w-full gap-2 px-3 text-xs sm:w-[140px]"
|
||||||
|
title="发布广场"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="全部广场" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||||
|
<SelectItem value="__all__">全部广场</SelectItem>
|
||||||
|
{publishSquares?.squares.map((sq) => (
|
||||||
|
<SelectItem key={sq.id} value={sq.id}>
|
||||||
|
{sq.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -544,6 +579,7 @@ export function SkillRow({
|
|||||||
const isCard = layout === "card";
|
const isCard = layout === "card";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setPublished = useSetSkillPublished();
|
const setPublished = useSetSkillPublished();
|
||||||
|
const { data: cardPublishSquares } = usePublishSquares();
|
||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const setFeatured = useSetSkillFeatured();
|
const setFeatured = useSetSkillFeatured();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
@@ -578,6 +614,22 @@ export function SkillRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move an already-published skill into a different square (发布广场). Each
|
||||||
|
// skill belongs to exactly one square; re-publishing with a new square_id
|
||||||
|
// relocates it rather than duplicating it.
|
||||||
|
async function handleChangeSquare(squareId: string) {
|
||||||
|
try {
|
||||||
|
await setPublished.mutateAsync({
|
||||||
|
skillName: skill.name,
|
||||||
|
published: true,
|
||||||
|
squareId,
|
||||||
|
});
|
||||||
|
toast.success("已更新发布广场");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
await deleteSkill.mutateAsync(skill.name);
|
await deleteSkill.mutateAsync(skill.name);
|
||||||
@@ -845,6 +897,28 @@ export function SkillRow({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canTogglePublish &&
|
||||||
|
skill.published &&
|
||||||
|
(cardPublishSquares?.squares.length ?? 0) > 0 && (
|
||||||
|
<Select
|
||||||
|
value={skill.square_id || cardPublishSquares?.default_square_id || ""}
|
||||||
|
onValueChange={(v) => void handleChangeSquare(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-8 w-28 shrink-0 gap-1 px-2 text-xs"
|
||||||
|
title="发布广场"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="发布广场" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||||
|
{cardPublishSquares?.squares.map((sq) => (
|
||||||
|
<SelectItem key={sq.id} value={sq.id}>
|
||||||
|
{sq.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface ListAgentsParams {
|
|||||||
sort?: AgentSort;
|
sort?: AgentSort;
|
||||||
/** Keep only agents published by this user id (the card's publisher). */
|
/** Keep only agents published by this user id (the card's publisher). */
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
/** Keep only agents filed under this publish square (发布广场). */
|
||||||
|
square?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAgentsQuery(params: ListAgentsParams): string {
|
function buildAgentsQuery(params: ListAgentsParams): string {
|
||||||
@@ -44,6 +46,7 @@ function buildAgentsQuery(params: ListAgentsParams): string {
|
|||||||
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
|
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
|
||||||
if (params.sort) qs.set("sort", params.sort);
|
if (params.sort) qs.set("sort", params.sort);
|
||||||
if (params.owner) qs.set("owner", params.owner);
|
if (params.owner) qs.set("owner", params.owner);
|
||||||
|
if (params.square) qs.set("square", params.square);
|
||||||
return qs.toString();
|
return qs.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,12 +88,15 @@ export function useAgentsPaginated(params: {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Keep only agents published by this user id (the card's publisher). */
|
/** Keep only agents published by this user id (the card's publisher). */
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
/** Keep only agents filed under this publish square (发布广场). */
|
||||||
|
square?: string;
|
||||||
}) {
|
}) {
|
||||||
const { scope, page, pageSize, enabled = true } = params;
|
const { scope, page, pageSize, enabled = true } = params;
|
||||||
const search = params.search ?? "";
|
const search = params.search ?? "";
|
||||||
const tagIds = params.tagIds ?? [];
|
const tagIds = params.tagIds ?? [];
|
||||||
const sort = params.sort ?? "time_desc";
|
const sort = params.sort ?? "time_desc";
|
||||||
const owner = params.owner ?? "";
|
const owner = params.owner ?? "";
|
||||||
|
const square = params.square ?? "";
|
||||||
const { data, isLoading, isFetching, error } = useQuery({
|
const { data, isLoading, isFetching, error } = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"agents",
|
"agents",
|
||||||
@@ -105,6 +108,7 @@ export function useAgentsPaginated(params: {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
owner,
|
owner,
|
||||||
|
square,
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
listAgentsPaginated({
|
listAgentsPaginated({
|
||||||
@@ -115,6 +119,7 @@ export function useAgentsPaginated(params: {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
owner: owner || undefined,
|
owner: owner || undefined,
|
||||||
|
square: square || undefined,
|
||||||
}),
|
}),
|
||||||
enabled,
|
enabled,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
import type { Tag } from "@/core/tags";
|
import type { Tag } from "@/core/tags";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recommended question shown on the agent chat home / sidebar. `title` is the
|
||||||
|
* short chip label; `prompt` is the full message sent when clicked. Legacy
|
||||||
|
* agents stored questions as bare strings — use {@link normalizeAgentQuestions}
|
||||||
|
* to read either shape.
|
||||||
|
*/
|
||||||
|
export interface AgentQuestion {
|
||||||
|
title: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce the raw `common_questions` payload (legacy `string[]` or the new
|
||||||
|
* `{title, prompt}[]`) into a uniform, non-empty `AgentQuestion[]`. */
|
||||||
|
export function normalizeAgentQuestions(
|
||||||
|
raw: (string | AgentQuestion)[] | null | undefined,
|
||||||
|
): AgentQuestion[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
const out: AgentQuestion[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const text = item.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
out.push({ title: text.length <= 40 ? text : `${text.slice(0, 40)}…`, prompt: text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prompt = (item?.prompt ?? "").trim();
|
||||||
|
const title = (item?.title ?? "").trim() || prompt;
|
||||||
|
if (!prompt && !title) continue;
|
||||||
|
out.push({ title: title || prompt, prompt: prompt || title });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,6 +42,8 @@ export interface Agent {
|
|||||||
owner_username?: string | null;
|
owner_username?: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
is_private: boolean;
|
is_private: boolean;
|
||||||
|
/** Publish square (发布广场) the agent is filed under. "" = the default square. */
|
||||||
|
square_id?: string;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
@@ -16,8 +51,10 @@ export interface Agent {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
/** Frequently-asked questions (常用问题) shown in the agent chat sidebar. */
|
/** Recommended questions (常用问题) shown on the agent chat home / sidebar.
|
||||||
common_questions?: string[] | null;
|
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
|
||||||
|
* Read via {@link normalizeAgentQuestions}. */
|
||||||
|
common_questions?: (string | AgentQuestion)[] | null;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
featured_order?: number | null;
|
featured_order?: number | null;
|
||||||
/** Whether the current user has favorited (收藏) this agent. */
|
/** Whether the current user has favorited (收藏) this agent. */
|
||||||
@@ -45,10 +82,11 @@ export interface CreateAgentRequest {
|
|||||||
soul?: string;
|
soul?: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
is_private?: boolean;
|
is_private?: boolean;
|
||||||
|
square_id?: string;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
common_questions?: string[] | null;
|
common_questions?: AgentQuestion[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAgentRequest {
|
export interface UpdateAgentRequest {
|
||||||
@@ -57,8 +95,9 @@ export interface UpdateAgentRequest {
|
|||||||
soul?: string | null;
|
soul?: string | null;
|
||||||
published?: boolean | null;
|
published?: boolean | null;
|
||||||
is_private?: boolean | null;
|
is_private?: boolean | null;
|
||||||
|
square_id?: string | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
common_questions?: string[] | null;
|
common_questions?: AgentQuestion[] | null;
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,8 @@ export interface ListSkillsParams {
|
|||||||
search?: string;
|
search?: string;
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||||
|
/** Keep only skills filed under this publish square (发布广场). */
|
||||||
|
square?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||||
@@ -14,6 +16,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
|||||||
if (params.search) qs.set("search", params.search);
|
if (params.search) qs.set("search", params.search);
|
||||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||||
if (params.sort) qs.set("sort", params.sort);
|
if (params.sort) qs.set("sort", params.sort);
|
||||||
|
if (params.square) qs.set("square", params.square);
|
||||||
const query = qs.toString();
|
const query = qs.toString();
|
||||||
const skills = await apiFetch(
|
const skills = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||||
@@ -242,18 +245,22 @@ export interface SetSkillPublishedResponse {
|
|||||||
name: string;
|
name: string;
|
||||||
owner_user_id: string | null;
|
owner_user_id: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
square_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSkillPublished(
|
export async function setSkillPublished(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
published: boolean,
|
published: boolean,
|
||||||
|
squareId?: string,
|
||||||
): Promise<SetSkillPublishedResponse> {
|
): Promise<SetSkillPublishedResponse> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ published }),
|
body: JSON.stringify(
|
||||||
|
squareId != null ? { published, square_id: squareId } : { published },
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -203,10 +203,12 @@ export function useSetSkillPublished() {
|
|||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
skillName,
|
skillName,
|
||||||
published,
|
published,
|
||||||
|
squareId,
|
||||||
}: {
|
}: {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
}) => setSkillPublished(skillName, published),
|
squareId?: string;
|
||||||
|
}) => setSkillPublished(skillName, published, squareId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
// Unpublishing triggers the backend cascade that strips this skill
|
// Unpublishing triggers the backend cascade that strips this skill
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface Skill {
|
|||||||
// backend. Null for built-in / public skills with no human owner.
|
// backend. Null for built-in / public skills with no human owner.
|
||||||
owner_username?: string | null;
|
owner_username?: string | null;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
||||||
|
square_id?: string;
|
||||||
builtin?: boolean;
|
builtin?: boolean;
|
||||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||||
|
|||||||
@@ -89,6 +89,52 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
|||||||
return (await response.json()) as PrivateSquareStatus;
|
return (await response.json()) as PrivateSquareStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublishSquare {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishSquaresSettings {
|
||||||
|
squares: PublishSquare[];
|
||||||
|
default_square_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishSquaresUpdate {
|
||||||
|
squares?: PublishSquare[];
|
||||||
|
default_square_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load publish squares: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as PublishSquaresSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePublishSquares(
|
||||||
|
body: PublishSquaresUpdate,
|
||||||
|
): Promise<PublishSquaresSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "Failed to update publish squares" }));
|
||||||
|
throw new Error(detail.detail ?? "Failed to update publish squares");
|
||||||
|
}
|
||||||
|
return (await response.json()) as PublishSquaresSettings;
|
||||||
|
}
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark";
|
export type ThemeMode = "light" | "dark";
|
||||||
export type ViewMode = "card" | "list";
|
export type ViewMode = "card" | "list";
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,25 @@ import {
|
|||||||
loadLeaderboardSettings,
|
loadLeaderboardSettings,
|
||||||
loadPrivateSquareStatus,
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
|
loadPublishSquares,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
updateCitationDisplaySettings,
|
updateCitationDisplaySettings,
|
||||||
updateLeaderboardSettings,
|
updateLeaderboardSettings,
|
||||||
updatePrivateSquareSettings,
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
|
updatePublishSquares,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
type CitationDisplayUpdate,
|
type CitationDisplayUpdate,
|
||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
|
type PublishSquaresUpdate,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||||
|
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
|
||||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||||
@@ -67,6 +71,31 @@ export function useUpdatePrivateSquareSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The configured publish squares (发布广场). Readable by any logged-in user —
|
||||||
|
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
||||||
|
export function usePublishSquares() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PUBLISH_SQUARES_KEY,
|
||||||
|
queryFn: () => loadPublishSquares(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePublishSquares() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PublishSquaresUpdate) => updatePublishSquares(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(PUBLISH_SQUARES_KEY, data);
|
||||||
|
// Squares changing can re-bucket gallery tabs; refresh agent/skill lists.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAppearanceSettings() {
|
export function useAppearanceSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: APPEARANCE_KEY,
|
queryKey: APPEARANCE_KEY,
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export default function AgentChatPage() {
|
|||||||
<InputBox
|
<InputBox
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread
|
isNewThread
|
||||||
|
hideAdminRecommendedQuestions
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2Icon, PlusIcon, StarIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
usePublishSquares,
|
||||||
|
useUpdatePublishSquares,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
|
import type { PublishSquare } from "@/core/system-settings/api";
|
||||||
|
|
||||||
|
/** Local draft row — `id` is stable (server- or client-generated) so the
|
||||||
|
* "default" radio can reference it before the first save. */
|
||||||
|
type Draft = PublishSquare;
|
||||||
|
|
||||||
|
function genId(): string {
|
||||||
|
// Frontend-only id; the backend also accepts/normalizes these.
|
||||||
|
return `sq_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublishSquaresSettingsPage({
|
||||||
|
embedded = false,
|
||||||
|
}: {
|
||||||
|
embedded?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const { data, isLoading, error } = usePublishSquares();
|
||||||
|
const update = useUpdatePublishSquares();
|
||||||
|
|
||||||
|
const [squares, setSquares] = useState<Draft[]>([]);
|
||||||
|
const [defaultId, setDefaultId] = useState<string>("");
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || initializedRef.current) return;
|
||||||
|
setSquares(data.squares.map((s) => ({ ...s })));
|
||||||
|
setDefaultId(data.default_square_id);
|
||||||
|
initializedRef.current = true;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
function addSquare() {
|
||||||
|
setSquares((prev) => [...prev, { id: genId(), name: "", description: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSquare(id: string, patch: Partial<Draft>) {
|
||||||
|
setSquares((prev) =>
|
||||||
|
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSquare(id: string) {
|
||||||
|
setSquares((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
if (defaultId === id) setDefaultId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const cleaned = squares
|
||||||
|
.map((s) => ({ ...s, name: s.name.trim(), description: s.description.trim() }))
|
||||||
|
.filter((s) => s.name.length > 0);
|
||||||
|
if (cleaned.length === 0) {
|
||||||
|
toast.error("请至少配置一个广场(名称不能为空)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-anchor the default to a surviving square if the picked one was removed.
|
||||||
|
const nextDefault = cleaned.some((s) => s.id === defaultId)
|
||||||
|
? defaultId
|
||||||
|
: cleaned[0].id;
|
||||||
|
try {
|
||||||
|
const saved = await update.mutateAsync({
|
||||||
|
squares: cleaned,
|
||||||
|
default_square_id: nextDefault,
|
||||||
|
});
|
||||||
|
setSquares(saved.squares.map((s) => ({ ...s })));
|
||||||
|
setDefaultId(saved.default_square_id);
|
||||||
|
toast.success("已保存");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
embedded
|
||||||
|
? "flex w-full flex-col gap-6"
|
||||||
|
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}
|
||||||
|
>
|
||||||
|
发布广场配置
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
管理员可在此配置多个「发布广场」。发布智能体或技能时,未选择广场则归入默认广场,也可选择发布到其他广场;
|
||||||
|
每个智能体或技能只能归属于一个广场,不会重复出现在多个广场中。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{squares.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
|
暂无广场,点击下方「添加广场」创建第一个。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
squares.map((s) => {
|
||||||
|
const isDefault = defaultId === s.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border p-3 dark:border-[#1B477E]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
title={isDefault ? "默认广场" : "设为默认广场"}
|
||||||
|
className={
|
||||||
|
isDefault ? "text-amber-500 hover:text-amber-600" : ""
|
||||||
|
}
|
||||||
|
onClick={() => setDefaultId(s.id)}
|
||||||
|
>
|
||||||
|
<StarIcon
|
||||||
|
className={isDefault ? "size-4 fill-current" : "size-4"}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={s.name}
|
||||||
|
placeholder="广场名称(如:公开广场 / 合作伙伴 / 内测)"
|
||||||
|
onChange={(e) => patchSquare(s.id, { name: e.target.value })}
|
||||||
|
className="h-9 flex-1 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title="删除广场"
|
||||||
|
onClick={() => removeSquare(s.id)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={s.description}
|
||||||
|
placeholder="广场描述(选填)"
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSquare(s.id, { description: e.target.value })
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
{isDefault && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
默认广场:发布时未选择广场的智能体/技能会归入此处。
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addSquare}>
|
||||||
|
<PlusIcon className="mr-1.5 size-4" />
|
||||||
|
添加广场
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={update.isPending}>
|
||||||
|
{update.isPending && (
|
||||||
|
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
|
|||||||
import MemoryPage from './MemoryPage';
|
import MemoryPage from './MemoryPage';
|
||||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||||
|
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||||
import SkillsPage from './SkillsPage';
|
import SkillsPage from './SkillsPage';
|
||||||
import UsersPage from './UsersPage';
|
import UsersPage from './UsersPage';
|
||||||
@@ -496,6 +497,18 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings/publish-squares"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === 'admin' ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<PublishSquaresSettingsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/llm-metrics"
|
path="settings/llm-metrics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -311,6 +311,12 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
|
|||||||
|
|
||||||
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
||||||
|
|
||||||
|
### Publish Squares (发布广场)
|
||||||
|
|
||||||
|
Admin-configurable, **access-control-free** categories that published agents/skills are filed under (distinct from the password-gated 私密广场). The square list lives in `system_settings.json` → `publish_squares` (`PublishSquaresSettings`: ordered `squares: [{id,name,description}]` + `default_square_id`); helpers `list_publish_squares` / `resolve_default_square_id` / `normalize_square_id` always yield a valid square (built-in "默认广场" fallback when none configured). Admin CRUD: `GET/PUT /api/system-settings/publish-squares` (GET readable by any user — the gallery sub-tabs + publish picker need it; PUT admin-only, auto-slugs missing ids and re-anchors a dangling default).
|
||||||
|
|
||||||
|
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned → resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a 广场-tab square sub-filter in both galleries.
|
||||||
|
|
||||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||||
|
|
||||||
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ from app.gateway.auth.password import verify_password_async
|
|||||||
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
from deerflow.config.agents_api_config import get_agents_api_config
|
||||||
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.config.system_settings import load_system_settings
|
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
@@ -42,6 +42,7 @@ class AgentResponse(BaseModel):
|
|||||||
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
|
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
|
||||||
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
||||||
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
||||||
|
square_id: str = Field(default="", description="Publish square (发布广场) the agent is filed under. Empty string = the default square. Independent of is_private.")
|
||||||
builtin: bool = Field(default=False, description="True when user_id is null")
|
builtin: bool = Field(default=False, description="True when user_id is null")
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
updated_at: datetime | str | None = None
|
updated_at: datetime | str | None = None
|
||||||
@@ -53,9 +54,9 @@ class AgentResponse(BaseModel):
|
|||||||
tool_groups: list[str] | None = None
|
tool_groups: list[str] | None = None
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
|
|
||||||
common_questions: list[str] | None = Field(
|
common_questions: list[CommonQuestion] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Frequently-asked questions shown in the agent chat sidebar (常用问题).",
|
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
|
||||||
)
|
)
|
||||||
|
|
||||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
||||||
@@ -103,10 +104,11 @@ class AgentCreateRequest(BaseModel):
|
|||||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||||
published: bool = Field(default=False, description="Whether the new agent should be public")
|
published: bool = Field(default=False, description="Whether the new agent should be public")
|
||||||
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
||||||
|
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Omit / empty = the default square.")
|
||||||
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
||||||
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||||
common_questions: list[str] | None = Field(default=None, description="Frequently-asked questions (常用问题) persisted to config.yaml")
|
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
|
||||||
|
|
||||||
|
|
||||||
class AgentUpdateRequest(BaseModel):
|
class AgentUpdateRequest(BaseModel):
|
||||||
@@ -117,10 +119,11 @@ class AgentUpdateRequest(BaseModel):
|
|||||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
published: bool | None = Field(default=None, description="Updated publication state")
|
published: bool | None = Field(default=None, description="Updated publication state")
|
||||||
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
||||||
|
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Empty string = the default square.")
|
||||||
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
||||||
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||||
common_questions: list[str] | None = Field(default=None, description="Updated frequently-asked questions (常用问题, config.yaml)")
|
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
|
||||||
|
|
||||||
|
|
||||||
class AgentSelectorResponse(BaseModel):
|
class AgentSelectorResponse(BaseModel):
|
||||||
@@ -224,7 +227,7 @@ def _write_agent_config(
|
|||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
tool_groups: list[str] | None = None,
|
tool_groups: list[str] | None = None,
|
||||||
common_questions: list[str] | None = None,
|
common_questions: list[CommonQuestion] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
agent_dir = get_paths().agent_dir(agent_id)
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -241,7 +244,16 @@ def _write_agent_config(
|
|||||||
if tool_groups is not None:
|
if tool_groups is not None:
|
||||||
config_data["tool_groups"] = tool_groups
|
config_data["tool_groups"] = tool_groups
|
||||||
if common_questions is not None:
|
if common_questions is not None:
|
||||||
config_data["common_questions"] = common_questions
|
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
|
||||||
|
# clean mapping rather than a pydantic repr. Accepts already-coerced
|
||||||
|
# CommonQuestion models or raw dicts/strings.
|
||||||
|
normalized = [
|
||||||
|
q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q)
|
||||||
|
for q in common_questions
|
||||||
|
]
|
||||||
|
config_data["common_questions"] = [
|
||||||
|
q.model_dump() for q in normalized if q.prompt.strip()
|
||||||
|
]
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
@@ -434,6 +446,7 @@ def _record_to_response(
|
|||||||
user_id=record.get("user_id"),
|
user_id=record.get("user_id"),
|
||||||
published=bool(record.get("published", False)),
|
published=bool(record.get("published", False)),
|
||||||
is_private=bool(record.get("is_private", False)),
|
is_private=bool(record.get("is_private", False)),
|
||||||
|
square_id=str(record.get("square_id") or ""),
|
||||||
builtin=record.get("user_id") is None,
|
builtin=record.get("user_id") is None,
|
||||||
created_at=record.get("created_at"),
|
created_at=record.get("created_at"),
|
||||||
updated_at=record.get("updated_at"),
|
updated_at=record.get("updated_at"),
|
||||||
@@ -576,6 +589,10 @@ async def list_agents(
|
|||||||
default=None,
|
default=None,
|
||||||
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
|
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
|
||||||
),
|
),
|
||||||
|
square: str | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Keep only agents filed under this publish square (发布广场). Omit for all squares.",
|
||||||
|
),
|
||||||
admin: bool = Query(
|
admin: bool = Query(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
@@ -596,6 +613,11 @@ async def list_agents(
|
|||||||
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
||||||
|
|
||||||
paginate = scope is not None or page is not None
|
paginate = scope is not None or page is not None
|
||||||
|
square_id = square.strip() if square and square.strip() else None
|
||||||
|
square_is_default = False
|
||||||
|
if square_id:
|
||||||
|
sq = load_system_settings().publish_squares
|
||||||
|
square_is_default = square_id == resolve_default_square_id(sq)
|
||||||
if paginate:
|
if paginate:
|
||||||
eff_page = page or 1
|
eff_page = page or 1
|
||||||
rows, total = await store.list_paginated(
|
rows, total = await store.list_paginated(
|
||||||
@@ -608,6 +630,8 @@ async def list_agents(
|
|||||||
admin=admin,
|
admin=admin,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
square_id=square_id,
|
||||||
|
square_is_default=square_is_default,
|
||||||
)
|
)
|
||||||
responses = await _build_list_responses(store, tag_store, user_id, rows)
|
responses = await _build_list_responses(store, tag_store, user_id, rows)
|
||||||
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
|
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
|
||||||
@@ -625,6 +649,12 @@ async def list_agents(
|
|||||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||||
if owner and owner.strip():
|
if owner and owner.strip():
|
||||||
responses = [r for r in responses if r.user_id == owner.strip()]
|
responses = [r for r in responses if r.user_id == owner.strip()]
|
||||||
|
if square_id:
|
||||||
|
responses = [
|
||||||
|
r
|
||||||
|
for r in responses
|
||||||
|
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||||
|
]
|
||||||
responses = _sort_agent_responses(responses, sort)
|
responses = _sort_agent_responses(responses, sort)
|
||||||
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -754,6 +784,8 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
|||||||
"user_id": _current_user_id(http_request),
|
"user_id": _current_user_id(http_request),
|
||||||
"published": request.published,
|
"published": request.published,
|
||||||
"is_private": request.is_private,
|
"is_private": request.is_private,
|
||||||
|
# Normalize to a valid configured square id (default when unset).
|
||||||
|
"square_id": normalize_square_id(request.square_id, load_system_settings().publish_squares),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Mirror skills/tool_groups/model into the listing cache so the gallery
|
# Mirror skills/tool_groups/model into the listing cache so the gallery
|
||||||
@@ -800,6 +832,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
update_data["published"] = bool(request.published)
|
update_data["published"] = bool(request.published)
|
||||||
if "is_private" in fields_set:
|
if "is_private" in fields_set:
|
||||||
update_data["is_private"] = bool(request.is_private)
|
update_data["is_private"] = bool(request.is_private)
|
||||||
|
if "square_id" in fields_set:
|
||||||
|
# Normalize to a valid configured square id (default when unset /
|
||||||
|
# unknown), so the row never holds a dangling square reference.
|
||||||
|
update_data["square_id"] = normalize_square_id(request.square_id, load_system_settings().publish_squares)
|
||||||
# Mutual exclusion: an agent moved into the private square cannot
|
# Mutual exclusion: an agent moved into the private square cannot
|
||||||
# also be publicly published. Force ``published=false`` whenever the
|
# also be publicly published. Force ``published=false`` whenever the
|
||||||
# same request sets ``is_private=true``, even if the client forgot
|
# same request sets ``is_private=true``, even if the client forgot
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class SkillResponse(BaseModel):
|
|||||||
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||||
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
|
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
|
||||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||||
|
square_id: str = Field(default="", description="Publish square (发布广场) the skill is filed under once published. Empty string = the default square.")
|
||||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
||||||
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
||||||
@@ -308,12 +309,14 @@ def _skill_to_response(
|
|||||||
builtin = skill.category != SkillCategory.CUSTOM
|
builtin = skill.category != SkillCategory.CUSTOM
|
||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
name_zh: str | None = None
|
name_zh: str | None = None
|
||||||
|
square_id = ""
|
||||||
if ownership is not None:
|
if ownership is not None:
|
||||||
owner_user_id = ownership.get("owner_user_id")
|
owner_user_id = ownership.get("owner_user_id")
|
||||||
published = bool(ownership.get("published", False))
|
published = bool(ownership.get("published", False))
|
||||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||||
detail = ownership.get("detail")
|
detail = ownership.get("detail")
|
||||||
name_zh = ownership.get("name_zh")
|
name_zh = ownership.get("name_zh")
|
||||||
|
square_id = str(ownership.get("square_id") or "")
|
||||||
|
|
||||||
display = None if display_override is _UNSET else display_override
|
display = None if display_override is _UNSET else display_override
|
||||||
if display_override is _UNSET:
|
if display_override is _UNSET:
|
||||||
@@ -342,6 +345,7 @@ def _skill_to_response(
|
|||||||
source=entry.get("source", "upload"),
|
source=entry.get("source", "upload"),
|
||||||
owner_user_id=owner_user_id,
|
owner_user_id=owner_user_id,
|
||||||
published=published,
|
published=published,
|
||||||
|
square_id=square_id,
|
||||||
builtin=builtin,
|
builtin=builtin,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
display=display,
|
display=display,
|
||||||
@@ -772,6 +776,7 @@ async def list_skills(
|
|||||||
tag_store: TagStore = Depends(get_tag_store),
|
tag_store: TagStore = Depends(get_tag_store),
|
||||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
|
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
|
||||||
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
||||||
|
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
|
||||||
sort: str = Query(
|
sort: str = Query(
|
||||||
default="time_desc",
|
default="time_desc",
|
||||||
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
|
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
|
||||||
@@ -831,6 +836,16 @@ async def list_skills(
|
|||||||
)
|
)
|
||||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||||
responses = await _attach_owner_usernames(responses)
|
responses = await _attach_owner_usernames(responses)
|
||||||
|
square_id = square.strip() if square and square.strip() else None
|
||||||
|
if square_id:
|
||||||
|
from deerflow.config.system_settings import load_system_settings, resolve_default_square_id
|
||||||
|
|
||||||
|
square_is_default = square_id == resolve_default_square_id(load_system_settings().publish_squares)
|
||||||
|
responses = [
|
||||||
|
r
|
||||||
|
for r in responses
|
||||||
|
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||||
|
]
|
||||||
responses = _sort_skill_responses(responses, sort)
|
responses = _sort_skill_responses(responses, sort)
|
||||||
return SkillsListResponse(skills=responses)
|
return SkillsListResponse(skills=responses)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -1366,12 +1381,14 @@ class SkillPublishRequest(BaseModel):
|
|||||||
"""Request body for toggling a custom skill's published flag."""
|
"""Request body for toggling a custom skill's published flag."""
|
||||||
|
|
||||||
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
|
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
|
||||||
|
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the skill under when publishing. Omit / empty = the default square.")
|
||||||
|
|
||||||
|
|
||||||
class SkillPublishResponse(BaseModel):
|
class SkillPublishResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
owner_user_id: str | None
|
owner_user_id: str | None
|
||||||
published: bool
|
published: bool
|
||||||
|
square_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -1393,10 +1410,18 @@ async def update_custom_skill_published(
|
|||||||
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
|
|
||||||
was_published = bool(record.get("published", False))
|
was_published = bool(record.get("published", False))
|
||||||
|
# When publishing, also file the skill into the chosen square (normalized
|
||||||
|
# to a valid configured square id; default when unset). Only one square
|
||||||
|
# per skill — a single column, so it can never be "published twice".
|
||||||
|
update_fields: dict[str, Any] = {"published": body.published}
|
||||||
|
if body.published:
|
||||||
|
from deerflow.config.system_settings import load_system_settings, normalize_square_id
|
||||||
|
|
||||||
|
update_fields["square_id"] = normalize_square_id(body.square_id, load_system_settings().publish_squares)
|
||||||
if mode == "admin":
|
if mode == "admin":
|
||||||
updated = await skill_store.update_admin(skill_name, {"published": body.published})
|
updated = await skill_store.update_admin(skill_name, update_fields)
|
||||||
else:
|
else:
|
||||||
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
|
updated = await skill_store.update(skill_name, user_id, update_fields)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to persist published state")
|
raise HTTPException(status_code=500, detail="Failed to persist published state")
|
||||||
|
|
||||||
@@ -1419,6 +1444,7 @@ async def update_custom_skill_published(
|
|||||||
name=updated["name"],
|
name=updated["name"],
|
||||||
owner_user_id=updated.get("owner_user_id"),
|
owner_user_id=updated.get("owner_user_id"),
|
||||||
published=bool(updated.get("published", False)),
|
published=bool(updated.get("published", False)),
|
||||||
|
square_id=str(updated.get("square_id") or ""),
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ from deerflow.config import get_app_config
|
|||||||
from deerflow.config.system_settings import (
|
from deerflow.config.system_settings import (
|
||||||
LeaderboardSettings,
|
LeaderboardSettings,
|
||||||
PromptPrefixSettings,
|
PromptPrefixSettings,
|
||||||
|
PublishSquare,
|
||||||
|
PublishSquaresSettings,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
|
list_publish_squares,
|
||||||
load_system_settings,
|
load_system_settings,
|
||||||
|
resolve_default_square_id,
|
||||||
save_system_settings,
|
save_system_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,6 +223,88 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishSquareItem(BaseModel):
|
||||||
|
id: str = Field(..., description="Stable square id")
|
||||||
|
name: str = Field(..., description="Display name / tab label")
|
||||||
|
description: str = Field(default="", description="Optional description")
|
||||||
|
|
||||||
|
|
||||||
|
class PublishSquaresResponse(BaseModel):
|
||||||
|
"""The configured publish squares plus the resolved default.
|
||||||
|
|
||||||
|
Readable by any logged-in user — squares are pure category tabs with no
|
||||||
|
access control, so the gallery needs the list to render its sub-tabs and
|
||||||
|
the publish flow needs it to populate the square picker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
squares: list[PublishSquareItem] = Field(default_factory=list)
|
||||||
|
default_square_id: str = Field(..., description="Square id used when a publisher picks none")
|
||||||
|
|
||||||
|
|
||||||
|
class PublishSquaresUpdateRequest(BaseModel):
|
||||||
|
squares: list[PublishSquareItem] | None = Field(default=None, description="Full ordered replacement list of squares")
|
||||||
|
default_square_id: str | None = Field(default=None, description="Which square id is the publish default")
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_squares_response(settings: PublishSquaresSettings) -> PublishSquaresResponse:
|
||||||
|
squares = list_publish_squares(settings)
|
||||||
|
return PublishSquaresResponse(
|
||||||
|
squares=[PublishSquareItem(id=s.id, name=s.name, description=s.description) for s in squares],
|
||||||
|
default_square_id=resolve_default_square_id(settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/publish-squares", response_model=PublishSquaresResponse)
|
||||||
|
async def get_publish_squares() -> PublishSquaresResponse:
|
||||||
|
"""Return the configured publish squares (readable by any logged-in user)."""
|
||||||
|
return _publish_squares_response(load_system_settings().publish_squares)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/publish-squares", response_model=PublishSquaresResponse)
|
||||||
|
async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Request) -> PublishSquaresResponse:
|
||||||
|
"""Replace the publish-squares configuration. Admin only.
|
||||||
|
|
||||||
|
``squares`` is a full ordered replacement. Each square needs a non-empty
|
||||||
|
name; a missing id is auto-generated from a slug of the name. Duplicate ids
|
||||||
|
are de-duplicated (first wins). ``default_square_id`` must reference one of
|
||||||
|
the surviving squares, else it falls back to the first.
|
||||||
|
"""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
update: dict[str, object] = {}
|
||||||
|
|
||||||
|
if "squares" in fields and fields["squares"] is not None:
|
||||||
|
normalized: list[PublishSquare] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for raw in body.squares or []:
|
||||||
|
name = (raw.name or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
sid = (raw.id or "").strip() or uuid4().hex[:8]
|
||||||
|
if sid in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(sid)
|
||||||
|
normalized.append(PublishSquare(id=sid, name=name, description=(raw.description or "").strip()))
|
||||||
|
update["squares"] = normalized
|
||||||
|
|
||||||
|
if "default_square_id" in fields and fields["default_square_id"] is not None:
|
||||||
|
update["default_square_id"] = str(fields["default_square_id"]).strip()
|
||||||
|
|
||||||
|
if update:
|
||||||
|
updated = current.publish_squares.model_copy(update=update)
|
||||||
|
# Re-anchor the default to a surviving square id.
|
||||||
|
if updated.squares and updated.default_square_id not in {s.id for s in updated.squares}:
|
||||||
|
updated = updated.model_copy(update={"default_square_id": updated.squares[0].id})
|
||||||
|
saved = save_system_settings(current.model_copy(update={"publish_squares": updated}))
|
||||||
|
else:
|
||||||
|
saved = current
|
||||||
|
return _publish_squares_response(saved.publish_squares)
|
||||||
|
|
||||||
|
|
||||||
class AppearanceResponse(BaseModel):
|
class AppearanceResponse(BaseModel):
|
||||||
"""Effective system-wide appearance defaults."""
|
"""Effective system-wide appearance defaults."""
|
||||||
|
|
||||||
|
|||||||
+32
-7
@@ -20,7 +20,7 @@ from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddlew
|
|||||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
from deerflow.config.agents_config import load_agent_config, load_agent_soul, validate_agent_id
|
||||||
from deerflow.config.app_config import AppConfig, get_app_config
|
from deerflow.config.app_config import AppConfig, get_app_config
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
@@ -535,11 +535,30 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
|
|
||||||
default_tools = [notebook_search_tool] + default_tools
|
default_tools = [notebook_search_tool] + default_tools
|
||||||
|
|
||||||
return create_agent(
|
# Custom agents are steered ONLY by their own SOUL.md — no generic
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
# super-agent system template and no personal memory injection. We branch to
|
||||||
tools=default_tools,
|
# the agent-only prompt builder whenever a custom agent config carries a
|
||||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
# non-empty SOUL.md. The plain default lead agent (no agent_config) and
|
||||||
system_prompt=apply_prompt_template(
|
# notebook mode keep the full template. See apply_agent_only_prompt_template.
|
||||||
|
has_agent_soul = bool(agent_config) and bool(load_agent_soul(agent_id))
|
||||||
|
use_agent_only_prompt = has_agent_soul and not is_notebook_mode
|
||||||
|
|
||||||
|
if use_agent_only_prompt:
|
||||||
|
from deerflow.agents.lead_agent.prompt import apply_agent_only_prompt_template
|
||||||
|
|
||||||
|
system_prompt = apply_agent_only_prompt_template(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_name=agent_display_name,
|
||||||
|
subagent_enabled=subagent_enabled,
|
||||||
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
|
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||||
|
app_config=resolved_app_config,
|
||||||
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
|
writing_mode=writing_mode,
|
||||||
|
writing_artifact_path=writing_artifact_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
system_prompt = apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
@@ -552,6 +571,12 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
rag_mode_enabled=rag_mode_enabled,
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
is_notebook_mode=is_notebook_mode,
|
is_notebook_mode=is_notebook_mode,
|
||||||
notebook_source_ids=notebook_source_ids,
|
notebook_source_ids=notebook_source_ids,
|
||||||
),
|
)
|
||||||
|
|
||||||
|
return create_agent(
|
||||||
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||||
|
tools=default_tools,
|
||||||
|
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||||
|
system_prompt=system_prompt,
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
+121
@@ -615,6 +615,127 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal system prompt for custom agents that should be steered ONLY by their
|
||||||
|
# own SOUL.md — no generic "super agent" persona scaffolding (role line, thinking
|
||||||
|
# style, clarification system, citation rules, response-style / critical-reminder
|
||||||
|
# defaults) and no personal memory injection. Only the mechanical sections an
|
||||||
|
# agent still needs to actually use its configured skills/tools are kept.
|
||||||
|
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
|
||||||
|
{network_policy_section}{skills_section}
|
||||||
|
|
||||||
|
{deferred_tools_section}
|
||||||
|
|
||||||
|
{writing_mode_section}
|
||||||
|
|
||||||
|
{subagent_section}
|
||||||
|
|
||||||
|
<working_directory existed="true">
|
||||||
|
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
||||||
|
- User workspace: `/mnt/user-data/workspace` - Working directory for temporary files
|
||||||
|
- Output files: `/mnt/user-data/outputs` - Final deliverables must be saved here
|
||||||
|
|
||||||
|
**File Management:**
|
||||||
|
- Uploaded files are automatically listed in the <uploaded_files> section before each request
|
||||||
|
- Use `read_file` tool to read uploaded files using their paths from the list
|
||||||
|
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
|
||||||
|
- Treat `/mnt/user-data/workspace` as your default current working directory for coding and file-editing tasks
|
||||||
|
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
|
||||||
|
{acp_section}
|
||||||
|
</working_directory>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def apply_agent_only_prompt_template(
|
||||||
|
*,
|
||||||
|
agent_id: str | None,
|
||||||
|
agent_name: str | None,
|
||||||
|
subagent_enabled: bool = False,
|
||||||
|
max_concurrent_subagents: int = 3,
|
||||||
|
available_skills: set[str] | None = None,
|
||||||
|
app_config: AppConfig | None = None,
|
||||||
|
rag_mode_enabled: bool = False,
|
||||||
|
writing_mode: bool = False,
|
||||||
|
writing_artifact_path: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a custom agent's system prompt from ONLY its SOUL.md plus the
|
||||||
|
mechanical sections required for its configured skills/tools to work.
|
||||||
|
|
||||||
|
Unlike :func:`apply_prompt_template`, this deliberately omits:
|
||||||
|
- the generic ``<role>`` "internal super agent" framing,
|
||||||
|
- ``<thinking_style>`` / ``<clarification_system>`` / ``<response_style>`` /
|
||||||
|
``<citations>`` / ``<critical_reminders>`` behavioural defaults,
|
||||||
|
- the personal ``<memory>`` injection (USER.md / MEMORY.md).
|
||||||
|
|
||||||
|
The agent's SOUL.md is therefore the sole source of persona and behaviour.
|
||||||
|
Skills, deferred tools, the subagent orchestration section (when enabled) and
|
||||||
|
the working-directory contract are kept because they are capability wiring,
|
||||||
|
not persona — dropping them would silently break agents that rely on skills
|
||||||
|
or file output.
|
||||||
|
"""
|
||||||
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
|
|
||||||
|
soul = get_agent_soul(storage_agent_id)
|
||||||
|
|
||||||
|
skills_section = get_skills_prompt_section(
|
||||||
|
available_skills,
|
||||||
|
app_config=app_config,
|
||||||
|
display_enabled=rag_mode_enabled,
|
||||||
|
)
|
||||||
|
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||||
|
|
||||||
|
n = max_concurrent_subagents
|
||||||
|
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
|
||||||
|
|
||||||
|
current_writing_artifact = (
|
||||||
|
f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n"
|
||||||
|
if writing_artifact_path
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
writing_mode_section = (
|
||||||
|
f"""<writing_mode>
|
||||||
|
Writing mode is enabled for this run.
|
||||||
|
- Your final deliverable MUST be a Markdown file saved under `/mnt/user-data/outputs/` (e.g. `/mnt/user-data/outputs/report.md`).
|
||||||
|
- If a current Markdown artifact is provided below, revise that file rather than creating a new one unless the user explicitly asks for a separate document.
|
||||||
|
- After writing the file, call `present_files` with the Markdown file path.
|
||||||
|
{current_writing_artifact}</writing_mode>
|
||||||
|
"""
|
||||||
|
if writing_mode
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
acp_section = _build_acp_section(app_config=app_config)
|
||||||
|
custom_mounts_section = _build_custom_mounts_section(app_config=app_config)
|
||||||
|
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
||||||
|
|
||||||
|
network_policy_section = _build_network_policy_section()
|
||||||
|
|
||||||
|
rag_mode_section = (
|
||||||
|
"""
|
||||||
|
<rag_citation_mode>
|
||||||
|
RAG/reference display mode is enabled.
|
||||||
|
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering per search term, tool, or skill.
|
||||||
|
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
|
||||||
|
- Do not output `<skill-display .../>` in RAG/reference mode; the frontend renders the right reference panel.
|
||||||
|
</rag_citation_mode>
|
||||||
|
"""
|
||||||
|
if rag_mode_enabled
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = AGENT_ONLY_PROMPT_TEMPLATE.format(
|
||||||
|
soul=soul,
|
||||||
|
skills_section=skills_section,
|
||||||
|
deferred_tools_section=deferred_tools_section,
|
||||||
|
writing_mode_section=writing_mode_section,
|
||||||
|
subagent_section=subagent_section,
|
||||||
|
network_policy_section=network_policy_section,
|
||||||
|
acp_section=acp_and_mounts_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str:
|
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str:
|
||||||
"""组装注入系统提示词的记忆上下文 (Memory V2)。
|
"""组装注入系统提示词的记忆上下文 (Memory V2)。
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import threading
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
@@ -56,6 +56,32 @@ def validate_agent_name(name: str | None) -> str | None:
|
|||||||
return validate_agent_id(name)
|
return validate_agent_id(name)
|
||||||
|
|
||||||
|
|
||||||
|
class CommonQuestion(BaseModel):
|
||||||
|
"""A recommended question shown on the agent chat home / sidebar.
|
||||||
|
|
||||||
|
``title`` is the short label rendered on the question chip; ``prompt`` is the
|
||||||
|
full message actually sent when the user clicks it. Splitting the two lets an
|
||||||
|
admin keep the chip terse while still sending a richly-worded question.
|
||||||
|
|
||||||
|
Legacy configs stored questions as bare strings (content only). The
|
||||||
|
before-validator coerces such a string into ``{title, prompt}`` so old
|
||||||
|
config.yaml files keep loading without a migration — the title falls back to
|
||||||
|
a truncated copy of the content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: str = ""
|
||||||
|
prompt: str = ""
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_legacy_string(cls, value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
title = text if len(text) <= 40 else text[:40].rstrip() + "…"
|
||||||
|
return {"title": title, "prompt": text}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class AgentConfig(BaseModel):
|
class AgentConfig(BaseModel):
|
||||||
"""Configuration for a custom agent."""
|
"""Configuration for a custom agent."""
|
||||||
|
|
||||||
@@ -71,7 +97,9 @@ class AgentConfig(BaseModel):
|
|||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
# Frequently-asked questions ("常用问题") shown in the agent chat sidebar.
|
# Frequently-asked questions ("常用问题") shown in the agent chat sidebar.
|
||||||
# Display/UX metadata only — the runtime never reads these. None = none set.
|
# Display/UX metadata only — the runtime never reads these. None = none set.
|
||||||
common_questions: list[str] | None = None
|
# Accepts both the legacy ``list[str]`` shape and the new ``{title, prompt}``
|
||||||
|
# objects (see CommonQuestion).
|
||||||
|
common_questions: list[CommonQuestion] | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||||
|
|||||||
@@ -86,6 +86,75 @@ class PrivateSquareSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
#: Id of the always-present built-in default square (used when the admin has
|
||||||
|
#: configured no squares, or when a published item picks none).
|
||||||
|
DEFAULT_SQUARE_ID = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class PublishSquare(BaseModel):
|
||||||
|
"""One admin-configured publish square (发布广场).
|
||||||
|
|
||||||
|
A pure organizational category that published agents/skills can be filed
|
||||||
|
under — unlike the password-gated :class:`PrivateSquareSettings`, a square
|
||||||
|
has **no access control**: every logged-in user can browse every square.
|
||||||
|
Each agent/skill belongs to at most one square (a single ``square_id``
|
||||||
|
field on the row), so an item can never be "published twice".
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(..., description="Stable square id (slug/uuid); used as agents.square_id / skills.square_id")
|
||||||
|
name: str = Field(..., description="Display name shown as the square's tab label")
|
||||||
|
description: str = Field(default="", description="Optional description shown in the admin config / tab hint")
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class PublishSquaresSettings(BaseModel):
|
||||||
|
"""Admin-configurable list of publish squares.
|
||||||
|
|
||||||
|
``squares`` is display-ordered. ``default_square_id`` selects the square a
|
||||||
|
publisher lands in when they do not pick one explicitly. There is always at
|
||||||
|
least one effective square — :func:`list_publish_squares` injects a built-in
|
||||||
|
"默认广场" when the admin has configured none, so the publish flow always has
|
||||||
|
a valid target.
|
||||||
|
"""
|
||||||
|
|
||||||
|
squares: list[PublishSquare] = Field(default_factory=list, description="Ordered list of configured squares")
|
||||||
|
default_square_id: str = Field(default=DEFAULT_SQUARE_ID, description="Square id used when a publisher picks none")
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def list_publish_squares(settings: PublishSquaresSettings) -> list[PublishSquare]:
|
||||||
|
"""Return the effective square list, always non-empty.
|
||||||
|
|
||||||
|
Falls back to a single built-in default square when the admin has not
|
||||||
|
configured any, so callers never have to special-case the empty state.
|
||||||
|
"""
|
||||||
|
if settings.squares:
|
||||||
|
return list(settings.squares)
|
||||||
|
return [PublishSquare(id=DEFAULT_SQUARE_ID, name="默认广场", description="")]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_default_square_id(settings: PublishSquaresSettings) -> str:
|
||||||
|
"""Resolve the effective default square id against the configured set."""
|
||||||
|
ids = [s.id for s in settings.squares]
|
||||||
|
if settings.default_square_id and settings.default_square_id in ids:
|
||||||
|
return settings.default_square_id
|
||||||
|
return ids[0] if ids else DEFAULT_SQUARE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_square_id(square_id: str | None, settings: PublishSquaresSettings) -> str:
|
||||||
|
"""Coerce an arbitrary ``square_id`` to a valid configured square id.
|
||||||
|
|
||||||
|
Empty / unknown / orphaned (square later deleted) ids all collapse to the
|
||||||
|
effective default square, so a published item always resolves to a real
|
||||||
|
tab.
|
||||||
|
"""
|
||||||
|
default_id = resolve_default_square_id(settings)
|
||||||
|
if not square_id:
|
||||||
|
return default_id
|
||||||
|
valid = {s.id for s in list_publish_squares(settings)}
|
||||||
|
return square_id if square_id in valid else default_id
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardSettings(BaseModel):
|
class LeaderboardSettings(BaseModel):
|
||||||
"""Admin-configurable defaults for the user leaderboard analytics."""
|
"""Admin-configurable defaults for the user leaderboard analytics."""
|
||||||
|
|
||||||
@@ -102,6 +171,7 @@ class SystemSettings(BaseModel):
|
|||||||
|
|
||||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||||
|
publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings)
|
||||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||||
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
||||||
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
|
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
|
||||||
|
|||||||
@@ -229,8 +229,8 @@ const GRAPH = __GRAPH_DATA__;
|
|||||||
// Theme is supplied by the embedding iframe (it can't read the parent's CSS).
|
// Theme is supplied by the embedding iframe (it can't read the parent's CSS).
|
||||||
const THEME = (new URLSearchParams(location.search).get('theme') === 'light') ? 'light' : 'dark';
|
const THEME = (new URLSearchParams(location.search).get('theme') === 'light') ? 'light' : 'dark';
|
||||||
const PAL = {
|
const PAL = {
|
||||||
dark: {note:'#5b8cff', entity:'#ffb454', label:'rgba(231,236,255,.92)', edge:[140,165,255], halo:'rgba(11,16,32,.55)'},
|
dark: {note:'#5b8cff', entity:'#ffb454', label:'rgba(231,236,255,.92)', edge:[150,178,255], halo:'rgba(11,16,32,.55)'},
|
||||||
light: {note:'#2f6df6', entity:'#d97706', label:'rgba(31,36,51,.92)', edge:[70,110,200], halo:'rgba(255,255,255,.65)'},
|
light: {note:'#2f6df6', entity:'#d97706', label:'rgba(31,36,51,.92)', edge:[63,96,168], halo:'rgba(255,255,255,.7)'},
|
||||||
}[THEME];
|
}[THEME];
|
||||||
document.documentElement.classList.toggle('light', THEME === 'light');
|
document.documentElement.classList.toggle('light', THEME === 'light');
|
||||||
document.getElementById('lg-note').style.background = PAL.note;
|
document.getElementById('lg-note').style.background = PAL.note;
|
||||||
@@ -317,12 +317,16 @@ function draw(){
|
|||||||
const act=active(), nb=neighbors(act), zoomed=cam.k>1.35;
|
const act=active(), nb=neighbors(act), zoomed=cam.k>1.35;
|
||||||
const [er,eg,eb]=PAL.edge;
|
const [er,eg,eb]=PAL.edge;
|
||||||
|
|
||||||
// edges: dim by default; when a node is focused, only its edges stay bright.
|
// edges: clearly visible by default; when a node is focused, only its edges
|
||||||
|
// stay bright and the rest fade back (but never to invisible).
|
||||||
for(const e of edges){
|
for(const e of edges){
|
||||||
const lit = act && (e.s===act||e.t===act);
|
const lit = act && (e.s===act||e.t===act);
|
||||||
const op = act ? (lit?0.9:0.05) : Math.min(0.5,0.12+e.w*0.05);
|
const op = act ? (lit?0.98:0.12) : Math.min(0.85,0.55+e.w*0.06);
|
||||||
|
// Keep a stable on-screen thickness regardless of zoom (>=1px), so lines
|
||||||
|
// never thin out to a hairline when zoomed in or on dense graphs.
|
||||||
|
const screenW = lit ? 2.6 : Math.min(2.4,1.4+e.w*0.45);
|
||||||
ctx.strokeStyle='rgba('+er+','+eg+','+eb+','+op+')';
|
ctx.strokeStyle='rgba('+er+','+eg+','+eb+','+op+')';
|
||||||
ctx.lineWidth=(lit?1.6:0.8)*Math.max(0.6,e.w*0.55)/Math.sqrt(cam.k);
|
ctx.lineWidth=Math.max(1,screenW)/cam.k;
|
||||||
ctx.beginPath();ctx.moveTo(e.s.x,e.s.y);ctx.lineTo(e.t.x,e.t.y);ctx.stroke();
|
ctx.beginPath();ctx.moveTo(e.s.x,e.s.y);ctx.lineTo(e.t.x,e.t.y);ctx.stroke();
|
||||||
}
|
}
|
||||||
// nodes
|
// nodes
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ class AgentStore(ABC):
|
|||||||
admin: bool = False,
|
admin: bool = False,
|
||||||
sort: str = "time_desc",
|
sort: str = "time_desc",
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
|
square_id: str | None = None,
|
||||||
|
square_is_default: bool = False,
|
||||||
) -> tuple[list[dict[str, Any]], int]:
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
"""Return one page of agents plus the total matching count.
|
"""Return one page of agents plus the total matching count.
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -61,6 +61,8 @@ class MemoryAgentStore(AgentStore):
|
|||||||
"description": data.get("description") or "",
|
"description": data.get("description") or "",
|
||||||
"user_id": data.get("user_id"),
|
"user_id": data.get("user_id"),
|
||||||
"published": bool(data.get("published", False)),
|
"published": bool(data.get("published", False)),
|
||||||
|
"is_private": bool(data.get("is_private", False)),
|
||||||
|
"square_id": str(data.get("square_id") or ""),
|
||||||
"featured_order": None,
|
"featured_order": None,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
@@ -93,7 +95,7 @@ class MemoryAgentStore(AgentStore):
|
|||||||
row = self._agents.get(agent_id)
|
row = self._agents.get(agent_id)
|
||||||
if row is None or row.get("user_id") != user_id:
|
if row is None or row.get("user_id") != user_id:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published"):
|
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||||
if key in data:
|
if key in data:
|
||||||
row[key] = data[key]
|
row[key] = data[key]
|
||||||
row["updated_at"] = _now()
|
row["updated_at"] = _now()
|
||||||
@@ -103,7 +105,7 @@ class MemoryAgentStore(AgentStore):
|
|||||||
row = self._agents.get(agent_id)
|
row = self._agents.get(agent_id)
|
||||||
if row is None or row.get("user_id") is not None:
|
if row is None or row.get("user_id") is not None:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published"):
|
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||||
if key in data:
|
if key in data:
|
||||||
row[key] = data[key]
|
row[key] = data[key]
|
||||||
row["updated_at"] = _now()
|
row["updated_at"] = _now()
|
||||||
@@ -159,7 +161,7 @@ class MemoryAgentStore(AgentStore):
|
|||||||
row = self._agents.get(agent_id)
|
row = self._agents.get(agent_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published", "is_private"):
|
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||||
if key in data:
|
if key in data:
|
||||||
row[key] = data[key]
|
row[key] = data[key]
|
||||||
row["updated_at"] = _now()
|
row["updated_at"] = _now()
|
||||||
@@ -198,16 +200,27 @@ class MemoryAgentStore(AgentStore):
|
|||||||
admin: bool = False,
|
admin: bool = False,
|
||||||
sort: str = "time_desc",
|
sort: str = "time_desc",
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
|
square_id: str | None = None,
|
||||||
|
square_is_default: bool = False,
|
||||||
) -> tuple[list[dict[str, Any]], int]:
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
|
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
|
||||||
# is ignored here. Tag-filter behavior is covered by the SQL repo tests.
|
# is ignored here. Tag-filter behavior is covered by the SQL repo tests.
|
||||||
owner_key = owner.strip() if owner and owner.strip() else None
|
owner_key = owner.strip() if owner and owner.strip() else None
|
||||||
|
square_key = square_id.strip() if square_id and square_id.strip() else None
|
||||||
|
|
||||||
|
def _square_ok(a: dict[str, Any]) -> bool:
|
||||||
|
if square_key is None:
|
||||||
|
return True
|
||||||
|
sid = str(a.get("square_id") or "")
|
||||||
|
return sid == square_key or (square_is_default and sid == "")
|
||||||
|
|
||||||
matched = [
|
matched = [
|
||||||
a
|
a
|
||||||
for a in self._agents.values()
|
for a in self._agents.values()
|
||||||
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
|
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
|
||||||
and (not search or not search.strip() or name_matches(a.get("name") or "", search))
|
and (not search or not search.strip() or name_matches(a.get("name") or "", search))
|
||||||
and (owner_key is None or a.get("user_id") == owner_key)
|
and (owner_key is None or a.get("user_id") == owner_key)
|
||||||
|
and _square_ok(a)
|
||||||
]
|
]
|
||||||
if sort == "time_asc":
|
if sort == "time_asc":
|
||||||
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
|
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
|
||||||
|
|||||||
+23
-17
@@ -27,6 +27,12 @@ class AgentRow(Base):
|
|||||||
# visible to its owner in the "我的" tab, and visible to anyone in the
|
# visible to its owner in the "我的" tab, and visible to anyone in the
|
||||||
# private-square listing once the password has been verified.
|
# private-square listing once the password has been verified.
|
||||||
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
# Publish square (发布广场) this agent is filed under. A pure organizational
|
||||||
|
# category with no access control — independent of ``published`` /
|
||||||
|
# ``is_private``. Empty string = "not assigned" → the listing layer treats
|
||||||
|
# it as the admin-configured default square. Each agent belongs to at most
|
||||||
|
# one square (single column), so it can never be published to two squares.
|
||||||
|
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
|
||||||
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
||||||
# featured rank (lower sorts first). Only ``published=True`` agents are
|
# featured rank (lower sorts first). Only ``published=True`` agents are
|
||||||
# eligible; the router enforces that invariant on writes. BIGINT so the
|
# eligible; the router enforces that invariant on writes. BIGINT so the
|
||||||
@@ -37,7 +43,7 @@ class AgentRow(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class AgentFavoriteRow(Base):
|
class AgentFavoriteRow(Base):
|
||||||
"""A user's bookmark of a (typically public-square) agent.
|
"""A user's bookmark of a (typically public-square) agent.
|
||||||
|
|
||||||
Lets a user collect agents from the 广场 (square) into their "我的" tab
|
Lets a user collect agents from the 广场 (square) into their "我的" tab
|
||||||
@@ -51,22 +57,22 @@ class AgentFavoriteRow(Base):
|
|||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class AgentPinRow(Base):
|
class AgentPinRow(Base):
|
||||||
"""A per-user pin that keeps an agent near the top of that user's lists."""
|
"""A per-user pin that keeps an agent near the top of that user's lists."""
|
||||||
|
|
||||||
__tablename__ = "agent_pins"
|
__tablename__ = "agent_pins"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),)
|
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class AgentSkillRow(Base):
|
class AgentSkillRow(Base):
|
||||||
"""Denormalized cache of a custom agent's selected skills.
|
"""Denormalized cache of a custom agent's selected skills.
|
||||||
|
|
||||||
``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth
|
``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth
|
||||||
|
|||||||
+14
-3
@@ -93,6 +93,7 @@ class AgentRepository(AgentStore):
|
|||||||
user_id=data.get("user_id"),
|
user_id=data.get("user_id"),
|
||||||
published=bool(data.get("published", False)),
|
published=bool(data.get("published", False)),
|
||||||
is_private=bool(data.get("is_private", False)),
|
is_private=bool(data.get("is_private", False)),
|
||||||
|
square_id=str(data.get("square_id") or ""),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -131,7 +132,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None or row.user_id != user_id:
|
if row is None or row.user_id != user_id:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published", "is_private"):
|
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -144,7 +145,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None or row.user_id is not None:
|
if row is None or row.user_id is not None:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published", "is_private"):
|
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -224,7 +225,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published", "is_private"):
|
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -290,6 +291,8 @@ class AgentRepository(AgentStore):
|
|||||||
admin: bool = False,
|
admin: bool = False,
|
||||||
sort: str = "time_desc",
|
sort: str = "time_desc",
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
|
square_id: str | None = None,
|
||||||
|
square_is_default: bool = False,
|
||||||
) -> tuple[list[dict[str, Any]], int]:
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
filters = []
|
filters = []
|
||||||
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
|
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
|
||||||
@@ -302,6 +305,14 @@ class AgentRepository(AgentStore):
|
|||||||
if owner and owner.strip():
|
if owner and owner.strip():
|
||||||
# Filter to one publisher. Built-in agents (user_id IS NULL) never match.
|
# Filter to one publisher. Built-in agents (user_id IS NULL) never match.
|
||||||
filters.append(AgentRow.user_id == owner.strip())
|
filters.append(AgentRow.user_id == owner.strip())
|
||||||
|
if square_id and square_id.strip():
|
||||||
|
sid = square_id.strip()
|
||||||
|
if square_is_default:
|
||||||
|
# Legacy / unassigned rows (square_id == "") belong to the
|
||||||
|
# default square, so include them alongside explicit matches.
|
||||||
|
filters.append(or_(AgentRow.square_id == sid, AgentRow.square_id == ""))
|
||||||
|
else:
|
||||||
|
filters.append(AgentRow.square_id == sid)
|
||||||
|
|
||||||
count_stmt = select(func.count()).select_from(AgentRow)
|
count_stmt = select(func.count()).select_from(AgentRow)
|
||||||
pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
|
pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
"""Add ``agents.square_id`` and ``skills.square_id`` for the publish-square (发布广场) feature.
|
||||||
|
|
||||||
|
A nullable-default (server_default '') string column tagging which admin-
|
||||||
|
configured publish square an agent / skill is filed under. Pure organizational
|
||||||
|
category — independent of the ``published`` / ``is_private`` flags. Empty string
|
||||||
|
means "unassigned" and is resolved to the default square at read time.
|
||||||
|
|
||||||
|
Revision ID: 20260611_01
|
||||||
|
Revises: 20260609_02
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260611_01"
|
||||||
|
down_revision = "20260609_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_square_id(table_name: str, index_name: str) -> None:
|
||||||
|
if _has_table(table_name) and not _has_column(table_name, "square_id"):
|
||||||
|
op.add_column(
|
||||||
|
table_name,
|
||||||
|
sa.Column(
|
||||||
|
"square_id",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
server_default="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if _has_table(table_name) and not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, ["square_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_square_id(table_name: str, index_name: str) -> None:
|
||||||
|
if _has_index(table_name, index_name):
|
||||||
|
op.drop_index(index_name, table_name=table_name)
|
||||||
|
if _has_column(table_name, "square_id"):
|
||||||
|
op.drop_column(table_name, "square_id")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_square_id("agents", "ix_agents_square_id")
|
||||||
|
_add_square_id("skills", "ix_skills_square_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_drop_square_id("skills", "ix_skills_square_id")
|
||||||
|
_drop_square_id("agents", "ix_agents_square_id")
|
||||||
+5
@@ -71,6 +71,7 @@ class MemorySkillStore(SkillStore):
|
|||||||
"published": bool(data.get("published", False)),
|
"published": bool(data.get("published", False)),
|
||||||
"detail": data.get("detail"),
|
"detail": data.get("detail"),
|
||||||
"name_zh": data.get("name_zh"),
|
"name_zh": data.get("name_zh"),
|
||||||
|
"square_id": str(data.get("square_id") or ""),
|
||||||
"created_at": _now(),
|
"created_at": _now(),
|
||||||
"updated_at": _now(),
|
"updated_at": _now(),
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,8 @@ class MemorySkillStore(SkillStore):
|
|||||||
record["detail"] = data["detail"]
|
record["detail"] = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
record["name_zh"] = data["name_zh"]
|
record["name_zh"] = data["name_zh"]
|
||||||
|
if "square_id" in data:
|
||||||
|
record["square_id"] = str(data["square_id"] or "")
|
||||||
record["updated_at"] = _now()
|
record["updated_at"] = _now()
|
||||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
@@ -121,6 +124,8 @@ class MemorySkillStore(SkillStore):
|
|||||||
record["detail"] = data["detail"]
|
record["detail"] = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
record["name_zh"] = data["name_zh"]
|
record["name_zh"] = data["name_zh"]
|
||||||
|
if "square_id" in data:
|
||||||
|
record["square_id"] = str(data["square_id"] or "")
|
||||||
record["updated_at"] = _now()
|
record["updated_at"] = _now()
|
||||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class SkillRow(Base):
|
|||||||
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
||||||
# shared source file is never rewritten.
|
# shared source file is never rewritten.
|
||||||
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
||||||
|
# Publish square (发布广场) this skill is filed under once published. Pure
|
||||||
|
# organizational category, no access control. Empty string = unassigned →
|
||||||
|
# the listing layer resolves it to the default square. One square per skill.
|
||||||
|
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class SkillRepository(SkillStore):
|
|||||||
published=bool(data.get("published", False)),
|
published=bool(data.get("published", False)),
|
||||||
detail=data.get("detail"),
|
detail=data.get("detail"),
|
||||||
name_zh=data.get("name_zh"),
|
name_zh=data.get("name_zh"),
|
||||||
|
square_id=str(data.get("square_id") or ""),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -109,6 +110,8 @@ class SkillRepository(SkillStore):
|
|||||||
row.detail = data["detail"]
|
row.detail = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
row.name_zh = data["name_zh"]
|
row.name_zh = data["name_zh"]
|
||||||
|
if "square_id" in data:
|
||||||
|
row.square_id = str(data["square_id"] or "")
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
@@ -127,6 +130,8 @@ class SkillRepository(SkillStore):
|
|||||||
row.detail = data["detail"]
|
row.detail = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
row.name_zh = data["name_zh"]
|
row.name_zh = data["name_zh"]
|
||||||
|
if "square_id" in data:
|
||||||
|
row.square_id = str(data["square_id"] or "")
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ CALL _add_column_if_missing('skills', 'name_zh', 'VARCHAR(128) NULL DEFAULT NULL
|
|||||||
-- detail: 技能的长文介绍,对应 migration 20260518_01。
|
-- detail: 技能的长文介绍,对应 migration 20260518_01。
|
||||||
-- ORM: SkillRow.detail = Text, nullable=True。
|
-- ORM: SkillRow.detail = Text, nullable=True。
|
||||||
CALL _add_column_if_missing('skills', 'detail', 'TEXT NULL DEFAULT NULL');
|
CALL _add_column_if_missing('skills', 'detail', 'TEXT NULL DEFAULT NULL');
|
||||||
|
-- square_id: 技能所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
||||||
|
-- ORM: SkillRow.square_id = String(64), nullable=False, default=''。
|
||||||
|
CALL _add_column_if_missing('skills', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
|
||||||
|
|
||||||
|
-- ---- agents ------------------------------------------------------------------
|
||||||
|
-- square_id: 智能体所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
||||||
|
-- ORM: AgentRow.square_id = String(64), nullable=False, default=''。
|
||||||
|
CALL _add_column_if_missing('agents', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
|
||||||
|
|
||||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||||
@@ -70,5 +78,6 @@ DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
|||||||
SELECT table_name, column_name
|
SELECT table_name, column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
AND (table_name = 'skills' AND column_name IN ('name_zh', 'detail'))
|
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id'))
|
||||||
|
OR (table_name = 'agents' AND column_name IN ('square_id')))
|
||||||
ORDER BY table_name, column_name;
|
ORDER BY table_name, column_name;
|
||||||
@@ -187,7 +187,11 @@ def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths
|
|||||||
|
|
||||||
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
||||||
agent_id = "agent-cq-1"
|
agent_id = "agent-cq-1"
|
||||||
questions = ["这个智能体能做什么?", "如何上传文件?"]
|
# Structured {title, prompt} questions round-trip as clean dict mappings.
|
||||||
|
questions = [
|
||||||
|
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
|
||||||
|
{"title": "上传文件", "prompt": "如何上传文件?"},
|
||||||
|
]
|
||||||
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
|
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
|
||||||
|
|
||||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||||
@@ -195,7 +199,21 @@ def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
|||||||
assert raw["common_questions"] == questions
|
assert raw["common_questions"] == questions
|
||||||
|
|
||||||
extras = _load_config_extras(agent_id)
|
extras = _load_config_extras(agent_id)
|
||||||
assert extras["common_questions"] == questions
|
assert [q.model_dump() for q in extras["common_questions"]] == questions
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
|
||||||
|
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
|
||||||
|
agent_id = "agent-cq-legacy"
|
||||||
|
_write_agent_config(
|
||||||
|
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
|
||||||
|
)
|
||||||
|
|
||||||
|
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||||
|
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||||
|
assert raw["common_questions"] == [
|
||||||
|
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
|
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user