feat(skills): 技能管理 - 增加可选的平台子分类策展 (#693)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,14 @@ import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills";
|
||||
import { SkillCurationBrowser, matchesCuration, type useSkillCuration } from "@/components/skills/skill-curation-browser";
|
||||
|
||||
type SkillLibraryTab = "enabled" | "installed" | "market";
|
||||
|
||||
type CanvasAgentSkillLibraryModalProps = {
|
||||
curationState: ReturnType<typeof useSkillCuration>;
|
||||
platformCategory: string;
|
||||
onPlatformCategoryChange: (value: string) => void;
|
||||
open: boolean;
|
||||
theme: CanvasTheme;
|
||||
installedSkills: Skill[];
|
||||
@@ -29,6 +33,7 @@ type CanvasAgentSkillLibraryModalProps = {
|
||||
};
|
||||
|
||||
export function CanvasAgentSkillLibraryModal({
|
||||
curationState, platformCategory, onPlatformCategoryChange,
|
||||
open,
|
||||
theme,
|
||||
installedSkills,
|
||||
@@ -84,6 +89,7 @@ export function CanvasAgentSkillLibraryModal({
|
||||
const keyword = search.trim().toLocaleLowerCase("zh-CN");
|
||||
const source = tab === "market" ? marketSkills : librarySource;
|
||||
return source.filter((skill) => {
|
||||
if (!matchesCuration(skill, curationState.curation, platformCategory)) return false;
|
||||
if (tab === "market" && category !== "all" && skill.tag !== category) return false;
|
||||
if (tab !== "market") {
|
||||
if (libraryCategoryId === "__uncategorized__" && skill.libraryCategoryId) return false;
|
||||
@@ -94,7 +100,7 @@ export function CanvasAgentSkillLibraryModal({
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.includes(keyword);
|
||||
});
|
||||
}, [categories, category, libraryCategoryId, librarySource, marketSkills, search, tab]);
|
||||
}, [categories, category, libraryCategoryId, librarySource, marketSkills, search, tab, curationState.curation, platformCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
@@ -181,6 +187,7 @@ export function CanvasAgentSkillLibraryModal({
|
||||
className="canvas-agent-skill-library-search"
|
||||
/>
|
||||
<nav className="canvas-agent-skill-library-categories thin-scrollbar" aria-label="技能视图与分类">
|
||||
<SkillCurationBrowser data={curationState.curation} value={platformCategory} onChange={onPlatformCategoryChange} error={curationState.error} retry={curationState.retry} />
|
||||
<div className="canvas-agent-skill-library-category-group">
|
||||
<div className="canvas-agent-skill-library-category-heading">我的技能分类</div>
|
||||
<SkillTab active={tab !== "market" && libraryCategoryId === "all"} label="全部分类" count={librarySource.length} onClick={() => selectLibraryCategory("all")} />
|
||||
|
||||
@@ -52,6 +52,7 @@ import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
|
||||
import { AGENT_SCENE_DEFS, AgentChatComposer, parseCloudAgentFormAnswer, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, type AgentSceneBucket, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui";
|
||||
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
|
||||
import { useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
|
||||
import { CanvasCloudAgentSettings, type AgentContextKey } from "./canvas-cloud-agent-settings";
|
||||
import { useAgentPanelLayout } from "./use-agent-panel-layout";
|
||||
import "./canvas-cloud-agent.css";
|
||||
@@ -109,6 +110,11 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
const [libraryCategories, setLibraryCategories] = useState<SkillLibraryCategory[]>([]);
|
||||
const [skillTag, setSkillTag] = useState("all");
|
||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||
const curationState = useSkillCuration(skillsOpen);
|
||||
const [platformCategory, setPlatformCategory] = useState("");
|
||||
const skillBrowseKey = JSON.stringify([userId, skillTag, debouncedSkillSearch, platformCategory, curationState.curation?.revision, curationState.curation?.enabled]);
|
||||
const skillBrowseKeyRef = useRef(skillBrowseKey);
|
||||
skillBrowseKeyRef.current = skillBrowseKey;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [approvalSubmitting, setApprovalSubmitting] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -386,6 +392,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
setSkillsLoading(true);
|
||||
void listSkills({
|
||||
scope: "public",
|
||||
...curationQuery(curationState.curation, platformCategory),
|
||||
search: debouncedSkillSearch || undefined,
|
||||
tag: skillsOpen && skillTag !== "all" ? skillTag : undefined,
|
||||
pageSize: 20,
|
||||
@@ -408,7 +415,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [view, skillsOpen, debouncedSkillSearch, skillTag]);
|
||||
}, [view, skillsOpen, debouncedSkillSearch, skillTag, curationState.curation, platformCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "settings" && !skillsOpen) return;
|
||||
@@ -426,25 +433,28 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
}, [skillsOpen, userId, view]);
|
||||
|
||||
const loadMoreSkills = async () => {
|
||||
const requestKey = skillBrowseKeyRef.current;
|
||||
if (skillsLoading || skillPageRequestRef.current || !skillHasMore || skillSearch.trim() !== debouncedSkillSearch) return;
|
||||
skillPageRequestRef.current = true;
|
||||
setSkillsLoading(true);
|
||||
try {
|
||||
const result = await listSkills({
|
||||
scope: "public",
|
||||
...curationQuery(curationState.curation, platformCategory),
|
||||
search: debouncedSkillSearch || undefined,
|
||||
tag: skillsOpen && skillTag !== "all" ? skillTag : undefined,
|
||||
page: skillPage + 1,
|
||||
pageSize: 20,
|
||||
sort: "popular",
|
||||
});
|
||||
if (requestKey !== skillBrowseKeyRef.current) return;
|
||||
setMarketSkills((current) => [...current, ...result.skills.filter((skill) => !current.some((item) => item.skillId === skill.skillId))]);
|
||||
setSkillCategories(result.categories);
|
||||
setSkillPage(result.page);
|
||||
setSkillHasMore(result.hasMore);
|
||||
} finally {
|
||||
skillPageRequestRef.current = false;
|
||||
setSkillsLoading(false);
|
||||
if (requestKey === skillBrowseKeyRef.current) setSkillsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1059,6 +1069,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<CanvasAgentSkillLibraryModal
|
||||
curationState={curationState}
|
||||
platformCategory={platformCategory}
|
||||
onPlatformCategoryChange={(value) => { setPlatformCategory(value); setSkillTag("all"); }}
|
||||
open={skillsOpen}
|
||||
theme={theme}
|
||||
installedSkills={installedSkills}
|
||||
|
||||
47
web/src/components/skills/skill-curation-browser.tsx
Normal file
47
web/src/components/skills/skill-curation-browser.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Select, Space } from "antd";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { getSkillCuration, type SkillCuration } from "@/services/api/skill-curation";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
|
||||
|
||||
export function curationQuery(data: SkillCuration | null, value: string) {
|
||||
if (!data?.enabled || !value) return {};
|
||||
return value === "__uncategorized__" ? { platformUncategorized: true } : { platformCategoryId: value };
|
||||
}
|
||||
|
||||
export function useSkillCuration(active = true) {
|
||||
const scope = getActiveUserScope();
|
||||
const [result, setResult] = useState<{ scope: string; data: SkillCuration } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [reload, setReload] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
setError("");
|
||||
getSkillCuration().then((data) => { if (!cancelled) setResult({ scope, data }); }).catch(() => { if (!cancelled) { setResult(null); setError("分类暂不可用,请重试"); } });
|
||||
const refresh = () => setReload((value) => value + 1);
|
||||
window.addEventListener("focus", refresh);
|
||||
window.addEventListener("canvas-skills-changed", refresh);
|
||||
return () => { cancelled = true; window.removeEventListener("focus", refresh); window.removeEventListener("canvas-skills-changed", refresh); };
|
||||
}, [active, scope, reload]);
|
||||
return { curation: result?.scope === scope ? result.data : null, error, retry: () => setReload((value) => value + 1) };
|
||||
}
|
||||
|
||||
export function matchesCuration(skill: Skill, data: SkillCuration | null, category: string) {
|
||||
if (!data?.enabled || !category) return true;
|
||||
const ids = data.assignments.filter((item) => item.skillId === skill.skillId).map((item) => item.categoryId);
|
||||
return category === "__uncategorized__" ? ids.length === 0 : ids.includes(category);
|
||||
}
|
||||
|
||||
export function SkillCurationBrowser({ data, value, onChange, error, retry }: { data: SkillCuration | null; value: string; onChange: (value: string) => void; error: string; retry: () => void }) {
|
||||
useEffect(() => {
|
||||
if ((!data?.enabled || (value && value !== "__uncategorized__" && !data.categories.some((item) => item.id === value))) && value) onChange("");
|
||||
}, [data, value, onChange]);
|
||||
if (error) return <Alert type="warning" title={error} action={<Button onClick={retry}>重试</Button>} />;
|
||||
if (!data?.enabled) return null;
|
||||
return <Space wrap><span>平台分类</span><Select aria-label="平台子分类" value={value} onChange={onChange} popupMatchSelectWidth={false} options={[
|
||||
{ value: "", label: "全部子分类" }, { value: "__uncategorized__", label: "未细分" },
|
||||
...data.categories.map((item) => ({ value: item.id, label: `${fallbackSkillCategories.find((root) => root.value === item.rootTag)?.label || item.rootTag} / ${item.name}` })),
|
||||
]} /></Space>;
|
||||
}
|
||||
@@ -96,6 +96,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
label: "平台配置",
|
||||
items: [
|
||||
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
|
||||
{ path: "/admin/skill-curation", label: "技能分类", description: "平台策展与分类维护", icon: <PlugZap className="size-4" /> },
|
||||
{ path: "/admin/settings/appearance", label: "站点及外观", description: "品牌、SEO、备案与皮肤", icon: <Palette className="size-4" /> },
|
||||
{ path: "/admin/settings/features", label: "功能开放", description: "工作台、插件与模型能力", icon: <ToggleLeft className="size-4" /> },
|
||||
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
|
||||
|
||||
59
web/src/pages/admin/skill-curation-page.tsx
Normal file
59
web/src/pages/admin/skill-curation-page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd";
|
||||
import { AdminPageFrame } from "./components/admin-shell";
|
||||
import { AdminDataTable } from "./components/admin-ui";
|
||||
import { AdminSwitch } from "./ui/controls";
|
||||
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
|
||||
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration } from "@/services/api/skill-curation";
|
||||
import { listSkills, type Skill } from "@/services/api/skills";
|
||||
|
||||
export default function SkillCurationPage() {
|
||||
const { message } = App.useApp();
|
||||
const [data, setData] = useState<SkillCuration | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [category, setCategory] = useState<CurationCategory>({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true });
|
||||
const [search, setSearch] = useState("");
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [skillId, setSkillId] = useState("");
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
const reload = async () => { try { setData(await getSkillCuration(true)); setError(""); } catch (e) { setError(String(e)); } };
|
||||
useEffect(() => { void reload(); }, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => { listSkills({ scope: "public", search, pageSize: 50 }).then((result) => { if (!cancelled) setSkills(result.skills); }).catch((e) => { if (!cancelled) setError(String(e)); }); }, 250);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, [search]);
|
||||
const save = async (change: CurationChange) => {
|
||||
if (!data) return;
|
||||
setBusy(true);
|
||||
try { setData(await updateSkillCuration(data.revision, change)); setError(""); message.success("已保存"); window.dispatchEvent(new Event("canvas-skills-changed")); }
|
||||
catch (e) { setError(`${String(e)};请重新加载后核对修改`); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const selectedSkill = skills.find((skill) => skill.skillId === skillId);
|
||||
return <AdminPageFrame title="技能分类" description="平台策展模式默认关闭;开启后使用后台维护的分类。" scroll actions={<Button onClick={() => void reload()}>重新加载</Button>}>
|
||||
<Space orientation="vertical" className="w-full" size="large">
|
||||
{error && <Alert type="error" title={error} />}
|
||||
<Space><AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} /><span>平台策展模式</span></Space>
|
||||
<AdminDataTable<CurationCategory> table={{ rowKey: "id", dataSource: data?.categories || [], pagination: false, loading: !data, columns: [
|
||||
{ title: "一级分类", dataIndex: "rootTag" }, { title: "子分类", dataIndex: "name" }, { title: "排序", dataIndex: "sortOrder" },
|
||||
{ title: "状态", render: (_, row) => row.enabled ? "启用" : "停用" }, { title: "操作", render: (_, row) => <Button onClick={() => setCategory({ ...row })}>编辑</Button> },
|
||||
] }} />
|
||||
<Space wrap>
|
||||
<Select aria-label="所属一级分类" value={category.rootTag} disabled={Boolean(category.id)} options={fallbackSkillCategories} fieldNames={{ value: "value", label: "label" }} onChange={(rootTag) => setCategory({ ...category, rootTag })} />
|
||||
<Input aria-label="分类名称" placeholder="分类名称" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
|
||||
<InputNumber aria-label="排序" value={category.sortOrder} precision={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
|
||||
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
|
||||
<Button type="primary" loading={busy} disabled={!data || !category.name.trim()} onClick={() => void save({ category })}>{category.id ? "保存分类" : "新增子分类"}</Button>
|
||||
<Button onClick={() => setCategory({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true })}>新建</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Input aria-label="搜索公开技能" placeholder="搜索公开技能(最多50条)" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} />
|
||||
<Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === selectedSkill?.tag).map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds } })}>保存技能归类</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</AdminPageFrame>;
|
||||
}
|
||||
@@ -41,7 +41,11 @@ const sortOptions: { label: string; value: SkillSort }[] = [
|
||||
{ label: "最近更新", value: "updated" },
|
||||
];
|
||||
|
||||
import { SkillCurationBrowser, useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { curation, error: curationError, retry: retryCuration } = useSkillCuration();
|
||||
const [platformCategory, setPlatformCategory] = useState("");
|
||||
const { message, modal } = App.useApp();
|
||||
const [scope, setScope] = useState<SkillScope>("public");
|
||||
const [sort, setSort] = useState<SkillSort>("popular");
|
||||
@@ -106,7 +110,7 @@ export default function SkillsPage() {
|
||||
? { libraryUncategorized: true }
|
||||
: libraryCategoryId !== "all" ? { libraryCategoryId } : {}
|
||||
: {};
|
||||
listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: !isLibraryScope && tag !== "all" ? tag : undefined, ...libraryFilter })
|
||||
listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: !isLibraryScope && tag !== "all" ? tag : undefined, ...libraryFilter, ...curationQuery(curation, platformCategory) })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setSkills(result.skills);
|
||||
@@ -126,7 +130,7 @@ export default function SkillsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedSearch, isLibraryScope, libraryCategoryId, page, pageSize, reloadKey, scope, sort, tag]);
|
||||
}, [debouncedSearch, isLibraryScope, libraryCategoryId, page, pageSize, reloadKey, scope, sort, tag, curation, platformCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -370,6 +374,7 @@ export default function SkillsPage() {
|
||||
<PageHeader title="技能库" description="把提示词、角色设定和创作方法,变成随时可用的能力。" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>} />
|
||||
|
||||
<div className="skills-browse-bar">
|
||||
<SkillCurationBrowser data={curation} value={platformCategory} onChange={(value) => { setPlatformCategory(value); setTag("all"); setPage(1); }} error={curationError} retry={retryCuration} />
|
||||
<div className="skills-navigation">
|
||||
<div className="skills-tabs" ref={tabsRef} role="tablist" aria-label="技能库范围" onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
|
||||
@@ -21,6 +21,7 @@ const AgentLessonsPage = lazy(() => import("@/pages/admin/admin-route-pages").th
|
||||
const ChannelsPage = lazy(() => import("@/pages/admin/channels/channels-page"));
|
||||
const LogicalModelsPage = lazy(() => import("@/pages/admin/logical-models/logical-models-page"));
|
||||
const AdminPluginsPage = lazy(() => import("@/pages/admin/plugins/plugins-page"));
|
||||
const SkillCurationPage = lazy(() => import("@/pages/admin/skill-curation-page"));
|
||||
const AdminPaymentsPage = lazy(() => import("@/pages/admin/payments/payments-page"));
|
||||
const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page"));
|
||||
const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page"));
|
||||
@@ -188,6 +189,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "channels", element: <ChannelsPage /> },
|
||||
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled"><LogicalModelsPage /></RequireFeature> },
|
||||
{ path: "plugins", element: <AdminPluginsPage /> },
|
||||
{ path: "skill-curation", element: <SkillCurationPage /> },
|
||||
{ path: "payments", element: <AdminPaymentsPage /> },
|
||||
{ path: "prompt-templates", element: <StoryboardPromptsPage /> },
|
||||
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
|
||||
|
||||
9
web/src/services/api/skill-curation.ts
Normal file
9
web/src/services/api/skill-curation.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { http } from "./request";
|
||||
|
||||
export type CurationCategory = { id: string; rootTag: string; name: string; sortOrder: number; enabled: boolean };
|
||||
export type SkillCuration = { enabled: boolean; revision: number; categories: CurationCategory[]; assignments: { skillId: string; categoryId: string }[] };
|
||||
export type CurationChange = { enabled: boolean } | { category: CurationCategory } | { assignment: { skillId: string; categoryIds: string[] } };
|
||||
export function getSkillCuration(admin = false) { return http.get<SkillCuration>(admin ? "/admin/skill-curation" : "/skills/curation"); }
|
||||
export function updateSkillCuration(expectedRevision: number, change: CurationChange) {
|
||||
return http.put<SkillCuration>("/admin/skill-curation", { expectedRevision, ...change });
|
||||
}
|
||||
@@ -109,6 +109,8 @@ export type SkillList = {
|
||||
};
|
||||
|
||||
export type ListSkillsInput = {
|
||||
platformCategoryId?: string;
|
||||
platformUncategorized?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
scope?: SkillScope;
|
||||
|
||||
Reference in New Issue
Block a user