feat(skills): 技能管理 - 增加可选的平台子分类策展 (#693)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:24:21 +08:00
committed by GitHub
parent 5976389ed4
commit c963fc7b89
32 changed files with 981 additions and 24 deletions

View File

@@ -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")} />

View File

@@ -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}

View 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>;
}