feat(web): add Skill draft creation UI

This commit is contained in:
yu
2026-08-05 02:46:18 +08:00
parent f01cb889ef
commit 98858ada4f
8 changed files with 222 additions and 36 deletions
+2
View File
@@ -3,8 +3,10 @@
## Unreleased
+ [新增] Agent 支持查看、创建、编辑、删除、启停和显式调用本地 Codex Skill,并同步多标签页变更。
+ [新增] Agent 支持从当前对话或当前画布生成可编辑的 Skill 草稿,并在用户确认后保存。
+ [调整] Agent 面板连接状态、内容标签和新对话统一排版,窄面板仅隐藏文字并保留图标与数量。
+ [优化] Agent 输入区控件随面板宽度在图标与图标加文字之间自适应。
+ [修复] Agent Skill 草稿期间保持 MCP 活动画布隔离,支持停止临时生成任务,并禁止并发修改 Skill。
## v0.13.0 - 2026-08-03
@@ -16,6 +16,7 @@ description: 当前版本已实现但仍需人工验证的变更项
- Agent 顶部栏:在对话、历史和日志之间新增「技能」标签并显示已加载数量;连接状态应位于左侧 Agent 标题旁,并与右侧新对话使用相同规格的文字按钮,只有内容标签显示选中线;标签图标始终显示,窄面板仅隐藏文字并保留小号数量,宽面板显示图标和文字,拖动宽度时不应换行、横向滚动或错位。
- Agent 输入框窄屏布局:底部上传、Skill、确认模式、权限模式、模型和思考程度控件在窄面板下应只显示图标,宽面板下恢复文字;所有纯图标操作悬停时应使用统一样式显示名称或当前状态,任意宽度都不出现横向溢出。
- Agent 本地 Skill 管理:连接 Canvas Agent 后进入「技能」,应能读取当前 Codex 工作空间发现的项目、个人、系统和管理员 Skill,并支持搜索、按范围筛选、刷新及启停;未能加载的 Skill 应通过可点击、可滚动的详情查看原因;画布在站点工作空间 `.agents/skills` 下创建的 Skill 应可新建、编辑和删除,其他来源只读且不得被画布改写;编辑期间文件被其他窗口修改时应拒绝覆盖,清空显示名称、短说明和默认提示词后旧值应被删除,同时保留图标、品牌色等未由当前界面管理的元数据;多个标签页同时连接时,任一页面的 Skill 变更应自动同步,断开或切换 Agent 时旧请求不得重新写回列表或弹出编辑器。
- Agent Skill 草稿生成:创建 Skill 时应提供「空白创建」「从当前对话生成草稿」「从当前画布生成草稿」三种入口;空对话不得发起生成,取消时不得写入文件,确认后才写入站点工作空间 `.agents/skills`Agent 运行中应禁用生成,多标签页下对话和画布来源必须分别严格绑定发起请求的 `threadId` 和 `clientId`,生成期间外部 MCP 仍应操作最近聚焦页面;草稿运行时创建、启停、删除和更新 Skill 应被统一拒绝,点击停止应真实中断临时草稿 turn;草稿不得泄露本地路径、密钥、媒体数据、真实节点 ID 或任务进度等临时状态,普通中文斜杠文本与 `/imagine` 等命令必须保持完整,生成草稿使用的临时 turn 不得出现在 Agent 对话、历史或日志中。
- Agent Skill 调用:在技能页点击「使用」或在输入框左下方选择 Skill 后,输入框上方应显示可移除标签;发送任务时应通过 Codex 原生结构化 Skill 输入执行,而不是只把名称拼进普通提示词,发送成功后清除选择,失败时保留;新建对话、断开连接、Skill 被停用或删除时也应清除失效选择,发送前、任务完成后和刷新历史后的用户消息文字应保持一致。
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
+1
View File
@@ -8,3 +8,4 @@ description: 当前项目后续值得处理的事项
本文档用来记录当前项目后续比较值得处理的事项。
- 本地 Agent 接入后续:按[本地 Agent 接入规划](/docs/progress/local-agent-integration-plan)把 Claude Code CLI Adapter 升级为 Claude Agent SDK Adapter,并完善工具队列体验。
- Agent Skill 后续:补充网络检索安装、Skill 资源文件管理和可控的本地记忆沉淀。
+1 -1
View File
@@ -89,7 +89,7 @@ export function AgentChatTimeline({
/>
) : null}
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
{(sending || waiting || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
{(sending || waiting || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working && typeof working.detail === "string" ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
</div>
</div>
{showScrollToBottom ? (
+179 -21
View File
@@ -1,39 +1,52 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Form, Input, Modal, Select, Switch, Tooltip } from "antd";
import { Check, CircleAlert, FilePenLine, LoaderCircle, LockKeyhole, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-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, deleteCodexSkill, fetchCodexSkill, setCodexSkillEnabled, updateCodexSkill, type AgentSkillDetail, type AgentSkillInterface, type AgentSkillScope, type AgentSkillSummary } from "@/services/api/canvas-agent";
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 } from "@/stores/use-agent-store";
import { useAgentStore, type AgentChatItem } from "@/stores/use-agent-store";
import { useThemeStore } from "@/stores/use-theme-store";
type ScopeFilter = "all" | AgentSkillScope;
type SkillEditor = { mode: "create" } | { mode: "edit"; detail: AgentSkillDetail };
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<AgentSkillScope, string> = { repo: "项目", user: "个人", system: "系统", admin: "管理员" };
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function AgentSkillsView() {
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<ScopeFilter>("all");
const [editor, setEditor] = useState<SkillEditor | null>(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);
@@ -47,7 +60,7 @@ export function AgentSkillsView() {
});
}, [query, scope, skills]);
const editorValues = editor?.mode === "edit" ? skillFormValues(editor.detail) : undefined;
const editorValues = editor?.mode === "edit" ? skillFormValues(editor.detail) : editor?.values;
const refresh = (forceReload = true) => loadSkills(endpoint, token, forceReload);
const connectionIsCurrent = (revision: number) => {
@@ -55,12 +68,17 @@ export function AgentSkillsView() {
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();
@@ -69,8 +87,39 @@ export function AgentSkillsView() {
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) return;
if (!skill.managed || busySkill || useAgentSkillStore.getState().generatingSource) return;
const connectionRevision = useAgentSkillStore.getState().connectionRevision;
setBusySkill(skill.path);
try {
@@ -90,12 +139,17 @@ export function AgentSkillsView() {
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;
@@ -106,7 +160,9 @@ export function AgentSkillsView() {
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 已更新");
@@ -165,6 +221,51 @@ export function AgentSkillsView() {
if (connectionIsCurrent(connectionRevision)) setBusySkill("");
}
};
const codexBusy = sending || waiting;
const createMenu: MenuProps = {
items: [
{
key: "conversation",
icon: <MessageSquareText className="size-4" />,
disabled: codexBusy || !hasConversation,
label: (
<div className="py-0.5">
<div className="text-sm">稿</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{codexBusy ? "Codex 运行结束后可用" : hasConversation ? "整理当前对话中的可复用流程" : activeThreadId ? "当前对话还没有已完成内容" : "请先开始一段对话"}</div>
</div>
),
},
{
key: "canvas",
icon: <Workflow className="size-4" />,
disabled: codexBusy || !hasCanvas,
label: (
<div className="py-0.5">
<div className="text-sm">稿</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{codexBusy ? "Codex 运行结束后可用" : hasCanvas ? "整理当前页面的节点与生成流程" : "当前页面没有可用画布"}</div>
</div>
),
},
{ type: "divider" as const },
{
key: "manual",
icon: <FilePenLine className="size-4" />,
label: (
<div className="py-0.5">
<div className="text-sm"></div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}></div>
</div>
),
},
],
onClick: ({ key }) => {
if (key === "manual") {
setDraft(null);
setEditor({ mode: "create" });
}
else void generateDraft(key as SkillDraftSource);
},
};
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -178,19 +279,24 @@ export function AgentSkillsView() {
<Tooltip title="重新读取">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" aria-label="重新读取 Skill" disabled={!connected || loading} icon={<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />} onClick={() => void refresh()} />
</Tooltip>
<Button type="text" className="!h-8 !px-2" disabled={!connected} icon={<Plus className="size-4" />} onClick={() => setEditor({ mode: "create" })}></Button>
<Dropdown trigger={["click"]} placement="bottomRight" open={createMenuOpen} onOpenChange={setCreateMenuOpen} disabled={!connected || !clientId || Boolean(generatingSource)} menu={createMenu}>
<Button type="text" className="!h-8 !px-2" aria-haspopup="menu" aria-expanded={createMenuOpen} disabled={!connected || !clientId} loading={Boolean(generatingSource)} icon={<Plus className="size-4" />}>
Skill <ChevronDown className="size-3.5 opacity-60" />
</Button>
</Dropdown>
</div>
</div>
<div className="mt-3 flex gap-2">
<Input className="min-w-0 flex-1" allowClear disabled={!connected} value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder="搜索 Skill" />
<Input aria-label="搜索 Skill" className="min-w-0 flex-1" allowClear disabled={!connected} value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder="搜索 Skill" />
<Select<ScopeFilter>
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 }))]}
options={[{ value: "all", label: "全部来源" }, ...Object.entries(scopeLabels).map(([value, label]) => ({ value: value as AgentSkillScope, label }))]}
/>
</div>
{errors.length ? (
@@ -222,15 +328,15 @@ export function AgentSkillsView() {
</div>
<div className="mt-2 flex items-center justify-between gap-2 pl-7">
<label className="inline-flex items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
<Switch size="small" checked={skill.enabled} loading={busy} disabled={!connected || Boolean(busySkill)} onChange={(enabled) => void toggleEnabled(skill, enabled)} />
<Switch size="small" checked={skill.enabled} loading={busy} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} onChange={(enabled) => void toggleEnabled(skill, enabled)} />
{skill.enabled ? "已启用" : "已停用"}
</label>
<div className="flex items-center gap-0.5">
<Button type="text" size="small" disabled={!connected || !skill.enabled || Boolean(busySkill)} icon={selected ? <Check className="size-3.5" /> : <Sparkles className="size-3.5" />} onClick={() => useSkill(skill)}>{selected ? "已选择" : "使用"}</Button>
{skill.managed ? (
<>
<Tooltip title="编辑"><Button type="text" shape="circle" size="small" aria-label={`编辑 ${skill.interface?.displayName || skill.name}`} disabled={!connected || Boolean(busySkill)} icon={<FilePenLine className="size-3.5" />} onClick={() => void openEdit(skill)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" shape="circle" size="small" aria-label={`删除 ${skill.interface?.displayName || skill.name}`} disabled={!connected || Boolean(busySkill)} icon={<Trash2 className="size-3.5" />} onClick={() => confirmDelete(skill)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" shape="circle" size="small" aria-label={`编辑 ${skill.interface?.displayName || skill.name}`} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} icon={<FilePenLine className="size-3.5" />} onClick={() => void openEdit(skill)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" shape="circle" size="small" aria-label={`删除 ${skill.interface?.displayName || skill.name}`} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} icon={<Trash2 className="size-3.5" />} onClick={() => confirmDelete(skill)} /></Tooltip>
</>
) : null}
</div>
@@ -243,7 +349,7 @@ export function AgentSkillsView() {
<div className="flex h-48 flex-col items-center justify-center text-center">
<Sparkles className="size-5" style={{ color: theme.node.faint }} />
<div className="mt-3 text-sm font-medium">{!connected ? "连接 Agent 后查看 Skill" : skills.length ? "没有匹配的 Skill" : "还没有本地 Skill"}</div>
<div className="mt-1 text-xs" style={{ color: theme.node.muted }}>{!connected ? "连接成功后会读取本机已安装的 Skill" : skills.length ? "换个关键词或范围试试" : "建一个,或在本机安装后刷新"}</div>
<div className="mt-1 text-xs" style={{ color: theme.node.muted }}>{!connected ? "连接成功后会读取本机已安装的 Skill" : skills.length ? "换个关键词或来源试试" : "建一个,或在本机安装后刷新"}</div>
</div>
)}
</div>
@@ -254,16 +360,53 @@ export function AgentSkillsView() {
</div>
</Modal>
<Modal title={editor?.mode === "edit" ? `编辑 ${editor.detail.interface?.displayName || editor.detail.name}` : "新建 Skill"} open={Boolean(editor)} okText="保存" cancelText="取消" confirmLoading={saving} width={620} destroyOnHidden onCancel={() => !saving && setEditor(null)} onOk={() => void saveSkill()}>
<Form key={editor?.mode === "edit" ? editor.detail.revision : "create"} form={form} initialValues={editorValues} layout="vertical" className="mt-4" requiredMark={false} preserve={false}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }, { max: 64, message: "名称不能超过 64 个字符" }, { pattern: skillNamePattern, message: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾" }]}>
<Modal
title={editor?.mode === "edit" ? `编辑 ${editor.detail.interface?.displayName || editor.detail.name}` : "创建 Skill"}
open={Boolean(editor)}
okText={editor?.mode === "edit" ? "保存更改" : "创建 Skill"}
cancelText="取消"
confirmLoading={saving}
width={680}
centered
destroyOnHidden
styles={{ body: { maxHeight: "calc(100vh - 220px)", overflowY: "auto" } }}
onCancel={() => {
if (saving) return;
setDraft(null);
setEditor(null);
setAdvancedOpen(false);
}}
onOk={() => void saveSkill()}
>
<div className="mb-5 text-xs" style={{ color: theme.node.muted }}> Agent · <span className="font-mono">.agents/skills</span></div>
<Form key={editor?.mode === "edit" ? editor.detail.revision : `create:${editor?.values?.name || "blank"}`} form={form} initialValues={editorValues} layout="vertical" requiredMark="optional" preserve={false}>
<div className="mb-3 text-xs font-medium" style={{ color: theme.node.muted }}></div>
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
<Form.Item name="name" label="Skill 标识" extra="用于文件夹名和 $skill-name 调用。" rules={[{ required: true, message: "请输入 Skill 标识" }, { max: 64, message: "Skill 标识不能超过 64 个字符" }, { pattern: skillNamePattern, message: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾" }]}>
<Input maxLength={64} disabled={editor?.mode === "edit"} placeholder="例如 product-grid" />
</Form.Item>
<Form.Item name="displayName" label="显示名称" rules={[{ max: 64, message: "显示名称不能超过 64 个字符" }]}><Input maxLength={64} placeholder="例如 产品九宫格生成" /></Form.Item>
<Form.Item name="description" label="触发说明" extra="写清楚这个 Skill 做什么,以及应在什么情况下使用。" rules={[{ required: true, message: "请输入 Skill 触发说明" }, { max: 1024, message: "触发说明不能超过 1024 个字符" }, { validator: (_, value) => typeof value === "string" && /[<>]/.test(value) ? Promise.reject(new Error("触发说明不能包含尖括号")) : Promise.resolve() }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明能力和适用场景" /></Form.Item>
<Form.Item name="instructions" label="执行指引" rules={[{ required: true, message: "请输入执行指引" }]}><Input.TextArea className="font-mono text-xs" autoSize={{ minRows: 10, maxRows: 16 }} placeholder="写清楚触发条件、步骤和输出要求" /></Form.Item>
</div>
<Form.Item name="description" label="何时使用" extra="说明这个 Skill 的能力和适用场景,Codex 会据此判断是否调用。" rules={[{ required: true, message: "请输入使用场景" }, { max: 1024, message: "使用场景不能超过 1024 个字符" }, { validator: (_, value) => typeof value === "string" && /[<>]/.test(value) ? Promise.reject(new Error("使用场景不能包含尖括号")) : Promise.resolve() }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder="例如:当用户需要基于商品信息规划并生成一组产品图时使用" /></Form.Item>
<Form.Item name="instructions" label="执行说明" extra="按实际执行顺序写清步骤、约束和输出要求。" rules={[{ required: true, message: "请输入执行说明" }]}><Input.TextArea className="!leading-6" autoSize={{ minRows: 6, maxRows: 10 }} placeholder="写清楚执行步骤、必要检查和最终输出" /></Form.Item>
<Collapse
ghost
size="small"
activeKey={advancedOpen ? ["advanced"] : []}
expandIconPlacement="end"
onChange={(keys) => setAdvancedOpen((Array.isArray(keys) ? keys : [keys]).includes("advanced"))}
items={[{
key: "advanced",
forceRender: true,
label: <span className="text-sm font-medium"></span>,
children: (
<>
<Form.Item name="shortDescription" label="卡片短说明" extra="填写时控制在 25–64 个字符,便于快速浏览。" rules={[{ min: 25, message: "卡片短说明不能少于 25 个字符" }, { max: 64, message: "卡片短说明不能超过 64 个字符" }]}><Input maxLength={64} showCount placeholder="可选,用于列表展示" /></Form.Item>
<Form.Item name="defaultPrompt" label="默认提示词" extra="可选;填写时必须准确包含 $skill-name,例如 $product-grid" rules={[{ max: 1024, message: "默认提示词不能超过 1024 个字符" }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder="选择 Skill 时预填到输入框" /></Form.Item>
<Form.Item name="defaultPrompt" label="默认提示词" extra="填写时必须准确包含 $skill-name,例如 $product-grid" rules={[{ max: 1024, message: "默认提示词不能超过 1024 个字符" }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder="可选,选择 Skill 时预填到输入框" /></Form.Item>
</>
),
}]}
/>
</Form>
</Modal>
</div>
@@ -281,6 +424,21 @@ function skillFormValues(detail: AgentSkillDetail): SkillFormValues {
};
}
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,
@@ -17,7 +17,7 @@ import { useShallow } from "zustand/react/shallow";
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
import { type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
import { acknowledgeCodexHistory, activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
import { acknowledgeCodexHistory, activateAgentClient, discoverAgentConfig, fetchAgentJson, interruptCodexTurn, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat";
import { AgentChatComposer } from "./agent-chat-composer";
import { AgentConnectView } from "./agent-connect-view";
@@ -66,7 +66,7 @@ import { AgentSkillsView } from "./agent-skills-view";
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_PROTOCOL_VERSION = 4;
const AGENT_PROTOCOL_VERSION = 5;
const HISTORY_RETRY_DELAYS_MS = [0, 150, 350, 700, 1200];
const AGENT_REASONING_EFFORTS = new Set<AgentReasoningEffort>(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
const AGENT_REASONING_LABELS: Record<AgentReasoningEffort, string> = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" };
@@ -676,10 +676,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (!connected || (!sending && !waiting)) return;
setAgentState({ activity: "停止中" });
try {
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId: useAgentStore.getState().activeThreadId || undefined }) });
addEventLog("停止任务", "已发送停止请求");
} catch {
await interruptCodexTurn(endpoint, token, useAgentStore.getState().activeThreadId || undefined);
addEventLog("停止任务", "任务已停止");
} catch (error) {
setAgentState({ activity: "停止失败" });
addEventLog("停止失败", error);
}
};
@@ -1281,7 +1282,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
onToggleEnabled={toggleAgentConnection}
/>
) : activeTab === "skills" ? (
<AgentSkillsView />
<AgentSkillsView clientId={clientIdRef.current} />
) : activeTab === "history" ? (
<AgentHistoryView
theme={theme}
+17 -2
View File
@@ -1,4 +1,5 @@
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { AgentReasoningEffort } from "@/stores/use-agent-store";
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
@@ -25,17 +26,23 @@ export type AgentSkillDetail = {
revision: string;
};
export type AgentSkillInput = { name?: string; description: string; instructions: string; interface?: AgentSkillInterface | null; expectedRevision?: string };
export type AgentSkillDraft = { name: string; displayName: string; description: string; instructions: string; shortDescription: string; defaultPrompt: string };
export type AgentSkillDraftInput = { source: "conversation" | "canvas"; threadId: string; clientId: string; model?: string; effort?: AgentReasoningEffort };
export type AgentSkillsResponse = { ok?: boolean; data?: AgentSkillSummary[]; errors?: unknown[] };
export type AgentSkillResponse = { ok?: boolean; data?: AgentSkillDetail };
export type AgentSkillDraftResponse = { ok?: boolean; data?: AgentSkillDraft };
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
const response = await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }),
});
} catch {}
return response.ok;
} catch {
return false;
}
}
export async function activateAgentClient(endpoint: string, token: string, clientId: string) {
@@ -52,6 +59,10 @@ export async function postCodexApproval(endpoint: string, token: string, request
await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) });
}
export async function interruptCodexTurn(endpoint: string, token: string, threadId?: string) {
await fetchAgentJson(endpoint, token, "/agent/codex/interrupt", jsonPost({ threadId }));
}
export async function acknowledgeCodexHistory(endpoint: string, token: string, threadId: string, turnIds: string[]) {
await fetchAgentJson(endpoint, token, "/agent/codex/history/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId, turnIds }) });
}
@@ -72,6 +83,10 @@ export function createCodexSkill(endpoint: string, token: string, input: AgentSk
return fetchAgentJson<AgentSkillResponse>(endpoint, token, "/agent/codex/skills", jsonPost(input));
}
export function createCodexSkillDraft(endpoint: string, token: string, input: AgentSkillDraftInput) {
return fetchAgentJson<AgentSkillDraftResponse>(endpoint, token, "/agent/codex/skills/draft", jsonPost(input));
}
export function updateCodexSkill(endpoint: string, token: string, name: string, input: AgentSkillInput) {
return fetchAgentJson<AgentSkillResponse>(endpoint, token, `/agent/codex/skills/${encodeURIComponent(name)}`, jsonPost(input));
}
+10 -2
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { fetchCodexSkills, type AgentSkillSummary } from "@/services/api/canvas-agent";
import { fetchCodexSkills, type AgentSkillDraft, type AgentSkillSummary } from "@/services/api/canvas-agent";
import { useAgentStore } from "@/stores/use-agent-store";
let loadSequence = 0;
@@ -11,12 +11,16 @@ type AgentSkillStore = {
selectionRevision: number;
connectionRevision: number;
autoPrompt: string;
draft: AgentSkillDraft | null;
generatingSource: "conversation" | "canvas" | null;
loading: boolean;
loaded: boolean;
errors: string[];
loadSkills: (endpoint: string, token: string, forceReload?: boolean) => Promise<void>;
selectSkill: (skill: AgentSkillSummary | null) => void;
clearSelection: (expectedRevision?: number) => void;
setDraft: (draft: AgentSkillDraft | null) => void;
setGeneratingSource: (source: "conversation" | "canvas" | null) => void;
reset: () => void;
};
@@ -26,6 +30,8 @@ export const useAgentSkillStore = create<AgentSkillStore>((set, get) => ({
selectionRevision: 0,
connectionRevision: 0,
autoPrompt: "",
draft: null,
generatingSource: null,
loading: false,
loaded: false,
errors: [],
@@ -60,11 +66,13 @@ export const useAgentSkillStore = create<AgentSkillStore>((set, get) => ({
replaceAutoPrompt(current.autoPrompt, "", false);
set({ selectedSkill: null, autoPrompt: "", selectionRevision: current.selectionRevision + 1 });
},
setDraft: (draft) => set({ draft }),
setGeneratingSource: (generatingSource) => set({ generatingSource }),
reset: () => {
loadSequence += 1;
const current = get();
replaceAutoPrompt(current.autoPrompt, "", false);
set({ skills: [], selectedSkill: null, selectionRevision: current.selectionRevision + 1, connectionRevision: current.connectionRevision + 1, autoPrompt: "", loading: false, loaded: false, errors: [] });
set({ skills: [], selectedSkill: null, selectionRevision: current.selectionRevision + 1, connectionRevision: current.connectionRevision + 1, autoPrompt: "", draft: null, generatingSource: null, loading: false, loaded: false, errors: [] });
},
}));