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
@@ -13,11 +13,40 @@ import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
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 { desensitize } from "@/lib/desensitize";
|
||||
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
|
||||
* read-only window onto the agent's profile — avatar, name, slogan/description,
|
||||
@@ -50,7 +79,9 @@ export function AgentInfoSidebar({
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: 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).
|
||||
const skillLabel = useMemo(() => {
|
||||
@@ -62,7 +93,7 @@ export function AgentInfoSidebar({
|
||||
}, [skills]);
|
||||
|
||||
const questions = useMemo(
|
||||
() => (agent?.common_questions ?? []).filter((q) => q.trim().length > 0),
|
||||
() => normalizeAgentQuestions(agent?.common_questions),
|
||||
[agent?.common_questions],
|
||||
);
|
||||
|
||||
@@ -116,10 +147,11 @@ export function AgentInfoSidebar({
|
||||
)}
|
||||
</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 && (
|
||||
<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">
|
||||
{soul}
|
||||
</p>
|
||||
@@ -165,14 +197,14 @@ export function AgentInfoSidebar({
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAskQuestion(question)}
|
||||
title={question}
|
||||
onClick={() => onAskQuestion(question.prompt)}
|
||||
title={desensitize(question.prompt)}
|
||||
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",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{desensitize(question)}
|
||||
{desensitize(question.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { type Agent, normalizeAgentQuestions } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -30,9 +30,7 @@ export function AgentWelcome({
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
const questions = (agent?.common_questions ?? []).filter(
|
||||
(q) => q.trim().length > 0,
|
||||
);
|
||||
const questions = normalizeAgentQuestions(agent?.common_questions);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,14 +57,14 @@ export function AgentWelcome({
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAskQuestion(question)}
|
||||
title={question}
|
||||
onClick={() => onAskQuestion(question.prompt)}
|
||||
title={desensitize(question.prompt)}
|
||||
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",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{desensitize(question)}
|
||||
{desensitize(question.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
@@ -49,12 +47,22 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
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 { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
normalizeAgentQuestions,
|
||||
useAgent,
|
||||
useCreateAgent,
|
||||
useDeleteAgent,
|
||||
@@ -65,7 +73,7 @@ import {
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
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 { useI18n } from "@/core/i18n/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 [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
// 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
|
||||
// is always an empty input to type the next question into.
|
||||
const [editCommonQuestions, setEditCommonQuestions] = useState<string[]>(
|
||||
agent.common_questions ?? [],
|
||||
// back read-only on the agent chat page. Each row has a short title (shown on
|
||||
// the chip) and the full prompt sent when clicked.
|
||||
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
|
||||
() => normalizeAgentQuestions(agent.common_questions),
|
||||
);
|
||||
const [editPublished, setEditPublished] = useState(agent.published);
|
||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
agent.skills ?? [],
|
||||
);
|
||||
const [skillSearch, setSkillSearch] = useState("");
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
// 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 ?? "");
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setEditSquareId(agent.square_id ?? "");
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
setEditCommonQuestions(agent.common_questions ?? []);
|
||||
setSkillSearch("");
|
||||
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
|
||||
}, [
|
||||
agent.common_questions,
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
agent.published,
|
||||
agent.square_id,
|
||||
agent.skills,
|
||||
editOpen,
|
||||
]);
|
||||
@@ -279,7 +289,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
// include `soul`) keep working.
|
||||
if (agentDetail) {
|
||||
setEditSoul(agentDetail.soul ?? "");
|
||||
setEditCommonQuestions(agentDetail.common_questions ?? []);
|
||||
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
|
||||
} else {
|
||||
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.
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
square_id: editSquareId,
|
||||
skills: selectedSkills,
|
||||
common_questions: editCommonQuestions
|
||||
.map((q) => q.trim())
|
||||
.filter((q) => q.length > 0),
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0),
|
||||
},
|
||||
});
|
||||
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(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[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
|
||||
// both the card header and the compact list row.
|
||||
const statusBadges = (
|
||||
@@ -873,65 +864,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{enabledSkills.length > 0 && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能(支持中英文名称、描述)…"
|
||||
value={skillSearch}
|
||||
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>
|
||||
<SkillTransferSelector
|
||||
skills={enabledSkills}
|
||||
selected={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不选任何技能会保存为无技能(skills = [])。
|
||||
</p>
|
||||
@@ -954,32 +893,53 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{editCommonQuestions.map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
<div className="space-y-3">
|
||||
{editCommonQuestions.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-1.5 rounded-md border p-2.5"
|
||||
>
|
||||
<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) => {
|
||||
const next = [...editCommonQuestions];
|
||||
next[index] = e.currentTarget.value;
|
||||
next[index] = {
|
||||
...next[index]!,
|
||||
prompt: e.currentTarget.value,
|
||||
};
|
||||
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>
|
||||
))}
|
||||
<Button
|
||||
@@ -988,7 +948,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setEditCommonQuestions([...editCommonQuestions, ""])
|
||||
setEditCommonQuestions([
|
||||
...editCommonQuestions,
|
||||
{ title: "", prompt: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加问题
|
||||
@@ -1016,6 +979,32 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<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 type { Agent, AgentSort } from "@/core/agents";
|
||||
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";
|
||||
|
||||
@@ -36,6 +36,9 @@ export function AgentGallery() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
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.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
@@ -53,7 +56,7 @@ export function AgentGallery() {
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({ all: 1, mine: 1, private: 1 });
|
||||
}, [search, tagKey, sort, publisher?.id]);
|
||||
}, [search, tagKey, sort, publisher?.id, squareFilter]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -84,6 +87,8 @@ export function AgentGallery() {
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private",
|
||||
owner: publisher?.id,
|
||||
// Square sub-filter only applies to the public 广场 tab.
|
||||
square: activeTab === "all" && squareFilter ? squareFilter : undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -378,23 +383,49 @@ export function AgentGallery() {
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<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>
|
||||
<>
|
||||
{activeTab === "all" &&
|
||||
(publishSquares?.squares.length ?? 0) > 0 && (
|
||||
<Select
|
||||
value={squareFilter || "__all__"}
|
||||
onValueChange={(v) =>
|
||||
setSquareFilter(v === "__all__" ? "" : v)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
@@ -133,6 +133,7 @@ export function InputBox({
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -149,6 +150,9 @@ export function InputBox({
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
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;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
@@ -205,11 +209,12 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -516,7 +521,7 @@ export function InputBox({
|
||||
</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]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||
|
||||
@@ -349,6 +350,8 @@ export function AppearanceSettingsPage() {
|
||||
<PromptPrefixSettingsPage embedded />
|
||||
<Separator />
|
||||
<PrivateSquareSettingsPage embedded />
|
||||
<Separator />
|
||||
<PublishSquaresSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} 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 { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -164,6 +164,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
null,
|
||||
);
|
||||
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 fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
@@ -215,13 +218,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const filteredSkills = useMemo(
|
||||
() =>
|
||||
publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills,
|
||||
[scopedSkills, publisher],
|
||||
);
|
||||
const filteredSkills = useMemo(() => {
|
||||
let list = publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills;
|
||||
// Square sub-filter only applies to the public 广场 tab. The default square
|
||||
// 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(
|
||||
() =>
|
||||
filteredSkills
|
||||
@@ -413,6 +424,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<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} />
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||
<SelectTrigger
|
||||
@@ -544,6 +579,7 @@ export function SkillRow({
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
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() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
@@ -845,6 +897,28 @@ export function SkillRow({
|
||||
)}
|
||||
</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
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ListAgentsParams {
|
||||
sort?: AgentSort;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
/** Keep only agents filed under this publish square (发布广场). */
|
||||
square?: 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.sort) qs.set("sort", params.sort);
|
||||
if (params.owner) qs.set("owner", params.owner);
|
||||
if (params.square) qs.set("square", params.square);
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -88,12 +88,15 @@ export function useAgentsPaginated(params: {
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
/** Keep only agents filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
}) {
|
||||
const { scope, page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const square = params.square ?? "";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
@@ -105,6 +108,7 @@ export function useAgentsPaginated(params: {
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
square,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({
|
||||
@@ -115,6 +119,7 @@ export function useAgentsPaginated(params: {
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
square: square || undefined,
|
||||
}),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -9,6 +42,8 @@ export interface Agent {
|
||||
owner_username?: string | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
/** Publish square (发布广场) the agent is filed under. "" = the default square. */
|
||||
square_id?: string;
|
||||
builtin: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
@@ -16,8 +51,10 @@ export interface Agent {
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
skills?: string[] | null;
|
||||
/** Frequently-asked questions (常用问题) shown in the agent chat sidebar. */
|
||||
common_questions?: string[] | null;
|
||||
/** Recommended questions (常用问题) shown on the agent chat home / sidebar.
|
||||
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
|
||||
* Read via {@link normalizeAgentQuestions}. */
|
||||
common_questions?: (string | AgentQuestion)[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
@@ -45,10 +82,11 @@ export interface CreateAgentRequest {
|
||||
soul?: string;
|
||||
published?: boolean;
|
||||
is_private?: boolean;
|
||||
square_id?: string;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
common_questions?: string[] | null;
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -57,8 +95,9 @@ export interface UpdateAgentRequest {
|
||||
soul?: string | null;
|
||||
published?: boolean | null;
|
||||
is_private?: boolean | null;
|
||||
square_id?: string | null;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
common_questions?: string[] | null;
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
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 = {}) {
|
||||
@@ -14,6 +16,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
if (params.search) qs.set("search", params.search);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.square) qs.set("square", params.square);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
@@ -242,18 +245,22 @@ export interface SetSkillPublishedResponse {
|
||||
name: string;
|
||||
owner_user_id: string | null;
|
||||
published: boolean;
|
||||
square_id?: string;
|
||||
}
|
||||
|
||||
export async function setSkillPublished(
|
||||
skillName: string,
|
||||
published: boolean,
|
||||
squareId?: string,
|
||||
): Promise<SetSkillPublishedResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ published }),
|
||||
body: JSON.stringify(
|
||||
squareId != null ? { published, square_id: squareId } : { published },
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -203,10 +203,12 @@ export function useSetSkillPublished() {
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
published,
|
||||
squareId,
|
||||
}: {
|
||||
skillName: string;
|
||||
published: boolean;
|
||||
}) => setSkillPublished(skillName, published),
|
||||
squareId?: string;
|
||||
}) => setSkillPublished(skillName, published, squareId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
// 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.
|
||||
owner_username?: string | null;
|
||||
published?: boolean;
|
||||
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
||||
square_id?: string;
|
||||
builtin?: boolean;
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 ViewMode = "card" | "list";
|
||||
|
||||
|
||||
@@ -8,21 +8,25 @@ import {
|
||||
loadLeaderboardSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] 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 CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] 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() {
|
||||
return useQuery({
|
||||
queryKey: APPEARANCE_KEY,
|
||||
|
||||
@@ -233,6 +233,7 @@ export default function AgentChatPage() {
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
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 PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
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
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user