From df600272d688b37c63398e7d9d656515a155c043 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 23 Apr 2026 20:27:54 +0800 Subject: [PATCH] feat: add tool status management and localization for skill installation - Updated chat message types to include tool statuses. - Enhanced localization files for English, Thai, and Chinese to support new tool status messages. - Modified HomePage and SkillsPage components to handle tool statuses in chat messages. - Implemented tool status merging and updating logic in the chat store. - Added handling for tool status events in the gateway event processing. - Created tests for chat message rendering with tool statuses and skill installation shortcuts. - Improved gateway event dispatching for tool lifecycle events. --- dist-electron/main/main.js | 2 +- docs/ClawX-Skill-Install-Migration-Plan.md | 557 ++++++++------------- electron/gateway/browser-shortcut.ts | 68 ++- electron/gateway/event-dispatch.ts | 12 + electron/gateway/handlers/chat.ts | 50 +- electron/gateway/handlers/skills.ts | 44 +- electron/gateway/manager.ts | 92 +++- electron/gateway/protocol.ts | 4 +- electron/gateway/random-id.ts | 7 + electron/gateway/rpc-dispatch.ts | 8 + electron/gateway/runtime-context.ts | 28 ++ electron/gateway/skill-install-shortcut.ts | 233 +++++++++ electron/gateway/types.ts | 35 +- runtime-shared/shared/chat-model.ts | 3 + src/components/chat/ChatMessageList.tsx | 437 +++++++++++++++- src/components/chat/types.ts | 3 +- src/i18n/locales/en/chat.json | 33 ++ src/i18n/locales/th/chat.json | 33 ++ src/i18n/locales/zh/chat.json | 33 ++ src/pages/Home/index.tsx | 3 + src/pages/Skills/index.tsx | 6 + src/stores/chat.ts | 94 +++- src/types/runtime.ts | 18 +- tests/browser-shortcut.test.ts | 66 ++- tests/chat-message-list.test.tsx | 153 ++++++ tests/chat-runtime-context.test.ts | 137 +++++ tests/gateway-protocol-state.test.ts | 68 ++- tests/gateway-rpc-dispatch.test.ts | 24 + tests/skill-install-shortcut.test.ts | 174 +++++++ 29 files changed, 2041 insertions(+), 384 deletions(-) create mode 100644 electron/gateway/random-id.ts create mode 100644 electron/gateway/runtime-context.ts create mode 100644 electron/gateway/skill-install-shortcut.ts create mode 100644 tests/chat-message-list.test.tsx create mode 100644 tests/chat-runtime-context.test.ts create mode 100644 tests/skill-install-shortcut.test.ts diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 414c2f5..9f6ba8f 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-CFKIo_7y.js"); +require("./main-CYkgmH75.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/docs/ClawX-Skill-Install-Migration-Plan.md b/docs/ClawX-Skill-Install-Migration-Plan.md index b7091d5..3c1a8f4 100644 --- a/docs/ClawX-Skill-Install-Migration-Plan.md +++ b/docs/ClawX-Skill-Install-Migration-Plan.md @@ -1,427 +1,296 @@ -# ClawX Skill 安装能力迁移计划 +# ClawX 对话安装 Skill 功能同构计划 ## 1. 结论先行 -本轮对比后的结论很明确: +- `ClawX` 并没有在产品代码里写死一条“聊天里识别 GitHub `SKILL.md` 链接并直接调用安装接口”的专用链路。 +- `ClawX` 之所以看起来“同一句对话可以安装 skill”,更接近真实情况的是:聊天消息进入了更完整的 `OpenClaw runtime`,模型拿到了额外的 agent/tool 上下文,再通过通用 `tool_use / tool_result` 执行浏览、读取、命令调用或安装动作。 +- `zn-ai` 当前不是“没有 skill 安装能力”,而是“安装能力已经存在于 Skills 页面和 Host API,但聊天运行时没有拿到这项能力,也没有完整的 tool 协议和 UI 闭环”。 +- 因此,`zn-ai` 不能复现同一句对话安装 skill 的根因,不在 `SkillInstallService`,而在 `Runtime Context + Tool Protocol + Conversational Bridge + Tool UI` 四层。 -- `ClawX` 已经具备可用的 Skills marketplace 安装闭环,但它当前的运行时安装输入仍然是 `slug + version`,不是 GitHub skill 链接。 -- `zn-ai` 已经复制了 Skills 页面、Host API 路由、`ClawHubService` 外壳和预装 skill 逻辑,但运行时安装链路还没有真正闭环。 -- `zn-ai` 当前最关键的缺口不是 UI,而是“运行时执行器”和“GitHub skill 目录安装能力”。 -- 如果目标是“用户在对话里贴一个 GitHub skill 链接,应用可以把整目录安装到 `~/.openclaw/skills` 并启用”,那么 `ClawX` 和 `zn-ai` 目前都还没有原生支持这条链路。 +## 2. 当前真实对比 -本轮已经完成一项落地验证: - -- 已把 `MiniMax-AI/skills` 仓库下的 `skills/minimax-xlsx` 完整目录安装到 `C:\Users\Administrator\.openclaw\skills\minimax-xlsx` -- 已在 `C:\Users\Administrator\.openclaw\openclaw.json` 中补齐 `minimax-xlsx.enabled=true` - -这说明: - -- GitHub skill 本身可以按“完整目录复制”的方式被 OpenClaw 识别 -- 问题不在 skill 格式本身,而在产品代码还没有把“GitHub URL -> skill 安装”做成正式运行时能力 - -## 2. 现状对比 - -### 2.1 已有能力 - -| 能力 | ClawX | zn-ai | 结论 | +| 维度 | ClawX | zn-ai | 结论 | | --- | --- | --- | --- | -| Skills 页面与 Marketplace 抽屉 | 有 | 有 | `zn-ai` UI 已有,不需要从零重做 | -| `/api/clawhub/search/install/list` 路由 | 有 | 有 | `zn-ai` Host API 形态已基本对齐 | -| `~/.openclaw/skills` 托管目录 | 有 | 有 | 两个项目共用 OpenClaw skills 根目录 | -| 预装 bundled skills 拷贝 | 有 | 有 | `zn-ai` 已有 preinstalled install 逻辑 | -| 安装后技能扫描/配置读取 | 有 | 有 | `zn-ai` 已有 `skill-config.ts` 基础设施 | +| Skills 页面安装 | 有,走 `slug/version` marketplace 安装 | 有,已支持 `marketplace` 和 `github-url` | `zn-ai` 底层安装面并不弱 | +| GitHub skill 链接安装 | 页面/API 未见专用产品入口 | `POST /api/skills/install` 已支持 | `zn-ai` 已先行具备这项底层能力 | +| 对话执行上下文 | 有独立 `AGENTS.clawx.md`、`TOOLS.clawx.md` | 没有等价的运行时上下文注入 | `ClawX` 的“能执行”主要赢在这里 | +| 聊天工具执行 | `tool_use / tool_result` 已跑在完整链路里 | 只有普通文本聊天,另加一个浏览器快捷特判 | `zn-ai` 还不是真正的 tool runtime | +| Tool 结果展示 | 已有流式工具轨迹与结果渲染 | 现有聊天 UI 只消费文本/附件 | 即使后端能产出 tool 结果,前端也接不住 | +| 技能安装与聊天桥接 | 依赖 runtime/工具能力,不是硬编码 install API | 完全没有 `skills.install` 聊天入口 | 这是当前最直接的缺口 | -### 2.2 关键差距 +## 3. 已确认的关键证据 -| 差距项 | ClawX | zn-ai | 判断 | -| --- | --- | --- | --- | -| `clawhub` 运行时依赖 | 有,`package.json` 直接声明 | 没有 | `zn-ai` marketplace 安装大概率不可用 | -| Marketplace provider 扩展接线 | 有 | 没有 | 这会影响搜索/安装来源能力,但 install-only 迁移可先不搬整套扩展系统 | -| 安装输入类型 | 仅 `slug/version` | 仅 `slug/version` | 两边都不支持 GitHub skill 链接直装 | -| GitHub URL 解析与整目录下载 | 没有 | 没有 | 这是“通过对话贴链接安装”的核心缺口 | -| Skills 路径文案一致性 | 一致 | 不一致,前端 fallback 仍写 `~/.zn-ai/skills` | 会误导手动安装与报错提示 | +### 3.1 ClawX -### 2.3 关键证据 +- 聊天消息只是透传到 `chat.send`,没有 GitHub URL 安装分支: + - `ClawX/src/stores/chat/runtime-send-actions.ts` + - `ClawX/electron/main/ipc-handlers.ts` + - `ClawX/electron/api/routes/gateway.ts` +- Skills 页面安装仍是 `slug/version`: + - `ClawX/src/stores/skills.ts` + - `ClawX/electron/api/routes/skills.ts` + - `ClawX/electron/gateway/clawhub.ts` +- 运行时上下文与工具说明是单独存在的: + - `ClawX/resources/context/AGENTS.clawx.md` + - `ClawX/resources/context/TOOLS.clawx.md` +- 聊天 UI 和 store 确实按 `tool_use / tool_result` 在渲染: + - `ClawX/src/stores/chat/runtime-event-handlers.ts` + - `ClawX/src/pages/Chat/index.tsx` -ClawX 现状: +### 3.2 zn-ai -- `ClawX/package.json` 已声明 `clawhub` 依赖 -- `ClawX/electron/gateway/clawhub.ts` 的运行时安装参数只有 `slug/version/force` -- `ClawX/electron/main/index.ts` 会给 `ClawHubService` 接入 marketplace provider +- 统一安装服务已经支持 `marketplace` 和 `github-url`: + - `zn-ai/electron/service/skill-install-service.ts` +- Host API 已暴露安装入口: + - `zn-ai/electron/api/routes/skills.ts` +- Skills 页面已经支持从市场安装和 GitHub 链接直装: + - `zn-ai/src/lib/skills-api.ts` + - `zn-ai/src/pages/Skills/index.tsx` + - `zn-ai/src/pages/Skills/components/MarketplaceDrawer.tsx` +- Gateway 只暴露了 `skills.status / skills.update`,没有 `skills.install`: + - `zn-ai/electron/gateway/handlers/skills.ts` + - `zn-ai/electron/gateway/rpc-dispatch.ts` + - `zn-ai/electron/gateway/types.ts` +- 聊天当前仍是“普通 provider 文本流 + 浏览器快捷特判”: + - `zn-ai/electron/gateway/handlers/chat.ts` + - `zn-ai/electron/providers/OpenAIProvider.ts` +- 前端消息模型虽然有 `tool_use / tool_result` 类型,但页面没有真正渲染: + - `zn-ai/runtime-shared/shared/chat-model.ts` + - `zn-ai/src/shared/chat-model.ts` + - `zn-ai/src/pages/Home/index.tsx` + - `zn-ai/src/components/chat/ChatMessageList.tsx` -zn-ai 现状: +## 4. zn-ai 当前缺少哪些上下文或功能 -- `zn-ai/src/pages/Skills/index.tsx` 与 `MarketplaceDrawer.tsx` 已具备搜索/安装 UI -- `zn-ai/electron/api/routes/skills.ts` 已具备 `/api/clawhub/install` -- `zn-ai/electron/gateway/clawhub.ts` 已有 `ClawHubService` 外壳 -- `zn-ai/package.json` 没有 `clawhub` 依赖,导致运行时 CLI 入口无法成立 -- `zn-ai/src/lib/skills-api.ts` 与 `src/pages/Skills/index.tsx` 的 fallback 路径仍写成 `~/.zn-ai/skills` +### 4.1 P0 级缺口 -## 3. 迁移范围 +1. 聊天链路没有 `skills.install` 执行入口。 +2. 模型拿不到“本机可安装 skill”的结构化能力上下文。 +3. Provider 层没有 function calling / tools / responses-style tool loop。 +4. 聊天上下文没有保留和回灌 `tool_result`,多轮对话接不上工具执行结果。 -本计划只覆盖“安装 skill”闭环,严格排除以下范围: +### 4.2 P1 级缺口 -- 不改 Skills 详情页视觉样式 -- 不扩展 API Key / env 配置能力 -- 不处理 skill 导入导出 -- 不迁移 ClawX 全量 extension framework -- 不重构其它插件、频道、模型、任务执行逻辑 +1. Gateway 协议没有把 `tool.call_started / tool.call_completed / tool_result` 真正发到 renderer。 +2. Renderer store 对 `tool-only` 消息和 `pendingFinal` 的状态处理还不适合同构 `ClawX`。 +3. 聊天 UI 不能显示工具卡片、执行中状态、结果摘要。 +4. 安装完成后没有统一的 runtime 刷新广播,Skills 页和聊天页之间没有一致的同步信号。 -本次必须覆盖的范围只有四件事: +### 4.3 P2 级缺口 -1. 让 `zn-ai` 先真正具备可工作的 marketplace slug 安装能力 -2. 增加 GitHub skill 链接/仓库路径安装能力 -3. 让“通过对话安装 skill”有清晰的运行时入口 -4. 安装完成后自动启用、刷新、回显结果 +1. 缺对“ClawX 为什么能做这件事”的真实证据抓取与固化。 +2. 缺开发态、打包态、真实 OpenClaw 进程和真实浏览器的 smoke。 +3. 缺对 `skills.installBundle` 这类旁支能力的目标边界说明。 -## 4. 目标闭环 +## 5. 100% 同构目标 -迁移完成后,`zn-ai` 至少要满足下面的用户路径: +迁移完成后,`zn-ai` 需要做到和 `ClawX` 等价的三层能力: -### 4.1 Marketplace 路径 +### 5.1 运行时同构 -- 用户在 Skills 页面搜索 marketplace -- 点击安装 -- 应用把 skill 安装到 `~/.openclaw/skills/` -- 自动写入 enabled 配置 -- 页面刷新后能看到 skill,且状态为已启用 +- 对话进入 `OpenClaw` 风格的 agent/tool runtime,而不是只走普通文本 provider。 +- 模型能够看到明确的 skill 安装能力说明与结构化 tool schema。 +- `tool_use / tool_result / thinking` 能贯穿主进程、Gateway、renderer。 -### 4.2 GitHub 链接路径 +### 5.2 安装能力同构 -- 用户在对话中粘贴 `https://github.com///blob///SKILL.md` -- 系统把它解析为 `repo/ref/repoPath` -- 下载的是“整个 skill 目录”,不是单个 `SKILL.md` -- 校验目录中存在 `SKILL.md` -- 复制到 `~/.openclaw/skills/` -- 自动启用并返回安装结果、实际路径、后续提示 +- Marketplace `slug/version` 安装可用。 +- GitHub `blob/.../SKILL.md` 和 `tree/...` skill 目录安装可用。 +- 安装后自动启用,并能在 runtime 与 Skills 页即时可见。 -### 4.3 失败时的最低可用体验 +### 5.3 对话行为同构 -- 链接无效时,明确提示“不是可安装的 skill 目录链接” -- 目标目录已存在时,给出“已安装/是否覆盖”提示 -- 网络失败时,提示下载失败与建议重试 -- 目录缺 `SKILL.md` 时,提示“不是合法 skill 包” +- 在 `ClawX` 能触发安装的同一句话,在 `zn-ai` 也能触发等价行为。 +- 用户能在聊天中看到明确的工具执行过程、安装结果、失败原因和后续提示。 +- 整个链路在开发态和打包态都可验证。 -## 5. 推荐设计 +## 6. 目标架构 -## 5.1 不把 install 继续绑定死在 `clawhub` - -当前 `zn-ai` 的 `/api/clawhub/install` 天然只适合 marketplace slug。 -如果要支持 GitHub skill 链接,建议把安装能力上提为统一安装服务: - -```ts -type SkillInstallRequest = - | { kind: 'marketplace'; slug: string; version?: string; force?: boolean } - | { kind: 'github-url'; url: string; force?: boolean } - | { kind: 'github-repo-path'; repo: string; ref?: string; path: string; name?: string; force?: boolean }; +```text +User Chat Message + -> Renderer Chat Store + -> gateway:rpc('chat.send') + -> OpenClaw-style Runtime Context + -> Tool Planner / Tool Executor + -> skills.install + -> browser + -> shell / uv / other runtime tools + -> tool_use / tool_result events + -> Renderer Tool UI + final assistant reply + -> Skills runtime refresh ``` -建议新增: +其中 `skills.install` 不应该只是一个页面按钮 API,而要成为聊天运行时可见、可调用、可回传结果的正式能力。 -- `electron/services/skill-install.ts` +## 7. 分阶段同构计划 -由它统一负责: +### M0 能力定标 -- 解析安装源 -- 下载或 checkout skill 目录 -- 校验 `SKILL.md` -- 写入 `~/.openclaw/skills` -- 更新 `openclaw.json` -- 返回安装结果 +目标: +- 固化 `ClawX` 当前“同一句对话能安装 skill”背后的真实机制。 +- 区分“页面/API 安装能力”和“聊天运行时执行能力”。 -`/api/clawhub/install` 可以继续保留,但只作为 marketplace 的兼容入口,内部调用统一安装服务。 +产出: +- 行为证据清单 +- 非目标边界 +- 统一验收口径 -## 5.2 GitHub 安装逻辑建议直接落在 Electron TypeScript +### M1 Runtime Context 同构 -不建议把 Python helper script 直接搬进 `zn-ai` 运行时。 -建议在 Electron 侧用 TypeScript 实现,参考两类现有逻辑: +目标: +- 给 `zn-ai` 引入与 `ClawX` 对齐的 agent/tool/context 注入。 +- 让模型知道本机有哪些工具以及 `skills.install` 的输入约束。 -- GitHub 安装语义:参考本地 `skill-installer` 的 `install-skill-from-github.py` -- skill 目录复制语义:参考 `ClawX/scripts/bundle-preinstalled-skills.mjs` +涉及: +- 运行时 prompt/context 资源 +- tool capability 声明 +- chat send 前的上下文拼装 -运行时 GitHub install 最小步骤: +### M2 Gateway / Tool Protocol 同构 -1. 解析 GitHub URL -2. 把 `blob/.../SKILL.md` 还原为 skill 目录路径 -3. 下载 zip 或 sparse checkout -4. 定位 skill 目录 -5. 校验 `SKILL.md` -6. 拷贝到 `~/.openclaw/skills/` -7. 写入 `openclaw.json -> skills.entries[slug].enabled = true` -8. 返回 `{ success, slug, baseDir, source }` +目标: +- 打通 `tool_use / tool_result / thinking / tool lifecycle` 的真实协议链。 +- 不再只靠浏览器快捷特判。 -## 5.3 “通过对话安装”优先走 tool,而不是字符串黑魔法 +涉及: +- `GatewayRpc`/event 类型补齐 +- Gateway 事件分发 +- 主进程到 renderer 的流式工具事件 +- tool-only 消息收敛逻辑 -`zn-ai` 现有聊天链路已经能承载 `tool_use/tool_result` 消息块。 -因此推荐加一个明确的 host-side tool,例如: +### M3 Renderer Tool UI 同构 -```ts -skills.install({ - source: 'marketplace' | 'github-url' | 'github-repo-path', - slug?: string, - version?: string, - url?: string, - repo?: string, - ref?: string, - path?: string, - force?: boolean -}) -``` +目标: +- 在聊天页显示工具调用、执行中状态、结果摘要和失败态。 +- 让用户能看见“为什么安装成功/失败”,而不是只看到一段普通文本。 -这样做的好处: +涉及: +- `src/stores/chat.ts` +- `src/pages/Home/index.tsx` +- `src/components/chat/*` -- 不需要在聊天文本里做脆弱的正则硬解析 -- Renderer 已经能展示 tool 过程与结果 -- Skills 页面和聊天都能复用同一个安装服务 +### M4 Skill Install Service / API 同构 -这次迁移里,不需要重做整个聊天架构,只要补一个 install skill tool 接口即可。 +目标: +- 统一市场安装、GitHub skill 目录安装、安装后启用和刷新语义。 +- 明确 `skills.install` 的 tool 契约。 -## 5.4 install-only 阶段不迁移 ClawX 扩展系统 - -ClawX 有 marketplace provider extension 接线。 -但本次目标只是把“安装 skill”闭环跑通,不建议为了这一个功能把 extension registry 整套引入 `zn-ai`。 - -install-only 阶段建议: - -- 先直接依赖 `clawhub` CLI 完成 marketplace 安装 -- 再新增 GitHub URL install 适配器 -- extension marketplace provider 以后如果要做 ClawHub 中国镜像或自定义市场,再单独立项 - -## 6. 分阶段迁移计划 - -### Phase 1:补齐运行时前提 - -目标:让 `zn-ai` marketplace install 至少不是空壳。 - -工作项: - -- 在 `zn-ai/package.json` 增加 `clawhub` 依赖 -- 校验 `electron/utils/paths.ts` 的 `getClawHubCliBinPath/getClawHubCliEntryPath` -- 让 `electron/gateway/clawhub.ts` 的 capability 检查在依赖缺失时返回明确错误 -- 修正 `src/lib/skills-api.ts` 与 `src/pages/Skills/index.tsx` 中的 `~/.zn-ai/skills` fallback 为 `~/.openclaw/skills` - -验收: - -- `GET /api/clawhub/capability` 返回 `canInstall=true` -- Skills 页面点击 Install 不再因为缺 CLI 直接失败 - -### Phase 2:引入统一 Skill 安装服务 - -目标:把 install 从“只会装 slug”升级为“可装多来源”。 - -工作项: - -- 新建 `electron/services/skill-install.ts` -- 支持 marketplace 与 GitHub 两类安装源 -- 抽出公共能力: - - `parseGitHubSkillUrl` - - `downloadOrCheckoutSkillDir` - - `validateSkillDir` - - `copySkillIntoManagedDir` - - `enableInstalledSkill` -- 统一返回安装结果对象 - -验收: - -- 传入 `slug` 时仍能成功安装 marketplace skill -- 传入 GitHub `blob/.../SKILL.md` 链接时能成功安装整个目录 - -### Phase 3:接通 Host API 与前端安装入口 - -目标:让 UI 和聊天都能调统一安装服务。 - -工作项: - -- 新增 `POST /api/skills/install` -- 保留 `POST /api/clawhub/install` 作为兼容入口 -- `src/lib/skills-api.ts` 改成调用统一 install API -- Skills 页面安装成功后继续执行: - - enable - - reload skills - - toast success -- 在 MarketplaceDrawer 增加一个“粘贴 GitHub skill 链接”入口 - - 这一步只属于 install 能力,不算额外 UI 扩张 - -验收: - -- Skills 页面支持两种安装方式: - - marketplace 搜索安装 - - GitHub 链接直装 - -### Phase 4:接通聊天 tool 与回归验证 - -目标:完成“通过对话安装 skill”。 - -工作项: - -- 在网关/运行时工具桥里新增 `skills.install` -- 让模型遇到 GitHub skill 链接时可以直接调用该 tool -- Tool result 回传: - - `slug` - - `installedPath` - - `enabled` - - `source` - - `nextStep` -- 增加安装相关 smoke case - -验收: - -- 用户在对话里发送 GitHub skill 链接 -- 模型触发 `skills.install` -- 安装完成后页面和运行时都能识别该 skill - -## 7. 推荐 Sub-Agent 编制 - -本次 install-only 迁移,建议 **4 个开发 sub-agent + 1 个主协调 agent**。 - -如果少于 4 个,很容易出现: - -- 后端安装服务已写好,但聊天入口迟迟没接 -- marketplace slug 安装恢复了,但 GitHub URL 安装仍不可用 -- UI 提示和实际托管目录不一致,影响手动兜底 - -### SA-1:Runtime Installer - -职责: - -- 负责 `electron/services/skill-install.ts` -- 负责 GitHub URL 解析、下载/checkout、目录校验、复制安装 -- 负责安装后启用配置写入 - -文件责任边界: - -- `electron/services/skill-install.ts` -- `electron/utils/skill-config.ts` -- 必要时 `electron/utils/paths.ts` - -### SA-2:Marketplace & Host API - -职责: - -- 给 `zn-ai` 补 `clawhub` 运行时依赖 -- 接通 capability 检查 -- 调整 `/api/clawhub/install` 与新增 `/api/skills/install` -- 保持 marketplace slug 安装向后兼容 - -文件责任边界: - -- `package.json` +涉及: +- `electron/service/skill-install-service.ts` - `electron/api/routes/skills.ts` -- `electron/gateway/clawhub.ts` -- `src/lib/skills-api.ts` +- `electron/gateway/handlers/skills.ts` +- `electron/gateway/rpc-dispatch.ts` -### SA-3:Chat Tool Bridge +### M5 Conversational Install 同构 -职责: +目标: +- 把“同一句对话可执行安装”做成正式可测契约。 +- 同时支持至少这两类输入: + - `https://github.com/.../SKILL.md,帮我安装这个 skill` + - `帮我安装 minimax-xlsx 这个 skill` -- 给聊天链路增加 `skills.install` tool -- 负责“通过对话贴 GitHub skill 链接即可安装”的入口 -- 负责 tool result 事件与前端可见反馈 +涉及: +- tool planner / conversational bridge +- 安装结果回写 +- 安装后的 runtime refresh -文件责任边界: +### M6 验收封板 -- `electron/gateway/*` -- 运行时/类型定义文件 -- 与聊天消息 tool block 相关的 renderer 连接层 +目标: +- 把 mocked UI 回归、真实 smoke、打包态 smoke 分开管理。 +- 确保“同构 100%”是以真实链路为标准,而不是只看页面 mock。 -### SA-4:Skills UI & QA +## 8. 推荐 sub-agent 数量 -职责: +### 8.1 推荐编制 -- 修 Skills 页安装入口文案和 fallback 路径 -- 加 GitHub skill 链接安装入口 -- 补手工 smoke checklist、失败态提示、回归文档 +- `7` 个 sub-agent -文件责任边界: +这是比较稳的数量。因为这次不是单点 API 迁移,而是同时跨越: +- 运行时上下文 +- Gateway/tool 协议 +- 聊天 store 与 UI +- 安装服务与刷新同步 +- 真实 smoke 与证据固化 -- `src/pages/Skills/index.tsx` -- `src/pages/Skills/components/MarketplaceDrawer.tsx` -- `src/i18n/locales/*/skills.json` -- `docs/*` -- tests / smoke scripts +### 8.2 最小可行编制 -### 主协调 agent +- `5` 个 sub-agent -职责: +这只适合做“先跑通最小闭环”,不适合冲 `100%` 同构。 -- 维护安装请求契约 -- 控制范围不外溢到 skill 详情/配置等无关功能 -- 处理 agent 间接口对齐与回归顺序 +## 9. sub-agent 分工建议 -## 8. 推荐推进顺序 +| 角色 | 数量 | 负责范围 | 文件所有权 | +| --- | --- | --- | --- | +| A1:证据与契约 | 1 | 复现并固化 `ClawX` 的真实行为、非目标、验收标准 | 只读分析、测试脚本、文档 | +| M1:Runtime Context | 1 | agent/tool/context 资源、能力声明、聊天前置上下文拼装 | `resources/context/*` `electron/gateway/*` | +| M2:Gateway / Tool Protocol | 1 | `tool_use / tool_result / thinking` 协议、事件、状态收敛 | `electron/gateway/*` `src/types/runtime.ts` | +| M3:Renderer Tool UI | 1 | 聊天 store、工具卡片、结果渲染、tool-only 状态 | `src/stores/chat.ts` `src/pages/Home/index.tsx` `src/components/chat/*` | +| M4:Skill Install Service / API | 1 | `skills.install` 契约、install service、安装后启用与刷新 | `electron/service/skill-install-service.ts` `electron/api/routes/skills.ts` `electron/gateway/handlers/skills.ts` | +| M5:Conversational Behavior | 1 | “同一句对话触发安装”的桥接逻辑与行为测试 | `electron/gateway/handlers/chat.ts` `electron/gateway/*` `tests/*` | +| I1:QA / Regression | 1 | 真实 Gateway smoke、打包态 smoke、失败态矩阵、文档回填 | `tests/*` `docs/*` | -1. 先做 Phase 1,确保 `zn-ai` 不再是“安装 UI 有了,但执行器缺失” -2. 再做 Phase 2,把安装服务抽象成统一入口 -3. 然后并行推进: - - SA-2 接 Host API - - SA-3 接聊天 tool - - SA-4 接 Skills 页面 -4. 最后统一做一次 GitHub skill 链接实装验证 +## 10. 建议推进波次 -推荐实装验证用例就用这次的目标 skill: +### Wave 1 -- `https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md` +- `A1 + M1 + M2` -原因: +目标: +- 冻结真实行为基线 +- 补齐运行时上下文 +- 打通最小工具协议链 -- 它不是纯 `SKILL.md`,目录里有 `scripts/ references/ templates/` -- 能很好验证“必须安装完整 skill 目录”的约束 +### Wave 2 -## 9. 风险与控制 +- `M4 + M5` -### 风险 1:把 GitHub `SKILL.md` 当成单文件安装 +目标: +- 把现有 `SkillInstallService` 接进聊天运行时 +- 形成第一条“对话安装 skill”闭环 -后果: +### Wave 3 -- skill 看起来“装上了”,实际运行时找不到脚本和模板 +- `M3 + I1` -控制: +目标: +- 补齐 UI、真实 smoke、失败态与打包态验收 -- 安装器必须强制校验 skill 目录,不接受单文件安装 +## 11. 验收标准 -### 风险 2:覆盖用户已有 skill 目录 +### 11.1 功能验收 -后果: +1. 在 `ClawX` 能触发安装的同一句话,在 `zn-ai` 上也能触发等价安装行为。 +2. `zn-ai` 对话里能看到工具执行轨迹,而不是只有普通文本回复。 +3. GitHub skill 链接安装成功后,skill 会出现在 Skills 页并处于启用状态。 +4. Marketplace 安装与 GitHub 链接安装共用同一套安装语义和错误处理。 -- 覆盖用户定制内容 +### 11.2 失败态验收 -控制: +1. 无效 GitHub 链接有明确提示。 +2. 目录缺 `SKILL.md` 有明确提示。 +3. 目标目录已存在时有明确覆盖语义。 +4. OpenClaw / browser / CLI 不可用时有明确失败原因。 -- 默认禁止覆盖 -- 返回 “destination exists” -- 只有 `force=true` 才允许升级或替换 +### 11.3 真实链路验收 -### 风险 3:路径文案与真实目录不一致 +1. 开发态通过。 +2. 打包态通过。 +3. 不依赖 mocked `gateway:rpc` 的真实 Gateway smoke 通过。 +4. 文档、测试与实现保持一致,不再出现“文档假设已同构,但代码实际上还没接通”的情况。 -后果: +## 12. 当前不建议的误区 -- 手动安装指导错误 +- 不建议把问题继续理解成“只差一个 GitHub URL installer”。 +- 不建议只在聊天里再加一条字符串正则特判,就宣布已和 `ClawX` 同构。 +- 不建议只补页面 API,不补 runtime context 和 tool protocol。 +- 不建议用 mocked UI 回归替代真实 Gateway / runtime / browser smoke。 -控制: +## 13. 本轮计划结论 -- 所有 install 相关提示统一使用 `~/.openclaw/skills` - -### 风险 4:scope 扩散 - -后果: - -- 为了 install 迁移,顺带重做整页 Skills 管理 - -控制: - -- install-only 原则 -- 不改 detail/config 非必要逻辑 - -## 10. 完成标准 - -迁移完成后,至少满足以下标准: - -- `zn-ai` 运行时包含可用的 `clawhub` 执行器 -- Skills 页面可安装 marketplace skill -- Skills 页面可通过 GitHub skill 链接安装 skill -- 对话中贴 GitHub skill 链接可触发安装 -- 安装结果会落到 `~/.openclaw/skills/` -- `openclaw.json` 中会自动启用该 skill -- 安装完成后,技能列表能刷新并可见 -- 失败态提示明确,不再把错误归因为泛化的 “Install failed” +本轮结论很明确: +- `zn-ai` 已经有安装 skill 的底层能力。 +- `ClawX` 的优势主要不在 installer,而在完整的聊天运行时上下文和工具执行闭环。 +- 如果目标是 `100%` 同构,推荐按 `7` 个 sub-agent 的编制推进,先补 `Runtime Context + Tool Protocol`,再做 `skills.install` 对话桥接,最后收口到 UI 和真实 smoke。 diff --git a/electron/gateway/browser-shortcut.ts b/electron/gateway/browser-shortcut.ts index 40a787e..916dfa9 100644 --- a/electron/gateway/browser-shortcut.ts +++ b/electron/gateway/browser-shortcut.ts @@ -1,7 +1,7 @@ import logManager from '@electron/service/logger'; import { extractBrowserOpenIntent, openUrlInBrowser } from '@electron/service/browser-open-service'; import { appendTranscriptLine } from '@electron/utils/token-usage-writer'; -import type { RawMessage } from '@runtime/shared/chat-model'; +import type { RawMessage, ToolStatus } from '@runtime/shared/chat-model'; import { sessionStore } from './session-store'; import type { GatewayEvent } from './types'; @@ -22,6 +22,21 @@ async function processBrowserOpen( broadcast: (event: GatewayEvent) => void, ) { let assistantText = ''; + const toolCallId = `browser.open_url:${runId}`; + const startedAt = Date.now(); + let finalToolStatus: ToolStatus | null = null; + + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: 'browser.open_url', + status: 'running', + updatedAt: startedAt, + summary: `Opening ${url}`, + input: { url }, + }); try { const result = await openUrlInBrowser(url, { signal }); @@ -29,11 +44,61 @@ async function processBrowserOpen( return; } assistantText = buildBrowserOpenResponseText(result); + finalToolStatus = { + id: toolCallId, + toolCallId, + name: 'browser.open_url', + status: 'completed', + updatedAt: Date.now(), + durationMs: Date.now() - startedAt, + summary: assistantText, + input: { url }, + result, + }; + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: 'browser.open_url', + status: finalToolStatus.status, + updatedAt: finalToolStatus.updatedAt, + durationMs: finalToolStatus.durationMs, + summary: finalToolStatus.summary, + input: finalToolStatus.input, + result: finalToolStatus.result, + }); } catch (error) { if (signal.aborted) { return; } assistantText = buildBrowserOpenErrorText(error); + finalToolStatus = { + id: toolCallId, + toolCallId, + name: 'browser.open_url', + status: 'error', + updatedAt: Date.now(), + durationMs: Date.now() - startedAt, + summary: assistantText, + input: { url }, + result: { + error: error instanceof Error ? error.message : String(error), + }, + }; + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: 'browser.open_url', + status: finalToolStatus.status, + updatedAt: finalToolStatus.updatedAt, + durationMs: finalToolStatus.durationMs, + summary: finalToolStatus.summary, + input: finalToolStatus.input, + result: finalToolStatus.result, + }); } sessionStore.clearActiveRun(sessionKey); @@ -42,6 +107,7 @@ async function processBrowserOpen( role: 'assistant', content: assistantText, timestamp: Date.now(), + _toolStatuses: finalToolStatus ? [finalToolStatus] : undefined, }; sessionStore.appendMessage(sessionKey, finalMessage); diff --git a/electron/gateway/event-dispatch.ts b/electron/gateway/event-dispatch.ts index e33667c..4e11262 100644 --- a/electron/gateway/event-dispatch.ts +++ b/electron/gateway/event-dispatch.ts @@ -27,6 +27,12 @@ export function dispatchProtocolEvent( case 'ready': emitter.emit('gateway:ready', payload); break; + case GatewayEventType.TOOL_CALL_STARTED: + emitter.emit('tool:status', { status: 'running', payload }); + break; + case GatewayEventType.TOOL_CALL_COMPLETED: + emitter.emit('tool:status', { status: 'completed', payload }); + break; default: emitter.emit('notification', { method: event, params: payload }); } @@ -44,6 +50,12 @@ export function dispatchJsonRpcNotification( case GatewayEventType.MESSAGE_RECEIVED: emitter.emit('chat:message', notification.params as { message: unknown }); break; + case GatewayEventType.TOOL_CALL_STARTED: + emitter.emit('tool:status', { status: 'running', payload: notification.params }); + break; + case GatewayEventType.TOOL_CALL_COMPLETED: + emitter.emit('tool:status', { status: 'completed', payload: notification.params }); + break; case GatewayEventType.ERROR: { const errorData = notification.params as { message?: string }; emitter.emit('error', new Error(errorData.message || 'Gateway error')); diff --git a/electron/gateway/handlers/chat.ts b/electron/gateway/handlers/chat.ts index b0c76ef..a983ade 100644 --- a/electron/gateway/handlers/chat.ts +++ b/electron/gateway/handlers/chat.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { createProvider } from '@electron/providers'; import type { BaseProvider } from '@electron/providers/BaseProvider'; import { providerApiService } from '@electron/service/provider-api-service'; @@ -9,21 +8,57 @@ import { sessionStore } from '../session-store'; import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types'; import { appendTranscriptLine } from '@electron/utils/token-usage-writer'; import { maybeHandleBrowserOpenMessage } from '../browser-shortcut'; +import { maybeHandleSkillInstallMessage } from '../skill-install-shortcut'; +import { buildRuntimeContextMessages } from '../runtime-context'; +import { createRandomId } from '../random-id'; export interface GatewayChatMessage { role: 'system' | 'user' | 'assistant' | 'tool'; content: string; } +function flattenMessageContent(content: RawMessage['content']): string { + if (typeof content === 'string') { + return content; + } + + return content + .map((block) => { + if (!block || typeof block !== 'object') { + return ''; + } + + if (block.type === 'text' && typeof block.text === 'string') { + return block.text; + } + + if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.content === 'string') { + return block.content; + } + + if ((block.type === 'tool_result' || block.type === 'toolResult') && Array.isArray(block.content)) { + return flattenMessageContent(block.content as RawMessage['content']); + } + + return ''; + }) + .filter(Boolean) + .join('\n'); +} + function buildChatMessages(sessionMessages: RawMessage[]): GatewayChatMessage[] { return sessionMessages .map((msg): GatewayChatMessage | null => { if (!msg.role || !msg.content) return null; const role = msg.role; + const content = flattenMessageContent(msg.content).trim(); + if (!content) { + return null; + } if (role === 'user' || role === 'assistant' || role === 'system') { return { role, - content: typeof msg.content === 'string' ? msg.content : '', + content, }; } // Skip toolresult and unsupported roles for now @@ -114,7 +149,7 @@ export function handleChatSend( ): GatewayRpcReturns['chat.send'] { const sessionKey = normalizeAgentSessionKey(params.sessionKey); const { message, options } = params; - const runId = randomUUID(); + const runId = createRandomId(); // 1. Append user message const userMessage: RawMessage = { @@ -136,6 +171,10 @@ export function handleChatSend( return { runId }; } + if (maybeHandleSkillInstallMessage(sessionKey, runId, userMessage, broadcast)) { + return { runId }; + } + // 2. Resolve provider account const accountId = options?.providerAccountId || providerApiService.getDefault().accountId; if (!accountId) { @@ -154,7 +193,10 @@ export function handleChatSend( // 3. Build messages array from session history const session = sessionStore.getOrCreate(sessionKey); - const messages = buildChatMessages(session.messages); + const messages = [ + ...buildRuntimeContextMessages(sessionKey), + ...buildChatMessages(session.messages), + ]; // 4. Start streaming const abortController = new AbortController(); diff --git a/electron/gateway/handlers/skills.ts b/electron/gateway/handlers/skills.ts index 36f89fb..6274efd 100644 --- a/electron/gateway/handlers/skills.ts +++ b/electron/gateway/handlers/skills.ts @@ -1,5 +1,22 @@ +import { ClawHubService } from '@electron/gateway/clawhub'; +import { + SkillInstallService, + SkillInstallServiceError, + type SkillInstallRequest, +} from '@electron/service/skill-install-service'; import { getAllSkillConfigs, updateSkillConfig } from '@electron/utils/skill-config'; -import type { GatewayRpcReturns } from '../types'; +import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types'; + +type GatewayBroadcast = (event: GatewayEvent) => void; + +function broadcastSkillsRuntimeChanged(broadcast?: GatewayBroadcast, reason = 'skills:changed'): void { + broadcast?.({ + type: 'runtime:changed', + topics: ['skills'], + reason, + syncedAt: new Date().toISOString(), + }); +} export async function handleSkillsStatus(): Promise { const configs = await getAllSkillConfigs(); @@ -43,3 +60,28 @@ export async function handleSkillsUpdate( return { success: true }; } + +export async function handleSkillsInstall( + params: GatewayRpcParams['skills.install'], + broadcast?: GatewayBroadcast, +): Promise { + const request = params as SkillInstallRequest; + const installService = new SkillInstallService({ + clawHubService: new ClawHubService(), + }); + + try { + const result = await installService.install(request); + broadcastSkillsRuntimeChanged( + broadcast, + `skills:install:${result.source}:${result.slug}`, + ); + return result; + } catch (error) { + if (error instanceof SkillInstallServiceError) { + throw new Error(error.message); + } + + throw error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 62fe672..3454b40 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { createServer } from 'node:net'; import { join } from 'node:path'; @@ -63,6 +62,7 @@ import { import { GatewayStateController, type GatewayRuntimeStatus } from './state'; import { connectGatewaySocket, waitForGatewayReady } from './ws-client'; import { dispatchGatewayRpcMethod } from './rpc-dispatch'; +import { createRandomId } from './random-id'; type RuntimeChangeBroadcast = { topics: RuntimeRefreshTopic[]; @@ -95,6 +95,11 @@ type GatewayNotificationEvent = params?: unknown; }; +type GatewayToolStatusNotification = { + status: 'running' | 'completed' | 'error'; + payload?: unknown; +}; + export interface GatewayManagerEvents { status: (status: GatewayRuntimeStatus) => void; message: (message: unknown) => void; @@ -104,6 +109,7 @@ export interface GatewayManagerEvents { 'channel:status': (data: { channelId: string; status: string }) => void; 'chat:message': (data: { message: unknown }) => void; 'gateway:ready': (payload: unknown) => void; + 'tool:status': (payload: GatewayToolStatusNotification) => void; } function isRecord(value: unknown): value is Record { @@ -243,6 +249,78 @@ function extractTextFromGatewayPayload(payload: Record): string return ''; } +function getRecordStringField(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + + return undefined; +} + +function getRecordNumberField(record: Record, ...keys: string[]): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + + return undefined; +} + +function normalizeGatewayToolStatus(value: unknown): 'running' | 'completed' | 'error' { + if (typeof value !== 'string') { + return 'completed'; + } + + switch (value.trim().toLowerCase()) { + case 'running': + case 'completed': + case 'error': + return value.trim().toLowerCase() as 'running' | 'completed' | 'error'; + case 'failed': + return 'error'; + default: + return 'completed'; + } +} + +function normalizeToolStatusEvent(value: GatewayToolStatusNotification): GatewayEvent | null { + const payload = isRecord(value.payload) ? value.payload : {}; + const sessionKey = getRecordStringField(payload, 'sessionKey', 'session_key'); + const runId = getRecordStringField(payload, 'runId', 'run_id'); + const toolName = getRecordStringField(payload, 'toolName', 'tool_name', 'name'); + + if (!sessionKey || !runId || !toolName) { + return null; + } + + const toolCallId = getRecordStringField(payload, 'toolCallId', 'tool_call_id', 'id'); + const summary = getRecordStringField(payload, 'summary', 'message'); + const durationMs = getRecordNumberField(payload, 'durationMs', 'duration_ms'); + const errorMessage = getRecordStringField(payload, 'error'); + const status = errorMessage + ? 'error' + : normalizeGatewayToolStatus(payload.status ?? value.status); + + return { + type: 'tool:status', + sessionKey: normalizeAgentSessionKey(sessionKey), + runId, + toolCallId, + toolName, + status, + updatedAt: normalizeTimestamp(payload.timestamp) ?? Date.now(), + summary: errorMessage ?? summary, + durationMs, + input: payload.input ?? payload.arguments ?? payload.args, + result: payload.result, + }; +} + function buildGatewayRpcError(error: unknown, fallback: string): Error { if (typeof error === 'string' && error.trim()) { return new Error(error); @@ -292,7 +370,7 @@ export class GatewayManager extends EventEmitter { private readonly processOwner = new OpenClawProcessOwner(); private readonly pendingRequests = new Map(); private readonly deltaSnapshots = new Map(); - private gatewayToken = randomUUID(); + private gatewayToken = createRandomId(); private socket: WebSocket | null = null; private child: GatewayProcessHandle | null = null; private port: number | null = null; @@ -343,6 +421,7 @@ export class GatewayManager extends EventEmitter { override on(eventName: 'channel:status', listener: GatewayManagerEvents['channel:status']): this; override on(eventName: 'chat:message', listener: GatewayManagerEvents['chat:message']): this; override on(eventName: 'gateway:ready', listener: GatewayManagerEvents['gateway:ready']): this; + override on(eventName: 'tool:status', listener: GatewayManagerEvents['tool:status']): this; override on(eventName: string | symbol, listener: (...args: any[]) => void): this; override on(eventName: string | symbol, listener: (...args: any[]) => void): this { return super.on(eventName, listener); @@ -356,6 +435,7 @@ export class GatewayManager extends EventEmitter { override once(eventName: 'channel:status', listener: GatewayManagerEvents['channel:status']): this; override once(eventName: 'chat:message', listener: GatewayManagerEvents['chat:message']): this; override once(eventName: 'gateway:ready', listener: GatewayManagerEvents['gateway:ready']): this; + override once(eventName: 'tool:status', listener: GatewayManagerEvents['tool:status']): this; override once(eventName: string | symbol, listener: (...args: any[]) => void): this; override once(eventName: string | symbol, listener: (...args: any[]) => void): this { return super.once(eventName, listener); @@ -369,6 +449,7 @@ export class GatewayManager extends EventEmitter { override off(eventName: 'channel:status', listener: GatewayManagerEvents['channel:status']): this; override off(eventName: 'chat:message', listener: GatewayManagerEvents['chat:message']): this; override off(eventName: 'gateway:ready', listener: GatewayManagerEvents['gateway:ready']): this; + override off(eventName: 'tool:status', listener: GatewayManagerEvents['tool:status']): this; override off(eventName: string | symbol, listener: (...args: any[]) => void): this; override off(eventName: string | symbol, listener: (...args: any[]) => void): this { return super.off(eventName, listener); @@ -414,6 +495,13 @@ export class GatewayManager extends EventEmitter { } }); + this.on('tool:status', (payload) => { + const event = normalizeToolStatusEvent(payload); + if (event) { + this.broadcast(event); + } + }); + this.on('error', (error) => { logManager.debug('GatewayManager emitted error event', error); }); diff --git a/electron/gateway/protocol.ts b/electron/gateway/protocol.ts index 52117c9..1d99d42 100644 --- a/electron/gateway/protocol.ts +++ b/electron/gateway/protocol.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createRandomId } from './random-id'; export interface JsonRpcRequest { jsonrpc: '2.0'; @@ -67,7 +67,7 @@ export function createRequest( ): JsonRpcRequest { return { jsonrpc: '2.0', - id: id ?? randomUUID(), + id: id ?? createRandomId(), method, params, }; diff --git a/electron/gateway/random-id.ts b/electron/gateway/random-id.ts new file mode 100644 index 0000000..3d2b6bf --- /dev/null +++ b/electron/gateway/random-id.ts @@ -0,0 +1,7 @@ +export function createRandomId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} diff --git a/electron/gateway/rpc-dispatch.ts b/electron/gateway/rpc-dispatch.ts index bd65805..556af2c 100644 --- a/electron/gateway/rpc-dispatch.ts +++ b/electron/gateway/rpc-dispatch.ts @@ -74,6 +74,14 @@ export function dispatchGatewayRpcMethod( handled: true, result: skillHandlers.handleSkillsUpdate(params), }; + case 'skills.install': + return { + handled: true, + result: skillHandlers.handleSkillsInstall( + params as GatewayRpcParams['skills.install'], + broadcast, + ), + }; default: return { handled: false }; } diff --git a/electron/gateway/runtime-context.ts b/electron/gateway/runtime-context.ts new file mode 100644 index 0000000..f040e6d --- /dev/null +++ b/electron/gateway/runtime-context.ts @@ -0,0 +1,28 @@ +import type { GatewayChatMessage } from '@electron/providers/BaseProvider'; + +const AGENT_RUNTIME_CONTEXT = [ + 'You are zn-ai, a desktop AI assistant running through a local OpenClaw-style gateway.', + 'Treat host tools as explicit capabilities with real side effects.', + 'Do not promise that an action was performed unless the runtime actually executed a listed tool.', +].join(' '); + +const TOOL_RUNTIME_CONTEXT = [ + 'Available host tools in this build:', + '- browser.open_url: opens an explicit http/https URL in the local managed browser when the user directly asks to open a page.', + '- skills.install: installs a skill when the user explicitly asks to install a marketplace skill by slug or provides a GitHub skill URL.', + 'Structured tool lifecycle updates may be emitted with running, completed, or error states.', + 'Only claim a skill was installed after the runtime reports the tool completed successfully.', +].join('\n'); + +export function buildRuntimeContextMessages(sessionKey: string): GatewayChatMessage[] { + return [ + { + role: 'system', + content: [ + AGENT_RUNTIME_CONTEXT, + TOOL_RUNTIME_CONTEXT, + `Current session key: ${sessionKey}`, + ].join('\n\n'), + }, + ]; +} diff --git a/electron/gateway/skill-install-shortcut.ts b/electron/gateway/skill-install-shortcut.ts new file mode 100644 index 0000000..dca6eb3 --- /dev/null +++ b/electron/gateway/skill-install-shortcut.ts @@ -0,0 +1,233 @@ +import logManager from '@electron/service/logger'; +import type { SkillInstallRequest, SkillInstallResult } from '@electron/service/skill-install-service'; +import { parseGitHubSkillUrl } from '@electron/service/skill-install-service'; +import { appendTranscriptLine } from '@electron/utils/token-usage-writer'; +import type { RawMessage, ToolStatus } from '@runtime/shared/chat-model'; +import { handleSkillsInstall } from './handlers/skills'; +import { sessionStore } from './session-store'; +import type { GatewayEvent } from './types'; + +const GITHUB_URL_PATTERN = /https:\/\/github\.com\/[^\s)"',。!?;:]+/giu; +const INSTALL_VERB_PATTERN = /(安装|装上|装一下|帮我安装|帮我装|请安装|\binstall\b)/iu; +const MARKETPLACE_INSTALL_PATTERNS = [ + /^\s*(?:请)?(?:帮我)?(?:安装|装上|装一下)\s+(?:这个\s*)?(?:skill|技能)\s*[::]?\s*["“']?([a-z0-9][a-z0-9._-]*)["”']?\s*$/iu, + /^\s*(?:请)?(?:帮我)?(?:安装|装上|装一下)\s+["“']?([a-z0-9][a-z0-9._-]*)["”']?\s*(?:这个)?\s*(?:skill|技能)\s*$/iu, + /^\s*install\s+(?:the\s+)?(?:skill\s+)?["']?([a-z0-9][a-z0-9._-]*)["']?\s*$/iu, +] as const; + +export interface SkillInstallIntent { + request: SkillInstallRequest; + description: string; +} + +function stripTrailingPunctuation(value: string): string { + return value.trim().replace(/[)\]}>.,!?;:'",。!?;:)】》]+$/u, ''); +} + +function extractGitHubSkillUrl(text: string): string | null { + const matches = text.match(GITHUB_URL_PATTERN) ?? []; + for (const match of matches) { + const candidate = stripTrailingPunctuation(match); + try { + const parsed = parseGitHubSkillUrl(candidate); + return parsed.originalUrl; + } catch { + continue; + } + } + + return null; +} + +function describeInstallResult(result: SkillInstallResult): string { + return `已安装并启用 skill ${result.slug}(${result.source})。位置:${result.baseDir}`; +} + +function describeInstallFailure(error: unknown): string { + return `安装失败:${error instanceof Error ? error.message : String(error)}`; +} + +export function extractSkillInstallIntent(text: string): SkillInstallIntent | null { + const trimmed = String(text || '').trim(); + if (!trimmed || !INSTALL_VERB_PATTERN.test(trimmed)) { + return null; + } + + const githubUrl = extractGitHubSkillUrl(trimmed); + if (githubUrl) { + const parsed = parseGitHubSkillUrl(githubUrl); + return { + request: { + kind: 'github-url', + url: parsed.originalUrl, + }, + description: parsed.defaultSlug, + }; + } + + for (const pattern of MARKETPLACE_INSTALL_PATTERNS) { + const match = pattern.exec(trimmed); + if (!match?.[1]) { + continue; + } + + return { + request: { + kind: 'marketplace', + slug: match[1], + }, + description: match[1], + }; + } + + return null; +} + +async function processSkillInstall( + sessionKey: string, + runId: string, + intent: SkillInstallIntent, + signal: AbortSignal, + broadcast: (event: GatewayEvent) => void, +) { + const toolCallId = `skills.install:${runId}`; + const startedAt = Date.now(); + let finalToolStatus: ToolStatus | null = null; + + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: 'skills.install', + status: 'running', + updatedAt: startedAt, + summary: `Installing ${intent.description}`, + input: intent.request, + }); + + let assistantText = ''; + + try { + const result = await handleSkillsInstall(intent.request, broadcast); + if (signal.aborted) { + return; + } + + assistantText = describeInstallResult(result); + finalToolStatus = { + id: toolCallId, + toolCallId, + name: 'skills.install', + status: 'completed', + updatedAt: Date.now(), + durationMs: Date.now() - startedAt, + summary: assistantText, + input: intent.request, + result, + }; + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: 'skills.install', + status: finalToolStatus.status, + updatedAt: finalToolStatus.updatedAt, + durationMs: finalToolStatus.durationMs, + summary: finalToolStatus.summary, + input: finalToolStatus.input, + result: finalToolStatus.result, + }); + } catch (error) { + if (signal.aborted) { + return; + } + + assistantText = describeInstallFailure(error); + finalToolStatus = { + id: toolCallId, + toolCallId, + name: 'skills.install', + status: 'error', + updatedAt: Date.now(), + durationMs: Date.now() - startedAt, + summary: assistantText, + input: intent.request, + result: { + error: error instanceof Error ? error.message : String(error), + }, + }; + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: 'skills.install', + status: finalToolStatus.status, + updatedAt: finalToolStatus.updatedAt, + durationMs: finalToolStatus.durationMs, + summary: finalToolStatus.summary, + input: finalToolStatus.input, + result: finalToolStatus.result, + }); + } + + sessionStore.clearActiveRun(sessionKey); + + const finalMessage: RawMessage = { + role: 'assistant', + content: assistantText, + timestamp: Date.now(), + _toolStatuses: finalToolStatus ? [finalToolStatus] : undefined, + }; + sessionStore.appendMessage(sessionKey, finalMessage); + + appendTranscriptLine(sessionKey, { + type: 'message', + timestamp: new Date().toISOString(), + message: { + role: 'assistant', + content: assistantText, + tool: 'skills.install', + }, + }); + + broadcast({ + type: 'chat:final', + sessionKey, + runId, + message: finalMessage, + }); +} + +export function maybeHandleSkillInstallMessage( + sessionKey: string, + runId: string, + message: RawMessage, + broadcast: (event: GatewayEvent) => void, +): boolean { + const intent = typeof message.content === 'string' + ? extractSkillInstallIntent(message.content) + : null; + + if (!intent) { + return false; + } + + const abortController = new AbortController(); + sessionStore.setActiveRun(sessionKey, runId, abortController); + + processSkillInstall(sessionKey, runId, intent, abortController.signal, broadcast).catch((error) => { + logManager.error('Unexpected error in processSkillInstall:', error); + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: 'chat:error', + sessionKey, + runId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + return true; +} diff --git a/electron/gateway/types.ts b/electron/gateway/types.ts index c9ce17d..efe2420 100644 --- a/electron/gateway/types.ts +++ b/electron/gateway/types.ts @@ -5,7 +5,10 @@ export type RuntimeRefreshTopic = | 'models' | 'agents' | 'channels' - | 'channel-targets'; + | 'channel-targets' + | 'skills'; + +export type GatewayToolStatus = 'running' | 'completed' | 'error'; /// Gateway 向 Renderer 推送的事件类型 export type GatewayEvent = @@ -32,6 +35,19 @@ export type GatewayEvent = sessionKey: string; runId: string; } + | { + type: 'tool:status'; + sessionKey: string; + runId: string; + toolName: string; + status: GatewayToolStatus; + updatedAt: number; + toolCallId?: string; + summary?: string; + durationMs?: number; + input?: unknown; + result?: unknown; + } | { type: 'gateway:status'; status: 'connected' | 'disconnected' | 'reconnecting'; @@ -73,6 +89,16 @@ export interface GatewayRpcParams { skillKey: string; enabled?: boolean; }; + 'skills.install': { + kind: 'marketplace'; + slug: string; + version?: string; + force?: boolean; + } | { + kind: 'github-url'; + url: string; + force?: boolean; + }; } /// Gateway RPC 方法返回值映射 @@ -103,4 +129,11 @@ export interface GatewayRpcReturns { }>; }; 'skills.update': { success: boolean }; + 'skills.install': { + success: true; + slug: string; + baseDir: string; + source: 'marketplace' | 'github-url'; + enabled: true; + }; } diff --git a/runtime-shared/shared/chat-model.ts b/runtime-shared/shared/chat-model.ts index d4676bf..5d903ef 100644 --- a/runtime-shared/shared/chat-model.ts +++ b/runtime-shared/shared/chat-model.ts @@ -42,6 +42,7 @@ export interface RawMessage { question?: string[]; toolCall?: Record | null; _attachedFiles?: AttachedFileMeta[]; + _toolStatuses?: ToolStatus[]; } export interface ToolStatus { @@ -52,6 +53,8 @@ export interface ToolStatus { durationMs?: number; summary?: string; updatedAt: number; + input?: unknown; + result?: unknown; } export interface ChatSession { diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index cfb60b1..d1855ef 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -1,7 +1,22 @@ -import { memo, useEffect, useState, useRef } from 'react'; -import { Check, ChevronRight, Copy, ImageIcon, Paperclip, Sparkles } from 'lucide-react'; +import { memo, useEffect, useRef, useState } from 'react'; +import { + AlertCircle, + BookOpen, + Check, + CheckCircle2, + ChevronRight, + Copy, + FolderOpen, + ImageIcon, + Link2, + Loader2, + Paperclip, + Wrench, +} from 'lucide-react'; import type { ChatMessageItem } from './types'; +import type { ToolStatus } from '../../shared/chat-model'; import { useI18n } from '../../i18n'; +import { apiOpenSkillPath, apiOpenSkillReadme } from '../../lib/skills-api'; import ChatEmptyState from './ChatEmptyState'; import aiAvatar from '../../assets/images/ai_avatar.png'; import meAvatar from '../../assets/images/me_avatar.png'; @@ -10,6 +25,7 @@ type ChatMessageListProps = { messages: ChatMessageItem[]; loading?: boolean; showWelcomeState?: boolean; + streamingTools?: ToolStatus[]; }; function cn(...classes: Array) { @@ -89,16 +105,379 @@ function AssistantMeta({ ); } -function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageListProps) { +function formatToolDuration(durationMs?: number): string | null { + if (!durationMs || !Number.isFinite(durationMs)) return null; + if (durationMs < 1000) return `${Math.round(durationMs)}ms`; + return `${(durationMs / 1000).toFixed(1)}s`; +} + +type TranslateFn = ReturnType['t']; + +type ToolDetail = { + label: string; + value: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getRecordString(value: unknown, ...keys: string[]): string | undefined { + if (!isRecord(value)) return undefined; + + for (const key of keys) { + const field = value[key]; + if (typeof field === 'string' && field.trim()) { + return field.trim(); + } + } + + return undefined; +} + +function getToolDisplayName(name: string, t: TranslateFn): string { + switch (name) { + case 'skills.install': + return t('conversation.messageList.toolNames.skillsInstall'); + case 'browser.open_url': + return t('conversation.messageList.toolNames.browserOpenUrl'); + default: + return name || t('conversation.messageList.toolNames.unknown'); + } +} + +function getToolStatusLabel(status: ToolStatus['status'], t: TranslateFn): string { + switch (status) { + case 'running': + return t('conversation.messageList.toolStatus.running'); + case 'completed': + return t('conversation.messageList.toolStatus.completed'); + case 'error': + return t('conversation.messageList.toolStatus.error'); + default: + return status; + } +} + +function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] { + if (tool.name === 'skills.install') { + const slug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug'); + const source = getRecordString(tool.result, 'source') || getRecordString(tool.input, 'kind'); + const baseDir = getRecordString(tool.result, 'baseDir'); + const requestUrl = getRecordString(tool.input, 'url'); + const error = getRecordString(tool.result, 'error'); + const details: ToolDetail[] = []; + + if (slug) { + details.push({ label: t('conversation.messageList.toolFields.skill'), value: slug }); + } + if (source) { + details.push({ label: t('conversation.messageList.toolFields.source'), value: source }); + } + if (baseDir) { + details.push({ label: t('conversation.messageList.toolFields.path'), value: baseDir }); + } + if (requestUrl) { + details.push({ label: t('conversation.messageList.toolFields.request'), value: requestUrl }); + } + if (tool.status === 'error' && error) { + details.push({ label: t('conversation.messageList.toolFields.error'), value: error }); + } + + return details; + } + + if (tool.name === 'browser.open_url') { + const link = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url'); + const title = getRecordString(tool.result, 'title'); + const error = getRecordString(tool.result, 'error'); + const details: ToolDetail[] = []; + + if (link) { + details.push({ label: t('conversation.messageList.toolFields.link'), value: link }); + } + if (title) { + details.push({ label: t('conversation.messageList.toolFields.title'), value: title }); + } + if (tool.status === 'error' && error) { + details.push({ label: t('conversation.messageList.toolFields.error'), value: error }); + } + + return details; + } + + const error = getRecordString(tool.result, 'error'); + return error ? [{ label: t('conversation.messageList.toolFields.error'), value: error }] : []; +} + +function shouldHideAssistantToolSummary(message: ChatMessageItem): boolean { + if (message.role !== 'assistant' || !message.toolStatuses || message.toolStatuses.length !== 1) { + return false; + } + + const summary = message.toolStatuses[0]?.summary?.trim(); + const content = message.content.trim(); + return Boolean(summary && content && summary === content); +} + +function ToolActionButton({ + label, + icon: Icon, + onClick, + disabled, + busy, +}: { + label: string; + icon: typeof FolderOpen; + onClick: () => void; + disabled?: boolean; + busy?: boolean; +}) { + return ( + + ); +} + +function ToolResultCard({ + tool, +}: { + tool: ToolStatus; +}) { + const { t } = useI18n(); + const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); + const [busyAction, setBusyAction] = useState(null); + const [copiedAction, setCopiedAction] = useState(null); + const duration = formatToolDuration(tool.durationMs); + const isRunning = tool.status === 'running'; + const isError = tool.status === 'error'; + const details = buildToolDetails(tool, t); + const skillKey = getRecordString(tool.result, 'skillKey', 'slug') || getRecordString(tool.input, 'slug'); + const skillSlug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug'); + const skillBaseDir = getRecordString(tool.result, 'baseDir'); + const browserUrl = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url'); + + async function handleAction( + actionKey: string, + task: () => Promise, + successMessage?: string, + ) { + setBusyAction(actionKey); + setFeedback(null); + try { + await task(); + if (successMessage) { + setFeedback({ kind: 'success', text: successMessage }); + } + } catch (error) { + setFeedback({ + kind: 'error', + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusyAction(null); + } + } + + async function handleCopy(actionKey: string, value: string, successMessage: string) { + if (!navigator?.clipboard?.writeText) { + setFeedback({ + kind: 'error', + text: t('conversation.messageList.toolActions.copyUnavailable'), + }); + return; + } + + setFeedback(null); + try { + await navigator.clipboard.writeText(value); + setCopiedAction(actionKey); + setFeedback({ kind: 'success', text: successMessage }); + window.setTimeout(() => { + setCopiedAction((current) => (current === actionKey ? null : current)); + }, 1500); + } catch (error) { + setFeedback({ + kind: 'error', + text: error instanceof Error ? error.message : String(error), + }); + } + } + + return ( +
+
+
+ {isRunning ? ( + + ) : isError ? ( + + ) : ( + + )} + + {getToolDisplayName(tool.name, t)} +
+
+ {getToolStatusLabel(tool.status, t)} +
+ {duration ? {duration} : null} +
+ + {tool.summary ? ( +

+ {tool.summary} +

+ ) : null} + + {details.length > 0 ? ( +
+ {details.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
+ ) : null} + + {tool.name === 'skills.install' && !isRunning && (skillKey || skillBaseDir) ? ( +
+
+ {t('conversation.messageList.toolNextActions')} +
+
+ { + void handleAction( + 'open-folder', + () => apiOpenSkillPath(skillKey || skillSlug || '', skillSlug, skillBaseDir), + t('conversation.messageList.toolActions.openedFolder'), + ); + }} + /> + { + void handleAction( + 'open-readme', + () => apiOpenSkillReadme(skillKey || skillSlug || '', skillSlug, skillBaseDir), + t('conversation.messageList.toolActions.openedReadme'), + ); + }} + /> + {skillBaseDir ? ( + { + void handleCopy( + 'copy-path', + skillBaseDir, + t('conversation.messageList.toolActions.copiedPath'), + ); + }} + /> + ) : null} +
+
+ ) : null} + + {tool.name === 'browser.open_url' && !isRunning && browserUrl ? ( +
+
+ {t('conversation.messageList.toolNextActions')} +
+
+ { + void handleCopy( + 'copy-url', + browserUrl, + t('conversation.messageList.toolActions.copiedUrl'), + ); + }} + /> +
+
+ ) : null} + + {feedback ? ( +
+ {feedback.text} +
+ ) : null} +
+ ); +} + +function ToolResultCards({ + tools, + heading, +}: { + tools: ToolStatus[]; + heading?: string; +}) { + return ( +
+ {heading ? ( +
+ {heading} +
+ ) : null} + {tools.map((tool) => ( + + ))} +
+ ); +} + +function ChatMessageList({ messages, loading, showWelcomeState, streamingTools = [] }: ChatMessageListProps) { const containerRef = useRef(null); const { t } = useI18n(); - const shouldShowWelcomeState = !loading && (showWelcomeState || messages.length === 0); + const shouldShowWelcomeState = !loading && streamingTools.length === 0 && (showWelcomeState || messages.length === 0); + const hasStreamingAssistantMessage = messages.some((message) => message.role === 'assistant' && message.isStreaming); + const shouldRenderStandaloneToolStatus = streamingTools.length > 0 && !hasStreamingAssistantMessage; useEffect(() => { const container = containerRef.current; if (!container) return; container.scrollTop = container.scrollHeight; - }, [loading, messages]); + }, [loading, messages, streamingTools]); return (
@@ -130,19 +509,32 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis > {message.role === 'assistant' && message.isStreaming ? ( <> -
- {t('conversation.messageList.streaming')} -
- + {streamingTools.length > 0 ? ( + + ) : ( + <> +
+ {t('conversation.messageList.streaming')} +
+ + + )} ) : null} + {message.role === 'assistant' && message.toolStatuses && message.toolStatuses.length > 0 ? ( + + ) : null} + {message.role === 'user' && message.attachments && message.attachments.length > 0 ? (
{message.attachments.map((attachment, index) => ( @@ -155,7 +547,7 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
) : null} - {message.content ? ( + {message.content && !shouldHideAssistantToolSummary(message) ? (
))} + {shouldRenderStandaloneToolStatus ? ( +
+
+ aiAvatar +
+
+ +
+
+ ) : null}
); diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 6351ca6..53df546 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -1,4 +1,4 @@ -import type { AttachedFileMeta } from '../../shared/chat-model'; +import type { AttachedFileMeta, ToolStatus } from '../../shared/chat-model'; export type TaskTabValue = 'pending' | 'completed'; @@ -20,6 +20,7 @@ export type ChatMessageItem = { time: string; content: string; attachments?: AttachedFileMeta[]; + toolStatuses?: ToolStatus[]; isStreaming?: boolean; isError?: boolean; }; diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index e0a2fca..133cf95 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -20,6 +20,39 @@ "loading": "Loading conversation...", "emptyHint": "Start a new conversation by typing your question. Existing messages and streaming responses will appear here directly.", "streaming": "Generating reply...", + "toolRunning": "Running tools...", + "toolNextActions": "Next actions", + "toolStatus": { + "running": "Running", + "completed": "Completed", + "error": "Failed" + }, + "toolNames": { + "skillsInstall": "Install Skill", + "browserOpenUrl": "Open Webpage", + "unknown": "Tool execution" + }, + "toolFields": { + "skill": "Skill", + "source": "Source", + "path": "Install path", + "request": "Request", + "link": "Link", + "title": "Page title", + "error": "Error details" + }, + "toolActions": { + "openFolder": "Open folder", + "openReadme": "Open README", + "copyPath": "Copy path", + "copyUrl": "Copy link", + "copied": "Copied", + "copiedPath": "Path copied", + "copiedUrl": "Link copied", + "openedFolder": "Folder opened", + "openedReadme": "README opened", + "copyUnavailable": "Copy is unavailable in this environment" + }, "assistantBadge": "AI", "userBadge": "Me" }, diff --git a/src/i18n/locales/th/chat.json b/src/i18n/locales/th/chat.json index d777e11..a58fd0d 100644 --- a/src/i18n/locales/th/chat.json +++ b/src/i18n/locales/th/chat.json @@ -20,6 +20,39 @@ "loading": "กำลังโหลดเนื้อหาการสนทนา...", "emptyHint": "พิมพ์คำถามเพื่อเริ่มการสนทนาใหม่ ข้อความเดิมและคำตอบแบบสตรีมจะแสดงที่นี่โดยตรง", "streaming": "กำลังสร้างคำตอบ...", + "toolRunning": "กำลังเรียกใช้เครื่องมือ...", + "toolNextActions": "การดำเนินการถัดไป", + "toolStatus": { + "running": "กำลังทำงาน", + "completed": "เสร็จสิ้น", + "error": "ล้มเหลว" + }, + "toolNames": { + "skillsInstall": "ติดตั้ง Skill", + "browserOpenUrl": "เปิดหน้าเว็บ", + "unknown": "การเรียกใช้เครื่องมือ" + }, + "toolFields": { + "skill": "Skill", + "source": "แหล่งที่มา", + "path": "ตำแหน่งติดตั้ง", + "request": "คำขอ", + "link": "ลิงก์", + "title": "ชื่อหน้า", + "error": "รายละเอียดข้อผิดพลาด" + }, + "toolActions": { + "openFolder": "เปิดโฟลเดอร์", + "openReadme": "เปิด README", + "copyPath": "คัดลอกพาธ", + "copyUrl": "คัดลอกลิงก์", + "copied": "คัดลอกแล้ว", + "copiedPath": "คัดลอกพาธแล้ว", + "copiedUrl": "คัดลอกลิงก์แล้ว", + "openedFolder": "เปิดโฟลเดอร์แล้ว", + "openedReadme": "เปิด README แล้ว", + "copyUnavailable": "สภาพแวดล้อมนี้ไม่รองรับการคัดลอก" + }, "assistantBadge": "AI", "userBadge": "ฉัน" }, diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index c8cd7bd..c3560e2 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -20,6 +20,39 @@ "loading": "正在加载会话内容...", "emptyHint": "输入你的问题开始一段新对话,现有消息和流式响应都会直接显示在这里。", "streaming": "正在生成回复...", + "toolRunning": "正在执行工具...", + "toolNextActions": "后续动作", + "toolStatus": { + "running": "运行中", + "completed": "已完成", + "error": "失败" + }, + "toolNames": { + "skillsInstall": "安装 Skill", + "browserOpenUrl": "打开网页", + "unknown": "工具执行" + }, + "toolFields": { + "skill": "Skill", + "source": "来源", + "path": "安装目录", + "request": "请求", + "link": "链接", + "title": "页面标题", + "error": "错误细节" + }, + "toolActions": { + "openFolder": "打开目录", + "openReadme": "打开 README", + "copyPath": "复制路径", + "copyUrl": "复制链接", + "copied": "已复制", + "copiedPath": "路径已复制", + "copiedUrl": "链接已复制", + "openedFolder": "已打开目录", + "openedReadme": "已打开 README", + "copyUnavailable": "当前环境不支持复制" + }, "assistantBadge": "AI", "userBadge": "我" }, diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 7a2f00e..6285001 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -146,6 +146,7 @@ function mapMessages( time: getMessageTime(locale, message.timestamp), content: text, attachments: message._attachedFiles, + toolStatuses: message._toolStatuses, isError: Boolean(message.isError), }; }); @@ -228,6 +229,7 @@ export default function HomePage() { const chatMessages = useChatStore((state) => state.messages); const chatLoading = useChatStore((state) => state.loading); const chatStreamingMessage = useChatStore((state) => state.streamingMessage); + const chatStreamingTools = useChatStore((state) => state.streamingTools); const chatSessions = useChatStore((state) => state.sessions); const chatCurrentSessionKey = useChatStore((state) => state.currentSessionKey); const chatCurrentAgentId = useChatStore((state) => state.currentAgentId); @@ -509,6 +511,7 @@ export default function HomePage() { loading={chatLoading} messages={visibleMessages} showWelcomeState={!hasConversationHistory} + streamingTools={chatStreamingTools} /> { if (event.type === 'gateway:status') { setIsGatewayRunning(event.status === 'connected'); + return; + } + + if (isRuntimeChangedGatewayEvent(event) && runtimeEventHasTopic(event, 'skills')) { + void loadSkills(); } }); diff --git a/src/stores/chat.ts b/src/stores/chat.ts index bea909c..07f0451 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -253,11 +253,72 @@ function pruneChatEventDedupe(now: number): void { } } +function mergeToolStatus( + existing: ToolStatus['status'], + incoming: ToolStatus['status'], +): ToolStatus['status'] { + const order: Record = { + running: 0, + completed: 1, + error: 2, + }; + + return order[incoming] >= order[existing] ? incoming : existing; +} + +function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] { + const next = [...current]; + + for (const update of updates) { + const key = update.toolCallId || update.id || update.name; + const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key); + + if (index === -1) { + next.push(update); + continue; + } + + const existing = next[index]; + next[index] = { + ...existing, + ...update, + status: mergeToolStatus(existing.status, update.status), + updatedAt: Math.max(existing.updatedAt, update.updatedAt), + }; + } + + return next; +} + +function attachToolStatuses(message: RawMessage, tools: ToolStatus[]): RawMessage { + if (!tools.length) { + return message; + } + + const merged = upsertToolStatuses(message._toolStatuses || [], tools); + return { + ...message, + _toolStatuses: merged, + }; +} + function buildChatEventDedupeKey(event: GatewayEvent): string | null { const runId = 'runId' in event && typeof event.runId === 'string' ? event.runId : ''; const sessionKey = 'sessionKey' in event && typeof event.sessionKey === 'string' ? event.sessionKey : ''; const type = event.type; if (!runId && !sessionKey && !type) return null; + + if (event.type === 'tool:status') { + return [ + runId, + sessionKey, + type, + event.toolCallId || event.toolName, + event.status, + String(event.updatedAt), + ].join('|'); + } + return `${runId}|${sessionKey}|${type}`; } @@ -864,6 +925,28 @@ async function handleGatewayEvent(event: GatewayEvent): Promise { queueStreamingDelta(event.delta, typeof event.runId === 'string' ? event.runId : undefined); break; } + case 'tool:status': { + const toolUpdate: ToolStatus = { + id: event.toolCallId || event.toolName, + toolCallId: event.toolCallId, + name: event.toolName, + status: event.status, + durationMs: event.durationMs, + summary: event.summary, + updatedAt: event.updatedAt, + input: event.input, + result: event.result, + }; + + patchState({ + sending: true, + error: null, + activeRunId: event.runId || state.activeRunId, + pendingFinal: true, + streamingTools: upsertToolStatuses(state.streamingTools, [toolUpdate]), + }); + break; + } case 'chat:final': { flushPendingStreamingDelta(); @@ -873,17 +956,18 @@ async function handleGatewayEvent(event: GatewayEvent): Promise { content: `${extractText(state.streamingMessage)}${event.message.content}`, } : event.message; + const messageWithTools = attachToolStatuses(composedMessage, state.streamingTools); - const messageId = composedMessage.id || `run-${event.runId || Date.now()}`; - const hasOutput = Boolean(extractText(composedMessage).trim()); - const toolOnly = isToolOnlyMessage(composedMessage); + const messageId = messageWithTools.id || `run-${event.runId || Date.now()}`; + const hasOutput = Boolean(extractText(messageWithTools).trim()); + const toolOnly = isToolOnlyMessage(messageWithTools); if (!state.messages.some((message) => message.id === messageId)) { patchState({ - messages: [...state.messages, { ...composedMessage, id: messageId }], + messages: [...state.messages, { ...messageWithTools, id: messageId }], sessionLastActivity: { ...state.sessionLastActivity, - [state.currentSessionKey]: composedMessage.timestamp ? toMs(composedMessage.timestamp) : Date.now(), + [state.currentSessionKey]: messageWithTools.timestamp ? toMs(messageWithTools.timestamp) : Date.now(), }, streamingMessage: null, streamingTools: [], diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 696d45a..471fbb2 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -7,7 +7,10 @@ export type RuntimeRefreshTopic = | 'models' | 'agents' | 'channels' - | 'channel-targets'; + | 'channel-targets' + | 'skills'; + +export type GatewayToolStatus = 'running' | 'completed' | 'error'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -84,6 +87,19 @@ export type GatewayEvent = sessionKey: string; runId: string; } + | { + type: 'tool:status'; + sessionKey: string; + runId: string; + toolName: string; + status: GatewayToolStatus; + updatedAt: number; + toolCallId?: string; + summary?: string; + durationMs?: number; + input?: unknown; + result?: unknown; + } | { type: 'gateway:status'; status: 'connected' | 'disconnected' | 'reconnecting'; diff --git a/tests/browser-shortcut.test.ts b/tests/browser-shortcut.test.ts index 0204769..4d9ce8a 100644 --- a/tests/browser-shortcut.test.ts +++ b/tests/browser-shortcut.test.ts @@ -82,17 +82,50 @@ describe('gateway browser shortcut', () => { 'http://www.baidu.com/', expect.objectContaining({ signal: expect.any(AbortSignal) }), ); + expect(broadcast).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: 'tool:status', + sessionKey: 'agent:test:main', + runId: 'run-1', + toolName: 'browser.open_url', + status: 'running', + })); + expect(broadcast).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: 'tool:status', + sessionKey: 'agent:test:main', + runId: 'run-1', + toolName: 'browser.open_url', + status: 'completed', + })); expect(mocks.appendMessage).toHaveBeenCalledWith( 'agent:test:main', expect.objectContaining({ role: 'assistant', content: '已为你打开 http://www.baidu.com/(百度一下,你就知道)', + _toolStatuses: [ + expect.objectContaining({ + name: 'browser.open_url', + status: 'completed', + input: { url: 'http://www.baidu.com/' }, + result: expect.objectContaining({ + pageUrl: 'http://www.baidu.com/', + title: '百度一下,你就知道', + }), + }), + ], }), ); - expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + expect(broadcast).toHaveBeenNthCalledWith(3, expect.objectContaining({ type: 'chat:final', sessionKey: 'agent:test:main', runId: 'run-1', + message: expect.objectContaining({ + _toolStatuses: [ + expect.objectContaining({ + name: 'browser.open_url', + status: 'completed', + }), + ], + }), })); }); @@ -113,16 +146,45 @@ describe('gateway browser shortcut', () => { await flushAsyncTasks(); + expect(broadcast).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: 'tool:status', + runId: 'run-2', + toolName: 'browser.open_url', + status: 'running', + })); + expect(broadcast).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: 'tool:status', + runId: 'run-2', + toolName: 'browser.open_url', + status: 'error', + })); expect(mocks.appendMessage).toHaveBeenCalledWith( 'agent:test:main', expect.objectContaining({ role: 'assistant', content: '打开失败:No browser context available', + _toolStatuses: [ + expect.objectContaining({ + name: 'browser.open_url', + status: 'error', + result: { + error: 'No browser context available', + }, + }), + ], }), ); - expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + expect(broadcast).toHaveBeenNthCalledWith(3, expect.objectContaining({ type: 'chat:final', runId: 'run-2', + message: expect.objectContaining({ + _toolStatuses: [ + expect.objectContaining({ + name: 'browser.open_url', + status: 'error', + }), + ], + }), })); }); diff --git a/tests/chat-message-list.test.tsx b/tests/chat-message-list.test.tsx new file mode 100644 index 0000000..16398a9 --- /dev/null +++ b/tests/chat-message-list.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setLocale } from '../src/i18n'; +import ChatMessageList from '../src/components/chat/ChatMessageList'; + +const mocks = vi.hoisted(() => ({ + apiOpenSkillPath: vi.fn(), + apiOpenSkillReadme: vi.fn(), + writeText: vi.fn(), +})); + +vi.mock('../src/lib/skills-api', () => ({ + apiOpenSkillPath: mocks.apiOpenSkillPath, + apiOpenSkillReadme: mocks.apiOpenSkillReadme, +})); + +describe('ChatMessageList', () => { + beforeEach(() => { + setLocale('zh'); + vi.clearAllMocks(); + mocks.apiOpenSkillPath.mockResolvedValue(undefined); + mocks.apiOpenSkillReadme.mockResolvedValue(undefined); + mocks.writeText.mockResolvedValue(undefined); + Object.defineProperty(window.navigator, 'clipboard', { + value: { + writeText: mocks.writeText, + }, + configurable: true, + }); + }); + + it('renders standalone streaming tool status cards when there is no streaming assistant text yet', () => { + render( + , + ); + + expect(screen.getByText('正在执行工具...')).toBeTruthy(); + expect(screen.getByText('安装 Skill')).toBeTruthy(); + expect(screen.getByText('运行中')).toBeTruthy(); + expect(screen.getByText('Installing minimax-xlsx')).toBeTruthy(); + }); + + it('renders streaming tool status cards above a streaming assistant message', () => { + render( + , + ); + + expect(screen.getByText('打开网页')).toBeTruthy(); + expect(screen.getByText('已完成')).toBeTruthy(); + expect(screen.getByText('1.2s')).toBeTruthy(); + expect(screen.getByText('已为你打开 http://www.baidu.com/')).toBeTruthy(); + expect(screen.getByText('已安装完成')).toBeTruthy(); + }); + + it('renders persistent skill-install tool cards with follow-up actions', async () => { + render( + , + ); + + expect(screen.getAllByText('已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx')).toHaveLength(1); + expect(screen.getByText('Skill')).toBeTruthy(); + expect(screen.getByText('/tmp/minimax-xlsx')).toBeTruthy(); + expect(screen.getByText('后续动作')).toBeTruthy(); + + fireEvent.click(screen.getByText('打开目录')); + await waitFor(() => { + expect(mocks.apiOpenSkillPath).toHaveBeenCalledWith( + 'minimax-xlsx', + 'minimax-xlsx', + '/tmp/minimax-xlsx', + ); + }); + + fireEvent.click(screen.getByText('复制路径')); + await waitFor(() => { + expect(mocks.writeText).toHaveBeenCalledWith('/tmp/minimax-xlsx'); + }); + expect(screen.getByText('路径已复制')).toBeTruthy(); + }); +}); diff --git a/tests/chat-runtime-context.test.ts b/tests/chat-runtime-context.test.ts new file mode 100644 index 0000000..e27d338 --- /dev/null +++ b/tests/chat-runtime-context.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const sessionMessages: any[] = []; + + return { + sessionMessages, + providerChat: vi.fn(), + appendMessage: vi.fn((_: string, message: unknown) => { + sessionMessages.push(message); + }), + getOrCreate: vi.fn(() => ({ + key: 'agent:test:main', + messages: [...sessionMessages], + updatedAt: Date.now(), + })), + setActiveRun: vi.fn(), + clearActiveRun: vi.fn(), + appendTranscriptLine: vi.fn(), + maybeHandleBrowserOpenMessage: vi.fn(() => false), + maybeHandleSkillInstallMessage: vi.fn(() => false), + logger: { + error: vi.fn(), + }, + }; +}); + +vi.mock('@electron/providers', () => ({ + createProvider: vi.fn(() => ({ + chat: mocks.providerChat, + })), +})); + +vi.mock('@electron/service/provider-api-service', () => ({ + providerApiService: { + getDefault: () => ({ accountId: 'provider-1' }), + getAccounts: () => [ + { + id: 'provider-1', + model: 'gpt-4o-mini', + vendorId: 'openai', + label: 'OpenAI', + }, + ], + }, +})); + +vi.mock('../electron/gateway/session-store', () => ({ + sessionStore: { + appendMessage: mocks.appendMessage, + getOrCreate: mocks.getOrCreate, + setActiveRun: mocks.setActiveRun, + clearActiveRun: mocks.clearActiveRun, + }, +})); + +vi.mock('@electron/utils/token-usage-writer', () => ({ + appendTranscriptLine: mocks.appendTranscriptLine, +})); + +vi.mock('../electron/gateway/browser-shortcut', () => ({ + maybeHandleBrowserOpenMessage: mocks.maybeHandleBrowserOpenMessage, +})); + +vi.mock('../electron/gateway/skill-install-shortcut', () => ({ + maybeHandleSkillInstallMessage: mocks.maybeHandleSkillInstallMessage, +})); + +vi.mock('@electron/service/logger', () => ({ + default: mocks.logger, +})); + +function createStream(chunks: Array<{ result?: string; usage?: unknown }>) { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +function flushAsyncTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('chat runtime context', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.sessionMessages.length = 0; + mocks.maybeHandleBrowserOpenMessage.mockReturnValue(false); + mocks.maybeHandleSkillInstallMessage.mockReturnValue(false); + mocks.providerChat.mockResolvedValue(createStream([{ result: 'done' }])); + }); + + it('prepends the zn-ai runtime context before provider chat runs', async () => { + const { handleChatSend } = await import('../electron/gateway/handlers/chat'); + + const result = handleChatSend( + { + sessionKey: 'agent:test:main', + message: { + role: 'user', + content: '帮我看一下这个网页', + }, + }, + vi.fn(), + ); + + expect(result.runId).toBeTypeOf('string'); + + await flushAsyncTasks(); + + expect(mocks.providerChat).toHaveBeenCalledTimes(1); + const [messages, model] = mocks.providerChat.mock.calls[0] ?? []; + + expect(model).toBe('gpt-4o-mini'); + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: expect.stringContaining('browser.open_url'), + }), + expect.objectContaining({ + role: 'user', + content: '帮我看一下这个网页', + }), + ]), + ); + expect(messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('skills.install'), + }); + }); +}); diff --git a/tests/gateway-protocol-state.test.ts b/tests/gateway-protocol-state.test.ts index 98f6b05..38cd52b 100644 --- a/tests/gateway-protocol-state.test.ts +++ b/tests/gateway-protocol-state.test.ts @@ -120,6 +120,48 @@ describe('gateway event dispatch', () => { ]); }); + it('dispatches protocol tool lifecycle events onto the normalized tool status channel', () => { + const { emitter, events } = createEmitterRecorder(); + + dispatchProtocolEvent(emitter, GatewayEventType.TOOL_CALL_STARTED, { + sessionKey: 'agent:test:main', + runId: 'run-1', + toolName: 'browser.open_url', + }); + dispatchProtocolEvent(emitter, GatewayEventType.TOOL_CALL_COMPLETED, { + sessionKey: 'agent:test:main', + runId: 'run-1', + toolName: 'browser.open_url', + durationMs: 42, + }); + + expect(events).toEqual([ + { + event: 'tool:status', + payload: { + status: 'running', + payload: { + sessionKey: 'agent:test:main', + runId: 'run-1', + toolName: 'browser.open_url', + }, + }, + }, + { + event: 'tool:status', + payload: { + status: 'completed', + payload: { + sessionKey: 'agent:test:main', + runId: 'run-1', + toolName: 'browser.open_url', + durationMs: 42, + }, + }, + }, + ]); + }); + it('dispatches unknown protocol events to notification listeners', () => { const { emitter, events } = createEmitterRecorder(); @@ -146,6 +188,11 @@ describe('gateway event dispatch', () => { method: GatewayEventType.MESSAGE_RECEIVED, params: { message: { text: 'hello' } }, }); + dispatchJsonRpcNotification(emitter, { + jsonrpc: '2.0', + method: GatewayEventType.TOOL_CALL_STARTED, + params: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' }, + }); dispatchJsonRpcNotification(emitter, { jsonrpc: '2.0', method: GatewayEventType.ERROR, @@ -176,9 +223,24 @@ describe('gateway event dispatch', () => { event: 'chat:message', payload: { message: { text: 'hello' } }, }); - expect(events[4].event).toBe('notification'); - expect(events[5].event).toBe('error'); - expect((events[5].payload as Error).message).toBe('gateway boom'); + expect(events[4]).toEqual({ + event: 'notification', + payload: { + jsonrpc: '2.0', + method: GatewayEventType.TOOL_CALL_STARTED, + params: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' }, + }, + }); + expect(events[5]).toEqual({ + event: 'tool:status', + payload: { + status: 'running', + payload: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' }, + }, + }); + expect(events[6].event).toBe('notification'); + expect(events[7].event).toBe('error'); + expect((events[7].payload as Error).message).toBe('gateway boom'); }); }); diff --git a/tests/gateway-rpc-dispatch.test.ts b/tests/gateway-rpc-dispatch.test.ts index abfcad5..16415c7 100644 --- a/tests/gateway-rpc-dispatch.test.ts +++ b/tests/gateway-rpc-dispatch.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ handleProviderGetDefault: vi.fn(), handleSkillsStatus: vi.fn(), handleSkillsUpdate: vi.fn(), + handleSkillsInstall: vi.fn(), })); vi.mock('../electron/gateway/handlers/chat', () => ({ @@ -29,6 +30,7 @@ vi.mock('../electron/gateway/handlers/provider', () => ({ vi.mock('../electron/gateway/handlers/skills', () => ({ handleSkillsStatus: mocks.handleSkillsStatus, handleSkillsUpdate: mocks.handleSkillsUpdate, + handleSkillsInstall: mocks.handleSkillsInstall, })); describe('dispatchGatewayRpcMethod', () => { @@ -119,8 +121,16 @@ describe('dispatchGatewayRpcMethod', () => { mocks.handleProviderGetDefault.mockReturnValue({ accountId: 'provider-1' }); mocks.handleSkillsStatus.mockReturnValue({ skills: [] }); mocks.handleSkillsUpdate.mockReturnValue({ success: true }); + mocks.handleSkillsInstall.mockResolvedValue({ + success: true, + slug: 'minimax-xlsx', + baseDir: '/tmp/minimax-xlsx', + source: 'github-url', + enabled: true, + }); const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch'); + const broadcast = vi.fn(); expect( dispatchGatewayRpcMethod('provider.getDefault', {}, vi.fn()), @@ -140,6 +150,20 @@ describe('dispatchGatewayRpcMethod', () => { handled: true, result: { success: true }, }); + expect( + dispatchGatewayRpcMethod( + 'skills.install', + { kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md' }, + broadcast, + ), + ).toEqual({ + handled: true, + result: expect.any(Promise), + }); + expect(mocks.handleSkillsInstall).toHaveBeenCalledWith( + { kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md' }, + broadcast, + ); expect( dispatchGatewayRpcMethod('gateway.ping', {}, vi.fn()), ).toEqual({ diff --git a/tests/skill-install-shortcut.test.ts b/tests/skill-install-shortcut.test.ts new file mode 100644 index 0000000..14d5fd6 --- /dev/null +++ b/tests/skill-install-shortcut.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + appendMessage: vi.fn(), + setActiveRun: vi.fn(), + clearActiveRun: vi.fn(), + handleSkillsInstall: vi.fn(), + parseGitHubSkillUrl: vi.fn((url: string) => ({ + owner: 'MiniMax-AI', + repo: 'skills', + ref: 'main', + skillPath: 'skills/minimax-xlsx', + defaultSlug: 'minimax-xlsx', + archiveUrl: 'https://api.github.com/repos/MiniMax-AI/skills/zipball/main', + repositoryUrl: 'https://github.com/MiniMax-AI/skills.git', + originalUrl: url, + })), + appendTranscriptLine: vi.fn(), + logger: { + error: vi.fn(), + }, +})); + +vi.mock('../electron/gateway/session-store', () => ({ + sessionStore: { + appendMessage: mocks.appendMessage, + setActiveRun: mocks.setActiveRun, + clearActiveRun: mocks.clearActiveRun, + }, +})); + +vi.mock('../electron/gateway/handlers/skills', () => ({ + handleSkillsInstall: mocks.handleSkillsInstall, +})); + +vi.mock('@electron/service/skill-install-service', () => ({ + parseGitHubSkillUrl: mocks.parseGitHubSkillUrl, +})); + +vi.mock('@electron/utils/token-usage-writer', () => ({ + appendTranscriptLine: mocks.appendTranscriptLine, +})); + +vi.mock('@electron/service/logger', () => ({ + default: mocks.logger, +})); + +function flushAsyncTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('gateway skill install shortcut', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('extracts a github-url install intent from an explicit install request', async () => { + const { extractSkillInstallIntent } = await import('../electron/gateway/skill-install-shortcut'); + + expect( + extractSkillInstallIntent( + 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill', + ), + ).toEqual({ + request: { + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + }, + description: 'minimax-xlsx', + }); + }); + + it('extracts a marketplace install intent when the user explicitly names a skill slug', async () => { + const { extractSkillInstallIntent } = await import('../electron/gateway/skill-install-shortcut'); + + expect( + extractSkillInstallIntent('帮我安装 minimax-xlsx 这个 skill'), + ).toEqual({ + request: { + kind: 'marketplace', + slug: 'minimax-xlsx', + }, + description: 'minimax-xlsx', + }); + }); + + it('runs a skill install shortcut and emits tool status before the final assistant message', async () => { + mocks.handleSkillsInstall.mockResolvedValue({ + success: true, + slug: 'minimax-xlsx', + baseDir: '/tmp/minimax-xlsx', + source: 'github-url', + enabled: true, + }); + + const { maybeHandleSkillInstallMessage } = await import('../electron/gateway/skill-install-shortcut'); + const broadcast = vi.fn(); + + const handled = maybeHandleSkillInstallMessage( + 'agent:test:main', + 'run-1', + { + role: 'user', + content: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill', + }, + broadcast, + ); + + expect(handled).toBe(true); + expect(mocks.setActiveRun).toHaveBeenCalledWith( + 'agent:test:main', + 'run-1', + expect.any(AbortController), + ); + + await flushAsyncTasks(); + + expect(mocks.handleSkillsInstall).toHaveBeenCalledWith( + { + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + }, + broadcast, + ); + expect(broadcast).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: 'tool:status', + runId: 'run-1', + toolName: 'skills.install', + status: 'running', + })); + expect(broadcast).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: 'tool:status', + runId: 'run-1', + toolName: 'skills.install', + status: 'completed', + })); + expect(mocks.appendMessage).toHaveBeenCalledWith( + 'agent:test:main', + expect.objectContaining({ + role: 'assistant', + content: '已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx', + _toolStatuses: [ + expect.objectContaining({ + name: 'skills.install', + status: 'completed', + input: { + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + }, + result: expect.objectContaining({ + slug: 'minimax-xlsx', + baseDir: '/tmp/minimax-xlsx', + source: 'github-url', + }), + }), + ], + }), + ); + expect(broadcast).toHaveBeenNthCalledWith(3, expect.objectContaining({ + type: 'chat:final', + runId: 'run-1', + message: expect.objectContaining({ + _toolStatuses: [ + expect.objectContaining({ + name: 'skills.install', + status: 'completed', + }), + ], + }), + })); + }); +});