import { useEffect, useMemo, useRef, useState } from "react"; import { App, Button, Collapse, Dropdown, Form, Input, Modal, Select, Switch, Tooltip } from "antd"; import type { MenuProps } from "antd"; import { Check, ChevronDown, CircleAlert, FilePenLine, LoaderCircle, LockKeyhole, MessageSquareText, Plus, RefreshCw, Search, Sparkles, Trash2, Workflow } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { createCodexSkill, createCodexSkillDraft, deleteCodexSkill, fetchCodexSkill, postState, setCodexSkillEnabled, updateCodexSkill, type AgentSkillDetail, type AgentSkillDraft, type AgentSkillInterface, type AgentSkillScope, type AgentSkillSummary } from "@/services/api/canvas-agent"; import { useAgentSkillStore } from "@/stores/use-agent-skill-store"; import { useAgentStore, type AgentChatItem } from "@/stores/use-agent-store"; import { useThemeStore } from "@/stores/use-theme-store"; type ScopeFilter = "all" | AgentSkillScope; type SkillDraftSource = "conversation" | "canvas"; type SkillEditor = { mode: "create"; values?: SkillFormValues } | { mode: "edit"; detail: AgentSkillDetail }; type SkillFormValues = { name: string; description: string; instructions: string; displayName?: string; shortDescription?: string; defaultPrompt?: string }; const scopeLabels: Record = { repo: "项目", user: "个人", system: "系统", admin: "管理员" }; const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export function AgentSkillsView({ clientId }: { clientId: string }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const { message, modal } = App.useApp(); const connected = useAgentStore((state) => state.connected); const url = useAgentStore((state) => state.url); const token = useAgentStore((state) => state.token); const activeThreadId = useAgentStore((state) => state.activeThreadId); const hasConversation = useAgentStore((state) => hasSettledConversation(state.messages, state.activeThreadId)); const hasCanvas = useAgentStore((state) => Boolean(state.canvasContext)); const sending = useAgentStore((state) => state.sending); const waiting = useAgentStore((state) => state.waiting); const setAgentState = useAgentStore((state) => state.setAgentState); const skills = useAgentSkillStore((state) => state.skills); const selectedSkill = useAgentSkillStore((state) => state.selectedSkill); const loading = useAgentSkillStore((state) => state.loading); const loaded = useAgentSkillStore((state) => state.loaded); const errors = useAgentSkillStore((state) => state.errors); const draft = useAgentSkillStore((state) => state.draft); const generatingSource = useAgentSkillStore((state) => state.generatingSource); const loadSkills = useAgentSkillStore((state) => state.loadSkills); const selectSkill = useAgentSkillStore((state) => state.selectSkill); const clearSelection = useAgentSkillStore((state) => state.clearSelection); const setDraft = useAgentSkillStore((state) => state.setDraft); const setGeneratingSource = useAgentSkillStore((state) => state.setGeneratingSource); const [query, setQuery] = useState(""); const [scope, setScope] = useState("all"); const [editor, setEditor] = useState(null); const [saving, setSaving] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [busySkill, setBusySkill] = useState(""); const [errorsOpen, setErrorsOpen] = useState(false); const confirmRef = useRef<{ destroy: () => void } | null>(null); const [form] = Form.useForm(); const endpoint = url.trim().replace(/\/$/, ""); const filteredSkills = useMemo(() => { const keyword = query.trim().toLowerCase(); return skills.filter((skill) => { if (scope !== "all" && skill.scope !== scope) return false; return !keyword || [skill.name, skill.description, skill.interface?.displayName, skill.interface?.shortDescription, skill.shortDescription].some((value) => value?.toLowerCase().includes(keyword)); }); }, [query, scope, skills]); const editorValues = editor?.mode === "edit" ? skillFormValues(editor.detail) : editor?.values; const refresh = (forceReload = true) => loadSkills(endpoint, token, forceReload); const connectionIsCurrent = (revision: number) => { const agent = useAgentStore.getState(); const skillsState = useAgentSkillStore.getState(); return skillsState.connectionRevision === revision && agent.connected && agent.url.trim().replace(/\/$/, "") === endpoint && agent.token === token; }; useEffect(() => { if (draft) setEditor((current) => current || { mode: "create", values: draftFormValues(draft) }); }, [draft]); useEffect(() => { if (connected) return; confirmRef.current?.destroy(); confirmRef.current = null; setEditor(null); setSaving(false); setAdvancedOpen(false); setCreateMenuOpen(false); setBusySkill(""); setErrorsOpen(false); form.resetFields(); }, [connected, form]); const useSkill = (skill: AgentSkillSummary) => { selectSkill(skill); setAgentState({ activeTab: "chat" }); }; const generateDraft = async (source: SkillDraftSource) => { const agent = useAgentStore.getState(); if (agent.sending || agent.waiting) return message.warning("Codex 正在运行,请完成当前任务后再提炼 Skill"); if (source === "conversation" && !hasSettledConversation(agent.messages, agent.activeThreadId)) return message.warning("当前对话还没有可提炼的已完成内容"); if (source === "canvas" && !agent.canvasContext) return message.warning("当前页面没有可提炼的画布"); if (!clientId) return message.warning("当前页面仍在连接 Agent,请稍后再试"); const connectionRevision = useAgentSkillStore.getState().connectionRevision; setGeneratingSource(source); try { if (source === "canvas") { const synced = await postState(endpoint, token, clientId, agent.canvasContext?.snapshot || null); if (!synced) throw new Error("同步当前画布失败,请检查 Agent 连接后重试"); } if (!connectionIsCurrent(connectionRevision)) return; const response = await createCodexSkillDraft(endpoint, token, { source, threadId: agent.activeThreadId, clientId, ...(agent.model ? { model: agent.model } : {}), ...(agent.reasoningEffort ? { effort: agent.reasoningEffort } : {}), }); if (!connectionIsCurrent(connectionRevision)) return; if (!response.data) throw new Error("未生成 Skill 草稿"); setDraft(response.data); message.success("草稿已生成,可在技能页确认后创建"); } catch (error) { if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "生成 Skill 草稿失败"); } finally { if (connectionIsCurrent(connectionRevision)) setGeneratingSource(null); } }; const openEdit = async (skill: AgentSkillSummary) => { if (!skill.managed || busySkill || useAgentSkillStore.getState().generatingSource) return; const connectionRevision = useAgentSkillStore.getState().connectionRevision; setBusySkill(skill.path); try { const response = await fetchCodexSkill(endpoint, token, skill.name); if (!connectionIsCurrent(connectionRevision)) return; if (!response.data) throw new Error("未读取到 Skill 内容"); setEditor({ mode: "edit", detail: response.data }); } catch (error) { if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "读取 Skill 失败"); } finally { if (connectionIsCurrent(connectionRevision)) setBusySkill(""); } }; const saveSkill = async () => { if (!editor) return; let values: SkillFormValues; try { values = await form.validateFields(); } catch { const firstError = form.getFieldsError().find((field) => field.errors.length); if (firstError?.name.some((name) => name === "shortDescription" || name === "defaultPrompt")) setAdvancedOpen(true); if (firstError) requestAnimationFrame(() => form.scrollToField(firstError.name, { block: "center" })); return; } const name = editor.mode === "edit" ? editor.detail.name : values.name.trim(); const skillInterface = compactInterface(values); if (skillInterface?.defaultPrompt && !mentionsSkill(skillInterface.defaultPrompt, name)) { form.setFields([{ name: "defaultPrompt", errors: [`默认提示词需要包含 $${name}`] }]); setAdvancedOpen(true); requestAnimationFrame(() => form.scrollToField("defaultPrompt", { block: "center" })); return; } const connectionRevision = useAgentSkillStore.getState().connectionRevision; if (!connectionIsCurrent(connectionRevision)) return; setSaving(true); try { const input = { description: values.description.trim(), instructions: values.instructions.trim(), interface: skillInterface || null }; if (editor.mode === "create") await createCodexSkill(endpoint, token, { name, ...input }); else await updateCodexSkill(endpoint, token, name, { ...input, expectedRevision: editor.detail.revision }); if (!connectionIsCurrent(connectionRevision)) return; setDraft(null); setEditor(null); setAdvancedOpen(false); await refresh(); if (!connectionIsCurrent(connectionRevision)) return; message.success(editor.mode === "create" ? "Skill 已创建" : "Skill 已更新"); } catch (error) { if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "保存 Skill 失败"); } finally { if (connectionIsCurrent(connectionRevision)) setSaving(false); } }; const confirmDelete = (skill: AgentSkillSummary) => { const connectionRevision = useAgentSkillStore.getState().connectionRevision; confirmRef.current = modal.confirm({ title: `删除 ${skill.interface?.displayName || skill.name}`, content: "删除后本地文件无法恢复,确定继续吗?", okText: "删除", okType: "danger", cancelText: "取消", onOk: async () => { if (!connectionIsCurrent(connectionRevision)) return; setBusySkill(skill.path); try { const response = await fetchCodexSkill(endpoint, token, skill.name); if (!connectionIsCurrent(connectionRevision)) return; if (!response.data) throw new Error("未读取到 Skill 内容"); await deleteCodexSkill(endpoint, token, skill.name, response.data.revision); if (!connectionIsCurrent(connectionRevision)) return; if (selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection(); await refresh(); if (!connectionIsCurrent(connectionRevision)) return; message.success("Skill 已删除"); } catch (error) { if (!connectionIsCurrent(connectionRevision)) return; message.error(error instanceof Error ? error.message : "删除 Skill 失败"); throw error; } finally { if (connectionIsCurrent(connectionRevision)) setBusySkill(""); } }, afterClose: () => { confirmRef.current = null; }, }); }; const toggleEnabled = async (skill: AgentSkillSummary, enabled: boolean) => { const connectionRevision = useAgentSkillStore.getState().connectionRevision; if (!connectionIsCurrent(connectionRevision)) return; setBusySkill(skill.path); try { await setCodexSkillEnabled(endpoint, token, skill, enabled); if (!connectionIsCurrent(connectionRevision)) return; if (!enabled && selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection(); await refresh(); } catch (error) { if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "更新 Skill 状态失败"); } finally { if (connectionIsCurrent(connectionRevision)) setBusySkill(""); } }; const codexBusy = sending || waiting; const createMenu: MenuProps = { items: [ { key: "conversation", icon: , disabled: codexBusy || !hasConversation, label: (
从当前对话生成草稿
{codexBusy ? "Codex 运行结束后可用" : hasConversation ? "整理当前对话中的可复用流程" : activeThreadId ? "当前对话还没有已完成内容" : "请先开始一段对话"}
), }, { key: "canvas", icon: , disabled: codexBusy || !hasCanvas, label: (
从当前画布生成草稿
{codexBusy ? "Codex 运行结束后可用" : hasCanvas ? "整理当前页面的节点与生成流程" : "当前页面没有可用画布"}
), }, { type: "divider" as const }, { key: "manual", icon: , label: (
空白创建
从空白表单开始编写
), }, ], onClick: ({ key }) => { if (key === "manual") { setDraft(null); setEditor({ mode: "create" }); } else void generateDraft(key as SkillDraftSource); }, }; return (
本地 Skill
安装在本机,由 Codex 直接执行
setQuery(event.target.value)} prefix={} placeholder="搜索 Skill" /> size="small" variant="borderless" aria-label="按来源筛选 Skill" className="w-28 shrink-0" disabled={!connected} value={scope} onChange={setScope} options={[{ value: "all", label: "全部来源" }, ...Object.entries(scopeLabels).map(([value, label]) => ({ value: value as AgentSkillScope, label }))]} />
{errors.length ? ( ) : null}
{loading && !loaded ? (
正在读取 Skill
) : filteredSkills.length ? (
{filteredSkills.map((skill) => { const selected = selectedSkill?.name === skill.name && selectedSkill.path === skill.path; const busy = busySkill === skill.path; return (
{skill.interface?.displayName || skill.name} {!skill.managed ? : null}
{skill.interface?.shortDescription || skill.shortDescription || skill.description || "暂无说明"}
{scopeLabels[skill.scope] || skill.scope} · {skill.name}
{skill.managed ? ( <>
); })}
) : (
{!connected ? "连接 Agent 后查看 Skill" : skills.length ? "没有匹配的 Skill" : "还没有本地 Skill"}
{!connected ? "连接成功后会读取本机已安装的 Skill" : skills.length ? "换个关键词或来源试试" : "创建一个,或在本机安装后刷新"}
)}
setErrorsOpen(false)}>
{errors.map((error, index) =>
{error}
)}
{ if (saving) return; setDraft(null); setEditor(null); setAdvancedOpen(false); }} onOk={() => void saveSkill()} >
保存到 本地 Agent 工作区 · .agents/skills
基本信息
typeof value === "string" && /[<>]/.test(value) ? Promise.reject(new Error("使用场景不能包含尖括号")) : Promise.resolve() }]}> setAdvancedOpen((Array.isArray(keys) ? keys : [keys]).includes("advanced"))} items={[{ key: "advanced", forceRender: true, label: 高级设置, children: ( <> ), }]} />
); } function skillFormValues(detail: AgentSkillDetail): SkillFormValues { return { name: detail.name, description: detail.description, instructions: detail.instructions, displayName: detail.interface?.displayName || undefined, shortDescription: detail.interface?.shortDescription || undefined, defaultPrompt: detail.interface?.defaultPrompt || undefined, }; } function draftFormValues(draft: AgentSkillDraft): SkillFormValues { return { name: draft.name, description: draft.description, instructions: draft.instructions, displayName: draft.displayName || undefined, shortDescription: draft.shortDescription || undefined, defaultPrompt: draft.defaultPrompt || undefined, }; } function hasSettledConversation(messages: AgentChatItem[], threadId: string) { return Boolean(threadId && messages.some((item) => item.role === "user" && item.threadId === threadId && item.turnId)); } function compactInterface(values: SkillFormValues): AgentSkillInterface | undefined { const skillInterface = { displayName: values.displayName?.trim() || undefined, shortDescription: values.shortDescription?.trim() || undefined, defaultPrompt: values.defaultPrompt?.trim() || undefined, }; return Object.values(skillInterface).some(Boolean) ? skillInterface : undefined; } function mentionsSkill(prompt: string, name: string) { return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt); }