mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(web): add local Codex Skill management and invocation UI
This commit is contained in:
@@ -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。
|
||||||
|
|||||||
@@ -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 即可继续发送新任务。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user