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:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 11:37:42 +08:00
1 parent 3845d5090b
commit f0aec4cd7f
39 files changed
+1707 -226

No files matched your search

+1
View File
@@ -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={