feat(web): add local Codex Skill management and invocation UI

This commit is contained in:
yu
2026-08-05 02:45:41 +08:00
parent 6cea52302b
commit 08deda5b3f
11 changed files with 695 additions and 88 deletions
+4
View File
@@ -2,6 +2,10 @@
## Unreleased ## Unreleased
+ [新增] Agent 支持查看、创建、编辑、删除、启停和显式调用本地 Codex Skill,并同步多标签页变更。
+ [调整] Agent 面板连接状态、内容标签和新对话统一排版,窄面板仅隐藏文字并保留图标与数量。
+ [优化] Agent 输入区控件随面板宽度在图标与图标加文字之间自适应。
## v0.13.0 - 2026-08-03 ## v0.13.0 - 2026-08-03
+ [优化] Agent 进入空白对话或点击新对话后后台预热 Codex 与 MCP。 + [优化] Agent 进入空白对话或点击新对话后后台预热 Codex 与 MCP。
+4 -1
View File
@@ -13,7 +13,10 @@ description: 当前版本已实现但仍需人工验证的变更项
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。 - Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
- Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。 - Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。
- Agent 画布生图:重启 Canvas Agent 后,其自动生成的工作区 `AGENTS.md` 应同步为最新版指令(用户自行编写的其他 `AGENTS.md` 不应被覆盖);输入“生成一张图片”时应调用 `canvas_generate_image`,通过当前画布节点创建并运行生成流程;只有明确输入“使用 Codex 内置生图/ImageGen 技能生成”时才允许调用内置能力,Codex App Server 返回的 `imageGeneration` 结果应显示在对话中并作为保持原比例的图片节点添加到当前画布;Agent 不应在没有生成结果时提前声称“已生成”。 - Agent 画布生图:重启 Canvas Agent 后,其自动生成的工作区 `AGENTS.md` 应同步为最新版指令(用户自行编写的其他 `AGENTS.md` 不应被覆盖);输入“生成一张图片”时应调用 `canvas_generate_image`,通过当前画布节点创建并运行生成流程;只有明确输入“使用 Codex 内置生图/ImageGen 技能生成”时才允许调用内置能力,Codex App Server 返回的 `imageGeneration` 结果应显示在对话中并作为保持原比例的图片节点添加到当前画布;Agent 不应在没有生成结果时提前声称“已生成”。
- Agent 顶部栏:标题只显示垂直居中的「Agent」,连接、对话、历史、日志、新对话和收起操作应位于同一行;面板较窄时标签仍可横向滚动且操作不应错位。 - Agent 顶部栏:在对话、历史和日志之间新增「技能」标签并显示已加载数量;连接状态应位于左侧 Agent 标题旁,并与右侧新对话使用相同规格的文字按钮,只有内容标签显示选中线;标签图标始终显示,窄面板仅隐藏文字并保留小号数量,宽面板显示图标和文字,拖动宽度时不应换行、横向滚动或错位。
- Agent 输入框窄屏布局:底部上传、Skill、确认模式、权限模式、模型和思考程度控件在窄面板下应只显示图标,宽面板下恢复文字;所有纯图标操作悬停时应使用统一样式显示名称或当前状态,任意宽度都不出现横向溢出。
- Agent 本地 Skill 管理:连接 Canvas Agent 后进入「技能」,应能读取当前 Codex 工作空间发现的项目、个人、系统和管理员 Skill,并支持搜索、按范围筛选、刷新及启停;未能加载的 Skill 应通过可点击、可滚动的详情查看原因;画布在站点工作空间 `.agents/skills` 下创建的 Skill 应可新建、编辑和删除,其他来源只读且不得被画布改写;编辑期间文件被其他窗口修改时应拒绝覆盖,清空显示名称、短说明和默认提示词后旧值应被删除,同时保留图标、品牌色等未由当前界面管理的元数据;多个标签页同时连接时,任一页面的 Skill 变更应自动同步,断开或切换 Agent 时旧请求不得重新写回列表或弹出编辑器。
- Agent Skill 调用:在技能页点击「使用」或在输入框左下方选择 Skill 后,输入框上方应显示可移除标签;发送任务时应通过 Codex 原生结构化 Skill 输入执行,而不是只把名称拼进普通提示词,发送成功后清除选择,失败时保留;新建对话、断开连接、Skill 被停用或删除时也应清除失效选择,发送前、任务完成后和刷新历史后的用户消息文字应保持一致。
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。 - Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。 - Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
- Canvas Agent Codex 升级:启动 Canvas Agent 后应输出 Canvas Agent、内置 Codex 和本机 Codex 版本,实际运行的内置 Codex 应为 0.146.0;内置与本机版本不一致、未安装本机 Codex,或 npm 存在更新版本时应显示对应升级提醒,npm 检查失败不应阻止服务启动;Agent 应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。 - Canvas Agent Codex 升级:启动 Canvas Agent 后应输出 Canvas Agent、内置 Codex 和本机 Codex 版本,实际运行的内置 Codex 应为 0.146.0;内置与本机版本不一致、未安装本机 Codex,或 npm 存在更新版本时应显示对应升级提醒,npm 检查失败不应阻止服务启动;Agent 应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
+106 -64
View File
@@ -1,12 +1,14 @@
import { useRef, type ReactNode } from "react"; import { useRef, useState, type ReactNode } from "react";
import { Button, Dropdown, Tooltip } from "antd"; import { Button, Dropdown, Tooltip } from "antd";
import { ArrowUp, Check, ChevronUp, Cpu, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react"; import { ArrowUp, Check, ChevronUp, Cpu, Gauge, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { isPlainEnterKey } from "@/lib/keyboard-event"; import { isPlainEnterKey } from "@/lib/keyboard-event";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import type { AgentModel, AgentPermissionMode, AgentReasoningEffort } from "@/stores/use-agent-store"; import type { AgentModel, AgentPermissionMode, AgentReasoningEffort } from "@/stores/use-agent-store";
import type { AgentChatAttachment } from "./agent-chat-message"; import type { AgentChatAttachment } from "./agent-chat-message";
import { AgentSkillPicker } from "./agent-skill-picker";
export function AgentChatComposer({ export function AgentChatComposer({
prompt, prompt,
@@ -54,6 +56,8 @@ export function AgentChatComposer({
left?: ReactNode; left?: ReactNode;
}) { }) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
const clearSkillSelection = useAgentSkillStore((state) => state.clearSelection);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
return ( return (
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}> <div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
@@ -72,6 +76,16 @@ export function AgentChatComposer({
))} ))}
</div> </div>
) : null} ) : null}
{selectedSkill ? (
<div className="mb-2 flex items-center px-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="truncate">{selectedSkill.interface?.displayName || selectedSkill.name}</span>
<button type="button" className="grid size-4 shrink-0 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => clearSkillSelection()} aria-label="移除 Skill">
<X className="size-3" />
</button>
</span>
</div>
) : null}
<textarea <textarea
value={prompt} value={prompt}
onChange={(event) => onPromptChange(event.target.value)} onChange={(event) => onPromptChange(event.target.value)}
@@ -92,7 +106,7 @@ export function AgentChatComposer({
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
placeholder={placeholder} placeholder={placeholder}
/> />
<div className="mt-2 flex items-center justify-between gap-2"> <div className="@container mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
{onAddFiles ? ( {onAddFiles ? (
<> <>
@@ -101,10 +115,11 @@ export function AgentChatComposer({
event.target.value = ""; event.target.value = "";
}} /> }} />
<Tooltip title="上传图片"> <Tooltip title="上传图片">
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={disabled || sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} /> <Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={disabled || sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} aria-label="上传图片" />
</Tooltip> </Tooltip>
</> </>
) : null} ) : null}
<AgentSkillPicker />
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null} {onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null} {permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
{models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelControls models={models} model={model} reasoningEffort={reasoningEffort} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : null} {models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelControls models={models} model={model} reasoningEffort={reasoningEffort} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : null}
@@ -112,9 +127,9 @@ export function AgentChatComposer({
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{sending && onStop ? ( {sending && onStop ? (
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" /> <Tooltip title="停止" placement="top"><Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" /></Tooltip>
) : ( ) : (
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" /> <Tooltip title="发送" placement="top"><Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" /></Tooltip>
)} )}
</div> </div>
</div> </div>
@@ -125,25 +140,38 @@ export function AgentChatComposer({
function AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) { function AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) {
const current = models.find((item) => item.model === model) || models[0]; const current = models.find((item) => item.model === model) || models[0];
const [modelOpen, setModelOpen] = useState(false);
const [reasoningOpen, setReasoningOpen] = useState(false);
return ( return (
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<Select value={model} onValueChange={onModelChange}> <Tooltip title={`模型:${current.displayName || current.model}`} placement="top" open={modelOpen ? false : undefined}>
<SelectTrigger className="h-9 min-w-0 max-w-36 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" title={current.displayName || current.model} aria-label="选择 Codex 模型"> <span className="inline-flex shrink-0">
<Cpu className="size-3.5 shrink-0 opacity-70" /> <Select value={model} open={modelOpen} onOpenChange={setModelOpen} onValueChange={onModelChange}>
<span className="min-w-0 flex-1 truncate text-left">{current.displayName || current.model}</span> <SelectTrigger hideChevron className="h-9 w-9 min-w-9 justify-center gap-0 rounded-full border-0 bg-transparent px-0 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 @min-[660px]:w-auto @min-[660px]:min-w-36 @min-[660px]:max-w-36 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:bg-transparent dark:hover:bg-white/10" aria-label={`选择 Codex 模型,当前为 ${current.displayName || current.model}`}>
</SelectTrigger> <Cpu className="size-3.5 shrink-0 opacity-70" />
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] w-64 rounded-xl border border-border/70 bg-popover p-1 shadow-xl"> <span className="hidden min-w-0 flex-1 truncate text-left @min-[660px]:inline">{current.displayName || current.model}</span>
{models.map((item) => <SelectItem key={item.model} value={item.model}>{item.displayName || item.model}</SelectItem>)} <ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
</SelectContent> </SelectTrigger>
</Select> <SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] w-64 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
<Select value={reasoningEffort} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}> {models.map((item) => <SelectItem key={item.model} value={item.model}>{item.displayName || item.model}</SelectItem>)}
<SelectTrigger className="h-9 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" aria-label="选择推理强度"> </SelectContent>
<span>{effortLabels[reasoningEffort]}</span> </Select>
</SelectTrigger> </span>
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl"> </Tooltip>
{current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabels[item.reasoningEffort]}</SelectItem>)} <Tooltip title={`思考程度:${effortLabels[reasoningEffort]}`} placement="top" open={reasoningOpen ? false : undefined}>
</SelectContent> <span className="inline-flex shrink-0">
</Select> <Select value={reasoningEffort} open={reasoningOpen} onOpenChange={setReasoningOpen} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}>
<SelectTrigger hideChevron className="h-9 w-9 min-w-9 justify-center gap-0 rounded-full border-0 bg-transparent px-0 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 @min-[660px]:w-auto @min-[660px]:min-w-[4.5rem] @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:bg-transparent dark:hover:bg-white/10" aria-label={`选择思考程度,当前为 ${effortLabels[reasoningEffort]}`}>
<Gauge className="size-3.5 opacity-70" />
<span className="hidden @min-[660px]:inline">{effortLabels[reasoningEffort]}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
</SelectTrigger>
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
{current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabels[item.reasoningEffort]}</SelectItem>)}
</SelectContent>
</Select>
</span>
</Tooltip>
</div> </div>
); );
} }
@@ -160,24 +188,31 @@ const effortLabels: Record<AgentReasoningEffort, string> = {
function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) { function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) {
const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0]; const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0];
const [open, setOpen] = useState(false);
return ( return (
<Dropdown <Tooltip title={`权限:${current.shortTitle}`} placement="top" open={open ? false : undefined}>
trigger={["click"]} <span className="inline-flex shrink-0">
placement="topLeft" <Dropdown
menu={{ trigger={["click"]}
items: permissionOptions.map((item) => ({ placement="topLeft"
key: item.key, open={open}
label: <ConfirmationOption icon={item.icon} title={item.title} description={item.description} selected={permissionMode === item.key} />, onOpenChange={setOpen}
onClick: () => onChange(item.key), menu={{
})), items: permissionOptions.map((item) => ({
}} key: item.key,
> label: <ConfirmationOption icon={item.icon} title={item.title} description={item.description} selected={permissionMode === item.key} />,
<button type="button" className="flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: permissionMode === "full" ? "#ea580c" : theme.node.text }} aria-label="选择 Codex 权限模式"> onClick: () => onChange(item.key),
{current.icon} })),
<span>{current.shortTitle}</span> }}
<ChevronUp className="size-3 opacity-50" /> >
</button> <button type="button" className="flex h-9 w-9 min-w-9 shrink-0 items-center justify-center gap-0 rounded-full px-0 text-xs font-medium transition hover:bg-black/5 @min-[660px]:h-9 @min-[660px]:w-auto @min-[660px]:min-w-0 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:hover:bg-white/10" style={{ color: permissionMode === "full" ? "#ea580c" : theme.node.text }} aria-label={`选择 Codex 权限模式,当前为 ${current.title}`}>
</Dropdown> {current.icon}
<span className="hidden @min-[660px]:inline">{current.shortTitle}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
</button>
</Dropdown>
</span>
</Tooltip>
); );
} }
@@ -188,31 +223,38 @@ const permissionOptions: Array<{ key: AgentPermissionMode; title: string; shortT
]; ];
function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) { function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) {
const [open, setOpen] = useState(false);
return ( return (
<Dropdown <Tooltip title={`工具确认:${confirmTools ? "手动确认" : "自动确认"}`} placement="top" open={open ? false : undefined}>
trigger={["click"]} <span className="inline-flex shrink-0">
placement="topLeft" <Dropdown
menu={{ trigger={["click"]}
items: [ placement="topLeft"
{ open={open}
key: "manual", onOpenChange={setOpen}
label: <ConfirmationOption icon={<Hand className="size-4" />} title="手动确认" description="Agent 执行画布写入前会请求确认" selected={confirmTools} />, menu={{
onClick: () => onChange(true), items: [
}, {
{ key: "manual",
key: "automatic", label: <ConfirmationOption icon={<Hand className="size-4" />} title="手动确认" description="Agent 执行画布写入前会请求确认" selected={confirmTools} />,
label: <ConfirmationOption icon={<RefreshCw className="size-4" />} title="自动确认" description="Agent 会自动执行画布写入操作" selected={!confirmTools} />, onClick: () => onChange(true),
onClick: () => onChange(false), },
}, {
], key: "automatic",
}} label: <ConfirmationOption icon={<RefreshCw className="size-4" />} title="自动确认" description="Agent 会自动执行画布写入操作" selected={!confirmTools} />,
> onClick: () => onChange(false),
<button type="button" className="flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="选择工具确认模式"> },
{confirmTools ? <Hand className="size-3.5" /> : <RefreshCw className="size-3.5" />} ],
<span>{confirmTools ? "手动确认" : "自动确认"}</span> }}
<ChevronUp className="size-3 opacity-50" /> >
</button> <button type="button" className="flex h-9 w-9 min-w-9 shrink-0 items-center justify-center gap-0 rounded-full px-0 text-xs font-medium transition hover:bg-black/5 @min-[660px]:w-auto @min-[660px]:min-w-0 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label={`选择工具确认模式,当前为 ${confirmTools ? "手动确认" : "自动确认"}`}>
</Dropdown> {confirmTools ? <Hand className="size-3.5" /> : <RefreshCw className="size-3.5" />}
<span className="hidden @min-[660px]:inline">{confirmTools ? "手动确认" : "自动确认"}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
</button>
</Dropdown>
</span>
</Tooltip>
); );
} }
@@ -6,7 +6,7 @@ import { Streamdown, type LinkSafetyModalProps } from "streamdown";
import { useCopyText } from "@/hooks/use-copy-text"; import { useCopyText } from "@/hooks/use-copy-text";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store"; import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store";
import { revealAgentLocalFile } from "./agent-api"; import { revealAgentLocalFile } from "@/services/api/canvas-agent";
const streamdownProps = { const streamdownProps = {
className: "agent-streamdown", className: "agent-streamdown",
+12 -8
View File
@@ -1,21 +1,25 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Tooltip } from "antd";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
export function AgentPanelTabs<T extends string>({ value, items, theme, leading, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; leading?: ReactNode; right?: ReactNode; onChange: (value: T) => void }) { export function AgentPanelTabs<T extends string>({ value, items, theme, leading, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; leading?: ReactNode; right?: ReactNode; onChange: (value: T) => void }) {
return ( return (
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}> <div className="@container border-b px-2" style={{ borderColor: theme.node.stroke }}>
<div className="flex h-12 items-center justify-between gap-3"> <div className="flex h-12 items-center gap-1">
{leading ? <div className="flex shrink-0 items-center">{leading}</div> : null} {leading ? <div className="flex shrink-0 items-center">{leading}</div> : null}
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板"> <nav className="flex min-w-0 flex-1 items-center justify-center gap-0.5 overflow-hidden text-sm @min-[560px]:gap-3" role="tablist" aria-label="Agent 内容">
{items.map((item) => ( {items.map((item) => (
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-12 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}> <Tooltip key={item.value} title={`${item.label}${item.count ? ` ${item.count}` : ""}`} placement="bottom">
{item.icon} <button type="button" role="tab" aria-label={`${item.label}${item.count ? ` ${item.count}` : ""}`} aria-selected={value === item.value} className={`inline-flex h-12 min-w-8 shrink-0 items-center justify-center gap-1.5 border-b-2 px-1.5 transition @min-[560px]:px-0.5 ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
{item.label}{item.count ? ` ${item.count}` : ""} {item.icon ? <span className="agent-panel-tab-icon">{item.icon}</span> : null}
</button> <span className={item.icon ? "hidden @min-[560px]:inline" : undefined}>{item.label}</span>
{item.count ? <span className="text-[10px] font-normal leading-none tabular-nums opacity-60">{item.count > 99 ? "99+" : item.count}</span> : null}
</button>
</Tooltip>
))} ))}
</nav> </nav>
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null} {right ? <div className="flex shrink-0 items-center gap-1">{right}</div> : null}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,85 @@
import { useMemo, useState } from "react";
import { Button, Input, Popover, Tooltip } from "antd";
import { Check, Search, Sparkles } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import { useAgentStore } from "@/stores/use-agent-store";
import { useThemeStore } from "@/stores/use-theme-store";
export function AgentSkillPicker() {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const connected = useAgentStore((state) => state.connected);
const url = useAgentStore((state) => state.url);
const token = useAgentStore((state) => state.token);
const skills = useAgentSkillStore((state) => state.skills);
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
const loading = useAgentSkillStore((state) => state.loading);
const loadSkills = useAgentSkillStore((state) => state.loadSkills);
const selectSkill = useAgentSkillStore((state) => state.selectSkill);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const endpoint = url.trim().replace(/\/$/, "");
const enabledSkills = useMemo(() => {
const keyword = query.trim().toLowerCase();
return skills.filter((skill) => skill.enabled && (!keyword || [skill.name, skill.description, skill.interface?.displayName, skill.interface?.shortDescription, skill.shortDescription].some((value) => value?.toLowerCase().includes(keyword))));
}, [query, skills]);
const content = (
<div className="w-72" style={{ color: theme.node.text }}>
<div className="mb-2 px-1 text-xs font-medium" style={{ color: theme.node.muted }}> Skill</div>
<Input allowClear size="small" value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder="搜索 Skill" />
<div className="thin-scrollbar mt-2 max-h-64 overflow-y-auto">
{enabledSkills.length ? enabledSkills.map((skill) => {
const selected = selectedSkill?.name === skill.name && selectedSkill.path === skill.path;
return (
<button
key={`${skill.name}:${skill.path}`}
type="button"
className="flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left transition hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => {
selectSkill(skill);
setOpen(false);
}}
>
<Sparkles className="mt-0.5 size-4 shrink-0" style={{ color: selected ? theme.node.text : theme.node.muted }} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{skill.interface?.displayName || skill.name}</span>
<span className="mt-0.5 block truncate text-xs" style={{ color: theme.node.muted }}>{skill.interface?.shortDescription || skill.shortDescription || skill.description}</span>
</span>
{selected ? <Check className="mt-0.5 size-4 shrink-0" /> : null}
</button>
);
}) : (
<div className="px-2 py-6 text-center text-xs leading-5" style={{ color: theme.node.muted }}>
{loading ? "正在读取 Skill…" : skills.length ? "没有匹配的已启用 Skill" : "还没有可用的 Skill"}
</div>
)}
</div>
</div>
);
return (
<Tooltip title={connected ? "选择 Skill" : "连接 Agent 后使用 Skill"} placement="top" open={open ? false : undefined}>
<Popover
arrow={false}
trigger="click"
placement="topLeft"
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen && connected && !skills.length && !loading) void loadSkills(endpoint, token);
}}
content={content}
>
<Button
type="text"
shape="circle"
className="!h-9 !w-9 !min-w-9"
disabled={!connected}
style={{ color: selectedSkill ? theme.node.text : theme.node.muted, background: selectedSkill ? theme.toolbar.activeBg : undefined }}
icon={<Sparkles className="size-4" />}
aria-label="选择 Skill"
/>
</Popover>
</Tooltip>
);
}
@@ -0,0 +1,295 @@
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 { 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 { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import { useAgentStore } 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 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() {
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 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 loadSkills = useAgentSkillStore((state) => state.loadSkills);
const selectSkill = useAgentSkillStore((state) => state.selectSkill);
const clearSelection = useAgentSkillStore((state) => state.clearSelection);
const [query, setQuery] = useState("");
const [scope, setScope] = useState<ScopeFilter>("all");
const [editor, setEditor] = useState<SkillEditor | null>(null);
const [saving, setSaving] = useState(false);
const [busySkill, setBusySkill] = useState("");
const [errorsOpen, setErrorsOpen] = useState(false);
const confirmRef = useRef<{ destroy: () => void } | null>(null);
const [form] = Form.useForm<SkillFormValues>();
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) : undefined;
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 (connected) return;
confirmRef.current?.destroy();
confirmRef.current = null;
setEditor(null);
setSaving(false);
setBusySkill("");
setErrorsOpen(false);
form.resetFields();
}, [connected, form]);
const useSkill = (skill: AgentSkillSummary) => {
selectSkill(skill);
setAgentState({ activeTab: "chat" });
};
const openEdit = async (skill: AgentSkillSummary) => {
if (!skill.managed || busySkill) 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 {
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}`] }]);
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;
setEditor(null);
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("");
}
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold"> Skill</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}> Codex </div>
</div>
<div className="flex items-center gap-1">
<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>
</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" />
<Select<ScopeFilter>
size="small"
variant="borderless"
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 }))]}
/>
</div>
{errors.length ? (
<Button danger type="text" size="small" className="!mt-1 !h-7 !px-1 text-xs" icon={<CircleAlert className="size-3.5" />} onClick={() => setErrorsOpen(true)}>{errors.length} Skill </Button>
) : null}
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-4">
{loading && !loaded ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm" style={{ color: theme.node.muted }}><LoaderCircle className="size-4 animate-spin" /> Skill</div>
) : filteredSkills.length ? (
<div className="divide-y" style={{ borderColor: theme.node.stroke }}>
{filteredSkills.map((skill) => {
const selected = selectedSkill?.name === skill.name && selectedSkill.path === skill.path;
const busy = busySkill === skill.path;
return (
<div key={`${skill.name}:${skill.path}`} className={`py-3 transition-opacity ${skill.enabled ? "" : "opacity-55"}`} style={{ borderColor: theme.node.stroke }}>
<div className="flex items-start gap-3">
<Sparkles className="mt-0.5 size-4 shrink-0" style={{ color: selected ? theme.node.text : theme.node.muted }} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{skill.interface?.displayName || skill.name}</span>
{!skill.managed ? <Tooltip title="外部 Skill 只能使用或启停"><LockKeyhole className="size-3.5 shrink-0" style={{ color: theme.node.faint }} /></Tooltip> : null}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{skill.interface?.shortDescription || skill.shortDescription || skill.description || "暂无说明"}</div>
<Tooltip title={skill.path}>
<div className="mt-1.5 truncate text-[11px]" style={{ color: theme.node.faint }}>{scopeLabels[skill.scope] || skill.scope} · {skill.name}</div>
</Tooltip>
</div>
</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)} />
{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>
</>
) : null}
</div>
</div>
</div>
);
})}
</div>
) : (
<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>
)}
</div>
<Modal title={`${errors.length} 个 Skill 未能加载`} open={errorsOpen} footer={null} width={720} onCancel={() => setErrorsOpen(false)}>
<div className="thin-scrollbar mt-4 max-h-[60vh] overflow-y-auto rounded-md border px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke }}>
{errors.map((error, index) => <div key={`${index}:${error}`} className="break-all py-1" style={{ color: theme.node.muted }}>{error}</div>)}
</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: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾" }]}>
<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>
<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>
</Modal>
</div>
);
}
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 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);
}
+48 -13
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button, Tooltip } from "antd"; import { App, Button, Tooltip } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Terminal } from "lucide-react"; import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Sparkles, Terminal } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { imageMetadata } from "@/lib/canvas/canvas-node-factory"; import { imageMetadata } from "@/lib/canvas/canvas-node-factory";
@@ -12,11 +12,12 @@ import { randomId } from "@/lib/utils";
import { uploadImage } from "@/services/image-storage"; import { uploadImage } from "@/services/image-storage";
import { bindPendingAgentUserMessage, deleteAgentThreadMessages, deletePendingAgentUserMessage, moveAgentUserMessage, readAgentUserMessages, savePendingAgentUserMessage } from "@/services/agent-chat-storage"; import { bindPendingAgentUserMessage, deleteAgentThreadMessages, deletePendingAgentUserMessage, moveAgentUserMessage, readAgentUserMessages, savePendingAgentUserMessage } from "@/services/agent-chat-storage";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import { useShallow } from "zustand/react/shallow"; 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 { 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 { type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools"; import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
import { acknowledgeCodexHistory, activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "./agent-api"; import { acknowledgeCodexHistory, activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat"; import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat";
import { AgentChatComposer } from "./agent-chat-composer"; import { AgentChatComposer } from "./agent-chat-composer";
import { AgentConnectView } from "./agent-connect-view"; import { AgentConnectView } from "./agent-connect-view";
@@ -60,11 +61,12 @@ import {
import { AgentHistoryView } from "./agent-history-view"; import { AgentHistoryView } from "./agent-history-view";
import { AgentLogView } from "./agent-log-view"; import { AgentLogView } from "./agent-log-view";
import { AgentPanelTabs } from "./agent-panel-tabs"; import { AgentPanelTabs } from "./agent-panel-tabs";
import { AgentSkillsView } from "./agent-skills-view";
const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371"; const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_PROTOCOL_VERSION = 3; const AGENT_PROTOCOL_VERSION = 4;
const HISTORY_RETRY_DELAYS_MS = [0, 150, 350, 700, 1200]; 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_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" }; const AGENT_REASONING_LABELS: Record<AgentReasoningEffort, string> = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" };
@@ -130,6 +132,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const pushMessage = useAgentStore((state) => state.addMessage); const pushMessage = useAgentStore((state) => state.addMessage);
const pushEventLog = useAgentStore((state) => state.addEventLog); const pushEventLog = useAgentStore((state) => state.addEventLog);
const clearEventLogs = useAgentStore((state) => state.clearEventLogs); const clearEventLogs = useAgentStore((state) => state.clearEventLogs);
const loadSkills = useAgentSkillStore((state) => state.loadSkills);
const clearSkillSelection = useAgentSkillStore((state) => state.clearSelection);
const skillCount = useAgentSkillStore((state) => state.skills.length);
const messageCount = useAgentStore((state) => state.messages.length); const messageCount = useAgentStore((state) => state.messages.length);
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext); const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
const confirmToolsRef = useRef(confirmTools); const confirmToolsRef = useRef(confirmTools);
@@ -306,6 +311,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
source.close(); source.close();
connectedRef.current = false; connectedRef.current = false;
setAgentState({ enabled: false, connected: false, waiting: false, sending: false, activity: "需要重启 Agent", connectError: text, silentConnect: false, pendingTool: null, pendingApprovals: [] }); setAgentState({ enabled: false, connected: false, waiting: false, sending: false, activity: "需要重启 Agent", connectError: text, silentConnect: false, pendingTool: null, pendingApprovals: [] });
useAgentSkillStore.getState().reset();
addEventLog("Agent 版本不匹配", text, hello); addEventLog("Agent 版本不匹配", text, hello);
if (!headless) message.error(text); if (!headless) message.error(text);
return; return;
@@ -490,6 +496,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const text = parseEventData<{ text?: unknown }>(event)?.text; const text = parseEventData<{ text?: unknown }>(event)?.text;
addEventLog("日志", text, text); addEventLog("日志", text, text);
}); });
source.addEventListener("skills_changed", (event) => {
if (!isCurrentConnection()) return;
const data = parseEventData<{ forceReload?: boolean }>(event);
void loadSkills(endpoint, token, Boolean(data?.forceReload));
});
source.addEventListener("agent_error", (event) => { source.addEventListener("agent_error", (event) => {
const data = parseEventData<AgentEventPayload>(event); const data = parseEventData<AgentEventPayload>(event);
if (!data) return; if (!data) return;
@@ -521,6 +532,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
pendingTool: null, pendingTool: null,
pendingApprovals: [], pendingApprovals: [],
}); });
useAgentSkillStore.getState().reset();
if (!wasConnected) { if (!wasConnected) {
source.close(); source.close();
setAgentState({ enabled: false }); setAgentState({ enabled: false });
@@ -531,13 +543,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
source.close(); source.close();
connectedRef.current = false; connectedRef.current = false;
loadThreadsSequenceRef.current += 1; loadThreadsSequenceRef.current += 1;
useAgentSkillStore.getState().reset();
}; };
}, [applyWorkspaceChange, clientReady, enabled, endpoint, loadThreads, message, setAgentState, token]); }, [applyWorkspaceChange, clientReady, enabled, endpoint, loadSkills, loadThreads, message, setAgentState, token]);
useEffect(() => { useEffect(() => {
if (connected) void loadThreads(); if (connected) void loadThreads();
}, [connected, loadThreads]); }, [connected, loadThreads]);
useEffect(() => {
if (connected) void loadSkills(endpoint, token);
}, [connected, endpoint, loadSkills, token]);
useEffect(() => { useEffect(() => {
if (!connected) return; if (!connected) return;
void fetchAgentJson<AgentModelsResponse>(endpoint, token, "/agent/codex/models").then(({ data = [] }) => { void fetchAgentJson<AgentModelsResponse>(endpoint, token, "/agent/codex/models").then(({ data = [] }) => {
@@ -578,6 +595,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const sendPrompt = async () => { const sendPrompt = async () => {
const text = prompt.trim(); const text = prompt.trim();
const files = attachments; const files = attachments;
const skillState = useAgentSkillStore.getState();
const selectedSkill = skillState.selectedSkill;
const selectedSkillRevision = skillState.selectionRevision;
const requestPrompt = promptWithAttachments(text, files); const requestPrompt = promptWithAttachments(text, files);
const currentState = useAgentStore.getState(); const currentState = useAgentStore.getState();
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads) return; if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads) return;
@@ -597,7 +617,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (files.length) await savePendingAgentUserMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }); if (files.length) await savePendingAgentUserMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files });
const modelName = models.find((item) => item.model === model)?.displayName || model || "默认模型"; const modelName = models.find((item) => item.model === model)?.displayName || model || "默认模型";
const effortName = reasoningEffort ? AGENT_REASONING_LABELS[reasoningEffort] : "默认强度"; const effortName = reasoningEffort ? AGENT_REASONING_LABELS[reasoningEffort] : "默认强度";
addEventLog("发送任务", `${modelName} · ${effortName}${files.length ? ` · 附件 ${files.length}` : ""} · ${compactText(text) || "仅附件"}`); addEventLog("发送任务", `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · 附件 ${files.length}` : ""} · ${compactText(text) || "仅附件"}`);
const accepted = await fetchAgentJson<AgentTurnResponse>(endpoint, token, "/agent/codex/turn", { const accepted = await fetchAgentJson<AgentTurnResponse>(endpoint, token, "/agent/codex/turn", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -610,11 +630,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
permissionMode, permissionMode,
model, model,
effort: reasoningEffort, effort: reasoningEffort,
skill: selectedSkill ? { name: selectedSkill.name, path: selectedSkill.path } : undefined,
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })), attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
}), }),
}); });
threadId = accepted.threadId || threadId; threadId = accepted.threadId || threadId;
if (!threadId) throw new Error("启动对话失败"); if (!threadId) throw new Error("启动对话失败");
if (selectedSkill) clearSkillSelection(selectedSkillRevision);
if (files.length) { if (files.length) {
const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId); const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId);
const acceptedThreadId = latestMessage?.threadId || threadId; const acceptedThreadId = latestMessage?.threadId || threadId;
@@ -886,6 +908,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
mcpStartupStatuses: {}, mcpStartupStatuses: {},
...patch, ...patch,
}); });
useAgentSkillStore.getState().reset();
pendingToolRef.current = null; pendingToolRef.current = null;
} }
@@ -907,6 +930,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (!current.connected || current.sending || current.waiting || current.loadingThreads) return; if (!current.connected || current.sending || current.waiting || current.loadingThreads) return;
const operation = beginThreadOperation(); const operation = beginThreadOperation();
applyWorkspaceChange({ activeThreadId: "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current }); applyWorkspaceChange({ activeThreadId: "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current });
clearSkillSelection();
setAgentState({ activeTab: "chat", activity: "正在新建对话", bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} }); setAgentState({ activeTab: "chat", activity: "正在新建对话", bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
try { try {
const result = await fetchAgentJson<AgentWorkspaceResponse>(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current, permissionMode }) }); const result = await fetchAgentJson<AgentWorkspaceResponse>(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current, permissionMode }) });
@@ -1199,23 +1223,30 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: isDelta ? `${message.text}${text}` : mergeStreamText(message.text, text) } : message) }); setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: isDelta ? `${message.text}${text}` : mergeStreamText(message.text, text) } : message) });
}; };
const connectionStatus = connectError ? "连接失败" : connected ? "已连接" : enabled ? "连接中" : "未连接";
const connectionStatusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
const content = ( const content = (
<> <>
<AgentPanelTabs <AgentPanelTabs
value={activeTab} value={activeTab}
theme={theme} theme={theme}
leading={ leading={
<div className="flex items-center gap-2 pr-1"> <div className="flex items-center gap-1">
<span className="grid size-8 place-items-center"> <span className="grid size-8 place-items-center">
<Bot className="size-4" /> <Bot className="size-4" />
</span> </span>
<div className="text-base font-semibold leading-5">Agent</div> <div className="hidden text-base font-semibold leading-5 @min-[560px]:block">Agent</div>
<Tooltip title={`连接设置 · ${connectionStatus}`} placement="bottom">
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label={`连接设置,当前${connectionStatus}`} icon={<PlugZap className="size-3.5" style={{ color: connectionStatusColor }} />} onClick={() => setAgentState({ activeTab: "setup" })}>
<span className="hidden @min-[560px]:inline">{connectionStatus}</span>
</Button>
</Tooltip>
</div> </div>
} }
items={[ items={[
{ value: "setup", label: "连接", icon: <PlugZap className="size-3.5" /> },
{ value: "chat", label: "对话", icon: <MessageSquare className="size-3.5" /> }, { value: "chat", label: "对话", icon: <MessageSquare className="size-3.5" /> },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length }, { value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
{ value: "skills", label: "技能", icon: <Sparkles className="size-3.5" />, count: skillCount },
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length }, { value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
]} ]}
onChange={(activeTab) => { onChange={(activeTab) => {
@@ -1224,11 +1255,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
}} }}
right={ right={
<> <>
<Button size="small" type="text" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}> <Tooltip title="新对话" placement="bottom">
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label="新对话" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
</Button> <span className="hidden @min-[560px]:inline"></span>
</Button>
</Tooltip>
<Tooltip title="收起对话"> <Tooltip title="收起对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" aria-label="收起 Agent 面板" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
</Tooltip> </Tooltip>
</> </>
} }
@@ -1247,6 +1280,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
onTokenChange={(token) => setAgentState({ token, connectError: "" })} onTokenChange={(token) => setAgentState({ token, connectError: "" })}
onToggleEnabled={toggleAgentConnection} onToggleEnabled={toggleAgentConnection}
/> />
) : activeTab === "skills" ? (
<AgentSkillsView />
) : activeTab === "history" ? ( ) : activeTab === "history" ? (
<AgentHistoryView <AgentHistoryView
theme={theme} theme={theme}
@@ -1308,7 +1343,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
}} }}
left={ left={
attachments.length ? ( attachments.length ? (
<span className="text-[11px]" style={{ color: theme.node.muted }}> <span className="hidden text-[11px] @min-[660px]:inline" style={{ color: theme.node.muted }}>
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB {formatBytes(attachmentPayloadBytes(attachments))} / 30MB
</span> </span>
) : null ) : null
@@ -2,6 +2,32 @@ import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean }; type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
export type AgentSkillScope = "user" | "repo" | "system" | "admin";
export type AgentSkillInterface = { displayName?: string | null; shortDescription?: string | null; defaultPrompt?: string | null };
export type AgentSkillSummary = {
name: string;
description: string;
shortDescription?: string | null;
interface?: AgentSkillInterface | null;
dependencies?: unknown;
path: string;
scope: AgentSkillScope;
enabled: boolean;
managed: boolean;
};
export type AgentSkillDetail = {
name: string;
description: string;
instructions: string;
interface?: AgentSkillInterface | null;
path: string;
managed: true;
revision: string;
};
export type AgentSkillInput = { name?: string; description: string; instructions: string; interface?: AgentSkillInterface | null; expectedRevision?: string };
export type AgentSkillsResponse = { ok?: boolean; data?: AgentSkillSummary[]; errors?: unknown[] };
export type AgentSkillResponse = { ok?: boolean; data?: AgentSkillDetail };
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) { export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
try { try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
@@ -34,6 +60,30 @@ export async function revealAgentLocalFile(endpoint: string, token: string, path
await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) }); await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
} }
export function fetchCodexSkills(endpoint: string, token: string, forceReload = false) {
return fetchAgentJson<AgentSkillsResponse>(endpoint, token, `/agent/codex/skills${forceReload ? "?forceReload=1" : ""}`);
}
export function fetchCodexSkill(endpoint: string, token: string, name: string) {
return fetchAgentJson<AgentSkillResponse>(endpoint, token, `/agent/codex/skills/${encodeURIComponent(name)}`);
}
export function createCodexSkill(endpoint: string, token: string, input: AgentSkillInput) {
return fetchAgentJson<AgentSkillResponse>(endpoint, token, "/agent/codex/skills", 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));
}
export function deleteCodexSkill(endpoint: string, token: string, name: string, expectedRevision: string) {
return fetchAgentJson<{ ok?: boolean }>(endpoint, token, `/agent/codex/skills/${encodeURIComponent(name)}/delete`, jsonPost({ expectedRevision }));
}
export function setCodexSkillEnabled(endpoint: string, token: string, skill: Pick<AgentSkillSummary, "name" | "path">, enabled: boolean) {
return fetchAgentJson<{ ok?: boolean }>(endpoint, token, `/agent/codex/skills/${encodeURIComponent(skill.name)}/enabled`, jsonPost({ ...skill, enabled }));
}
export async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) { export async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`; const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
const res = await fetch(url, init); const res = await fetch(url, init);
@@ -52,3 +102,7 @@ export async function discoverAgentConfig(endpoint: string) {
return null; return null;
} }
} }
function jsonPost(body: unknown): RequestInit {
return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
}
+85
View File
@@ -0,0 +1,85 @@
import { create } from "zustand";
import { fetchCodexSkills, type AgentSkillSummary } from "@/services/api/canvas-agent";
import { useAgentStore } from "@/stores/use-agent-store";
let loadSequence = 0;
type AgentSkillStore = {
skills: AgentSkillSummary[];
selectedSkill: AgentSkillSummary | null;
selectionRevision: number;
connectionRevision: number;
autoPrompt: string;
loading: boolean;
loaded: boolean;
errors: string[];
loadSkills: (endpoint: string, token: string, forceReload?: boolean) => Promise<void>;
selectSkill: (skill: AgentSkillSummary | null) => void;
clearSelection: (expectedRevision?: number) => void;
reset: () => void;
};
export const useAgentSkillStore = create<AgentSkillStore>((set, get) => ({
skills: [],
selectedSkill: null,
selectionRevision: 0,
connectionRevision: 0,
autoPrompt: "",
loading: false,
loaded: false,
errors: [],
loadSkills: async (endpoint, token, forceReload = false) => {
const sequence = ++loadSequence;
set({ loading: true });
try {
const response = await fetchCodexSkills(endpoint, token, forceReload);
if (sequence !== loadSequence) return;
const skills = response.data || [];
const current = get();
const selected = current.selectedSkill;
const selectedSkill = selected ? skills.find((item) => item.name === selected.name && item.path === selected.path && item.enabled) || null : null;
const selectionChanged = Boolean(selected && !selectedSkill);
const autoPrompt = selectedSkill
? replaceAutoPrompt(current.autoPrompt, selectedSkill.interface?.defaultPrompt?.trim() || "", false)
: replaceAutoPrompt(current.autoPrompt, "", false);
set({ skills, selectedSkill, autoPrompt, selectionRevision: current.selectionRevision + (selectionChanged ? 1 : 0), loading: false, loaded: true, errors: (response.errors || []).map(skillErrorText) });
} catch (error) {
if (sequence !== loadSequence) return;
set({ loading: false, loaded: true, errors: [error instanceof Error ? error.message : "读取 Skill 失败"] });
}
},
selectSkill: (selectedSkill) => {
const current = get();
const autoPrompt = replaceAutoPrompt(current.autoPrompt, selectedSkill?.interface?.defaultPrompt?.trim() || "", true);
set({ selectedSkill, autoPrompt, selectionRevision: current.selectionRevision + 1 });
},
clearSelection: (expectedRevision) => {
const current = get();
if (expectedRevision !== undefined && expectedRevision !== current.selectionRevision) return;
replaceAutoPrompt(current.autoPrompt, "", false);
set({ selectedSkill: null, autoPrompt: "", selectionRevision: current.selectionRevision + 1 });
},
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: [] });
},
}));
function replaceAutoPrompt(previous: string, next: string, fillEmpty: boolean) {
const agent = useAgentStore.getState();
const ownsPrompt = Boolean(previous && agent.prompt === previous);
if (!ownsPrompt && !(fillEmpty && !agent.prompt.trim())) return "";
if (agent.prompt !== next) agent.setAgentState({ prompt: next });
return next;
}
function skillErrorText(value: unknown) {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return "Skill 解析失败";
const item = value as { path?: unknown; error?: unknown; message?: unknown };
const message = typeof item.error === "string" ? item.error : typeof item.message === "string" ? item.message : "Skill 解析失败";
return typeof item.path === "string" ? `${item.path}${message}` : message;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number }; export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
export type AgentTokenUsage = { input: number; cached: number; output: number }; export type AgentTokenUsage = { input: number; cached: number; output: number };
export type AgentBootstrapStatus = { key: string; text: string; detail: string; status: "running" | "ready" | "error" }; export type AgentBootstrapStatus = { key: string; text: string; detail: string; status: "running" | "ready" | "error" };
export type AgentPanelTab = "chat" | "setup" | "history" | "log"; export type AgentPanelTab = "chat" | "setup" | "history" | "skills" | "log";
const CONNECT_TIMEOUT_MS = 6000; const CONNECT_TIMEOUT_MS = 6000;
let agentSource: EventSource | null = null; let agentSource: EventSource | null = null;