diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 9f6ba8f..19437d3 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-CYkgmH75.js"); +require("./main-Cg0vBGtL.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/dist/index.html b/dist/index.html index ac3ddba..2f7a431 100644 --- a/dist/index.html +++ b/dist/index.html @@ -8,8 +8,8 @@ http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn" /> - - + +
diff --git a/docs/ClawX-Chat-Skill-Runtime-Migration-Checklist.md b/docs/ClawX-Chat-Skill-Runtime-Migration-Checklist.md new file mode 100644 index 0000000..8c88b2b --- /dev/null +++ b/docs/ClawX-Chat-Skill-Runtime-Migration-Checklist.md @@ -0,0 +1,1386 @@ +# zn-ai 对齐 ClawX 的聊天 Skill 运行时迁移清单 + +## 1. 目标 + +让 `zn-ai` 的聊天运行时具备与 `ClawX` 同方向的能力: + +- 用户在聊天中提到某个已启用的 skill 时,模型能把它当成可规划、可调用、可回传结果的运行时能力,而不是只把它当成设置页里的开关。 +- 用户上传文件、引用网页、请求执行动作时,运行时能根据当前已启用 skills 自动规划合适的 skill/tool 链路。 +- 聊天链路能产生真实的 `thinking / tool_use / tool_result / final answer`。 +- 用户能在 UI 中看到执行过程、失败原因和最终结果。 + +首个样板场景仍然是: + +- 用户发送:`使用 minimax-xlsx 这个 skill,帮我分析下` +- 如果会话里已有 `.xlsx/.csv/.tsv` 附件,运行时能识别并把文件交给表格类 skill + +但这份文档的目标已经调整为: + +- 架构上按“所有已启用 skills 都能接入聊天运行时”来设计 +- `xlsx/minimax-xlsx` 只是第一条落地样板,不再作为唯一目标 + +## 2. 范围定义 + +### 2.1 最终目标范围 + +最终目标不是只支持 `minimax-xlsx`,而是支持: + +- 所有已启用 skills 在聊天时可见 +- 所有已启用 skills 可被模型规划 +- 所有已启用 skills 可被运行时调用 +- 所有已启用 skills 的结果可回传给模型并写入会话历史 +- 所有已启用 skills 的执行结果可被 renderer 展示 + +### 2.2 首批交付范围 + +首批交付不要求把每一个 skill 都逐个深度适配,但要求: + +- 运行时架构必须按“全量 skills 通用接入”设计 +- 第一批用 `xlsx/minimax-xlsx` 完成端到端验收 +- 第二批逐步覆盖其他 skill 类型,而不是继续新增特判分支 + +### 2.3 明确不再采用的方向 + +不采用下面这种方案: + +- 只为 `minimax-xlsx` 单独写一条聊天特判链路 +- 只为少数 skill 手工写 prompt 注入和执行代码 +- 让不同 skill 分别走不同的 ad-hoc 执行协议 + +## 3. 全量 skills 通用接入边界与阶段化策略 + +这一节单独回答一个关键问题: + +- 这次迁移是不是要把“所有 skills 都接进聊天运行时”考虑进去? + +答案是: + +- 要考虑进去 +- 但不是要求第一批把每一个 skill 都做成同等质量的深度适配 +- 而是要求从第一天起,架构必须按“全量 skills 可接入”来设计 + +### 3.1 通用接入边界 + +本次迁移的“全量 skills 通用接入”边界如下: + +- 所有已启用 skills 都必须能进入聊天运行时的 capability 列表 +- 所有已启用 skills 都必须能被 planner 看见并参与候选规划 +- 所有已启用 skills 都必须能映射到统一 executor 协议 +- 所有 skills 的执行结果都必须能落入统一的 `tool_result` 结构 +- 所有 skills 的执行过程都必须能被 transcript 和 renderer 消费 + +本次迁移不承诺的边界如下: + +- 不承诺首批就为每一个 skill 做专门的高质量 UI 定制 +- 不承诺首批就为每一个 skill 做类型专属优化 +- 不承诺首批就覆盖所有外部依赖复杂、权限要求高的 skills + +换句话说: + +- 首批必须做到“都能接” +- 但不要求首批“都接得一样深、一样好看、一样聪明” + +### 3.2 `SKILL.md -> runtime capability` 通用抽取规则 + +这是第一项必须补进文档的内容。 + +目标: + +- 不能把 `SKILL.md` 原文直接塞给模型 +- 需要抽成稳定、短小、可规划的 runtime capability + +通用抽取规则建议如下: + +1. 基础身份字段 + - `skillKey` + - `displayName` + - `description` + +2. 能力字段 + - `inputKinds` + - `outputKinds` + - `triggerHints` + - `supportedFileTypes` + +3. 约束字段 + - `requiresFiles` + - `requiresEnv` + - `requiresRuntime` + - `riskLevel` + +4. 执行字段 + - `defaultEntrypoint` + - `callPattern` + - `resultShape` + +抽取优先级建议: + +1. 先读显式元数据 +2. 再读 `SKILL.md` 的标题、description、usage、limitations +3. 最后使用默认推断规则兜底 + +兜底策略: + +- 如果 skill 没有足够结构化信息,也必须能生成最小 capability +- 不能因为 metadata 不完整就把 skill 排除出聊天运行时 + +### 3.3 Skill 分类分层策略 + +这是第二项必须补进文档的内容。 + +目标: + +- 不同 skill 的输入输出模式不同 +- 不能要求所有 skill 共用完全相同的 planner 提示和 executor 适配方式 + +首版推荐分层如下: + +1. 文档/文件类 + - 例:`xlsx`、`minimax-xlsx`、`pdf`、`docx` + - 典型输入:文件、路径、sheet、段落、页码 + - 典型输出:结构化数据、文件、摘要 + +2. 浏览器/网页类 + - 例:browser automation、web browse + - 典型输入:URL、页面元素、交互动作 + - 典型输出:页面快照、DOM 结果、提取数据 + +3. 搜索/检索类 + - 例:web search、knowledge search + - 典型输入:query、filters、time range + - 典型输出:候选结果、摘要、来源 + +4. 命令/脚本类 + - 例:shell、uv、脚本执行型 skill + - 典型输入:命令参数、工作目录、文件引用 + - 典型输出:stdout、stderr、产物文件、退出状态 + +5. 外部 API / 服务类 + - 例:需要第三方 API key 的 skill + - 典型输入:结构化参数、凭据引用、速率限制 + - 典型输出:API 响应、结构化记录、失败状态 + +分层原则: + +- planner 先按 skill type 缩小候选范围 +- executor 再按该 type 的 adapter 执行 +- UI 先展示通用结果,再逐步补 skill type 专属展示 + +### 3.4 Skill 前置检查策略 + +这是第三项必须补进文档的内容。 + +目标: + +- 不是 skill 已启用就一定可执行 +- 聊天运行时必须在规划前或执行前做前置检查 + +每个 skill 的前置检查至少包括: + +1. 依赖检查 + - 本地二进制是否存在 + - Python/Node/uv 运行时是否可用 + - 所需脚本是否存在 + +2. 环境变量检查 + - 必需 env 是否已配置 + - API key 是否存在 + - 敏感变量是否允许在当前运行环境使用 + +3. 权限检查 + - 是否需要文件访问 + - 是否需要网络访问 + - 是否需要 shell/命令执行权限 + - 是否需要浏览器自动化权限 + +4. 输入检查 + - 文件是否存在 + - 文件类型是否受支持 + - 参数是否齐全 + +前置检查的输出必须标准化为: + +- `ready` +- `blocked_missing_dependency` +- `blocked_missing_env` +- `blocked_missing_permission` +- `blocked_invalid_input` + +这一步的意义是: + +- 避免模型盲目调用 skill +- 避免把“权限没给”误判成“skill 不会用” +- 避免用户只看到一句模糊失败文案 + +### 3.5 通用 `tool_result` 标准化与 UI 渲染协议 + +这是第四项必须补进文档的内容。 + +目标: + +- 所有 skill 返回结果必须进入统一 `tool_result` +- renderer 先消费统一协议,再渐进增强不同 skill 类型的展示 + +建议统一结果结构: + +- `ok` +- `summary` +- `structuredData` +- `artifacts` +- `logs` +- `error` +- `retryable` +- `skillType` +- `renderHints` + +其中: + +- `structuredData` 用于模型继续推理 +- `artifacts` 用于文件、图片、表格结果回传 +- `logs` 用于调试和错误诊断 +- `renderHints` 用于 UI 做轻量差异化展示 + +UI 渲染协议建议分两层: + +1. 通用层 + - 所有 skills 都显示状态、摘要、错误、耗时、输入概览 + +2. 类型增强层 + - 文档类显示文件、sheet、提取结果摘要 + - 搜索类显示来源列表和摘要 + - 浏览器类显示页面结果与操作轨迹 + - 命令类显示输出日志与产物 + +原则: + +- 没有类型增强时,通用层也必须完整可用 +- UI 不得依赖某个单 skill 的私有字段才能渲染 + +### 3.6 阶段化策略 + +“全量 skills 通用接入”建议按 4 个阶段推进: + +#### Phase A:通用协议冻结 + +目标: + +- 冻结 capability schema +- 冻结 skill type taxonomy +- 冻结 preflight check 输出 +- 冻结 `tool_result` 标准结构 + +产出: + +- skill registry 契约 +- planner 输入输出契约 +- renderer 消费契约 + +#### Phase B:样板链路落地 + +目标: + +- 用 `xlsx/minimax-xlsx` 跑通文件类样板 +- 验证“启用 -> 可见 -> 可规划 -> 可执行 -> 可回传 -> 可展示” + +要求: + +- 代码实现必须走通用协议 +- 不允许新增只服务于 `minimax-xlsx` 的永久特判 + +#### Phase C:扩展到多 skill 类型 + +目标: + +- 选至少 1 个搜索类 skill +- 选至少 1 个浏览器或命令类 skill +- 验证通用架构不是只对文件类有效 + +要求: + +- 不改聊天主链路,只增加 registry/parser/adapter 配置 + +#### Phase D:规模化接入与收敛 + +目标: + +- 把更多已启用 skills 纳入 capability 列表 +- 补前置检查、错误模型、UI 增强 +- 补自动化回归 + +验收口径: + +- 新增 skill 时,原则上不需要再改 `chat.send` 主编排 +- 如果新增 skill 还需要改主编排,说明架构仍未真正通用 +## 4. 当前主结论 + +当前 `zn-ai` 的主阻塞不是 `minimax-xlsx` 没启用,而是: + +- 聊天运行时没有把已启用 skills 注入成可用能力 +- 聊天主链路仍是“文本 provider 直连”,不是通用 tool/skill runtime +- 附件虽然能进入聊天,但没有稳定进入“技能可消费输入链路” +- renderer 能显示少量工具状态,但没有完整执行闭环 +- 现有设计更接近“个别功能特判”,还不是“所有 skills 通用接入聊天运行时” + +## 5. 验收标准 + +迁移完成后,至少要通过这 6 个场景: + +1. 已启用 `minimax-xlsx`,用户发送 `使用 minimax-xlsx 这个 skill,帮我分析下`,模型不会只返回空泛文本,而会进入 skill/tool 执行链路。 +2. 用户上传 `.xlsx/.csv/.tsv` 后发送同一句话,运行时能把附件作为分析输入,而不是只把文件路径当普通文本。 +3. 用户启用任意其他文档类或搜索类 skill 后,聊天运行时能把该 skill 暴露为能力,而不是只在 Skills 页显示“已启用”。 +4. 聊天中能看到工具执行状态、结果摘要、失败信息;失败时能明确是“文件缺失 / skill 不可见 / provider 不支持 / runtime 执行失败 / skill 依赖未满足”。 +5. 多轮对话里,tool 结果能回灌到后续推理,不会出现“执行过了但模型下一轮不知道结果”。 +6. 新增一个符合规范的 skill 时,不需要再给聊天链路补新的特判代码,只需通过注册/抽取机制接入。 + +## 6. 通用接入原则 + +这一节是本次调整的核心。 + +### 5.1 所有 skills 都必须经过同一条能力链路 + +统一链路: + +1. Skill 发现 +2. Skill 元数据抽取 +3. Runtime capability 注入 +4. Planner 选择 skill/tool +5. Executor 调用 skill/tool +6. Tool result 标准化 +7. Transcript 写回 +8. Renderer 展示 + +任何 skill 都不应该绕过这条链路单独接入聊天。 + +### 5.2 `xlsx/minimax-xlsx` 只是样板,不是特例 + +首批先用它验收,是因为它最能暴露当前缺口: + +- 附件输入 +- 文件访问 +- tool loop +- 结果回灌 +- UI 展示 + +但一旦链路建好,其他 skills 也必须复用同一套机制。 + +### 5.3 先做“通用协议”,再做“具体 skill 适配” + +顺序必须是: + +1. 先冻结通用 skill capability schema +2. 先冻结通用 planner/executor/tool_result 协议 +3. 再做 `xlsx/minimax-xlsx` 样板接入 +4. 再扩展到更多 skills + +不能反过来先做某一个 skill 的特例实现,再倒推通用架构。 + +## 7. 通用能力模型 + +### 6.1 Skill capability schema + +每个已启用 skill 在聊天运行时至少要暴露这些字段: + +- `skillKey` +- `displayName` +- `description` +- `inputKinds` +- `outputKinds` +- `triggerHints` +- `requiresFiles` +- `supportedFileTypes` +- `requiresEnv` +- `requiresRuntime` +- `riskLevel` + +说明: + +- 这里不是把整个 `SKILL.md` 原样塞给模型 +- 而是把它抽成稳定的、可规划的最小能力描述 + +### 6.2 Tool execution schema + +所有 skills 在执行层至少统一到这些结构: + +- `toolName` +- `toolCallId` +- `input` +- `status` +- `result` +- `error` +- `artifacts` +- `summary` + +### 6.3 Tool result schema + +所有 skill/tool 返回结果都要能映射成统一结构: + +- `ok` +- `summary` +- `structuredData` +- `files` +- `logs` +- `error` +- `retryable` + +### 6.4 Transcript schema + +所有 skills 的执行都要进入统一会话记录: + +- `thinking` +- `tool_use` +- `tool_result` +- `assistant final` + +### 6.5 Skills 通用接入数据流图 + +下面这张文字版流程图,定义的是 `registry -> planner -> executor -> tool_result -> transcript -> renderer` 的标准主链路: + +```text +[已启用 skills / 内建 tools / SKILL.md metadata / adapter manifest] + | + v + [Registry] + 产出统一 capability list / tool registry entries / preflight metadata + | + v +[用户消息 / 附件 / 会话历史 / runtime context / capability list] + | + v + [Planner] + 选择 skill/tool,产出 thinking + tool_use plan + 如果当前无需调用 tool,则直接走 assistant final + | + v + [Executor] + 执行 preflight check + 把附件、参数、上下文转成 adapter 可消费输入 + 调用具体 skill/tool adapter,拿到 raw result / artifacts / logs / error + | + v + [tool_result Normalize] + 统一映射成 ok / summary / structuredData / files / logs / error / retryable / renderHints + | + +-------------+-------------+ + | | + v v + [Transcript] [模型续推] + 写回 thinking / tool_use / 把 tool_result 回灌给模型, + tool_result / assistant final 决定继续下一次 tool_use 或输出 final + | + v + [Renderer] + 渲染执行状态、结果卡片、错误原因、产物入口、最终回答 +``` + +实现约束: + +- `registry` 只负责暴露能力,不负责替模型做最终决策 +- `planner` 只负责选什么能力、传什么输入,不直接承担具体执行 +- `executor` 只负责执行与标准化,不负责私自改写会话历史 +- `tool_result` 必须同时服务两条链路:一条给模型续推,一条给 transcript / renderer 展示 +- `transcript` 是多轮续接的唯一可信记录,不能只在 UI 本地保留工具结果 +- `renderer` 优先消费统一协议,再按 skill type 做渐进增强 + +落地时最容易漏掉的环节: + +- 只做了 `registry -> planner -> executor`,但没有把 `tool_result` 回灌模型 +- 只做了 `tool_result -> renderer`,但没有把 `tool_use / tool_result` 写回 transcript +- 只让 `minimax-xlsx` 走这条链路,其他 enabled skills 仍然走特判分支 +- 让 provider 直接内嵌 planner / executor 逻辑,导致通用 runtime 无法复用 + +### 6.6 Skills 通用接入代码文件映射图 + +下面这张图把上一节的数据流,直接映射到当前 `zn-ai` 里已经存在或建议新增的关键文件: + +```text +[Registry capability 装配入口] + runtime-context.ts + 说明:当前这里是 runtime capability 注入边界; + 后续可把真正的 registry 生成下沉到 skill-capability-registry.ts, + 但对 chat.send 的上游入口仍然是这里。 + | + v +[聊天主编排入口] + handlers/chat.ts + 说明:接收用户消息、附件、会话历史,串联 planner / executor / transcript + | + +---------+---------+ + | | + v v +[规划层] [执行层] + skill-planner.ts tool-runtime.ts + 说明: 说明: + - 消费 message - 执行 preflight check + / attachments - 调用 skill/tool adapter + / history - 生成标准化 tool_result + / runtime context - 产出 tool status / artifacts / logs + - 产出 thinking + / tool_use plan + / no-tool decision + | | + +---------+---------+ + | + v +[共享协议层] + chat-model.ts + 说明:定义 thinking / tool_use / tool_result / assistant final + 的 transcript schema 与共享消息结构 + | + v +[渲染层] + ChatMessageList.tsx + 说明:消费 transcript、toolStatuses、attachments、tool_result, + 渲染执行状态、结果摘要、错误信息和最终回答 +``` + +按职责拆到文件后的推荐边界如下: + +- `runtime-context.ts` + - 负责把当前 enabled skills、内建 tools、session 信息装配成模型可见的 runtime context + - 不负责直接决定“这次该调用哪个 skill” + - 不负责写死某个 skill 的执行分支 + +- `handlers/chat.ts` + - 负责主编排,是 `chat.send` 的唯一入口 + - 负责把用户消息、附件、历史、runtime context 串起来 + - 负责调用 `skill-planner.ts` 和 `tool-runtime.ts` + - 负责把 `thinking / tool_use / tool_result / final` 写回 transcript + - 不负责继续堆 `if user says x then call y` 的特判 + +- `skill-planner.ts` + - 负责根据 capability list、附件类型、用户意图、历史上下文决定是否调用 skill/tool + - 输出统一的 planner decision,而不是直接在这里执行 tool + - 如果模型无需调 tool,也要能明确返回 `assistant-final-only` 这类决策 + +- `tool-runtime.ts` + - 负责 preflight check、adapter dispatch、错误归一化、`tool_result` 标准化 + - 负责把 raw result 转成 transcript 和 renderer 都能消费的结构 + - 不负责挑选哪个 tool;那是 planner 的职责 + +- `chat-model.ts` + - 负责冻结共享协议,避免 gateway 和 renderer 各自维护一套 message shape + - 这里必须成为 `thinking / tool_use / tool_result / tool status / attachments` 的统一来源 + - 如果协议扩展,不应该只改 UI 或只改 gateway 单边 + +- `ChatMessageList.tsx` + - 负责把通用 transcript 协议渲染出来 + - 先支持通用 `tool_result` 卡片,再渐进增强不同 skill type 的专属展示 + - 不应该依赖某个 skill 的私有字段才能完成基础渲染 + +这张映射图对应的实施顺序建议是: + +1. 先冻结 `chat-model.ts` 的共享 schema +2. 再补 `skill-planner.ts` 和 `tool-runtime.ts` +3. 然后让 `handlers/chat.ts` 接管主编排 +4. 再把 `runtime-context.ts` 从“固定两三个 tool 描述”升级成“全量 enabled skills capability 注入” +5. 最后让 `ChatMessageList.tsx` 完整消费新的 transcript / tool_result 协议 + +如果后面要继续往下拆实现任务,这 6 个文件基本就能直接映射成 6 条主工作流,不容易再把 registry、planner、executor、renderer 的职责混到一起。 + +## 8. 优先级清单 + +状态回填基线(2026-04-24): + +- 已完成:当前阶段目标已经落地,后续只做增强,不再影响本阶段是否达标 +- 部分完成:主链路已接入或已有样板,但仍有明显缺口,暂不能视为该项收口 +- 未开始:还停留在文档/设计层,或尚未形成有效实现 + +### P0 阻塞项 + +这些必须先补,不补就很难达到目标效果。 + +#### P0-1 建立“全量 enabled skills -> runtime capabilities”注入机制 + +状态:部分完成 + +现状: + +- 已有 capability registry 和 runtime context 注入链路,首批样板能把已启用 skill 暴露给聊天运行时 +- 但“依赖/环境不满足”的统一降级提示还没有完全收口,新增 skill 也还没有做到真正零补丁接入 + +目标: + +- 聊天发送前,运行时拿到当前所有已启用 skills 的能力描述 +- 不是只暴露 `browser.open_url`、`skills.install` +- 首批先验证 `xlsx/minimax-xlsx`,但实现必须支持所有 skills + +当前涉及文件: + +- `zn-ai/electron/gateway/runtime-context.ts` +- `zn-ai/electron/gateway/handlers/chat.ts` +- `zn-ai/electron/gateway/handlers/skills.ts` + +清单: + +- [ ] 在聊天发送前读取当前 enabled skills +- [ ] 为 enabled skills 生成通用 runtime capability 描述 +- [ ] 首批先支持 `xlsx/minimax-xlsx` 样板注入 +- [ ] 确保后续 skill 接入不需要再改聊天主链路 +- [ ] 明确“已启用但依赖/环境不满足”时的降级提示 + +#### P0-2 建立通用 skill metadata 抽取机制 + +状态:已完成 + +现状: + +- capability schema、默认抽取规则、`SKILL.md` 降级抽取和 registry 入口都已落地 +- 更细的 skill type 扩展仍会继续增强,但不影响这一阶段的 metadata 抽取主目标 + +目标: + +- 从 skill 配置和 `SKILL.md` 提取统一 capability schema +- 为所有 skills 提供同一套抽取入口 + +当前涉及文件: + +- `zn-ai/electron/gateway/handlers/skills.ts` +- 新增建议文件:`zn-ai/electron/gateway/skill-capability-registry.ts` +- 新增建议文件:`zn-ai/electron/gateway/skill-capability-parser.ts` + +清单: + +- [ ] 定义 capability schema +- [ ] 定义默认抽取规则 +- [ ] 为缺少结构化元数据的 skill 提供降级抽取策略 +- [ ] 支持后续按 skill 类型扩展抽取器 + +#### P0-3 从“文本 provider”升级到“可执行 tool 的聊天运行时” + +状态:部分完成 + +现状: + +- tool-capable provider/runtime 契约和 planner-first 执行链路已接入,`tool_result` 也能回灌后续回答 +- 但 provider 原生 tool loop 还没有完整打通,`tools/toolChoice` 仍未成为统一主路径 + +目标: + +- 聊天主链路不能只调用 `provider.chat(messages, model)` +- 必须支持模型发起工具调用,运行时执行后再把结果回给模型 + +当前涉及文件: + +- `zn-ai/electron/providers/BaseProvider.ts` +- `zn-ai/electron/providers/OpenAIProvider.ts` +- `zn-ai/electron/gateway/handlers/chat.ts` + +清单: + +- [ ] 设计统一 tool-capable provider/runtime 接口 +- [ ] 支持模型输出 `tool_use` 或等价调用结构 +- [ ] 运行时执行 skill/tool 后生成 `tool_result` +- [ ] 把 `tool_result` 回灌给模型,继续完成最终回答 +- [ ] 保留现有文本流式输出作为降级路径 + +#### P0-4 建立通用 planner / registry,而不是继续堆特判 + +状态:部分完成 + +现状: + +- tool registry、planner、tool runtime 已建立统一入口,`skills.install`、浏览器、`xlsx/minimax-xlsx` 已纳入同一条链路 +- 但新增 skill 仍需要补 adapter,尚未做到“任意已启用 skill 都能直接执行” + +目标: + +- skill 调用不能再靠“遇到某句提示词就手工分支” +- 所有 skills 和内建 tools 都通过统一 registry 暴露给 planner + +当前涉及文件: + +- `zn-ai/electron/gateway/handlers/chat.ts` +- 新增建议文件:`zn-ai/electron/gateway/tool-runtime.ts` +- 新增建议文件:`zn-ai/electron/gateway/tool-registry.ts` +- 新增建议文件:`zn-ai/electron/gateway/skill-planner.ts` + +清单: + +- [ ] 抽象通用 tool registry +- [ ] skill/tool 共用一套注册与调度模型 +- [ ] 把 `skills.install`、浏览器、`xlsx/minimax-xlsx` 纳入统一 planner +- [ ] 后续 skill 不再新增聊天特判 + +#### P0-5 把附件真正接入“通用 skill 输入链路” + +状态:部分完成 + +现状: + +- 文件类样板已能把附件作为结构化输入交给表格分析链路,`.xls` 也已补上本地 fallback +- 但附件输入 schema 还没有沉淀成所有文件类 skill 共用的公共层 + +目标: + +- 对 `.xlsx/.csv/.tsv`,不能只把 `[media attached: ...]` 拼到文本里 +- 附件要变成 planner/executor 可消费的标准输入 +- 设计要对所有“需要文件输入”的 skills 通用 + +当前涉及文件: + +- `zn-ai/src/stores/chat.ts` +- `zn-ai/electron/api/routes/files.ts` +- `zn-ai/electron/gateway/handlers/chat.ts` + +清单: + +- [ ] 为文件类 skill 建立统一附件输入 schema +- [ ] 首批先验证表格类输入 +- [ ] tool/skill 执行时能拿到稳定文件路径或句柄信息 +- [ ] 附件缺失、路径失效时返回结构化错误 + +#### P0-6 把 tool 结果写回会话历史 + +状态:部分完成 + +现状: + +- `tool_use / tool_result` 已写回 transcript,并能参与后续轮次的推理 +- 但大结果摘要、上下文膨胀控制和进一步的历史裁剪策略还没有完全收口 + +目标: + +- 执行过的 skill 结果必须成为会话上下文的一部分 +- 下一轮问“继续分析第 2 个 sheet”时,模型能接上 + +当前涉及文件: + +- `zn-ai/electron/gateway/handlers/chat.ts` +- `zn-ai/src/stores/chat.ts` +- `zn-ai/runtime-shared/shared/chat-model.ts` + +清单: + +- [ ] 在 transcript/session history 中保留 `tool_use / tool_result` +- [ ] 重新定义发送下一轮时的 history flatten 规则 +- [ ] 避免把关键 tool 结果过滤掉 +- [ ] 对大结果做摘要,避免上下文膨胀 + +### P1 重要项 + +这些不一定是第一批 blocker,但会直接影响可用性和稳定性。 + +#### P1-1 建立“聊天 runtime 与 Skills 页”的同步机制 + +状态:部分完成 + +现状: + +- `skills.update/install` 后已能广播 runtime refresh,Skills 页也已经接到这类变化 +- 但聊天 runtime/store 侧还没有完整消费这些变更,热更新边界仍不够清晰 + +当前涉及文件: + +- `zn-ai/electron/gateway/handlers/skills.ts` +- `zn-ai/electron/gateway/types.ts` +- `zn-ai/src/lib/skills-api.ts` + +清单: + +- [ ] `skills.update` 后广播 runtime refresh +- [ ] 聊天 runtime 监听 skills 变化并刷新能力缓存 +- [ ] 明确“需要重启 gateway”与“可热更新”的边界 + +#### P1-2 补齐聊天 UI 的执行可视化 + +状态:部分完成 + +现状: + +- 通用 tool-only 消息和结果卡片已经具备,`browser.open_url`、`skills.install` 有专项展示 +- 但表格分析专项卡片、失败重试和查看详情入口还没有补齐 + +当前涉及文件: + +- `zn-ai/src/stores/chat.ts` +- `zn-ai/src/components/chat/ChatMessageList.tsx` +- `zn-ai/src/pages/Home/index.tsx` + +清单: + +- [ ] 区分普通 assistant 文本和 tool-only 消息 +- [ ] 增加通用 tool 结果展示结构 +- [ ] 首批先做好表格分析类结果卡片 +- [ ] 为失败结果增加重试或查看详情入口 + +#### P1-3 对齐 ClawX 的 workspace/context 注入机制 + +状态:未开始 + +现状: + +- 目前仍以现有 runtime context 注入为主 +- workspace/context 资源目录、统一说明文件和启动期上下文装配还没有正式开工 + +当前参考文件: + +- `ClawX/electron/utils/openclaw-workspace.ts` +- `ClawX/resources/context/AGENTS.clawx.md` +- `ClawX/resources/context/TOOLS.clawx.md` + +清单: + +- [ ] 为 `zn-ai` 增加运行时 context 资源目录 +- [ ] 定义 skill/tool 使用约束说明 +- [ ] 让 agent/workspace 在 gateway 启动后获得一致上下文 + +#### P1-4 明确 provider 能力矩阵 + +状态:未开始 + +现状: + +- provider 契约已经开始具备 tool-capable 方向的定义 +- 但支持矩阵、白名单和成体系的降级策略还没有形成可执行结果 + +清单: + +- [ ] 列出支持 tool loop 的 provider/model 白名单 +- [ ] 不支持时给出明确降级策略 +- [ ] 区分“模型不支持”与“skill 执行失败” + +### P2 增强项 + +这些会提高稳定性、可维护性和长期对齐度。 + +#### P2-1 Skill 类型分层 + +状态:部分完成 + +现状: + +- capability parser 已有轻量分类能力 +- 但 taxonomy 冻结、分类专属 adapter 和跨类型接入标准还没有全部完成 + +目标: + +- 不是所有 skill 都有相同输入输出模式 +- 需要按类型建立统一接入层 + +推荐分类: + +- 文档/文件类 +- 浏览器/网页类 +- 搜索/检索类 +- 命令/脚本类 +- 外部 API 类 + +清单: + +- [ ] 定义 skill type taxonomy +- [ ] 为每类定义默认 input/output adapter +- [ ] 首批至少覆盖文档类与浏览器类 + +#### P2-2 回归测试与 smoke + +状态:部分完成 + +现状: + +- 已有 runtime context、planner、shortcut、chat-tooling 等聚焦单测 +- 但非 `xlsx` skill 回归、`tool_use -> tool_result -> final` 集成测试,以及开发态/打包态 smoke 还未补齐 + +清单: + +- [ ] 单测:已启用 skill 会出现在 runtime context +- [ ] 单测:上传 `.xlsx` 后进入表格分析链路 +- [ ] 单测:新增一个非 `xlsx` skill 也能进入通用 registry +- [ ] 集成测试:`tool_use -> tool_result -> final` +- [ ] smoke:开发态和打包态都验证一次 + +#### P2-3 错误模型和日志分层 + +状态:部分完成 + +现状: + +- shared schema 已具备基础的 tool error/result 结构 +- 但授权、provider、tool、skill、附件的统一错误模型和 UI 提示映射仍未收口 + +清单: + +- [ ] 区分授权错误、provider 错误、tool 错误、skill 错误、附件错误 +- [ ] transcript 中保留最小必要诊断字段 +- [ ] UI 侧映射成用户能理解的提示 + +## 9. 推荐推进顺序 + +当前进度判断: + +- 第一阶段:已基本完成 +- 第二阶段:部分完成 +- 第三阶段:未完成 + +### 第一阶段 + +- P0-1 全量 enabled skills 注入 runtime +- P0-2 通用 skill metadata 抽取 +- P0-3 tool-capable 聊天运行时 +- P0-4 通用 planner / registry +- P0-5 附件进入通用 skill 输入链路 + +完成标志: + +- 架构上已经不再依赖单个 skill 特判 +- `xlsx/minimax-xlsx` 成为第一条跑通的样板链路 + +### 第二阶段 + +- P0-6 tool 结果回灌历史 +- P1-1 runtime 与 Skills 页同步 +- P1-2 聊天 UI 执行可视化 + +完成标志: + +- 用户能稳定看到执行过程,且多轮能接续 + +### 第三阶段 + +- P1-3 workspace/context 对齐 +- P1-4 provider 能力矩阵 +- P2 全部增强项 + +完成标志: + +- 新增 skill 时,聊天运行时不再需要新增 ad-hoc 接入代码 + +## 10. 推荐 sub-agent 编制 + +### 9.1 推荐数量 + +- 推荐:`7` 个 sub-agent +- 最小可行:`5` 个 sub-agent +- 稳妥完整:`8` 个 sub-agent + +解释: + +- 因为这次目标已经从“单个 skill 样板”升级为“全量 skills 通用接入架构” +- 需要单独有人负责 skill metadata/registry,不适合继续塞进原有分工里 + +默认建议采用: + +- `7 个 worker sub-agent + 1 个主协调 agent` + +说明: + +- 主协调 agent 不计入上面的 `sub-agent` 数量 +- 主协调 agent 负责冻结接口、安排波次、收敛冲突、做最终集成 + +### 9.2 7 个 sub-agent 分工 + +#### SA-1 Skill Capability Registry + +职责: + +- 把所有 enabled skills 注入聊天 runtime context +- 定义通用 capability schema +- 负责 skill metadata 抽取与 registry + +主要文件归属: + +- `zn-ai/electron/gateway/runtime-context.ts` +- `zn-ai/electron/gateway/handlers/skills.ts` +- `zn-ai/electron/gateway/types.ts` +- 新增 `zn-ai/electron/gateway/skill-capability-registry.ts` +- 新增 `zn-ai/electron/gateway/skill-capability-parser.ts` + +验收: + +- 任何已启用 skill 都能进入 runtime capability 列表 + +#### SA-2 Provider 与 Tool Runtime + +职责: + +- 把文本 provider 升级为可执行 tool 的聊天运行时 +- 定义 tool call / tool result / resume 推理接口 + +主要文件归属: + +- `zn-ai/electron/providers/BaseProvider.ts` +- `zn-ai/electron/providers/OpenAIProvider.ts` +- 新增 `zn-ai/electron/gateway/tool-runtime.ts` + +验收: + +- 模型能发起真实 tool 调用,不再只有文本流 + +#### SA-3 Planner / Tool Registry + +职责: + +- 设计通用 planner +- 统一 skill/tool 注册与调度 +- 去掉继续堆特判的趋势 + +主要文件归属: + +- 新增 `zn-ai/electron/gateway/tool-registry.ts` +- 新增 `zn-ai/electron/gateway/skill-planner.ts` +- 必要时补 `zn-ai/electron/gateway/types.ts` + +验收: + +- `skills.install`、浏览器、`xlsx/minimax-xlsx` 都通过统一 registry 接入 + +#### SA-4 Chat Orchestration / Transcript + +职责: + +- 负责 `chat.send` 主编排 +- 把 `tool_use / tool_result / final` 写回会话历史 +- 处理 history flatten 和多轮续接 + +主要文件归属: + +- `zn-ai/electron/gateway/handlers/chat.ts` +- `zn-ai/electron/gateway/session-store.ts` +- `zn-ai/runtime-shared/shared/chat-model.ts` + +验收: + +- 多轮对话能接上前一轮 tool 结果 + +#### SA-5 Attachment / File Skill Input + +职责: + +- 把 `.xlsx/.csv/.tsv` 等文件变成通用 skill 输入 +- 先打通文件类 skill 输入适配层 + +主要文件归属: + +- `zn-ai/src/stores/chat.ts` +- `zn-ai/electron/api/routes/files.ts` + +验收: + +- 文件类 skill 拿到稳定输入,不再只依赖文本路径猜测 + +#### SA-6 Renderer Tool UI + +职责: + +- 聊天 UI 展示 tool 卡片、状态、结果摘要、失败详情 +- 对齐 `pendingFinal`、tool-only 消息和分析过程可视化 + +主要文件归属: + +- `zn-ai/src/components/chat/ChatMessageList.tsx` +- `zn-ai/src/pages/Home/index.tsx` +- 新增聊天执行可视化组件时归此 agent + +验收: + +- 用户能在聊天页看清楚“何时开始分析、分析了什么、结果是什么” + +#### SA-7 Testing / Smoke / Rollout + +职责: + +- 为前 6 个 agent 的改动补回归测试 +- 增加最小集成测试和开发态 smoke +- 记录“新增 skill 是否零特判接入”的验收结果 + +主要文件归属: + +- `zn-ai/tests/chat-runtime-context.test.ts` +- `zn-ai/tests/gateway-rpc-dispatch.test.ts` +- 新增与 skill registry、planner、tool_result 相关测试 +- 需要时补充 `docs/` 中的验收记录 + +验收: + +- 至少有 1 条覆盖“已启用 skill + planner + tool_result + final”的集成链路 +- 至少有 1 条覆盖“新增非 xlsx skill 无需新增聊天特判”的回归验证 + +### 9.3 6 个核心文件实施任务拆解表 + +这张表只聚焦最容易互相卡住的 6 个核心文件。 + +- 辅助文件仍然跟随主责 `sub-agent` 所在分支推进 +- 任何辅助文件的改动,都不应绕开对应核心文件的 owner +- `chat-model.ts` 虽然归 `SA-4` 主责,但必须以“共享契约先冻结”方式提前进入 Wave 1 + +| 核心文件 | 主要实施任务 | 主责 sub-agent | 主要协作方 | 前置冻结 / 依赖 | 推荐合并顺序 | +| --- | --- | --- | --- | --- | --- | +| `zn-ai/runtime-shared/shared/chat-model.ts` | 冻结 `thinking / tool_use / tool_result / assistant final / tool status / attachments` 的共享 schema,统一 gateway、store、UI 的消息结构 | `SA-4` | `SA-1` `SA-2` `SA-6` | 先评审 schema,再允许其他链路接入;这是后续 `handlers/chat.ts` 和 `ChatMessageList.tsx` 的共同前置 | `0` | +| `zn-ai/electron/gateway/runtime-context.ts` | 把当前 enabled skills、内建 tools、session 信息装配成 runtime capability 注入入口;从“固定工具说明”升级成“全量能力注入” | `SA-1` | `SA-3` `SA-4` | 依赖 capability schema 冻结;与 `skill-capability-registry.ts`、`skill-capability-parser.ts` 一起推进 | `1` | +| `zn-ai/electron/gateway/tool-runtime.ts` | 定义 preflight check、adapter dispatch、raw result -> `tool_result` 标准化、resume 推理接口 | `SA-2` | `SA-3` `SA-4` | 依赖 `chat-model.ts` 的 `tool_result` 结构;需要先冻结 provider / runtime contract | `2` | +| `zn-ai/electron/gateway/skill-planner.ts` | 根据 capability list、附件、历史、用户意图产出统一 planner decision,去掉继续堆特判的趋势 | `SA-3` | `SA-1` `SA-2` `SA-4` | 依赖 runtime capability shape 与 tool runtime contract;与 `tool-registry.ts` 同分支推进 | `3` | +| `zn-ai/electron/gateway/handlers/chat.ts` | 接管 `chat.send` 主编排,串联 runtime context、planner、executor、transcript 写回与多轮续接 | `SA-4` | `SA-2` `SA-3` `SA-5` | 必须等 `runtime-context.ts`、`tool-runtime.ts`、`skill-planner.ts` 的契约稳定后再合并;其他 agent 不直接改这个文件 | `4` | +| `zn-ai/src/components/chat/ChatMessageList.tsx` | 消费统一 transcript / `tool_result` / `toolStatuses`,渲染 tool 卡片、错误、摘要、最终回答 | `SA-6` | `SA-4` `SA-7` | 必须等 `chat-model.ts` 与 `handlers/chat.ts` 稳定后接入;优先做通用渲染,再做类型增强 | `5` | + +推荐按下面的节奏合并: + +1. 先合并 `chat-model.ts` 的 schema 冻结 PR。 +2. 再并行推进 `runtime-context.ts`、`tool-runtime.ts`、`skill-planner.ts`,但以契约评审通过为合并门槛。 +3. 然后由 `SA-4` 合并 `handlers/chat.ts`,把前三者正式编排进主链路。 +4. 最后由 `SA-6` 合并 `ChatMessageList.tsx`,避免 UI 反向驱动协议。 + +这 6 个文件对应的辅助文件跟随关系建议如下: + +- `runtime-context.ts` + 对应辅助文件:`skill-capability-registry.ts`、`skill-capability-parser.ts`、必要时 `handlers/skills.ts` +- `tool-runtime.ts` + 对应辅助文件:`BaseProvider.ts`、`OpenAIProvider.ts` +- `skill-planner.ts` + 对应辅助文件:`tool-registry.ts`、必要时 `gateway/types.ts` +- `handlers/chat.ts` + 对应辅助文件:`session-store.ts`、必要时 `src/stores/chat.ts` +- `ChatMessageList.tsx` + 对应辅助文件:`src/pages/Home/index.tsx`、新增 tool result 展示组件 + +如果要把冲突降到最低,可以把这张表当成 branch / PR 边界: + +- `SA-1` 不直接改 `handlers/chat.ts`,而是通过 capability schema 和 registry 模块接入 +- `SA-2` 不在 provider 内重做 planner,而是只冻结 runtime contract +- `SA-3` 不直接改 UI,只输出 planner decision 与 registry 结构 +- `SA-4` 只在前三项契约稳定后改 `handlers/chat.ts` +- `SA-6` 只消费共享协议,不反向决定 gateway 数据结构 + +## 11. 波次安排 + +### Wave 1 + +当前状态:已完成 + +现状: + +- 核心契约、registry、planner 和 tool runtime 已经冻结并落地 +- 第一条样板链路已经跑通,不再停留在纯设计阶段 + +- `SA-1` Skill Capability Registry +- `SA-2` Provider 与 Tool Runtime +- `SA-3` Planner / Tool Registry + +目标: + +- 先冻结最核心接口:skill capability schema、tool runtime contract、planner contract + +### Wave 2 + +当前状态:部分完成 + +现状: + +- transcript 回写和样板附件链路已经进入主聊天编排 +- 但 runtime 与 Skills 页的同步,以及表格专项 UI 可视化还没有全部完成 + +- `SA-5` Attachment / File Skill Input +- `SA-4` Chat Orchestration / Transcript + +目标: + +- 在已冻结契约上,把样板链路接入主聊天编排 + +### Wave 3 + +当前状态:部分完成 + +现状: + +- 通用 tool UI 和一部分回归验证已经启动 +- 但 provider 能力矩阵、完整 smoke 和 rollout 收口还没有完成 + +- `SA-6` Renderer Tool UI +- `SA-7` Testing / Smoke / Rollout + +目标: + +- 把用户体验和回归验证补齐 + +## 12. 协作约束 + +为避免冲突,建议遵守这 5 条: + +1. 每个 sub-agent 只改自己负责的文件,不跨写别人的主文件。 +2. `zn-ai/electron/gateway/handlers/chat.ts` 只归 `SA-4`,其他人不要直接改。 +3. `zn-ai/src/stores/chat.ts` 只归 `SA-5`,UI agent 不要同时改这个文件。 +4. `zn-ai/electron/gateway/types.ts` 由 `SA-1` 牵头冻结,其他 agent 只在评审后接入。 +5. 主协调 agent 统一做接口冻结、合并、回归验证,不让多个 agent 反复改同一入口。 + +## 13. 功能交叉与冲突清单 + +这一节把真正会互相卡住的交叉点单独列出来。 + +### 12.1 冲突 A:`chat.send` 是所有能力的汇合点 + +交叉功能: + +- skill 可见性 +- planner 结果接入 +- tool runtime 执行 +- 附件输入 +- transcript 写回 +- final answer 回传 + +核心文件: + +- `zn-ai/electron/gateway/handlers/chat.ts` + +处理规则: + +- 只允许 `SA-4` 直接修改 +- `SA-1`、`SA-2`、`SA-3`、`SA-5` 通过契约和辅助模块接入 + +### 12.2 冲突 B:`src/stores/chat.ts` 同时承担“附件状态”和“聊天执行状态” + +交叉功能: + +- 附件 staging +- tool 状态消费 +- pendingFinal 状态 +- UI 展示状态 + +核心文件: + +- `zn-ai/src/stores/chat.ts` + +处理规则: + +- `SA-5` 主责 store shape +- `SA-6` 尽量通过 selector 和 helper 消费,不重写 store 结构 + +### 12.3 冲突 C:事件协议同时影响 runtime、store、UI + +交叉功能: + +- `tool:status` +- `chat:final` +- 未来的 `tool_use / tool_result / thinking` +- runtime refresh + +核心文件: + +- `zn-ai/electron/gateway/types.ts` +- `zn-ai/runtime-shared/shared/chat-model.ts` + +处理规则: + +- 先冻结 gateway event contract +- 再冻结 transcript/chat-model contract +- UI 最后接入,不反向驱动协议改名 + +### 12.4 冲突 D:provider 能力和 planner/runtime 边界容易混淆 + +交叉功能: + +- provider 是否支持 tool loop +- runtime 如何继续二次调用模型 +- planner 与 executor 谁负责决策 + +核心文件: + +- `zn-ai/electron/providers/BaseProvider.ts` +- `zn-ai/electron/providers/OpenAIProvider.ts` +- `zn-ai/electron/gateway/tool-runtime.ts` +- `zn-ai/electron/gateway/skill-planner.ts` + +处理规则: + +- `SA-2` 先冻结 provider/runtime 边界 +- `SA-3` 再实现 planner,不在 provider 内重复做编排 + +### 12.5 冲突 E:skill metadata 抽取与 renderer 展示不要耦合 + +交叉功能: + +- `SKILL.md` 抽取 +- capability schema +- UI 上如何显示 skill 名称、说明、输入输出 + +处理规则: + +- `SA-1` 负责 runtime capability schema +- `SA-6` 只能消费 schema,不反过来决定抽取字段 + +## 14. 最小落地建议 + +如果本轮只做最小可行闭环,优先只做这 6 件事: + +1. 聊天发送前读取 enabled skills,并把它们注入 runtime context。 +2. 建立通用 skill capability registry,而不是只处理 `minimax-xlsx`。 +3. 为聊天 runtime 增加最小 tool loop,不再只走纯文本 provider。 +4. 让文件类附件进入统一 skill 输入链路。 +5. 把 `tool_result` 写回 session history,保证多轮可续接。 +6. 在聊天 UI 里把执行过程展示成 tool 卡片,而不是只显示最终一句话。 + +## 15. 非目标 + +这一版清单默认不把下面内容作为首批阻塞项: + +- skill 市场页样式优化 +- 为每一个 skill 都单独做精细化 UI 定制 +- 完整复刻 ClawX 的所有 execution graph 细节 +- 与 skill 运行时无关的 channel/plugin 改造 + +注意: + +- “所有 skills 一次性通用化接入聊天运行时”不再是非目标 +- 但“所有 skills 一次性都达到同等体验质量”仍然不是首批目标 + +## 16. 建议先开工的代码入口 + +- `zn-ai/electron/gateway/handlers/chat.ts` +- `zn-ai/electron/gateway/runtime-context.ts` +- `zn-ai/electron/gateway/handlers/skills.ts` +- `zn-ai/electron/providers/BaseProvider.ts` +- `zn-ai/electron/providers/OpenAIProvider.ts` +- `zn-ai/src/stores/chat.ts` +- `zn-ai/src/components/chat/ChatMessageList.tsx` +- `zn-ai/electron/api/routes/files.ts` +- 新增 `zn-ai/electron/gateway/skill-capability-registry.ts` +- 新增 `zn-ai/electron/gateway/skill-capability-parser.ts` +- 新增 `zn-ai/electron/gateway/tool-registry.ts` +- 新增 `zn-ai/electron/gateway/skill-planner.ts` +## 17. Progress Update (2026-04-24) + +This checkpoint extends the "first batch of real generic skill execution adapters" +beyond `xlsx/minimax-xlsx` and lands the first reusable document-family, +search-family, browser-family, and command-family runtimes. + +Completed in this round: +- `zn-ai/electron/gateway/chat-tooling.ts` + - Added a real document adapter for enabled `docx`, `pptx`, and `pdf` skills. + - Added Node-side analyzers for `.docx`, `.pptx`, and `.pdf`. + - Added real search adapters for `brave-web-search` and `tavily-search`. + - Added a browser-category adapter so browser-capable skills can reuse the managed `browser.open_url` runtime. + - Added a command-category adapter that keeps `find-skills` on the safe ClawHub path and also executes generic command-style skills from safe manifest command templates or a single local `scripts/` entrypoint. + - Reads skill credentials from saved skill config (`apiKey` / `env`) instead of relying only on process env. + - Normalizes Brave and Tavily responses into shared `search-results` payloads and URL artifacts. + - Reused the same `tool_runtime -> tool_result -> transcript` path as spreadsheet analysis. + - Kept unsupported generic skills on capability-aware blocked results instead of silently falling through. +- `zn-ai/src/components/chat/ChatMessageList.tsx` + - Added a dedicated search-result card for query, provider, answer, ranked results, and install commands. + - Reused the same card for command-style discovery results such as `find-skills`. +- `zn-ai/electron/gateway/skill-capability-parser.ts` + - Tightened file-extension inference so URL-like fragments such as `.search` or `.results` no longer become fake input extensions. + - Tightened auth detection to prefer explicit auth/env signals and avoid substring false positives. + - Promoted office-style document skills into the shared `document-analysis` render shape. +- `zn-ai/tests/chat-tooling.test.ts` + - Added execution regressions for `.docx`, `.pptx`, and `.pdf`. + - Added execution regressions for `brave-web-search` and `tavily-search`. + - Added execution regressions for browser-capable skills, `find-skills`, and generic command-style skills that run from manifest command templates or local script entrypoints. + - Kept `.xls` fallback coverage and generic blocked-state coverage. +- `zn-ai/tests/chat-message-list.test.tsx` + - Added UI regressions for dedicated search cards and command-style install-command rendering. +- `zn-ai/tests/skill-capability-parser.test.ts` + - Added parser regressions for document capability classification, URL-like extension filtering, and auth detection. + +Current first-batch runtime coverage: +- Spreadsheet execution: `.xls`, `.xlsx`, `.xlsm`, `.csv`, `.tsv` +- Document execution: `.docx`, `.pptx`, `.pdf` +- Search execution: `brave-web-search`, `tavily-search` +- Browser execution: browser-capable skills that map to explicit URL open flows +- Command execution: `find-skills` through ClawHub search, plus generic command-style skills that expose a safe single-command template or one local `scripts/` entrypoint +- Generic non-document skills without a real adapter: still blocked with explicit reasons such as `missing_required_env`, `user_authorization_required`, or `skill_runtime_not_implemented` + +What is still not done: +- Browser/search/command category executors now exist, but multi-step shell flows, install/login/setup commands, and other unsafe command patterns are still intentionally blocked. Generic command execution is limited to safe single-command templates and local script entrypoints. +- Cross-type adapter manifesting and large-scale skill onboarding are still follow-up work after this checkpoint. + +Verification for this checkpoint: +- `pnpm typecheck` +- `pnpm exec vitest run tests/skill-capability-parser.test.ts tests/chat-tooling.test.ts` +- `pnpm exec vitest run tests/chat-provider-tool-loop.test.ts tests/chat-store-runtime-refresh.test.ts tests/chat-runtime-context.test.ts tests/runtime-context-capabilities.test.ts tests/skill-planner.test.ts tests/chat-tooling.test.ts tests/skill-capability-parser.test.ts tests/chat-message-list.test.tsx` diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 0e53aaa..398b6c3 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -18,4 +18,8 @@ - 重构KnowLedge/index.tsx渲染层、主进程,产品需求如下:上传文件按钮,上传到zn-ai/docs目录,文件列表显示内容:文件名称,文件大小、修改日期、文件类型,操作:删除,视觉UI沿用当前的风格。规划重构计划,估算sub-agent数量,安排sub-agent分工推进工作 -- 在ClawX项目中,通过对话,https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill,能正确安装。在zn-ai项目中,是否也能有同样的功能呢?帮我对比下,如果zn-ai项目没有安装skill的功能,就规划功能迁移计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工推进迁移安装skill功能,跟安装skill无关的功能先不考虑修改调整。 \ No newline at end of file +- 在ClawX项目中,通过对话,https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill,能正确安装。在zn-ai项目中,是否也能有同样的功能呢?帮我对比下,如果zn-ai项目没有安装skill的功能,就规划功能迁移计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工推进迁移安装skill功能,跟安装skill无关的功能先不考虑修改调整。 + +- 在ClawX发起对话,对话内容:"使用minimax-xlsx这个skill,帮我分析下。",它能正确的思考做出数据分析,同样在zn-ai中发起同样的对话内容,就做不到思考做出数据分析,对比一下,zn-ai是缺少了哪些功能,跟用户授权有关系吗?安排多个sub-agent分工对比工作。 + +- 直接给我补一版迁移清单,按优先级列出 zn-ai 要补哪些点,才能接近 ClawX 这种“在聊天里直接调用 minimax-xlsx做分析”的效果。 \ No newline at end of file diff --git a/electron/gateway/browser-shortcut.ts b/electron/gateway/browser-shortcut.ts index 916dfa9..259af0d 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, ToolStatus } from '@runtime/shared/chat-model'; +import type { RawMessage, ToolResultPayload, ToolStatus } from '@runtime/shared/chat-model'; import { sessionStore } from './session-store'; import type { GatewayEvent } from './types'; @@ -25,6 +25,7 @@ async function processBrowserOpen( const toolCallId = `browser.open_url:${runId}`; const startedAt = Date.now(); let finalToolStatus: ToolStatus | null = null; + let finalToolResult: ToolResultPayload | null = null; broadcast({ type: 'tool:status', @@ -44,6 +45,15 @@ async function processBrowserOpen( return; } assistantText = buildBrowserOpenResponseText(result); + finalToolResult = { + ok: true, + summary: assistantText, + structuredData: result, + renderHints: { + card: 'browser-step', + }, + raw: result, + }; finalToolStatus = { id: toolCallId, toolCallId, @@ -73,6 +83,18 @@ async function processBrowserOpen( return; } assistantText = buildBrowserOpenErrorText(error); + finalToolResult = { + ok: false, + summary: assistantText, + error: error instanceof Error ? error.message : String(error), + retryable: true, + renderHints: { + card: 'browser-step', + }, + raw: { + error: error instanceof Error ? error.message : String(error), + }, + }; finalToolStatus = { id: toolCallId, toolCallId, @@ -107,6 +129,7 @@ async function processBrowserOpen( role: 'assistant', content: assistantText, timestamp: Date.now(), + toolResult: finalToolResult, _toolStatuses: finalToolStatus ? [finalToolStatus] : undefined, }; sessionStore.appendMessage(sessionKey, finalMessage); diff --git a/electron/gateway/chat-tooling.ts b/electron/gateway/chat-tooling.ts new file mode 100644 index 0000000..64aa3ab --- /dev/null +++ b/electron/gateway/chat-tooling.ts @@ -0,0 +1,3571 @@ +import { + createToolRuntime, + type ToolRuntime, + type ToolRuntimeAdapter, + type ToolRuntimeContext, + type ToolRuntimeExecutionResult, + type ToolRuntimeInvocation, + type ToolRuntimePreflightResult, +} from './tool-runtime'; +import type { GatewayToolDefinition } from '@electron/providers/BaseProvider'; +import type { SkillCapability } from './skill-capability-parser'; +import type { ToolRegistryCapabilityInput, ToolRegistryEntry } from './tool-registry'; +import type { AttachedFileMeta, ToolArtifact, ToolCallPayload } from '@runtime/shared/chat-model'; + +type NodeFsDirentLike = { + name: string; + isFile(): boolean; +}; + +type NodeFsLike = { + existsSync(path: string): boolean; + readdirSync(path: string, options: { withFileTypes: true }): NodeFsDirentLike[]; +}; + +type NodePathLike = { + join(...parts: string[]): string; +}; + +type NodeChildProcessLike = { + spawn( + command: string, + args?: string[], + options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + windowsHide?: boolean; + signal?: AbortSignal; + } + ): { + stdout: { + on(event: 'data', listener: (chunk: unknown) => void): void; + }; + stderr: { + on(event: 'data', listener: (chunk: unknown) => void): void; + }; + on(event: 'error', listener: (error: Error) => void): void; + on(event: 'close', listener: (code: number | null) => void): void; + }; +}; + +function getBuiltinNodeModule(moduleName: string): T | null { + const getter = typeof process !== 'undefined' + ? (process as NodeJS.Process & { + getBuiltinModule?: (name: string) => unknown; + }).getBuiltinModule + : undefined; + + if (typeof getter !== 'function') { + return null; + } + + try { + return getter(moduleName) as T; + } catch { + return null; + } +} + +const nodeFs = getBuiltinNodeModule('fs'); +const nodePath = getBuiltinNodeModule('path'); +const nodeChildProcess = getBuiltinNodeModule('child_process'); + +function joinPath(...parts: string[]): string { + if (nodePath) { + return nodePath.join(...parts); + } + + return parts + .filter(Boolean) + .join('/') + .replace(/[\\/]+/g, '/'); +} + +type SpreadsheetToolInput = { + prompt?: string; + skillKey?: string; + intent?: string; + attachments?: Array<{ + fileName?: string; + mimeType?: string; + fileSize?: number; + filePath?: string; + source?: AttachedFileMeta['source']; + }>; + filePaths?: string[]; + reuseHistoryAttachment?: boolean; +}; + +type PythonCommandCandidate = { + command: string; + args: string[]; +}; + +type SpreadsheetAnalysisReport = { + filePath: string; + fileName: string; + report: Record; +}; + +type GenericSkillInput = { + prompt?: string; + query?: string; + q?: string; + command?: string; + args?: string[] | string; + skillKey?: string; + capabilityKey?: string; + attachments?: SpreadsheetToolInput['attachments']; + filePaths?: string[]; + reuseHistoryAttachment?: boolean; + count?: number; + maxResults?: number; + depth?: string; + searchDepth?: string; + timeRange?: string; + freshness?: string; + includeDomains?: string[] | string; + excludeDomains?: string[] | string; + topic?: string; + country?: string; + searchLang?: string; + uiLang?: string; + safesearch?: string; + safeSearch?: string; + includeAnswer?: boolean; + includeRawContent?: boolean | string; +}; + +type SpreadsheetCapabilityResolution = { + capability: SkillCapability; + scriptPath?: string; +}; + +type DocumentAnalysisReport = { + filePath: string; + fileName: string; + kind: 'pdf' | 'docx' | 'pptx'; + engine: string; + summary: string; + metadata: Record; + preview: Array>; +}; + +type SkillExecutionConfig = { + apiKey?: string; + env: Record; +}; + +type SearchProvider = 'brave' | 'tavily' | 'clawhub'; + +type SearchExecutionInput = GenericSkillInput & { + provider?: SearchProvider; + query: string; +}; + +type SearchResultItem = { + title: string; + url: string; + snippet?: string; + score?: number; + source?: string; + age?: string; + publishedAt?: string; + slug?: string; + version?: string; + downloads?: number; + stars?: number; + installCommand?: string; +}; + +type SearchExecutionReport = { + provider: SearchProvider; + engine: string; + query: string; + resultCount: number; + answer?: string; + responseTimeMs?: number; + results: SearchResultItem[]; + metadata: Record; +}; + +const PYTHON_COMMAND_CANDIDATES: PythonCommandCandidate[] = process.platform === 'win32' + ? [ + { command: 'python', args: [] }, + { command: 'py', args: ['-3'] }, + { command: 'python3', args: [] }, + ] + : [ + { command: 'python3', args: [] }, + { command: 'python', args: [] }, + ]; + +const SPREADSHEET_FALLBACK_EXTENSIONS = ['.xls', '.xlsx', '.xlsm', '.csv', '.tsv']; +const DOCUMENT_ANALYSIS_EXTENSIONS = ['.pdf', '.docx', '.pptx']; +const BRAVE_DEFAULT_RESULT_COUNT = 5; +const TAVILY_DEFAULT_RESULT_COUNT = 5; +const BRAVE_TIME_RANGE_TO_FRESHNESS: Record = { + day: 'pd', + week: 'pw', + month: 'pm', + year: 'py', +}; +const TAVILY_FRESHNESS_TO_TIME_RANGE: Record = { + pd: 'day', + pw: 'week', + pm: 'month', + py: 'year', +}; + +function dedupeStrings(values: Array): string[] { + return Array.from( + new Set( + values + .map((value) => String(value || '').trim()) + .filter(Boolean), + ), + ); +} + +function normalizeLookup(value: string | undefined | null): string { + return String(value || '').trim().toLowerCase(); +} + +function ensureRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function getTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function coerceInteger( + value: unknown, + fallback: number, + min: number, + max: number, +): number { + const parsed = typeof value === 'number' + ? value + : typeof value === 'string' + ? Number.parseInt(value.trim(), 10) + : NaN; + + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function coerceBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['false', '0', 'no', 'off'].includes(normalized)) { + return false; + } + + return undefined; +} + +function coerceStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return dedupeStrings(value.map((item) => (typeof item === 'string' ? item : String(item || '')))); + } + + if (typeof value === 'string') { + return dedupeStrings(value.split(',')); + } + + return []; +} + +function normalizeSearchQuery(input: GenericSkillInput & Record): string { + return ( + getTrimmedString(input.query) + || getTrimmedString(input.q) + || getTrimmedString(input.prompt) + || '' + ); +} + +function getUrlHostname(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + try { + return new URL(value).hostname; + } catch { + return undefined; + } +} + +function extractExplicitUrl(value: unknown): string | undefined { + const direct = getTrimmedString(value); + if (direct && /^https?:\/\//i.test(direct)) { + return direct; + } + + if (!direct) { + return undefined; + } + + const match = direct.match(/https?:\/\/[^\s)]+/i); + return match?.[0]; +} + +function getInputCommandName(input: GenericSkillInput & Record): string | undefined { + return getTrimmedString(input.command); +} + +function getInputArgs(input: GenericSkillInput & Record): string[] { + if (Array.isArray(input.args)) { + return dedupeStrings(input.args.map((item) => (typeof item === 'string' ? item : String(item || '')))); + } + + if (typeof input.args === 'string') { + return dedupeStrings(input.args.split(/\s+/)); + } + + return []; +} + +function toAttachmentReference(value: AttachedFileMeta | SpreadsheetToolInput['attachments'][number]): AttachedFileMeta { + return { + fileName: value?.fileName || value?.filePath?.split(/[\\/]/).pop() || 'attachment', + mimeType: value?.mimeType || 'application/octet-stream', + fileSize: value?.fileSize || 0, + preview: null, + filePath: value?.filePath, + source: value?.source || 'message-ref', + }; +} + +function extractFilePaths(input: SpreadsheetToolInput | undefined): AttachedFileMeta[] { + const attachments = (input?.attachments || []) + .map((attachment) => toAttachmentReference(attachment)) + .filter((attachment) => attachment.filePath); + + const fromPaths = (input?.filePaths || []) + .map((filePath) => String(filePath || '').trim()) + .filter(Boolean) + .map((filePath) => toAttachmentReference({ filePath })); + + const deduped = new Map(); + for (const attachment of [...attachments, ...fromPaths]) { + const key = normalizeLookup(attachment.filePath || attachment.fileName); + if (!key) { + continue; + } + if (!deduped.has(key)) { + deduped.set(key, attachment); + } + } + + return Array.from(deduped.values()).filter((attachment) => attachment.filePath); +} + +function resolveInvocationAttachments( + input: SpreadsheetToolInput | GenericSkillInput | undefined, + context?: ToolRuntimeContext, +): AttachedFileMeta[] { + const direct = extractFilePaths(input as SpreadsheetToolInput | undefined); + const fromContext = (context?.files || []).filter((attachment) => attachment.filePath); + const deduped = new Map(); + + for (const attachment of [...direct, ...fromContext]) { + const key = normalizeLookup(attachment.filePath || attachment.fileName); + if (!key) { + continue; + } + if (!deduped.has(key)) { + deduped.set(key, attachment); + } + } + + return Array.from(deduped.values()); +} + +function buildSpreadsheetToolSummary(reports: SpreadsheetAnalysisReport[]): string { + if (reports.length === 0) { + return 'Spreadsheet analysis completed with no report output.'; + } + + const parts = reports.map(({ fileName, report }) => { + const structure = ensureRecord(report.structure); + const sheetNames = Object.keys(structure); + const totalRows = Object.values(structure).reduce((sum, sheetValue) => { + const shape = ensureRecord(ensureRecord(sheetValue).shape); + const rows = typeof shape.rows === 'number' ? shape.rows : 0; + return sum + rows; + }, 0); + + return `${fileName}: ${sheetNames.length} sheet(s), ${totalRows} total row(s)`; + }); + + return `Spreadsheet analysis completed. ${parts.join('; ')}`; +} + +function getFileExtension(filePath: string): string { + const match = filePath.toLowerCase().match(/\.[^./\\]+$/); + return match ? match[0] : ''; +} + +function isDocumentAnalysisExtension(filePath: string): boolean { + return DOCUMENT_ANALYSIS_EXTENSIONS.includes(getFileExtension(filePath)); +} + +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '\'') + .replace(/'/g, '\'') + .replace(/&/g, '&'); +} + +function normalizePlainText(value: string): string { + return decodeXmlEntities(value) + .replace(/\s+/g, ' ') + .trim(); +} + +function truncateText(value: string, maxLength = 280): string { + const normalized = normalizePlainText(value); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength - 3).trim()}...`; +} + +function countWords(value: string): number { + const normalized = normalizePlainText(value); + if (!normalized) { + return 0; + } + + return normalized.split(/\s+/).filter(Boolean).length; +} + +function toRecordArray(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter((item): item is Record => item !== null && typeof item === 'object' && !Array.isArray(item)) + : []; +} + +function collectXmlTagText(xml: string, tagName: string): string[] { + const pattern = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'gi'); + return Array.from(xml.matchAll(pattern)) + .map((match) => normalizePlainText(match[1] || '')) + .filter(Boolean); +} + +function extractXmlScalar(xml: string | null, tagNames: string[]): string | undefined { + if (!xml) { + return undefined; + } + + for (const tagName of tagNames) { + const pattern = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'); + const match = xml.match(pattern); + const value = normalizePlainText(match?.[1] || ''); + if (value) { + return value; + } + } + + return undefined; +} + +function extractXmlInteger(xml: string | null, tagNames: string[]): number | undefined { + const scalar = extractXmlScalar(xml, tagNames); + if (!scalar) { + return undefined; + } + + const parsed = Number.parseInt(scalar, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function buildSkillScriptPath(baseDir: string): string { + const separator = baseDir.includes('\\') ? '\\' : '/'; + const normalizedBaseDir = baseDir.replace(/[\\/]+$/, ''); + return `${normalizedBaseDir}${separator}scripts${separator}xlsx_reader.py`; +} + +function isSpreadsheetCapability(capability: SkillCapability): boolean { + if (normalizeLookup(capability.renderHints?.skillType) === 'spreadsheet') { + return true; + } + + const haystack = [ + capability.skillKey, + capability.slug, + capability.name, + capability.description, + capability.category, + ...capability.operationHints, + ...capability.triggerHints, + ...capability.inputExtensions, + ].map((value) => normalizeLookup(value)); + + if (capability.inputExtensions.some((extension) => + ['.xls', '.xlsx', '.xlsm', '.csv', '.tsv', '.ods'].includes(normalizeLookup(extension)) + )) { + return true; + } + + return haystack.some((value) => + value === 'xlsx' + || value === 'minimax-xlsx' + || value.includes('spreadsheet') + || value.includes('excel') + || value.includes('worksheet') + || value.includes('table file') + ); +} + +function findCapabilityByToolName( + capabilities: SkillCapability[], + requestedSkillKey?: string, +): SkillCapability | null { + const normalizedRequested = normalizeLookup(requestedSkillKey); + if (!normalizedRequested) { + return null; + } + + return capabilities.find((capability) => + normalizeLookup(capability.skillKey) === normalizedRequested + || normalizeLookup(capability.slug) === normalizedRequested + ) ?? null; +} + +function findSpreadsheetCapability( + capabilities: SkillCapability[], + requestedSkillKey?: string, +): SpreadsheetCapabilityResolution | null { + const spreadsheetCapabilities = capabilities.filter(isSpreadsheetCapability); + const normalizedRequested = normalizeLookup(requestedSkillKey); + const orderedCapabilities = normalizedRequested + ? [ + ...spreadsheetCapabilities.filter((capability) => normalizeLookup(capability.skillKey) === normalizedRequested), + ...spreadsheetCapabilities.filter((capability) => normalizeLookup(capability.slug) === normalizedRequested), + ...spreadsheetCapabilities.filter((capability) => normalizeLookup(capability.skillKey) !== normalizedRequested && normalizeLookup(capability.slug) !== normalizedRequested), + ] + : spreadsheetCapabilities; + + for (const capability of orderedCapabilities) { + return { + capability, + scriptPath: capability.baseDir ? buildSkillScriptPath(capability.baseDir) : undefined, + }; + } + + return null; +} + +function getSpreadsheetExtension(filePath: string): string { + return getFileExtension(filePath); +} + +function supportsNodeSpreadsheetFallback(filePath: string): boolean { + return SPREADSHEET_FALLBACK_EXTENSIONS.includes(getSpreadsheetExtension(filePath)); +} + +function isMissingPythonRuntimeError(error: Error | null): boolean { + if (!error) { + return false; + } + + const message = error.message.toLowerCase(); + return [ + 'enoent', + 'not found', + 'cannot find', + 'spawn', + 'no usable python command', + 'pyenv', + 'no global/local python version has been set yet', + ].some((pattern) => message.includes(pattern)); +} + +function isUnsupportedPythonSpreadsheetError(error: Error | null): boolean { + if (!error) { + return false; + } + + const message = error.message.toLowerCase(); + return message.includes('legacy binary format not supported') + || message.includes('unsupported file format') + || message.includes('.xls is a legacy binary format'); +} + +async function runPythonJsonScript(scriptPath: string, filePath: string, signal?: AbortSignal): Promise> { + const { spawn } = await import('child_process'); + let lastError: Error | null = null; + + for (const candidate of PYTHON_COMMAND_CANDIDATES) { + try { + const result = await new Promise>((resolve, reject) => { + const child = spawn(candidate.command, [...candidate.args, scriptPath, filePath, '--json'], { + windowsHide: true, + signal, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(stderr.trim() || stdout.trim() || `Python exited with code ${code ?? 'unknown'}`)); + return; + } + + try { + resolve(JSON.parse(stdout) as Record); + } catch (error) { + reject(new Error(`Failed to parse spreadsheet analysis JSON: ${error instanceof Error ? error.message : String(error)}`)); + } + }); + }); + + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const message = lastError.message.toLowerCase(); + if ( + message.includes('enoent') + || message.includes('not found') + || message.includes('cannot find') + || message.includes('spawn') + ) { + continue; + } + break; + } + } + + throw lastError || new Error('No usable Python command was found for spreadsheet analysis.'); +} + +function toSerializableSpreadsheetValue(value: unknown): unknown { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === 'number' && !Number.isFinite(value)) { + return null; + } + + return value ?? null; +} + +function detectSpreadsheetValueType(value: unknown): string { + if (value === null || value === undefined || value === '') { + return 'null'; + } + + if (value instanceof Date) { + return 'date'; + } + + if (Array.isArray(value)) { + return 'array'; + } + + switch (typeof value) { + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'string': + return 'string'; + case 'object': + return 'object'; + default: + return typeof value; + } +} + +function normalizeSpreadsheetRows(rows: Array>, columns: string[]): Array> { + return rows.map((row) => { + const normalized: Record = {}; + for (const column of columns) { + normalized[column] = toSerializableSpreadsheetValue(row[column]); + } + return normalized; + }); +} + +function getSpreadsheetColumns(rows: Array>): string[] { + const seen = new Set(); + const columns: string[] = []; + + for (const row of rows) { + for (const key of Object.keys(row)) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey || seen.has(normalizedKey)) { + continue; + } + seen.add(normalizedKey); + columns.push(normalizedKey); + } + } + + return columns; +} + +function computeSpreadsheetNullColumns( + rows: Array>, + columns: string[], +): Record { + const result: Record = {}; + const rowCount = rows.length; + + for (const column of columns) { + let count = 0; + for (const row of rows) { + const value = row[column]; + if (value === null || value === undefined || value === '') { + count += 1; + } + } + + if (count > 0) { + result[column] = { + count, + pct: Number(((count / Math.max(rowCount, 1)) * 100).toFixed(1)), + }; + } + } + + return result; +} + +function computeSpreadsheetDtypes( + rows: Array>, + columns: string[], +): Record { + const result: Record = {}; + + for (const column of columns) { + const types = new Set(); + + for (const row of rows) { + const type = detectSpreadsheetValueType(row[column]); + if (type !== 'null') { + types.add(type); + } + } + + if (types.size === 0) { + result[column] = 'null'; + continue; + } + + if (types.size === 1) { + result[column] = Array.from(types)[0] || 'unknown'; + continue; + } + + result[column] = 'mixed'; + } + + return result; +} + +function getNumericColumnValues( + rows: Array>, + column: string, +): number[] { + return rows + .map((row) => row[column]) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); +} + +function quantile(sorted: number[], percentile: number): number { + if (sorted.length === 0) { + return NaN; + } + + if (sorted.length === 1) { + return sorted[0] || 0; + } + + const index = (sorted.length - 1) * percentile; + const lower = Math.floor(index); + const upper = Math.ceil(index); + const lowerValue = sorted[lower] ?? sorted[0] ?? 0; + const upperValue = sorted[upper] ?? sorted[sorted.length - 1] ?? lowerValue; + + if (lower === upper) { + return lowerValue; + } + + return lowerValue + (upperValue - lowerValue) * (index - lower); +} + +function computeSpreadsheetStats( + rows: Array>, + columns: string[], +): Record> { + const stats: Record> = {}; + + for (const column of columns) { + const values = getNumericColumnValues(rows, column); + if (values.length === 0) { + continue; + } + + const sorted = [...values].sort((left, right) => left - right); + const total = sorted.reduce((sum, value) => sum + value, 0); + const mean = total / sorted.length; + const variance = sorted.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / sorted.length; + + stats[column] = { + count: sorted.length, + mean: Number(mean.toFixed(4)), + std: Number(Math.sqrt(variance).toFixed(4)), + min: sorted[0] ?? 0, + '25%': Number(quantile(sorted, 0.25).toFixed(4)), + '50%': Number(quantile(sorted, 0.5).toFixed(4)), + '75%': Number(quantile(sorted, 0.75).toFixed(4)), + max: sorted[sorted.length - 1] ?? 0, + }; + } + + return stats; +} + +function computeSpreadsheetQuality( + rows: Array>, + columns: string[], +): Array> { + const findings: Array> = []; + const rowCount = rows.length; + + for (const column of columns) { + const nullCount = rows.reduce((count, row) => { + const value = row[column]; + return value === null || value === undefined || value === '' ? count + 1 : count; + }, 0); + + if (nullCount > 0) { + findings.push({ + type: 'null_values', + column, + count: nullCount, + pct: Number(((nullCount / Math.max(rowCount, 1)) * 100).toFixed(1)), + note: `Column '${column}' has ${nullCount} null value(s).`, + }); + } + } + + const normalizedRows = normalizeSpreadsheetRows(rows, columns); + const duplicates = normalizedRows.length - new Set(normalizedRows.map((row) => JSON.stringify(row))).size; + if (duplicates > 0) { + findings.push({ + type: 'duplicate_rows', + count: duplicates, + note: `${duplicates} fully duplicate row(s) found.`, + }); + } + + for (const column of columns) { + const types = new Set( + rows + .map((row) => detectSpreadsheetValueType(row[column])) + .filter((type) => type !== 'null'), + ); + + if (types.size > 1) { + findings.push({ + type: 'mixed_type', + column, + types: Array.from(types), + note: `Column '${column}' contains mixed types: ${Array.from(types).join(', ')}.`, + }); + } + + const numericValues = getNumericColumnValues(rows, column); + if (numericValues.length < 4) { + continue; + } + + const sorted = [...numericValues].sort((left, right) => left - right); + const q1 = quantile(sorted, 0.25); + const q3 = quantile(sorted, 0.75); + const iqr = q3 - q1; + if (iqr === 0) { + continue; + } + + const lower = q1 - (1.5 * iqr); + const upper = q3 + (1.5 * iqr); + const outlierCount = numericValues.filter((value) => value < lower || value > upper).length; + if (outlierCount > 0) { + findings.push({ + type: 'outliers_iqr', + column, + count: outlierCount, + note: `Column '${column}' has ${outlierCount} potential outlier(s) outside [${lower.toFixed(2)}, ${upper.toFixed(2)}].`, + }); + } + } + + return findings; +} + +async function readSpreadsheetWithNode(filePath: string): Promise> { + const imported = await import('xlsx'); + const XLSX = ('readFile' in imported ? imported : imported.default) as typeof import('xlsx'); + const workbook = XLSX.readFile(filePath, { + cellDates: true, + raw: true, + dense: false, + }); + + const structure: Record = {}; + const quality: Record = {}; + const stats: Record = {}; + let totalRows = 0; + + for (const sheetName of workbook.SheetNames) { + const worksheet = workbook.Sheets[sheetName]; + if (!worksheet) { + continue; + } + + const rows = XLSX.utils.sheet_to_json>(worksheet, { + defval: null, + raw: true, + }); + const columns = getSpreadsheetColumns(rows); + const preview = normalizeSpreadsheetRows(rows.slice(0, 5), columns); + const dtypes = computeSpreadsheetDtypes(rows, columns); + const nullColumns = computeSpreadsheetNullColumns(rows, columns); + const sheetStats = computeSpreadsheetStats(rows, columns); + const sheetQuality = computeSpreadsheetQuality(rows, columns); + + totalRows += rows.length; + structure[sheetName] = { + shape: { + rows: rows.length, + cols: columns.length, + }, + columns, + dtypes, + null_columns: nullColumns, + preview, + }; + quality[sheetName] = sheetQuality; + stats[sheetName] = sheetStats; + } + + return { + file: filePath, + engine: 'node-xlsx', + workbook: { + sheetNames: workbook.SheetNames, + totalRows, + }, + structure, + quality, + stats, + }; +} + +async function runSpreadsheetAnalysis( + filePath: string, + options: { + scriptPath?: string; + signal?: AbortSignal; + }, +): Promise> { + const canUseNodeFallback = supportsNodeSpreadsheetFallback(filePath); + const mustUseNodeFallback = getSpreadsheetExtension(filePath) === '.xls' || !options.scriptPath; + + if (mustUseNodeFallback && canUseNodeFallback) { + return readSpreadsheetWithNode(filePath); + } + + let pythonError: Error | null = null; + if (options.scriptPath) { + try { + const report = await runPythonJsonScript(options.scriptPath, filePath, options.signal); + return { + ...report, + engine: 'python-xlsx_reader', + }; + } catch (error) { + pythonError = error instanceof Error ? error : new Error(String(error)); + } + } + + if ( + canUseNodeFallback + && ( + !options.scriptPath + || isMissingPythonRuntimeError(pythonError) + || isUnsupportedPythonSpreadsheetError(pythonError) + ) + ) { + return readSpreadsheetWithNode(filePath); + } + + throw pythonError || new Error('Spreadsheet analysis runtime is unavailable.'); +} + +function buildSpreadsheetArtifacts(attachments: AttachedFileMeta[]): ToolArtifact[] { + return attachments.map((attachment) => ({ + kind: 'file', + name: attachment.fileName, + filePath: attachment.filePath, + mimeType: attachment.mimeType, + preview: attachment.preview, + metadata: { + source: attachment.source, + fileSize: attachment.fileSize, + }, + })); +} + +function isDocumentCapability(capability: SkillCapability): boolean { + if (isSpreadsheetCapability(capability)) { + return false; + } + + const haystack = [ + capability.skillKey, + capability.slug, + capability.name, + capability.description, + capability.category, + ...capability.operationHints, + ...capability.triggerHints, + ...capability.inputExtensions, + ].map((value) => normalizeLookup(value)); + + if (capability.inputExtensions.some((extension) => DOCUMENT_ANALYSIS_EXTENSIONS.includes(normalizeLookup(extension)))) { + return true; + } + + return haystack.some((value) => + value === 'pdf' + || value === 'docx' + || value === 'pptx' + || value.includes('pdf') + || value.includes('word document') + || value.includes('powerpoint') + || value.includes('presentation') + ); +} + +function findDocumentCapabilityByToolName( + capabilities: SkillCapability[], + requestedSkillKey?: string, +): SkillCapability | null { + const capability = findCapabilityByToolName(capabilities, requestedSkillKey); + return capability && isDocumentCapability(capability) ? capability : null; +} + +function filterCapabilityCompatibleAttachments( + capability: SkillCapability, + attachments: AttachedFileMeta[], +): AttachedFileMeta[] { + const declaredExtensions = capability.inputExtensions.map((extension) => normalizeLookup(extension)); + if (declaredExtensions.length === 0) { + return attachments; + } + + return attachments.filter((attachment) => { + const extension = getFileExtension(attachment.filePath || attachment.fileName || ''); + return Boolean(extension) && declaredExtensions.includes(extension); + }); +} + +function filterDocumentAnalysisAttachments(attachments: AttachedFileMeta[]): AttachedFileMeta[] { + return attachments.filter((attachment) => isDocumentAnalysisExtension(attachment.filePath || attachment.fileName || '')); +} + +async function loadZipArchive(filePath: string) { + const fs = (await import('fs-extra')).default; + const JSZip = (await import('jszip')).default; + const buffer = await fs.readFile(filePath); + return JSZip.loadAsync(buffer); +} + +async function readZipEntryText(zip: { file(name: string): { async(type: 'text'): Promise } | null }, entryName: string): Promise { + const entry = zip.file(entryName); + if (!entry) { + return null; + } + + return entry.async('text'); +} + +function extractDocxParagraphs(documentXml: string): Array<{ text: string; headingLevel?: number }> { + const paragraphs: Array<{ text: string; headingLevel?: number }> = []; + const paragraphPattern = //gi; + + for (const match of documentXml.matchAll(paragraphPattern)) { + const block = match[0] || ''; + const text = collectXmlTagText(block, 'w:t').join(' ').trim(); + if (!text) { + continue; + } + + const headingMatch = block.match(/]*w:val="Heading([1-9])"/i) + || block.match(/]*val="Heading([1-9])"/i); + paragraphs.push({ + text, + headingLevel: headingMatch?.[1] ? Number.parseInt(headingMatch[1], 10) : undefined, + }); + } + + return paragraphs; +} + +async function analyzeDocxWithNode(filePath: string): Promise { + const zip = await loadZipArchive(filePath); + const documentXml = await readZipEntryText(zip, 'word/document.xml'); + if (!documentXml) { + throw new Error('word/document.xml not found in DOCX archive.'); + } + + const coreXml = await readZipEntryText(zip, 'docProps/core.xml'); + const appXml = await readZipEntryText(zip, 'docProps/app.xml'); + const commentsXml = await readZipEntryText(zip, 'word/comments.xml'); + const tableCount = Array.from(documentXml.matchAll(//gi, ' '); + const paragraphs = extractDocxParagraphs(bodyXml); + const headings = paragraphs.filter((paragraph) => typeof paragraph.headingLevel === 'number'); + const paragraphPreview = paragraphs.slice(0, 6).map((paragraph, index) => ({ + paragraph: index + 1, + text: truncateText(paragraph.text), + headingLevel: paragraph.headingLevel, + })); + const wordCount = paragraphs.reduce((sum, paragraph) => sum + countWords(paragraph.text), 0); + const pageCount = extractXmlInteger(appXml, ['Pages']); + const title = extractXmlScalar(coreXml, ['dc:title', 'title']); + const author = extractXmlScalar(coreXml, ['dc:creator', 'creator']); + const subject = extractXmlScalar(coreXml, ['dc:subject', 'subject']); + const commentCount = commentsXml ? Array.from(commentsXml.matchAll(/ = { + title, + author, + subject, + pageCount, + paragraphCount: paragraphs.length, + headingCount: headings.length, + tableCount, + commentCount, + wordCount, + }; + + return { + filePath, + fileName: filePath.split(/[\\/]/).pop() || filePath, + kind: 'docx', + engine: 'node-openxml', + summary: `${filePath.split(/[\\/]/).pop() || filePath}: ${paragraphs.length} paragraph(s), ${tableCount} table(s)`, + metadata, + preview: paragraphPreview, + }; +} + +async function analyzePptxWithNode(filePath: string): Promise { + const zip = await loadZipArchive(filePath); + const presentationXml = await readZipEntryText(zip, 'ppt/presentation.xml'); + const slideEntries = zip + .file(/^ppt\/slides\/slide\d+\.xml$/) + .sort((left, right) => { + const leftNumber = Number.parseInt(left.name.match(/slide(\d+)\.xml$/)?.[1] || '0', 10); + const rightNumber = Number.parseInt(right.name.match(/slide(\d+)\.xml$/)?.[1] || '0', 10); + return leftNumber - rightNumber; + }); + + const slidePreview: Array> = []; + let textBlockCount = 0; + let wordCount = 0; + + for (const entry of slideEntries) { + const slideXml = await entry.async('text'); + const texts = collectXmlTagText(slideXml, 'a:t'); + textBlockCount += texts.length; + const combinedText = texts.join(' ').trim(); + wordCount += countWords(combinedText); + + if (slidePreview.length < 6 && combinedText) { + const slideNumber = Number.parseInt(entry.name.match(/slide(\d+)\.xml$/)?.[1] || '0', 10); + slidePreview.push({ + slide: slideNumber || slidePreview.length + 1, + text: truncateText(combinedText, 320), + }); + } + } + + const hiddenSlideCount = presentationXml + ? Array.from(presentationXml.matchAll(/]*show="0"/gi)).length + : 0; + const title = slidePreview[0] && typeof slidePreview[0].text === 'string' + ? slidePreview[0].text + : undefined; + const metadata: Record = { + title, + slideCount: slideEntries.length, + hiddenSlideCount, + textBlockCount, + wordCount, + }; + + return { + filePath, + fileName: filePath.split(/[\\/]/).pop() || filePath, + kind: 'pptx', + engine: 'node-openxml', + summary: `${filePath.split(/[\\/]/).pop() || filePath}: ${slideEntries.length} slide(s)`, + metadata, + preview: slidePreview, + }; +} + +async function analyzePdfWithNode(filePath: string): Promise { + const fs = (await import('fs-extra')).default; + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const buffer = await fs.readFile(filePath); + const loadingTask = (pdfjs as { getDocument: (input: Record) => { promise: Promise; destroy?: () => Promise } }).getDocument({ + data: new Uint8Array(buffer), + disableFontFace: true, + isEvalSupported: false, + useWorkerFetch: false, + }); + const document = await loadingTask.promise; + const metadataResult = typeof document.getMetadata === 'function' + ? await document.getMetadata().catch(() => null) + : null; + const preview: Array> = []; + let textItemCount = 0; + let wordCount = 0; + + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const textContent = await page.getTextContent(); + const text = (Array.isArray(textContent.items) ? textContent.items : []) + .map((item: { str?: string }) => (typeof item?.str === 'string' ? item.str : '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + textItemCount += Array.isArray(textContent.items) ? textContent.items.length : 0; + wordCount += countWords(text); + + if (preview.length < 4 && text) { + preview.push({ + page: pageNumber, + text: truncateText(text, 320), + }); + } + } + + const info = metadataResult && typeof metadataResult === 'object' && 'info' in metadataResult + ? ensureRecord((metadataResult as { info?: unknown }).info) + : {}; + const metadata: Record = { + title: typeof info.Title === 'string' ? info.Title : undefined, + author: typeof info.Author === 'string' ? info.Author : undefined, + pageCount: document.numPages, + textItemCount, + wordCount, + }; + + if (typeof document.destroy === 'function') { + await document.destroy(); + } + if (typeof loadingTask.destroy === 'function') { + await loadingTask.destroy().catch(() => undefined); + } + + return { + filePath, + fileName: filePath.split(/[\\/]/).pop() || filePath, + kind: 'pdf', + engine: 'node-pdfjs', + summary: `${filePath.split(/[\\/]/).pop() || filePath}: ${document.numPages} page(s)`, + metadata, + preview, + }; +} + +async function runDocumentAnalysis(filePath: string): Promise { + const extension = getFileExtension(filePath); + switch (extension) { + case '.docx': + return analyzeDocxWithNode(filePath); + case '.pptx': + return analyzePptxWithNode(filePath); + case '.pdf': + return analyzePdfWithNode(filePath); + default: + throw new Error(`Unsupported document attachment: ${filePath}`); + } +} + +function buildDocumentAnalysisArtifacts(attachments: AttachedFileMeta[]): ToolArtifact[] { + return buildSpreadsheetArtifacts(attachments); +} + +function buildDocumentStructuredResult( + capability: SkillCapability, + reports: DocumentAnalysisReport[], +): Record { + return { + skillKey: capability.skillKey, + fileCount: reports.length, + kinds: dedupeStrings(reports.map((report) => report.kind)), + reports: reports.map((report) => ({ + fileName: report.fileName, + filePath: report.filePath, + kind: report.kind, + engine: report.engine, + summary: report.summary, + metadata: report.metadata, + preview: report.preview, + })), + }; +} + +function buildDocumentAnalysisSummary(reports: DocumentAnalysisReport[]): string { + if (reports.length === 0) { + return 'Document analysis completed with no report output.'; + } + + return `Document analysis completed. ${reports.map((report) => report.summary).join('; ')}`; +} + +async function loadSkillExecutionConfig(skillKey: string): Promise { + try { + const { getSkillConfig } = await import('@electron/utils/skill-config'); + const config = await getSkillConfig(skillKey); + const env = Object.fromEntries( + Object.entries(config?.env || {}) + .map(([key, value]) => [String(key || '').trim(), String(value || '').trim()]) + .filter(([key, value]) => Boolean(key) && Boolean(value)), + ); + const apiKey = getTrimmedString(config?.apiKey); + + return { + apiKey, + env, + }; + } catch { + return { + env: {}, + }; + } +} + +function getConfiguredEnvValue(config: SkillExecutionConfig, envName: string): string | undefined { + const fromConfig = getTrimmedString(config.env[envName]); + if (fromConfig) { + return fromConfig; + } + + return getTrimmedString(process.env[envName]); +} + +function hasConfiguredAuth(capability: SkillCapability, config: SkillExecutionConfig): boolean { + if (config.apiKey) { + return true; + } + + return capability.requiredEnvVars.some((envName) => Boolean(getConfiguredEnvValue(config, envName))); +} + +function isSearchCapability(capability: SkillCapability): boolean { + if (isSpreadsheetCapability(capability) || isDocumentCapability(capability)) { + return false; + } + + if (normalizeLookup(capability.renderHints?.skillType) === 'search') { + return true; + } + + if (normalizeLookup(capability.category) === 'search') { + return true; + } + + return capability.allowedTools.some((toolName) => normalizeLookup(toolName).includes('search')) + || capability.operationHints.some((operation) => ['search', 'research', 'crawl', 'extract'].includes(normalizeLookup(operation))); +} + +function isBrowserCapability(capability: SkillCapability): boolean { + if (isSpreadsheetCapability(capability) || isDocumentCapability(capability) || isSearchCapability(capability)) { + return false; + } + + if (normalizeLookup(capability.renderHints?.skillType) === 'browser') { + return true; + } + + const haystack = [ + capability.skillKey, + capability.slug, + capability.name, + capability.description, + capability.category, + ...capability.allowedTools, + ...capability.operationHints, + ...capability.triggerHints, + ].map((value) => normalizeLookup(value)); + + return haystack.some((value) => + value === 'browser.open_url' + || value.includes('browser') + || value.includes('open url') + || value.includes('open webpage') + || value.includes('visit url') + ); +} + +type CommandProvider = 'clawhub-search' | 'generic-command'; + +type CommandExecutionPlan = { + provider: CommandProvider; + displayCommand: string; + command: string; + args: string[]; + cwd?: string; +}; + +function isCommandCapability(capability: SkillCapability): boolean { + if ( + isSpreadsheetCapability(capability) + || isDocumentCapability(capability) + || isSearchCapability(capability) + || isBrowserCapability(capability) + ) { + return false; + } + + return Boolean(resolveCommandProvider(capability)); +} + +function listCommandScriptEntrypoints(baseDir: string | undefined): string[] { + if (!baseDir || !nodeFs) { + return []; + } + + const scriptsDir = joinPath(baseDir, 'scripts'); + if (!nodeFs.existsSync(scriptsDir)) { + return []; + } + + const supportedExtensions = new Set(['.js', '.cjs', '.mjs', '.py', '.ps1', '.cmd', '.bat', '.sh']); + const entries: string[] = []; + + for (const entry of nodeFs.readdirSync(scriptsDir, { withFileTypes: true })) { + if (!entry.isFile()) { + continue; + } + + const lowerName = entry.name.toLowerCase(); + if (lowerName.startsWith('_') || lowerName === '__init__.py') { + continue; + } + + const extension = getFileExtension(entry.name); + if (!supportedExtensions.has(extension)) { + continue; + } + + entries.push(joinPath(scriptsDir, entry.name)); + } + + return entries; +} + +function tokenizeCommandLine(commandLine: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | '\'' | null = null; + let escaping = false; + + for (const char of commandLine.trim()) { + if (escaping) { + current += char; + escaping = false; + continue; + } + + if (char === '\\') { + escaping = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + + if (char === '"' || char === '\'') { + quote = char; + continue; + } + + if (/\s/.test(char)) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current) { + tokens.push(current); + } + + return tokens; +} + +function looksLikeQueryPlaceholder(token: string): boolean { + const normalized = normalizeLookup(token) + .replace(/^["']|["']$/g, '') + .replace(/^[<{[(]+|[>})\]]+$/g, ''); + + return normalized === 'query' + || normalized === 'your query' + || normalized === 'search query' + || normalized === 'your search query' + || normalized === 'topic' + || normalized === 'task' + || normalized === 'package' + || normalized === 'name' + || normalized === 'slug'; +} + +function applyCommandTemplateInputs( + template: string, + query: string, + inputArgs: string[], +): { command: string; args: string[]; displayCommand: string } | null { + const tokens = tokenizeCommandLine(template); + if (tokens.length === 0) { + return null; + } + + const hydratedTokens = [...tokens]; + let replacedPlaceholder = false; + for (let index = 0; index < hydratedTokens.length; index += 1) { + if (looksLikeQueryPlaceholder(hydratedTokens[index] || '')) { + hydratedTokens[index] = query; + replacedPlaceholder = true; + } + } + + if (query && !replacedPlaceholder) { + hydratedTokens.push(query); + } + + if (inputArgs.length > 0) { + hydratedTokens.push(...inputArgs); + } + + const [command, ...args] = hydratedTokens; + return { + command, + args, + displayCommand: hydratedTokens.join(' '), + }; +} + +function maybeResolveRelativeCommand(command: string, baseDir: string | undefined): string { + if (!baseDir) { + return command; + } + + if (command.startsWith('./') || command.startsWith('.\\')) { + return joinPath(baseDir, command.slice(2)); + } + + if (command.startsWith('scripts/') || command.startsWith('scripts\\')) { + return joinPath(baseDir, command); + } + + return command; +} + +function buildCommandPlanFromTokens( + capability: SkillCapability, + command: string, + args: string[], + displayCommand: string, +): CommandExecutionPlan | null { + const resolvedCommand = maybeResolveRelativeCommand(command, capability.baseDir); + const extension = getFileExtension(resolvedCommand); + + if (extension === '.js' || extension === '.cjs' || extension === '.mjs') { + return { + provider: 'generic-command', + command: process.execPath, + args: [resolvedCommand, ...args], + cwd: capability.baseDir, + displayCommand, + }; + } + + if (extension === '.py') { + const candidate = PYTHON_COMMAND_CANDIDATES[0]; + return { + provider: 'generic-command', + command: candidate?.command || 'python', + args: [...(candidate?.args || []), resolvedCommand, ...args], + cwd: capability.baseDir, + displayCommand, + }; + } + + if (extension === '.ps1') { + return { + provider: 'generic-command', + command: 'powershell', + args: ['-ExecutionPolicy', 'Bypass', '-File', resolvedCommand, ...args], + cwd: capability.baseDir, + displayCommand, + }; + } + + if (extension === '.sh') { + return { + provider: 'generic-command', + command: 'bash', + args: [resolvedCommand, ...args], + cwd: capability.baseDir, + displayCommand, + }; + } + + if (extension === '.cmd' || extension === '.bat') { + return { + provider: 'generic-command', + command: resolvedCommand, + args, + cwd: capability.baseDir, + displayCommand, + }; + } + + if (normalizeLookup(resolvedCommand) === 'node') { + return { + provider: 'generic-command', + command: process.execPath, + args, + cwd: capability.baseDir, + displayCommand, + }; + } + + return { + provider: 'generic-command', + command: resolvedCommand, + args, + cwd: capability.baseDir, + displayCommand, + }; +} + +function resolveSearchProvider(capability: SkillCapability): SearchProvider | null { + const haystack = [ + capability.skillKey, + capability.slug, + capability.name, + capability.description, + ...capability.allowedTools, + ...capability.requiredEnvVars, + ...capability.triggerHints, + ] + .map((value) => normalizeLookup(value)) + .join(' '); + + if (haystack.includes('tavily') || haystack.includes('tvly')) { + return 'tavily'; + } + + if (haystack.includes('brave')) { + return 'brave'; + } + + return null; +} + +function findSearchExecutionCapability( + capabilities: SkillCapability[], + requestedSkillKey?: string, +): { capability: SkillCapability; provider: SearchProvider } | null { + const capability = findCapabilityByToolName(capabilities, requestedSkillKey); + if (!capability || !isSearchCapability(capability)) { + return null; + } + + const provider = resolveSearchProvider(capability); + return provider + ? { capability, provider } + : null; +} + +function resolveBrowserProvider(capability: SkillCapability): 'browser.open_url' | null { + if (!isBrowserCapability(capability)) { + return null; + } + + const haystack = [ + capability.skillKey, + capability.slug, + ...capability.allowedTools, + ...capability.triggerHints, + ] + .map((value) => normalizeLookup(value)) + .join(' '); + + return haystack.includes('browser.open_url') || haystack.includes('open url') || haystack.includes('browser') + ? 'browser.open_url' + : null; +} + +function findBrowserExecutionCapability( + capabilities: SkillCapability[], + requestedSkillKey?: string, +): { capability: SkillCapability; provider: 'browser.open_url' } | null { + const capability = findCapabilityByToolName(capabilities, requestedSkillKey); + if (!capability) { + return null; + } + + const provider = resolveBrowserProvider(capability); + return provider ? { capability, provider } : null; +} + +function resolveCommandProvider(capability: SkillCapability): CommandProvider | null { + const haystack = [ + capability.skillKey, + capability.slug, + capability.name, + capability.description, + ...capability.allowedTools, + ...capability.triggerHints, + ] + .map((value) => normalizeLookup(value)) + .join(' '); + + if ( + haystack.includes('find-skills') + || haystack.includes('npx skills find') + || haystack.includes('skills cli') + ) { + return 'clawhub-search'; + } + + if ((capability.commandExamples?.length ?? 0) > 0 || listCommandScriptEntrypoints(capability.baseDir).length > 0) { + return 'generic-command'; + } + + return null; +} + +function findCommandExecutionCapability( + capabilities: SkillCapability[], + requestedSkillKey?: string, +): { capability: SkillCapability; provider: CommandProvider } | null { + const capability = findCapabilityByToolName(capabilities, requestedSkillKey); + if (!capability) { + return null; + } + + const provider = resolveCommandProvider(capability); + return provider ? { capability, provider } : null; +} + +function resolveSearchApiKey( + provider: SearchProvider, + capability: SkillCapability, + config: SkillExecutionConfig, +): string | undefined { + if (config.apiKey) { + return config.apiKey; + } + + const envCandidates = provider === 'brave' + ? dedupeStrings([...capability.requiredEnvVars, 'BRAVE_SEARCH_API_KEY']) + : dedupeStrings([...capability.requiredEnvVars, 'TAVILY_API_KEY']); + + for (const envName of envCandidates) { + const value = getConfiguredEnvValue(config, envName); + if (value) { + return value; + } + } + + return undefined; +} + +function getMissingSearchCredentialNames( + provider: SearchProvider, + capability: SkillCapability, +): string[] { + const defaults = provider === 'brave' ? ['BRAVE_SEARCH_API_KEY'] : ['TAVILY_API_KEY']; + return dedupeStrings([...capability.requiredEnvVars, ...defaults]); +} + +function normalizeBraveFreshness(input: SearchExecutionInput): string | undefined { + const freshness = getTrimmedString(input.freshness); + if (freshness) { + return freshness; + } + + const timeRange = normalizeLookup(input.timeRange); + return BRAVE_TIME_RANGE_TO_FRESHNESS[timeRange]; +} + +function normalizeTavilyTimeRange(input: SearchExecutionInput): string | undefined { + const timeRange = getTrimmedString(input.timeRange); + if (timeRange) { + return timeRange; + } + + const freshness = normalizeLookup(input.freshness); + return TAVILY_FRESHNESS_TO_TIME_RANGE[freshness]; +} + +function applyQueryDomainFilters( + query: string, + includeDomains: string[], + excludeDomains: string[], +): string { + const parts = [query.trim()]; + + if (includeDomains.length === 1) { + parts.push(`site:${includeDomains[0]}`); + } else if (includeDomains.length > 1) { + parts.push(`(${includeDomains.map((domain) => `site:${domain}`).join(' OR ')})`); + } + + for (const domain of excludeDomains) { + parts.push(`-site:${domain}`); + } + + return parts.filter(Boolean).join(' ').trim(); +} + +async function parseJsonResponse(response: Response): Promise> { + const text = await response.text(); + if (!text.trim()) { + return {}; + } + + try { + return JSON.parse(text) as Record; + } catch { + throw new Error(`Failed to parse JSON response (HTTP ${response.status}).`); + } +} + +async function throwSearchResponseError(response: Response, providerLabel: string): Promise { + const payload = await parseJsonResponse(response).catch(() => ({})); + const message = getTrimmedString(payload.error) + || getTrimmedString(payload.message) + || getTrimmedString(payload.detail) + || `HTTP ${response.status}`; + throw new Error(`${providerLabel} search failed: ${message}`); +} + +async function executeBraveSearch( + capability: SkillCapability, + input: SearchExecutionInput, + apiKey: string, + signal?: AbortSignal, +): Promise { + const count = coerceInteger(input.count ?? input.maxResults, BRAVE_DEFAULT_RESULT_COUNT, 1, 20); + const includeDomains = coerceStringList(input.includeDomains); + const excludeDomains = coerceStringList(input.excludeDomains); + const params = new URLSearchParams({ + q: applyQueryDomainFilters(input.query, includeDomains, excludeDomains), + count: String(count), + }); + const country = getTrimmedString(input.country); + const searchLang = getTrimmedString(input.searchLang); + const uiLang = getTrimmedString(input.uiLang); + const safeSearch = getTrimmedString(input.safesearch) || getTrimmedString(input.safeSearch); + const freshness = normalizeBraveFreshness(input); + + if (country) { + params.set('country', country); + } + if (searchLang) { + params.set('search_lang', searchLang); + } + if (uiLang) { + params.set('ui_lang', uiLang); + } + if (safeSearch) { + params.set('safesearch', safeSearch); + } + if (freshness) { + params.set('freshness', freshness); + } + + const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-Subscription-Token': apiKey, + }, + signal, + }); + + if (!response.ok) { + await throwSearchResponseError(response, capability.name); + } + + const payload = await parseJsonResponse(response); + const web = ensureRecord(payload.web); + const results = toRecordArray(web.results) + .slice(0, count) + .map((item) => ({ + title: getTrimmedString(item.title) || getTrimmedString(item.url) || 'Untitled result', + url: getTrimmedString(item.url) || '', + snippet: getTrimmedString(item.description) + || (Array.isArray(item.extra_snippets) + ? getTrimmedString(item.extra_snippets.find((value) => typeof value === 'string')) + : undefined), + source: getTrimmedString(ensureRecord(item.profile).name) + || getTrimmedString(item.age) + || getUrlHostname(getTrimmedString(item.url)), + age: getTrimmedString(item.age), + publishedAt: getTrimmedString(item.page_age), + })) + .filter((item) => item.url); + + return { + provider: 'brave', + engine: 'brave-search-api', + query: getTrimmedString(ensureRecord(payload.query).original) || input.query, + resultCount: results.length, + results, + metadata: { + skillKey: capability.skillKey, + count, + freshness, + country, + searchLang, + uiLang, + moreResultsAvailable: ensureRecord(payload.query).more_results_available === true, + familyFriendly: web.family_friendly === true, + }, + }; +} + +async function executeTavilySearch( + capability: SkillCapability, + input: SearchExecutionInput, + apiKey: string, + signal?: AbortSignal, +): Promise { + const maxResults = coerceInteger(input.maxResults ?? input.count, TAVILY_DEFAULT_RESULT_COUNT, 1, 20); + const searchDepth = getTrimmedString(input.searchDepth) || getTrimmedString(input.depth); + const topic = getTrimmedString(input.topic); + const timeRange = normalizeTavilyTimeRange(input); + const includeDomains = coerceStringList(input.includeDomains); + const excludeDomains = coerceStringList(input.excludeDomains); + const includeAnswer = coerceBoolean(input.includeAnswer); + const includeRawContent = typeof input.includeRawContent === 'string' + ? getTrimmedString(input.includeRawContent) + : coerceBoolean(input.includeRawContent); + + const body: Record = { + query: input.query, + max_results: maxResults, + }; + + if (searchDepth) { + body.search_depth = searchDepth; + } + if (topic) { + body.topic = topic; + } + if (timeRange) { + body.time_range = timeRange; + } + if (includeDomains.length > 0) { + body.include_domains = includeDomains; + } + if (excludeDomains.length > 0) { + body.exclude_domains = excludeDomains; + } + if (includeAnswer !== undefined) { + body.include_answer = includeAnswer; + } + if (includeRawContent !== undefined) { + body.include_raw_content = includeRawContent; + } + + const response = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal, + }); + + if (!response.ok) { + await throwSearchResponseError(response, capability.name); + } + + const payload = await parseJsonResponse(response); + const results = toRecordArray(payload.results) + .slice(0, maxResults) + .map((item) => ({ + title: getTrimmedString(item.title) || getTrimmedString(item.url) || 'Untitled result', + url: getTrimmedString(item.url) || '', + snippet: getTrimmedString(item.content) + || getTrimmedString(item.raw_content) + || getTrimmedString(item.snippet), + score: typeof item.score === 'number' && Number.isFinite(item.score) + ? Number(item.score.toFixed(4)) + : undefined, + source: getUrlHostname(getTrimmedString(item.url)), + publishedAt: getTrimmedString(item.published_date), + })) + .filter((item) => item.url); + + const responseTime = typeof payload.response_time === 'number' && Number.isFinite(payload.response_time) + ? payload.response_time + : undefined; + + return { + provider: 'tavily', + engine: 'tavily-search-api', + query: getTrimmedString(payload.query) || input.query, + resultCount: results.length, + answer: getTrimmedString(payload.answer), + responseTimeMs: typeof responseTime === 'number' + ? Math.round(responseTime * 1000) + : undefined, + results, + metadata: { + skillKey: capability.skillKey, + maxResults, + searchDepth, + topic, + timeRange, + includeDomains, + excludeDomains, + }, + }; +} + +function buildSearchArtifacts(report: SearchExecutionReport): ToolArtifact[] { + return report.results.slice(0, 6).map((result, index) => ({ + kind: 'url', + name: result.title || `Result ${index + 1}`, + label: result.title || `Result ${index + 1}`, + description: result.snippet || result.source || report.provider, + uri: result.url, + metadata: { + provider: report.provider, + score: result.score, + source: result.source, + age: result.age, + publishedAt: result.publishedAt, + }, + })); +} + +function buildSearchStructuredResult( + capability: SkillCapability, + report: SearchExecutionReport, +): Record { + return { + skillKey: capability.skillKey, + provider: report.provider, + engine: report.engine, + query: report.query, + resultCount: report.resultCount, + answer: report.answer, + responseTimeMs: report.responseTimeMs, + ...report.metadata, + results: report.results, + }; +} + +function buildSearchSummary( + capability: SkillCapability, + report: SearchExecutionReport, +): string { + const answerSuffix = report.answer ? ' Included an answer summary.' : ''; + return `Search completed with ${capability.name}. Found ${report.resultCount} result(s) for "${report.query}".${answerSuffix}`; +} + +async function executeBrowserOpen( + url: string, + signal?: AbortSignal, +): Promise> { + const { openUrlInBrowser } = await import('@electron/service/browser-open-service'); + return openUrlInBrowser(url, { signal }) as Promise>; +} + +async function executeClawHubSearch( + capability: SkillCapability, + query: string, + limit: number, +): Promise { + const { ClawHubService } = await import('./clawhub'); + const service = new ClawHubService(); + const results = await service.search({ query, limit }); + const normalizedResults: SearchResultItem[] = results.map((item) => { + const slug = getTrimmedString(item.slug) || ''; + const skillUrl = slug + ? `https://skills.sh/${slug.replace('@', '/')}` + : ''; + + return { + title: getTrimmedString(item.name) || slug || 'Skill result', + url: skillUrl, + snippet: getTrimmedString(item.description), + source: 'skills.sh', + slug, + version: getTrimmedString(item.version), + downloads: typeof item.downloads === 'number' && Number.isFinite(item.downloads) ? item.downloads : undefined, + stars: typeof item.stars === 'number' && Number.isFinite(item.stars) ? item.stars : undefined, + installCommand: slug ? `npx skills add ${slug} -g -y` : undefined, + }; + }); + + return { + provider: 'clawhub', + engine: 'clawhub-search', + query, + resultCount: normalizedResults.length, + results: normalizedResults, + metadata: { + skillKey: capability.skillKey, + provider: 'clawhub', + limit, + command: 'npx skills find', + }, + }; +} + +function buildSkillCommandEnv( + capability: SkillCapability, + config: SkillExecutionConfig, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + }; + + for (const [key, value] of Object.entries(config.env)) { + if (value) { + env[key] = value; + } + } + + if (config.apiKey) { + for (const envName of capability.requiredEnvVars) { + if (!env[envName]) { + env[envName] = config.apiKey; + } + } + } + + return env; +} + +function selectGenericCommandPlan( + capability: SkillCapability, + input: GenericSkillInput & Record, +): CommandExecutionPlan | null { + const explicitCommand = getInputCommandName(input); + const inputArgs = getInputArgs(input); + const query = normalizeSearchQuery(input); + + if (capability.commandExamples?.length) { + const templates = explicitCommand + ? capability.commandExamples.filter((template) => + normalizeLookup(template).includes(normalizeLookup(explicitCommand)) + ) + : capability.commandExamples; + + for (const template of templates) { + const hydrated = applyCommandTemplateInputs(template, query, inputArgs); + if (!hydrated) { + continue; + } + + const plan = buildCommandPlanFromTokens( + capability, + hydrated.command, + hydrated.args, + hydrated.displayCommand, + ); + if (plan) { + return plan; + } + } + } + + const scriptCandidates = listCommandScriptEntrypoints(capability.baseDir); + const matchedScripts = explicitCommand + ? scriptCandidates.filter((filePath) => + normalizeLookup(filePath.split(/[\\/]/).pop()).includes(normalizeLookup(explicitCommand)) + ) + : scriptCandidates; + const selectedScript = matchedScripts.length === 1 + ? matchedScripts[0] + : matchedScripts.find((filePath) => { + const baseName = normalizeLookup(filePath.split(/[\\/]/).pop()); + return baseName.includes(normalizeLookup(capability.slug)) + || baseName.includes(normalizeLookup(capability.skillKey)); + }); + + if (selectedScript) { + return buildCommandPlanFromTokens( + capability, + selectedScript, + query ? [query, ...inputArgs] : inputArgs, + `${selectedScript}${query ? ` ${query}` : ''}${inputArgs.length ? ` ${inputArgs.join(' ')}` : ''}`.trim(), + ); + } + + return null; +} + +async function runGenericCommandPlan( + plan: CommandExecutionPlan, + env: NodeJS.ProcessEnv, + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string }> { + if (!nodeChildProcess) { + throw new Error('Node child_process runtime is unavailable for command-style skill execution.'); + } + + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + const child = nodeChildProcess.spawn(plan.command, plan.args, { + cwd: plan.cwd, + env, + windowsHide: true, + signal, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code ?? 'unknown'}`)); + return; + } + + resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); + }); + }); +} + +function parseCommandOutput(stdout: string): unknown { + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + + return trimmed; +} + +function buildUnsupportedSkillSummary(capability: SkillCapability): string { + const details = [ + capability.category ? `category=${capability.category}` : null, + capability.allowedTools.length > 0 ? `allowedTools=${capability.allowedTools.join(',')}` : null, + capability.inputExtensions.length > 0 ? `inputs=${capability.inputExtensions.join(',')}` : null, + ].filter(Boolean); + + return [ + `Skill ${capability.name} is enabled, but direct chat execution is not implemented yet.`, + details.length > 0 ? `(${details.join('; ')})` : null, + ].filter(Boolean).join(' '); +} + +function createBrowserOpenAdapter(): ToolRuntimeAdapter { + return { + toolName: 'browser.open_url', + async preflight(invocation: ToolRuntimeInvocation): Promise { + const input = ensureRecord(invocation.input); + const url = typeof input.url === 'string' ? input.url.trim() : ''; + const isValidUrl = /^https?:\/\//i.test(url); + + if (!isValidUrl) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: 'browser.open_url requires a valid http/https URL.', + error: { + code: 'missing_required_url', + message: 'browser.open_url requires a valid http/https URL.', + }, + }; + } + + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { url }, + summary: `Open ${url} in the managed browser.`, + }; + }, + async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const input = ensureRecord(invocation.input); + const url = typeof input.url === 'string' ? input.url.trim() : ''; + const result = await executeBrowserOpen(url, context.signal); + + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { url }, + summary: `Opened ${result.pageUrl}${result.title ? ` (${result.title})` : ''}`, + raw: result, + renderHints: { + card: 'browser-step', + preferredView: 'summary', + skillType: 'browser', + }, + durationMs: 0, + }; + }, + }; +} + +function createBrowserSkillAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { + return { + toolName: '__browser_skill__', + matchesTool(toolName: string): boolean { + return Boolean(findBrowserExecutionCapability(capabilities, toolName)); + }, + async preflight(invocation: ToolRuntimeInvocation): Promise { + const resolved = findBrowserExecutionCapability(capabilities, invocation.toolName); + if (!resolved) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `No installed browser skill runtime is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed browser skill runtime is available for ${invocation.toolName}.`, + }, + }; + } + + const input = ensureRecord(invocation.input); + const url = extractExplicitUrl(input.url) || extractExplicitUrl(input.prompt); + if (!url) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} requires an explicit http/https URL.`, + error: { + code: 'missing_required_url', + message: `Skill ${resolved.capability.name} requires an explicit http/https URL.`, + }, + missing: ['url'], + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + }, + }; + } + + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + url, + }, + summary: `Open ${url} with ${resolved.capability.name}.`, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const resolved = findBrowserExecutionCapability(capabilities, invocation.toolName); + const input = ensureRecord(invocation.input); + const url = extractExplicitUrl(input.url) || extractExplicitUrl(input.prompt) || ''; + if (!resolved || !url) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: `No installed browser skill runtime is available for ${invocation.toolName}.`, + error: { + code: resolved ? 'missing_required_url' : 'missing_skill_runtime', + message: resolved + ? `Skill ${invocation.toolName} requires an explicit http/https URL.` + : `No installed browser skill runtime is available for ${invocation.toolName}.`, + }, + durationMs: 0, + }; + } + + const result = await executeBrowserOpen(url, context.signal); + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + url, + }, + summary: `Opened ${result.pageUrl || url}${result.title ? ` (${result.title})` : ''} with ${resolved.capability.name}.`, + raw: { + skillKey: resolved.capability.skillKey, + ...result, + }, + renderHints: resolved.capability.renderHints || { + card: 'browser-step', + preferredView: 'summary', + skillType: 'browser', + }, + skillType: 'browser', + durationMs: 0, + }; + }, + }; +} + +function createSkillsInstallAdapter(): ToolRuntimeAdapter { + return { + toolName: 'skills.install', + async preflight(invocation: ToolRuntimeInvocation): Promise { + const input = ensureRecord(invocation.input); + const kind = typeof input.kind === 'string' ? input.kind : ''; + + if (kind === 'github-url' && typeof input.url === 'string' && input.url.trim()) { + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + kind: 'github-url', + url: input.url.trim(), + force: input.force === true, + }, + summary: `Install a skill from ${input.url.trim()}.`, + }; + } + + if (kind === 'marketplace' && typeof input.slug === 'string' && input.slug.trim()) { + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + kind: 'marketplace', + slug: input.slug.trim(), + force: input.force === true, + }, + summary: `Install marketplace skill ${input.slug.trim()}.`, + }; + } + + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: 'skills.install requires a marketplace slug or GitHub URL.', + error: { + code: 'missing_required_input', + message: 'skills.install requires a marketplace slug or GitHub URL.', + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation): Promise { + const { handleSkillsInstall } = await import('./handlers/skills'); + const input = invocation.input as ToolCallPayload['input'] & { + kind: 'marketplace' | 'github-url'; + slug?: string; + url?: string; + force?: boolean; + }; + const result = await handleSkillsInstall(input); + + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: input, + summary: `Installed skill ${result.slug}.`, + raw: result, + renderHints: { + card: 'skill-install', + preferredView: 'summary', + skillType: 'skill-install', + }, + durationMs: 0, + }; + }, + }; +} + +function createSpreadsheetAnalysisAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { + return { + toolName: 'minimax-xlsx', + matchesTool(toolName: string): boolean { + const normalized = normalizeLookup(toolName); + return normalized === 'xlsx' || normalized === 'minimax-xlsx' || normalized === 'spreadsheet.analysis'; + }, + async preflight(invocation: ToolRuntimeInvocation): Promise { + const input = (invocation.input || {}) as SpreadsheetToolInput; + const attachments = resolveInvocationAttachments(input); + if (attachments.length === 0) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: 'Spreadsheet analysis requires at least one spreadsheet attachment.', + error: { + code: 'missing_required_attachment', + message: 'Spreadsheet analysis requires at least one spreadsheet attachment.', + }, + missing: ['attachment'], + }; + } + + const resolved = findSpreadsheetCapability(capabilities, input.skillKey || invocation.toolName); + if (!resolved) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: 'No installed spreadsheet analysis skill is available.', + error: { + code: 'missing_skill_runtime', + message: 'No installed spreadsheet analysis skill is available.', + }, + }; + } + + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + attachments, + filePaths: attachments + .map((attachment) => attachment.filePath) + .filter((filePath): filePath is string => Boolean(filePath)), + }, + summary: `Analyze ${attachments.length} spreadsheet attachment(s) with ${resolved.capability.name}.`, + metadata: { + skillKey: resolved.capability.skillKey, + scriptPath: resolved.scriptPath, + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const normalizedInput = invocation.input as SpreadsheetToolInput & { attachments: AttachedFileMeta[] }; + const attachments = resolveInvocationAttachments(normalizedInput, context); + const resolved = findSpreadsheetCapability(capabilities, normalizedInput.skillKey || invocation.toolName); + if (!resolved) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput, + summary: 'Spreadsheet skill runtime is unavailable.', + error: { + code: 'missing_skill_runtime', + message: 'Spreadsheet skill runtime is unavailable.', + }, + durationMs: 0, + }; + } + + const reports: SpreadsheetAnalysisReport[] = []; + for (const attachment of attachments) { + const filePath = attachment.filePath; + if (!filePath) { + continue; + } + + const report = await runSpreadsheetAnalysis(filePath, { + scriptPath: resolved.scriptPath, + signal: context.signal, + }); + reports.push({ + filePath, + fileName: attachment.fileName || filePath.split(/[\\/]/).pop() || filePath, + report, + }); + } + + const summary = buildSpreadsheetToolSummary(reports); + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput, + summary, + raw: { + prompt: normalizedInput.prompt, + skillKey: resolved.capability.skillKey, + reports, + }, + artifacts: buildSpreadsheetArtifacts(attachments), + skillType: 'spreadsheet', + renderHints: resolved.capability.renderHints || { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + }, + durationMs: 0, + }; + }, + }; +} + +function createDocumentAnalysisAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { + return { + toolName: '__document_skill__', + matchesTool(toolName: string): boolean { + return Boolean(findDocumentCapabilityByToolName(capabilities, toolName)); + }, + async preflight(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const capability = findDocumentCapabilityByToolName(capabilities, invocation.toolName); + if (!capability) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `No installed document-analysis skill is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed document-analysis skill is available for ${invocation.toolName}.`, + }, + }; + } + + const input = ensureRecord(invocation.input) as GenericSkillInput & Record; + const attachments = resolveInvocationAttachments(input, context); + const compatibleAttachments = filterCapabilityCompatibleAttachments(capability, attachments); + if (compatibleAttachments.length === 0) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${capability.name} requires at least one compatible attachment (${capability.inputExtensions.join(', ')}).`, + error: { + code: 'missing_required_attachment', + message: `Skill ${capability.name} requires at least one compatible attachment (${capability.inputExtensions.join(', ')}).`, + }, + missing: ['attachment'], + metadata: { + skillKey: capability.skillKey, + category: capability.category, + inputExtensions: capability.inputExtensions, + }, + }; + } + + const supportedAttachments = filterDocumentAnalysisAttachments(compatibleAttachments); + if (supportedAttachments.length === 0) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${capability.name} can see the attachment, but the current runtime only supports .pdf, .docx, and .pptx execution in chat.`, + error: { + code: 'unsupported_attachment_type', + message: `Skill ${capability.name} can see the attachment, but the current runtime only supports .pdf, .docx, and .pptx execution in chat.`, + }, + metadata: { + skillKey: capability.skillKey, + category: capability.category, + inputExtensions: capability.inputExtensions, + }, + }; + } + + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: capability.skillKey, + attachments: supportedAttachments, + filePaths: supportedAttachments + .map((attachment) => attachment.filePath) + .filter((filePath): filePath is string => Boolean(filePath)), + }, + summary: `Analyze ${supportedAttachments.length} document attachment(s) with ${capability.name}.`, + metadata: { + skillKey: capability.skillKey, + category: capability.category, + inputExtensions: capability.inputExtensions, + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const capability = findDocumentCapabilityByToolName(capabilities, invocation.toolName); + const normalizedInput = invocation.input as GenericSkillInput & { attachments?: AttachedFileMeta[] }; + const attachments = filterDocumentAnalysisAttachments(resolveInvocationAttachments(normalizedInput, context)); + if (!capability) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput, + summary: `No installed document-analysis skill is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed document-analysis skill is available for ${invocation.toolName}.`, + }, + durationMs: 0, + }; + } + + const reports: DocumentAnalysisReport[] = []; + for (const attachment of attachments) { + if (!attachment.filePath) { + continue; + } + + const report = await runDocumentAnalysis(attachment.filePath); + reports.push({ + ...report, + fileName: attachment.fileName || report.fileName, + filePath: attachment.filePath, + }); + } + + const summary = buildDocumentAnalysisSummary(reports); + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...normalizedInput, + skillKey: capability.skillKey, + attachments, + }, + summary, + raw: buildDocumentStructuredResult(capability, reports), + artifacts: buildDocumentAnalysisArtifacts(attachments), + skillType: 'document', + renderHints: capability.renderHints || { + card: 'document-analysis', + preferredView: 'summary', + skillType: 'document', + }, + durationMs: 0, + }; + }, + }; +} + +function createSearchExecutionAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { + return { + toolName: '__search_skill__', + matchesTool(toolName: string): boolean { + return Boolean(findSearchExecutionCapability(capabilities, toolName)); + }, + async preflight(invocation: ToolRuntimeInvocation): Promise { + const resolved = findSearchExecutionCapability(capabilities, invocation.toolName); + if (!resolved) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `No installed search skill runtime is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed search skill runtime is available for ${invocation.toolName}.`, + }, + }; + } + + const input = ensureRecord(invocation.input) as SearchExecutionInput & Record; + const query = normalizeSearchQuery(input); + if (!query) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} requires a search query.`, + error: { + code: 'missing_required_input', + message: `Skill ${resolved.capability.name} requires a search query.`, + }, + missing: ['query'], + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + }, + }; + } + + const config = await loadSkillExecutionConfig(resolved.capability.skillKey); + const apiKey = resolveSearchApiKey(resolved.provider, resolved.capability, config); + if (!apiKey) { + const missing = getMissingSearchCredentialNames(resolved.provider, resolved.capability); + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, + error: { + code: 'missing_required_env', + message: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, + }, + missing, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + requiredEnvVars: missing, + }, + }; + } + + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + query, + }, + summary: `Search for "${query}" with ${resolved.capability.name}.`, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const resolved = findSearchExecutionCapability(capabilities, invocation.toolName); + const normalizedInput = invocation.input as SearchExecutionInput; + if (!resolved) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput, + summary: `No installed search skill runtime is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed search skill runtime is available for ${invocation.toolName}.`, + }, + durationMs: 0, + }; + } + + const config = await loadSkillExecutionConfig(resolved.capability.skillKey); + const apiKey = resolveSearchApiKey(resolved.provider, resolved.capability, config); + if (!apiKey) { + const missing = getMissingSearchCredentialNames(resolved.provider, resolved.capability); + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput, + summary: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, + error: { + code: 'missing_required_env', + message: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, + }, + durationMs: 0, + }; + } + + const report = resolved.provider === 'brave' + ? await executeBraveSearch(resolved.capability, normalizedInput, apiKey, context.signal) + : await executeTavilySearch(resolved.capability, normalizedInput, apiKey, context.signal); + const summary = buildSearchSummary(resolved.capability, report); + + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput, + summary, + raw: buildSearchStructuredResult(resolved.capability, report), + artifacts: buildSearchArtifacts(report), + skillType: 'search', + renderHints: resolved.capability.renderHints || { + card: 'search-results', + preferredView: 'summary', + skillType: 'search', + }, + durationMs: 0, + }; + }, + }; +} + +function createCommandExecutionAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { + return { + toolName: '__command_skill__', + matchesTool(toolName: string): boolean { + return Boolean(findCommandExecutionCapability(capabilities, toolName)); + }, + async preflight(invocation: ToolRuntimeInvocation): Promise { + const resolved = findCommandExecutionCapability(capabilities, invocation.toolName); + if (!resolved) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `No installed command skill runtime is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed command skill runtime is available for ${invocation.toolName}.`, + }, + }; + } + + const input = ensureRecord(invocation.input) as GenericSkillInput & Record; + const query = normalizeSearchQuery(input); + if (resolved.provider === 'clawhub-search' && !query) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} requires a query or task description.`, + error: { + code: 'missing_required_input', + message: `Skill ${resolved.capability.name} requires a query or task description.`, + }, + missing: ['query'], + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + }, + }; + } + + const config = await loadSkillExecutionConfig(resolved.capability.skillKey); + const missingEnvVars = resolved.capability.requiredEnvVars.filter((envName) => !getConfiguredEnvValue(config, envName)); + if (missingEnvVars.length > 0) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, + error: { + code: 'missing_required_env', + message: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, + }, + missing: missingEnvVars, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + requiredEnvVars: resolved.capability.requiredEnvVars, + }, + }; + } + + if (resolved.capability.requiresAuth && !hasConfiguredAuth(resolved.capability, config)) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} requires user authorization or account configuration before it can run in chat.`, + error: { + code: 'user_authorization_required', + message: `Skill ${resolved.capability.name} requires user authorization or account configuration before it can run in chat.`, + }, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + requiresAuth: true, + }, + }; + } + + const commandPlan = resolved.provider === 'generic-command' + ? selectGenericCommandPlan(resolved.capability, input) + : null; + if (resolved.provider === 'generic-command' && !commandPlan) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, + error: { + code: 'skill_runtime_not_implemented', + message: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, + }, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + commandExamples: resolved.capability.commandExamples, + }, + }; + } + + return { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + query, + command: getInputCommandName(input), + args: getInputArgs(input), + }, + summary: resolved.provider === 'clawhub-search' + ? `Run ${resolved.capability.name} for "${query}".` + : `Run ${resolved.capability.name}${commandPlan ? ` via ${commandPlan.displayCommand}` : ''}.`, + metadata: { + skillKey: resolved.capability.skillKey, + provider: resolved.provider, + commandPlan: commandPlan?.displayCommand, + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const resolved = findCommandExecutionCapability(capabilities, invocation.toolName); + const input = ensureRecord(invocation.input) as GenericSkillInput & Record; + const query = normalizeSearchQuery(input); + + if (!resolved || (resolved.provider === 'clawhub-search' && !query)) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: resolved + ? `Skill ${resolved.capability.name} requires a query or task description.` + : `No installed command skill runtime is available for ${invocation.toolName}.`, + error: { + code: resolved ? 'missing_required_input' : 'missing_skill_runtime', + message: resolved + ? `Skill ${resolved.capability.name} requires a query or task description.` + : `No installed command skill runtime is available for ${invocation.toolName}.`, + }, + durationMs: 0, + }; + } + + const config = await loadSkillExecutionConfig(resolved.capability.skillKey); + const missingEnvVars = resolved.capability.requiredEnvVars.filter((envName) => !getConfiguredEnvValue(config, envName)); + if (missingEnvVars.length > 0) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, + error: { + code: 'missing_required_env', + message: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, + }, + durationMs: 0, + }; + } + + switch (resolved.provider) { + case 'clawhub-search': { + const report = await executeClawHubSearch( + resolved.capability, + query, + coerceInteger((input as GenericSkillInput).count ?? (input as GenericSkillInput).maxResults, 5, 1, 10), + ); + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + query, + }, + summary: `Found ${report.resultCount} matching skill result(s) for "${query}".`, + raw: buildSearchStructuredResult(resolved.capability, report), + artifacts: buildSearchArtifacts(report), + logs: [`npx skills find ${query}`], + skillType: 'command', + renderHints: { + ...(resolved.capability.renderHints || {}), + card: 'search-results', + preferredView: 'summary', + skillType: 'command', + }, + durationMs: 0, + }; + } + case 'generic-command': { + const commandPlan = selectGenericCommandPlan(resolved.capability, input); + if (!commandPlan) { + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, + error: { + code: 'skill_runtime_not_implemented', + message: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, + }, + durationMs: 0, + }; + } + + const env = buildSkillCommandEnv(resolved.capability, config); + const output = await runGenericCommandPlan(commandPlan, env, context.signal); + const parsedOutput = parseCommandOutput(output.stdout); + const summary = typeof parsedOutput === 'string' + ? `Command completed via ${commandPlan.displayCommand}.` + : `Command completed via ${commandPlan.displayCommand}.`; + + return { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: { + ...input, + skillKey: resolved.capability.skillKey, + query, + command: getInputCommandName(input), + args: getInputArgs(input), + }, + summary, + raw: Array.isArray(parsedOutput) + ? { + skillKey: resolved.capability.skillKey, + command: commandPlan.displayCommand, + results: parsedOutput, + stderr: output.stderr || undefined, + } + : (parsedOutput && typeof parsedOutput === 'object' && !Array.isArray(parsedOutput)) + ? { + skillKey: resolved.capability.skillKey, + command: commandPlan.displayCommand, + ...parsedOutput, + stderr: output.stderr || undefined, + } + : { + skillKey: resolved.capability.skillKey, + command: commandPlan.displayCommand, + stdout: parsedOutput, + stderr: output.stderr || undefined, + }, + logs: [commandPlan.displayCommand, ...(output.stderr ? [output.stderr] : [])], + skillType: 'command', + renderHints: { + ...(resolved.capability.renderHints || {}), + card: resolved.capability.renderHints?.card || 'command-output', + preferredView: resolved.capability.renderHints?.preferredView || 'log', + skillType: 'command', + }, + durationMs: 0, + }; + } + default: + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: buildUnsupportedSkillSummary(resolved.capability), + error: { + code: 'skill_runtime_not_implemented', + message: buildUnsupportedSkillSummary(resolved.capability), + }, + durationMs: 0, + }; + } + }, + }; +} + +function createGenericSkillAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { + return { + toolName: '__generic_skill__', + matchesTool(toolName: string): boolean { + const capability = findCapabilityByToolName(capabilities, toolName); + return Boolean( + capability + && !isSpreadsheetCapability(capability) + && !isDocumentCapability(capability) + && !findSearchExecutionCapability(capabilities, toolName) + && !findBrowserExecutionCapability(capabilities, toolName) + && !findCommandExecutionCapability(capabilities, toolName), + ); + }, + async preflight(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { + const capability = findCapabilityByToolName(capabilities, invocation.toolName); + if ( + !capability + || isSpreadsheetCapability(capability) + || isDocumentCapability(capability) + || findSearchExecutionCapability(capabilities, invocation.toolName) + || findBrowserExecutionCapability(capabilities, invocation.toolName) + || findCommandExecutionCapability(capabilities, invocation.toolName) + ) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `No installed skill runtime is available for ${invocation.toolName}.`, + error: { + code: 'missing_skill_runtime', + message: `No installed skill runtime is available for ${invocation.toolName}.`, + }, + }; + } + + const input = ensureRecord(invocation.input) as GenericSkillInput & Record; + const attachments = resolveInvocationAttachments(input, context); + const config = await loadSkillExecutionConfig(capability.skillKey); + if (capability.inputExtensions.length > 0 && attachments.length === 0 && (context.files?.length ?? 0) === 0) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${capability.name} requires at least one supported attachment (${capability.inputExtensions.join(', ')}).`, + error: { + code: 'missing_required_attachment', + message: `Skill ${capability.name} requires at least one supported attachment (${capability.inputExtensions.join(', ')}).`, + }, + missing: ['attachment'], + metadata: { + skillKey: capability.skillKey, + category: capability.category, + }, + }; + } + + const missingEnvVars = capability.requiredEnvVars.filter((envName) => !getConfiguredEnvValue(config, envName)); + if (missingEnvVars.length > 0) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, + error: { + code: 'missing_required_env', + message: `Skill ${capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, + }, + missing: missingEnvVars, + metadata: { + skillKey: capability.skillKey, + category: capability.category, + requiredEnvVars: capability.requiredEnvVars, + }, + }; + } + + if (capability.requiresAuth && !hasConfiguredAuth(capability, config)) { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `Skill ${capability.name} requires user authorization or account configuration before it can run in chat.`, + error: { + code: 'user_authorization_required', + message: `Skill ${capability.name} requires user authorization or account configuration before it can run in chat.`, + }, + metadata: { + skillKey: capability.skillKey, + category: capability.category, + requiresAuth: true, + }, + }; + } + + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: buildUnsupportedSkillSummary(capability), + error: { + code: 'skill_runtime_not_implemented', + message: buildUnsupportedSkillSummary(capability), + }, + metadata: { + skillKey: capability.skillKey, + category: capability.category, + allowedTools: capability.allowedTools, + inputExtensions: capability.inputExtensions, + }, + }; + }, + async execute(invocation: ToolRuntimeInvocation): Promise { + const capability = findCapabilityByToolName(capabilities, invocation.toolName); + const summary = capability + ? buildUnsupportedSkillSummary(capability) + : `No installed skill runtime is available for ${invocation.toolName}.`; + + return { + ok: false, + status: 'error', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary, + error: { + code: capability ? 'skill_runtime_not_implemented' : 'missing_skill_runtime', + message: summary, + }, + durationMs: 0, + }; + }, + }; +} + +export function mapSkillCapabilitiesToRegistryInputs(capabilities: SkillCapability[]): ToolRegistryCapabilityInput[] { + return capabilities.map((capability) => ({ + capabilityKey: capability.skillKey, + toolName: capability.skillKey, + skillKey: capability.skillKey, + slug: capability.slug, + name: capability.name, + displayName: capability.name, + description: capability.description, + kind: 'skill', + aliases: dedupeStrings([ + capability.skillKey, + capability.slug, + capability.name, + ...capability.triggerHints, + ]), + inputKinds: capability.inputExtensions.length > 0 + ? ['text', 'file', 'attachment'] + : ['text'], + outputKinds: ['text', 'json', 'artifacts'], + triggerHints: capability.triggerHints, + supportedFileTypes: capability.inputExtensions, + requiresFiles: capability.inputExtensions.length > 0, + enabled: capability.enabled, + source: capability.source, + metadata: { + category: capability.category, + version: capability.version, + baseDir: capability.baseDir, + manifestPath: capability.manifestPath, + allowedTools: capability.allowedTools, + operationHints: capability.operationHints, + requiredEnvVars: capability.requiredEnvVars, + requiresAuth: capability.requiresAuth, + plannerSummary: capability.plannerSummary, + renderHints: capability.renderHints, + }, + })); +} + +export function createGatewayToolDefinitions(registry: ToolRegistryEntry[]): GatewayToolDefinition[] { + return registry.map((entry) => { + const fileItems = entry.requiresFiles + ? { + attachments: { + type: 'array', + items: { + type: 'object', + properties: { + fileName: { type: 'string' }, + mimeType: { type: 'string' }, + filePath: { type: 'string' }, + }, + required: ['filePath'], + additionalProperties: true, + }, + }, + filePaths: { + type: 'array', + items: { type: 'string' }, + }, + } + : {}; + + return { + name: entry.toolName, + description: entry.description || entry.displayName, + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string' }, + query: { type: 'string' }, + url: { type: 'string' }, + slug: { type: 'string' }, + kind: { type: 'string' }, + skillKey: { type: 'string' }, + count: { type: 'integer' }, + maxResults: { type: 'integer' }, + depth: { type: 'string' }, + searchDepth: { type: 'string' }, + timeRange: { type: 'string' }, + freshness: { type: 'string' }, + includeDomains: { + anyOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + excludeDomains: { + anyOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + topic: { type: 'string' }, + country: { type: 'string' }, + searchLang: { type: 'string' }, + uiLang: { type: 'string' }, + includeAnswer: { type: 'boolean' }, + includeRawContent: { + anyOf: [ + { type: 'boolean' }, + { type: 'string' }, + ], + }, + ...fileItems, + }, + additionalProperties: true, + }, + }; + }); +} + +export function createChatToolRuntime(capabilities: SkillCapability[]): ToolRuntime { + return createToolRuntime([ + createBrowserOpenAdapter(), + createBrowserSkillAdapter(capabilities), + createSkillsInstallAdapter(), + createSpreadsheetAnalysisAdapter(capabilities), + createDocumentAnalysisAdapter(capabilities), + createSearchExecutionAdapter(capabilities), + createCommandExecutionAdapter(capabilities), + createGenericSkillAdapter(capabilities), + ]); +} diff --git a/electron/gateway/handlers/chat.ts b/electron/gateway/handlers/chat.ts index a983ade..74bb924 100644 --- a/electron/gateway/handlers/chat.ts +++ b/electron/gateway/handlers/chat.ts @@ -1,21 +1,48 @@ import { createProvider } from '@electron/providers'; -import type { BaseProvider } from '@electron/providers/BaseProvider'; +import type { + BaseProvider, + ProviderCapabilities, + GatewayChatContentBlock, + GatewayChatMessage, +} from '@electron/providers/BaseProvider'; +import { DEFAULT_PROVIDER_CAPABILITIES } from '@electron/providers/BaseProvider'; import { providerApiService } from '@electron/service/provider-api-service'; import logManager from '@electron/service/logger'; import { normalizeAgentSessionKey } from '@runtime/lib/models'; -import type { RawMessage } from '@runtime/shared/chat-model'; -import { sessionStore } from '../session-store'; -import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types'; +import type { + ContentBlock, + RawMessage, + ToolCallPayload, + ToolStatus, +} from '@runtime/shared/chat-model'; 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 { + createChatToolRuntime, + createGatewayToolDefinitions, + mapSkillCapabilitiesToRegistryInputs, +} from '../chat-tooling'; import { createRandomId } from '../random-id'; +import { buildRuntimeContextMessages } from '../runtime-context'; +import { sessionStore } from '../session-store'; +import { getEnabledSkillCapabilities } from '../skill-capability-registry'; +import { planToolCall } from '../skill-planner'; +import { createToolRegistry } from '../tool-registry'; +import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types'; +import type { ToolRuntime } from '../tool-runtime'; -export interface GatewayChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string; -} +type ResolvedProviderTarget = { + accountId: string; + model: string; + provider: BaseProvider; + providerName: string; +}; + +type StreamedToolCallState = { + index: number; + id: string; + name: string; + argumentsText: string; +}; function flattenMessageContent(content: RawMessage['content']): string { if (typeof content === 'string') { @@ -32,6 +59,10 @@ function flattenMessageContent(content: RawMessage['content']): string { return block.text; } + if (block.type === 'thinking' && typeof block.thinking === 'string') { + return block.thinking; + } + if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.content === 'string') { return block.content; } @@ -40,31 +71,438 @@ function flattenMessageContent(content: RawMessage['content']): string { return flattenMessageContent(block.content as RawMessage['content']); } + if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.summary === 'string') { + return block.summary; + } + + if ( + (block.type === 'tool_result' || block.type === 'toolResult') + && block.result + && typeof block.result === 'object' + && 'summary' in block.result + && typeof block.result.summary === 'string' + ) { + return block.result.summary; + } + return ''; }) .filter(Boolean) .join('\n'); } +function contentBlockToGatewayBlock(block: ContentBlock): GatewayChatContentBlock | null { + switch (block.type) { + case 'text': + return typeof block.text === 'string' + ? { + type: 'text', + text: block.text, + } + : null; + case 'thinking': + return typeof block.thinking === 'string' + ? { + type: 'thinking', + thinking: block.thinking, + } + : null; + case 'tool_use': + case 'toolCall': + return { + type: 'tool_use', + id: block.id || block.toolCallId || createRandomId(), + name: block.name || 'tool', + input: block.input ?? block.arguments, + summary: block.summary, + }; + case 'tool_result': + case 'toolResult': + return { + type: 'tool_result', + toolCallId: block.toolCallId || block.id, + content: Array.isArray(block.content) + ? block.content + .map((child) => contentBlockToGatewayBlock(child)) + .filter((child): child is GatewayChatContentBlock => child !== null) + : block.content, + result: block.result, + summary: block.summary, + ok: block.ok, + error: block.error, + }; + default: + return null; + } +} + 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) { + .map((message): GatewayChatMessage | null => { + if (!message.role || !message.content) { return null; } - if (role === 'user' || role === 'assistant' || role === 'system') { + + const role = message.role; + const normalizedRole = role === 'toolresult' ? 'tool_result' : role; + + if (typeof message.content === 'string') { + const content = message.content.trim(); + if (!content) { + return null; + } + + if (normalizedRole === 'user' || normalizedRole === 'assistant' || normalizedRole === 'system' || normalizedRole === 'tool_result') { + return { + role: normalizedRole, + content, + name: message.toolName, + toolCallId: message.toolCallId, + }; + } + + return null; + } + + const blocks = message.content + .map((block) => contentBlockToGatewayBlock(block)) + .filter((block): block is GatewayChatContentBlock => block !== null); + + if (blocks.length === 0) { + const content = flattenMessageContent(message.content).trim(); + if (!content) { + return null; + } + return { - role, + role: normalizedRole, content, + name: message.toolName, + toolCallId: message.toolCallId, }; } - // Skip toolresult and unsupported roles for now - return null; + + return { + role: normalizedRole, + content: blocks, + name: message.toolName, + toolCallId: message.toolCallId, + }; }) - .filter((m): m is GatewayChatMessage => m !== null); + .filter((message): message is GatewayChatMessage => message !== null); +} + +function appendTranscriptMessage( + sessionKey: string, + message: RawMessage, + extras?: Record, +): void { + appendTranscriptLine(sessionKey, { + type: 'message', + timestamp: new Date().toISOString(), + message: { + role: message.role === 'tool_result' || message.role === 'toolresult' ? 'toolResult' : message.role, + content: flattenMessageContent(message.content), + toolCallId: message.toolCallId, + tool: message.toolName, + details: message.toolResult, + ...extras, + }, + }); +} + +function buildToolUseMessage(toolCallId: string, toolCall: ToolCallPayload): RawMessage { + const toolName = toolCall.name || 'tool'; + return { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: toolCallId, + name: toolName, + input: toolCall.input, + summary: toolCall.summary, + }, + ], + timestamp: Date.now(), + toolCallId, + toolName, + toolCall: { + id: toolCallId, + name: toolName, + input: toolCall.input, + summary: toolCall.summary, + }, + }; +} + +function buildMultiToolUseMessage(toolCalls: Array): RawMessage { + const firstToolCall = toolCalls[0]; + return { + role: 'assistant', + content: toolCalls.map((toolCall) => ({ + type: 'tool_use' as const, + id: toolCall.id, + name: toolCall.name || 'tool', + input: toolCall.input, + summary: toolCall.summary, + })), + timestamp: Date.now(), + toolCallId: firstToolCall?.id, + toolName: toolCalls.length === 1 ? firstToolCall?.name : undefined, + toolCall: toolCalls.length === 1 && firstToolCall + ? { + id: firstToolCall.id, + name: firstToolCall.name, + input: firstToolCall.input, + summary: firstToolCall.summary, + } + : null, + }; +} + +function buildToolStatus( + toolCallId: string, + toolCall: ToolCallPayload, + status: ToolStatus['status'], + summary: string, + updatedAt: number, + result?: unknown, + durationMs?: number, +): ToolStatus { + return { + id: toolCallId, + toolCallId, + name: toolCall.name || 'tool', + status, + updatedAt, + durationMs, + summary, + input: toolCall.input, + result, + }; +} + +function collectSessionFiles(sessionMessages: RawMessage[]): RawMessage['_attachedFiles'] { + const files = new Map[number]>(); + + for (let index = sessionMessages.length - 1; index >= 0; index -= 1) { + const message = sessionMessages[index]; + for (const attachment of message?._attachedFiles || []) { + const key = `${attachment.filePath || ''}|${attachment.fileName || ''}|${attachment.mimeType || ''}`; + if (!key.trim() || files.has(key)) { + continue; + } + files.set(key, attachment); + } + } + + return Array.from(files.values()); +} + +function parseProviderToolCallInput(argumentsText: string): unknown { + const trimmed = argumentsText.trim(); + if (!trimmed) { + return {}; + } + + try { + return JSON.parse(trimmed) as unknown; + } catch { + return { + rawArguments: trimmed, + }; + } +} + +function applyProviderToolCallDelta( + states: Map, + delta: NonNullable> extends AsyncIterable ? T : never>['toolCalls'][number], +): void { + const index = typeof delta.index === 'number' ? delta.index : states.size; + const existing = states.get(index) || { + index, + id: delta.id || createRandomId(), + name: delta.name || 'tool', + argumentsText: '', + }; + + if (typeof delta.id === 'string' && delta.id.trim()) { + existing.id = delta.id; + } + if (typeof delta.name === 'string' && delta.name.trim()) { + existing.name = delta.name; + } + if (typeof delta.argumentsDelta === 'string') { + existing.argumentsText += delta.argumentsDelta; + } + + states.set(index, existing); +} + +function finalizeProviderToolCalls( + states: Map, +): Array { + return Array.from(states.values()) + .sort((left, right) => left.index - right.index) + .filter((state) => state.name.trim()) + .map((state) => ({ + id: state.id, + name: state.name, + input: parseProviderToolCallInput(state.argumentsText), + summary: `Model requested ${state.name}.`, + })); +} + +function finalizeAssistantMessage( + sessionKey: string, + runId: string, + message: RawMessage, + broadcast: (event: GatewayEvent) => void, + extras?: Record, +): void { + sessionStore.appendMessage(sessionKey, message); + sessionStore.clearActiveRun(sessionKey); + appendTranscriptMessage(sessionKey, message, extras); + broadcast({ + type: 'chat:final', + sessionKey, + runId, + message, + }); +} + +async function executeToolCallAndPersist( + sessionKey: string, + runId: string, + runtime: ToolRuntime, + toolCallId: string, + toolCall: ToolCallPayload, + broadcast: (event: GatewayEvent) => void, +): Promise<{ finalStatus: ToolStatus; toolResultMessage: RawMessage }> { + const startedAt = Date.now(); + const runningStatus = buildToolStatus( + toolCallId, + toolCall, + 'running', + toolCall.summary || `Running ${toolCall.name || 'tool'}`, + startedAt, + ); + + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: runningStatus.name, + status: runningStatus.status, + updatedAt: runningStatus.updatedAt, + summary: runningStatus.summary, + input: runningStatus.input, + }); + + const toolRun = await runtime.run( + { + toolCallId, + toolName: toolCall.name || 'tool', + input: toolCall.input, + summary: toolCall.summary, + source: 'planner', + }, + { + sessionKey, + runId, + signal: sessionStore.getActiveRun(sessionKey)?.abortController.signal, + files: collectSessionFiles(sessionStore.getOrCreate(sessionKey).messages), + metadata: { + requestedBy: 'chat.send', + }, + }, + ); + + const finalStatus = buildToolStatus( + toolCallId, + toolCall, + toolRun.execution.status, + toolRun.normalized.summary || toolCall.summary || `Finished ${toolCall.name || 'tool'}`, + Date.now(), + toolRun.normalized.payload, + toolRun.execution.durationMs, + ); + + const toolResultMessage: RawMessage = { + ...toolRun.normalized.transcriptMessage, + _toolStatuses: [finalStatus], + }; + sessionStore.appendMessage(sessionKey, toolResultMessage); + appendTranscriptMessage(sessionKey, toolResultMessage, { + tool: toolCall.name, + toolCallId, + }); + + broadcast({ + type: 'tool:status', + sessionKey, + runId, + toolCallId, + toolName: finalStatus.name, + status: finalStatus.status, + updatedAt: finalStatus.updatedAt, + durationMs: finalStatus.durationMs, + summary: finalStatus.summary, + input: finalStatus.input, + result: finalStatus.result, + }); + + return { + finalStatus, + toolResultMessage, + }; +} + +function resolveProviderTarget( + options?: GatewayRpcParams['chat.send']['options'], +): ResolvedProviderTarget { + const accountId = options?.providerAccountId || providerApiService.getDefault().accountId; + if (!accountId) { + throw new Error('No provider account selected'); + } + + const account = providerApiService.getAccounts().find((candidate) => candidate.id === accountId); + if (!account) { + throw new Error(`Provider account ${accountId} not found`); + } + + const model = account.model; + if (!model) { + throw new Error(`Provider account ${accountId} has no model configured`); + } + + return { + accountId, + model, + provider: createProvider(accountId), + providerName: account.vendorId || account.label || account.model || 'unknown', + }; +} + +function tryResolveProviderTarget( + options?: GatewayRpcParams['chat.send']['options'], +): ResolvedProviderTarget | null { + try { + return resolveProviderTarget(options); + } catch (error) { + logManager.warn('Provider resolution skipped for this chat turn:', error); + return null; + } +} + +function getProviderCapabilities(provider: BaseProvider): ProviderCapabilities { + if (typeof provider.getCapabilities === 'function') { + return provider.getCapabilities(); + } + + return DEFAULT_PROVIDER_CAPABILITIES; } async function processChatStream( @@ -75,62 +513,115 @@ async function processChatStream( providerName: string, messages: GatewayChatMessage[], signal: AbortSignal, - broadcast: (event: GatewayEvent) => void + broadcast: (event: GatewayEvent) => void, ) { - let assistantContent = ''; - let finalUsage: any = undefined; + const capabilities = getEnabledSkillCapabilities(); + const capabilityInputs = mapSkillCapabilitiesToRegistryInputs(capabilities); + const registry = createToolRegistry({ + capabilities: capabilityInputs, + }); + const runtime = createChatToolRuntime(capabilities); + const providerCapabilities = getProviderCapabilities(provider); + const toolDefinitions = providerCapabilities.toolCalls + ? createGatewayToolDefinitions(registry) + : undefined; + const maxToolRounds = providerCapabilities.toolCalls && toolDefinitions && toolDefinitions.length > 0 ? 4 : 1; + let currentMessages = [...messages]; + let finalUsage: unknown = undefined; try { - const chunks = await provider.chat(messages, model, { signal }); - - for await (const chunk of chunks) { - if (signal.aborted) break; - - if (chunk.result) { - assistantContent += chunk.result; - broadcast({ - type: 'chat:delta', + for (let round = 0; round < maxToolRounds; round += 1) { + let assistantContent = ''; + const streamedToolCalls = new Map(); + const chunks = await provider.chat(currentMessages, model, { + signal, + ...(toolDefinitions?.length ? { tools: toolDefinitions, toolChoice: 'auto' as const } : {}), + metadata: { sessionKey, runId, - delta: chunk.result, - }); - } - - if (chunk.usage !== undefined) { - finalUsage = chunk.usage; - } - - // Do not break on isEnd; the iterable may still yield a trailing usage chunk. - // The loop will finish naturally when the generator is done. - } - - if (!signal.aborted) { - const finalMessage: RawMessage = { - role: 'assistant', - content: assistantContent, - timestamp: Date.now(), - }; - sessionStore.appendMessage(sessionKey, finalMessage); - sessionStore.clearActiveRun(sessionKey); - - appendTranscriptLine(sessionKey, { - type: 'message', - timestamp: new Date().toISOString(), - message: { - role: 'assistant', - content: assistantContent, - model, provider: providerName, - usage: finalUsage, + round, }, }); - broadcast({ - type: 'chat:final', - sessionKey, - runId, - message: finalMessage, + for await (const chunk of chunks) { + if (signal.aborted) { + break; + } + + if (chunk.result) { + assistantContent += chunk.result; + if (!providerCapabilities.toolCalls) { + broadcast({ + type: 'chat:delta', + sessionKey, + runId, + delta: chunk.result, + }); + } + } + + if (chunk.toolCalls?.length) { + for (const toolCallDelta of chunk.toolCalls) { + applyProviderToolCallDelta(streamedToolCalls, toolCallDelta); + } + } + + if (chunk.usage !== undefined) { + finalUsage = chunk.usage; + } + } + + if (signal.aborted) { + break; + } + + const providerToolCalls = finalizeProviderToolCalls(streamedToolCalls); + if (providerToolCalls.length === 0) { + if (providerCapabilities.toolCalls && assistantContent) { + broadcast({ + type: 'chat:delta', + sessionKey, + runId, + delta: assistantContent, + }); + } + + const finalMessage: RawMessage = { + role: 'assistant', + content: assistantContent, + timestamp: Date.now(), + }; + + finalizeAssistantMessage(sessionKey, runId, finalMessage, broadcast, { + model, + provider: providerName, + usage: finalUsage, + }); + return; + } + + const toolUseMessage = buildMultiToolUseMessage(providerToolCalls); + sessionStore.appendMessage(sessionKey, toolUseMessage); + appendTranscriptMessage(sessionKey, toolUseMessage, { + toolCalls: providerToolCalls.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + })), }); + currentMessages.push(...buildChatMessages([toolUseMessage])); + + for (const providerToolCall of providerToolCalls) { + const { toolResultMessage } = await executeToolCallAndPersist( + sessionKey, + runId, + runtime, + providerToolCall.id, + providerToolCall, + broadcast, + ); + currentMessages.push(...buildChatMessages([toolResultMessage])); + } } } catch (error) { sessionStore.clearActiveRun(sessionKey); @@ -143,93 +634,194 @@ async function processChatStream( } } +async function processPlannedToolRun( + sessionKey: string, + runId: string, + userMessage: RawMessage, + toolCallId: string, + toolCall: ToolCallPayload, + options: GatewayRpcParams['chat.send']['options'] | undefined, + broadcast: (event: GatewayEvent) => void, +): Promise { + const capabilities = getEnabledSkillCapabilities(); + const runtime = createChatToolRuntime(capabilities); + + try { + const { finalStatus, toolResultMessage } = await executeToolCallAndPersist( + sessionKey, + runId, + runtime, + toolCallId, + toolCall, + broadcast, + ); + + const providerTarget = tryResolveProviderTarget(options); + if (!providerTarget) { + finalizeAssistantMessage( + sessionKey, + runId, + { + role: 'assistant', + content: toolRun.normalized.summary || flattenMessageContent(toolResultMessage.content), + timestamp: Date.now(), + _toolStatuses: [finalStatus], + }, + broadcast, + { + tool: toolCall.name, + }, + ); + return; + } + + const session = sessionStore.getOrCreate(sessionKey); + const messages = [ + ...buildRuntimeContextMessages(sessionKey), + ...buildChatMessages(session.messages), + ]; + + await processChatStream( + sessionKey, + runId, + providerTarget.provider, + providerTarget.model, + providerTarget.providerName, + messages, + sessionStore.getActiveRun(sessionKey)?.abortController.signal || new AbortController().signal, + broadcast, + ); + } catch (error) { + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: 'chat:error', + sessionKey, + runId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function buildPlannerResponse( + sessionKey: string, + runId: string, + summary: string, + broadcast: (event: GatewayEvent) => void, +): GatewayRpcReturns['chat.send'] { + const finalMessage: RawMessage = { + role: 'assistant', + content: summary, + timestamp: Date.now(), + }; + finalizeAssistantMessage(sessionKey, runId, finalMessage, broadcast); + return { runId }; +} + export function handleChatSend( params: GatewayRpcParams['chat.send'], - broadcast: (event: GatewayEvent) => void + broadcast: (event: GatewayEvent) => void, ): GatewayRpcReturns['chat.send'] { const sessionKey = normalizeAgentSessionKey(params.sessionKey); const { message, options } = params; const runId = createRandomId(); - // 1. Append user message const userMessage: RawMessage = { ...message, timestamp: message.timestamp || Date.now(), }; - sessionStore.appendMessage(sessionKey, userMessage); - appendTranscriptLine(sessionKey, { - type: 'message', - timestamp: new Date().toISOString(), - message: { - role: 'user', - content: typeof userMessage.content === 'string' ? userMessage.content : '', - }, + sessionStore.appendMessage(sessionKey, userMessage); + appendTranscriptMessage(sessionKey, userMessage); + + const session = sessionStore.getOrCreate(sessionKey); + const capabilities = getEnabledSkillCapabilities(); + const capabilityInputs = mapSkillCapabilitiesToRegistryInputs(capabilities); + const registry = createToolRegistry({ + capabilities: capabilityInputs, + }); + const decision = planToolCall({ + message: userMessage, + attachments: userMessage._attachedFiles, + history: session.messages.slice(0, -1), + capabilities: capabilityInputs, + registry, }); - if (maybeHandleBrowserOpenMessage(sessionKey, runId, userMessage, broadcast)) { + if (decision.kind === 'tool' && decision.toolCall) { + const toolCallId = `${decision.toolCall.name || 'tool'}:${runId}`; + const toolUseMessage = buildToolUseMessage(toolCallId, decision.toolCall); + sessionStore.appendMessage(sessionKey, toolUseMessage); + appendTranscriptMessage(sessionKey, toolUseMessage, { + tool: decision.toolCall.name, + toolCallId, + }); + + const abortController = new AbortController(); + sessionStore.setActiveRun(sessionKey, runId, abortController); + + void processPlannedToolRun( + sessionKey, + runId, + userMessage, + toolCallId, + decision.toolCall, + options, + broadcast, + ); + return { runId }; } - if (maybeHandleSkillInstallMessage(sessionKey, runId, userMessage, broadcast)) { - return { runId }; + if (decision.kind === 'no-tool' && decision.blockingIssue) { + return buildPlannerResponse( + sessionKey, + runId, + decision.blockingIssue.message, + broadcast, + ); } - // 2. Resolve provider account - const accountId = options?.providerAccountId || providerApiService.getDefault().accountId; - if (!accountId) { - throw new Error('No provider account selected'); - } - - const account = providerApiService.getAccounts().find((a) => a.id === accountId); - if (!account) { - throw new Error(`Provider account ${accountId} not found`); - } - - const model = account.model; - if (!model) { - throw new Error(`Provider account ${accountId} has no model configured`); - } - - // 3. Build messages array from session history - const session = sessionStore.getOrCreate(sessionKey); + const providerTarget = resolveProviderTarget(options); const messages = [ ...buildRuntimeContextMessages(sessionKey), ...buildChatMessages(session.messages), ]; - // 4. Start streaming const abortController = new AbortController(); sessionStore.setActiveRun(sessionKey, runId, abortController); - // Run async stream processing in background - const provider = createProvider(accountId); - const providerName = account.vendorId || account.label || account.model || 'unknown'; - processChatStream(sessionKey, runId, provider, model, providerName, messages, abortController.signal, broadcast).catch( - (err) => { - logManager.error('Unexpected error in processChatStream:', err); - sessionStore.clearActiveRun(sessionKey); - broadcast({ - type: 'chat:error', - sessionKey, - runId, - error: err instanceof Error ? err.message : String(err), - }); - } - ); + void processChatStream( + sessionKey, + runId, + providerTarget.provider, + providerTarget.model, + providerTarget.providerName, + messages, + abortController.signal, + broadcast, + ).catch((error) => { + logManager.error('Unexpected error in processChatStream:', error); + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: 'chat:error', + sessionKey, + runId, + error: error instanceof Error ? error.message : String(error), + }); + }); return { runId }; } export function handleChatHistory( - params: GatewayRpcParams['chat.history'] + params: GatewayRpcParams['chat.history'], ): GatewayRpcReturns['chat.history'] { return sessionStore.getMessages(normalizeAgentSessionKey(params.sessionKey), params.limit ?? 50); } export function handleChatAbort( params: GatewayRpcParams['chat.abort'], - broadcast: (event: GatewayEvent) => void + broadcast: (event: GatewayEvent) => void, ): GatewayRpcReturns['chat.abort'] { const sessionKey = normalizeAgentSessionKey(params.sessionKey); const activeRun = sessionStore.getActiveRun(sessionKey); @@ -249,7 +841,7 @@ export function handleSessionList(): GatewayRpcReturns['session.list'] { } export function handleSessionDelete( - params: GatewayRpcParams['session.delete'] + params: GatewayRpcParams['session.delete'], ): GatewayRpcReturns['session.delete'] { sessionStore.deleteSession(normalizeAgentSessionKey(params.sessionKey)); return { success: true }; diff --git a/electron/gateway/handlers/skills.ts b/electron/gateway/handlers/skills.ts index 6274efd..4e6a472 100644 --- a/electron/gateway/handlers/skills.ts +++ b/electron/gateway/handlers/skills.ts @@ -1,4 +1,10 @@ +import { BrowserWindow } from 'electron'; import { ClawHubService } from '@electron/gateway/clawhub'; +import { + hydrateSkillCapabilityRegistry, + refreshSkillCapabilityRegistry, +} from '@electron/gateway/skill-capability-registry'; +import { windowManager } from '@electron/service/window-service'; import { SkillInstallService, SkillInstallServiceError, @@ -9,17 +15,34 @@ import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types type GatewayBroadcast = (event: GatewayEvent) => void; +function broadcastGatewayEvent(event: GatewayEvent): void { + const mainWindow = BrowserWindow.getAllWindows().find( + (win) => windowManager.getName(win) === 'main', + ) ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:event', event); + } +} + function broadcastSkillsRuntimeChanged(broadcast?: GatewayBroadcast, reason = 'skills:changed'): void { - broadcast?.({ + const event: GatewayEvent = { type: 'runtime:changed', topics: ['skills'], reason, syncedAt: new Date().toISOString(), - }); + }; + + if (broadcast) { + broadcast(event); + return; + } + + broadcastGatewayEvent(event); } export async function handleSkillsStatus(): Promise { const configs = await getAllSkillConfigs(); + hydrateSkillCapabilityRegistry(configs); return { skills: Object.entries(configs).map(([skillKey, config]) => ({ @@ -47,6 +70,7 @@ export async function handleSkillsStatus(): Promise { const { skillKey, enabled } = params; if (!skillKey || !String(skillKey).trim()) { @@ -58,6 +82,17 @@ export async function handleSkillsUpdate( throw new Error(result.error || 'Failed to update skill'); } + try { + const configs = await getAllSkillConfigs(); + hydrateSkillCapabilityRegistry(configs); + } catch (error) { + console.warn('Failed to refresh skill capability registry after skills.update:', error); + } + + const normalizedSkillKey = String(skillKey).trim(); + const action = enabled === false ? 'disabled' : enabled === true ? 'enabled' : 'updated'; + broadcastSkillsRuntimeChanged(broadcast, `skills:update:${normalizedSkillKey}:${action}`); + return { success: true }; } @@ -72,6 +107,11 @@ export async function handleSkillsInstall( try { const result = await installService.install(request); + try { + await refreshSkillCapabilityRegistry(); + } catch (error) { + console.warn('Failed to refresh skill capability registry after skills.install:', error); + } broadcastSkillsRuntimeChanged( broadcast, `skills:install:${result.source}:${result.slug}`, diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 3454b40..416394c 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -988,7 +988,7 @@ export class GatewayManager extends EventEmitter { throw new Error('OpenClaw Gateway socket is not connected'); } - const requestId = `${method}-${randomUUID()}`; + const requestId = `${method}-${createRandomId()}`; const timeoutMs = options?.timeoutMs ?? 30_000; return await new Promise((resolve, reject) => { @@ -1488,7 +1488,7 @@ export class GatewayManager extends EventEmitter { } this.exitCode = null; if (!reusingManagedProcess) { - this.gatewayToken = randomUUID(); + this.gatewayToken = createRandomId(); } this.setGatewayState({ state: wasAutoReconnectStart ? 'reconnecting' : 'starting', diff --git a/electron/gateway/rpc-dispatch.ts b/electron/gateway/rpc-dispatch.ts index 556af2c..5ce3c3d 100644 --- a/electron/gateway/rpc-dispatch.ts +++ b/electron/gateway/rpc-dispatch.ts @@ -72,7 +72,10 @@ export function dispatchGatewayRpcMethod( case 'skills.update': return { handled: true, - result: skillHandlers.handleSkillsUpdate(params), + result: skillHandlers.handleSkillsUpdate( + params as GatewayRpcParams['skills.update'], + broadcast, + ), }; case 'skills.install': return { diff --git a/electron/gateway/runtime-context.ts b/electron/gateway/runtime-context.ts index f040e6d..c3cfc3b 100644 --- a/electron/gateway/runtime-context.ts +++ b/electron/gateway/runtime-context.ts @@ -1,4 +1,5 @@ import type { GatewayChatMessage } from '@electron/providers/BaseProvider'; +import { getEnabledSkillCapabilities } from './skill-capability-registry'; const AGENT_RUNTIME_CONTEXT = [ 'You are zn-ai, a desktop AI assistant running through a local OpenClaw-style gateway.', @@ -6,13 +7,51 @@ const AGENT_RUNTIME_CONTEXT = [ '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'); +function formatRuntimeCapabilityLine(skill: ReturnType[number]): string { + const details: string[] = [`category=${skill.category}`]; + + if (skill.operationHints.length > 0) { + details.push(`operations=${skill.operationHints.join(',')}`); + } + + if (skill.inputExtensions.length > 0) { + details.push(`inputs=${skill.inputExtensions.join(',')}`); + } + + if (skill.allowedTools.length > 0) { + details.push(`allowedTools=${skill.allowedTools.join(',')}`); + } + + if (skill.requiredEnvVars.length > 0) { + details.push(`env=${skill.requiredEnvVars.join(',')}`); + } + + if (skill.requiresAuth) { + details.push('auth=required'); + } + + details.push(`summary=${skill.plannerSummary}`); + + return `- ${skill.slug}: ${details.join('; ')}`; +} + +function buildToolRuntimeContext(): string { + const enabledSkillCapabilities = getEnabledSkillCapabilities(); + const skillCapabilityLines = enabledSkillCapabilities.length > 0 + ? enabledSkillCapabilities.map((skill) => formatRuntimeCapabilityLine(skill)) + : ['- none']; + + return [ + '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.', + 'Enabled skill capabilities registered for routing/planning:', + ...skillCapabilityLines, + 'Treat registered skills as planning capabilities for the downstream planner/executor chain, not as a claim that execution already happened.', + 'Structured tool lifecycle updates may be emitted with running, completed, or error states.', + 'Only claim a tool or skill action completed after the runtime reports success.', + ].join('\n'); +} export function buildRuntimeContextMessages(sessionKey: string): GatewayChatMessage[] { return [ @@ -20,7 +59,7 @@ export function buildRuntimeContextMessages(sessionKey: string): GatewayChatMess role: 'system', content: [ AGENT_RUNTIME_CONTEXT, - TOOL_RUNTIME_CONTEXT, + buildToolRuntimeContext(), `Current session key: ${sessionKey}`, ].join('\n\n'), }, diff --git a/electron/gateway/skill-capability-parser.ts b/electron/gateway/skill-capability-parser.ts new file mode 100644 index 0000000..eafdb0e --- /dev/null +++ b/electron/gateway/skill-capability-parser.ts @@ -0,0 +1,498 @@ +import type { SkillConfigRecord } from '@electron/utils/skill-config'; +import type { ToolRenderHints } from '@runtime/shared/chat-model'; + +const FRONTMATTER_PATTERN = /^---\s*\r?\n([\s\S]*?)\r?\n---/; +const FILE_EXTENSION_PATTERN = /\.[a-z0-9]{2,8}\b/gi; +const ENV_VAR_PATTERN = /\$\{([A-Z][A-Z0-9_]+)\}/g; +const QUOTED_TEXT_PATTERN = /"([^"\n]{2,80})"|'([^'\n]{2,80})'/g; +const CODE_FENCE_PATTERN = /```([^\n`]*)\r?\n([\s\S]*?)```/g; +const SPREADSHEET_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.csv', '.tsv', '.ods']; +const DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.doc', '.pptx', '.ppt', '.odt', '.odp', '.rtf']; +const COMMON_FILE_EXTENSIONS = [ + ...SPREADSHEET_EXTENSIONS, + ...DOCUMENT_EXTENSIONS, + '.txt', + '.md', + '.json', + '.xml', + '.html', + '.htm', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.bmp', + '.webp', + '.svg', +]; + +const OPERATION_KEYWORDS: Array<[string, string[]]> = [ + ['search', ['search', 'look up', 'lookup', 'find', 'discover']], + ['research', ['research', 'investigate']], + ['extract', ['extract', 'scrape']], + ['crawl', ['crawl', 'map']], + ['read', ['read', 'open', 'inspect', 'review']], + ['analyze', ['analyze', 'analysis', 'summarize', 'statistics']], + ['create', ['create', 'build', 'generate', 'produce']], + ['edit', ['edit', 'modify', 'update', 'fill cells', 'add formulas']], + ['convert', ['convert', 'transform', 'export']], + ['validate', ['validate', 'check formulas', 'verify']], + ['repair', ['repair', 'fix', 'recover']], + ['format', ['format', 'styling', 'style']], +]; + +export interface SkillCapability { + skillKey: string; + slug: string; + name: string; + description: string; + enabled: boolean; + category: string; + version?: string; + source?: string; + baseDir?: string; + manifestPath?: string; + allowedTools: string[]; + operationHints: string[]; + triggerHints: string[]; + inputExtensions: string[]; + requiredEnvVars: string[]; + requiresAuth: boolean; + plannerSummary: string; + commandExamples?: string[]; + renderHints?: ToolRenderHints; +} + +export interface SkillCapabilityParserInput { + skillKey: string; + config?: Partial & { + enabled?: boolean; + apiKey?: string; + env?: Record; + }; + manifestContent?: string | null; +} + +type ParsedFrontmatter = { + name?: string; + description?: string; + version?: string; + category?: string; + allowedTools: string[]; +}; + +function uniq(values: Array): string[] { + return Array.from(new Set(values.map((value) => value?.trim()).filter(Boolean) as string[])); +} + +function extractFrontmatter(markdown?: string | null): { frontmatter: string; body: string } { + if (!markdown) { + return { frontmatter: '', body: '' }; + } + + const match = markdown.match(FRONTMATTER_PATTERN); + if (!match) { + return { frontmatter: '', body: markdown }; + } + + return { + frontmatter: match[1] || '', + body: markdown.slice(match[0].length).trim(), + }; +} + +function parseScalarFrontmatterValue(frontmatter: string, key: string): string | undefined { + const pattern = new RegExp(`^${key}:\\s*(.+)$`, 'im'); + const match = frontmatter.match(pattern); + if (!match?.[1]) { + return undefined; + } + + const value = match[1].trim().replace(/^["']|["']$/g, ''); + if (value === '|' || value === '>') { + return undefined; + } + + return value; +} + +function parseNestedFrontmatterValue(frontmatter: string, key: string): string | undefined { + const pattern = new RegExp(`^\\s+${key}:\\s*(.+)$`, 'im'); + const match = frontmatter.match(pattern); + if (!match?.[1]) { + return undefined; + } + + return match[1].trim().replace(/^["']|["']$/g, ''); +} + +function parseDescription(frontmatter: string, markdown?: string | null): string | undefined { + const blockMatch = frontmatter.match(/^description:\s*\|\s*\r?\n([\s\S]*?)(?:\r?\n(?:[A-Za-z0-9_-]+:|[A-Za-z0-9_-]+:\s)|$)/im); + if (blockMatch?.[1]) { + return blockMatch[1] + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .trim(); + } + + const scalar = parseScalarFrontmatterValue(frontmatter, 'description'); + if (scalar) { + return scalar; + } + + if (!markdown) { + return undefined; + } + + const lines = markdown + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + for (const line of lines) { + if (line.startsWith('#') || line === '---') { + continue; + } + return line.replace(/\s+/g, ' ').trim(); + } + + return undefined; +} + +function parseAllowedTools(frontmatter: string): string[] { + const scalar = parseScalarFrontmatterValue(frontmatter, 'allowed-tools'); + if (!scalar) { + return []; + } + + return uniq(scalar.split(',')); +} + +function parseCategory(frontmatter: string): string | undefined { + const scalar = parseScalarFrontmatterValue(frontmatter, 'category'); + if (scalar) { + return scalar; + } + + const nested = frontmatter.match(/^\s+category:\s*(.+)$/im); + if (!nested?.[1]) { + return undefined; + } + + return nested[1].trim().replace(/^["']|["']$/g, ''); +} + +function parseFrontmatter(markdown?: string | null): ParsedFrontmatter { + const { frontmatter } = extractFrontmatter(markdown); + return { + name: parseScalarFrontmatterValue(frontmatter, 'name'), + description: parseDescription(frontmatter, markdown), + version: parseScalarFrontmatterValue(frontmatter, 'version') + || parseNestedFrontmatterValue(frontmatter, 'version'), + category: parseCategory(frontmatter), + allowedTools: parseAllowedTools(frontmatter), + }; +} + +function collectMatches(pattern: RegExp, text: string): string[] { + const matches: string[] = []; + const regex = new RegExp(pattern.source, pattern.flags); + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + const value = match.slice(1).find(Boolean) || match[0]; + if (value) { + matches.push(value); + } + } + + return uniq(matches); +} + +function detectOperations(text: string): string[] { + const lowerText = text.toLowerCase(); + const operations = OPERATION_KEYWORDS + .filter(([, keywords]) => keywords.some((keyword) => lowerText.includes(keyword))) + .map(([operation]) => operation); + + return operations.length > 0 ? operations : ['assist']; +} + +function detectInputExtensions(text: string): string[] { + return uniq( + collectMatches(FILE_EXTENSION_PATTERN, text) + .map((value) => value.toLowerCase()) + .filter((value) => COMMON_FILE_EXTENSIONS.includes(value)), + ); +} + +function detectTriggerHints(text: string, inputExtensions: string[]): string[] { + const quotedHints = collectMatches(QUOTED_TEXT_PATTERN, text) + .filter((value) => value.length <= 64); + + const sentenceHints = text + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter((sentence) => { + const lowerSentence = sentence.toLowerCase(); + return lowerSentence.includes('use this skill') + || lowerSentence.includes('use when') + || lowerSentence.includes('trigger') + || lowerSentence.includes('when to use'); + }) + .slice(0, 4) + .map((sentence) => sentence.replace(/\s+/g, ' ').trim()); + + return uniq([...quotedHints, ...sentenceHints, ...inputExtensions]).slice(0, 8); +} + +function detectRequiredEnvVars( + manifestContent: string, + config?: SkillCapabilityParserInput['config'], +): string[] { + const fromManifest = collectMatches(ENV_VAR_PATTERN, manifestContent); + const fromConfig = config?.env ? Object.keys(config.env) : []; + return uniq([...fromManifest, ...fromConfig]); +} + +function isCommandFenceLanguage(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === '' + || ['bash', 'sh', 'shell', 'zsh', 'powershell', 'pwsh', 'ps1', 'cmd', 'bat'].includes(normalized); +} + +function normalizeCommandBlockLines(block: string): string[] { + const lines = block + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !line.startsWith('#')); + + const combined: string[] = []; + for (const line of lines) { + if (combined.length > 0 && combined[combined.length - 1].endsWith('\\')) { + combined[combined.length - 1] = `${combined[combined.length - 1].slice(0, -1).trim()} ${line}`; + continue; + } + + combined.push(line); + } + + return combined; +} + +function isSafeCommandExample(line: string): boolean { + const normalized = line.trim(); + if (!normalized) { + return false; + } + + if (normalized.startsWith('$ ')) { + return isSafeCommandExample(normalized.slice(2)); + } + + if ( + normalized.includes('|') + || normalized.includes('&&') + || normalized.includes('||') + || normalized.includes(';') + || normalized.includes('>$') + || normalized.includes('$(') + || normalized.includes('`') + || normalized.includes('<<') + ) { + return false; + } + + return !/^(curl|wget|brew|apt|apt-get|yum|dnf|pip|pip3|pnpm|npm|yarn)\s+(install|add)\b/i.test(normalized) + && !/\blogin\b/i.test(normalized) + && !/\bsetup\b/i.test(normalized); +} + +function extractCommandExamples(markdown?: string | null): string[] { + if (!markdown) { + return []; + } + + const examples: string[] = []; + for (const match of markdown.matchAll(CODE_FENCE_PATTERN)) { + const language = match[1] || ''; + const block = match[2] || ''; + if (!isCommandFenceLanguage(language)) { + continue; + } + + for (const line of normalizeCommandBlockLines(block)) { + const candidate = line.startsWith('$ ') ? line.slice(2).trim() : line; + if (!isSafeCommandExample(candidate)) { + continue; + } + examples.push(candidate); + } + } + + return uniq(examples).slice(0, 12); +} + +function inferCategory( + parsedCategory: string | undefined, + operationHints: string[], + inputExtensions: string[], +): string { + if (parsedCategory) { + return parsedCategory; + } + + if (inputExtensions.some((extension) => SPREADSHEET_EXTENSIONS.includes(extension))) { + return 'document'; + } + + if (inputExtensions.some((extension) => DOCUMENT_EXTENSIONS.includes(extension))) { + return 'document'; + } + + if (operationHints.some((operation) => ['search', 'research', 'crawl', 'extract'].includes(operation))) { + return 'search'; + } + + return 'general'; +} + +function inferRenderHints( + category: string, + operationHints: string[], + inputExtensions: string[], + skillKey: string, +): ToolRenderHints { + if (inputExtensions.some((extension) => SPREADSHEET_EXTENSIONS.includes(extension))) { + return { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + metadata: { + skillKey, + operations: operationHints, + }, + }; + } + + if (inputExtensions.some((extension) => DOCUMENT_EXTENSIONS.includes(extension)) || category === 'document') { + return { + card: 'document-analysis', + preferredView: 'summary', + skillType: 'document', + metadata: { + skillKey, + operations: operationHints, + inputExtensions, + }, + }; + } + + if (category === 'search' || operationHints.some((operation) => ['search', 'research', 'crawl'].includes(operation))) { + return { + card: 'search-results', + preferredView: 'summary', + skillType: 'search', + metadata: { + skillKey, + operations: operationHints, + }, + }; + } + + return { + card: 'generic', + preferredView: 'summary', + skillType: category, + metadata: { + skillKey, + operations: operationHints, + }, + }; +} + +function truncateDescription(value: string, maxLength = 180): string { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength - 3).trim()}...`; +} + +function buildPlannerSummary( + category: string, + operationHints: string[], + inputExtensions: string[], + description: string, + requiresAuth: boolean, +): string { + const segments: string[] = []; + + segments.push(`${category} skill`); + + if (operationHints.length > 0) { + segments.push(`operations: ${operationHints.join(', ')}`); + } + + if (inputExtensions.length > 0) { + segments.push(`inputs: ${inputExtensions.join(', ')}`); + } + + if (requiresAuth) { + segments.push('auth/environment required'); + } + + segments.push(truncateDescription(description)); + + return segments.join('; '); +} + +export function parseSkillCapability(input: SkillCapabilityParserInput): SkillCapability { + const manifestContent = input.manifestContent?.trim() || ''; + const parsed = parseFrontmatter(manifestContent); + const config = input.config || {}; + const name = parsed.name || config.name || config.slug || input.skillKey; + const description = parsed.description || config.description || `Enabled skill ${name}`; + const combinedText = [description, manifestContent].filter(Boolean).join('\n'); + const operationHints = detectOperations(combinedText); + const inputExtensions = detectInputExtensions(combinedText); + const requiredEnvVars = detectRequiredEnvVars(manifestContent, config); + const commandExamples = extractCommandExamples(manifestContent); + const requiresAuth = Boolean(config.apiKey) + || requiredEnvVars.length > 0 + || /requires api key|api key required|authentication required|authorization required|authorize|authorization|login\b|sign in|oauth|access token|bearer token/i.test(combinedText); + const category = inferCategory(parsed.category, operationHints, inputExtensions); + const renderHints = inferRenderHints(category, operationHints, inputExtensions, input.skillKey); + + return { + skillKey: input.skillKey, + slug: config.slug || input.skillKey, + name, + description, + enabled: config.enabled !== false, + category, + version: config.version || parsed.version, + source: config.source, + baseDir: config.baseDir, + manifestPath: config.filePath, + allowedTools: uniq(parsed.allowedTools), + operationHints, + triggerHints: detectTriggerHints(combinedText, inputExtensions), + inputExtensions, + requiredEnvVars, + requiresAuth, + commandExamples, + plannerSummary: buildPlannerSummary( + category, + operationHints, + inputExtensions, + description, + requiresAuth, + ), + renderHints, + }; +} diff --git a/electron/gateway/skill-capability-registry.ts b/electron/gateway/skill-capability-registry.ts new file mode 100644 index 0000000..c5ce2dc --- /dev/null +++ b/electron/gateway/skill-capability-registry.ts @@ -0,0 +1,228 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + getAllSkillConfigs, + type SkillConfigRecord, +} from '@electron/utils/skill-config'; +import { getOpenClawConfigDir } from '@electron/utils/paths'; +import { + parseSkillCapability, + type SkillCapability, +} from './skill-capability-parser'; + +type SkillConfigEntry = { + enabled?: boolean; + apiKey?: string; + env?: Record; +}; + +type RegistrySnapshot = { + capabilities: SkillCapability[]; + loadedAt: string; + source: 'disk-sync' | 'skill-config'; +}; + +const OPENCLAW_CONFIG_FILE = 'openclaw.json'; +const OPENCLAW_SKILLS_DIR = 'skills'; +const SKILL_MANIFEST_FILE = 'SKILL.md'; + +let registrySnapshot: RegistrySnapshot | null = null; +let refreshPromise: Promise | null = null; + +function uniq(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function cloneCapability(capability: SkillCapability): SkillCapability { + return { + ...capability, + allowedTools: [...capability.allowedTools], + operationHints: [...capability.operationHints], + triggerHints: [...capability.triggerHints], + inputExtensions: [...capability.inputExtensions], + requiredEnvVars: [...capability.requiredEnvVars], + commandExamples: capability.commandExamples ? [...capability.commandExamples] : undefined, + renderHints: capability.renderHints + ? { + ...capability.renderHints, + metadata: capability.renderHints.metadata + ? { ...capability.renderHints.metadata } + : undefined, + } + : undefined, + }; +} + +function sortCapabilities(capabilities: SkillCapability[]): SkillCapability[] { + return [...capabilities].sort((left, right) => { + if (left.enabled !== right.enabled) { + return left.enabled ? -1 : 1; + } + + return left.slug.localeCompare(right.slug); + }); +} + +function readManifestContent(manifestPath?: string): string { + if (!manifestPath || !existsSync(manifestPath)) { + return ''; + } + + try { + return readFileSync(manifestPath, 'utf-8'); + } catch { + return ''; + } +} + +function setRegistrySnapshot( + capabilities: SkillCapability[], + source: RegistrySnapshot['source'], +): SkillCapability[] { + registrySnapshot = { + capabilities: sortCapabilities(capabilities), + loadedAt: new Date().toISOString(), + source, + }; + + return registrySnapshot.capabilities.map(cloneCapability); +} + +function buildCapabilityFromConfig( + skillKey: string, + config: Partial & { + enabled?: boolean; + apiKey?: string; + env?: Record; + }, +): SkillCapability { + const manifestPath = config.filePath || (config.baseDir ? join(config.baseDir, SKILL_MANIFEST_FILE) : undefined); + const manifestContent = readManifestContent(manifestPath); + + return parseSkillCapability({ + skillKey, + config: { + ...config, + filePath: manifestPath, + }, + manifestContent, + }); +} + +function readSkillConfigEntriesSync(): Record { + const configPath = join(getOpenClawConfigDir(), OPENCLAW_CONFIG_FILE); + if (!existsSync(configPath)) { + return {}; + } + + try { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as { + skills?: { + entries?: Record; + }; + }; + return parsed.skills?.entries || {}; + } catch { + return {}; + } +} + +function readInstalledSkillsSync(): Record> { + const skillsDir = join(getOpenClawConfigDir(), OPENCLAW_SKILLS_DIR); + if (!existsSync(skillsDir)) { + return {}; + } + + const result: Record> = {}; + + for (const entry of readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const baseDir = join(skillsDir, entry.name); + const manifestPath = join(baseDir, SKILL_MANIFEST_FILE); + if (!existsSync(manifestPath)) { + continue; + } + + result[entry.name] = { + slug: entry.name, + name: entry.name, + baseDir, + filePath: manifestPath, + }; + } + + return result; +} + +function buildSynchronousSnapshot(): SkillCapability[] { + const installedSkills = readInstalledSkillsSync(); + const configEntries = readSkillConfigEntriesSync(); + const skillKeys = uniq([ + ...Object.keys(installedSkills), + ...Object.keys(configEntries), + ]); + + const capabilities = skillKeys.map((skillKey) => { + const installedSkill = installedSkills[skillKey] || {}; + const configEntry = configEntries[skillKey] || {}; + return buildCapabilityFromConfig(skillKey, { + ...installedSkill, + enabled: configEntry.enabled, + apiKey: configEntry.apiKey, + env: configEntry.env, + }); + }); + + return setRegistrySnapshot(capabilities, 'disk-sync'); +} + +function ensureRegistrySnapshot(): RegistrySnapshot { + if (!registrySnapshot) { + buildSynchronousSnapshot(); + } + + return registrySnapshot || { + capabilities: [], + loadedAt: new Date().toISOString(), + source: 'disk-sync', + }; +} + +export function hydrateSkillCapabilityRegistry( + configs: Record, +): SkillCapability[] { + const capabilities = Object.entries(configs).map(([skillKey, config]) => + buildCapabilityFromConfig(skillKey, config), + ); + + return setRegistrySnapshot(capabilities, 'skill-config'); +} + +export async function refreshSkillCapabilityRegistry(): Promise { + if (!refreshPromise) { + refreshPromise = getAllSkillConfigs() + .then((configs) => hydrateSkillCapabilityRegistry(configs)) + .finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; +} + +export function listSkillCapabilities(options?: { enabledOnly?: boolean }): SkillCapability[] { + const snapshot = ensureRegistrySnapshot(); + const capabilities = options?.enabledOnly + ? snapshot.capabilities.filter((capability) => capability.enabled) + : snapshot.capabilities; + + return capabilities.map(cloneCapability); +} + +export function getEnabledSkillCapabilities(): SkillCapability[] { + return listSkillCapabilities({ enabledOnly: true }); +} diff --git a/electron/gateway/skill-install-shortcut.ts b/electron/gateway/skill-install-shortcut.ts index dca6eb3..0f6ba66 100644 --- a/electron/gateway/skill-install-shortcut.ts +++ b/electron/gateway/skill-install-shortcut.ts @@ -2,7 +2,7 @@ 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 type { RawMessage, ToolResultPayload, ToolStatus } from '@runtime/shared/chat-model'; import { handleSkillsInstall } from './handlers/skills'; import { sessionStore } from './session-store'; import type { GatewayEvent } from './types'; @@ -93,6 +93,7 @@ async function processSkillInstall( const toolCallId = `skills.install:${runId}`; const startedAt = Date.now(); let finalToolStatus: ToolStatus | null = null; + let finalToolResult: ToolResultPayload | null = null; broadcast({ type: 'tool:status', @@ -115,6 +116,15 @@ async function processSkillInstall( } assistantText = describeInstallResult(result); + finalToolResult = { + ok: true, + summary: assistantText, + structuredData: result, + renderHints: { + card: 'skill-install', + }, + raw: result, + }; finalToolStatus = { id: toolCallId, toolCallId, @@ -145,6 +155,18 @@ async function processSkillInstall( } assistantText = describeInstallFailure(error); + finalToolResult = { + ok: false, + summary: assistantText, + error: error instanceof Error ? error.message : String(error), + retryable: true, + renderHints: { + card: 'skill-install', + }, + raw: { + error: error instanceof Error ? error.message : String(error), + }, + }; finalToolStatus = { id: toolCallId, toolCallId, @@ -179,6 +201,7 @@ async function processSkillInstall( role: 'assistant', content: assistantText, timestamp: Date.now(), + toolResult: finalToolResult, _toolStatuses: finalToolStatus ? [finalToolStatus] : undefined, }; sessionStore.appendMessage(sessionKey, finalMessage); diff --git a/electron/gateway/skill-planner.ts b/electron/gateway/skill-planner.ts new file mode 100644 index 0000000..cefec9e --- /dev/null +++ b/electron/gateway/skill-planner.ts @@ -0,0 +1,809 @@ +import type { + AttachedFileMeta, + ContentBlock, + RawMessage, + ToolCallPayload, +} from '@runtime/shared/chat-model'; +import { + createToolRegistry, + getRegistryEntryByName, + getRegistryEntriesByFamily, + getSpreadsheetFamilyKey, + isSpreadsheetFamilyEntry, + isSpreadsheetFileType, + matchRegistryEntriesByAlias, + registryEntrySupportsFileType, + type ToolRegistryCapabilityInput, + type ToolRegistryEntry, +} from './tool-registry'; + +export type PlannerDecisionReason = + | 'browser_open_url' + | 'skills_install' + | 'explicit_skill_request' + | 'spreadsheet_analysis' + | 'missing_required_attachment' + | 'missing_required_url' + | 'no_matching_capability' + | 'no_tool_needed'; + +export interface PlannerBlockingIssue { + code: + | 'missing_required_attachment' + | 'missing_required_url' + | 'insufficient_user_intent'; + message: string; + missing?: string[]; +} + +export interface PlannerCapabilityMatch { + capabilityKey: string; + familyKey: string; + toolName: string; + displayName: string; + score: number; + reasons: string[]; +} + +export interface PlannerAttachmentContext { + current: AttachedFileMeta[]; + recent: AttachedFileMeta[]; + selected: AttachedFileMeta[]; + spreadsheet: AttachedFileMeta[]; + usedHistoryAttachments: boolean; +} + +export interface PlannerSelectedCapability { + capabilityKey: string; + familyKey: string; + toolName: string; + displayName: string; + kind: ToolRegistryEntry['kind']; +} + +export interface PlannerDecision { + kind: 'tool' | 'no-tool'; + reason: PlannerDecisionReason; + summary: string; + thinking: string; + normalizedUserText: string; + attachmentContext: PlannerAttachmentContext; + matchedCapabilities: PlannerCapabilityMatch[]; + selectedCapability?: PlannerSelectedCapability; + toolCall?: ToolCallPayload; + blockingIssue?: PlannerBlockingIssue; +} + +export interface PlannerInput { + message?: RawMessage; + userText?: string; + attachments?: AttachedFileMeta[]; + history?: RawMessage[]; + capabilities?: ToolRegistryCapabilityInput[]; + registry?: ToolRegistryEntry[]; +} + +type InstallPlanInput = + | { + kind: 'marketplace'; + slug: string; + force?: boolean; + } + | { + kind: 'github-url'; + url: string; + force?: boolean; + }; + +const FILE_REFERENCE_KEYWORDS = [ + 'this file', + 'the file', + 'this attachment', + 'the attachment', + 'this sheet', + 'that sheet', + 'this spreadsheet', + 'this excel', + '这个文件', + '该文件', + '这个附件', + '这个表', + '这份表', + '这个表格', + '这个 excel', + '这个工作表', +]; + +const ANALYSIS_KEYWORDS = [ + 'analyze', + 'analyse', + 'analysis', + 'inspect', + 'review', + 'summarize', + 'summary', + 'report', + '统计', + '分析', + '汇总', + '总结', + '看看', + '处理', +]; + +const OPEN_URL_KEYWORDS = [ + 'open', + 'visit', + 'browse', + 'navigate', + '打开', + '访问', + '进入', +]; + +const INSTALL_KEYWORDS = [ + 'install', + 'add skill', + 'enable and install', + '安装', + '添加技能', + '安装 skill', +]; + +const SKILL_WORD_KEYWORDS = ['skill', 'skills', '技能']; + +function normalizeText(value: string | undefined | null): string { + return String(value ?? '').trim(); +} + +function normalizeLookupText(value: string | undefined | null): string { + return normalizeText(value).toLowerCase(); +} + +function dedupeAttachments(values: AttachedFileMeta[]): AttachedFileMeta[] { + const seen = new Set(); + const result: AttachedFileMeta[] = []; + + for (const attachment of values) { + const key = [ + normalizeLookupText(attachment.filePath), + normalizeLookupText(attachment.fileName), + normalizeLookupText(attachment.mimeType), + ].join('|'); + + if (!attachment.fileName && !attachment.filePath) { + continue; + } + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(attachment); + } + + return result; +} + +function flattenContent(content: RawMessage['content'] | ContentBlock[] | string): string { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .map((block) => { + if (!block || typeof block !== 'object') { + return ''; + } + + if (block.type === 'text' && typeof block.text === 'string') { + return block.text; + } + + if (block.type === 'thinking' && typeof block.thinking === 'string') { + return block.thinking; + } + + if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.summary === 'string') { + return block.summary; + } + + 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 flattenContent(block.content); + } + + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +function extractMessageText(message?: RawMessage): string { + if (!message) { + return ''; + } + + return flattenContent(message.content); +} + +function parseAttachmentRefsFromText(text: string): AttachedFileMeta[] { + const matches = text.matchAll(/\[media attached:\s*(.+?)\s*\((.+?)\)\s*\|\s*(.+?)\]/gi); + const attachments: AttachedFileMeta[] = []; + + for (const match of matches) { + const fileName = normalizeText(match[1]); + const mimeType = normalizeText(match[2]) || 'application/octet-stream'; + const filePath = normalizeText(match[3]); + if (!fileName && !filePath) { + continue; + } + attachments.push({ + fileName: fileName || filePath.split(/[\\/]/).pop() || 'attachment', + mimeType, + fileSize: 0, + preview: null, + filePath, + source: 'message-ref', + }); + } + + return attachments; +} + +function collectCurrentAttachments(input: PlannerInput, text: string): AttachedFileMeta[] { + return dedupeAttachments([ + ...(input.attachments || []), + ...(input.message?._attachedFiles || []), + ...parseAttachmentRefsFromText(text), + ]); +} + +function collectRecentAttachments(history: RawMessage[] | undefined): AttachedFileMeta[] { + if (!history || history.length === 0) { + return []; + } + + const attachments: AttachedFileMeta[] = []; + for (let index = history.length - 1; index >= 0; index -= 1) { + const message = history[index]; + if (message.role !== 'user') { + continue; + } + + attachments.push(...(message._attachedFiles || [])); + attachments.push(...parseAttachmentRefsFromText(extractMessageText(message))); + + if (attachments.length >= 8) { + break; + } + } + + return dedupeAttachments(attachments); +} + +function hasAnyKeyword(text: string, keywords: string[]): boolean { + const normalizedText = normalizeLookupText(text); + return keywords.some((keyword) => normalizedText.includes(normalizeLookupText(keyword))); +} + +function extractUrls(text: string): string[] { + const urls = text.match(/https?:\/\/[^\s)\]]+/gi) || []; + return Array.from(new Set(urls.map((item) => item.trim()))); +} + +function isSpreadsheetAttachment(attachment: AttachedFileMeta): boolean { + const fileName = normalizeLookupText(attachment.fileName); + const mimeType = normalizeLookupText(attachment.mimeType); + const filePath = normalizeLookupText(attachment.filePath); + + return isSpreadsheetFileType(fileName) || isSpreadsheetFileType(filePath) || isSpreadsheetFileType(mimeType); +} + +function shouldReuseHistoryAttachments(text: string, explicitSpreadsheetMention: boolean): boolean { + return explicitSpreadsheetMention + || hasAnyKeyword(text, FILE_REFERENCE_KEYWORDS) + || hasAnyKeyword(text, ANALYSIS_KEYWORDS); +} + +function pickSelectedAttachments( + current: AttachedFileMeta[], + recent: AttachedFileMeta[], + text: string, + explicitSpreadsheetMention: boolean, +): PlannerAttachmentContext { + const usedHistoryAttachments = current.length === 0 && recent.length > 0 && shouldReuseHistoryAttachments(text, explicitSpreadsheetMention); + const selected = current.length > 0 ? current : (usedHistoryAttachments ? recent : []); + const spreadsheet = selected.filter((attachment) => isSpreadsheetAttachment(attachment)); + + return { + current, + recent, + selected, + spreadsheet, + usedHistoryAttachments, + }; +} + +function buildCapabilityMatches( + registry: ToolRegistryEntry[], + text: string, + attachmentContext: PlannerAttachmentContext, + hasOpenUrlIntent: boolean, + hasInstallIntent: boolean, +): PlannerCapabilityMatch[] { + const aliasMatches = matchRegistryEntriesByAlias(registry, text); + const aliasByCapability = new Map(); + for (const match of aliasMatches) { + const existing = aliasByCapability.get(match.entry.capabilityKey) || []; + existing.push(match.alias); + aliasByCapability.set(match.entry.capabilityKey, existing); + } + + return registry + .map((entry): PlannerCapabilityMatch => { + const reasons: string[] = []; + let score = 0; + const aliases = aliasByCapability.get(entry.capabilityKey) || []; + + if (aliases.length > 0) { + score += 12; + reasons.push(`alias:${aliases[0]}`); + } + + if (entry.triggerHints.some((hint) => normalizeLookupText(text).includes(normalizeLookupText(hint)))) { + score += 4; + reasons.push('trigger-hint'); + } + + if (hasOpenUrlIntent && entry.toolName === 'browser.open_url') { + score += 9; + reasons.push('open-url-intent'); + } + + if (hasInstallIntent && entry.toolName === 'skills.install') { + score += 9; + reasons.push('install-intent'); + } + + if (entry.requiresFiles && attachmentContext.selected.length > 0) { + score += 3; + reasons.push('attachments-available'); + } + + if (isSpreadsheetFamilyEntry(entry) && attachmentContext.spreadsheet.length > 0) { + score += 8; + reasons.push('spreadsheet-attachments'); + } + + if (entry.requiresFiles && attachmentContext.selected.length === 0) { + score -= 6; + reasons.push('missing-required-attachments'); + } + + return { + capabilityKey: entry.capabilityKey, + familyKey: entry.familyKey, + toolName: entry.toolName, + displayName: entry.displayName, + score, + reasons, + }; + }) + .sort((left, right) => right.score - left.score); +} + +function buildSelectedCapability(entry: ToolRegistryEntry): PlannerSelectedCapability { + return { + capabilityKey: entry.capabilityKey, + familyKey: entry.familyKey, + toolName: entry.toolName, + displayName: entry.displayName, + kind: entry.kind, + }; +} + +function buildNoToolDecision( + reason: PlannerDecisionReason, + summary: string, + thinking: string, + normalizedUserText: string, + attachmentContext: PlannerAttachmentContext, + matchedCapabilities: PlannerCapabilityMatch[], + blockingIssue?: PlannerBlockingIssue, + selectedEntry?: ToolRegistryEntry, +): PlannerDecision { + return { + kind: 'no-tool', + reason, + summary, + thinking, + normalizedUserText, + attachmentContext, + matchedCapabilities, + selectedCapability: selectedEntry ? buildSelectedCapability(selectedEntry) : undefined, + blockingIssue, + }; +} + +function buildToolDecision( + reason: PlannerDecisionReason, + summary: string, + thinking: string, + normalizedUserText: string, + attachmentContext: PlannerAttachmentContext, + matchedCapabilities: PlannerCapabilityMatch[], + entry: ToolRegistryEntry, + toolCall: ToolCallPayload, +): PlannerDecision { + return { + kind: 'tool', + reason, + summary, + thinking, + normalizedUserText, + attachmentContext, + matchedCapabilities, + selectedCapability: buildSelectedCapability(entry), + toolCall, + }; +} + +function choosePreferredEntry(entries: ToolRegistryEntry[], text: string): ToolRegistryEntry | undefined { + if (entries.length === 0) { + return undefined; + } + + const exactText = normalizeLookupText(text); + const exactMatch = entries.find((entry) => + normalizeLookupText(entry.capabilityKey) === exactText + || normalizeLookupText(entry.toolName) === exactText, + ); + if (exactMatch) { + return exactMatch; + } + + const aliasMatch = matchRegistryEntriesByAlias(entries, text)[0]; + if (aliasMatch) { + return aliasMatch.entry; + } + + const minimaxEntry = entries.find((entry) => normalizeLookupText(entry.capabilityKey) === 'minimax-xlsx'); + if (minimaxEntry) { + return minimaxEntry; + } + + return entries[0]; +} + +function buildAttachmentReference(attachment: AttachedFileMeta): Record { + return { + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + filePath: attachment.filePath, + source: attachment.source, + }; +} + +function buildSpreadsheetToolCall( + entry: ToolRegistryEntry, + text: string, + attachmentContext: PlannerAttachmentContext, +): ToolCallPayload { + return { + name: entry.toolName, + input: { + prompt: text, + skillKey: entry.capabilityKey, + intent: 'spreadsheet-analysis', + attachments: attachmentContext.spreadsheet.map((attachment) => buildAttachmentReference(attachment)), + filePaths: attachmentContext.spreadsheet + .map((attachment) => attachment.filePath) + .filter((filePath): filePath is string => Boolean(filePath)), + reuseHistoryAttachment: attachmentContext.usedHistoryAttachments, + }, + summary: `Use ${entry.displayName} to analyze ${attachmentContext.spreadsheet.length} spreadsheet attachment(s).`, + }; +} + +function buildGenericToolCall( + entry: ToolRegistryEntry, + text: string, + attachmentContext: PlannerAttachmentContext, +): ToolCallPayload { + return { + name: entry.toolName, + input: { + prompt: text, + capabilityKey: entry.capabilityKey, + attachments: attachmentContext.selected.map((attachment) => buildAttachmentReference(attachment)), + reuseHistoryAttachment: attachmentContext.usedHistoryAttachments, + }, + summary: `Use ${entry.displayName} because the user explicitly requested this capability.`, + }; +} + +function detectInstallPlan(text: string): InstallPlanInput | undefined { + if (!hasAnyKeyword(text, INSTALL_KEYWORDS)) { + return undefined; + } + + const urls = extractUrls(text); + const githubUrl = urls.find((url) => /github\.com/i.test(url)); + if (githubUrl) { + return { + kind: 'github-url', + url: githubUrl, + }; + } + + const normalizedText = normalizeLookupText(text); + if (!hasAnyKeyword(text, SKILL_WORD_KEYWORDS) && !normalizedText.includes('skills.install')) { + return undefined; + } + + const slugPatterns = [ + /install\s+(?:the\s+)?(?:skill\s+)?([a-z0-9][a-z0-9-_./]{1,127})/i, + /安装\s*([a-z0-9][a-z0-9-_./]{1,127})\s*(?:这个)?\s*skill/i, + /安装\s*skill\s*[::]?\s*([a-z0-9][a-z0-9-_./]{1,127})/i, + ]; + + for (const pattern of slugPatterns) { + const match = text.match(pattern); + const slug = normalizeText(match?.[1]); + if (slug && slug !== 'skill' && slug !== 'skills') { + return { + kind: 'marketplace', + slug, + }; + } + } + + return undefined; +} + +function detectBrowserUrl(text: string): string | undefined { + const urls = extractUrls(text); + if (urls.length === 0) { + return undefined; + } + + if (hasAnyKeyword(text, OPEN_URL_KEYWORDS) || normalizeLookupText(text).includes('browser.open_url')) { + return urls[0]; + } + + return undefined; +} + +function filterCompatibleAttachments( + entry: ToolRegistryEntry, + attachments: AttachedFileMeta[], +): AttachedFileMeta[] { + if (attachments.length === 0) { + return []; + } + + if (entry.supportedFileTypes.length === 0) { + return attachments; + } + + return attachments.filter((attachment) => + registryEntrySupportsFileType(entry, attachment.fileName) + || registryEntrySupportsFileType(entry, attachment.mimeType) + || registryEntrySupportsFileType(entry, attachment.filePath || ''), + ); +} + +function resolveRegistry(input: PlannerInput): ToolRegistryEntry[] { + if (input.registry && input.registry.length > 0) { + return [...input.registry]; + } + + return createToolRegistry({ + capabilities: input.capabilities, + }); +} + +export function planToolCall(input: PlannerInput): PlannerDecision { + const messageText = normalizeText(input.userText || extractMessageText(input.message)); + const normalizedUserText = normalizeLookupText(messageText); + const registry = resolveRegistry(input); + const spreadsheetFamilyEntries = getRegistryEntriesByFamily(registry, getSpreadsheetFamilyKey()); + const explicitSpreadsheetMention = matchRegistryEntriesByAlias(spreadsheetFamilyEntries, messageText).length > 0; + const currentAttachments = collectCurrentAttachments(input, messageText); + const recentAttachments = collectRecentAttachments(input.history); + const attachmentContext = pickSelectedAttachments( + currentAttachments, + recentAttachments, + messageText, + explicitSpreadsheetMention, + ); + const browserUrl = detectBrowserUrl(messageText); + const installPlan = detectInstallPlan(messageText); + const matchedCapabilities = buildCapabilityMatches( + registry, + messageText, + attachmentContext, + Boolean(browserUrl), + Boolean(installPlan), + ); + + if (browserUrl) { + const browserEntry = getRegistryEntryByName(registry, 'browser.open_url'); + if (browserEntry) { + return buildToolDecision( + 'browser_open_url', + `Plan to open ${browserUrl} with browser.open_url.`, + 'The user explicitly asked to open a URL, so the browser tool should run before generating any follow-up response.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + browserEntry, + { + name: browserEntry.toolName, + input: { url: browserUrl }, + summary: `Open ${browserUrl} in the managed browser.`, + }, + ); + } + } + + if (installPlan) { + const installEntry = getRegistryEntryByName(registry, 'skills.install'); + if (installEntry) { + return buildToolDecision( + 'skills_install', + `Plan to install a skill through ${installEntry.toolName}.`, + 'The user explicitly asked to install a skill, so the install tool should be called with the parsed marketplace slug or GitHub URL.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + installEntry, + { + name: installEntry.toolName, + input: installPlan, + summary: installPlan.kind === 'github-url' + ? `Install a skill from ${installPlan.url}.` + : `Install marketplace skill ${installPlan.slug}.`, + }, + ); + } + } + + if (spreadsheetFamilyEntries.length > 0 && (explicitSpreadsheetMention || attachmentContext.spreadsheet.length > 0)) { + const spreadsheetEntry = choosePreferredEntry(spreadsheetFamilyEntries, messageText); + if (spreadsheetEntry) { + if (attachmentContext.spreadsheet.length === 0) { + return buildNoToolDecision( + 'missing_required_attachment', + `Cannot call ${spreadsheetEntry.displayName} yet because no spreadsheet attachment is available.`, + 'A spreadsheet analysis skill is available and mentioned, but no .xlsx/.xls/.csv/.tsv attachment was found in the current message or recent history.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + { + code: 'missing_required_attachment', + message: 'Spreadsheet analysis requires at least one spreadsheet attachment.', + missing: ['attachment'], + }, + spreadsheetEntry, + ); + } + + return buildToolDecision( + explicitSpreadsheetMention ? 'explicit_skill_request' : 'spreadsheet_analysis', + `Plan to analyze ${attachmentContext.spreadsheet.length} spreadsheet attachment(s) with ${spreadsheetEntry.displayName}.`, + explicitSpreadsheetMention + ? 'The user explicitly referenced the spreadsheet-analysis skill, and compatible spreadsheet attachments are available.' + : 'Compatible spreadsheet attachments are available and the latest user turn looks like a data-analysis request.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + spreadsheetEntry, + buildSpreadsheetToolCall(spreadsheetEntry, messageText, attachmentContext), + ); + } + } + + const explicitMatches = matchRegistryEntriesByAlias(registry, messageText) + .map((match) => match.entry) + .filter((entry) => entry.toolName !== 'browser.open_url' && entry.toolName !== 'skills.install'); + const explicitEntry = choosePreferredEntry(dedupeEntries(explicitMatches), messageText); + if (explicitEntry) { + const compatibleAttachments = filterCompatibleAttachments(explicitEntry, attachmentContext.selected); + if (explicitEntry.requiresFiles && compatibleAttachments.length === 0) { + return buildNoToolDecision( + 'missing_required_attachment', + `Cannot call ${explicitEntry.displayName} yet because the required attachment input is missing.`, + 'The user explicitly referenced a capability that expects files, but no compatible attachment was found in the current turn or recent history.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + { + code: 'missing_required_attachment', + message: `${explicitEntry.displayName} requires a compatible attachment before it can run.`, + missing: ['attachment'], + }, + explicitEntry, + ); + } + + return buildToolDecision( + 'explicit_skill_request', + `Plan to call ${explicitEntry.displayName} because the user explicitly referenced it.`, + 'An enabled capability was explicitly mentioned in the latest user message, so the planner should route the turn into that tool or skill.', + normalizedUserText, + { + ...attachmentContext, + selected: compatibleAttachments.length > 0 ? compatibleAttachments : attachmentContext.selected, + }, + matchedCapabilities, + explicitEntry, + buildGenericToolCall( + explicitEntry, + messageText, + { + ...attachmentContext, + selected: compatibleAttachments.length > 0 ? compatibleAttachments : attachmentContext.selected, + }, + ), + ); + } + + if (!messageText) { + return buildNoToolDecision( + 'no_tool_needed', + 'No tool call was planned because the latest user text is empty.', + 'There is no user text to classify for tool use, so the caller can fall back to the normal assistant response path.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + ); + } + + if (matchedCapabilities.length === 0 || matchedCapabilities[0]?.score <= 0) { + return buildNoToolDecision( + 'no_matching_capability', + 'No tool call was planned because no enabled capability matched the latest user turn strongly enough.', + 'The latest user turn does not contain an explicit tool request or a high-confidence capability match, so the chat runtime can continue on the no-tool path.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + ); + } + + return buildNoToolDecision( + 'no_tool_needed', + 'A capability match exists, but the current turn does not require an immediate tool call.', + 'The planner found possible capabilities, but none of them crossed the threshold for an explicit tool-first action on this turn.', + normalizedUserText, + attachmentContext, + matchedCapabilities, + { + code: 'insufficient_user_intent', + message: 'No explicit tool-first intent was detected for this turn.', + }, + ); +} + +function dedupeEntries(entries: ToolRegistryEntry[]): ToolRegistryEntry[] { + const seen = new Set(); + const result: ToolRegistryEntry[] = []; + + for (const entry of entries) { + if (seen.has(entry.capabilityKey)) { + continue; + } + seen.add(entry.capabilityKey); + result.push(entry); + } + + return result; +} diff --git a/electron/gateway/tool-registry.ts b/electron/gateway/tool-registry.ts new file mode 100644 index 0000000..72c568d --- /dev/null +++ b/electron/gateway/tool-registry.ts @@ -0,0 +1,550 @@ +export type ToolRegistryEntryKind = 'builtin-tool' | 'skill'; + +export type ToolRiskLevel = 'low' | 'medium' | 'high'; + +export type ToolInputKind = + | 'text' + | 'url' + | 'file' + | 'attachment' + | 'history' + | 'structured'; + +export type ToolOutputKind = + | 'text' + | 'json' + | 'file' + | 'artifacts' + | 'browser-state' + | 'status'; + +export interface ToolRegistryCapabilityInput { + capabilityKey?: string; + familyKey?: string; + toolName?: string; + skillKey?: string; + slug?: string; + name?: string; + displayName?: string; + description?: string; + kind?: ToolRegistryEntryKind; + aliases?: string[]; + inputKinds?: string[]; + outputKinds?: string[]; + triggerHints?: string[]; + supportedFileTypes?: string[]; + requiresFiles?: boolean; + requiresExplicitUserIntent?: boolean; + enabled?: boolean; + disabled?: boolean; + riskLevel?: ToolRiskLevel; + source?: string; + metadata?: Record; +} + +export interface ToolRegistryEntry { + capabilityKey: string; + familyKey: string; + toolName: string; + kind: ToolRegistryEntryKind; + displayName: string; + description: string; + aliases: string[]; + inputKinds: ToolInputKind[]; + outputKinds: ToolOutputKind[]; + triggerHints: string[]; + supportedFileTypes: string[]; + requiresFiles: boolean; + requiresExplicitUserIntent: boolean; + riskLevel: ToolRiskLevel; + enabled: boolean; + source?: string; + metadata: Record; +} + +export interface CreateToolRegistryOptions { + capabilities?: ToolRegistryCapabilityInput[]; + includeBuiltins?: boolean; +} + +const SPREADSHEET_FAMILY_KEY = 'spreadsheet.analysis'; + +const SPREADSHEET_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv', '.ods']; + +const SPREADSHEET_MIME_PATTERNS = [ + 'spreadsheet', + 'excel', + 'sheet', + 'csv', + 'tab-separated-values', + 'officedocument.spreadsheetml', +]; + +const DEFAULT_BUILTIN_ENTRIES: ToolRegistryEntry[] = [ + { + capabilityKey: 'browser.open_url', + familyKey: 'browser.open_url', + toolName: 'browser.open_url', + kind: 'builtin-tool', + displayName: 'Browser Open URL', + description: 'Open an explicit URL in the managed browser when the user clearly asks to open or visit a page.', + aliases: ['browser.open_url', 'open url', 'open link', 'visit url'], + inputKinds: ['url'], + outputKinds: ['browser-state', 'text'], + triggerHints: ['open this url', 'open the link', 'visit the page', '打开链接', '打开网页', '访问网页'], + supportedFileTypes: [], + requiresFiles: false, + requiresExplicitUserIntent: true, + riskLevel: 'medium', + enabled: true, + metadata: { + intent: 'open_url', + sideEffect: 'browser-navigation', + }, + }, + { + capabilityKey: 'skills.install', + familyKey: 'skills.install', + toolName: 'skills.install', + kind: 'builtin-tool', + displayName: 'Skills Install', + description: 'Install a skill from the marketplace slug or a GitHub skill URL when the user explicitly asks to install one.', + aliases: ['skills.install', 'install skill', 'skill install', 'add skill'], + inputKinds: ['text', 'url'], + outputKinds: ['status', 'text'], + triggerHints: ['install this skill', 'install skill', 'github skill url', '安装 skill', '安装技能'], + supportedFileTypes: [], + requiresFiles: false, + requiresExplicitUserIntent: true, + riskLevel: 'medium', + enabled: true, + metadata: { + intent: 'install_skill', + sideEffect: 'skill-installation', + }, + }, +]; + +function normalizeTextValue(value: string | undefined | null): string { + return String(value ?? '').trim(); +} + +function normalizeLookupValue(value: string | undefined | null): string { + return normalizeTextValue(value).toLowerCase(); +} + +function dedupeStrings(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const value of values) { + const trimmed = normalizeTextValue(value); + if (!trimmed) { + continue; + } + const lookup = trimmed.toLowerCase(); + if (seen.has(lookup)) { + continue; + } + seen.add(lookup); + result.push(trimmed); + } + + return result; +} + +function normalizeStringList(values: unknown, fallback: string[] = []): string[] { + if (!Array.isArray(values)) { + return [...fallback]; + } + + return dedupeStrings(values.map((item) => (typeof item === 'string' ? item : ''))); +} + +function normalizeInputKinds(values: unknown): ToolInputKind[] { + const allowed: ToolInputKind[] = ['text', 'url', 'file', 'attachment', 'history', 'structured']; + return normalizeStringList(values) + .map((value) => value.toLowerCase()) + .filter((value): value is ToolInputKind => allowed.includes(value as ToolInputKind)); +} + +function normalizeOutputKinds(values: unknown): ToolOutputKind[] { + const allowed: ToolOutputKind[] = ['text', 'json', 'file', 'artifacts', 'browser-state', 'status']; + return normalizeStringList(values) + .map((value) => value.toLowerCase()) + .filter((value): value is ToolOutputKind => allowed.includes(value as ToolOutputKind)); +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getMetadataStringArray( + metadata: Record, + ...keys: string[] +): string[] { + for (const key of keys) { + const value = metadata[key]; + if (Array.isArray(value)) { + return normalizeStringList(value); + } + } + + return []; +} + +function getMetadataBoolean( + metadata: Record, + ...keys: string[] +): boolean | undefined { + for (const key of keys) { + const value = metadata[key]; + if (typeof value === 'boolean') { + return value; + } + } + + return undefined; +} + +function inferCapabilityKind(input: ToolRegistryCapabilityInput, capabilityKey: string): ToolRegistryEntryKind { + if (input.kind === 'builtin-tool' || input.kind === 'skill') { + return input.kind; + } + + if (capabilityKey === 'browser.open_url' || capabilityKey === 'skills.install') { + return 'builtin-tool'; + } + + return 'skill'; +} + +function normalizeRiskLevel(value: string | undefined): ToolRiskLevel { + switch (normalizeLookupValue(value)) { + case 'high': + return 'high'; + case 'medium': + return 'medium'; + default: + return 'low'; + } +} + +function isSpreadsheetCapability( + capabilityKey: string, + displayName: string, + description: string, + aliases: string[], + supportedFileTypes: string[], +): boolean { + const haystack = [ + capabilityKey, + displayName, + description, + ...aliases, + ...supportedFileTypes, + ].map((item) => item.toLowerCase()); + + if (haystack.some((item) => item === 'minimax-xlsx' || item === 'xlsx')) { + return true; + } + + if (supportedFileTypes.some((item) => isSpreadsheetFileType(item))) { + return true; + } + + return haystack.some((item) => + item.includes('spreadsheet') + || item.includes('excel') + || item.includes('sheet') + || item.includes('csv') + || item.includes('表格') + || item.includes('excel') + || item.includes('工作表'), + ); +} + +function normalizeSupportedFileTypes( + input: ToolRegistryCapabilityInput, + metadata: Record, +): string[] { + const explicit = dedupeStrings([ + ...normalizeStringList(input.supportedFileTypes), + ...getMetadataStringArray(metadata, 'supportedFileTypes', 'supported_file_types'), + ]); + + if (explicit.length > 0) { + return explicit; + } + + const capabilityKey = normalizeLookupValue( + input.capabilityKey || input.toolName || input.skillKey || input.slug || input.name, + ); + if (capabilityKey === 'xlsx' || capabilityKey === 'minimax-xlsx') { + return [...SPREADSHEET_EXTENSIONS]; + } + + return []; +} + +function normalizeAliases( + input: ToolRegistryCapabilityInput, + capabilityKey: string, + displayName: string, + toolName: string, + isSpreadsheet: boolean, +): string[] { + return dedupeStrings([ + capabilityKey, + toolName, + displayName, + input.skillKey, + input.slug, + input.name, + ...normalizeStringList(input.aliases), + ...(isSpreadsheet ? ['minimax-xlsx', 'xlsx', 'spreadsheet', 'excel analyzer', 'excel analysis'] : []), + ]); +} + +function normalizeTriggerHints( + input: ToolRegistryCapabilityInput, + metadata: Record, + isSpreadsheet: boolean, +): string[] { + const hints = dedupeStrings([ + ...normalizeStringList(input.triggerHints), + ...getMetadataStringArray(metadata, 'triggerHints', 'trigger_hints'), + ]); + + if (!isSpreadsheet) { + return hints; + } + + return dedupeStrings([ + ...hints, + 'analyze spreadsheet', + 'analyze excel', + 'analyze table file', + 'summarize worksheet', + '分析表格', + '分析 excel', + '分析工作表', + '分析这个表', + ]); +} + +function mergeEntries(base: ToolRegistryEntry, incoming: ToolRegistryEntry): ToolRegistryEntry { + return { + ...base, + ...incoming, + aliases: dedupeStrings([...base.aliases, ...incoming.aliases]), + inputKinds: Array.from(new Set([...base.inputKinds, ...incoming.inputKinds])), + outputKinds: Array.from(new Set([...base.outputKinds, ...incoming.outputKinds])), + triggerHints: dedupeStrings([...base.triggerHints, ...incoming.triggerHints]), + supportedFileTypes: dedupeStrings([...base.supportedFileTypes, ...incoming.supportedFileTypes]), + metadata: { + ...base.metadata, + ...incoming.metadata, + }, + enabled: base.enabled || incoming.enabled, + requiresFiles: base.requiresFiles || incoming.requiresFiles, + requiresExplicitUserIntent: base.requiresExplicitUserIntent || incoming.requiresExplicitUserIntent, + }; +} + +function buildRegistryEntry(input: ToolRegistryCapabilityInput): ToolRegistryEntry | null { + const metadata = isPlainRecord(input.metadata) ? input.metadata : {}; + const capabilityKey = normalizeTextValue( + input.capabilityKey || input.toolName || input.skillKey || input.slug || input.name, + ); + if (!capabilityKey) { + return null; + } + + const toolName = normalizeTextValue(input.toolName || capabilityKey); + const displayName = normalizeTextValue(input.displayName || input.name || input.skillKey || input.slug || toolName); + const description = normalizeTextValue(input.description); + const supportedFileTypes = normalizeSupportedFileTypes(input, metadata); + const isSpreadsheet = isSpreadsheetCapability( + capabilityKey, + displayName, + description, + normalizeStringList(input.aliases), + supportedFileTypes, + ); + const aliases = normalizeAliases(input, capabilityKey, displayName, toolName, isSpreadsheet); + const triggerHints = normalizeTriggerHints(input, metadata, isSpreadsheet); + const inputKinds = normalizeInputKinds(input.inputKinds); + const outputKinds = normalizeOutputKinds(input.outputKinds); + const inferredRequiresFiles = isSpreadsheet + || supportedFileTypes.length > 0 + || inputKinds.includes('file') + || inputKinds.includes('attachment'); + const requiresFiles = input.requiresFiles + ?? getMetadataBoolean(metadata, 'requiresFiles', 'requires_files') + ?? inferredRequiresFiles; + const requiresExplicitUserIntent = input.requiresExplicitUserIntent + ?? getMetadataBoolean(metadata, 'requiresExplicitUserIntent', 'requires_explicit_user_intent') + ?? false; + const familyKey = normalizeTextValue(input.familyKey) + || (isSpreadsheet ? SPREADSHEET_FAMILY_KEY : capabilityKey); + + return { + capabilityKey, + familyKey, + toolName, + kind: inferCapabilityKind(input, capabilityKey), + displayName, + description, + aliases, + inputKinds: inputKinds.length > 0 ? inputKinds : (requiresFiles ? ['text', 'file', 'attachment'] : ['text']), + outputKinds: outputKinds.length > 0 ? outputKinds : ['text'], + triggerHints, + supportedFileTypes, + requiresFiles, + requiresExplicitUserIntent, + riskLevel: normalizeRiskLevel(input.riskLevel), + enabled: input.disabled === true ? false : input.enabled !== false, + source: normalizeTextValue(input.source) || undefined, + metadata: { + ...metadata, + familyKey, + }, + }; +} + +export function createToolRegistry(options: CreateToolRegistryOptions = {}): ToolRegistryEntry[] { + const includeBuiltins = options.includeBuiltins !== false; + const merged = new Map(); + + if (includeBuiltins) { + for (const entry of DEFAULT_BUILTIN_ENTRIES) { + merged.set(entry.capabilityKey, { ...entry, aliases: [...entry.aliases], triggerHints: [...entry.triggerHints] }); + } + } + + for (const capability of options.capabilities || []) { + if (capability.disabled === true || capability.enabled === false) { + continue; + } + + const entry = buildRegistryEntry(capability); + if (!entry || entry.enabled === false) { + continue; + } + + const existing = merged.get(entry.capabilityKey); + merged.set(entry.capabilityKey, existing ? mergeEntries(existing, entry) : entry); + } + + return Array.from(merged.values()).sort((left, right) => { + if (left.kind !== right.kind) { + return left.kind === 'builtin-tool' ? -1 : 1; + } + return left.displayName.localeCompare(right.displayName); + }); +} + +export function getRegistryEntryByName( + registry: ToolRegistryEntry[], + reference: string, +): ToolRegistryEntry | undefined { + const lookup = normalizeLookupValue(reference); + if (!lookup) { + return undefined; + } + + return registry.find((entry) => + normalizeLookupValue(entry.capabilityKey) === lookup + || normalizeLookupValue(entry.toolName) === lookup + || normalizeLookupValue(entry.familyKey) === lookup + || entry.aliases.some((alias) => normalizeLookupValue(alias) === lookup), + ); +} + +export function getRegistryEntriesByFamily( + registry: ToolRegistryEntry[], + familyKey: string, +): ToolRegistryEntry[] { + const lookup = normalizeLookupValue(familyKey); + if (!lookup) { + return []; + } + + return registry.filter((entry) => normalizeLookupValue(entry.familyKey) === lookup); +} + +export function matchRegistryEntriesByAlias( + registry: ToolRegistryEntry[], + text: string, +): Array<{ entry: ToolRegistryEntry; alias: string }> { + const normalizedText = normalizeLookupValue(text); + if (!normalizedText) { + return []; + } + + const results: Array<{ entry: ToolRegistryEntry; alias: string }> = []; + + for (const entry of registry) { + for (const alias of entry.aliases) { + const normalizedAlias = normalizeLookupValue(alias); + if (!normalizedAlias) { + continue; + } + + if (normalizedText.includes(normalizedAlias)) { + results.push({ entry, alias }); + } + } + } + + return results; +} + +export function isSpreadsheetFileType(value: string): boolean { + const normalized = normalizeLookupValue(value); + if (!normalized) { + return false; + } + + if (SPREADSHEET_EXTENSIONS.some((ext) => normalized.endsWith(ext))) { + return true; + } + + return SPREADSHEET_MIME_PATTERNS.some((pattern) => normalized.includes(pattern)); +} + +export function registryEntrySupportsFileType( + entry: ToolRegistryEntry, + fileType: string, +): boolean { + if (!fileType) { + return false; + } + + if (entry.supportedFileTypes.length === 0) { + return !entry.requiresFiles; + } + + const normalizedFileType = normalizeLookupValue(fileType); + return entry.supportedFileTypes.some((candidate) => { + const normalizedCandidate = normalizeLookupValue(candidate); + if (!normalizedCandidate) { + return false; + } + + if (normalizedCandidate.startsWith('.')) { + return normalizedFileType.endsWith(normalizedCandidate); + } + + return normalizedFileType.includes(normalizedCandidate); + }); +} + +export function isSpreadsheetFamilyEntry(entry: ToolRegistryEntry): boolean { + return normalizeLookupValue(entry.familyKey) === SPREADSHEET_FAMILY_KEY; +} + +export function getSpreadsheetFamilyKey(): string { + return SPREADSHEET_FAMILY_KEY; +} diff --git a/electron/gateway/tool-runtime.ts b/electron/gateway/tool-runtime.ts new file mode 100644 index 0000000..971de2c --- /dev/null +++ b/electron/gateway/tool-runtime.ts @@ -0,0 +1,483 @@ +import type { GatewayToolResultContentBlock } from '@electron/providers/BaseProvider'; +import type { + AttachedFileMeta, + RawMessage, + ToolArtifact, + ToolErrorInfo, + ToolLifecycleStatus, + ToolRenderHints, + ToolResultPayload, +} from '@runtime/shared/chat-model'; + +type MaybePromise = T | Promise; + +export type ToolRuntimePreflightStatus = 'ready' | 'blocked'; +export type ToolRuntimeTerminalStatus = Exclude; +export type ToolRuntimeSource = 'planner' | 'provider' | 'manual'; +export type ToolRuntimePhase = 'preflight' | 'execute' | 'normalize'; + +export interface ToolRuntimeLifecycleEvent { + phase: ToolRuntimePhase; + toolCallId: string; + toolName: string; + summary?: string; + ok?: boolean; + error?: ToolErrorInfo; + metadata?: Record; +} + +export interface ToolRuntimeContext { + sessionKey?: string; + runId?: string; + signal?: AbortSignal; + workingDirectory?: string; + files?: AttachedFileMeta[]; + metadata?: Record; + emitStatus?: (event: ToolRuntimeLifecycleEvent) => void; +} + +export interface ToolRuntimeInvocation { + toolCallId: string; + toolName: string; + input?: unknown; + summary?: string; + source?: ToolRuntimeSource; + metadata?: Record; +} + +export interface ToolRuntimePreflightResult { + ok: boolean; + status: ToolRuntimePreflightStatus; + toolCallId: string; + toolName: string; + summary?: string; + normalizedInput?: unknown; + warnings?: string[]; + missing?: string[]; + error?: ToolErrorInfo; + metadata?: Record; +} + +export interface ToolRuntimeExecutionResult { + ok: boolean; + status: ToolRuntimeTerminalStatus; + toolCallId: string; + toolName: string; + normalizedInput?: unknown; + summary?: string; + raw?: unknown; + files?: AttachedFileMeta[]; + artifacts?: ToolArtifact[]; + logs?: Array>; + error?: ToolErrorInfo; + retryable?: boolean; + skillType?: string; + renderHints?: ToolRenderHints; + metadata?: Record; + durationMs: number; +} + +export interface ToolRuntimeNormalizedResult { + ok: boolean; + status: ToolRuntimeTerminalStatus; + toolCallId: string; + toolName: string; + summary?: string; + payload: ToolResultPayload; + block: GatewayToolResultContentBlock; + transcriptMessage: RawMessage; +} + +export interface ToolRuntimeRunResult { + preflight: ToolRuntimePreflightResult; + execution: ToolRuntimeExecutionResult; + normalized: ToolRuntimeNormalizedResult; +} + +export interface ToolRuntimeAdapter { + readonly toolName: string; + matchesTool?(toolName: string): boolean; + preflight( + invocation: ToolRuntimeInvocation, + context: ToolRuntimeContext + ): MaybePromise; + execute( + invocation: ToolRuntimeInvocation, + context: ToolRuntimeContext + ): MaybePromise; + normalize?( + execution: ToolRuntimeExecutionResult, + context: ToolRuntimeContext + ): MaybePromise; +} + +function normalizeToolError(error: unknown, fallbackCode = 'tool_runtime_error'): ToolErrorInfo { + if (typeof error === 'string') { + return { code: fallbackCode, message: error }; + } + + if (error instanceof Error) { + return { + code: fallbackCode, + message: error.message, + details: error, + }; + } + + return { + code: fallbackCode, + message: 'Unknown tool runtime error', + details: error, + }; +} + +function buildFallbackSummary(execution: ToolRuntimeExecutionResult): string { + if (execution.summary?.trim()) { + return execution.summary; + } + + if (execution.error?.message?.trim()) { + return execution.error.message; + } + + return execution.ok + ? `Tool ${execution.toolName} completed` + : `Tool ${execution.toolName} failed`; +} + +function buildToolResultText(payload: ToolResultPayload, execution: ToolRuntimeExecutionResult): string { + if (payload.summary?.trim()) { + return payload.summary; + } + + if (typeof payload.error === 'string' && payload.error.trim()) { + return payload.error; + } + + if ( + payload.error && + typeof payload.error === 'object' && + 'message' in payload.error && + typeof payload.error.message === 'string' && + payload.error.message.trim() + ) { + return payload.error.message; + } + + if (typeof payload.raw === 'string' && payload.raw.trim()) { + return payload.raw; + } + + if (payload.logs?.length) { + const firstLog = payload.logs[0]; + return typeof firstLog === 'string' ? firstLog : JSON.stringify(firstLog); + } + + return buildFallbackSummary(execution); +} + +export function buildToolResultPayload( + execution: ToolRuntimeExecutionResult +): ToolResultPayload { + const summary = buildFallbackSummary(execution); + + return { + ok: execution.ok, + summary, + structuredData: execution.raw, + files: execution.files, + artifacts: execution.artifacts, + logs: execution.logs, + error: execution.error, + retryable: execution.retryable, + skillType: execution.skillType, + renderHints: execution.renderHints, + raw: execution.raw, + }; +} + +export function normalizeToolExecutionResult( + execution: ToolRuntimeExecutionResult +): ToolRuntimeNormalizedResult { + const payload = buildToolResultPayload(execution); + const summary = payload.summary ?? buildFallbackSummary(execution); + const block: GatewayToolResultContentBlock = { + type: 'tool_result', + toolCallId: execution.toolCallId, + content: buildToolResultText(payload, execution), + result: payload, + summary, + ok: payload.ok, + error: payload.error, + }; + + return { + ok: execution.ok, + status: execution.status, + toolCallId: execution.toolCallId, + toolName: execution.toolName, + summary, + payload, + block, + transcriptMessage: { + role: 'tool_result', + content: [block], + timestamp: Date.now(), + toolCallId: execution.toolCallId, + toolName: execution.toolName, + toolCall: { + id: execution.toolCallId, + name: execution.toolName, + input: execution.normalizedInput, + summary, + }, + toolResult: payload, + details: execution.raw, + isError: !execution.ok, + }, + }; +} + +export function createUnsupportedToolPreflightResult( + invocation: ToolRuntimeInvocation +): ToolRuntimePreflightResult { + return { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: `No adapter registered for ${invocation.toolName}`, + error: { + code: 'tool_adapter_not_found', + message: `No adapter registered for ${invocation.toolName}`, + }, + }; +} + +export function createBlockedToolExecutionResult( + preflight: ToolRuntimePreflightResult +): ToolRuntimeExecutionResult { + return { + ok: false, + status: 'error', + toolCallId: preflight.toolCallId, + toolName: preflight.toolName, + normalizedInput: preflight.normalizedInput, + summary: preflight.summary, + error: preflight.error ?? { + code: 'tool_preflight_blocked', + message: preflight.summary || `Tool ${preflight.toolName} was blocked in preflight`, + }, + metadata: preflight.metadata, + durationMs: 0, + }; +} + +function toolMatches(adapter: ToolRuntimeAdapter, toolName: string): boolean { + return adapter.toolName === toolName || adapter.matchesTool?.(toolName) === true; +} + +function emitStatus( + context: ToolRuntimeContext, + event: ToolRuntimeLifecycleEvent +): void { + context.emitStatus?.(event); +} + +export class ToolRuntime { + private readonly adapters: ToolRuntimeAdapter[]; + + constructor(adapters: ToolRuntimeAdapter[] = []) { + this.adapters = [...adapters]; + } + + register(adapter: ToolRuntimeAdapter): void { + this.adapters.push(adapter); + } + + listAdapters(): ToolRuntimeAdapter[] { + return [...this.adapters]; + } + + resolveAdapter(toolName: string): ToolRuntimeAdapter | null { + return this.adapters.find((adapter) => toolMatches(adapter, toolName)) ?? null; + } + + async preflight( + invocation: ToolRuntimeInvocation, + context: ToolRuntimeContext = {} + ): Promise { + const adapter = this.resolveAdapter(invocation.toolName); + if (!adapter) { + const blocked = createUnsupportedToolPreflightResult(invocation); + emitStatus(context, { + phase: 'preflight', + toolCallId: blocked.toolCallId, + toolName: blocked.toolName, + summary: blocked.summary, + ok: blocked.ok, + error: blocked.error, + }); + return blocked; + } + + try { + const preflight = await adapter.preflight(invocation, context); + emitStatus(context, { + phase: 'preflight', + toolCallId: preflight.toolCallId, + toolName: preflight.toolName, + summary: preflight.summary, + ok: preflight.ok, + error: preflight.error, + metadata: preflight.metadata, + }); + return preflight; + } catch (error) { + const normalizedError = normalizeToolError(error, 'tool_preflight_failed'); + const blocked: ToolRuntimePreflightResult = { + ok: false, + status: 'blocked', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: normalizedError.message, + error: normalizedError, + }; + emitStatus(context, { + phase: 'preflight', + toolCallId: blocked.toolCallId, + toolName: blocked.toolName, + summary: blocked.summary, + ok: blocked.ok, + error: blocked.error, + }); + return blocked; + } + } + + async execute( + invocation: ToolRuntimeInvocation, + context: ToolRuntimeContext = {}, + preflight?: ToolRuntimePreflightResult + ): Promise { + const resolvedPreflight = preflight ?? await this.preflight(invocation, context); + if (!resolvedPreflight.ok || resolvedPreflight.status === 'blocked') { + const blocked = createBlockedToolExecutionResult(resolvedPreflight); + emitStatus(context, { + phase: 'execute', + toolCallId: blocked.toolCallId, + toolName: blocked.toolName, + summary: blocked.summary, + ok: blocked.ok, + error: blocked.error, + metadata: blocked.metadata, + }); + return blocked; + } + + const adapter = this.resolveAdapter(invocation.toolName); + if (!adapter) { + const blocked = createBlockedToolExecutionResult( + createUnsupportedToolPreflightResult(invocation) + ); + emitStatus(context, { + phase: 'execute', + toolCallId: blocked.toolCallId, + toolName: blocked.toolName, + summary: blocked.summary, + ok: blocked.ok, + error: blocked.error, + }); + return blocked; + } + + const preparedInvocation: ToolRuntimeInvocation = { + ...invocation, + input: resolvedPreflight.normalizedInput ?? invocation.input, + }; + + const startTime = Date.now(); + try { + const execution = await adapter.execute(preparedInvocation, context); + const completed: ToolRuntimeExecutionResult = { + ...execution, + toolCallId: execution.toolCallId || preparedInvocation.toolCallId, + toolName: execution.toolName || preparedInvocation.toolName, + normalizedInput: execution.normalizedInput ?? preparedInvocation.input, + durationMs: execution.durationMs ?? Date.now() - startTime, + }; + emitStatus(context, { + phase: 'execute', + toolCallId: completed.toolCallId, + toolName: completed.toolName, + summary: completed.summary, + ok: completed.ok, + error: completed.error, + metadata: completed.metadata, + }); + return completed; + } catch (error) { + const normalizedError = normalizeToolError(error, 'tool_execute_failed'); + const failed: ToolRuntimeExecutionResult = { + ok: false, + status: 'error', + toolCallId: preparedInvocation.toolCallId, + toolName: preparedInvocation.toolName, + normalizedInput: preparedInvocation.input, + summary: normalizedError.message, + error: normalizedError, + durationMs: Date.now() - startTime, + }; + emitStatus(context, { + phase: 'execute', + toolCallId: failed.toolCallId, + toolName: failed.toolName, + summary: failed.summary, + ok: failed.ok, + error: failed.error, + }); + return failed; + } + } + + async normalize( + execution: ToolRuntimeExecutionResult, + context: ToolRuntimeContext = {} + ): Promise { + const adapter = this.resolveAdapter(execution.toolName); + const normalized = adapter?.normalize + ? await adapter.normalize(execution, context) + : normalizeToolExecutionResult(execution); + + emitStatus(context, { + phase: 'normalize', + toolCallId: normalized.toolCallId, + toolName: normalized.toolName, + summary: normalized.summary, + ok: normalized.ok, + error: typeof normalized.payload.error === 'string' + ? { message: normalized.payload.error } + : normalized.payload.error, + }); + + return normalized; + } + + async run( + invocation: ToolRuntimeInvocation, + context: ToolRuntimeContext = {} + ): Promise { + const preflight = await this.preflight(invocation, context); + const execution = await this.execute(invocation, context, preflight); + const normalized = await this.normalize(execution, context); + return { + preflight, + execution, + normalized, + }; + } +} + +export function createToolRuntime(adapters: ToolRuntimeAdapter[] = []): ToolRuntime { + return new ToolRuntime(adapters); +} diff --git a/electron/providers/BaseProvider.ts b/electron/providers/BaseProvider.ts index 69ac8fb..62d11a0 100644 --- a/electron/providers/BaseProvider.ts +++ b/electron/providers/BaseProvider.ts @@ -2,11 +2,115 @@ export interface ChatOptions { signal?: AbortSignal; } -export interface GatewayChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string; +export interface GatewayTextContentBlock { + type: 'text'; + text: string; } -export abstract class BaseProvider { - abstract chat(messages: GatewayChatMessage[], modelName: string, options?: ChatOptions): Promise> +export interface GatewayThinkingContentBlock { + type: 'thinking'; + thinking: string; + signature?: string; +} + +export interface GatewayToolUseContentBlock { + type: 'tool_use'; + id: string; + name: string; + input?: unknown; + summary?: string; +} + +export interface GatewayToolResultContentBlock { + type: 'tool_result'; + toolCallId?: string; + content?: string | GatewayChatContentBlock[]; + result?: unknown; + summary?: string; + ok?: boolean; + error?: unknown; +} + +export type GatewayChatContentBlock = + | GatewayTextContentBlock + | GatewayThinkingContentBlock + | GatewayToolUseContentBlock + | GatewayToolResultContentBlock; + +export type GatewayChatMessageRole = + | 'system' + | 'user' + | 'assistant' + | 'tool' + | 'toolresult' + | 'tool_result'; + +export interface GatewayChatMessage { + role: GatewayChatMessageRole; + content: string | GatewayChatContentBlock[]; + name?: string; + toolCallId?: string; + metadata?: Record; +} + +export interface GatewayToolDefinition { + name: string; + description?: string; + inputSchema?: unknown; +} + +export type GatewayToolChoice = + | 'auto' + | 'none' + | 'required' + | { + type: 'tool'; + name: string; + }; + +export interface ToolCapableChatOptions extends ChatOptions { + tools?: GatewayToolDefinition[]; + toolChoice?: GatewayToolChoice; + metadata?: Record; +} + +export interface ProviderToolCallDelta { + index?: number; + id?: string; + name?: string; + argumentsDelta?: string; + raw?: unknown; +} + +export interface ProviderCapabilities { + structuredMessages: boolean; + toolCalls: boolean; + toolResults: boolean; + thinking: boolean; +} + +export interface ProviderStreamChunk extends UniversalChunk { + content?: GatewayChatContentBlock[]; + toolCalls?: ProviderToolCallDelta[]; + finishReason?: string | null; + raw?: unknown; +} + +export const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { + structuredMessages: false, + toolCalls: false, + toolResults: false, + thinking: false, +}; + +export abstract class BaseProvider { + getCapabilities(): ProviderCapabilities { + return DEFAULT_PROVIDER_CAPABILITIES; + } + + abstract chat( + messages: GatewayChatMessage[], + modelName: string, + options?: ToolCapableChatOptions + ): Promise> } diff --git a/electron/providers/OpenAIProvider.ts b/electron/providers/OpenAIProvider.ts index c009f4c..80d1840 100644 --- a/electron/providers/OpenAIProvider.ts +++ b/electron/providers/OpenAIProvider.ts @@ -1,15 +1,218 @@ -import { BaseProvider, ChatOptions, GatewayChatMessage } from "./BaseProvider"; +import { + BaseProvider, + GatewayChatContentBlock, + GatewayChatMessage, + GatewayToolChoice, + GatewayToolDefinition, + GatewayToolResultContentBlock, + ProviderCapabilities, + ProviderStreamChunk, + ToolCapableChatOptions, +} from "./BaseProvider"; import OpenAI from "openai"; import logManager from "@electron/service/logger" -function _transformChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): UniversalChunk { +const OPENAI_PROVIDER_CAPABILITIES: ProviderCapabilities = { + structuredMessages: true, + toolCalls: true, + toolResults: true, + thinking: false, +}; + +function _flattenContent(content: string | GatewayChatContentBlock[] | undefined): string { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .map((block) => { + if (!block || typeof block !== 'object') { + return ''; + } + + if (block.type === 'text' && typeof block.text === 'string') { + return block.text; + } + + if (block.type === 'thinking' && typeof block.thinking === 'string') { + return block.thinking; + } + + if (block.type === 'tool_result') { + return _flattenContent(block.content); + } + + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +function _extractToolCalls(content: GatewayChatContentBlock[]): Array> | undefined { + const toolCalls = content + .flatMap((block, index) => { + if (block.type !== 'tool_use' || !block.name) { + return []; + } + + return [{ + id: block.id || `tool_call_${index}`, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }]; + }); + + return toolCalls.length ? toolCalls : undefined; +} + +function _findToolResultBlock( + content: GatewayChatMessage['content'] +): GatewayToolResultContentBlock | undefined { + if (!Array.isArray(content)) { + return undefined; + } + + return content.find( + (block): block is GatewayToolResultContentBlock => block.type === 'tool_result' + ); +} + +function _transformMessage(message: GatewayChatMessage): Record | null { + const normalizedRole = message.role === 'toolresult' || message.role === 'tool_result' + ? 'tool' + : message.role; + + if (normalizedRole === 'assistant') { + const content = Array.isArray(message.content) ? message.content : undefined; + const toolCalls = content ? _extractToolCalls(content) : undefined; + const text = _flattenContent(message.content).trim(); + + if (!text && !toolCalls?.length) { + return null; + } + + return { + role: 'assistant', + content: text || null, + ...(toolCalls?.length ? { tool_calls: toolCalls } : {}), + }; + } + + if (normalizedRole === 'tool') { + const resultBlock = _findToolResultBlock(message.content); + const toolCallId = message.toolCallId || resultBlock?.toolCallId; + const text = _flattenContent(resultBlock?.content ?? message.content).trim(); + + return { + role: 'tool', + tool_call_id: toolCallId || `${message.name || 'tool'}_call`, + content: text || resultBlock?.summary || 'Tool result', + }; + } + + const text = _flattenContent(message.content).trim(); + if (!text) { + return null; + } + + return { + role: normalizedRole, + content: text, + }; +} + +function _transformMessages(messages: GatewayChatMessage[]): Array> { + return messages + .map((message) => _transformMessage(message)) + .filter((message): message is Record => message !== null); +} + +function _normalizeToolSchema(inputSchema: unknown): Record { + if (inputSchema && typeof inputSchema === 'object' && !Array.isArray(inputSchema)) { + return inputSchema as Record; + } + + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; +} + +function _transformTools(tools?: GatewayToolDefinition[]): Array> | undefined { + if (!tools?.length) { + return undefined; + } + + return tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + parameters: _normalizeToolSchema(tool.inputSchema), + }, + })); +} + +function _transformToolChoice(choice?: GatewayToolChoice): string | Record | undefined { + if (!choice) { + return undefined; + } + + if (typeof choice === 'string') { + return choice; + } + + return { + type: 'function', + function: { + name: choice.name, + }, + }; +} + +function _summarizeMessage(message?: GatewayChatMessage): string { + if (!message) { + return ''; + } + + return _flattenContent(message.content); +} + +function _transformChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): ProviderStreamChunk { const choice = chunk.choices[0]; const usage = (chunk as any).usage; + const delta = choice?.delta as any; + const result = delta?.content ?? ''; + const toolCalls = Array.isArray(delta?.tool_calls) + ? delta.tool_calls.map((toolCall: any) => ({ + index: typeof toolCall?.index === 'number' ? toolCall.index : undefined, + id: typeof toolCall?.id === 'string' ? toolCall.id : undefined, + name: typeof toolCall?.function?.name === 'string' ? toolCall.function.name : undefined, + argumentsDelta: + typeof toolCall?.function?.arguments === 'string' + ? toolCall.function.arguments + : undefined, + raw: toolCall, + })) + : undefined; + return { isEnd: choice?.finish_reason != null || (chunk.choices.length === 0 && usage != null), - result: choice?.delta?.content ?? '', + result, usage: usage ?? undefined, + content: result ? [{ type: 'text', text: result }] : undefined, + toolCalls: toolCalls?.length ? toolCalls : undefined, + finishReason: choice?.finish_reason ?? null, + raw: chunk, } } @@ -21,24 +224,47 @@ export class OpenAIProvider extends BaseProvider { this.client = new OpenAI({ apiKey, baseURL, defaultHeaders: headers }); } - async chat(messages: GatewayChatMessage[], model: string, options?: ChatOptions): Promise> { - const startTime = Date.now(); + getCapabilities(): ProviderCapabilities { + return OPENAI_PROVIDER_CAPABILITIES; + } + async chat( + messages: GatewayChatMessage[], + model: string, + options?: ToolCapableChatOptions + ): Promise> { + const startTime = Date.now(); + const transformedMessages = _transformMessages(messages); + const tools = _transformTools(options?.tools); + const toolChoice = tools?.length ? _transformToolChoice(options?.toolChoice) : undefined; const lastMessage = messages[messages.length - 1]; logManager.logApiRequest('chat.completions.create', { model, - lastMessage: lastMessage?.content?.substring(0, 100) + (lastMessage?.content?.length > 100 ? '...' : ''), - messageCount: messages.length, + lastMessage: _summarizeMessage(lastMessage).substring(0, 100) + + (_summarizeMessage(lastMessage).length > 100 ? '...' : ''), + messageCount: transformedMessages.length, + toolCount: tools?.length ?? 0, + toolChoice: typeof toolChoice === 'string' ? toolChoice : (toolChoice ? 'named' : undefined), }, 'POST'); try { - const chunks = await this.client.chat.completions.create({ + const request: Record = { model, - messages: messages as any, + messages: transformedMessages, stream: true, stream_options: { include_usage: true }, - }, { + }; + + if (tools?.length) { + request.tools = tools; + } + + if (toolChoice) { + request.tool_choice = toolChoice; + } + + const chunks = await this.client.chat.completions.create(request as any, { signal: options?.signal, }); diff --git a/electron/utils/uv-setup.ts b/electron/utils/uv-setup.ts index 7e7b652..c40c5f1 100644 --- a/electron/utils/uv-setup.ts +++ b/electron/utils/uv-setup.ts @@ -5,6 +5,14 @@ import { app } from 'electron'; import logManager from '@electron/service/logger'; import { getUvMirrorEnv } from './uv-env'; +type UvResolutionSource = 'bundled' | 'path' | 'bundled-fallback' | 'missing'; + +type UvResolution = { + bin: string; + source: UvResolutionSource; + bundledPath: string; +}; + function getBundledUvPath(): string { const platform = process.platform; const arch = process.arch; @@ -18,35 +26,54 @@ function getBundledUvPath(): string { return join(process.cwd(), 'resources', 'bin', target, binName); } -function findUvInPathSync(): boolean { +function findUvInPathSync(): string | null { try { const command = process.platform === 'win32' ? 'where.exe uv' : 'which uv'; - execSync(command, { stdio: 'ignore', timeout: 5000, windowsHide: true }); - return true; + const output = execSync(command, { + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + windowsHide: true, + encoding: 'utf8', + }); + const resolved = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return resolved || null; } catch { - return false; + return null; } } -function resolveUvBin(): { bin: string; source: 'bundled' | 'path' | 'bundled-fallback' } { +function resolveUvBin(): UvResolution { const bundled = getBundledUvPath(); if (app.isPackaged) { if (existsSync(bundled)) { - return { bin: bundled, source: 'bundled' }; + return { bin: bundled, source: 'bundled', bundledPath: bundled }; } logManager.warn(`Bundled uv binary not found at ${bundled}, falling back to system PATH`); } - if (findUvInPathSync()) { - return { bin: 'uv', source: 'path' }; + const fromPath = findUvInPathSync(); + if (fromPath) { + return { bin: fromPath, source: 'path', bundledPath: bundled }; } if (existsSync(bundled)) { - return { bin: bundled, source: 'bundled-fallback' }; + return { bin: bundled, source: 'bundled-fallback', bundledPath: bundled }; } - return { bin: 'uv', source: 'path' }; + return { bin: bundled, source: 'missing', bundledPath: bundled }; +} + +function buildMissingUvError(resolution: UvResolution): Error { + return new Error( + `uv is required for managed Python setup but is unavailable.\n` + + ` expected bundled binary: ${resolution.bundledPath}\n` + + ` platform: ${process.platform}/${process.arch}\n` + + ` searched PATH: yes`, + ); } export async function checkUvInstalled(): Promise { @@ -54,20 +81,26 @@ export async function checkUvInstalled(): Promise { if (source === 'bundled' || source === 'bundled-fallback') { return existsSync(bin); } - return findUvInPathSync(); + if (source === 'path') { + return Boolean(bin); + } + return false; } export async function installUv(): Promise { - const isAvailable = await checkUvInstalled(); - if (!isAvailable) { - const bin = getBundledUvPath(); - throw new Error(`uv not found in system PATH and bundled binary missing at ${bin}`); + const resolution = resolveUvBin(); + if (resolution.source === 'missing') { + throw buildMissingUvError(resolution); } logManager.info('uv is available and ready to use'); } export async function isPythonReady(): Promise { - const { bin: uvBin } = resolveUvBin(); + const resolution = resolveUvBin(); + if (resolution.source === 'missing') { + return false; + } + const uvBin = resolution.bin; return await new Promise((resolve) => { try { @@ -140,7 +173,11 @@ async function runPythonInstall( } export async function setupManagedPython(): Promise { - const { bin: uvBin, source } = resolveUvBin(); + const resolution = resolveUvBin(); + if (resolution.source === 'missing') { + throw buildMissingUvError(resolution); + } + const { bin: uvBin, source } = resolution; const uvEnv = await getUvMirrorEnv(); const hasMirror = Object.keys(uvEnv).length > 0; diff --git a/package.json b/package.json index 5abd83d..b93dba8 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "main": "dist-electron/main/main.js", "scripts": { "init": "pnpm install && pnpm run uv:download", + "prepackage": "node scripts/ensure-bundled-runtime-binaries.mjs", "predev": "zx scripts/prepare-preinstalled-skills-dev.mjs", "prestart": "zx scripts/prepare-preinstalled-skills-dev.mjs", "dev": "vite", "start": "vite", - "build": "vite build && node scripts/bundle-openclaw.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", + "build": "pnpm run package && electron-builder", "build:vite": "vite build", "test": "vitest run", "smoke:agents": "node scripts/agents-runtime-smoke.mjs", @@ -25,7 +26,7 @@ "uv:download:all": "zx scripts/download-bundled-uv.mjs --all", "node:download:win": "zx scripts/download-bundled-node.mjs --platform=win", "prep:win-binaries": "pnpm run uv:download:win && pnpm run node:download:win", - "package:win": "pnpm run prep:win-binaries && pnpm run package && electron-builder --win --publish never", + "package:win": "pnpm run package && electron-builder --win --publish never", "package:mac": "pnpm run package && electron-builder --mac --publish never", "package:mac:local": "SKIP_AFTERPACK_CLEANUP=1 pnpm run package && electron-builder --mac --publish never", "package:mac:x64": "pnpm run package && electron-builder --mac --x64 --publish never", @@ -140,6 +141,7 @@ "ts-node": "^10.9.2", "use-stick-to-bottom": "^1.1.3", "uuid": "^13.0.0", + "xlsx": "^0.18.5", "zustand": "^5.0.12", "zx": "^8.8.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805553c..846009b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: uuid: specifier: ^13.0.0 version: 13.0.0 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zustand: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) @@ -3235,6 +3238,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3567,6 +3574,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3680,6 +3691,10 @@ packages: codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3790,6 +3805,11 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -4418,6 +4438,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -6600,6 +6624,10 @@ packages: sqlite-vec@0.1.9: resolution: {integrity: sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + ssri@12.0.0: resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -7185,6 +7213,14 @@ packages: win-guid@0.2.1: resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==} + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wordwrapjs@5.1.1: resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} engines: {node: '>=12.17'} @@ -7212,6 +7248,11 @@ packages: utf-8-validate: optional: true + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -10957,6 +10998,8 @@ snapshots: acorn@8.16.0: {} + adler-32@1.3.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -11363,6 +11406,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@6.2.2: {} chalk-template@0.4.0: @@ -11482,6 +11530,8 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -11584,6 +11634,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc-32@1.2.2: {} + crc@3.8.0: dependencies: buffer: 5.7.1 @@ -12298,6 +12350,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@5.3.4: {} framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -15055,6 +15109,10 @@ snapshots: sqlite-vec-linux-x64: 0.1.9 sqlite-vec-windows-x64: 0.1.9 + ssf@0.11.2: + dependencies: + frac: 1.1.2 + ssri@12.0.0: dependencies: minipass: 7.1.3 @@ -15590,6 +15648,10 @@ snapshots: win-guid@0.2.1: {} + wmf@1.0.2: {} + + word@0.3.0: {} + wordwrapjs@5.1.1: {} wrap-ansi@7.0.0: @@ -15608,6 +15670,16 @@ snapshots: ws@8.20.0: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xml-name-validator@5.0.0: {} xml-parse-from-string@1.0.1: {} diff --git a/resources/bin/win32-arm64/uv.exe b/resources/bin/win32-arm64/uv.exe new file mode 100644 index 0000000..e655e57 Binary files /dev/null and b/resources/bin/win32-arm64/uv.exe differ diff --git a/resources/bin/win32-x64/uv.exe b/resources/bin/win32-x64/uv.exe new file mode 100644 index 0000000..c0c9c7e Binary files /dev/null and b/resources/bin/win32-x64/uv.exe differ diff --git a/runtime-shared/shared/chat-model.ts b/runtime-shared/shared/chat-model.ts index 5d903ef..fbd83c1 100644 --- a/runtime-shared/shared/chat-model.ts +++ b/runtime-shared/shared/chat-model.ts @@ -4,7 +4,71 @@ export interface AttachedFileMeta { fileSize: number; preview: string | null; filePath?: string; - source?: 'user-upload' | 'tool-result' | 'message-ref'; + source?: 'user-upload' | 'tool-result' | 'tool-artifact' | 'message-ref'; +} + +export type ToolLifecycleStatus = 'running' | 'completed' | 'error'; + +export type ToolArtifactKind = + | 'file' + | 'image' + | 'table' + | 'url' + | 'json' + | 'text' + | 'unknown'; + +export interface ToolArtifact { + kind?: ToolArtifactKind; + name?: string; + label?: string; + description?: string; + mimeType?: string; + uri?: string; + filePath?: string; + preview?: string | null; + metadata?: Record; +} + +export interface ToolErrorInfo { + code?: string; + message: string; + details?: unknown; +} + +export interface ToolRenderHints { + card?: + | 'generic' + | 'document-analysis' + | 'search-results' + | 'browser-step' + | 'command-output' + | 'skill-install'; + preferredView?: 'summary' | 'table' | 'log' | 'artifact-list'; + skillType?: string; + metadata?: Record; +} + +export interface ToolResultPayload { + ok?: boolean; + summary?: string; + structuredData?: unknown; + files?: AttachedFileMeta[]; + artifacts?: ToolArtifact[]; + logs?: Array>; + error?: string | ToolErrorInfo; + retryable?: boolean; + skillType?: string; + renderHints?: ToolRenderHints; + raw?: unknown; +} + +export interface ToolCallPayload { + id?: string; + name?: string; + input?: unknown; + arguments?: unknown; + summary?: string; } export interface ContentBlockSource { @@ -23,9 +87,17 @@ export interface ContentBlock { mimeType?: string; id?: string; name?: string; + toolCallId?: string; input?: unknown; arguments?: unknown; content?: string | ContentBlock[]; + result?: ToolResultPayload | unknown; + summary?: string; + ok?: boolean; + error?: string | ToolErrorInfo; + artifacts?: ToolArtifact[]; + renderHints?: ToolRenderHints; + metadata?: Record; } export type RawMessageRole = 'user' | 'assistant' | 'system' | 'toolresult' | 'tool_result'; @@ -40,7 +112,8 @@ export interface RawMessage { details?: unknown; isError?: boolean; question?: string[]; - toolCall?: Record | null; + toolCall?: ToolCallPayload | Record | null; + toolResult?: ToolResultPayload | null; _attachedFiles?: AttachedFileMeta[]; _toolStatuses?: ToolStatus[]; } @@ -49,12 +122,15 @@ export interface ToolStatus { id?: string; toolCallId?: string; name: string; - status: 'running' | 'completed' | 'error'; + status: ToolLifecycleStatus; durationMs?: number; summary?: string; updatedAt: number; input?: unknown; - result?: unknown; + result?: ToolResultPayload | unknown; + error?: string | ToolErrorInfo; + artifacts?: ToolArtifact[]; + renderHints?: ToolRenderHints; } export interface ChatSession { diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 58745f6..e98b0c1 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -15,6 +15,83 @@ const fs = require('fs-extra'); const path = require('path'); const esbuild = require('esbuild'); +const ARCH_NAME_MAP = { + 0: 'ia32', + 1: 'x64', + 2: 'armv7l', + 3: 'arm64', + 4: 'universal', +}; + +const REQUIRED_RUNTIME_BINARIES = { + win32: ['uv.exe', 'node.exe'], + darwin: ['uv'], + linux: ['uv'], +}; + +function normalizePackArch(arch) { + if (typeof arch === 'string') { + return arch; + } + + return ARCH_NAME_MAP[arch] || String(arch); +} + +function normalizePlatformName(electronPlatformName) { + switch (electronPlatformName) { + case 'win': + return 'win32'; + case 'mac': + return 'darwin'; + default: + return electronPlatformName; + } +} + +function resolveBundledRuntimeTarget(context) { + const platform = normalizePlatformName(context.electronPlatformName); + const arch = normalizePackArch(context.arch); + return `${platform}-${arch}`; +} + +async function copyBundledRuntimeBinaries(context, overrides = {}) { + const target = resolveBundledRuntimeTarget(context); + const platform = normalizePlatformName(context.electronPlatformName); + const sourceRoot = overrides.sourceRoot || path.join(__dirname, '..', 'resources', 'bin', target); + const resourcesDir = overrides.resourcesDir || resolveResourcesDir(context); + const destRoot = overrides.destRoot || path.join(resourcesDir, 'bin'); + const requiredFiles = overrides.requiredFiles || REQUIRED_RUNTIME_BINARIES[platform] || []; + + if (!(await fs.pathExists(sourceRoot))) { + throw new Error( + `[after-pack] Missing bundled runtime directory for ${target}: ${sourceRoot}. ` + + `Run the bundled runtime preparation step before packaging.`, + ); + } + + await fs.ensureDir(destRoot); + + const entries = await fs.readdir(sourceRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + await fs.copy(path.join(sourceRoot, entry.name), path.join(destRoot, entry.name), { + overwrite: true, + }); + } + + const missing = requiredFiles.filter((fileName) => !fs.existsSync(path.join(destRoot, fileName))); + if (missing.length > 0) { + throw new Error( + `[after-pack] Missing required bundled runtime binaries for ${target}: ${missing.join(', ')}.`, + ); + } + + console.log(`[after-pack] Copied bundled runtime binaries for ${target} -> ${destRoot}`); +} + /** * Remove development artifacts from a directory (recursive). * Removes: test directories, TypeScript definitions, source maps, docs, etc. @@ -131,6 +208,8 @@ module.exports = async function afterPack(context) { console.log(`Running afterPack hook for ${platform}-${arch}`); console.log(`App output directory: ${appOutDir}`); + + await copyBundledRuntimeBinaries(context, { resourcesDir }); // 1. Handle electron/scripts/ directory const scriptsSrc = path.join(__dirname, '..', 'electron/scripts'); @@ -219,3 +298,10 @@ module.exports = async function afterPack(context) { console.log('afterPack hook completed successfully'); }; + +module.exports.__test__ = { + normalizePackArch, + normalizePlatformName, + resolveBundledRuntimeTarget, + copyBundledRuntimeBinaries, +}; diff --git a/scripts/download-bundled-uv.mjs b/scripts/download-bundled-uv.mjs index e8ab673..51aa0ca 100644 --- a/scripts/download-bundled-uv.mjs +++ b/scripts/download-bundled-uv.mjs @@ -53,11 +53,15 @@ async function setupTarget(id) { const tempDir = path.join(ROOT_DIR, 'temp_uv_extract'); const archivePath = path.join(ROOT_DIR, target.filename); const downloadUrl = `${BASE_URL}/${target.filename}`; + const outputBinary = path.join(targetDir, target.binName); echo(chalk.blue`\n📦 Setting up uv for ${id}...`); - // Cleanup & Prep - await fs.remove(targetDir); + // Only remove the target binary, not the entire directory, + // so uv downloads do not wipe other bundled runtime files like node.exe. + if (await fs.pathExists(outputBinary)) { + await fs.remove(outputBinary); + } await fs.remove(tempDir); await fs.ensureDir(targetDir); await fs.ensureDir(tempDir); @@ -96,7 +100,7 @@ async function setupTarget(id) { // uv archives usually contain a folder named after the target const folderName = target.filename.replace('.tar.gz', '').replace('.zip', ''); const sourceBin = path.join(tempDir, folderName, target.binName); - const destBin = path.join(targetDir, target.binName); + const destBin = outputBinary; if (await fs.pathExists(sourceBin)) { await fs.move(sourceBin, destBin, { overwrite: true }); @@ -171,4 +175,4 @@ try { echo(chalk.yellow` Packaging will continue without uv binary.`); // Exit with code 0 to allow packaging to continue process.exit(0); -} \ No newline at end of file +} diff --git a/scripts/ensure-bundled-runtime-binaries.mjs b/scripts/ensure-bundled-runtime-binaries.mjs new file mode 100644 index 0000000..b333116 --- /dev/null +++ b/scripts/ensure-bundled-runtime-binaries.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const ROOT_DIR = path.resolve(import.meta.dirname, '..'); +const BIN_ROOT = path.join(ROOT_DIR, 'resources', 'bin'); + +const PLATFORM_CONFIG = { + win32: { + scripts: ['uv:download:win', 'node:download:win'], + required: [ + ['win32-x64', 'uv.exe'], + ['win32-x64', 'node.exe'], + ['win32-arm64', 'uv.exe'], + ['win32-arm64', 'node.exe'], + ], + }, + darwin: { + scripts: ['uv:download:mac'], + required: [ + ['darwin-x64', 'uv'], + ['darwin-arm64', 'uv'], + ], + }, + linux: { + scripts: ['uv:download:linux'], + required: [ + ['linux-x64', 'uv'], + ['linux-arm64', 'uv'], + ], + }, +}; + +function getMissingBinaries(requiredEntries) { + return requiredEntries + .map(([target, fileName]) => ({ + target, + fileName, + filePath: path.join(BIN_ROOT, target, fileName), + })) + .filter(({ filePath }) => !existsSync(filePath)); +} + +function runPnpmScript(scriptName) { + const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const result = spawnSync(command, ['run', scriptName], { + cwd: ROOT_DIR, + stdio: 'inherit', + windowsHide: true, + }); + + if (result.status !== 0) { + throw new Error(`Failed to run "${scriptName}" (exit=${result.status ?? 'unknown'})`); + } +} + +function formatMissing(missingEntries) { + return missingEntries + .map(({ target, fileName }) => `${target}/${fileName}`) + .join(', '); +} + +function main() { + const config = PLATFORM_CONFIG[process.platform]; + if (!config) { + console.log(`[bundled-runtime] No bundled runtime requirements for platform ${process.platform}; skipping.`); + return; + } + + let missing = getMissingBinaries(config.required); + if (missing.length === 0) { + console.log(`[bundled-runtime] All required bundled runtime binaries are present for ${process.platform}.`); + return; + } + + console.log(`[bundled-runtime] Missing bundled runtime binaries: ${formatMissing(missing)}`); + for (const scriptName of config.scripts) { + runPnpmScript(scriptName); + } + + missing = getMissingBinaries(config.required); + if (missing.length > 0) { + throw new Error( + `[bundled-runtime] Required binaries are still missing after download: ${formatMissing(missing)}`, + ); + } + + console.log(`[bundled-runtime] Bundled runtime binaries are ready for ${process.platform}.`); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index d1855ef..728b12b 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -11,10 +11,17 @@ import { Link2, Loader2, Paperclip, + Search, Wrench, } from 'lucide-react'; import type { ChatMessageItem } from './types'; -import type { ToolStatus } from '../../shared/chat-model'; +import type { + AttachedFileMeta, + ToolArtifact, + ToolRenderHints, + ToolResultPayload, + ToolStatus, +} from '../../shared/chat-model'; import { useI18n } from '../../i18n'; import { apiOpenSkillPath, apiOpenSkillReadme } from '../../lib/skills-api'; import ChatEmptyState from './ChatEmptyState'; @@ -118,10 +125,112 @@ type ToolDetail = { value: string; }; +type ToolUiLabels = { + analysisOverview: string; + structuredData: string; + searchOverview?: string; + files: string; + sheets: string; + rows: string; + cols: string; + columns: string; + preview: string; + findings: string; + artifacts: string; + file: string; + path: string; + engine: string; + provider?: string; + query?: string; + answer?: string; + results?: string; + source?: string; + score?: string; + responseTime?: string; + installCommand?: string; + notAvailable: string; + emptyPreview: string; + moreColumns: string; + topLevelResult: string; +}; + +type NormalizedToolResult = { + payload: ToolResultPayload | null; + renderHints?: ToolRenderHints; + structuredData?: unknown; + artifacts: ToolArtifact[]; +}; + +type StructuredPreview = { + details: ToolDetail[]; + table?: { + headers: string[]; + rows: string[][]; + }; +}; + +type SpreadsheetSheetPreview = { + name: string; + rows?: number; + cols?: number; + columns: string[]; + previewRows: Array>; + findings: string[]; +}; + +type SpreadsheetReportPreview = { + key: string; + fileName: string; + filePath?: string; + engine?: string; + totalRows?: number; + sheetCount?: number; + sheets: SpreadsheetSheetPreview[]; +}; + +type SearchResultPreview = { + provider?: string; + query?: string; + answer?: string; + responseTimeMs?: number; + resultCount?: number; + results: Array<{ + title: string; + url?: string; + snippet?: string; + source?: string; + score?: number; + age?: string; + publishedAt?: string; + installCommand?: string; + }>; +}; + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isAttachedFileMeta(value: unknown): value is AttachedFileMeta { + return isRecord(value) + && typeof value.fileName === 'string' + && typeof value.mimeType === 'string'; +} + +function isToolArtifact(value: unknown): value is ToolArtifact { + return isRecord(value) + && ( + typeof value.kind === 'string' + || typeof value.name === 'string' + || typeof value.label === 'string' + || typeof value.filePath === 'string' + || typeof value.uri === 'string' + ); +} + function getRecordString(value: unknown, ...keys: string[]): string | undefined { if (!isRecord(value)) return undefined; @@ -135,6 +244,396 @@ function getRecordString(value: unknown, ...keys: string[]): string | undefined return undefined; } +function getRecordNumber(value: unknown, ...keys: string[]): number | undefined { + if (!isRecord(value)) return undefined; + + for (const key of keys) { + const field = value[key]; + if (typeof field === 'number' && Number.isFinite(field)) { + return field; + } + } + + return undefined; +} + +function getToolUiLabels(locale: string): ToolUiLabels { + if (locale.startsWith('zh')) { + return { + analysisOverview: '分析概览', + structuredData: '结构化结果', + files: '文件', + sheets: '工作表', + rows: '行', + cols: '列', + columns: '字段', + preview: '预览', + findings: '发现', + artifacts: '相关产物', + file: '文件', + path: '路径', + engine: '解析引擎', + notAvailable: '暂无', + emptyPreview: '没有可展示的预览内容', + moreColumns: '更多字段', + topLevelResult: '结果', + }; + } + + return { + analysisOverview: 'Analysis overview', + structuredData: 'Structured result', + files: 'Files', + sheets: 'Sheets', + rows: 'Rows', + cols: 'Cols', + columns: 'Columns', + preview: 'Preview', + findings: 'Findings', + artifacts: 'Artifacts', + file: 'File', + path: 'Path', + engine: 'Engine', + notAvailable: 'N/A', + emptyPreview: 'No preview available', + moreColumns: 'More columns', + topLevelResult: 'Result', + }; +} + +function formatNumberValue(value: number, locale: string, maximumFractionDigits = 2): string { + return new Intl.NumberFormat(locale.startsWith('zh') ? 'zh-CN' : undefined, { + maximumFractionDigits, + }).format(value); +} + +function formatStructuredValue(value: unknown, locale: string, labels: ToolUiLabels): string { + if (value === null || value === undefined || value === '') { + return labels.notAvailable; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number') { + return formatNumberValue(value, locale); + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (Array.isArray(value)) { + const compact = value + .map((item) => { + if (typeof item === 'string') return item; + if (typeof item === 'number') return formatNumberValue(item, locale); + return ''; + }) + .filter(Boolean); + + if (compact.length > 0) { + const summary = compact.slice(0, 4).join(', '); + return compact.length > 4 ? `${summary} +${compact.length - 4}` : summary; + } + + return `${value.length}`; + } + + if (isRecord(value)) { + return getRecordString(value, 'label', 'title', 'name', 'message', 'summary') + || `${Object.keys(value).length}`; + } + + return String(value); +} + +function prettifyKey(key: string): string { + if (!key) return key; + if (/[\u4e00-\u9fff]/.test(key)) return key; + + return key + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/^./, (char) => char.toUpperCase()); +} + +function isToolResultPayload(value: unknown): value is ToolResultPayload { + return isRecord(value) + && ( + 'structuredData' in value + || 'renderHints' in value + || 'artifacts' in value + || 'files' in value + || 'logs' in value + || 'retryable' in value + || 'ok' in value + ); +} + +function mergeToolArtifacts( + payloadArtifacts: ToolArtifact[], + payloadFiles: AttachedFileMeta[], + toolArtifacts: ToolArtifact[], +): ToolArtifact[] { + const merged = [ + ...payloadArtifacts, + ...payloadFiles.map((file) => ({ + kind: 'file' as const, + name: file.fileName, + filePath: file.filePath, + mimeType: file.mimeType, + preview: file.preview, + metadata: { + fileSize: file.fileSize, + source: file.source, + }, + })), + ...toolArtifacts, + ]; + + const seen = new Set(); + return merged.filter((artifact) => { + const key = [ + artifact.kind, + artifact.name, + artifact.label, + artifact.filePath, + artifact.uri, + ].filter(Boolean).join('|'); + if (!key || seen.has(key)) { + return Boolean(!key); + } + seen.add(key); + return true; + }); +} + +function getNormalizedToolResult(tool: ToolStatus): NormalizedToolResult { + const payload = isToolResultPayload(tool.result) ? tool.result : null; + const payloadArtifacts = payload?.artifacts?.filter(isToolArtifact) ?? []; + const payloadFiles = payload?.files?.filter(isAttachedFileMeta) ?? []; + const toolArtifacts = tool.artifacts?.filter(isToolArtifact) ?? []; + + return { + payload, + renderHints: payload?.renderHints || tool.renderHints, + structuredData: payload?.structuredData, + artifacts: mergeToolArtifacts(payloadArtifacts, payloadFiles, toolArtifacts), + }; +} + +function getToolResultSource(tool: ToolStatus): unknown { + const normalized = getNormalizedToolResult(tool); + if (isRecord(normalized.payload?.structuredData)) { + return normalized.payload.structuredData; + } + + return normalized.payload || tool.result; +} + +function buildMetricLabel(kind: 'files' | 'sheets' | 'rows' | 'cols', value: number, labels: ToolUiLabels, locale: string): string { + const count = formatNumberValue(value, locale); + if (locale.startsWith('zh')) { + switch (kind) { + case 'files': + return `${count}${labels.files}`; + case 'sheets': + return `${count}${labels.sheets}`; + case 'rows': + return `${count}${labels.rows}`; + case 'cols': + return `${count}${labels.cols}`; + default: + return count; + } + } + + switch (kind) { + case 'files': + return `${count} ${value === 1 ? 'file' : labels.files.toLowerCase()}`; + case 'sheets': + return `${count} ${value === 1 ? 'sheet' : labels.sheets.toLowerCase()}`; + case 'rows': + return `${count} ${value === 1 ? 'row' : labels.rows.toLowerCase()}`; + case 'cols': + return `${count} ${value === 1 ? 'col' : labels.cols.toLowerCase()}`; + default: + return count; + } +} + +function buildStructuredPreview( + structuredData: unknown, + locale: string, + labels: ToolUiLabels, +): StructuredPreview | null { + if (Array.isArray(structuredData)) { + const rows = structuredData.filter(isRecord).slice(0, 5); + if (rows.length === 0) { + return null; + } + + const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 6); + return { + details: [], + table: { + headers, + rows: rows.map((row) => headers.map((header) => formatStructuredValue(row[header], locale, labels))), + }, + }; + } + + if (!isRecord(structuredData)) { + if (typeof structuredData === 'string' && structuredData.trim()) { + return { + details: [{ label: labels.topLevelResult, value: structuredData.trim() }], + }; + } + return null; + } + + const detailEntries = Object.entries(structuredData) + .filter(([, value]) => + typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + || isStringArray(value), + ) + .slice(0, 8) + .map(([key, value]) => ({ + label: prettifyKey(key), + value: formatStructuredValue(value, locale, labels), + })); + + const tableSource = Object.values(structuredData).find((value) => + Array.isArray(value) && value.length > 0 && value.every(isRecord), + ); + + if (Array.isArray(tableSource)) { + const rows = tableSource.filter(isRecord).slice(0, 5); + const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 6); + if (headers.length > 0) { + return { + details: detailEntries, + table: { + headers, + rows: rows.map((row) => headers.map((header) => formatStructuredValue(row[header], locale, labels))), + }, + }; + } + } + + return detailEntries.length > 0 ? { details: detailEntries } : null; +} + +function formatFinding(value: unknown, locale: string, labels: ToolUiLabels): string { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + + if (!isRecord(value)) { + return formatStructuredValue(value, locale, labels); + } + + return getRecordString(value, 'message', 'summary', 'description', 'label') + || [ + getRecordString(value, 'type', 'code'), + getRecordString(value, 'column', 'field'), + typeof value.count === 'number' ? formatNumberValue(value.count, locale) : undefined, + ].filter(Boolean).join(' · '); +} + +function buildSpreadsheetPreview( + structuredData: unknown, + locale: string, + labels: ToolUiLabels, +): SpreadsheetReportPreview[] { + if (!isRecord(structuredData) || !Array.isArray(structuredData.reports)) { + return []; + } + + return structuredData.reports + .filter(isRecord) + .map((reportItem, reportIndex) => { + const report = isRecord(reportItem.report) ? reportItem.report : null; + const structure = isRecord(report?.structure) ? report.structure : null; + const quality = isRecord(report?.quality) ? report.quality : null; + const workbook = isRecord(report?.workbook) ? report.workbook : null; + const sheetNames = isStringArray(workbook?.sheetNames) ? workbook.sheetNames : []; + const sheets = Object.entries(structure ?? {}) + .filter(([, value]) => isRecord(value)) + .slice(0, 3) + .map(([sheetName, sheetValue]) => { + const shape = isRecord(sheetValue.shape) ? sheetValue.shape : null; + const columns = isStringArray(sheetValue.columns) ? sheetValue.columns : []; + const previewRows = Array.isArray(sheetValue.preview) + ? sheetValue.preview.filter(isRecord).slice(0, 5) + : []; + const findings = Array.isArray(quality?.[sheetName]) + ? quality[sheetName] + .map((finding) => formatFinding(finding, locale, labels)) + .filter(Boolean) + .slice(0, 4) + : []; + + return { + name: sheetName, + rows: getRecordNumber(shape, 'rows'), + cols: getRecordNumber(shape, 'cols'), + columns, + previewRows, + findings, + }; + }); + + return { + key: `${getRecordString(reportItem, 'filePath', 'fileName') || `report-${reportIndex}`}`, + fileName: getRecordString(reportItem, 'fileName') || getRecordString(reportItem, 'filePath') || `Report ${reportIndex + 1}`, + filePath: getRecordString(reportItem, 'filePath'), + engine: getRecordString(report, 'engine'), + totalRows: getRecordNumber(workbook, 'totalRows'), + sheetCount: sheetNames.length || sheets.length, + sheets, + }; + }); +} + +function buildSearchResultsPreview(structuredData: unknown): SearchResultPreview | null { + if (!isRecord(structuredData) || !Array.isArray(structuredData.results)) { + return null; + } + + const results = structuredData.results + .filter(isRecord) + .slice(0, 6) + .map((item) => ({ + title: getRecordString(item, 'title', 'name') || 'Untitled result', + url: getRecordString(item, 'url'), + snippet: getRecordString(item, 'snippet', 'description', 'content'), + source: getRecordString(item, 'source'), + score: getRecordNumber(item, 'score'), + age: getRecordString(item, 'age'), + publishedAt: getRecordString(item, 'publishedAt', 'published_at', 'published_date'), + installCommand: getRecordString(item, 'installCommand', 'install_command'), + })); + + if (results.length === 0) { + return null; + } + + return { + provider: getRecordString(structuredData, 'provider'), + query: getRecordString(structuredData, 'query'), + answer: getRecordString(structuredData, 'answer'), + responseTimeMs: getRecordNumber(structuredData, 'responseTimeMs', 'response_time_ms'), + resultCount: getRecordNumber(structuredData, 'resultCount', 'result_count'), + results, + }; +} + function getToolDisplayName(name: string, t: TranslateFn): string { switch (name) { case 'skills.install': @@ -160,12 +659,15 @@ function getToolStatusLabel(status: ToolStatus['status'], t: TranslateFn): strin } function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] { + const normalized = getNormalizedToolResult(tool); + const toolResult = getToolResultSource(tool); + 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 slug = getRecordString(toolResult, 'slug') || getRecordString(tool.input, 'slug'); + const source = getRecordString(toolResult, 'source') || getRecordString(tool.input, 'kind'); + const baseDir = getRecordString(toolResult, 'baseDir'); const requestUrl = getRecordString(tool.input, 'url'); - const error = getRecordString(tool.result, 'error'); + const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message'); const details: ToolDetail[] = []; if (slug) { @@ -188,9 +690,9 @@ function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] { } 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 link = getRecordString(toolResult, 'pageUrl', 'url') || getRecordString(tool.input, 'url'); + const title = getRecordString(toolResult, 'title'); + const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message'); const details: ToolDetail[] = []; if (link) { @@ -206,7 +708,7 @@ function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] { return details; } - const error = getRecordString(tool.result, 'error'); + const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message'); return error ? [{ label: t('conversation.messageList.toolFields.error'), value: error }] : []; } @@ -251,18 +753,44 @@ function ToolResultCard({ }: { tool: ToolStatus; }) { - const { t } = useI18n(); + const { t, locale } = 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 labels = getToolUiLabels(locale); + const normalized = getNormalizedToolResult(tool); + const toolResult = getToolResultSource(tool); 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'); + const skillKey = getRecordString(toolResult, 'skillKey', 'slug') || getRecordString(tool.input, 'slug'); + const skillSlug = getRecordString(toolResult, 'slug') || getRecordString(tool.input, 'slug'); + const skillBaseDir = getRecordString(toolResult, 'baseDir'); + const browserUrl = getRecordString(toolResult, 'pageUrl', 'url') || getRecordString(tool.input, 'url'); + const spreadsheetReports = !isRunning && !isError + ? buildSpreadsheetPreview(normalized.structuredData, locale, labels) + : []; + const searchPreview = !isRunning && !isError + ? buildSearchResultsPreview(normalized.structuredData) + : null; + const genericStructuredPreview = !isRunning && !isError && spreadsheetReports.length === 0 && !searchPreview + ? buildStructuredPreview(normalized.structuredData, locale, labels) + : null; + const totalSheets = spreadsheetReports.reduce((sum, report) => sum + (report.sheetCount || report.sheets.length), 0); + const totalRows = spreadsheetReports.reduce((sum, report) => { + if (typeof report.totalRows === 'number') { + return sum + report.totalRows; + } + + return sum + report.sheets.reduce((sheetSum, sheet) => sheetSum + (sheet.rows || 0), 0); + }, 0); + const overviewMetrics = [ + spreadsheetReports.length > 0 ? buildMetricLabel('files', spreadsheetReports.length, labels, locale) : null, + totalSheets > 0 ? buildMetricLabel('sheets', totalSheets, labels, locale) : null, + totalRows > 0 ? buildMetricLabel('rows', totalRows, labels, locale) : null, + ].filter(Boolean) as string[]; + const artifacts = normalized.artifacts.slice(0, 8); async function handleAction( actionKey: string, @@ -359,6 +887,347 @@ function ToolResultCard({ ) : null} + {spreadsheetReports.length > 0 ? ( +
+
+
+ {labels.analysisOverview} +
+ {overviewMetrics.length > 0 ? ( +
+ {overviewMetrics.map((metric) => ( + + {metric} + + ))} +
+ ) : null} +
+ + {spreadsheetReports.map((report) => ( +
+
+
+
+ {report.fileName} +
+
+ {report.filePath ? ( + {labels.path}: {report.filePath} + ) : null} + {report.engine ? {labels.engine}: {report.engine} : null} +
+
+
+ {typeof report.sheetCount === 'number' && report.sheetCount > 0 ? ( + + {buildMetricLabel('sheets', report.sheetCount, labels, locale)} + + ) : null} + {typeof report.totalRows === 'number' && report.totalRows > 0 ? ( + + {buildMetricLabel('rows', report.totalRows, labels, locale)} + + ) : null} +
+
+ + {report.sheets.map((sheet) => { + const previewHeaders = Array.from(new Set([ + ...sheet.columns, + ...sheet.previewRows.flatMap((row) => Object.keys(row)), + ])).slice(0, 6); + const extraColumnCount = Math.max(sheet.columns.length - previewHeaders.length, 0); + + return ( +
+
+
+ {sheet.name} +
+ {typeof sheet.rows === 'number' ? ( + + {buildMetricLabel('rows', sheet.rows, labels, locale)} + + ) : null} + {typeof sheet.cols === 'number' ? ( + + {buildMetricLabel('cols', sheet.cols, labels, locale)} + + ) : null} +
+ + {sheet.columns.length > 0 ? ( +
+ {labels.columns}: {sheet.columns.slice(0, 6).join(', ')} + {extraColumnCount > 0 ? ` · ${labels.moreColumns} +${extraColumnCount}` : ''} +
+ ) : null} + +
+
+ {labels.preview} +
+ {previewHeaders.length > 0 && sheet.previewRows.length > 0 ? ( +
+ + + + {previewHeaders.map((header) => ( + + ))} + + + + {sheet.previewRows.map((row, rowIndex) => ( + + {previewHeaders.map((header) => ( + + ))} + + ))} + +
+ {header} +
+ {formatStructuredValue(row[header], locale, labels)} +
+
+ ) : ( +
+ {labels.emptyPreview} +
+ )} +
+ + {sheet.findings.length > 0 ? ( +
+
+ {labels.findings} +
+
    + {sheet.findings.map((finding, index) => ( +
  • + {finding} +
  • + ))} +
+
+ ) : null} +
+ ); + })} +
+ ))} +
+ ) : null} + + {searchPreview ? ( +
+
+
+ + {labels.searchOverview || 'Search overview'} +
+ {searchPreview.provider ? ( + + {(labels.provider || 'Provider')}: {searchPreview.provider} + + ) : null} + {typeof searchPreview.resultCount === 'number' ? ( + + {formatNumberValue(searchPreview.resultCount, locale)} {(labels.results || 'Results').toLowerCase()} + + ) : null} + {typeof searchPreview.responseTimeMs === 'number' ? ( + + {(labels.responseTime || 'Response time')}: {formatToolDuration(searchPreview.responseTimeMs) || `${searchPreview.responseTimeMs}ms`} + + ) : null} +
+ + {searchPreview.query ? ( +
+
+ {labels.query || 'Query'} +
+
+ {searchPreview.query} +
+
+ ) : null} + + {searchPreview.answer ? ( +
+
+ {labels.answer || 'Answer'} +
+
+ {searchPreview.answer} +
+
+ ) : null} + +
+
+ {labels.results || 'Results'} +
+ {searchPreview.results.map((result, index) => ( +
+
+
+ {result.title} +
+ {result.url ? ( +
+ {result.url} +
+ ) : null} +
+ + {result.snippet ? ( +
+ {result.snippet} +
+ ) : null} + +
+ {result.source ? ( + {labels.source || 'Source'}: {result.source} + ) : null} + {typeof result.score === 'number' ? ( + {labels.score || 'Score'}: {result.score.toFixed(4)} + ) : null} + {result.age ? {result.age} : null} + {result.publishedAt ? {result.publishedAt} : null} +
+ + {result.installCommand ? ( +
+
+ {labels.installCommand || 'Install command'} +
+
+ {result.installCommand} +
+
+ ) : null} +
+ ))} +
+
+ ) : null} + + {genericStructuredPreview && (genericStructuredPreview.details.length > 0 || genericStructuredPreview.table) ? ( +
+
+ {labels.structuredData} +
+ + {genericStructuredPreview.details.length > 0 ? ( +
+ {genericStructuredPreview.details.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
+ ) : null} + + {genericStructuredPreview.table ? ( +
+ + + + {genericStructuredPreview.table.headers.map((header) => ( + + ))} + + + + {genericStructuredPreview.table.rows.map((row, rowIndex) => ( + + {row.map((value, valueIndex) => ( + + ))} + + ))} + +
+ {prettifyKey(header)} +
+ {value} +
+
+ ) : null} +
+ ) : null} + + {artifacts.length > 0 ? ( +
+
+ {labels.artifacts} +
+
+ {artifacts.map((artifact, index) => { + const title = artifact.label || artifact.name || artifact.filePath || artifact.uri || `${labels.file} ${index + 1}`; + const subtitle = artifact.description + || artifact.filePath + || artifact.uri + || artifact.mimeType; + + return ( +
+
+ {title} +
+ {subtitle ? ( +
+ {subtitle} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} + {tool.name === 'skills.install' && !isRunning && (skillKey || skillBaseDir) ? (
diff --git a/src/shared/chat-model.ts b/src/shared/chat-model.ts index 7249991..20626d8 100644 --- a/src/shared/chat-model.ts +++ b/src/shared/chat-model.ts @@ -2,17 +2,56 @@ import type { RawMessage } from '../../runtime-shared/shared/chat-model'; export * from '../../runtime-shared/shared/chat-model'; +function flattenContentBlocks(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')) { + if (typeof block.content === 'string' && block.content.trim()) { + return block.content; + } + + if (Array.isArray(block.content)) { + return flattenContentBlocks(block.content); + } + + if (typeof block.summary === 'string' && block.summary.trim()) { + return block.summary; + } + + const result = block.result; + if ( + result + && typeof result === 'object' + && 'summary' in result + && typeof result.summary === 'string' + && result.summary.trim() + ) { + return result.summary; + } + } + + return ''; + }) + .filter(Boolean) + .join('\n'); +} + export function extractText(message?: RawMessage | null): string { if (!message) return ''; - if (typeof message.content === 'string') { - return message.content; - } - - return message.content - .filter((block) => block.type === 'text' && typeof block.text === 'string') - .map((block) => block.text ?? '') - .join('\n'); + return flattenContentBlocks(message.content); } export function extractThinking(message?: RawMessage | null): string | null { @@ -99,6 +138,16 @@ export function isInternalMessage(message: { role?: string; content?: unknown }) if (message.role === 'system') return true; if (message.role === 'assistant') { + if (Array.isArray(message.content)) { + const hasVisibleText = message.content.some((block) => block.type === 'text' && block.text?.trim()); + const hasOnlyToolUseBlocks = message.content.length > 0 + && message.content.every((block) => block.type === 'tool_use' || block.type === 'toolCall'); + + if (hasOnlyToolUseBlocks && !hasVisibleText) { + return true; + } + } + const text = typeof message.content === 'string' ? message.content : extractText(message as RawMessage); diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 07f0451..f24ae28 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -13,6 +13,7 @@ import { t } from '../i18n'; import { extractText, isToolOnlyMessage } from '../shared/chat-model'; import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client'; import { hostApiFetch } from '../lib/host-api'; +import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../lib/runtime-events'; import type { GatewayEvent } from '../types/runtime'; import { agentsStore } from './agents'; @@ -417,6 +418,11 @@ async function subscribeToGateway(): Promise { return; } + if (isRuntimeChangedGatewayEvent(event) && runtimeEventHasTopic(event, 'skills')) { + void handleGatewayEvent(event); + return; + } + if (typeof event.sessionKey === 'string' && event.sessionKey !== state.currentSessionKey) { return; } @@ -1031,6 +1037,14 @@ async function handleGatewayEvent(event: GatewayEvent): Promise { }); break; } + case 'runtime:changed': { + if (runtimeEventHasTopic(event, 'skills') && state.initialized) { + patchState({ error: null }); + lastHistoryLoadAtBySession.delete(state.currentSessionKey); + await loadHistory(state.currentSessionKey, true); + } + break; + } default: break; } diff --git a/tests/after-pack-runtime-binaries.test.ts b/tests/after-pack-runtime-binaries.test.ts new file mode 100644 index 0000000..73bb03d --- /dev/null +++ b/tests/after-pack-runtime-binaries.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'fs-extra'; + +import afterPackModule from '../scripts/after-pack.cjs'; +const afterPackTestApi = afterPackModule.__test__ as { + resolveBundledRuntimeTarget(context: { electronPlatformName: string; arch: string | number }): string; + copyBundledRuntimeBinaries( + context: { electronPlatformName: string; arch: string | number; appOutDir: string }, + overrides?: { + sourceRoot?: string; + resourcesDir?: string; + destRoot?: string; + requiredFiles?: string[]; + } + ): Promise; +}; + +const tempDirs: string[] = []; + +function joinPath(...parts: string[]): string { + return parts + .filter(Boolean) + .join('/') + .replace(/\/+/g, '/'); +} + +async function createTempDir(prefix: string): Promise { + const dir = joinPath( + process.cwd().replace(/\\/g, '/'), + '.tmp-vitest', + `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + ); + await fs.ensureDir(dir); + tempDirs.push(dir); + return dir; +} + +describe('after-pack bundled runtime binaries', () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir))); + }); + + it('resolves electron-builder platform and arch to the runtime binary target', () => { + expect(afterPackTestApi.resolveBundledRuntimeTarget({ + electronPlatformName: 'win', + arch: 1, + })).toBe('win32-x64'); + + expect(afterPackTestApi.resolveBundledRuntimeTarget({ + electronPlatformName: 'mac', + arch: 'arm64', + })).toBe('darwin-arm64'); + }); + + it('copies required runtime binaries into the packaged resources/bin directory', async () => { + const sourceRoot = await createTempDir('after-pack-source-'); + const resourcesDir = await createTempDir('after-pack-resources-'); + + await fs.writeFile(joinPath(sourceRoot, 'uv.exe'), 'uv-binary', 'utf8'); + await fs.writeFile(joinPath(sourceRoot, 'node.exe'), 'node-binary', 'utf8'); + + await afterPackTestApi.copyBundledRuntimeBinaries( + { + electronPlatformName: 'win', + arch: 'x64', + appOutDir: resourcesDir, + }, + { + sourceRoot, + resourcesDir, + requiredFiles: ['uv.exe', 'node.exe'], + }, + ); + + expect(await fs.readFile(joinPath(resourcesDir, 'bin', 'uv.exe'), 'utf8')).toBe('uv-binary'); + expect(await fs.readFile(joinPath(resourcesDir, 'bin', 'node.exe'), 'utf8')).toBe('node-binary'); + }); + + it('fails packaging when required runtime binaries are still missing', async () => { + const sourceRoot = await createTempDir('after-pack-missing-source-'); + const resourcesDir = await createTempDir('after-pack-missing-resources-'); + + await fs.writeFile(joinPath(sourceRoot, 'node.exe'), 'node-binary', 'utf8'); + + await expect(afterPackTestApi.copyBundledRuntimeBinaries( + { + electronPlatformName: 'win', + arch: 'x64', + appOutDir: resourcesDir, + }, + { + sourceRoot, + resourcesDir, + requiredFiles: ['uv.exe', 'node.exe'], + }, + )).rejects.toThrow('Missing required bundled runtime binaries'); + }); +}); diff --git a/tests/chat-message-list.test.tsx b/tests/chat-message-list.test.tsx index 16398a9..34a017f 100644 --- a/tests/chat-message-list.test.tsx +++ b/tests/chat-message-list.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { setLocale } from '../src/i18n'; import ChatMessageList from '../src/components/chat/ChatMessageList'; @@ -17,7 +18,7 @@ vi.mock('../src/lib/skills-api', () => ({ describe('ChatMessageList', () => { beforeEach(() => { - setLocale('zh'); + setLocale('en'); vi.clearAllMocks(); mocks.apiOpenSkillPath.mockResolvedValue(undefined); mocks.apiOpenSkillReadme.mockResolvedValue(undefined); @@ -37,9 +38,9 @@ describe('ChatMessageList', () => { { id: 'user-1', role: 'user', - name: '你', + name: 'Me', time: '10:00', - content: '帮我安装这个 skill', + content: 'Install this skill for me.', }, ]} streamingTools={[ @@ -55,9 +56,9 @@ describe('ChatMessageList', () => { />, ); - expect(screen.getByText('正在执行工具...')).toBeTruthy(); - expect(screen.getByText('安装 Skill')).toBeTruthy(); - expect(screen.getByText('运行中')).toBeTruthy(); + expect(screen.getByText('Running tools...')).toBeTruthy(); + expect(screen.getByText('Install Skill')).toBeTruthy(); + expect(screen.getByText('Running')).toBeTruthy(); expect(screen.getByText('Installing minimax-xlsx')).toBeTruthy(); }); @@ -70,7 +71,7 @@ describe('ChatMessageList', () => { role: 'assistant', name: 'YINIAN', time: '10:01', - content: '已安装完成', + content: 'The page is open.', isStreaming: true, }, ]} @@ -81,18 +82,18 @@ describe('ChatMessageList', () => { name: 'browser.open_url', status: 'completed', durationMs: 1200, - summary: '已为你打开 http://www.baidu.com/', + summary: 'Opened http://www.baidu.com/', updatedAt: Date.now(), }, ]} />, ); - expect(screen.getByText('打开网页')).toBeTruthy(); - expect(screen.getByText('已完成')).toBeTruthy(); + expect(screen.getByText('Open Webpage')).toBeTruthy(); + expect(screen.getByText('Completed')).toBeTruthy(); expect(screen.getByText('1.2s')).toBeTruthy(); - expect(screen.getByText('已为你打开 http://www.baidu.com/')).toBeTruthy(); - expect(screen.getByText('已安装完成')).toBeTruthy(); + expect(screen.getByText('Opened http://www.baidu.com/')).toBeTruthy(); + expect(screen.getByText('The page is open.')).toBeTruthy(); }); it('renders persistent skill-install tool cards with follow-up actions', async () => { @@ -104,14 +105,14 @@ describe('ChatMessageList', () => { role: 'assistant', name: 'YINIAN', time: '10:03', - content: '已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx', + content: 'Installed and enabled minimax-xlsx at /tmp/minimax-xlsx', toolStatuses: [ { id: 'tool-3', toolCallId: 'skills.install:run-3', name: 'skills.install', status: 'completed', - summary: '已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx', + summary: 'Installed and enabled minimax-xlsx at /tmp/minimax-xlsx', durationMs: 980, updatedAt: Date.now(), input: { @@ -130,12 +131,12 @@ describe('ChatMessageList', () => { />, ); - expect(screen.getAllByText('已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx')).toHaveLength(1); + expect(screen.getAllByText('Installed and enabled minimax-xlsx at /tmp/minimax-xlsx')).toHaveLength(1); expect(screen.getByText('Skill')).toBeTruthy(); expect(screen.getByText('/tmp/minimax-xlsx')).toBeTruthy(); - expect(screen.getByText('后续动作')).toBeTruthy(); + expect(screen.getByText('Next actions')).toBeTruthy(); - fireEvent.click(screen.getByText('打开目录')); + fireEvent.click(screen.getByText('Open folder')); await waitFor(() => { expect(mocks.apiOpenSkillPath).toHaveBeenCalledWith( 'minimax-xlsx', @@ -144,10 +145,291 @@ describe('ChatMessageList', () => { ); }); - fireEvent.click(screen.getByText('复制路径')); + fireEvent.click(screen.getByText('Copy path')); await waitFor(() => { expect(mocks.writeText).toHaveBeenCalledWith('/tmp/minimax-xlsx'); }); - expect(screen.getByText('路径已复制')).toBeTruthy(); + expect(screen.getByText('Path copied')).toBeTruthy(); + }); + + it('renders spreadsheet analysis cards from structured tool results', () => { + render( + , + ); + + const spreadsheetCard = screen.getByTestId('tool-spreadsheet-preview'); + expect(within(spreadsheetCard).getByText('Analysis overview')).toBeTruthy(); + expect(within(spreadsheetCard).getByText('1 file')).toBeTruthy(); + expect(within(spreadsheetCard).getAllByText('1 sheet').length).toBeGreaterThan(0); + expect(within(spreadsheetCard).getAllByText('18,358 rows').length).toBeGreaterThan(0); + expect(within(spreadsheetCard).getByText('hotel-sales.xls')).toBeTruthy(); + expect(within(spreadsheetCard).getByText('Grid Results')).toBeTruthy(); + expect(within(spreadsheetCard).getByText('12 cols')).toBeTruthy(); + expect(within(spreadsheetCard).getByText(/Columns: channel, sales_amount, sales_date, room_type/)).toBeTruthy(); + expect(within(spreadsheetCard).getByText('Douyin')).toBeTruthy(); + expect(within(spreadsheetCard).getByText('615.53')).toBeTruthy(); + expect(within(spreadsheetCard).getByText('sales_date missing in 2,566 rows')).toBeTruthy(); + expect(screen.getByText('Artifacts')).toBeTruthy(); + expect(screen.getByText('F:\\Downloads\\hotel-sales.xls')).toBeTruthy(); + }); + + it('renders generic document-analysis structured data when there is no spreadsheet preview', () => { + render( + , + ); + + const structuredCard = screen.getByTestId('tool-structured-result'); + expect(within(structuredCard).getByText('Structured result')).toBeTruthy(); + expect(within(structuredCard).getByText('Confidence')).toBeTruthy(); + expect(within(structuredCard).getByText('0.94')).toBeTruthy(); + expect(within(structuredCard).getByText('Documents')).toBeTruthy(); + expect(within(structuredCard).getByText('master-service-agreement.pdf')).toBeTruthy(); + expect(within(structuredCard).getByText('Termination')).toBeTruthy(); + expect(within(structuredCard).getByText('Needs review')).toBeTruthy(); + }); + + it('renders dedicated search result cards for search-family tool results', () => { + render( + , + ); + + const searchCard = screen.getByTestId('tool-search-results'); + expect(within(searchCard).getByText('Search overview')).toBeTruthy(); + expect(within(searchCard).getByText('Provider: brave')).toBeTruthy(); + expect(within(searchCard).getByText('2 results')).toBeTruthy(); + expect(within(searchCard).getByText('Query')).toBeTruthy(); + expect(within(searchCard).getByText('hotel revenue trends')).toBeTruthy(); + expect(within(searchCard).getByText('Answer')).toBeTruthy(); + expect(within(searchCard).getByText('Revenue continues to improve across the sector.')).toBeTruthy(); + expect(within(searchCard).getByText('Hotel Revenue Trends Q1')).toBeTruthy(); + expect(within(searchCard).getByText('https://example.com/revenue-q1')).toBeTruthy(); + expect(within(searchCard).getByText('Source: Example News')).toBeTruthy(); + expect(within(searchCard).getByText('Score: 0.9821')).toBeTruthy(); + }); + + it('renders install commands in the search result card for command-style tool results', () => { + render( + , + ); + + const searchCard = screen.getByTestId('tool-search-results'); + expect(within(searchCard).getByText('react-best-practices')).toBeTruthy(); + expect(within(searchCard).getByText('Install command')).toBeTruthy(); + expect(within(searchCard).getByText('npx skills add vercel-labs/agent-skills@react-best-practices -g -y')).toBeTruthy(); }); }); diff --git a/tests/chat-provider-tool-loop.test.ts b/tests/chat-provider-tool-loop.test.ts new file mode 100644 index 0000000..e2f63d7 --- /dev/null +++ b/tests/chat-provider-tool-loop.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const sessionMessages: any[] = []; + let activeRun: + | { + runId: string; + abortController: AbortController; + } + | undefined; + + return { + sessionMessages, + getActiveRun: vi.fn(() => activeRun), + providerChat: vi.fn(), + openUrlInBrowser: vi.fn(), + appendTranscriptLine: vi.fn(), + logger: { + error: vi.fn(), + warn: vi.fn(), + }, + sessionStore: { + appendMessage: vi.fn((_: string, message: unknown) => { + sessionMessages.push(message); + }), + getOrCreate: vi.fn(() => ({ + key: 'agent:test:main', + messages: [...sessionMessages], + updatedAt: Date.now(), + })), + setActiveRun: vi.fn((_: string, runId: string, abortController: AbortController) => { + activeRun = { runId, abortController }; + }), + clearActiveRun: vi.fn(() => { + activeRun = undefined; + }), + getActiveRun: vi.fn(() => activeRun), + }, + }; +}); + +vi.mock('@electron/providers', () => ({ + createProvider: vi.fn(() => ({ + getCapabilities: () => ({ + structuredMessages: true, + toolCalls: true, + toolResults: true, + thinking: false, + }), + 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: mocks.sessionStore, +})); + +vi.mock('@electron/service/browser-open-service', () => ({ + openUrlInBrowser: mocks.openUrlInBrowser, +})); + +vi.mock('@electron/utils/token-usage-writer', () => ({ + appendTranscriptLine: mocks.appendTranscriptLine, +})); + +vi.mock('../electron/gateway/skill-capability-registry', () => ({ + getEnabledSkillCapabilities: () => [], +})); + +vi.mock('@electron/service/logger', () => ({ + default: mocks.logger, +})); + +function createStream(chunks: Array>) { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +function flushAsyncTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('chat provider tool loop', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.sessionMessages.length = 0; + mocks.openUrlInBrowser.mockResolvedValue({ + url: 'https://example.com/', + pageUrl: 'https://example.com/', + title: 'Example Domain', + }); + + mocks.providerChat + .mockResolvedValueOnce(createStream([ + { + toolCalls: [ + { + index: 0, + id: 'call_browser_1', + name: 'browser.open_url', + argumentsDelta: '{"url":"https://example.com/"}', + }, + ], + finishReason: 'tool_calls', + }, + ])) + .mockResolvedValueOnce(createStream([ + { + result: '已打开页面并获取标题:Example Domain', + finishReason: 'stop', + }, + ])); + }); + + it('executes provider-requested tools and resumes the model with tool_result context', async () => { + const { handleChatSend } = await import('../electron/gateway/handlers/chat'); + const broadcast = vi.fn(); + + const result = handleChatSend( + { + sessionKey: 'agent:test:main', + message: { + role: 'user', + content: '请帮我查看一下官网首页信息', + }, + }, + broadcast, + ); + + expect(result.runId).toBeTypeOf('string'); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(mocks.providerChat).toHaveBeenCalledTimes(2); + expect(mocks.providerChat.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: 'browser.open_url', + }), + ]), + toolChoice: 'auto', + })); + + const secondCallMessages = mocks.providerChat.mock.calls[1]?.[0] as Array>; + expect(secondCallMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'assistant', + content: expect.arrayContaining([ + expect.objectContaining({ + type: 'tool_use', + name: 'browser.open_url', + }), + ]), + }), + expect.objectContaining({ + role: 'tool_result', + }), + ])); + + expect(mocks.openUrlInBrowser).toHaveBeenCalledWith( + 'https://example.com/', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tool:status', + toolName: 'browser.open_url', + status: 'running', + })); + expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tool:status', + toolName: 'browser.open_url', + status: 'completed', + })); + expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + type: 'chat:final', + message: expect.objectContaining({ + role: 'assistant', + content: '已打开页面并获取标题:Example Domain', + }), + })); + }); +}); diff --git a/tests/chat-runtime-context.test.ts b/tests/chat-runtime-context.test.ts index e27d338..9533499 100644 --- a/tests/chat-runtime-context.test.ts +++ b/tests/chat-runtime-context.test.ts @@ -4,10 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const sessionMessages: any[] = []; + const activeRuns = new Map(); return { sessionMessages, + activeRuns, providerChat: vi.fn(), + providerGetCapabilities: vi.fn(), + getEnabledSkillCapabilities: vi.fn(() => []), + toolRuntimeRun: vi.fn(), + createChatToolRuntime: vi.fn(() => ({ + run: mocks.toolRuntimeRun, + })), appendMessage: vi.fn((_: string, message: unknown) => { sessionMessages.push(message); }), @@ -16,8 +24,13 @@ const mocks = vi.hoisted(() => { messages: [...sessionMessages], updatedAt: Date.now(), })), - setActiveRun: vi.fn(), - clearActiveRun: vi.fn(), + setActiveRun: vi.fn((sessionKey: string, runId: string, abortController: AbortController) => { + activeRuns.set(sessionKey, { runId, abortController }); + }), + clearActiveRun: vi.fn((sessionKey: string) => { + activeRuns.delete(sessionKey); + }), + getActiveRun: vi.fn((sessionKey: string) => activeRuns.get(sessionKey)), appendTranscriptLine: vi.fn(), maybeHandleBrowserOpenMessage: vi.fn(() => false), maybeHandleSkillInstallMessage: vi.fn(() => false), @@ -30,6 +43,7 @@ const mocks = vi.hoisted(() => { vi.mock('@electron/providers', () => ({ createProvider: vi.fn(() => ({ chat: mocks.providerChat, + getCapabilities: mocks.providerGetCapabilities, })), })); @@ -53,6 +67,7 @@ vi.mock('../electron/gateway/session-store', () => ({ getOrCreate: mocks.getOrCreate, setActiveRun: mocks.setActiveRun, clearActiveRun: mocks.clearActiveRun, + getActiveRun: mocks.getActiveRun, }, })); @@ -60,6 +75,18 @@ vi.mock('@electron/utils/token-usage-writer', () => ({ appendTranscriptLine: mocks.appendTranscriptLine, })); +vi.mock('../electron/gateway/skill-capability-registry', () => ({ + getEnabledSkillCapabilities: mocks.getEnabledSkillCapabilities, +})); + +vi.mock('../electron/gateway/chat-tooling', async () => { + const actual = await vi.importActual('../electron/gateway/chat-tooling'); + return { + ...actual, + createChatToolRuntime: mocks.createChatToolRuntime, + }; +}); + vi.mock('../electron/gateway/browser-shortcut', () => ({ maybeHandleBrowserOpenMessage: mocks.maybeHandleBrowserOpenMessage, })); @@ -82,17 +109,62 @@ function createStream(chunks: Array<{ result?: string; usage?: unknown }>) { }; } -function flushAsyncTasks(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); +function flushAsyncTasks(iterations = 1): Promise { + return new Promise((resolve) => { + const next = (remaining: number) => { + if (remaining <= 0) { + resolve(); + return; + } + + setTimeout(() => next(remaining - 1), 0); + }; + + next(iterations); + }); } +const spreadsheetCapability = { + skillKey: 'minimax-xlsx', + slug: 'minimax-xlsx', + name: 'MiniMax XLSX', + description: 'Analyze spreadsheet files such as .xlsx and .csv.', + enabled: true, + category: 'document', + allowedTools: [], + operationHints: ['read', 'analyze'], + triggerHints: ['spreadsheet', 'excel'], + inputExtensions: ['.xlsx', '.csv', '.tsv'], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'document skill; operations: read, analyze; inputs: .xlsx, .csv, .tsv', + renderHints: { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + }, +}; + describe('chat runtime context', () => { beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); mocks.sessionMessages.length = 0; + mocks.activeRuns.clear(); mocks.maybeHandleBrowserOpenMessage.mockReturnValue(false); mocks.maybeHandleSkillInstallMessage.mockReturnValue(false); + mocks.getEnabledSkillCapabilities.mockReturnValue([]); mocks.providerChat.mockResolvedValue(createStream([{ result: 'done' }])); + mocks.providerGetCapabilities.mockReturnValue({ + structuredMessages: false, + toolCalls: false, + toolResults: false, + thinking: false, + }); + mocks.toolRuntimeRun.mockReset(); + mocks.createChatToolRuntime.mockImplementation(() => ({ + run: mocks.toolRuntimeRun, + })); }); it('prepends the zn-ai runtime context before provider chat runs', async () => { @@ -134,4 +206,212 @@ describe('chat runtime context', () => { content: expect.stringContaining('skills.install'), }); }); + + it('persists tool_use -> tool_result -> final for planner-first spreadsheet execution', async () => { + mocks.getEnabledSkillCapabilities.mockReturnValue([spreadsheetCapability]); + mocks.providerChat.mockResolvedValue(createStream([{ result: 'Final answer from provider.' }])); + mocks.toolRuntimeRun.mockImplementation(async (invocation: { toolCallId: string; toolName: string; input: unknown }) => { + const payload = { + ok: true, + summary: 'Spreadsheet analysis completed.', + structuredData: { + reports: [{ filePath: 'C:\\tmp\\report.xlsx', rows: 3 }], + }, + renderHints: { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + }, + raw: { + reports: [{ filePath: 'C:\\tmp\\report.xlsx', rows: 3 }], + }, + }; + + return { + preflight: { + ok: true, + status: 'ready', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: 'Ready to analyze the spreadsheet.', + }, + execution: { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + normalizedInput: invocation.input, + summary: 'Spreadsheet analysis completed.', + raw: payload.raw, + durationMs: 12, + }, + normalized: { + ok: true, + status: 'completed', + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + summary: 'Spreadsheet analysis completed.', + payload, + block: { + type: 'tool_result', + toolCallId: invocation.toolCallId, + content: 'Spreadsheet analysis completed.', + result: payload, + summary: 'Spreadsheet analysis completed.', + ok: true, + }, + transcriptMessage: { + role: 'tool_result', + content: [ + { + type: 'tool_result', + toolCallId: invocation.toolCallId, + content: 'Spreadsheet analysis completed.', + result: payload, + summary: 'Spreadsheet analysis completed.', + ok: true, + }, + ], + timestamp: Date.now(), + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + toolCall: { + id: invocation.toolCallId, + name: invocation.toolName, + input: invocation.input, + summary: 'Spreadsheet analysis completed.', + }, + toolResult: payload, + }, + }, + }; + }); + + const { handleChatSend } = await import('../electron/gateway/handlers/chat'); + const broadcast = vi.fn(); + + const result = handleChatSend( + { + sessionKey: 'agent:test:main', + message: { + role: 'user', + content: 'Use minimax-xlsx to analyze this spreadsheet.', + _attachedFiles: [ + { + fileName: 'report.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + fileSize: 2048, + preview: null, + filePath: 'C:\\tmp\\report.xlsx', + source: 'user-upload', + }, + ], + }, + }, + broadcast, + ); + + expect(result.runId).toBeTypeOf('string'); + expect(mocks.sessionMessages).toHaveLength(2); + expect(mocks.sessionMessages[1]).toEqual(expect.objectContaining({ + role: 'assistant', + toolName: 'minimax-xlsx', + content: [ + expect.objectContaining({ + type: 'tool_use', + name: 'minimax-xlsx', + }), + ], + })); + + await flushAsyncTasks(4); + + expect(mocks.toolRuntimeRun).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'minimax-xlsx', + source: 'planner', + }), + expect.objectContaining({ + sessionKey: 'agent:test:main', + runId: result.runId, + files: expect.arrayContaining([ + expect.objectContaining({ + filePath: 'C:\\tmp\\report.xlsx', + }), + ]), + }), + ); + 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('minimax-xlsx'), + }), + expect.objectContaining({ + role: 'assistant', + content: [ + expect.objectContaining({ + type: 'tool_use', + name: 'minimax-xlsx', + }), + ], + }), + expect.objectContaining({ + role: 'tool_result', + content: [ + expect.objectContaining({ + type: 'tool_result', + summary: 'Spreadsheet analysis completed.', + }), + ], + }), + ])); + + expect(mocks.sessionMessages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool_result', + 'assistant', + ]); + expect(mocks.sessionMessages[2]).toEqual(expect.objectContaining({ + role: 'tool_result', + toolName: 'minimax-xlsx', + toolResult: expect.objectContaining({ + summary: 'Spreadsheet analysis completed.', + }), + _toolStatuses: [ + expect.objectContaining({ + name: 'minimax-xlsx', + status: 'completed', + summary: 'Spreadsheet analysis completed.', + }), + ], + })); + expect(mocks.sessionMessages[3]).toEqual(expect.objectContaining({ + role: 'assistant', + content: 'Final answer from provider.', + })); + + expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tool:status', + toolName: 'minimax-xlsx', + status: 'running', + })); + expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tool:status', + toolName: 'minimax-xlsx', + status: 'completed', + })); + expect(broadcast).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'chat:final', + runId: result.runId, + message: expect.objectContaining({ + content: 'Final answer from provider.', + }), + })); + }); }); diff --git a/tests/chat-store-runtime-refresh.test.ts b/tests/chat-store-runtime-refresh.test.ts new file mode 100644 index 0000000..fcc6255 --- /dev/null +++ b/tests/chat-store-runtime-refresh.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + let gatewayEventHandler: ((event: any) => void | Promise) | null = null; + + return { + gatewayRpc: vi.fn(), + hostApiFetch: vi.fn(), + onGatewayEvent: vi.fn((callback: (event: any) => void | Promise) => { + gatewayEventHandler = callback; + return () => { + gatewayEventHandler = null; + }; + }), + emitGatewayEvent: async (event: any) => { + await gatewayEventHandler?.(event); + }, + }; +}); + +vi.mock('../src/lib/gateway-client', () => ({ + gatewayRpc: mocks.gatewayRpc, + onGatewayEvent: mocks.onGatewayEvent, +})); + +vi.mock('../src/lib/host-api', () => ({ + hostApiFetch: mocks.hostApiFetch, +})); + +vi.mock('../src/stores/agents', () => ({ + agentsStore: { + init: vi.fn(async () => undefined), + resolveMainSessionKey: vi.fn(() => 'agent:test:main'), + getState: vi.fn(() => ({ + defaultAgentId: 'test', + defaultProviderAccountId: null, + })), + getAgentById: vi.fn(() => undefined), + }, +})); + +describe('chat store runtime refresh', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mocks.gatewayRpc.mockImplementation(async (method: string) => { + if (method === 'session.list') { + return ['agent:test:main']; + } + + if (method === 'chat.history') { + return []; + } + + throw new Error(`Unexpected RPC method: ${method}`); + }); + + mocks.hostApiFetch.mockResolvedValue({ + messages: [], + }); + }); + + it('reloads current history when skills runtime changes', async () => { + const { chatStore } = await import('../src/stores/chat'); + + await chatStore.init(); + + const initialHistoryCalls = mocks.gatewayRpc.mock.calls.filter( + ([method]) => method === 'chat.history', + ).length; + + await mocks.emitGatewayEvent({ + type: 'runtime:changed', + topics: ['skills'], + syncedAt: new Date().toISOString(), + }); + + const afterRefreshHistoryCalls = mocks.gatewayRpc.mock.calls.filter( + ([method]) => method === 'chat.history', + ).length; + + expect(afterRefreshHistoryCalls).toBe(initialHistoryCalls + 1); + }); +}); diff --git a/tests/chat-tooling.test.ts b/tests/chat-tooling.test.ts new file mode 100644 index 0000000..3baf96b --- /dev/null +++ b/tests/chat-tooling.test.ts @@ -0,0 +1,992 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; + +import { createChatToolRuntime } from '../electron/gateway/chat-tooling'; +import type { SkillCapability } from '../electron/gateway/skill-capability-parser'; + +const pdfJsMocks = vi.hoisted(() => ({ + getDocument: vi.fn(), +})); + +const skillConfigMocks = vi.hoisted(() => ({ + getSkillConfig: vi.fn(), +})); + +const fetchMocks = vi.hoisted(() => ({ + fetch: vi.fn(), +})); + +const browserOpenMocks = vi.hoisted(() => ({ + openUrlInBrowser: vi.fn(), +})); + +const clawHubMocks = vi.hoisted(() => ({ + search: vi.fn(), +})); + +const workbookRows = [ + { hotel: 'Hongqiao', sales_amount: 128000, room_nights: 420, occupancy: 0.86 }, + { hotel: 'Pudong', sales_amount: 98000, room_nights: 365, occupancy: 0.73 }, + { hotel: 'Hongqiao', sales_amount: 128000, room_nights: 420, occupancy: 0.86 }, +]; + +vi.mock('xlsx', () => ({ + readFile: vi.fn(() => ({ + SheetNames: ['sales'], + Sheets: { + sales: { '!ref': 'A1:D4' }, + }, + })), + utils: { + sheet_to_json: vi.fn(() => workbookRows), + }, +})); + +vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({ + getDocument: pdfJsMocks.getDocument, +})); + +vi.mock('@electron/utils/skill-config', () => ({ + getSkillConfig: skillConfigMocks.getSkillConfig, +})); + +vi.mock('@electron/service/browser-open-service', () => ({ + openUrlInBrowser: browserOpenMocks.openUrlInBrowser, +})); + +vi.mock('../electron/gateway/clawhub', () => ({ + ClawHubService: class { + search = clawHubMocks.search; + }, +})); + +const spreadsheetCapability: SkillCapability = { + skillKey: 'minimax-xlsx', + slug: 'minimax-xlsx', + name: 'MiniMax XLSX', + description: 'Analyze spreadsheet files such as .xls, .xlsx, .csv, and .tsv.', + enabled: true, + category: 'document', + allowedTools: [], + operationHints: ['read', 'analyze'], + triggerHints: ['spreadsheet', 'excel'], + inputExtensions: ['.xls', '.xlsx', '.csv', '.tsv'], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'document skill; operations: read, analyze; inputs: .xls, .xlsx, .csv, .tsv', + renderHints: { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + }, +}; + +const genericSearchCapability: SkillCapability = { + skillKey: 'minimax-search', + slug: 'minimax-search', + name: 'MiniMax Search', + description: 'Search indexed knowledge sources and summarize the results.', + enabled: true, + category: 'search', + allowedTools: ['web.search'], + operationHints: ['search', 'retrieve'], + triggerHints: ['search', 'lookup'], + inputExtensions: [], + requiredEnvVars: ['ZN_AI_TEST_GENERIC_SKILL_ENV'], + requiresAuth: true, + plannerSummary: 'search skill; operations: search, retrieve; inputs: text', + renderHints: { + card: 'search-results', + preferredView: 'summary', + skillType: 'search', + }, +}; + +const braveSearchCapability: SkillCapability = { + skillKey: 'brave-web-search', + slug: 'brave-web-search', + name: 'Brave Web Search', + description: 'Search the web with Brave Search.', + enabled: true, + category: 'search', + allowedTools: ['web.search'], + operationHints: ['search'], + triggerHints: ['search', 'web'], + inputExtensions: [], + requiredEnvVars: ['BRAVE_SEARCH_API_KEY'], + requiresAuth: true, + plannerSummary: 'search skill; operations: search; inputs: text', + renderHints: { + card: 'search-results', + preferredView: 'summary', + skillType: 'search', + }, +}; + +const tavilySearchCapability: SkillCapability = { + skillKey: 'tavily-search', + slug: 'tavily-search', + name: 'Tavily Search', + description: 'Search the web with Tavily.', + enabled: true, + category: 'search', + allowedTools: ['Bash(tvly *)'], + operationHints: ['search', 'research'], + triggerHints: ['search', 'latest'], + inputExtensions: [], + requiredEnvVars: ['TAVILY_API_KEY'], + requiresAuth: true, + plannerSummary: 'search skill; operations: search, research; inputs: text', + renderHints: { + card: 'search-results', + preferredView: 'summary', + skillType: 'search', + }, +}; + +const browserSkillCapability: SkillCapability = { + skillKey: 'browser-scout', + slug: 'browser-scout', + name: 'Browser Scout', + description: 'Open an explicit URL in the local browser.', + enabled: true, + category: 'general', + allowedTools: ['browser.open_url'], + operationHints: ['open'], + triggerHints: ['open url', 'visit page'], + inputExtensions: [], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'browser skill; operations: open; inputs: url', + renderHints: { + card: 'browser-step', + preferredView: 'summary', + skillType: 'browser', + }, +}; + +const commandSkillCapability: SkillCapability = { + skillKey: 'find-skills', + slug: 'find-skills', + name: 'Find Skills', + description: 'Discover installable skills for a task.', + enabled: true, + category: 'general', + allowedTools: [], + operationHints: ['find'], + triggerHints: ['find a skill', 'discover skill'], + inputExtensions: [], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'command skill; operations: find; inputs: text', + renderHints: { + card: 'search-results', + preferredView: 'summary', + skillType: 'command', + }, +}; + +const docxCapability: SkillCapability = { + skillKey: 'docx', + slug: 'docx', + name: 'DOCX', + description: 'Create, read, edit, or analyze .docx files.', + enabled: true, + category: 'document', + allowedTools: [], + operationHints: ['read', 'analyze'], + triggerHints: ['docx', 'word document'], + inputExtensions: ['.docx'], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'document skill; operations: read, analyze; inputs: .docx', + renderHints: { + card: 'document-analysis', + preferredView: 'summary', + skillType: 'document', + }, +}; + +const pptxCapability: SkillCapability = { + skillKey: 'pptx', + slug: 'pptx', + name: 'PPTX', + description: 'Create, read, edit, or analyze .pptx files.', + enabled: true, + category: 'document', + allowedTools: [], + operationHints: ['read', 'analyze'], + triggerHints: ['pptx', 'presentation'], + inputExtensions: ['.pptx'], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'document skill; operations: read, analyze; inputs: .pptx', + renderHints: { + card: 'document-analysis', + preferredView: 'summary', + skillType: 'document', + }, +}; + +const pdfCapability: SkillCapability = { + skillKey: 'pdf', + slug: 'pdf', + name: 'PDF', + description: 'Create, read, edit, or analyze .pdf files.', + enabled: true, + category: 'document', + allowedTools: [], + operationHints: ['read', 'analyze'], + triggerHints: ['pdf', 'document'], + inputExtensions: ['.pdf'], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'document skill; operations: read, analyze; inputs: .pdf', + renderHints: { + card: 'document-analysis', + preferredView: 'summary', + skillType: 'document', + }, +}; + +const tempDirs: string[] = []; + +async function createTempDir(prefix: string): Promise { + const root = `${process.cwd().replace(/\\/g, '/')}/.tmp-vitest`; + const dir = `${root}/${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + await fs.ensureDir(dir); + tempDirs.push(dir); + return dir; +} + +async function createDocxFixture(): Promise { + const JSZip = (await import('jszip')).default; + const dir = await createTempDir('zn-ai-docx-'); + const filePath = `${dir}/brief.docx`; + const zip = new JSZip(); + + zip.file('word/document.xml', ` + + + + + Quarterly Hotel Review + + + Revenue grew 12 percent compared with last quarter. + + + Table Cell + + +`); + zip.file('word/comments.xml', ` + + Looks good +`); + zip.file('docProps/core.xml', ` + + Hotel Review + ZN AI +`); + zip.file('docProps/app.xml', ` + + 3 +`); + + await fs.writeFile(filePath, await zip.generateAsync({ type: 'nodebuffer' })); + return filePath; +} + +async function createPptxFixture(): Promise { + const JSZip = (await import('jszip')).default; + const dir = await createTempDir('zn-ai-pptx-'); + const filePath = `${dir}/deck.pptx`; + const zip = new JSZip(); + + zip.file('ppt/presentation.xml', ` + + + + + +`); + zip.file('ppt/slides/slide1.xml', ` + + Executive Summary +`); + zip.file('ppt/slides/slide2.xml', ` + + Growth Plan +`); + + await fs.writeFile(filePath, await zip.generateAsync({ type: 'nodebuffer' })); + return filePath; +} + +async function createPdfFixture(): Promise { + const dir = await createTempDir('zn-ai-pdf-'); + const filePath = `${dir}/brief.pdf`; + await fs.writeFile(filePath, Buffer.from('%PDF-1.4\n%mock\n', 'utf-8')); + return filePath; +} + +async function createGenericCommandSkillFixture(scriptName = 'search.js'): Promise<{ baseDir: string; scriptPath: string }> { + const dir = await createTempDir('zn-ai-command-skill-'); + const scriptsDir = `${dir}/scripts`; + const scriptPath = `${scriptsDir}/${scriptName}`; + await fs.ensureDir(scriptsDir); + await fs.writeFile( + scriptPath, + [ + 'const query = process.argv.slice(2).join(" ").trim();', + 'process.stdout.write(JSON.stringify({', + ' query,', + ' summary: `Ran generic command for ${query}`,', + ' results: [{', + ' title: `Result for ${query}`,', + ' url: "https://example.com/result",', + ' snippet: "Generated from local command script",', + ' }],', + '}));', + ].join('\n'), + 'utf8', + ); + + return { baseDir: dir, scriptPath }; +} + +describe('chat tooling adapters', () => { + beforeEach(() => { + delete process.env.ZN_AI_TEST_GENERIC_SKILL_ENV; + pdfJsMocks.getDocument.mockReset(); + skillConfigMocks.getSkillConfig.mockReset(); + skillConfigMocks.getSkillConfig.mockResolvedValue(undefined); + fetchMocks.fetch.mockReset(); + browserOpenMocks.openUrlInBrowser.mockReset(); + clawHubMocks.search.mockReset(); + vi.stubGlobal('fetch', fetchMocks.fetch); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir))); + vi.unstubAllGlobals(); + }); + + it('analyzes legacy .xls attachments without relying on a system python runtime', async () => { + const runtime = createChatToolRuntime([spreadsheetCapability]); + + const result = await runtime.run({ + toolCallId: 'tool-call-1', + toolName: 'minimax-xlsx', + input: { + prompt: 'Analyze this hotel sales workbook.', + skillKey: 'minimax-xlsx', + attachments: [ + { + fileName: 'hotel-sales.xls', + filePath: 'F:\\Downloads\\hotel-sales.xls', + mimeType: 'application/vnd.ms-excel', + source: 'user-upload', + }, + ], + }, + }); + + expect(result.execution.ok).toBe(true); + expect(result.execution.summary).toContain('3 total row(s)'); + + const executionRaw = result.execution.raw as { + reports: Array<{ + report: { + engine: string; + workbook: { sheetNames: string[] }; + structure: Record; + quality: Record>; + }; + }>; + }; + + expect(executionRaw.reports[0]?.report.engine).toBe('node-xlsx'); + expect(executionRaw.reports[0]?.report.workbook.sheetNames).toContain('sales'); + expect(executionRaw.reports[0]?.report.structure.sales?.shape).toEqual({ + rows: 3, + cols: 4, + }); + expect(executionRaw.reports[0]?.report.quality.sales).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'duplicate_rows', + }), + ]), + ); + }); + + it('analyzes docx attachments with the generic document adapter', async () => { + const runtime = createChatToolRuntime([docxCapability]); + const filePath = await createDocxFixture(); + + const result = await runtime.run({ + toolCallId: 'tool-call-docx', + toolName: 'docx', + input: { + prompt: 'Review this Word document.', + attachments: [ + { + fileName: 'brief.docx', + filePath, + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + source: 'user-upload', + }, + ], + }, + }); + + expect(result.execution.ok).toBe(true); + expect(result.execution.summary).toContain('Document analysis completed.'); + expect(result.execution.renderHints).toEqual(expect.objectContaining({ + skillType: 'document', + })); + + const executionRaw = result.execution.raw as { + fileCount: number; + kinds: string[]; + reports: Array<{ + kind: string; + summary: string; + metadata: { + pageCount?: number; + paragraphCount?: number; + tableCount?: number; + commentCount?: number; + }; + preview: Array<{ text?: string }>; + }>; + }; + + expect(executionRaw.fileCount).toBe(1); + expect(executionRaw.kinds).toContain('docx'); + expect(executionRaw.reports[0]?.kind).toBe('docx'); + expect(executionRaw.reports[0]?.summary).toContain('2 paragraph(s)'); + expect(executionRaw.reports[0]?.metadata).toEqual(expect.objectContaining({ + pageCount: 3, + paragraphCount: 2, + tableCount: 1, + commentCount: 1, + })); + expect(executionRaw.reports[0]?.preview[0]?.text).toContain('Quarterly Hotel Review'); + }); + + it('analyzes pptx attachments with the generic document adapter', async () => { + const runtime = createChatToolRuntime([pptxCapability]); + const filePath = await createPptxFixture(); + + const result = await runtime.run({ + toolCallId: 'tool-call-pptx', + toolName: 'pptx', + input: { + prompt: 'Summarize this deck.', + attachments: [ + { + fileName: 'deck.pptx', + filePath, + mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + source: 'user-upload', + }, + ], + }, + }); + + expect(result.execution.ok).toBe(true); + const executionRaw = result.execution.raw as { + reports: Array<{ + kind: string; + metadata: { + slideCount?: number; + hiddenSlideCount?: number; + }; + preview: Array<{ slide?: number; text?: string }>; + }>; + }; + + expect(executionRaw.reports[0]?.kind).toBe('pptx'); + expect(executionRaw.reports[0]?.metadata).toEqual(expect.objectContaining({ + slideCount: 2, + hiddenSlideCount: 1, + })); + expect(executionRaw.reports[0]?.preview[0]).toEqual(expect.objectContaining({ + slide: 1, + })); + expect(executionRaw.reports[0]?.preview[0]?.text).toContain('Executive Summary'); + }); + + it('analyzes pdf attachments with the generic document adapter', async () => { + pdfJsMocks.getDocument.mockImplementation(() => ({ + promise: Promise.resolve({ + numPages: 2, + getMetadata: async () => ({ + info: { + Title: 'Hotel Brief', + Author: 'ZN AI', + }, + }), + getPage: async (pageNumber: number) => ({ + getTextContent: async () => ({ + items: [ + { str: pageNumber === 1 ? 'Hotel brief overview' : 'Revenue improved year over year' }, + ], + }), + }), + destroy: async () => undefined, + }), + destroy: async () => undefined, + })); + + const runtime = createChatToolRuntime([pdfCapability]); + const filePath = await createPdfFixture(); + + const result = await runtime.run({ + toolCallId: 'tool-call-pdf', + toolName: 'pdf', + input: { + prompt: 'Analyze this PDF.', + attachments: [ + { + fileName: 'brief.pdf', + filePath, + mimeType: 'application/pdf', + source: 'user-upload', + }, + ], + }, + }); + + expect(result.execution.ok).toBe(true); + const executionRaw = result.execution.raw as { + reports: Array<{ + kind: string; + engine: string; + metadata: { + title?: string; + pageCount?: number; + }; + preview: Array<{ page?: number; text?: string }>; + }>; + }; + + expect(executionRaw.reports[0]?.kind).toBe('pdf'); + expect(executionRaw.reports[0]?.engine).toBe('node-pdfjs'); + expect(executionRaw.reports[0]?.metadata).toEqual(expect.objectContaining({ + title: 'Hotel Brief', + pageCount: 2, + })); + expect(executionRaw.reports[0]?.preview[1]).toEqual(expect.objectContaining({ + page: 2, + })); + }); + + it('executes brave-web-search with configured credentials and normalizes results', async () => { + skillConfigMocks.getSkillConfig.mockResolvedValue({ + apiKey: 'brave-key', + env: {}, + }); + fetchMocks.fetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + query: { + original: 'hotel revenue trends', + more_results_available: true, + }, + web: { + family_friendly: true, + results: [ + { + title: 'Hotel Revenue Trends Q1', + url: 'https://example.com/revenue-q1', + description: 'Revenue increased across the segment.', + age: '2 days ago', + page_age: '2026-04-20T10:00:00Z', + profile: { + name: 'Example News', + }, + }, + { + title: 'Hotel Demand Outlook', + url: 'https://example.com/demand', + description: 'Demand remains resilient.', + }, + ], + }, + }), + }); + + const runtime = createChatToolRuntime([braveSearchCapability]); + const result = await runtime.run({ + toolCallId: 'tool-call-brave', + toolName: 'brave-web-search', + input: { + prompt: 'hotel revenue trends', + count: 2, + timeRange: 'week', + country: 'US', + }, + }); + + expect(result.execution.ok).toBe(true); + expect(result.execution.summary).toContain('Found 2 result(s)'); + expect(fetchMocks.fetch).toHaveBeenCalledTimes(1); + + const [requestUrl, requestInit] = fetchMocks.fetch.mock.calls[0] || []; + expect(String(requestUrl)).toContain('https://api.search.brave.com/res/v1/web/search?'); + expect(String(requestUrl)).toContain('q=hotel+revenue+trends'); + expect(String(requestUrl)).toContain('count=2'); + expect(String(requestUrl)).toContain('freshness=pw'); + expect(String(requestUrl)).toContain('country=US'); + expect(requestInit).toEqual(expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'X-Subscription-Token': 'brave-key', + }), + })); + + const executionRaw = result.execution.raw as { + provider: string; + resultCount: number; + query: string; + results: Array<{ title: string; url: string; snippet?: string; source?: string }>; + moreResultsAvailable?: boolean; + }; + + expect(executionRaw.provider).toBe('brave'); + expect(executionRaw.resultCount).toBe(2); + expect(executionRaw.query).toBe('hotel revenue trends'); + expect(executionRaw.results[0]).toEqual(expect.objectContaining({ + title: 'Hotel Revenue Trends Q1', + url: 'https://example.com/revenue-q1', + snippet: 'Revenue increased across the segment.', + source: 'Example News', + })); + expect(result.execution.artifacts?.[0]).toEqual(expect.objectContaining({ + kind: 'url', + uri: 'https://example.com/revenue-q1', + })); + }); + + it('executes tavily-search with configured env credentials and normalizes results', async () => { + skillConfigMocks.getSkillConfig.mockResolvedValue({ + env: { + TAVILY_API_KEY: 'tvly-key', + }, + }); + fetchMocks.fetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + query: 'hotel demand forecast', + answer: 'Demand is increasing heading into the summer season.', + response_time: 1.42, + results: [ + { + title: 'Hospitality Forecast 2026', + url: 'https://example.com/forecast', + content: 'Forecast shows double-digit demand growth.', + score: 0.9123, + published_date: '2026-04-21', + }, + ], + }), + }); + + const runtime = createChatToolRuntime([tavilySearchCapability]); + const result = await runtime.run({ + toolCallId: 'tool-call-tavily', + toolName: 'tavily-search', + input: { + prompt: 'hotel demand forecast', + maxResults: 3, + depth: 'advanced', + timeRange: 'month', + includeDomains: ['example.com', 'reuters.com'], + includeAnswer: true, + }, + }); + + expect(result.execution.ok).toBe(true); + expect(result.execution.summary).toContain('Found 1 result(s)'); + expect(fetchMocks.fetch).toHaveBeenCalledTimes(1); + + const [requestUrl, requestInit] = fetchMocks.fetch.mock.calls[0] || []; + expect(requestUrl).toBe('https://api.tavily.com/search'); + expect(requestInit).toEqual(expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer tvly-key', + }), + })); + + const body = JSON.parse(String(requestInit?.body || '{}')) as Record; + expect(body).toEqual(expect.objectContaining({ + query: 'hotel demand forecast', + max_results: 3, + search_depth: 'advanced', + time_range: 'month', + include_answer: true, + include_domains: ['example.com', 'reuters.com'], + })); + + const executionRaw = result.execution.raw as { + provider: string; + query: string; + answer?: string; + responseTimeMs?: number; + results: Array<{ title: string; score?: number; publishedAt?: string }>; + }; + + expect(executionRaw.provider).toBe('tavily'); + expect(executionRaw.query).toBe('hotel demand forecast'); + expect(executionRaw.answer).toContain('Demand is increasing'); + expect(executionRaw.responseTimeMs).toBe(1420); + expect(executionRaw.results[0]).toEqual(expect.objectContaining({ + title: 'Hospitality Forecast 2026', + score: 0.9123, + publishedAt: '2026-04-21', + })); + }); + + it('executes browser-capable skills by delegating to browser.open_url behavior', async () => { + browserOpenMocks.openUrlInBrowser.mockResolvedValue({ + pageUrl: 'https://example.com/hotels', + title: 'Hotel Trends', + }); + + const runtime = createChatToolRuntime([browserSkillCapability]); + const result = await runtime.run({ + toolCallId: 'tool-call-browser-skill', + toolName: 'browser-scout', + input: { + prompt: 'Open https://example.com/hotels', + }, + }); + + expect(result.execution.ok).toBe(true); + expect(browserOpenMocks.openUrlInBrowser).toHaveBeenCalledWith( + 'https://example.com/hotels', + expect.any(Object), + ); + expect(result.execution.renderHints).toEqual(expect.objectContaining({ + skillType: 'browser', + })); + expect(result.execution.raw).toEqual(expect.objectContaining({ + pageUrl: 'https://example.com/hotels', + title: 'Hotel Trends', + skillKey: 'browser-scout', + })); + }); + + it('executes find-skills through the command adapter and normalizes clawhub results', async () => { + clawHubMocks.search.mockResolvedValue([ + { + slug: 'vercel-labs/agent-skills@react-best-practices', + name: 'react-best-practices', + description: 'React and Next.js guidance from Vercel.', + version: '1.2.0', + downloads: 185000, + stars: 4200, + }, + ]); + + const runtime = createChatToolRuntime([commandSkillCapability]); + const result = await runtime.run({ + toolCallId: 'tool-call-find-skills', + toolName: 'find-skills', + input: { + prompt: 'react performance', + maxResults: 5, + }, + }); + + expect(result.execution.ok).toBe(true); + expect(clawHubMocks.search).toHaveBeenCalledWith({ + query: 'react performance', + limit: 5, + }); + expect(result.execution.summary).toContain('1 matching skill result(s)'); + expect(result.execution.logs).toContain('npx skills find react performance'); + expect(result.execution.renderHints).toEqual(expect.objectContaining({ + card: 'search-results', + skillType: 'command', + })); + + const executionRaw = result.execution.raw as { + provider: string; + command?: string; + results: Array<{ + title: string; + url: string; + installCommand?: string; + downloads?: number; + stars?: number; + }>; + }; + + expect(executionRaw.provider).toBe('clawhub'); + expect(executionRaw.command).toBe('npx skills find'); + expect(executionRaw.results[0]).toEqual(expect.objectContaining({ + title: 'react-best-practices', + url: 'https://skills.sh/vercel-labs/agent-skills/react-best-practices', + installCommand: 'npx skills add vercel-labs/agent-skills@react-best-practices -g -y', + downloads: 185000, + stars: 4200, + })); + }); + + it('executes generic command-style skills from manifest command templates', async () => { + const fixture = await createGenericCommandSkillFixture('search.js'); + const capability: SkillCapability = { + ...commandSkillCapability, + skillKey: 'custom-command-skill', + slug: 'custom-command-skill', + name: 'Custom Command Skill', + baseDir: fixture.baseDir, + manifestPath: `${fixture.baseDir}/SKILL.md`, + commandExamples: ['node scripts/search.js [query]'], + }; + + const runtime = createChatToolRuntime([capability]); + const result = await runtime.run({ + toolCallId: 'tool-call-generic-command-template', + toolName: 'custom-command-skill', + input: { + prompt: 'hotel strategy', + }, + }); + + expect(result.execution.ok).toBe(true); + expect(result.execution.summary).toContain('Command completed via node scripts/search.js hotel strategy'); + + const executionRaw = result.execution.raw as { + skillKey: string; + command: string; + query: string; + summary: string; + results: Array<{ title: string; url: string }>; + }; + + expect(executionRaw.skillKey).toBe('custom-command-skill'); + expect(executionRaw.command).toContain('node scripts/search.js hotel strategy'); + expect(executionRaw.query).toBe('hotel strategy'); + expect(executionRaw.summary).toContain('hotel strategy'); + expect(executionRaw.results[0]).toEqual(expect.objectContaining({ + title: 'Result for hotel strategy', + url: 'https://example.com/result', + })); + }); + + it('executes generic command-style skills from a single scripts entrypoint when no manifest template exists', async () => { + const fixture = await createGenericCommandSkillFixture('run.js'); + const capability: SkillCapability = { + ...commandSkillCapability, + skillKey: 'script-entry-skill', + slug: 'script-entry-skill', + name: 'Script Entry Skill', + baseDir: fixture.baseDir, + manifestPath: `${fixture.baseDir}/SKILL.md`, + commandExamples: [], + }; + + const runtime = createChatToolRuntime([capability]); + const result = await runtime.run({ + toolCallId: 'tool-call-generic-command-script', + toolName: 'script-entry-skill', + input: { + prompt: 'competitive review', + }, + }); + + expect(result.execution.ok).toBe(true); + const executionRaw = result.execution.raw as { + command: string; + query: string; + }; + + expect(executionRaw.command).toContain('run.js competitive review'); + expect(executionRaw.query).toBe('competitive review'); + }); + + it('keeps spreadsheet execution isolated from non-spreadsheet skills', async () => { + const runtime = createChatToolRuntime([genericSearchCapability]); + + const result = await runtime.run({ + toolCallId: 'tool-call-2', + toolName: 'minimax-xlsx', + input: { + prompt: 'Analyze this workbook.', + skillKey: 'minimax-xlsx', + attachments: [ + { + fileName: 'hotel-sales.xls', + filePath: 'F:\\Downloads\\hotel-sales.xls', + mimeType: 'application/vnd.ms-excel', + source: 'user-upload', + }, + ], + }, + }); + + expect(result.preflight.ok).toBe(false); + expect(result.preflight.error).toEqual(expect.objectContaining({ + code: 'missing_skill_runtime', + })); + }); + + it('surfaces missing env prerequisites for non-spreadsheet skills', async () => { + const runtime = createChatToolRuntime([genericSearchCapability]); + + const result = await runtime.run({ + toolCallId: 'tool-call-3', + toolName: 'minimax-search', + input: { + prompt: 'Search for hotel sales anomalies.', + }, + }); + + expect(result.preflight.ok).toBe(false); + expect(result.preflight.error).toEqual(expect.objectContaining({ + code: 'missing_required_env', + })); + expect(result.execution.summary).toContain('ZN_AI_TEST_GENERIC_SKILL_ENV'); + }); + + it('returns a capability-aware blocked result for unsupported generic skill runtimes', async () => { + process.env.ZN_AI_TEST_GENERIC_SKILL_ENV = 'configured'; + const runtime = createChatToolRuntime([ + { + ...genericSearchCapability, + requiresAuth: false, + requiredEnvVars: [], + }, + ]); + + const result = await runtime.run({ + toolCallId: 'tool-call-4', + toolName: 'minimax-search', + input: { + prompt: 'Search for hotel sales anomalies.', + }, + }); + + expect(result.preflight.ok).toBe(false); + expect(result.preflight.error).toEqual(expect.objectContaining({ + code: 'skill_runtime_not_implemented', + })); + expect(result.execution.summary).toContain('category=search'); + expect(result.execution.summary).toContain('allowedTools=web.search'); + }); +}); diff --git a/tests/gateway-startup-helpers.test.ts b/tests/gateway-startup-helpers.test.ts index b013fb7..d786239 100644 --- a/tests/gateway-startup-helpers.test.ts +++ b/tests/gateway-startup-helpers.test.ts @@ -33,6 +33,7 @@ import { GatewayLifecycleController, LifecycleSupersededError } from '../electro import { GatewayConnectionMonitor } from '../electron/gateway/connection-monitor'; import { GatewayRestartController } from '../electron/gateway/restart-controller'; import { GatewayRestartGovernor } from '../electron/gateway/restart-governor'; +import { createRandomId } from '../electron/gateway/random-id'; import { DEFAULT_GATEWAY_RELOAD_POLICY, parseGatewayReloadPolicy, @@ -69,6 +70,52 @@ describe('startup-stderr helpers', () => { }); }); +describe('random-id helpers', () => { + it('prefers crypto.randomUUID when available', () => { + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + + try { + Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => 'uuid-from-crypto', + }, + configurable: true, + }); + + expect(createRandomId()).toBe('uuid-from-crypto'); + } finally { + if (originalCrypto) { + Object.defineProperty(globalThis, 'crypto', originalCrypto); + } else { + Reflect.deleteProperty(globalThis, 'crypto'); + } + } + }); + + it('falls back to a deterministic local id when crypto.randomUUID is unavailable', () => { + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1760000000000); + const mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + + try { + Object.defineProperty(globalThis, 'crypto', { + value: undefined, + configurable: true, + }); + + expect(createRandomId()).toMatch(/^id-1760000000000-/); + } finally { + dateNowSpy.mockRestore(); + mathRandomSpy.mockRestore(); + if (originalCrypto) { + Object.defineProperty(globalThis, 'crypto', originalCrypto); + } else { + Reflect.deleteProperty(globalThis, 'crypto'); + } + } + }); +}); + describe('startup-recovery helpers', () => { it('retries transient startup errors before max attempts', () => { expect(getGatewayStartupRecoveryAction({ diff --git a/tests/runtime-context-capabilities.test.ts b/tests/runtime-context-capabilities.test.ts new file mode 100644 index 0000000..02ef5d7 --- /dev/null +++ b/tests/runtime-context-capabilities.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getEnabledSkillCapabilities: vi.fn(), +})); + +vi.mock('../electron/gateway/skill-capability-registry', () => ({ + getEnabledSkillCapabilities: mocks.getEnabledSkillCapabilities, +})); + +const spreadsheetCapability = { + skillKey: 'minimax-xlsx', + slug: 'minimax-xlsx', + name: 'MiniMax XLSX', + description: 'Analyze spreadsheet files.', + enabled: true, + category: 'document', + allowedTools: [], + operationHints: ['read', 'analyze'], + triggerHints: ['spreadsheet', 'excel'], + inputExtensions: ['.xlsx', '.csv', '.tsv'], + requiredEnvVars: [], + requiresAuth: false, + plannerSummary: 'document skill; operations: read, analyze; inputs: .xlsx, .csv, .tsv', + renderHints: { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + }, +}; + +const genericSearchCapability = { + skillKey: 'minimax-search', + slug: 'minimax-search', + name: 'MiniMax Search', + description: 'Search across indexed knowledge sources.', + enabled: true, + category: 'search', + allowedTools: ['web.search'], + operationHints: ['search', 'retrieve'], + triggerHints: ['search', 'lookup'], + inputExtensions: [], + requiredEnvVars: ['MINIMAX_API_KEY'], + requiresAuth: true, + plannerSummary: 'search skill; operations: search, retrieve; inputs: text', + renderHints: { + card: 'search-results', + preferredView: 'summary', + skillType: 'search', + }, +}; + +describe('runtime context capabilities', () => { + it('injects enabled skill capabilities into the system runtime context', async () => { + mocks.getEnabledSkillCapabilities.mockReturnValue([spreadsheetCapability]); + const { buildRuntimeContextMessages } = await import('../electron/gateway/runtime-context'); + + const messages = buildRuntimeContextMessages('agent:test:main'); + + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual(expect.objectContaining({ + role: 'system', + content: expect.stringContaining('Enabled skill capabilities registered for routing/planning'), + })); + expect(messages[0]?.content).toContain('minimax-xlsx'); + expect(messages[0]?.content).toContain('inputs=.xlsx,.csv,.tsv'); + expect(messages[0]?.content).toContain('operations=read,analyze'); + }); + + it('maps a non-spreadsheet skill into the generic registry and runtime capability context', async () => { + mocks.getEnabledSkillCapabilities.mockReturnValue([genericSearchCapability]); + + const { mapSkillCapabilitiesToRegistryInputs } = await import('../electron/gateway/chat-tooling'); + const { buildRuntimeContextMessages } = await import('../electron/gateway/runtime-context'); + const { createToolRegistry, getRegistryEntryByName } = await import('../electron/gateway/tool-registry'); + + const registry = createToolRegistry({ + capabilities: mapSkillCapabilitiesToRegistryInputs([genericSearchCapability]), + }); + const entry = getRegistryEntryByName(registry, 'minimax-search'); + + expect(entry).toEqual(expect.objectContaining({ + capabilityKey: 'minimax-search', + toolName: 'minimax-search', + familyKey: 'minimax-search', + kind: 'skill', + displayName: 'MiniMax Search', + requiresFiles: false, + })); + expect(entry?.metadata).toEqual(expect.objectContaining({ + category: 'search', + requiredEnvVars: ['MINIMAX_API_KEY'], + requiresAuth: true, + })); + + const messages = buildRuntimeContextMessages('agent:test:main'); + + expect(messages[0]?.content).toContain('minimax-search'); + expect(messages[0]?.content).toContain('category=search'); + expect(messages[0]?.content).toContain('operations=search,retrieve'); + expect(messages[0]?.content).toContain('allowedTools=web.search'); + expect(messages[0]?.content).toContain('env=MINIMAX_API_KEY'); + expect(messages[0]?.content).toContain('auth=required'); + }); +}); diff --git a/tests/skill-capability-parser.test.ts b/tests/skill-capability-parser.test.ts new file mode 100644 index 0000000..1162298 --- /dev/null +++ b/tests/skill-capability-parser.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; + +import { parseSkillCapability } from '../electron/gateway/skill-capability-parser'; + +describe('skill capability parser', () => { + it('classifies office-style document skills as document-analysis capabilities', () => { + const capability = parseSkillCapability({ + skillKey: 'docx', + manifestContent: `--- +name: docx +description: Use this skill whenever the user wants to create, read, edit, or analyze .docx files. +--- + +# DOCX + +Read and analyze .docx files.`, + }); + + expect(capability.category).toBe('document'); + expect(capability.inputExtensions).toContain('.docx'); + expect(capability.renderHints).toEqual(expect.objectContaining({ + card: 'document-analysis', + preferredView: 'summary', + skillType: 'document', + })); + }); + + it('ignores URL-like pseudo extensions when inferring file inputs', () => { + const capability = parseSkillCapability({ + skillKey: 'brave-web-search', + manifestContent: `--- +name: web-search +description: USE FOR web search. Returns ranked results with snippets, URLs, thumbnails. +--- + +GET https://api.search.brave.com/res/v1/web/search +POST https://api.search.brave.com/res/v1/web/search`, + }); + + expect(capability.inputExtensions).toEqual([]); + expect(capability.requiresAuth).toBe(false); + }); + + it('detects env-driven auth without false-positives from unrelated auth substrings', () => { + const capability = parseSkillCapability({ + skillKey: 'search-cli', + manifestContent: `--- +name: search-cli +description: Search the web with your API key. +author: ZN AI +--- + +curl -H "X-Subscription-Token: \${BRAVE_SEARCH_API_KEY}" https://example.com/search`, + }); + + expect(capability.requiredEnvVars).toEqual(['BRAVE_SEARCH_API_KEY']); + expect(capability.requiresAuth).toBe(true); + }); + + it('extracts safe command examples from shell fences while skipping setup commands', () => { + const capability = parseSkillCapability({ + skillKey: 'find-skills', + manifestContent: `--- +name: find-skills +description: Discover skills for a task. +--- + +\`\`\`bash +curl -fsSL https://example.com/install.sh | bash && tool login +npx skills find [query] +\`\`\` + +\`\`\`bash +tvly search "your query" --json +\`\`\``, + }); + + expect(capability.commandExamples).toEqual([ + 'npx skills find [query]', + 'tvly search "your query" --json', + ]); + }); +}); diff --git a/tests/skill-planner.test.ts b/tests/skill-planner.test.ts new file mode 100644 index 0000000..e44cbff --- /dev/null +++ b/tests/skill-planner.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import { planToolCall } from '../electron/gateway/skill-planner'; + +const spreadsheetCapability = { + capabilityKey: 'minimax-xlsx', + toolName: 'minimax-xlsx', + skillKey: 'minimax-xlsx', + slug: 'minimax-xlsx', + name: 'MiniMax XLSX', + displayName: 'MiniMax XLSX', + description: 'Analyze spreadsheet files such as .xlsx, .csv, and .tsv.', + kind: 'skill' as const, + aliases: ['minimax-xlsx', 'xlsx', 'spreadsheet', 'excel'], + inputKinds: ['text', 'file', 'attachment'], + outputKinds: ['text', 'json', 'artifacts'], + triggerHints: ['spreadsheet', 'excel', 'xlsx', 'csv'], + supportedFileTypes: ['.xlsx', '.csv', '.tsv'], + requiresFiles: true, + enabled: true, + metadata: { + renderHints: { + card: 'document-analysis', + preferredView: 'table', + skillType: 'spreadsheet', + }, + }, +}; + +describe('skill planner', () => { + it('plans a spreadsheet tool call when the user explicitly requests minimax-xlsx with an attachment', () => { + const decision = planToolCall({ + userText: '使用minimax-xlsx这个skill,帮我分析下。', + message: { + role: 'user', + content: '使用minimax-xlsx这个skill,帮我分析下。', + }, + attachments: [ + { + fileName: 'report.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + fileSize: 1024, + preview: null, + filePath: 'C:\\tmp\\report.xlsx', + source: 'user-upload', + }, + ], + capabilities: [spreadsheetCapability], + }); + + expect(decision.kind).toBe('tool'); + expect(decision.reason).toBe('explicit_skill_request'); + expect(decision.toolCall?.name).toBe('minimax-xlsx'); + expect(decision.toolCall?.input).toEqual(expect.objectContaining({ + filePaths: ['C:\\tmp\\report.xlsx'], + skillKey: 'minimax-xlsx', + })); + }); + + it('returns a blocking no-tool decision when spreadsheet analysis is requested without an attachment', () => { + const decision = planToolCall({ + userText: '使用minimax-xlsx这个skill,帮我分析下。', + message: { + role: 'user', + content: '使用minimax-xlsx这个skill,帮我分析下。', + }, + capabilities: [spreadsheetCapability], + }); + + expect(decision.kind).toBe('no-tool'); + expect(decision.reason).toBe('missing_required_attachment'); + expect(decision.blockingIssue).toEqual(expect.objectContaining({ + code: 'missing_required_attachment', + })); + }); +}); diff --git a/tests/uv-setup.test.ts b/tests/uv-setup.test.ts new file mode 100644 index 0000000..c758ec2 --- /dev/null +++ b/tests/uv-setup.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const childProcessMocks = vi.hoisted(() => ({ + execSync: vi.fn(), + spawn: vi.fn(), +})); + +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn(), +})); + +const electronMocks = vi.hoisted(() => ({ + app: { + isPackaged: false, + }, +})); + +const uvEnvMocks = vi.hoisted(() => ({ + getUvMirrorEnv: vi.fn(async () => ({})), +})); + +const loggerMocks = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execSync: childProcessMocks.execSync, + spawn: childProcessMocks.spawn, +})); + +vi.mock('node:fs', () => ({ + existsSync: fsMocks.existsSync, +})); + +vi.mock('node:path', () => ({ + join: (...parts: string[]) => parts.join('/'), +})); + +vi.mock('electron', () => ({ + app: electronMocks.app, +})); + +vi.mock('../electron/utils/uv-env', () => ({ + getUvMirrorEnv: uvEnvMocks.getUvMirrorEnv, +})); + +vi.mock('@electron/service/logger', () => ({ + default: loggerMocks, +})); + +import { + isPythonReady, + setupManagedPython, +} from '../electron/utils/uv-setup'; + +function createSpawnChild(options: { + closeCode?: number; + error?: Error; + stdout?: string[]; + stderr?: string[]; +}) { + const childListeners = new Map void>>(); + const stdoutListeners = new Map void>>(); + const stderrListeners = new Map void>>(); + + const emitAll = (listeners: Map void>>, event: string, value?: unknown) => { + for (const listener of listeners.get(event) || []) { + listener(value); + } + }; + + const child = { + stdout: { + on(event: string, listener: (value: unknown) => void) { + const next = stdoutListeners.get(event) || []; + next.push(listener); + stdoutListeners.set(event, next); + }, + }, + stderr: { + on(event: string, listener: (value: unknown) => void) { + const next = stderrListeners.get(event) || []; + next.push(listener); + stderrListeners.set(event, next); + }, + }, + on(event: string, listener: (value?: unknown) => void) { + const next = childListeners.get(event) || []; + next.push(listener); + childListeners.set(event, next); + return child; + }, + }; + + queueMicrotask(() => { + for (const chunk of options.stdout || []) { + emitAll(stdoutListeners as Map void>>, 'data', Buffer.from(chunk)); + } + for (const chunk of options.stderr || []) { + emitAll(stderrListeners as Map void>>, 'data', Buffer.from(chunk)); + } + + if (options.error) { + emitAll(childListeners, 'error', options.error); + return; + } + + emitAll(childListeners, 'close', options.closeCode ?? 0); + }); + + return child; +} + +describe('uv setup', () => { + beforeEach(() => { + childProcessMocks.execSync.mockReset(); + childProcessMocks.spawn.mockReset(); + fsMocks.existsSync.mockReset(); + uvEnvMocks.getUvMirrorEnv.mockReset(); + uvEnvMocks.getUvMirrorEnv.mockResolvedValue({}); + loggerMocks.info.mockReset(); + loggerMocks.warn.mockReset(); + loggerMocks.debug.mockReset(); + loggerMocks.error.mockReset(); + electronMocks.app.isPackaged = false; + }); + + it('does not spawn uv when bundled and PATH uv are both missing', async () => { + fsMocks.existsSync.mockReturnValue(false); + childProcessMocks.execSync.mockImplementation(() => { + throw new Error('uv not found'); + }); + + await expect(isPythonReady()).resolves.toBe(false); + await expect(setupManagedPython()).rejects.toThrow('uv is required for managed Python setup but is unavailable'); + + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it('does not fall back to literal uv when PATH lookup returns empty output', async () => { + fsMocks.existsSync.mockReturnValue(false); + childProcessMocks.execSync.mockReturnValue('\r\n'); + + await expect(isPythonReady()).resolves.toBe(false); + await expect(setupManagedPython()).rejects.toThrow('uv is required for managed Python setup but is unavailable'); + + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it('retries Python install without mirror when bundled uv exists', async () => { + fsMocks.existsSync.mockImplementation((value: unknown) => String(value).endsWith('uv.exe')); + childProcessMocks.execSync.mockImplementation(() => { + throw new Error('uv not on PATH'); + }); + uvEnvMocks.getUvMirrorEnv.mockResolvedValue({ + UV_INDEX_URL: 'https://mirror.example/simple', + }); + + childProcessMocks.spawn + .mockImplementationOnce(() => createSpawnChild({ + closeCode: 1, + stderr: ['mirror failed'], + })) + .mockImplementationOnce(() => createSpawnChild({ + closeCode: 0, + stdout: ['installed'], + })) + .mockImplementationOnce(() => createSpawnChild({ + closeCode: 0, + stdout: ['C:\\Python312\\python.exe'], + })); + + await expect(setupManagedPython()).resolves.toBeUndefined(); + + expect(childProcessMocks.spawn).toHaveBeenCalledTimes(3); + expect(String(childProcessMocks.spawn.mock.calls[0]?.[0] || '')).toContain('uv.exe'); + expect(childProcessMocks.spawn.mock.calls[0]?.[2]?.env?.UV_INDEX_URL).toBe('https://mirror.example/simple'); + expect(childProcessMocks.spawn.mock.calls[1]?.[2]?.env?.UV_INDEX_URL).toBeUndefined(); + expect(loggerMocks.warn).toHaveBeenCalledWith('Python install attempt 1 failed:', expect.any(Error)); + expect(loggerMocks.info).toHaveBeenCalledWith('Retrying Python install without mirror...'); + }); +});