diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index cc3173f..cc37785 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -115,7 +115,61 @@ jobs: name: goodbuddy-production-bundle path: out - - name: Build and verify release packages + - name: Prepare macOS signing credentials + if: matrix.platform == 'macos' + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + test -n "$MACOS_CERTIFICATE_BASE64" + test -n "$MACOS_CERTIFICATE_PASSWORD" + test -n "$APPLE_API_KEY_BASE64" + test -n "$APPLE_API_KEY_ID" + test -n "$APPLE_API_ISSUER" + printf '%s' "$MACOS_CERTIFICATE_BASE64" | base64 -D > "$RUNNER_TEMP/goodbuddy-developer-id.p12" + printf '%s' "$APPLE_API_KEY_BASE64" | base64 -D > "$RUNNER_TEMP/AuthKey.p8" + chmod 600 "$RUNNER_TEMP/goodbuddy-developer-id.p12" "$RUNNER_TEMP/AuthKey.p8" + + - name: Build, sign and notarize macOS release packages + if: matrix.platform == 'macos' + run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build + env: + ELECTRON_CACHE: ${{ runner.temp }}/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder + CSC_LINK: ${{ runner.temp }}/goodbuddy-developer-id.p12 + CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + CSC_IDENTITY_AUTO_DISCOVERY: 'true' + APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + + - name: Verify macOS signature and notarization ticket + if: matrix.platform == 'macos' + shell: bash + run: | + set -euo pipefail + dmg="$(find "dist/release/macos-${{ matrix.arch }}" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + test -n "$dmg" + mount_point="$RUNNER_TEMP/goodbuddy-dmg" + mkdir "$mount_point" + cleanup() { + hdiutil detach "$mount_point" -quiet || true + } + trap cleanup EXIT + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_point" -quiet + app="$(find "$mount_point" -maxdepth 1 -type d -name '*.app' -print -quit)" + test -n "$app" + codesign --verify --deep --strict --verbose=2 "$app" + spctl --assess --type execute --verbose=4 "$app" + xcrun stapler validate "$app" + + - name: Build and verify non-macOS release packages + if: matrix.platform != 'macos' run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build env: ELECTRON_CACHE: ${{ runner.temp }}/electron diff --git a/BUILD.md b/BUILD.md index 2dec559..4bb16ce 100644 --- a/BUILD.md +++ b/BUILD.md @@ -195,8 +195,32 @@ git push origin "$tag" git push github "$tag" ``` -当前未配置 Windows/macOS 代码签名或 macOS notarization。对外分发前应按 -目标平台配置签名凭据并重新验证安装、升级和系统安全提示。 +macOS 发布 job 使用 Developer ID Application 证书签名,并通过 App Store +Connect API Key 提交 Apple notarization。仓库的 Actions Secrets 需要配置: + +- `MACOS_CERTIFICATE_BASE64`:包含证书及私钥的 `.p12` 文件经 Base64 编码后的内容。 +- `MACOS_CERTIFICATE_PASSWORD`:导出 `.p12` 时设置的密码。 +- `APPLE_API_KEY_BASE64`:App Store Connect API Key 的 `.p8` 文件经 Base64 编码后的内容。 +- `APPLE_API_KEY_ID`:App Store Connect API Key 的 Key ID。 +- `APPLE_API_ISSUER`:App Store Connect API Key 的 Issuer ID。 + +在 macOS 上生成适合 Secrets 的单行 Base64 内容: + +```bash +base64 -i DeveloperIDApplication.p12 | tr -d '\n' +base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n' +``` + +证书必须是 Apple Developer 后台创建的 `Developer ID Application`,并在导出 +`.p12` 的 Mac 钥匙串中同时包含对应私钥。API Key 建议使用团队级 App Store +Connect Key;`.p8` 只能下载一次。签名材料只放入 GitHub Secrets,不提交到仓库。 + +macOS 打包完成后,工作流会挂载 DMG,并分别执行 `codesign`、Gatekeeper `spctl` +和 `stapler` 校验。缺少凭据、签名无效或 notarization ticket 不存在时,发布矩阵 +会在上传产物前失败。本地没有签名凭据时仍可生成仅供开发验证的未签名包。 + +Windows 代码签名仍未配置。对外分发前还应配置 Windows 签名凭据,并重新验证 +安装、升级和系统安全提示。 ## 发布前冒烟测试 @@ -259,3 +283,24 @@ npm test -- src/main/agent/deepseek-harness-acp-e2e.test.ts -t "rejects a real n 如需覆盖发布包内置 npm 路径,再设置 `GOODBUDDY_DSH_NPM_CLI` 与 `GOODBUDDY_DSH_NODE_EXECUTABLE` 指向已解包应用中的 npm CLI 和应用主程序。 + +## Renderer bundle 性能门禁 + +`electron-vite` 会在 `out/renderer/.vite` 生成 Vite manifest 和仅含构建模块 +归属的 module manifest。`npm run build:bundle` 在 bundle 完成后自动运行 +`build/check-renderer-bundle.cjs`,去重统计首屏同步闭包及 Knowledge、知识图谱、 +Activity、Magic Notes、Settings 动态入口同步加载的 JS 与 CSS 合计 raw / gzip +大小并执行预算校验。 + +门禁同时验证以下结构约束: + +- Knowledge、Activity 与 G6 不得进入首屏同步闭包。 +- `KnowledgeGraphChart` 与 G6 不得进入 Knowledge shell 的同步闭包。 +- G6 必须由知识图谱动态入口同步拥有。 + +路径遍历使用 manifest 中的相对文件名并通过 Node `path.resolve` 读取,因此兼容 +Windows 与 POSIX 构建输出。构建专用 module manifest 只记录项目相对路径、 +`node_modules/` 相对路径或稳定的虚拟模块名,不记录 Runner 的盘符、主目录或 +绝对路径;检查成功后会删除该诊断文件,检查失败时保留以便排查。标准 Vite +manifest 会保留在输出中。预算以干净生产构建为基线并保留有限余量;若业务确需 +提高预算,必须先检查 manifest 闭包和产物差异,不能只为通过 CI 调大数值。 diff --git a/FEATURES.md b/FEATURES.md index 64b113c..d2157be 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -12,6 +12,7 @@ ### 桌面基础、工作空间与上下文 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 +- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。 - [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。 @@ -21,7 +22,7 @@ ### Agent Runtime 与模型连接 - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 -- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。 +- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。 @@ -30,7 +31,7 @@ - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 - [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。 -- [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 +- [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。 - [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。 - [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Task 级委派、后台执行、Workflow/Hook、长任务与原生会话监督;用户只选择 Conversation 或 Task,Job/Run 保持内部,不形成树或独立操作对象。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index d610f66..bc0ddd1 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -300,16 +300,20 @@ 自动增删入口。产品契约见 [通用助手工作栏与执行空间 PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 -- 默认固定提供 Task Center、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文九个标准栏目。 +- 默认固定提供任务中心、上下文、工作区、浏览器和成果五个标准栏目,不根据当前页面或 + Runtime 能力自动增删入口。 - Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容, 也不得把 Job/Run 提升为可导航 UI 对象。 -- 其他可绑定目标的栏目独立支持“跟随当前上下文”和“固定到指定对象”。当前会话、项目和 Runtime 只提供默认目标,不能成为进入栏目或切换目标的前提。 +- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。 - 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 - 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 -- 九个栏目优先使用带稳定图标与标签的纵向工具导航,并保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 +- 五个栏目使用稳定标签并保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 - 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。 - 宽窗口可停靠并调整宽度,中等窗口可停靠或覆盖,窄窗口使用全屏或接近全屏抽屉;所有尺寸下均须保留全部栏目入口。 +- 覆盖和抽屉布局打开后焦点进入工作栏并限制在其中,Escape 或背景点击关闭,关闭后焦点 + 返回触发按钮;覆盖期间背景内容必须从指针与辅助技术导航中隔离。宽窗口停靠布局不得 + 获得对话框语义或隔离主工作区。 - 终端、宽日志和大型成果可以由用户切换到底部停靠或独立窗口,应用不得因内容变化自动改变用户已选布局。 ### 6.10 应用顶栏与全局操作 @@ -579,6 +583,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。 - 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。 +- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。 - 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。 - 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。 @@ -594,6 +599,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供 “打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。 - 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。 +- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 @@ -698,3 +704,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 4. 全局或项目范围在浏览、创建、编辑和危险操作中均可见。 5. 浅色、深色、键盘和各窗口宽度下均可完成核心任务。 6. 空状态、错误状态和危险操作符合本文规则。 + +## 17. 一级页面加载性能 + +- 首次启动只可在低优先级空闲时预加载轻量一级页面;Knowledge、Magic Notes、 + Settings、Activity 等较重页面应在对应导航控件获得指针意图或键盘焦点时预加载。 +- 点击、快捷键和程序化导航不能依赖预加载完成,必须保留页面级 `Suspense` 加载 + 状态、错误边界和 KeepAlive 行为。 +- Workspace 与 Conversation 的 KeepAlive 缓存必须在每次访问时立即执行容量上限 + 与 LRU 保护规则;定时清理只负责过期与数据失效兜底,不能作为容量门禁。 +- 页面内大型可选视图应使用局部加载边界。知识图谱画布加载失败时,只替换画布 + 区域并提供可访问的重试操作,不得替换整个 Knowledge 页面或丢失其余页面状态。 +- 加载状态使用 `role="status"`、`aria-live="polite"` 与 `aria-busy="true"`; + 局部加载失败使用 `role="alert"`,并保留明确的恢复操作。 diff --git a/build/check-renderer-bundle.cjs b/build/check-renderer-bundle.cjs new file mode 100644 index 0000000..f2f528b --- /dev/null +++ b/build/check-renderer-bundle.cjs @@ -0,0 +1,386 @@ +const { existsSync, readFileSync, rmSync } = require('node:fs') +const { gzipSync } = require('node:zlib') +const { resolve } = require('node:path') + +const rendererBundleBudgets = Object.freeze({ + initial: Object.freeze({ raw: 3_500_000, gzip: 720_000 }), + knowledge: Object.freeze({ raw: 330_000, gzip: 50_000 }), + graph: Object.freeze({ raw: 3_500_000, gzip: 720_000 }), + activity: Object.freeze({ raw: 55_000, gzip: 9_000 }), + magicNotes: Object.freeze({ raw: 630_000, gzip: 130_000 }), + settings: Object.freeze({ raw: 650_000, gzip: 105_000 }) +}) + +function normalizePath(value) { + return value.replaceAll('\\', '/') +} + +function findEntryKey(manifest) { + const entries = Object.entries(manifest).filter( + ([, item]) => item && item.isEntry + ) + if (entries.length !== 1) { + throw new Error( + `Expected one renderer entry in the manifest, found ${entries.length}` + ) + } + return entries[0][0] +} + +function findSourceKey(manifest, sourceSuffix) { + const normalizedSuffix = normalizePath(sourceSuffix) + const matches = Object.entries(manifest).filter(([key, item]) => { + const source = normalizePath(item.src || key) + return source.endsWith(normalizedSuffix) + }) + if (matches.length !== 1) { + throw new Error( + `Expected one manifest entry for ${sourceSuffix}, found ${matches.length}` + ) + } + return matches[0][0] +} + +function findModuleOwnerKeys(manifest, moduleManifest, pattern) { + const keysByFile = new Map( + Object.entries(manifest).map(([key, item]) => [item.file, key]) + ) + const keys = new Set() + for (const [file, modules] of Object.entries(moduleManifest)) { + if (modules.some((id) => pattern.test(normalizePath(id)))) { + const key = keysByFile.get(normalizePath(file)) + if (!key) { + throw new Error(`Module manifest chunk is missing: ${file}`) + } + keys.add(key) + } + } + if (keys.size === 0) { + throw new Error(`No renderer chunk owns modules matching ${pattern}`) + } + return keys +} + +function assertSafeModuleManifest(moduleManifest) { + for (const modules of Object.values(moduleManifest)) { + for (const id of modules) { + const normalized = normalizePath(id) + if ( + normalized !== id || + normalized.startsWith('/') || + normalized.startsWith('../') || + /[A-Za-z]:\//u.test(normalized) || + normalized.includes('/Users/') || + normalized.includes('/home/') + ) { + throw new Error(`Unsafe renderer module manifest path: ${id}`) + } + } + } +} + +function collectSynchronousClosure(manifest, rootKey) { + if (!manifest[rootKey]) { + throw new Error(`Manifest entry is missing: ${rootKey}`) + } + const visited = new Set() + const pending = [rootKey] + while (pending.length > 0) { + const key = pending.pop() + if (visited.has(key)) { + continue + } + const item = manifest[key] + if (!item) { + throw new Error(`Manifest import is missing: ${key}`) + } + visited.add(key) + for (const imported of item.imports || []) { + pending.push(imported) + } + } + return visited +} + +function collectAssetFiles(manifest, keys) { + const files = new Set() + for (const key of keys) { + const item = manifest[key] + if (!item) { + throw new Error(`Manifest entry is missing: ${key}`) + } + files.add(normalizePath(item.file)) + for (const cssFile of item.css || []) { + files.add(normalizePath(cssFile)) + } + } + return files +} + +function measureFiles(files, readAsset, metricCache = new Map()) { + let raw = 0 + let gzip = 0 + const measuredFiles = [...files].sort() + for (const file of measuredFiles) { + let metrics = metricCache.get(file) + if (!metrics) { + const bytes = Buffer.from(readAsset(file)) + metrics = { + raw: bytes.byteLength, + gzip: gzipSync(bytes).byteLength + } + metricCache.set(file, metrics) + } + raw += metrics.raw + gzip += metrics.gzip + } + return { raw, gzip, files: measuredFiles } +} + +function withoutItems(keys, excluded) { + return new Set([...keys].filter((key) => !excluded.has(key))) +} + +function describeEntry( + manifest, + key, + initialKeys, + initialFiles, + readAsset, + metricCache +) { + const closureKeys = collectSynchronousClosure(manifest, key) + const closureFiles = collectAssetFiles(manifest, closureKeys) + const incrementalKeys = withoutItems(closureKeys, initialKeys) + const incrementalFiles = withoutItems(closureFiles, initialFiles) + const root = measureFiles( + collectAssetFiles(manifest, new Set([key])), + readAsset, + metricCache + ) + return { + key, + file: manifest[key].file, + rootRaw: root.raw, + rootGzip: root.gzip, + closureKeys, + incrementalKeys, + ...measureFiles(incrementalFiles, readAsset, metricCache) + } +} + +function assertDisjoint(description, keys, forbiddenKeys) { + const matches = [...forbiddenKeys].filter((key) => keys.has(key)) + if (matches.length > 0) { + throw new Error( + `${description} synchronously includes ${matches.join(', ')}` + ) + } +} + +/** + * @typedef {object} RendererAnalysisKeys + * @property {string} initial + * @property {string} knowledge + * @property {string} graph + * @property {string} activity + * @property {string} magicNotes + * @property {string} settings + * @property {string[]} g6 + */ + +/** + * Analyzes one renderer manifest. + * + * `keys` is the authoritative set of discovered roots and module owners; + * notably, `keys.g6` is always an array because G6 may span several chunks. + * + * @returns {{ keys: RendererAnalysisKeys, sections: Record }} + */ +function analyzeRendererManifest(manifest, readAsset, moduleManifest) { + assertSafeModuleManifest(moduleManifest) + const metricCache = new Map() + const keys = { + initial: findEntryKey(manifest), + knowledge: findSourceKey(manifest, 'KnowledgeWorkspace.tsx'), + graph: findSourceKey(manifest, 'KnowledgeGraphChart.tsx'), + activity: findSourceKey(manifest, 'ActivityPanel.tsx'), + magicNotes: findSourceKey(manifest, 'MagicNotesWorkspace.tsx'), + settings: findSourceKey(manifest, 'SettingsPanel.tsx') + } + const g6Keys = findModuleOwnerKeys( + manifest, + moduleManifest, + /(?:^|\/)node_modules\/@antv\/g6\//u + ) + const initialKeys = collectSynchronousClosure(manifest, keys.initial) + const initialFiles = collectAssetFiles(manifest, initialKeys) + const initialRoot = measureFiles( + collectAssetFiles(manifest, new Set([keys.initial])), + readAsset, + metricCache + ) + const initial = { + key: keys.initial, + file: manifest[keys.initial].file, + rootRaw: initialRoot.raw, + rootGzip: initialRoot.gzip, + closureKeys: initialKeys, + incrementalKeys: initialKeys, + ...measureFiles(initialFiles, readAsset, metricCache) + } + const sections = { + initial, + knowledge: describeEntry( + manifest, + keys.knowledge, + initialKeys, + initialFiles, + readAsset, + metricCache + ), + graph: describeEntry( + manifest, + keys.graph, + initialKeys, + initialFiles, + readAsset, + metricCache + ), + activity: describeEntry( + manifest, + keys.activity, + initialKeys, + initialFiles, + readAsset, + metricCache + ), + magicNotes: describeEntry( + manifest, + keys.magicNotes, + initialKeys, + initialFiles, + readAsset, + metricCache + ), + settings: describeEntry( + manifest, + keys.settings, + initialKeys, + initialFiles, + readAsset, + metricCache + ) + } + + assertDisjoint( + 'The renderer entry', + initialKeys, + new Set([keys.knowledge, keys.graph, keys.activity, ...g6Keys]) + ) + assertDisjoint( + 'The Knowledge shell', + sections.knowledge.closureKeys, + new Set([keys.graph, ...g6Keys]) + ) + for (const g6Key of g6Keys) { + if (!sections.graph.closureKeys.has(g6Key)) { + throw new Error('The graph chunk no longer synchronously owns G6') + } + } + + return { keys: { ...keys, g6: [...g6Keys] }, sections } +} + +function checkBudgets(analysis, budgets = rendererBundleBudgets) { + const failures = [] + for (const [name, budget] of Object.entries(budgets)) { + const section = analysis.sections[name] + if (!section) { + failures.push(`Unknown budget section: ${name}`) + continue + } + for (const metric of ['raw', 'gzip']) { + if (section[metric] > budget[metric]) { + failures.push( + `${name} ${metric} ${section[metric]} exceeds ${budget[metric]}` + ) + } + } + } + if (failures.length > 0) { + throw new Error(`Renderer bundle budget failed:\n- ${failures.join('\n- ')}`) + } +} + +function formatBytes(bytes) { + return `${(bytes / 1000).toFixed(2)} kB` +} + +function formatReport(analysis) { + return [ + 'Renderer bundle budget:', + ...Object.entries(analysis.sections).map( + ([name, section]) => + `- ${name}: root ${formatBytes(section.rootRaw)} raw / ` + + `${formatBytes(section.rootGzip)} gzip, ` + + `incremental closure ${formatBytes(section.raw)} raw / ` + + `${formatBytes(section.gzip)} gzip` + ) + ].join('\n') +} + +function checkRendererBundle(root = process.cwd()) { + const rendererRoot = resolve(root, 'out', 'renderer') + const manifestPath = resolve(rendererRoot, '.vite', 'manifest.json') + const moduleManifestPath = resolve( + rendererRoot, + '.vite', + 'module-manifest.json' + ) + if (!existsSync(manifestPath)) { + throw new Error(`Renderer manifest not found: ${manifestPath}`) + } + if (!existsSync(moduleManifestPath)) { + throw new Error( + `Renderer module manifest not found: ${moduleManifestPath}` + ) + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const moduleManifest = JSON.parse( + readFileSync(moduleManifestPath, 'utf8') + ) + const analysis = analyzeRendererManifest( + manifest, + (file) => readFileSync(resolve(rendererRoot, file)), + moduleManifest + ) + checkBudgets(analysis) + rmSync(moduleManifestPath) + return analysis +} + +module.exports = { + analyzeRendererManifest, + checkBudgets, + checkRendererBundle, + collectAssetFiles, + collectSynchronousClosure, + findEntryKey, + findModuleOwnerKeys, + findSourceKey, + formatReport, + assertSafeModuleManifest, + measureFiles, + rendererBundleBudgets +} + +if (require.main === module) { + try { + const analysis = checkRendererBundle() + console.log(formatReport(analysis)) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} diff --git a/docs/architecture/deepseek-harness-runtime-design.md b/docs/architecture/deepseek-harness-runtime-design.md index 884f3fb..624bc52 100644 --- a/docs/architecture/deepseek-harness-runtime-design.md +++ b/docs/architecture/deepseek-harness-runtime-design.md @@ -219,6 +219,10 @@ GoodBuddy 控制面自身不导出 `apply(ctx, config)`,也不提供默认 std - 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。 GoodBuddy 不扫描任意目录、不读取用户 profile 插件清单,也不接受 Renderer 直接提供文件路径。 +插件安装、升级和移除在目录重命名前写入受管变更日志。Main 下次初始化时以持久 +Store 是否已经提交为准,确定性完成新目录或恢复旧目录,并在处理前重新验证受管 +目录、入口真实路径、符号链接和根目录包含关系。旧版 `store.json` 继续原地迁移, +不要求用户重新安装插件。 ## 8. 协议设计 @@ -523,14 +527,14 @@ OpenCode、Continue 和 DeepSeek Harness 的后续能力按操作生命周期放 | 表面 | 负责内容 | 不负责内容 | | --- | --- | --- | | Composer 通用行 | 附件、语音、知识范围、专家、Ask/Execute、Runtime 和发送 | Session 监督、后台进度、历史任务管理 | -| Composer Runtime 专属行 | 仅对当前消息生效且需要高频选择的 Agent、预设、Prompt/Command 快捷操作 | Subagent 树、后台 Job、Workflow/Hook 生命周期 | -| 助手工作栏固定“Runtime”栏目 | 用户所选会话或 Run 的 Runtime 状态、Subagent 层级与取消、后台 Job 队列/进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止和会话监督 | 持久模型、程序路径、默认 Agent/预设配置 | +| Composer Runtime 专属行 | 仅对当前消息生效且需要高频选择的 Agent、预设、Prompt/Command 快捷操作 | Task 级委派、后台执行、Workflow/Hook 生命周期 | +| 助手工作栏固定“Runtime”栏目 | 用户所选 Conversation 或 Task 的 Runtime 状态、Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止和会话监督;不显示 Job/Run 树 | 持久模型、程序路径、默认 Agent/预设配置 | | 设置 > Agent Runtime | 持久 Runtime 配置、默认值、插件管理、能力清单和连接诊断 | 某次活动会话的实时控制 | Runtime 栏目入口始终存在,并采用统一监督模型;内部再按用户所选目标及其 Runtime 的真实能力 显示 OpenCode、Continue 或 DSH 的具体区域。未支持能力不渲染空卡片或一排禁用按钮,而是 -在用户需要理解缺口时显示原因和可执行入口。跟随模式切换 Runtime 或会话时必须清理上一归属 -的 Job/Subagent 状态,固定目标则保持不变。完整工作栏契约见 +在用户需要理解缺口时显示原因和可执行入口。跟随模式切换 Runtime、Conversation 或 Task +时必须清理上一归属的聚合执行状态,固定目标则保持不变。完整工作栏契约见 [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 所有未来的 Subagent、Job、Workflow、Hook 和会话操作仍须经过 Main 的 Runtime 边界,保留取消、超时、权限、Task/Job/Subjob 层级、用量和活动审计。高风险动作在侧栏就地确认,运行结果进入活动与成果记录,不以 Composer 按钮代替监督面板。DeepSeek Harness 首版仍不加载这些服务,本节只确定未来跨 Runtime 的产品位置和协议归属。 @@ -732,7 +736,7 @@ npm run build - Harness 文件和命令工具没有 Runtime OS 隔离,会继承 GoodBuddy 客户端当前用户能够访问的主机资源。 - 首版不恢复 Harness 原生 Session,Runtime 重启后由 GoodBuddy 历史重建。 - 图片输入仅在所选模型连接明确声明支持时可用;首版仍不支持知识库、浏览器控制和 Harness Subagent。Web Search/Fetch 仅使用 Main 代理,MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。 -- Harness Subagent、后台 Job、Workflow、Hook 和原生会话监督尚未实现;未来入口固定在右侧 Runtime 监督栏,不扩张 Composer 工具栏。 +- Harness Subagent、后台 Job、Workflow、Hook 和原生会话监督尚未实现;未来按 Task 聚合到右侧 Runtime 监督栏,不扩张 Composer 工具栏或暴露 Job/Run 层级。 - 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。 - 市场来自公共 npm 关键字搜索,不是精选目录;包的质量、兼容性和维护状态由发布者负责。 - 插件安装、初始化、后台生命周期和 Execute 工具使用当前用户权限,不受 Runtime OS 沙箱保护;Ask 只控制模型工具调用。 diff --git a/docs/architecture/model-download-source-design.md b/docs/architecture/model-download-source-design.md index f67c6dd..243e44a 100644 --- a/docs/architecture/model-download-source-design.md +++ b/docs/architecture/model-download-source-design.md @@ -918,6 +918,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 - Renderer 提交过期来源时零网络请求。 - 两个来源的安装 Manifest 文件摘要相同。 - 取消、关闭、错误和摘要不匹配不留下正式模型目录。 +- 快照准备会清理名称同时匹配受管模型 ID 与安装 UUID 的陈旧 + `.install-*` 目录,以及受管文件名对应的孤立 `.partial` 文件;已安装模型、 + 非目录条目和用户自建文件不参与清理。 ### 17.5 回归 diff --git a/docs/design/cross-platform-assistant-product-design.md b/docs/design/cross-platform-assistant-product-design.md index 64106d5..0ab086f 100644 --- a/docs/design/cross-platform-assistant-product-design.md +++ b/docs/design/cross-platform-assistant-product-design.md @@ -670,6 +670,17 @@ Task 身份和左侧行首展开入口,不改变 Conversation 类型或复制 - 清除数据前列出会删除的数据范围。 - 配置写入采用原子替换,损坏时可恢复默认配置。 +#### 本地助理数据兼容性 + +- 助理 SQLite 当前 schema 为 `user_version = 25`。`projects.built_in_default` + 是 Main 维护的只读内置身份,默认值为 `0`;普通项目创建、更新输入和 IPC 均不能设置它。 +- 全新数据库只通过 Main 内部种子路径将自动创建的本地默认项目标记为 `1`。界面仅在该标记 + 存在且原始种子名称、说明仍精确匹配时本地化展示,不改写持久化名称、说明或历史快照。 +- 从旧 schema 升级时,只有恰好一个项目满足完整旧种子签名(本地用户项目、活动状态、 + Ask、无 Runtime、原始名称和说明精确匹配、创建与更新时间相同),且该项目仍是数据库 + 最初插入的 Project 时才回填标记。零个、多个、原始项目已编辑或删除后出现的同名候选均 + 不标记,避免把用户后来独立创建的同名项目误认为内置项目。迁移在单一事务中完成。 + ### 5.16 自动更新 #### 功能项 diff --git a/docs/prd/channels/wechat-clawbot-channel-project-prd.md b/docs/prd/channels/wechat-clawbot-channel-project-prd.md index 2e1c43c..2fed2cd 100644 --- a/docs/prd/channels/wechat-clawbot-channel-project-prd.md +++ b/docs/prd/channels/wechat-clawbot-channel-project-prd.md @@ -397,6 +397,8 @@ Execute 消息通过身份、长度、去重和并发检查后: - 失败:回传经过脱敏、长度受限的用户可处理错误。 - 取消:回传“任务已取消”。 - 结果投递失败时保留发件箱记录并显示通道错误,不重复执行任务。 +- 发件箱达到五次投递尝试后进入可查询的终止状态,并继续通过现有通道错误回调 + 暴露;终止记录不再发送,也不会从未投递查询中静默消失。 ### 9.6 媒体与文件 diff --git a/docs/prd/experiments/parallel-experiments-prd.md b/docs/prd/experiments/parallel-experiments-prd.md index 6694665..a1cd3d4 100644 --- a/docs/prd/experiments/parallel-experiments-prd.md +++ b/docs/prd/experiments/parallel-experiments-prd.md @@ -286,13 +286,14 @@ Supervisor 不能: 实验工作台页签: 1. **设计**:问题、协议、变量、指标和预算。 -2. **运行**:总体进度、Run 表和状态。 +2. **运行**:总体进度、候选执行和聚合状态。 3. **比较**:指标表、图表、差异和 Pareto 候选。 -4. **证据**:按结论、指标和 Run 查看证据。 +4. **证据**:按结论、指标和候选查看证据。 5. **结论**:总结、限制和后续操作。 -Run 详情展示参数、协议版本、时间线、消息、任务、成果、监督记录、指标、评估理由、 -上下文和记忆快照、Token、耗时与错误。 +候选详情在 Experiment 工作台内展示参数、协议版本、时间线、消息、Task、成果、监督记录、 +指标、评估理由、上下文和记忆快照、Token、耗时与错误。内部 Run ID 只用于关联和审计, +不提供独立 Run 路由、页面或操作菜单。 ## 15. 后续操作 @@ -302,7 +303,7 @@ Run 详情展示参数、协议版本、时间线、消息、任务、成果、 - 创建自动化计划草稿。 - 保存实验模板。 - 创建记忆候选。 -- 追加确认 Run。 +- 追加确认执行。 - 导出脱敏结果摘要。 不得自动启用新计划、覆盖现有计划、确认长期记忆、应用工作区 Patch 或扩大权限。 diff --git a/docs/prd/learning/continuous-learning-prd.md b/docs/prd/learning/continuous-learning-prd.md index 0216d4e..358fb85 100644 --- a/docs/prd/learning/continuous-learning-prd.md +++ b/docs/prd/learning/continuous-learning-prd.md @@ -84,7 +84,7 @@ Observe - 用户对回答、任务或 Supervisor 意见的显式反馈。 - 用户对心跳报告或建议的显式反馈。 -- Task/Job Run 的成功与失败比较。 +- Task 执行的成功与失败比较。 - 并行实验结论。 - 回放评估发现的稳定差异。 - 用户手动创建。 diff --git a/docs/prd/memory/partitioned-memory-prd.md b/docs/prd/memory/partitioned-memory-prd.md index e9a9c7f..f0391a8 100644 --- a/docs/prd/memory/partitioned-memory-prd.md +++ b/docs/prd/memory/partitioned-memory-prd.md @@ -140,10 +140,10 @@ agent:{expertId} Conversation → Project → Global ``` -Task/Job Run: +Task 执行(内部 Job/Run): ```text -Run → Automation → Conversation(可选)→ Project → Global +Run → Job → Task → Conversation → Project → Global ``` 实验 Run: @@ -232,7 +232,7 @@ type MemorySource = - 用户明确“记住这个”。 - 会话结束总结。 -- Task/Job Run 结束反思。 +- Task 执行结束反思。 - 实验结论。 - Supervisor 建议后用户采纳。 - 智能心跳。 @@ -481,7 +481,7 @@ Project 记忆与 Global 偏好冲突时: - [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。 - [ ] 智能心跳配置只能属于 Global 或 Main 已验证的一个、多个 Project。 - [ ] 未来分区记忆完成独立设计前,不新增相关表、状态或检索行为。 -- [ ] Task/Job Run 只读取运行快照绑定的分区。 +- [ ] Task 执行只读取内部 Run 快照绑定的分区。 - [ ] 实验 Run 不能读取其他 Run 的消息或记忆。 - [ ] 每条非手动记忆都有可追溯来源。 - [ ] 候选和被拒绝记忆不进入普通上下文。 @@ -489,6 +489,6 @@ Project 记忆与 Global 偏好冲突时: - [ ] 冲突事实不被静默覆盖。 - [ ] 当前有效事实可通过有效时间正确选择。 - [ ] 上下文组装遵守各层和总字符预算。 -- [ ] UI 能显示某次 Run 实际使用的记忆。 +- [ ] UI 能在 Task 执行记录中显示实际使用的记忆,不把 Run 暴露为独立导航对象。 - [ ] 删除或忘记后,文本、索引和缓存不再可检索。 - [ ] Restricted 记忆不会自动生成或发送给外部 Embedding 服务。 diff --git a/docs/prd/smart-heartbeat/smart-heartbeat-prd.md b/docs/prd/smart-heartbeat/smart-heartbeat-prd.md index 68c5809..0582bd3 100644 --- a/docs/prd/smart-heartbeat/smart-heartbeat-prd.md +++ b/docs/prd/smart-heartbeat/smart-heartbeat-prd.md @@ -197,6 +197,8 @@ type HeartbeatScope = - 多项目汇总后统一应用上限,不能按项目倍增预算。 - 心跳结果默认不在系统通知中暴露私人正文。 - 数据迁移必须使用 SQLite 事务,保留外键、级联删除和现有历史。 +- 心跳运行失败时,运行记录与配置的 `last_status` 必须在同一 SQLite 事务中 + 更新;任一写入失败时两者一起回滚,不能留下半提交状态。 ## 7. 实施状态与后续顺序 diff --git a/docs/prd/supervision/conversation-supervision-prd.md b/docs/prd/supervision/conversation-supervision-prd.md index ec86c15..e6f2701 100644 --- a/docs/prd/supervision/conversation-supervision-prd.md +++ b/docs/prd/supervision/conversation-supervision-prd.md @@ -28,9 +28,10 @@ GoodBuddy 的魔法笔记已经提供一种有价值的交互:用户持续写 ## 2. 产品定义 -会话监督是在明确范围和策略下,对普通 Conversation、Task、Job/Run 或 ExperimentRun -的可见事件进行独立观察,产生带证据的评论、告警和人工介入请求。一个 Task 与唯一 -Conversation 一对一绑定;Job/Run 是内部执行和审计对象。 +会话监督是在明确范围和策略下,对普通 Conversation、Task 或 Experiment 的可见事件 +进行独立观察,产生带证据的评论、告警和人工介入请求。每个 Task 只关联一条 Conversation, +一条 Conversation 可以承载多个 Task;Job/Run 是内部执行和审计对象,不作为当前 UI +监督目标。 它不是: @@ -55,7 +56,7 @@ Conversation 一对一绑定;Job/Run 是内部执行和审计对象。 ## 4. 已确认的产品决策 -1. 监督默认关闭,由用户对 Conversation、Task、Job/Run 或实验显式启用。 +1. 监督默认关闭,由用户对 Conversation、Task 或实验显式启用。 2. 监督只读取用户可查看的消息、工具事件、状态、指标、成果摘要和目标。 3. 不读取、推断或保存模型隐藏推理链。 4. 每条重要判断必须引用具体消息、工具、步骤、指标或成果。 @@ -100,9 +101,7 @@ Conversation 一对一绑定;Job/Run 是内部执行和审计对象。 | --- | --- | --- | | 普通会话 | 用户消息、助手回答、引用、工具事件 | 质量和证据评论 | | Task | 目标、状态、Conversation、成果 | 偏离、循环和失败分析 | -| Job/Run | 触发、步骤、协议、预算、审批、指标 | 无人值守或内部执行关注 | -| 实验 Run | 协议、变量、指标、证据 | 协议一致性 | -| 实验整体 | 各 Run 结算和比较 | 评估公平性与无结论提示 | +| Experiment | 协议、变量、各候选执行、指标和证据 | 协议一致性、评估公平性与无结论提示 | 每个监督会话只能绑定一个主对象,并继承其项目范围。 @@ -254,7 +253,7 @@ type SupervisorDecision = { ### 13.1 工作栏监督栏目评论流 监督是助手工作栏中固定且始终可访问的栏目,不是只在聊天页面出现的附属面板。栏目默认 -跟随当前会话,用户也可以固定到其他普通 Conversation、Task、Job/Run 或 ExperimentRun。 +跟随当前会话,用户也可以固定到其他普通 Conversation、Task 或 Experiment。 切换页面不会改变固定目标;目标失效时必须显示修复状态,不能静默回到当前会话。 复用魔法笔记的体验方向: diff --git a/docs/prd/task-and-job/README.md b/docs/prd/task-and-job/README.md index fdd43db..a391cdb 100644 --- a/docs/prd/task-and-job/README.md +++ b/docs/prd/task-and-job/README.md @@ -12,5 +12,5 @@ ## 阅读顺序 -先阅读统一领域模型。其他三份文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。 +先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。 若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。 diff --git a/docs/prd/task-and-job/goal-task-prd.md b/docs/prd/task-and-job/goal-task-prd.md index e8b40f2..7cb8053 100644 --- a/docs/prd/task-and-job/goal-task-prd.md +++ b/docs/prd/task-and-job/goal-task-prd.md @@ -11,8 +11,8 @@ ## 1. 产品定义 -Goal Task 是围绕可验证结果持续推进的 Task。它仍然只有一个 Task Conversation;每轮观察、 -计划、行动和评估由 Job/Run 表达,不创建一串顶层 Task。 +Goal Task 是围绕可验证结果持续推进的 Task。它只关联一条 Conversation,但该 Conversation +也可以承载其他 Task;每轮观察、计划、行动和评估由内部 Job/Run 表达,不创建一串顶层 Task。 ## 2. 必要配置 @@ -35,8 +35,9 @@ Observe Job → Complete, pause, revise or continue ``` -循环内的所有 Job 共享 Task Conversation。只有协调器把有意义的阶段进展写入消息时间线, -避免每个内部步骤产生一条顶层任务或杂乱消息。 +循环内的所有 Job 通过所属 Task 写入同一关联 Conversation。只有协调器把有意义的阶段进展 +写入消息时间线,避免每个内部步骤产生一条顶层 Task 或杂乱消息。当前 UI 只显示 Goal Task +及其聚合状态,不显示 Job/Run 层级。 ## 4. 完成和无进展 @@ -47,7 +48,8 @@ Observe Job ## 5. 验收原则 -- [ ] Goal Task 只有一个 Task Conversation。 +- [ ] Goal Task 只关联一条 Conversation,Conversation 可以承载其他 Task。 - [ ] 循环步骤以 Job 表达,不创建顶层子 Task。 +- [ ] 当前 UI 不展示 Goal Task 内部 Job/Run 层级。 - [ ] 没有成功标准和停止条件时不能启用。 - [ ] 无进展和预算耗尽不会伪装为成功。 diff --git a/docs/prd/task-and-job/job-and-subjob-prd.md b/docs/prd/task-and-job/job-and-subjob-prd.md index 281346d..3eade3c 100644 --- a/docs/prd/task-and-job/job-and-subjob-prd.md +++ b/docs/prd/task-and-job/job-and-subjob-prd.md @@ -29,7 +29,7 @@ ## 3. 并行模型 ```text -Task Conversation +关联 Conversation └─ Coordinating Job ├─ Parallel Job A ├─ Parallel Job B @@ -41,7 +41,8 @@ Task Conversation - 每个 Job 有独立输入快照、状态、Run、预算和输出缓冲。 - 并行 Job 不直接同时追加助手消息。 - Aggregation Job 或 Task 协调器按确定顺序生成一条进展或结果消息。 -- 用户可以查看每个 Job 的详细活动,但主 Conversation 保持可读。 +- 用户可以按 Task 查看有界活动和聚合状态,但不选择或展开单个 Job;主 Conversation + 保持可读。 ## 4. Subjob @@ -78,21 +79,24 @@ queued → running → waiting_approval → completed ## 7. 界面 -Task Conversation 显示: +当前产品 UI 的对象层级止于 Task,不提供 Job/Subjob 树、独立页面或导航入口。 + +关联 Conversation 和 Task Center 只显示: - 当前总体进展。 -- 并行 Job 数量和聚合状态。 -- 需要审批或用户输入的 Job。 +- 并行执行数量和聚合状态。 +- 需要审批或用户输入的 Task 状态。 - 完成后的统一结果。 -详细活动视图显示 Job 树、执行者、Runtime、耗时、预算、Run、错误和成果。Task Center 只显示 -Task 聚合状态,不展开 Job 树。 +活动与 Runtime 可以按 Task 显示执行者、工具、耗时、预算、错误、审批和成果事件,但不把 +Job、Subjob 或 Run 暴露为可选择、可展开或可操作的产品对象。内部标识只用于关联与审计。 ## 8. 验收标准 -- [ ] 并行 Job 共享所属 Task 的 Conversation。 +- [ ] 并行 Job 通过所属 Task 写入同一关联 Conversation。 - [ ] Job 不创建顶层 Task。 - [ ] 并行输出不会无序污染消息时间线。 - [ ] Subjob 深度、并发、预算和输出有界。 - [ ] Subagent 失败能够返回部分输出和明确状态。 - [ ] 父级取消传播到所有活动子级。 +- [ ] 当前 UI 只展示到 Task,不显示 Job/Subjob/Run 层级。 diff --git a/docs/roadmap/long-term-assistant-roadmap.md b/docs/roadmap/long-term-assistant-roadmap.md index ea288c4..f61d668 100644 --- a/docs/roadmap/long-term-assistant-roadmap.md +++ b/docs/roadmap/long-term-assistant-roadmap.md @@ -9,7 +9,7 @@ | 版本 | 0.3 | | 日期 | 2026-08-19 | | 适用产品 | GoodBuddy 桌面端 | -| 相关设计 | [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)、[Task 与 Job 统一领域模型](../prd/task-and-job/task-and-job-model.md)、[智能心跳 PRD](../prd/smart-heartbeat/smart-heartbeat-prd.md) | +| 相关设计 | [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)、[Task 与 Job 统一领域模型](../prd/task-and-job/task-and-job-model.md)、[智能心跳 PRD](../prd/smart-heartbeat/smart-heartbeat-prd.md)、[全双工实时语音交互设计](../architecture/full-duplex-voice-design.md) | ## 1. 文档目标 @@ -202,9 +202,12 @@ GoodBuddy 应能够: ### 4.10 语音 -- 首期提供按住说话和语音转文字。 +- 当前已提供点击开始、再次点击停止或到达 20 秒上限后停止的本地一次性语音听写。 - 转写结果先进入可编辑输入框,不自动发送。 -- 后续增加流式语音对话和文本转语音。 +- 后续按[全双工实时语音交互设计](../architecture/full-duplex-voice-design.md)增加持续听说、 + Barge-in、流式文本转语音、本地与云端显式语音引擎。 +- 活动会话冻结引擎、Provider、模型、地域、数据位置和能力;引擎失败时明确停止或重试 + 当前选择,不在本地/云端、原生/模块化、语音/文本之间静默降级。 - 麦克风权限仅在可信主窗口、显式语音会话和用户操作后开启。 - 音频转写完成后默认删除。 @@ -304,8 +307,9 @@ GoodBuddy 应能够: ### 阶段 5:语音 -- 按住说话、转写适配器和可编辑转写。 -- 后续扩展实时语音与 TTS。 +- 以现有点击式一次性听写、本地转写适配器和可编辑转写作为实施基线。 +- 实现全双工会话契约、AudioWorklet 音频平面、Barge-in 和播放提交语义。 +- 接入本地模块化、本地原生和云端原生语音引擎;所有引擎均由用户显式选择,不静默降级。 ### 阶段 6:专家与远程委派 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 24c8376..f5b3649 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -58,6 +58,81 @@ function deepSeekHarnessBundleManifestPlugin(): Plugin { } } +export function sanitizeRendererModuleId( + id: string, + projectRoot = resolve('.') +): string { + const normalized = id.replaceAll('\\', '/') + const normalizedRoot = resolve(projectRoot).replaceAll('\\', '/') + if (normalized.startsWith('\0')) { + const virtualId = normalized.slice(1) + if (virtualId.startsWith(`${normalizedRoot}/`)) { + return `virtual:${virtualId.slice(normalizedRoot.length + 1)}` + } + const virtualNodeModulesIndex = + virtualId.lastIndexOf('/node_modules/') + if (virtualNodeModulesIndex >= 0) { + return `virtual:node_modules/${virtualId.slice( + virtualNodeModulesIndex + '/node_modules/'.length + )}` + } + if ( + virtualId.startsWith('/') || + /[A-Za-z]:\//u.test(virtualId) || + /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(virtualId) || + virtualId.includes('/Users/') || + virtualId.includes('/home/') + ) { + throw new Error(`Renderer virtual module leaks a path: ${id}`) + } + return `virtual:${virtualId}` + } + if ( + normalized === normalizedRoot || + normalized.startsWith(`${normalizedRoot}/`) + ) { + return normalized.slice(normalizedRoot.length + 1) + } + const nodeModulesMarker = '/node_modules/' + const nodeModulesIndex = normalized.lastIndexOf(nodeModulesMarker) + if (nodeModulesIndex >= 0) { + return `node_modules/${normalized.slice( + nodeModulesIndex + nodeModulesMarker.length + )}` + } + if ( + !normalized.startsWith('/') && + !/^[A-Za-z]:\//u.test(normalized) && + !/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(normalized) && + !normalized.startsWith('../') + ) { + return normalized + } + throw new Error(`Renderer module is outside the project: ${id}`) +} + +function rendererBundleModuleManifestPlugin(): Plugin { + const projectRoot = resolve('.') + return { + name: 'renderer-bundle-module-manifest', + generateBundle(_options, bundle) { + const chunks = Object.values(bundle) + .filter((item) => item.type === 'chunk') + .map((chunk) => [ + chunk.fileName, + Object.keys(chunk.modules).map((id) => + sanitizeRendererModuleId(id, projectRoot) + ) + ]) + this.emitFile({ + type: 'asset', + fileName: '.vite/module-manifest.json', + source: `${JSON.stringify(Object.fromEntries(chunks), null, 2)}\n` + }) + } + } +} + export default defineConfig({ main: { plugins: [ @@ -161,6 +236,9 @@ export default defineConfig({ worker: { format: 'es' }, - plugins: [react()] + build: { + manifest: true + }, + plugins: [react(), rendererBundleModuleManifestPlugin()] } }) diff --git a/package.json b/package.json index f1177f4..5e5cae4 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:watch": "vitest", "eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts", "build": "npm run typecheck && npm run build:bundle", - "build:bundle": "electron-vite build", + "build:bundle": "electron-vite build && node build/check-renderer-bundle.cjs", "smoke:deepseek-harness": "npm run build:bundle && node build/run-deepseek-harness-utility-smoke.cjs", "smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs", "release:notes:verify": "node build/release-notes.cjs", @@ -208,6 +208,9 @@ "dmg" ], "category": "public.app-category.productivity", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "notarize": true, "extendInfo": { "NSMicrophoneUsageDescription": "GoodBuddy 需要访问麦克风,将语音转换为可编辑文字。" } diff --git a/sites/README.md b/sites/README.md index 4e0a0ed..11005a5 100644 --- a/sites/README.md +++ b/sites/README.md @@ -1,14 +1,16 @@ # GoodBuddy 静态官网 -`sites` 是无需构建步骤或额外依赖的静态官网源码,可直接托管整个目录。 +`sites` 是无需构建步骤或额外依赖的中英文静态官网源码,可直接托管整个目录。 正式站点地址: -首页将 GoodBuddy 定位为“桌面助手|AI 编程工具台”,优先展示三大桌面 -系统与双架构下载入口、统一 Agent Runtime,以及知识库、魔法笔记、 -智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于主要 -功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾 +首页将 GoodBuddy 定位为“免注册、支持信创软硬件的一站式 AI 助手”, +优先展示三大桌面系统与双架构下载入口、统一 Agent Runtime,以及知识库、 +魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于 +主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾 对应的 Linux x64 / arm64 包。页面不重复设置底部下载推广区。 +英文页面位于 `en.html`,不展示信创适配文案,三个平台的下载按钮始终前往 +GitHub 最新正式 Release。 首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、 柔和跟随光效和同步浮动标签;触屏设备保持静态布局,系统启用“减少动态 效果”时不运行该交互。 @@ -22,6 +24,14 @@ 设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件 或自定义域名 DNS 配置。 +## 语言选择 + +首次访问时,`language.js` 使用浏览器的第一首选语言选择页面:中文语言进入 +中文首页,其他语言进入英文页。页头的语言按钮允许手动切换,并将选择保存在 +浏览器本地;之后访问优先使用手动选择。两个页面都声明 canonical 与 +`hreflang` alternate 地址。手动切换语言会保留当前 URL 片段,例如从下载区 +切换后仍停留在 `#download`。 + ## 本地预览 在仓库根目录运行: @@ -37,29 +47,73 @@ python -m http.server 4173 --bind 127.0.0.1 --directory sites ```powershell node sites/scripts/validate.mjs node --check sites/app.js +node --check sites/language.js +node --check sites/release-index.js +node --test sites/scripts/app.test.mjs sites/scripts/release-index.test.mjs ``` -校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式 -规则,以及下载选择器是否从受信任的正式发布索引加载并保留 GitHub -Release 回退入口。 +校验脚本会检查中英文页面、语言选择、页内链接、本地资源、关键产品文案、 +主题与响应式规则,以及中文下载选择器是否从受信任的正式发布索引加载并 +保留 GitHub Release 回退入口。它还计算浅色弱文本与控件边框的 WCAG +对比度、检查移动导航和下载控件结构、本地字体及许可证。发布索引测试覆盖 +严格 SemVer、六个目标、格式和扩展名、文件大小、SHA-256、唯一文件名及 +不可变 URL,并按发布生成器的实际命名绑定版本、平台、架构和格式。移动 +导航行为测试同时覆盖现代 MediaQueryList 监听与旧版 Safari 的 `addListener` +回退。英文下载入口固定指向 GitHub Release。 ## 下载入口 官网正文不写死版本号,页面启动后读取最新正式发布索引。 Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类型选择器, 选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、 -格式无效或返回非受信任的官方下载地址时,按钮继续指向 GitHub 最新正式 -Release: +过大、发生重定向、格式无效或任一字段返回非受信任的官方下载地址时, +整组按钮会以 fail-closed 方式继续指向 GitHub 最新正式 Release,不会混用 +部分 OSS 数据: ```text https://github.com/mesalogo/goodbuddy/releases/latest ``` +校验规则与桌面更新检查保持一致:索引只能指向稳定 SemVer 版本,必须恰好 +包含 Windows、macOS、Linux 的 x64 / arm64 六个匹配目标;每个目标必须提供 +准确的两种格式和扩展名、正的安全整数大小、64 字符小写十六进制 SHA-256、 +全局唯一文件名,以及位于 +`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${version}/` +下、对文件名进行 URL 编码的精确地址。校验清单和 GitHub 回退地址也必须 +完全匹配,所有地址都不得包含凭据、端口、查询参数或片段。 + +安装包文件名必须与发布生成器完全一致:Windows 使用 +`GoodBuddy-${version}-windows-${arch}-setup.exe` 与 +`GoodBuddy-${version}-windows-${arch}-portable.zip`;macOS 使用 +`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage 与 +DEB 分别使用 electron-builder 的 `x86_64` 与 `amd64` 架构名,Linux +arm64 使用 `arm64`。 + +## 字体与可访问性 + +站点随包提供约 48 KB 的 Inter Variable Latin 子集,不发起远程字体请求。 +拉丁字符优先使用该字体;中文依次使用系统提供的苹方、微软雅黑 UI、 +Noto Sans CJK SC 或思源黑体,并保留 `system-ui` 与无衬线回退。Inter 的 +SIL OFL 1.1 许可证位于 `assets/fonts/inter-OFL.txt`。 + +浅色主题的弱文本达到 WCAG AA 正文对比度,控件边框达到至少 3:1; +站点也支持系统强制颜色与减少动态效果模式。动态替换下载链接时会保留 +“在新窗口打开”的屏幕阅读器说明。移动导航打开后会暂时将页头外内容设为 +`inert` 并聚焦第一个导航项;关闭时安全恢复原有 `inert` 状态和菜单按钮 +焦点,切换回桌面宽度也会解除隔离。媒体查询监听兼容现代浏览器和使用 +`MediaQueryList.addListener` 的旧版 Safari。 + ## 文件 - `index.html`:页面结构与简体中文内容 +- `en.html`:不包含信创适配文案的英文页面 - `styles.css`:语义令牌、浅深主题、焦点与响应式布局 -- `app.js`:主题、移动导航和当前章节 +- `app.js`:主题、移动导航、当前章节和中文下载索引 +- `language.js`:浏览器语言自动选择与手动语言偏好 +- `release-index.js`:中文下载索引的严格、整页 fail-closed 校验 - `assets/goodbuddy-light.png`、`assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标 - `assets/linux-plain.svg`:Devicon v2.17.0 提供的黑白 Linux 图标,许可见 `assets/devicon-LICENSE` -- `scripts/validate.mjs`:无依赖静态检查 +- `assets/fonts/inter-latin-variable.woff2`、`assets/fonts/inter-OFL.txt`:本地 Inter Variable Latin 子集及许可证 +- `scripts/validate.mjs`:无依赖静态与对比度检查 +- `scripts/app.test.mjs`:移动导航、焦点、内容隔离和媒体查询兼容性回归测试 +- `scripts/release-index.test.mjs`:发布索引行为回归测试 diff --git a/sites/app.js b/sites/app.js index c94ea39..bb5ba19 100644 --- a/sites/app.js +++ b/sites/app.js @@ -5,6 +5,7 @@ const header = document.querySelector("[data-site-header]"); const menuToggle = document.querySelector("[data-menu-toggle]"); const navigation = document.querySelector("[data-navigation]"); + const menuBackdrop = document.querySelector("[data-menu-backdrop]"); const themeToggle = document.querySelector("[data-theme-toggle]"); const themeColor = document.querySelector('meta[name="theme-color"]'); const tiltStage = document.querySelector("[data-tilt-stage]"); @@ -12,14 +13,30 @@ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const mobileMenu = window.matchMedia("(max-width: 719px)"); + const isEnglish = root.lang.toLowerCase().startsWith("en"); const releaseManifestUrl = "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json"; const releaseFallbackUrl = "https://github.com/mesalogo/goodbuddy/releases/latest"; - const releaseStatus = document.querySelector("[data-release-status]"); + const releaseRequestTimeoutMs = 10_000; + const releaseIndexApi = window.GoodBuddyReleaseIndex; const downloadCards = [ ...document.querySelectorAll("[data-download-card]"), ]; + const interfaceCopy = isEnglish + ? { + themeDark: "Switch to dark theme", + themeLight: "Switch to light theme", + menuOpen: "Open navigation", + menuClose: "Close navigation", + } + : { + themeDark: "切换为深色主题", + themeLight: "切换为浅色主题", + menuOpen: "打开导航", + menuClose: "关闭导航", + }; const platformNames = { windows: "Windows", macos: "macOS", @@ -39,29 +56,93 @@ return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`; }; - const isTrustedReleaseUrl = (value) => { - try { - const url = new URL(value); - return ( - url.protocol === "https:" && - url.hostname === "goodbuddy.oss-cn-beijing.aliyuncs.com" && - url.pathname.startsWith("/releases/") - ); - } catch { - return false; + const listenMediaQuery = (query, listener) => { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + } else if (typeof query.addListener === "function") { + query.addListener(listener); } }; - const configureDownloads = (release) => { - if ( - release?.formatVersion !== 1 || - release?.productName !== "GoodBuddy" || - typeof release?.version !== "string" || - !release?.targets - ) { - throw new Error("发布索引格式无效"); + const readBoundedJson = async (response) => { + const maximumBytes = releaseIndexApi?.maximumIndexBytes; + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { + throw new Error("发布索引大小上限无效"); } + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null) { + const parsedLength = Number(declaredLength); + if ( + !Number.isSafeInteger(parsedLength) || + parsedLength < 0 || + parsedLength > maximumBytes + ) { + throw new Error("发布索引响应大小无效"); + } + } + if (!response.body) { + throw new Error("发布索引响应没有正文"); + } + + const reader = response.body.getReader(); + const chunks = []; + let length = 0; + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + length += result.value.byteLength; + if (length > maximumBytes) { + await reader.cancel(); + throw new Error("发布索引响应过大"); + } + chunks.push(result.value); + } + + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(bytes)); + }; + + const setReleaseLink = (link, url, visibleText) => { + const newWindowNotice = link.querySelector(".sr-only"); + link.href = url; + link.replaceChildren(document.createTextNode(visibleText)); + if (newWindowNotice) { + link.append(newWindowNotice); + } + }; + + const setFallbackDownloads = () => { + for (const card of downloadCards) { + const platform = card.dataset.downloadCard; + const link = card.closest(".download-card")?.querySelector("[data-release-link]"); + const meta = card.closest(".download-card")?.querySelector("[data-download-meta]"); + if (link instanceof HTMLAnchorElement) { + setReleaseLink( + link, + releaseFallbackUrl, + `前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`, + ); + } + if (meta instanceof HTMLElement) { + meta.textContent = "请在 GitHub Release 中选择对应的安装文件。"; + } + } + }; + + const configureDownloads = (payload) => { + if (!releaseIndexApi?.validateReleaseIndex) { + throw new Error("发布索引校验器不可用"); + } + const release = releaseIndexApi.validateReleaseIndex(payload); + const updateCard = (card) => { const platform = card.dataset.downloadCard; const archSelect = card.querySelector("[data-download-arch]"); @@ -75,26 +156,15 @@ !(link instanceof HTMLAnchorElement) || !(meta instanceof HTMLElement) ) { - return; + throw new Error("下载卡片结构无效"); } const target = release.targets[`${platform}-${archSelect.value}`]; const file = target?.files?.[formatSelect.value]; - if ( - !file || - typeof file.name !== "string" || - !Number.isSafeInteger(file.size) || - file.size < 1 || - !isTrustedReleaseUrl(file.url) - ) { - link.href = releaseFallbackUrl; - link.textContent = - `前往 GitHub 下载 ${platformNames[platform] ?? platform} →`; - meta.textContent = "当前选项暂不可用,请在 GitHub Release 中选择文件。"; - return; + if (!file) { + throw new Error("下载选项不在已校验的发布索引中"); } - link.href = file.url; const platformName = platformNames[platform] ?? platform; const archName = platform === "macos" && archSelect.value === "arm64" @@ -103,7 +173,11 @@ ? "ARM64" : "x64"; const formatName = formatNames[formatSelect.value] ?? formatSelect.value; - link.textContent = `下载 ${platformName} ${archName} ${formatName} →`; + setReleaseLink( + link, + file.url, + `下载 ${platformName} ${archName} ${formatName} →`, + ); meta.textContent = `GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` + `${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`; @@ -112,35 +186,40 @@ for (const card of downloadCards) { const selects = card.querySelectorAll("select"); for (const select of selects) { - select.addEventListener("change", () => updateCard(card)); + select.addEventListener("change", () => { + try { + updateCard(card); + } catch { + setFallbackDownloads(); + } + }); } updateCard(card); } - - if (releaseStatus instanceof HTMLElement) { - releaseStatus.textContent = - `官方下载源已就绪:GoodBuddy ${release.version}。` + - "请选择处理器和安装包类型。"; - releaseStatus.classList.add("is-ready"); - } }; const loadRelease = async () => { + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(), + releaseRequestTimeoutMs, + ); try { const response = await fetch(releaseManifestUrl, { cache: "no-store", credentials: "omit", + redirect: "error", + referrerPolicy: "no-referrer", + signal: controller.signal, }); if (!response.ok) { throw new Error(`发布索引请求失败:${response.status}`); } - configureDownloads(await response.json()); + configureDownloads(await readBoundedJson(response)); } catch { - if (releaseStatus instanceof HTMLElement) { - releaseStatus.textContent = - "官方下载源暂不可用,下载按钮已切换到 GitHub Release。"; - releaseStatus.classList.add("is-fallback"); - } + setFallbackDownloads(); + } finally { + window.clearTimeout(timeout); } }; @@ -157,7 +236,7 @@ root.dataset.theme = theme; themeToggle?.setAttribute( "aria-label", - theme === "dark" ? "切换为浅色主题" : "切换为深色主题", + theme === "dark" ? interfaceCopy.themeLight : interfaceCopy.themeDark, ); themeColor?.setAttribute("content", theme === "dark" ? "#07101f" : "#f6f8fb"); @@ -170,10 +249,59 @@ } }; - const closeMenu = () => { + let isolatedMenuContent = null; + + const isolateMenuContent = () => { + if (isolatedMenuContent) { + return; + } + isolatedMenuContent = new Map(); + for (const element of document.body.children) { + if ( + element === header || + element === menuBackdrop || + element instanceof HTMLScriptElement + ) { + continue; + } + isolatedMenuContent.set(element, element.inert); + element.inert = true; + } + }; + + const restoreMenuContent = () => { + if (!isolatedMenuContent) { + return; + } + for (const [element, wasInert] of isolatedMenuContent) { + element.inert = wasInert; + } + isolatedMenuContent = null; + }; + + const closeMenu = ({ restoreFocus = true } = {}) => { + const wasOpen = header?.classList.contains("is-menu-open") ?? false; header?.classList.remove("is-menu-open"); menuToggle?.setAttribute("aria-expanded", "false"); - menuToggle?.setAttribute("aria-label", "打开导航"); + menuToggle?.setAttribute("aria-label", interfaceCopy.menuOpen); + menuBackdrop?.classList.remove("is-active"); + restoreMenuContent(); + if (wasOpen && restoreFocus) { + menuToggle?.focus(); + } + }; + + const openMenu = () => { + if (!mobileMenu.matches) { + closeMenu({ restoreFocus: false }); + return; + } + header?.classList.add("is-menu-open"); + menuToggle?.setAttribute("aria-expanded", "true"); + menuToggle?.setAttribute("aria-label", interfaceCopy.menuClose); + menuBackdrop?.classList.add("is-active"); + isolateMenuContent(); + navigation?.querySelector("a")?.focus(); }; const setHeaderState = () => { @@ -182,35 +310,37 @@ applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light")); setHeaderState(); - void loadRelease(); + if (!isEnglish) { + void loadRelease(); + } themeToggle?.addEventListener("click", () => { applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true); }); - systemTheme.addEventListener("change", (event) => { - if (!getSavedTheme()) { - applyTheme(event.matches ? "dark" : "light"); - } - }); - menuToggle?.addEventListener("click", () => { - const willOpen = !header?.classList.contains("is-menu-open"); - header?.classList.toggle("is-menu-open", willOpen); - menuToggle.setAttribute("aria-expanded", String(willOpen)); - menuToggle.setAttribute("aria-label", willOpen ? "关闭导航" : "打开导航"); + if (header?.classList.contains("is-menu-open")) { + closeMenu(); + } else { + openMenu(); + } }); navigation?.addEventListener("click", (event) => { if (event.target instanceof HTMLAnchorElement) { - closeMenu(); + closeMenu({ restoreFocus: false }); + window.setTimeout(() => { + if (!header?.classList.contains("is-menu-open")) { + menuToggle?.focus(); + } + }, 0); } }); + menuBackdrop?.addEventListener("click", () => closeMenu()); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && header?.classList.contains("is-menu-open")) { closeMenu(); - menuToggle?.focus(); } }); @@ -224,6 +354,17 @@ } }); + listenMediaQuery(systemTheme, (event) => { + if (!getSavedTheme()) { + applyTheme(event.matches ? "dark" : "light"); + } + }); + listenMediaQuery(mobileMenu, (event) => { + if (!event.matches) { + closeMenu({ restoreFocus: false }); + } + }); + window.addEventListener("scroll", setHeaderState, { passive: true }); if (tiltStage instanceof HTMLElement && tiltCard instanceof HTMLElement) { @@ -266,8 +407,8 @@ tiltStage.addEventListener("pointermove", updateTilt, { passive: true }); tiltStage.addEventListener("pointerleave", resetTilt); - finePointer.addEventListener("change", resetTilt); - reducedMotion.addEventListener("change", resetTilt); + listenMediaQuery(finePointer, resetTilt); + listenMediaQuery(reducedMotion, resetTilt); } const sections = [...document.querySelectorAll("main section[id]")]; diff --git a/sites/assets/fonts/inter-OFL.txt b/sites/assets/fonts/inter-OFL.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/sites/assets/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/sites/assets/fonts/inter-latin-variable.woff2 b/sites/assets/fonts/inter-latin-variable.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/sites/assets/fonts/inter-latin-variable.woff2 differ diff --git a/sites/en.html b/sites/en.html new file mode 100644 index 0000000..49ab124 --- /dev/null +++ b/sites/en.html @@ -0,0 +1,451 @@ + + + + + + + + + + + + GoodBuddy | All-in-one AI assistant, no account required + + + + + + + + + + + + +
+
+
+
+
+ + Windows · macOS · Linux +
+

+ No account required.
+ Your all-in-one
+ AI assistant. +

+

+ GoodBuddy brings conversations, knowledge, notes, and tasks together in a desktop + assistant and AI coding workspace. Its unified Agent Runtime connects direct models, + OpenCode, Continue, and DeepSeek Harness without repeated command-line setup. +

+ +
    +
  • + + Three desktop platforms, x64 and arm64 +
  • +
  • + + Four Agent Runtime options +
  • +
  • + + No GoodBuddy account required +
  • +
+
+ + +
+
+ +
+
+
4 RuntimesMultiple AI coding paths
+
3 platformsWindows / macOS / Linux
+
2 architecturesx64 / arm64
+
1 workspaceSelect, configure, run, audit
+
+
+ +
+
+
+ + + +
+
+
+ +
+
+
+
+

Unified Agent Runtime

+

Different tools, one workflow

+
+

+ Bring Runtime selection, model connections, Skills, MCP, and permissions into one desktop interface. +

+
+ +
+
+
+ +
+ 01 +

One entry point for multiple Agent Runtimes

+

Choose direct models, OpenCode, Continue, or DeepSeek Harness for each task without learning a new entry point.

+
+ Direct modelsOpenCodeContinueDeepSeek Harness +
+
+ +
+
+ +
+ 02 +

Built for desktop platforms

+

Windows, macOS, and Linux releases are available for both x64 and arm64.

+
+ +
+
+ +
+ 03 +

Lower setup overhead

+

Select the Runtime, model, work mode, and project in a graphical interface instead of memorizing commands.

+
+ +
+
+ +
+ 04 +

Shared capabilities, preserved boundaries

+

Skills, MCP, and tools follow each Runtime. Ask stays read-only, while Execute remains approval-controlled and auditable.

+
+ Ask Read-only + Execute Controlled +
+
+ +
+
+ +
+ 05 +

Project context in one place

+

Organize conversations, knowledge, tasks, and run history by project without losing context when switching Runtime.

+
+ +
+
+ +
+ 06 +

Trace every run from action to result

+

Review tool calls, cancellation, timeouts, token usage, and run history in one place.

+
+
+
+
+ +
+
+
+ +

Desktop assistant

+

Conversations, knowledge, notes, and tasks on your desktop

+

+ GoodBuddy keeps reference material, to-dos, and long-running work in one desktop workspace, + so everyday assistance and AI coding share the same project context. +

+ + Choose your desktop release + + +
+ +
+
+ 01 +

Turn sources into searchable knowledge

Import files, folders, and web pages, then search with full text, vectors, and a knowledge graph.

+
+
+ 02 +

Notes, to-dos, and long-term follow-up

Magic Notes captures ideas and tasks, while Heartbeat reviews progress, builds memory, and proposes next steps.

+
+
+ 03 +

Understand what is on your desktop

Add files, screenshots, app windows, clipboard content, and offline voice input when needed.

+
+
+ 04 +

Keep working away from your computer

Connect messaging channels to separate conversations and hand tasks to GoodBuddy on your desktop.

+
+
+
+
+
+ + + + + + diff --git a/sites/index.html b/sites/index.html index 5f97381..f5669b3 100644 --- a/sites/index.html +++ b/sites/index.html @@ -5,11 +5,14 @@ + + + - GoodBuddy|桌面助手与 AI 编程工具台 + GoodBuddy|免注册、支持信创软硬件的一站式 AI 助手 + diff --git a/sites/language.js b/sites/language.js new file mode 100644 index 0000000..3425fd1 --- /dev/null +++ b/sites/language.js @@ -0,0 +1,49 @@ +(() => { + "use strict"; + + const root = document.documentElement; + const currentLanguage = root.lang.toLowerCase().startsWith("zh") ? "zh" : "en"; + const requestedLanguage = new URLSearchParams(window.location.search).get("lang"); + let savedLanguage = null; + + if (requestedLanguage === "zh" || requestedLanguage === "en") { + savedLanguage = requestedLanguage; + try { + localStorage.setItem("goodbuddy-site-language", requestedLanguage); + } catch { + // The requested language still applies to this navigation. + } + } else { + try { + const storedLanguage = localStorage.getItem("goodbuddy-site-language"); + if (storedLanguage === "zh" || storedLanguage === "en") { + savedLanguage = storedLanguage; + } + } catch { + // Fall back to the browser language when storage is unavailable. + } + } + + const preferredLanguage = + navigator.languages?.[0] ?? navigator.language ?? "en"; + const targetLanguage = + savedLanguage ?? (preferredLanguage.toLowerCase().startsWith("zh") ? "zh" : "en"); + + document.addEventListener("click", (event) => { + const languageLink = + event.target instanceof Element + ? event.target.closest("[data-language-link]") + : null; + if (!(languageLink instanceof HTMLAnchorElement)) { + return; + } + const targetUrl = new URL(languageLink.href, window.location.href); + targetUrl.hash = window.location.hash; + languageLink.href = targetUrl.href; + }); + + if (targetLanguage !== currentLanguage) { + const targetPath = targetLanguage === "zh" ? "./" : "./en.html"; + window.location.replace(`${targetPath}${window.location.hash}`); + } +})(); diff --git a/sites/release-index.js b/sites/release-index.js new file mode 100644 index 0000000..bba5702 --- /dev/null +++ b/sites/release-index.js @@ -0,0 +1,206 @@ +(() => { + "use strict"; + + const mirrorIndexUrl = + "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json"; + const fallbackUrl = + "https://github.com/mesalogo/goodbuddy/releases/latest"; + const maximumIndexBytes = 512 * 1024; + const semVerPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u; + const sha256Pattern = /^[a-f0-9]{64}$/u; + const safeFileNamePattern = /^(?!\.{1,2}$)[^/\\\0]+$/u; + const targetDefinitions = Object.freeze({ + "windows-x64": Object.freeze({ + platform: "windows", + arch: "x64", + formats: Object.freeze(["nsis", "portable"]), + }), + "windows-arm64": Object.freeze({ + platform: "windows", + arch: "arm64", + formats: Object.freeze(["nsis", "portable"]), + }), + "macos-x64": Object.freeze({ + platform: "macos", + arch: "x64", + formats: Object.freeze(["dmg", "zip"]), + }), + "macos-arm64": Object.freeze({ + platform: "macos", + arch: "arm64", + formats: Object.freeze(["dmg", "zip"]), + }), + "linux-x64": Object.freeze({ + platform: "linux", + arch: "x64", + formats: Object.freeze(["AppImage", "deb"]), + }), + "linux-arm64": Object.freeze({ + platform: "linux", + arch: "arm64", + formats: Object.freeze(["AppImage", "deb"]), + }), + }); + const targetKeys = Object.freeze(Object.keys(targetDefinitions)); + + const isRecord = (value) => + value !== null && typeof value === "object" && !Array.isArray(value); + + const hasExactKeys = (value, keys) => { + if (!isRecord(value)) { + return false; + } + const actualKeys = Object.keys(value); + return ( + actualKeys.length === keys.length && + keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); + }; + + const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } + }; + + const canonicalFileName = (version, platform, arch, format) => { + if (platform === "windows" && format === "nsis") { + return `GoodBuddy-${version}-windows-${arch}-setup.exe`; + } + if (platform === "windows" && format === "portable") { + return `GoodBuddy-${version}-windows-${arch}-portable.zip`; + } + + if (platform === "macos") { + return `GoodBuddy-${version}-mac-${arch}.${format}`; + } + + const artifactArch = + arch === "x64" + ? format === "AppImage" + ? "x86_64" + : "amd64" + : arch; + return `GoodBuddy-${version}-linux-${artifactArch}.${format}`; + }; + + const assertExactUrl = (value, expected, label) => { + assert(typeof value === "string" && value.length <= 2_048, `${label} 无效`); + let url; + try { + url = new URL(value); + } catch { + throw new Error(`${label} 无效`); + } + assert( + value === expected && + url.href === expected && + url.protocol === "https:" && + !url.username && + !url.password && + !url.port && + !url.search && + !url.hash, + `${label} 不是受信任的正式地址`, + ); + }; + + const validateReleaseIndex = (index) => { + assert( + hasExactKeys(index, [ + "formatVersion", + "productName", + "version", + "targets", + "checksumUrl", + "fallbackUrl", + ]), + "发布索引结构无效", + ); + assert(index.formatVersion === 1, "发布索引版本无效"); + assert(index.productName === "GoodBuddy", "发布索引产品名称无效"); + assert( + typeof index.version === "string" && index.version.length <= 256, + "发布版本无效", + ); + const parsedVersion = semVerPattern.exec(index.version); + assert(parsedVersion && !parsedVersion[4], "发布索引必须指向稳定 SemVer 版本"); + assert( + hasExactKeys(index.targets, targetKeys), + "发布索引必须包含且仅包含六个平台目标", + ); + + const releaseBase = new URL(`v${index.version}/`, mirrorIndexUrl); + const seenNames = new Set(); + const seenUrls = new Set(); + + for (const key of targetKeys) { + const definition = targetDefinitions[key]; + const target = index.targets[key]; + assert( + hasExactKeys(target, ["platform", "arch", "files"]) && + target.platform === definition.platform && + target.arch === definition.arch, + `发布目标与键不匹配:${key}`, + ); + assert( + hasExactKeys(target.files, definition.formats), + `发布目标文件格式无效:${key}`, + ); + + for (const format of definition.formats) { + const file = target.files[format]; + assert( + hasExactKeys(file, ["name", "size", "sha256", "url"]), + `发布文件结构无效:${key}/${format}`, + ); + assert( + typeof file.name === "string" && + file.name.length >= 1 && + file.name.length <= 255 && + safeFileNamePattern.test(file.name) && + file.name === + canonicalFileName( + index.version, + target.platform, + target.arch, + format, + ), + `发布文件名或扩展名无效:${key}/${format}`, + ); + assert( + Number.isSafeInteger(file.size) && file.size > 0, + `发布文件大小无效:${key}/${format}`, + ); + assert( + typeof file.sha256 === "string" && sha256Pattern.test(file.sha256), + `发布文件校验值无效:${key}/${format}`, + ); + assert(!seenNames.has(file.name), `发布文件名重复:${file.name}`); + seenNames.add(file.name); + + const expectedUrl = new URL( + encodeURIComponent(file.name), + releaseBase, + ).href; + assertExactUrl(file.url, expectedUrl, "发布文件地址"); + assert(!seenUrls.has(file.url), `发布文件地址重复:${file.url}`); + seenUrls.add(file.url); + } + } + + assertExactUrl( + index.checksumUrl, + new URL("SHA256SUMS", releaseBase).href, + "校验清单地址", + ); + assertExactUrl(index.fallbackUrl, fallbackUrl, "GitHub 回退地址"); + return index; + }; + + window.GoodBuddyReleaseIndex = Object.freeze({ + maximumIndexBytes, + validateReleaseIndex, + }); +})(); diff --git a/sites/scripts/app.test.mjs b/sites/scripts/app.test.mjs new file mode 100644 index 0000000..491b5c1 --- /dev/null +++ b/sites/scripts/app.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { JSDOM } from "jsdom"; + +const { test } = process.env.VITEST + ? await import("vitest") + : await import("node:test"); +const source = await readFile(path.resolve("sites/app.js"), "utf8"); + +const createMediaQueries = (window, legacy) => { + const queries = new Map(); + window.matchMedia = (media) => { + if (!queries.has(media)) { + const listeners = new Set(); + const query = { + media, + matches: media === "(max-width: 719px)", + addEventListener: legacy + ? undefined + : (_type, listener) => listeners.add(listener), + addListener: legacy + ? (listener) => listeners.add(listener) + : undefined, + dispatch(matches) { + query.matches = matches; + for (const listener of listeners) { + listener(query); + } + }, + }; + queries.set(media, query); + } + return queries.get(media); + }; + return queries; +}; + +const renderApp = (legacy = false) => { + const dom = new JSDOM( + ` + + + + +
+ + + +
+
+
+
+
+
+
Footer
+ + + `, + { + runScripts: "outside-only", + url: "https://example.test/en.html?lang=en", + }, + ); + const { window } = dom; + const inertState = new WeakMap(); + Object.defineProperty(window.HTMLElement.prototype, "inert", { + configurable: true, + get() { + return inertState.get(this) ?? false; + }, + set(value) { + inertState.set(this, Boolean(value)); + }, + }); + const queries = createMediaQueries(window, legacy); + window.eval(source); + return { dom, queries, window }; +}; + +for (const legacy of [false, true]) { + test( + `mobile menu isolates content and restores focus with ${ + legacy ? "legacy" : "modern" + } media listeners`, + async () => { + const { dom, queries, window } = renderApp(legacy); + const header = window.document.querySelector("[data-site-header]"); + const toggle = window.document.querySelector("[data-menu-toggle]"); + const firstLink = window.document.querySelector("#first-nav-link"); + const main = window.document.querySelector("main"); + const footer = window.document.querySelector("footer"); + const skip = window.document.querySelector("#skip"); + const backdrop = window.document.querySelector("[data-menu-backdrop]"); + + main.inert = true; + toggle.click(); + assert.equal(header.classList.contains("is-menu-open"), true); + assert.equal(toggle.getAttribute("aria-expanded"), "true"); + assert.equal(window.document.activeElement, firstLink); + assert.equal(skip.inert, true); + assert.equal(main.inert, true); + assert.equal(footer.inert, true); + assert.equal(backdrop.inert, false); + assert.equal(backdrop.classList.contains("is-active"), true); + + window.document.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + assert.equal(header.classList.contains("is-menu-open"), false); + assert.equal(toggle.getAttribute("aria-expanded"), "false"); + assert.equal(window.document.activeElement, toggle); + assert.equal(skip.inert, false); + assert.equal(main.inert, true); + assert.equal(footer.inert, false); + assert.equal(backdrop.classList.contains("is-active"), false); + + toggle.click(); + backdrop.click(); + assert.equal(header.classList.contains("is-menu-open"), false); + assert.equal(window.document.activeElement, toggle); + + toggle.click(); + footer.click(); + assert.equal(header.classList.contains("is-menu-open"), false); + assert.equal(window.document.activeElement, toggle); + + toggle.click(); + firstLink.click(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + assert.equal(header.classList.contains("is-menu-open"), false); + assert.equal(window.document.activeElement, toggle); + + toggle.click(); + queries.get("(max-width: 719px)").dispatch(false); + assert.equal(header.classList.contains("is-menu-open"), false); + assert.equal(toggle.getAttribute("aria-expanded"), "false"); + assert.equal(footer.inert, false); + + dom.window.close(); + }, + ); +} diff --git a/sites/scripts/release-index.test.mjs b/sites/scripts/release-index.test.mjs new file mode 100644 index 0000000..754e896 --- /dev/null +++ b/sites/scripts/release-index.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import vm from "node:vm"; + +const { test } = process.env.VITEST + ? await import("vitest") + : await import("node:test"); +const source = await readFile(path.resolve("sites/release-index.js"), "utf8"); +const context = vm.createContext({ URL, window: {} }); +vm.runInContext(source, context, { filename: "release-index.js" }); +const { validateReleaseIndex } = context.window.GoodBuddyReleaseIndex; + +const definitions = [ + ["windows", "x64", ["nsis", "portable"]], + ["windows", "arm64", ["nsis", "portable"]], + ["macos", "x64", ["dmg", "zip"]], + ["macos", "arm64", ["dmg", "zip"]], + ["linux", "x64", ["AppImage", "deb"]], + ["linux", "arm64", ["AppImage", "deb"]], +]; + +const canonicalFileName = (version, platform, arch, format) => { + const names = { + "windows-x64": { + nsis: `GoodBuddy-${version}-windows-x64-setup.exe`, + portable: `GoodBuddy-${version}-windows-x64-portable.zip`, + }, + "windows-arm64": { + nsis: `GoodBuddy-${version}-windows-arm64-setup.exe`, + portable: `GoodBuddy-${version}-windows-arm64-portable.zip`, + }, + "macos-x64": { + dmg: `GoodBuddy-${version}-mac-x64.dmg`, + zip: `GoodBuddy-${version}-mac-x64.zip`, + }, + "macos-arm64": { + dmg: `GoodBuddy-${version}-mac-arm64.dmg`, + zip: `GoodBuddy-${version}-mac-arm64.zip`, + }, + "linux-x64": { + AppImage: `GoodBuddy-${version}-linux-x86_64.AppImage`, + deb: `GoodBuddy-${version}-linux-amd64.deb`, + }, + "linux-arm64": { + AppImage: `GoodBuddy-${version}-linux-arm64.AppImage`, + deb: `GoodBuddy-${version}-linux-arm64.deb`, + }, + }; + return names[`${platform}-${arch}`][format]; +}; + +const validIndex = () => { + const version = "1.2.3"; + const releaseBase = + `https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${version}/`; + const targets = {}; + let fileNumber = 1; + + for (const [platform, arch, formats] of definitions) { + const files = {}; + for (const format of formats) { + const name = canonicalFileName(version, platform, arch, format); + files[format] = { + name, + size: 1_024 * fileNumber, + sha256: fileNumber.toString(16).padStart(64, "0"), + url: new URL(encodeURIComponent(name), releaseBase).href, + }; + fileNumber += 1; + } + targets[`${platform}-${arch}`] = { platform, arch, files }; + } + + return { + formatVersion: 1, + productName: "GoodBuddy", + version, + targets, + checksumUrl: new URL("SHA256SUMS", releaseBase).href, + fallbackUrl: "https://github.com/mesalogo/goodbuddy/releases/latest", + }; +}; + +const expectRejected = (mutate) => { + const index = validIndex(); + mutate(index); + assert.throws(() => validateReleaseIndex(index)); +}; + +test("accepts the canonical stable six-target release index", () => { + const index = validIndex(); + assert.equal(validateReleaseIndex(index), index); +}); + +test("rejects unstable or non-strict versions and extra top-level fields", () => { + for (const version of ["v1.2.3", "01.2.3", "1.2", "1.2.3-rc.1"]) { + expectRejected((index) => { + index.version = version; + }); + } + expectRejected((index) => { + index.unexpected = true; + }); +}); + +test("requires the exact six target keys and matching platform metadata", () => { + expectRejected((index) => { + delete index.targets["linux-arm64"]; + }); + expectRejected((index) => { + index.targets["linux-arm64"].platform = "windows"; + }); + expectRejected((index) => { + index.targets["unexpected-x64"] = index.targets["linux-arm64"]; + }); +}); + +test("requires exact formats, extensions, positive safe sizes, and SHA-256", () => { + expectRejected((index) => { + index.targets["windows-x64"].files.nsis.name = "GoodBuddy-1.2.3.zip"; + }); + expectRejected((index) => { + index.targets["macos-arm64"].files.extra = + index.targets["macos-arm64"].files.zip; + }); + for (const size of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expectRejected((index) => { + index.targets["linux-x64"].files.deb.size = size; + }); + } + expectRejected((index) => { + index.targets["linux-x64"].files.deb.sha256 = "A".repeat(64); + }); +}); + +test("binds every filename to the indexed release version", () => { + expectRejected((index) => { + const file = index.targets["macos-arm64"].files.dmg; + file.name = file.name.replace(index.version, "1.2.2"); + file.url = new URL( + encodeURIComponent(file.name), + `https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${index.version}/`, + ).href; + }); +}); + +test("binds filenames to their target platform and architecture", () => { + expectRejected((index) => { + const file = index.targets["macos-x64"].files.zip; + file.name = file.name.replace("-mac-x64.zip", "-linux-x64.zip"); + file.url = new URL( + encodeURIComponent(file.name), + `https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${index.version}/`, + ).href; + }); + + expectRejected((index) => { + const x64Files = index.targets["linux-x64"].files; + index.targets["linux-x64"].files = + index.targets["linux-arm64"].files; + index.targets["linux-arm64"].files = x64Files; + }); +}); + +test("rejects swapped x64 and arm64 target records", () => { + expectRejected((index) => { + const x64Target = index.targets["windows-x64"]; + index.targets["windows-x64"] = index.targets["windows-arm64"]; + index.targets["windows-arm64"] = x64Target; + }); +}); + +test("rejects duplicate files and any non-canonical release URL", () => { + expectRejected((index) => { + const duplicate = index.targets["windows-x64"].files.nsis; + index.targets["windows-arm64"].files.nsis.name = duplicate.name; + index.targets["windows-arm64"].files.nsis.url = duplicate.url; + }); + + for (const changeUrl of [ + (url) => url.replace("https://", "http://"), + (url) => url.replace("goodbuddy.", "user:pass@goodbuddy."), + (url) => url.replace(".com/", ".com:444/"), + (url) => `${url}?download=1`, + (url) => `${url}#asset`, + (url) => url.replace("/releases/v1.2.3/", "/releases/v9.9.9/"), + (url) => url.replace("GoodBuddy-", "OtherBuddy-"), + ]) { + expectRejected((index) => { + const file = index.targets["linux-arm64"].files.AppImage; + file.url = changeUrl(file.url); + }); + } +}); + +test("requires exact checksum and GitHub fallback URLs", () => { + expectRejected((index) => { + index.checksumUrl += "?raw=1"; + }); + expectRejected((index) => { + index.fallbackUrl = "https://github.com/mesalogo/goodbuddy/releases"; + }); +}); diff --git a/sites/scripts/validate.mjs b/sites/scripts/validate.mjs index 651727b..8a95b9a 100644 --- a/sites/scripts/validate.mjs +++ b/sites/scripts/validate.mjs @@ -7,12 +7,19 @@ const errors = []; const requiredFiles = [ "index.html", + "en.html", "styles.css", "app.js", + "language.js", + "release-index.js", "assets/goodbuddy-light.png", "assets/goodbuddy-dark.png", "assets/linux-plain.svg", "assets/devicon-LICENSE", + "assets/fonts/inter-latin-variable.woff2", + "assets/fonts/inter-OFL.txt", + "scripts/app.test.mjs", + "scripts/release-index.test.mjs", "README.md", ]; @@ -42,34 +49,92 @@ await Promise.all( }), ); -const [html, css, appJs] = await Promise.all([ - readSiteFile("index.html"), - readSiteFile("styles.css"), - readSiteFile("app.js"), -]); +const [html, englishHtml, css, appJs, languageJs, releaseIndexJs, fontLicense] = + await Promise.all([ + readSiteFile("index.html"), + readSiteFile("en.html"), + readSiteFile("styles.css"), + readSiteFile("app.js"), + readSiteFile("language.js"), + readSiteFile("release-index.js"), + readSiteFile("assets/fonts/inter-OFL.txt"), + ]); for (const [relativePath, content] of [ ["index.html", html], + ["en.html", englishHtml], ["styles.css", css], ["app.js", appJs], + ["language.js", languageJs], + ["release-index.js", releaseIndexJs], ]) { report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`); report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`); } report(//.test(html), "页面语言必须是 zh-CN"); +report(//.test(englishHtml), "英文页面语言必须是 en"); report(//.test( html, ), "canonical 地址必须指向 GitHub Pages 正式站点", ); +report( + //.test( + englishHtml, + ), + "英文 canonical 地址必须指向 GitHub Pages 英文站点", +); +for (const [relativePath, content] of [ + ["index.html", html], + ["en.html", englishHtml], +]) { + report( + /hreflang="zh-CN"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"/.test(content), + `${relativePath} 缺少中文 alternate 链接`, + ); + report( + /hreflang="en"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/en\.html"/.test( + content, + ), + `${relativePath} 缺少英文 alternate 链接`, + ); + report( + /<\/script>/.test(content), + `${relativePath} 缺少语言选择脚本`, + ); +} report((html.match(/]/g) ?? []).length === 1, "页面必须且只能包含一个 h1"); +report( + (englishHtml.match(/]/g) ?? []).length === 1, + "英文页面必须且只能包含一个 h1", +); report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接"); +report( + /class="skip-link"\s+href="#main-content"/.test(englishHtml), + "英文页面缺少跳到主要内容链接", +); report(//.test(html), "缺少 main-content 主区域"); +report(//.test(englishHtml), "英文页面缺少 main-content 主区域"); report(/aria-label="主导航"/.test(html), "主导航缺少可访问名称"); +report(/aria-label="Main navigation"/.test(englishHtml), "英文主导航缺少可访问名称"); report(/data-theme-toggle/.test(html), "缺少主题切换控件"); +report(/data-theme-toggle/.test(englishHtml), "英文页面缺少主题切换控件"); +report( + /href="\.\/en\.html\?lang=en"/.test(html), + "中文页面缺少英文语言切换入口", +); +report( + /href="\.\/index\.html\?lang=zh"/.test(englishHtml), + "英文页面缺少中文语言切换入口", +); +report( + /data-language-link/.test(html) && /data-language-link/.test(englishHtml), + "中英文语言切换入口必须标记为保留片段的手动切换", +); report( (html.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5, "品牌位置必须使用官方亮色图标", @@ -78,9 +143,20 @@ report( (html.match(/src="\.\/assets\/goodbuddy-dark\.png"/g) ?? []).length >= 5, "品牌位置必须使用官方深色图标", ); +report( + (englishHtml.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5, + "英文品牌位置必须使用官方亮色图标", +); +report( + (englishHtml.match(/src="\.\/assets\/goodbuddy-dark\.png"/g) ?? []).length >= 5, + "英文品牌位置必须使用官方深色图标", +); report(!/class="brand-mark"/.test(html), "官网不得使用自绘品牌标志"); +report(!/class="brand-mark"/.test(englishHtml), "英文官网不得使用自绘品牌标志"); report(/data-tilt-stage/.test(html), "首屏产品界面缺少倾斜交互区域"); +report(/data-tilt-stage/.test(englishHtml), "英文首屏产品界面缺少倾斜交互区域"); report(/data-tilt-card/.test(html), "首屏产品界面缺少倾斜卡片"); +report(/data-tilt-card/.test(englishHtml), "英文首屏产品界面缺少倾斜卡片"); report(/prefers-reduced-motion:\s*reduce/.test(css), "缺少减少动态效果规则"); report(/\[data-theme="dark"\]/.test(css), "缺少深色主题令牌"); report(/--scene-tilt-x/.test(css), "缺少产品界面横向倾斜变量"); @@ -90,12 +166,90 @@ report( "浮动标签必须跟随产品界面倾斜", ); report(/requestAnimationFrame/.test(appJs), "产品界面倾斜交互必须按帧更新"); +report( + /@font-face[\s\S]*font-family:\s*"Inter Variable"[\s\S]*inter-latin-variable\.woff2/.test( + css, + ), + "官网必须使用本地 Inter Variable Latin 字体", +); +report( + !/@import\s+url|fonts\.(?:googleapis|gstatic)\.com|https?:\/\/[^)"']+\.(?:woff2?|ttf)/iu.test( + css, + ), + "官网字体不得通过远程请求加载", +); +report( + /SIL OPEN FONT LICENSE Version 1\.1/.test(fontLicense), + "Inter 字体必须附带 OFL 1.1 许可证", +); +const fontStats = await stat( + path.join(siteRoot, "assets/fonts/inter-latin-variable.woff2"), +).catch(() => null); +report( + fontStats?.isFile() && fontStats.size >= 20_000 && fontStats.size <= 100_000, + "Inter Latin 字体文件大小应保持在 20 KB 到 100 KB", +); + +const hexToLuminance = (hex) => { + const channels = [1, 3, 5].map( + (offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255, + ); + const linear = channels.map((channel) => + channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +}; + +const contrastRatio = (foreground, background) => { + const foregroundLuminance = hexToLuminance(foreground); + const backgroundLuminance = hexToLuminance(background); + return ( + (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / + (Math.min(foregroundLuminance, backgroundLuminance) + 0.05) + ); +}; + +const lightThemeBlock = css.match(/:root\s*\{([\s\S]*?)\n\}/)?.[1] ?? ""; +const getLightToken = (name) => + lightThemeBlock.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1]; +const mutedColor = getLightToken("text-muted"); +const controlBorder = getLightToken("border-control"); +const raisedSurface = getLightToken("surface-raised"); +const subtleSurface = getLightToken("surface-subtle"); +const canvasSurface = getLightToken("surface-canvas"); +for (const [label, foreground, background, minimum] of [ + ["浅色弱文本/画布", mutedColor, canvasSurface, 4.5], + ["浅色弱文本/卡片", mutedColor, raisedSurface, 4.5], + ["浅色弱文本/次级表面", mutedColor, subtleSurface, 4.5], + ["浅色控件边框/卡片", controlBorder, raisedSurface, 3], + ["浅色控件边框/次级表面", controlBorder, subtleSurface, 3], +]) { + report( + foreground && + background && + contrastRatio(foreground, background) >= minimum, + `${label} 对比度必须至少达到 ${minimum}:1`, + ); +} +for (const selector of ["language-link", "icon-button", "button--quiet"]) { + report( + new RegExp( + `\\.${selector}\\s*\\{[^}]*border(?:-color)?:\\s*(?:1px solid )?var\\(--border-control\\)`, + ).test(css), + `${selector} 必须使用达到 3:1 的控件边框`, + ); +} +report(/@media\s*\(forced-colors:\s*active\)/.test(css), "缺少强制颜色模式适配"); for (const breakpoint of ["1199px", "959px", "719px"]) { report(css.includes(`max-width: ${breakpoint}`), `缺少 ${breakpoint} 响应式断点`); } const requiredCopy = [ + "免注册", + "支持信创软硬件的一站式 AI 助手", "桌面助手", "AI 编程工具台", "Windows、macOS、Linux", @@ -116,11 +270,37 @@ for (const copy of requiredCopy) { report(html.includes(copy), `缺少准确文案:${copy}`); } -const htmlWithoutSvg = html.replace(//g, ""); -report( - !/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg), - "官网正文不得写入需要随发布更新的具体版本号", -); +const requiredEnglishCopy = [ + "No account required.", + "Your all-in-one", + "AI assistant.", + "Windows, macOS, and Linux", + "Unified Agent Runtime", + "Direct models", + "OpenCode", + "Continue", + "DeepSeek Harness", + "Download from GitHub", +]; + +for (const copy of requiredEnglishCopy) { + report(englishHtml.includes(copy), `英文页面缺少准确文案:${copy}`); +} + +for (const forbiddenCopy of ["信创", "国产", "统信 UOS", "银河麒麟", "海光", "兆芯", "鲲鹏", "飞腾"]) { + report(!englishHtml.includes(forbiddenCopy), `英文页面不得包含中文信创文案:${forbiddenCopy}`); +} + +for (const [relativePath, content] of [ + ["index.html", html], + ["en.html", englishHtml], +]) { + const contentWithoutSvg = content.replace(//g, ""); + report( + !/\bv?\d+\.\d+\.\d+\b/.test(contentWithoutSvg), + `${relativePath} 正文不得写入需要随发布更新的具体版本号`, + ); +} const releaseLinks = [ ...html.matchAll(/]*data-release-link)[^>]*>/g), @@ -146,7 +326,34 @@ report( (html.match(/data-download-format/g) ?? []).length === 3, "每个平台必须提供安装包类型选择器", ); -report(/data-release-status/.test(html), "下载区缺少发布源状态"); +report( + !/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`), + "官网不得显示下载源状态提示", +); +const englishReleaseLinks = [ + ...englishHtml.matchAll( + /]*href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest")[^>]*>/g, + ), +].map((match) => match[0]); +report(englishReleaseLinks.length === 3, "英文页面必须包含三个 GitHub Release 下载入口"); +for (const link of englishReleaseLinks) { + report(/target="_blank"/.test(link), `英文下载入口必须在新窗口打开:${link}`); + report(/rel="[^"]*noreferrer[^"]*"/.test(link), `英文下载入口缺少 noreferrer:${link}`); +} +report( + !/data-download-card|data-download-meta|data-release-link/.test(englishHtml), + "英文下载入口必须保持为直接 GitHub Release 链接", +); +report( + /<\/script>\s*<\/script>/.test( + html, + ), + "中文页面必须在交互脚本前加载发布索引校验器", +); +report( + !/release-index\.js/.test(englishHtml), + "英文页面不得加载动态发布索引校验器", +); report( appJs.includes( "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json", @@ -158,52 +365,248 @@ report( "官网必须保留 GitHub Release 回退地址", ); report(/credentials:\s*"omit"/.test(appJs), "OSS 发布索引请求不得携带凭据"); -report(/isTrustedReleaseUrl/.test(appJs), "OSS 下载链接缺少来源校验"); - -const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); -const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); -report(duplicateIds.length === 0, `存在重复 id:${[...new Set(duplicateIds)].join(", ")}`); - -const attributes = [...html.matchAll(/\s(?:href|src)="([^"]+)"/g)].map((match) => match[1]); -const fragmentLinks = attributes.filter((value) => value.startsWith("#") && value.length > 1); - -for (const fragment of fragmentLinks) { - report(ids.includes(fragment.slice(1)), `页内链接目标不存在:${fragment}`); +report(/redirect:\s*"error"/.test(appJs), "OSS 发布索引请求不得跟随重定向"); +report(/referrerPolicy:\s*"no-referrer"/.test(appJs), "OSS 发布索引请求必须禁用来源信息"); +report(/maximumIndexBytes/.test(appJs), "OSS 发布索引响应缺少大小上限"); +report(/response\.body\.getReader\(\)/.test(appJs), "OSS 发布索引响应必须在读取时限制大小"); +report(/AbortController/.test(appJs), "OSS 发布索引请求必须设置超时取消"); +report( + /validateReleaseIndex\(payload\)/.test(appJs), + "动态下载链接必须先通过完整发布索引校验", +); +report( + /const setFallbackDownloads[\s\S]*catch\s*\{[\s\S]*setFallbackDownloads\(\)/.test( + appJs, + ), + "发布索引任一错误必须让全部下载入口回退 GitHub", +); +report( + /replaceChildren\(document\.createTextNode\(visibleText\)\)[\s\S]*append\(newWindowNotice\)/.test( + appJs, + ), + "动态更新下载链接时必须保留新窗口的屏幕阅读器提示", +); +report(/if\s*\(!isEnglish\)\s*\{\s*void loadRelease\(\)/.test(appJs), "英文页面不得请求 OSS 发布索引"); +for (const listenerRule of [ + 'typeof query.addEventListener === "function"', + 'typeof query.addListener === "function"', + "listenMediaQuery(systemTheme", + "listenMediaQuery(finePointer", + "listenMediaQuery(reducedMotion", + "listenMediaQuery(mobileMenu", +]) { + report(appJs.includes(listenerRule), `媒体查询监听缺少兼容规则:${listenerRule}`); } - -const localAssets = attributes.filter( - (value) => - !value.startsWith("#") && - !value.startsWith("https://") && - !value.startsWith("http://") && - !value.startsWith("mailto:") && - !value.startsWith("data:"), +for (const menuRule of [ + "isolatedMenuContent = new Map()", + "element === menuBackdrop", + "element.inert = true", + "element.inert = wasInert", + 'navigation?.querySelector("a")?.focus()', + "closeMenu({ restoreFocus: false })", + 'menuBackdrop?.addEventListener("click", () => closeMenu())', +]) { + report(appJs.includes(menuRule), `移动导航隔离或焦点管理缺少规则:${menuRule}`); +} +report( + /const semVerPattern[\s\S]*const sha256Pattern[\s\S]*const targetDefinitions/.test( + releaseIndexJs, + ), + "发布索引校验器缺少 SemVer、SHA-256 或目标定义", +); +for (const rule of [ + "windows-x64", + "windows-arm64", + "macos-x64", + "macos-arm64", + "linux-x64", + "linux-arm64", + "SHA256SUMS", + "encodeURIComponent(file.name)", + "!url.username", + "!url.password", + "!url.port", + "!url.search", + "!url.hash", + "canonicalFileName", + "GoodBuddy-${version}-windows-${arch}-setup.exe", + "GoodBuddy-${version}-windows-${arch}-portable.zip", + "GoodBuddy-${version}-mac-${arch}.${format}", + '"x86_64"', + '"amd64"', +]) { + report(releaseIndexJs.includes(rule), `发布索引校验器缺少规则:${rule}`); +} +report( + /navigator\.languages\?\.\[0\]/.test(languageJs), + "语言选择必须读取浏览器首选语言", +); +report( + /goodbuddy-site-language/.test(languageJs), + "语言选择必须记住用户的手动切换", +); +report( + /window\.location\.replace/.test(languageJs), + "语言选择缺少自动页面切换", +); +report( + /targetUrl\.hash\s*=\s*window\.location\.hash/.test(languageJs), + "手动切换语言必须保留当前页面片段", ); -for (const asset of localAssets) { - const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, ""); - try { - const assetStats = await stat(path.join(siteRoot, cleanAsset)); - report(assetStats.isFile(), `本地资源不是文件:${asset}`); - } catch { - errors.push(`本地资源不存在:${asset}`); +for (const [relativePath, content] of [ + ["index.html", html], + ["en.html", englishHtml], +]) { + const menuButton = content.match( + /]*data-menu-toggle)[^>]*>/, + )?.[0]; + const controlsId = menuButton?.match(/aria-controls="([^"]+)"/)?.[1]; + report( + Boolean(controlsId) && content.includes(`id="${controlsId}"`), + `${relativePath} 移动导航按钮必须关联现有导航区域`, + ); + report( + menuButton?.includes('aria-expanded="false"'), + `${relativePath} 移动导航必须声明初始折叠状态`, + ); + report( + /<\/div>/.test( + content, + ), + `${relativePath} 移动导航缺少页外点击关闭层`, + ); +} +report( + /@media\s*\(max-width:\s*719px\)[\s\S]*\.menu-toggle\s*\{[\s\S]*display:\s*inline-grid/.test( + css, + ) && + /@media\s*\(max-width:\s*719px\)[\s\S]*\.site-header\.is-menu-open \.site-navigation\s*\{[\s\S]*display:\s*flex/.test( + css, + ) && + /@media\s*\(max-width:\s*719px\)[\s\S]*\.menu-backdrop\.is-active\s*\{[\s\S]*display:\s*block/.test( + css, + ), + "移动断点必须显示菜单按钮、页外关闭层并支持展开导航", +); + +const expectedDownloadOptions = { + windows: { + arches: ["x64", "arm64"], + formats: ["nsis", "portable"], + }, + macos: { + arches: ["arm64", "x64"], + formats: ["dmg", "zip"], + }, + linux: { + arches: ["x64", "arm64"], + formats: ["AppImage", "deb"], + }, +}; +for (const [platform, expected] of Object.entries(expectedDownloadOptions)) { + const card = html.match( + new RegExp( + `data-download-card="${platform}"([\\s\\S]*?)<\\/article>`, + ), + )?.[1]; + const archOptions = [ + ...(card ?? "").matchAll(/ match[1]); + report( + expected.arches.every((arch, index) => archOptions[index] === arch) && + expected.formats.every( + (format, index) => archOptions[index + expected.arches.length] === format, + ) && + archOptions.length === expected.arches.length + expected.formats.length, + `${platform} 下载控件的架构或格式选项无效`, + ); + report( + (card?.match(/]*aria-label="[^"]+"/g) ?? []).length === 2, + `${platform} 下载选择器必须有可访问名称`, + ); +} + +let totalIds = 0; +let totalLocalAssets = 0; +for (const [relativePath, content] of [ + ["index.html", html], + ["en.html", englishHtml], +]) { + const ids = [...content.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); + totalIds += ids.length; + const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); + report( + duplicateIds.length === 0, + `${relativePath} 存在重复 id:${[...new Set(duplicateIds)].join(", ")}`, + ); + + const attributes = [...content.matchAll(/\s(?:href|src)="([^"]+)"/g)].map( + (match) => match[1], + ); + const fragmentLinks = attributes.filter( + (value) => value.startsWith("#") && value.length > 1, + ); + for (const fragment of fragmentLinks) { + report(ids.includes(fragment.slice(1)), `${relativePath} 页内链接目标不存在:${fragment}`); + } + + const localAssets = attributes.filter( + (value) => + !value.startsWith("#") && + !value.startsWith("https://") && + !value.startsWith("http://") && + !value.startsWith("mailto:") && + !value.startsWith("data:"), + ); + totalLocalAssets += localAssets.length; + for (const asset of localAssets) { + const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, ""); + try { + const assetStats = await stat(path.join(siteRoot, cleanAsset)); + report(assetStats.isFile(), `${relativePath} 本地资源不是文件:${asset}`); + } catch { + errors.push(`${relativePath} 本地资源不存在:${asset}`); + } + } + + const externalBlankLinks = [ + ...content.matchAll(/]*target="_blank")[^>]*>/g), + ].map((match) => match[0]); + for (const link of externalBlankLinks) { + report( + /rel="[^"]*noreferrer[^"]*"/.test(link), + `${relativePath} 新窗口链接缺少 noreferrer:${link}`, + ); } } -const externalBlankLinks = [ - ...html.matchAll(/]*target="_blank")[^>]*>/g), -].map((match) => match[0]); - -for (const link of externalBlankLinks) { - report(/rel="[^"]*noreferrer[^"]*"/.test(link), `新窗口链接缺少 noreferrer:${link}`); +const cssAssets = [...css.matchAll(/url\(["']?([^"')]+)["']?\)/g)].map( + (match) => match[1], +); +for (const asset of cssAssets) { + if (/^(?:data:|https?:)/u.test(asset)) { + continue; + } + const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, ""); + try { + const assetStats = await stat(path.join(siteRoot, cleanAsset)); + report(assetStats.isFile(), `CSS 本地资源不是文件:${asset}`); + } catch { + errors.push(`CSS 本地资源不存在:${asset}`); + } } report( - !/]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html), + !/]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test( + `${html}\n${englishHtml}`, + ), "具体安装资产链接应由 OSS 发布索引动态提供", ); report( - !/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(html), + !/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test( + `${html}\n${englishHtml}`, + ), "静态官网不得引入额外框架资源", ); @@ -215,6 +618,6 @@ if (errors.length > 0) { process.exitCode = 1; } else { console.log( - `官网静态检查通过:${requiredFiles.length} 个必需文件,${ids.length} 个唯一 id,${localAssets.length} 个本地资源引用。`, + `官网静态检查通过:${requiredFiles.length} 个必需文件,${totalIds} 个唯一 id,${totalLocalAssets} 个本地资源引用。`, ); } diff --git a/sites/styles.css b/sites/styles.css index ee11353..ef4495f 100644 --- a/sites/styles.css +++ b/sites/styles.css @@ -1,3 +1,15 @@ +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("./assets/fonts/inter-latin-variable.woff2") format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} + :root { color-scheme: light; --surface-canvas: #f6f8fb; @@ -8,11 +20,11 @@ --surface-overlay: rgba(255, 255, 255, 0.82); --text-primary: #10213a; --text-secondary: #4f6178; - --text-muted: #738198; + --text-muted: #5b6c82; --text-on-accent: #ffffff; --text-on-inverse: #f7faff; --border-default: #d6dee9; - --border-control: #bcc8d7; + --border-control: #7c8b9e; --border-subtle: #e6ebf2; --accent: #0877e8; --accent-hover: #0567ca; @@ -59,8 +71,9 @@ --z-header: 20; --z-menu: 30; font-family: - Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", - "PingFang SC", "Microsoft YaHei", sans-serif; + "Inter Variable", "PingFang SC", "Microsoft YaHei UI", + "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", system-ui, + sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; } @@ -327,13 +340,36 @@ p { gap: var(--space-2); } +.language-link { + display: inline-grid; + min-width: 40px; + height: 40px; + padding-inline: var(--space-2); + place-items: center; + border: 1px solid var(--border-control); + border-radius: var(--radius-control); + background: var(--surface-raised); + color: var(--text-primary); + font-size: 0.75rem; + font-weight: 750; + text-decoration: none; + transition: + border-color var(--motion-fast) ease-out, + background-color var(--motion-fast) ease-out; +} + +.language-link:hover { + border-color: var(--accent-selected); + background: var(--accent-subtle); +} + .icon-button { display: inline-grid; width: 40px; height: 40px; padding: 0; place-items: center; - border: 1px solid var(--border-default); + border: 1px solid var(--border-control); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); @@ -379,6 +415,10 @@ p { display: none; } +.menu-backdrop { + display: none; +} + .button { display: inline-flex; min-height: 44px; @@ -430,7 +470,7 @@ p { .button--quiet { min-height: 40px; padding: var(--space-2) var(--space-4); - border-color: var(--border-default); + border-color: var(--border-control); background: var(--surface-raised); color: var(--text-primary); } @@ -511,7 +551,7 @@ p { .hero h1 { max-width: 760px; margin-bottom: var(--space-6); - font-size: var(--font-page-title); + font-size: clamp(2.45rem, 4vw, 3.5rem); letter-spacing: -0.065em; } @@ -1351,28 +1391,6 @@ p { text-align: left; } -.download-release-status { - padding: var(--space-3) var(--space-4); - margin-top: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-control); - background: var(--surface-subtle); - color: var(--text-secondary); - font-size: 0.75rem; - text-align: center; -} - -.download-release-status.is-ready { - border-color: color-mix(in srgb, var(--success) 34%, var(--border-default)); - background: var(--success-subtle); - color: var(--success); -} - -.download-release-status.is-fallback { - border-color: color-mix(in srgb, var(--warning) 34%, var(--border-default)); - color: var(--text-secondary); -} - .domestic-support { display: grid; grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.1fr); @@ -1724,6 +1742,8 @@ p { } .header-inner { + position: relative; + z-index: 1; grid-template-columns: auto 1fr auto; min-height: 64px; } @@ -1763,6 +1783,14 @@ p { box-shadow: var(--shadow-dialog); } + .menu-backdrop.is-active { + position: fixed; + z-index: calc(var(--z-header) - 1); + inset: 64px 0 0; + display: block; + background: transparent; + } + .site-header.is-menu-open .site-navigation { display: flex; } @@ -1972,3 +2000,78 @@ p { transform: none !important; } } + +@media (forced-colors: active) { + :root, + [data-theme="dark"] { + --surface-canvas: Canvas; + --surface-raised: Canvas; + --surface-subtle: Canvas; + --surface-muted: Canvas; + --surface-inverse: Canvas; + --surface-overlay: Canvas; + --text-primary: CanvasText; + --text-secondary: CanvasText; + --text-muted: CanvasText; + --text-on-accent: HighlightText; + --text-on-inverse: CanvasText; + --border-default: ButtonBorder; + --border-control: ButtonText; + --border-subtle: ButtonBorder; + --accent: LinkText; + --accent-hover: LinkText; + --accent-solid: Highlight; + --accent-solid-hover: Highlight; + --accent-selected: Highlight; + --accent-subtle: Canvas; + --accent-cyan: LinkText; + --accent-mint: LinkText; + --success: CanvasText; + --success-subtle: Canvas; + --warning: CanvasText; + --danger: CanvasText; + --shadow-card: none; + --shadow-dialog: none; + --shadow-button: none; + } + + .site-header.is-scrolled, + .site-header.is-menu-open, + .site-navigation, + .language-link, + .icon-button, + .button, + .app-window, + .floating-card, + .feature-card, + .download-card, + .download-options select, + .domestic-support { + border-color: ButtonBorder; + } + + .site-navigation a[aria-current="true"], + .button--primary, + .message--user, + .composer-actions b { + background: Highlight; + color: HighlightText; + forced-color-adjust: none; + } + + .hero h1 span { + background: none; + color: LinkText; + } + + .stage-glow, + .hero::before, + .product-stage::before, + .app-window::after { + display: none; + } + + :focus-visible { + outline-color: Highlight; + } +} diff --git a/src/main/agent/child-process-termination.test.ts b/src/main/agent/child-process-termination.test.ts new file mode 100644 index 0000000..cbab2d7 --- /dev/null +++ b/src/main/agent/child-process-termination.test.ts @@ -0,0 +1,195 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + requestProcessTreeTermination, + terminateProcessTreeAndWait, + waitForProcessExit, + type WaitableProcessTreeChild +} from './child-process-termination' + +function fakeChild( + pid = 42 +): WaitableProcessTreeChild & EventEmitter { + const child = + new EventEmitter() as WaitableProcessTreeChild & EventEmitter + child.exitCode = null + child.pid = pid + child.kill = vi.fn() + return child +} + +describe('child process tree termination', () => { + it('uses taskkill /T /F on Windows and bounds both exit waits', async () => { + vi.useFakeTimers() + try { + const child = fakeChild(314) + const killer = fakeChild(315) + killer.unref = vi.fn() + const spawnMock = vi.fn(() => killer) + + const termination = terminateProcessTreeAndWait(child, { + platform: 'win32', + spawn: spawnMock, + waitMs: 25 + }) + await vi.advanceTimersByTimeAsync(25) + await vi.advanceTimersByTimeAsync(25) + await expect(termination).resolves.toBeUndefined() + + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill.exe', + ['/PID', '314', '/T', '/F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true + } + ) + expect(killer.unref).toHaveBeenCalledOnce() + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + } finally { + vi.useRealTimers() + } + }) + + it('falls back to direct termination when Windows taskkill fails', async () => { + const child = fakeChild(314) + const killer = fakeChild(315) + const spawnMock = vi.fn(() => killer) + const termination = terminateProcessTreeAndWait(child, { + platform: 'win32', + spawn: spawnMock, + waitMs: 1_000 + }) + + killer.exitCode = 1 + killer.emit('close', 1, null) + await Promise.resolve() + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + child.exitCode = 0 + child.emit('close', 0, null) + + await expect(termination).resolves.toBeUndefined() + }) + + it('terminates a detached POSIX process group with the requested signal', () => { + const child = fakeChild(2718) + const killProcess = vi.fn() + + requestProcessTreeTermination(child, { + platform: 'linux', + processGroup: true, + signal: 'SIGKILL', + killProcess + }) + + expect(killProcess).toHaveBeenCalledWith(-2718, 'SIGKILL') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('falls back to the direct child when POSIX group termination fails', () => { + const child = fakeChild(2718) + + requestProcessTreeTermination(child, { + platform: 'linux', + processGroup: true, + killProcess: vi.fn(() => { + throw new Error('not a group leader') + }) + }) + + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + }) + + it('resolves exit waiting immediately on close', async () => { + const child = fakeChild() + const waiting = waitForProcessExit(child, 1_000) + child.emit('close', 0, null) + await expect(waiting).resolves.toBeUndefined() + }) + + it('does not terminate a child already marked as killed', () => { + const child = fakeChild() + child.killed = true + const spawnMock = vi.fn() + const killProcess = vi.fn() + + expect( + requestProcessTreeTermination(child, { + platform: 'win32', + spawn: spawnMock, + killProcess + }) + ).toBeUndefined() + expect(spawnMock).not.toHaveBeenCalled() + expect(killProcess).not.toHaveBeenCalled() + expect(child.kill).not.toHaveBeenCalled() + }) + + it('supports utility-process handles without an exitCode', () => { + const child = { + killed: false, + pid: 99, + kill: vi.fn() + } + const killer = fakeChild(100) + const spawnMock = vi.fn(() => killer) + + expect( + requestProcessTreeTermination(child, { + platform: 'win32', + spawn: spawnMock + }) + ).toBe(killer) + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill.exe', + ['/PID', '99', '/T', '/F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true + } + ) + }) + + it('falls back asynchronously for a synchronous Windows caller', async () => { + vi.useFakeTimers() + try { + const child = { + pid: 99, + kill: vi.fn() + } + const killer = fakeChild(100) + + requestProcessTreeTermination(child, { + platform: 'win32', + spawn: vi.fn(() => killer), + signal: 'SIGKILL', + waitMs: 25 + }) + + await vi.advanceTimersByTimeAsync(25) + expect(child.kill).toHaveBeenCalledOnce() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + } finally { + vi.useRealTimers() + } + }) + + it('does not directly kill after successful Windows tree termination', () => { + const child = { + pid: 99, + kill: vi.fn() + } + const killer = fakeChild(100) + + requestProcessTreeTermination(child, { + platform: 'win32', + spawn: vi.fn(() => killer) + }) + killer.exitCode = 0 + killer.emit('close', 0, null) + + expect(child.kill).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent/child-process-termination.ts b/src/main/agent/child-process-termination.ts new file mode 100644 index 0000000..3642c28 --- /dev/null +++ b/src/main/agent/child-process-termination.ts @@ -0,0 +1,193 @@ +import spawn from 'cross-spawn' + +export type ProcessTreeChild = { + exitCode?: number | null + killed?: boolean + pid?: number + kill: (signal?: NodeJS.Signals) => unknown + unref?: () => unknown +} + +export type WaitableProcessTreeChild = ProcessTreeChild & { + exitCode: number | null + once: ( + event: 'close' | 'error', + listener: (...args: unknown[]) => void + ) => unknown + removeListener?: ( + event: 'close' | 'error', + listener: (...args: unknown[]) => void + ) => unknown +} + +export type ProcessTreeSpawn = ( + command: string, + args: string[], + options: { + shell: false + stdio: 'ignore' + windowsHide: true + } +) => WaitableProcessTreeChild + +export type ProcessGroupKill = ( + pid: number, + signal: NodeJS.Signals +) => unknown + +export type ProcessTreeTerminationOptions = { + platform?: NodeJS.Platform + spawn?: ProcessTreeSpawn + killProcess?: ProcessGroupKill + processGroup?: boolean + signal?: NodeJS.Signals + waitMs?: number +} + +const DEFAULT_EXIT_WAIT_MS = 2_000 + +type ProcessExitResult = 'closed' | 'error' | 'timeout' + +function monitorWindowsKiller( + killer: WaitableProcessTreeChild, + child: ProcessTreeChild, + signal: NodeJS.Signals, + waitMs: number +): void { + let settled = false + const fallback = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + killer.removeListener?.('close', onClose) + killer.removeListener?.('error', onError) + if ( + !child.killed && + (child.exitCode === undefined || child.exitCode === null) + ) { + child.kill(signal) + } + } + const onClose = (): void => { + if (killer.exitCode === 0) { + settled = true + clearTimeout(timer) + killer.removeListener?.('error', onError) + return + } + fallback() + } + const onError = (): void => fallback() + const timer = setTimeout(fallback, waitMs) + timer.unref?.() + killer.once('close', onClose) + killer.once('error', onError) +} + +function waitForProcessExitResult( + child: WaitableProcessTreeChild, + waitMs: number +): Promise { + if (child.exitCode !== null) { + return Promise.resolve('closed') + } + return new Promise((resolve) => { + const finish = (result: ProcessExitResult): void => { + clearTimeout(timer) + child.removeListener?.('close', onClose) + child.removeListener?.('error', onError) + resolve(result) + } + const onClose = (): void => finish('closed') + const onError = (): void => finish('error') + const timer = setTimeout( + () => finish('timeout'), + waitMs + ) + timer.unref?.() + child.once('close', onClose) + child.once('error', onError) + }) +} + +export function waitForProcessExit( + child: WaitableProcessTreeChild, + waitMs = DEFAULT_EXIT_WAIT_MS +): Promise { + return waitForProcessExitResult(child, waitMs).then(() => undefined) +} + +export function requestProcessTreeTermination( + child: ProcessTreeChild, + options: ProcessTreeTerminationOptions = {} +): WaitableProcessTreeChild | undefined { + if ( + (child.exitCode !== undefined && child.exitCode !== null) || + child.killed + ) { + return undefined + } + const platform = options.platform ?? process.platform + const signal = options.signal ?? 'SIGTERM' + if (platform === 'win32' && child.pid) { + try { + const killer = (options.spawn ?? spawn)( + 'taskkill.exe', + ['/PID', String(child.pid), '/T', '/F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true + } + ) + killer.unref?.() + monitorWindowsKiller( + killer, + child, + signal, + options.waitMs ?? DEFAULT_EXIT_WAIT_MS + ) + return killer + } catch { + child.kill(signal) + return undefined + } + } + if (options.processGroup && child.pid) { + try { + ;(options.killProcess ?? process.kill)(-child.pid, signal) + if (child.exitCode === null) { + child.kill(signal) + } + return undefined + } catch { + // The child may not be a process-group leader. Fall back to the + // direct handle so cleanup is never weakened by that assumption. + } + } + child.kill(signal) + return undefined +} + +export async function terminateProcessTreeAndWait( + child: WaitableProcessTreeChild, + options: ProcessTreeTerminationOptions = {} +): Promise { + if (child.exitCode !== null) { + return + } + const exited = waitForProcessExit( + child, + options.waitMs ?? DEFAULT_EXIT_WAIT_MS + ) + const killer = requestProcessTreeTermination(child, options) + if (killer) { + await waitForProcessExitResult( + killer, + options.waitMs ?? DEFAULT_EXIT_WAIT_MS + ) + } + await exited +} diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 2184c55..1008975 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -1124,6 +1124,263 @@ describe('ContinueHostAdapter', () => { ).rejects.toThrow('流式事件超过安全限制') }) + it.each([ + { + label: 'event count', + limits: { + maximumStreamEvents: 1, + maximumStreamEventBytes: 10_000 + } + }, + { + label: 'event bytes', + limits: { + maximumStreamEvents: 10, + maximumStreamEventBytes: 60 + } + } + ])( + 'enforces cumulative streamed $label across state polls', + async ({ limits }) => { + const distribution = await createDistribution() + let stateRequests = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith('/state')) { + stateRequests += 1 + return Response.json({ + session: { history: [] }, + isProcessing: stateRequests > 1, + messageQueueLength: 0, + pendingPermission: null, + goodbuddyEvents: + stateRequests > 1 + ? [{ type: 'text', delta: '1234567890' }] + : [] + }) + } + return Response.json({}) + }) + ) + const forwarded: unknown[] = [] + const adapter = new ContinueHostAdapter( + { + binaryPath: distribution.entryPath, + configPath: '', + workspace: process.cwd(), + cacheRoot: distribution.cacheRoot, + trustedBundleHashes: [distribution.sourceHash], + launchHost: () => ({ + exitCode: null, + killed: false, + stderr: null, + once: () => undefined, + kill: () => true + }), + modelProfile: { + id: randomUUID(), + name: 'Local model', + baseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none' + } + }, + { + ...limits, + maximumToolCalls: 100, + terminateProcessTree: vi.fn().mockResolvedValue(undefined) + } + ) + + await expect( + adapter.run( + 'hello', + new AbortController().signal, + async () => 'deny', + { + onEvent: (event) => { + forwarded.push(event) + } + } + ) + ).rejects.toThrow('流式事件超过安全限制') + expect(forwarded).toEqual([ + { type: 'text', delta: '1234567890' } + ]) + } + ) + + it('enforces cumulative unique tool calls before forwarding a later batch', async () => { + const distribution = await createDistribution() + let stateRequests = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith('/state')) { + stateRequests += 1 + return Response.json({ + session: { history: [] }, + isProcessing: stateRequests > 1, + messageQueueLength: 0, + pendingPermission: null, + goodbuddyEvents: + stateRequests > 1 + ? [ + { + type: 'tool', + callId: `call-${stateRequests}`, + name: 'Bash', + state: 'running' + } + ] + : [] + }) + } + return Response.json({}) + }) + ) + const forwarded: unknown[] = [] + const adapter = new ContinueHostAdapter( + { + binaryPath: distribution.entryPath, + configPath: '', + workspace: process.cwd(), + cacheRoot: distribution.cacheRoot, + trustedBundleHashes: [distribution.sourceHash], + launchHost: () => ({ + exitCode: null, + killed: false, + stderr: null, + once: () => undefined, + kill: () => true + }), + modelProfile: { + id: randomUUID(), + name: 'Local model', + baseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none' + } + }, + { + maximumStreamEvents: 10, + maximumStreamEventBytes: 10_000, + maximumToolCalls: 1, + terminateProcessTree: vi.fn().mockResolvedValue(undefined) + } + ) + + await expect( + adapter.run( + 'hello', + new AbortController().signal, + async () => 'deny', + { + onEvent: (event) => { + forwarded.push(event) + } + } + ) + ).rejects.toThrow('工具调用超过 100 个') + expect(forwarded).toHaveLength(1) + expect(forwarded[0]).toMatchObject({ + type: 'tool', + tool: { callId: 'call-2' } + }) + }) + + it('awaits bounded process cleanup before deleting the run directory', async () => { + const distribution = await createDistribution() + let globalDirectory = '' + let stateRequests = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith('/state')) { + stateRequests += 1 + return Response.json({ + session: { + history: + stateRequests === 1 + ? [] + : [ + { + message: { + role: 'assistant', + content: 'CLEANUP_OK' + } + } + ] + }, + isProcessing: false, + messageQueueLength: 0, + pendingPermission: null + }) + } + return Response.json({}) + }) + ) + let releaseTermination!: () => void + const terminateProcessTree = vi.fn( + () => + new Promise((resolve) => { + releaseTermination = resolve + }) + ) + const adapter = new ContinueHostAdapter( + { + binaryPath: distribution.entryPath, + configPath: '', + workspace: process.cwd(), + cacheRoot: distribution.cacheRoot, + trustedBundleHashes: [distribution.sourceHash], + launchHost: (_entry, _args, options) => { + globalDirectory = + options.env.CONTINUE_GLOBAL_DIR ?? '' + return { + exitCode: null, + killed: false, + stderr: null, + once: () => undefined, + kill: () => true + } + }, + modelProfile: { + id: randomUUID(), + name: 'Local model', + baseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none' + } + }, + { + maximumStreamEvents: 10, + maximumStreamEventBytes: 10_000, + maximumToolCalls: 10, + terminateProcessTree + } + ) + + const run = adapter.run( + 'hello', + new AbortController().signal, + async () => 'deny' + ) + await vi.waitFor(() => + expect(terminateProcessTree).toHaveBeenCalledOnce() + ) + expect(globalDirectory).toBeTruthy() + expect(existsSync(globalDirectory)).toBe(true) + + releaseTermination() + await expect(run).resolves.toEqual({ text: 'CLEANUP_OK' }) + expect(existsSync(globalDirectory)).toBe(false) + }) + it('uses auto mode and returns audit metadata for agent tools', async () => { const distribution = await createDistribution() let launchArgs: string[] = [] diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 388431c..533dd12 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -50,6 +50,7 @@ import { stageRuntimeSkillPackages } from './runtime-skill-packages' import { readBoundedResponseText } from './bounded-response' import { scopedReadToolNames } from '../../shared/scoped-data-tools' import { readBoundedFile } from '../workspace-file-access' +import { terminateProcessTreeAndWait } from './child-process-termination' const supportedVersion = '1.5.47' const supportedBundleHashes = new Set([ @@ -65,6 +66,7 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts const maximumStreamEvents = 5_000 const maximumStreamEventBytes = 2 * 1024 * 1024 +const maximumToolCalls = 100 const maximumExecutionMilliseconds = 10 * 60_000 const knowledgeMcpName = 'goodbuddy-knowledge' const customMcpName = 'goodbuddy-custom-mcp' @@ -239,6 +241,13 @@ export type ContinueHostAdapterOptions = { skillPackages?: RuntimeSkillPackage[] } +export type ContinueHostAdapterDependencies = { + terminateProcessTree: typeof terminateProcessTreeAndWait + maximumStreamEvents: number + maximumStreamEventBytes: number + maximumToolCalls: number +} + export type ContinueHostRunOptions = { workMode?: 'ask' | 'execute' images?: AgentImage[] @@ -504,8 +513,12 @@ export type ContinueHostChild = { ) => unknown } | null once: ( - event: 'error', - listener: (error: Error) => void + event: 'error' | 'close', + listener: (error: Error | number | null) => void + ) => unknown + removeListener?: ( + event: 'error' | 'close', + listener: (error: Error | number | null) => void ) => unknown kill: (signal?: NodeJS.Signals) => unknown } @@ -792,6 +805,10 @@ function extractUsageDelta( export class ContinueHostAdapter { private readonly children = new Set() + private readonly childTerminations = new WeakMap< + ContinueHostChild, + Promise + >() private readonly pendingQuestions = new Map< string, { @@ -802,7 +819,20 @@ export class ContinueHostAdapter { >() private preparation?: Promise - constructor(private readonly options: ContinueHostAdapterOptions) {} + private readonly dependencies: ContinueHostAdapterDependencies + + constructor( + private readonly options: ContinueHostAdapterOptions, + dependencies: Partial = {} + ) { + this.dependencies = { + terminateProcessTree: terminateProcessTreeAndWait, + maximumStreamEvents, + maximumStreamEventBytes, + maximumToolCalls, + ...dependencies + } + } private async prepare(): Promise { if (!isAbsolute(this.options.cacheRoot)) { @@ -1515,17 +1545,20 @@ export class ContinueHostAdapter { child.stderr?.on('data', (chunk: Buffer | string) => { stderrBytes += Buffer.byteLength(chunk) if (stderrBytes > 64 * 1024) { - this.terminate(child) + void this.terminate(child) } }) const abort = (): void => { - this.terminate(child) + void this.terminate(child) } signal.addEventListener('abort', abort, { once: true }) let observedTools: ContinueHostTool[] = [] const reportedQuestionIds = new Set() let streamedText = false + let streamEventCount = 0 + let streamEventBytes = 0 + const observedToolCallIds = new Set() let executionTimeoutSignal: AbortSignal | undefined try { const initialState = await this.waitForStartup( @@ -1585,17 +1618,55 @@ export class ContinueHostAdapter { if (state.goodbuddyEventsOverflow) { throw new Error('Continue 宿主流式事件超过安全限制') } - const streamEventBytes = Buffer.byteLength( - JSON.stringify(state.goodbuddyEvents ?? []) + const streamEvents = state.goodbuddyEvents ?? [] + const batchStreamEventBytes = Buffer.byteLength( + JSON.stringify(streamEvents) ) - if (streamEventBytes > maximumStreamEventBytes) { + const nextStreamEventCount = + streamEventCount + streamEvents.length + const nextStreamEventBytes = + streamEventBytes + batchStreamEventBytes + const nextToolCallIds = new Set(observedToolCallIds) + for (const event of streamEvents) { + if (event.type === 'tool') { + nextToolCallIds.add(event.callId) + } + } + if ( + nextStreamEventCount > + this.dependencies.maximumStreamEvents || + nextStreamEventBytes > + this.dependencies.maximumStreamEventBytes + ) { throw new Error('Continue 宿主流式事件超过安全限制') } + if ( + nextToolCallIds.size > this.dependencies.maximumToolCalls + ) { + throw new Error('Continue 单次运行的工具调用超过 100 个') + } + streamEventCount = nextStreamEventCount + streamEventBytes = nextStreamEventBytes + for (const callId of nextToolCallIds) { + observedToolCallIds.add(callId) + } + const historyTools = extractContinueTools( + state.session.history, + startIndex + ) + for (const tool of historyTools) { + observedToolCallIds.add(tool.callId) + } + if ( + observedToolCallIds.size > this.dependencies.maximumToolCalls + ) { + throw new Error('Continue 单次运行的工具调用超过 100 个') + } observedTools = mergeContinueTools( observedTools, - extractContinueTools(state.session.history, startIndex) + historyTools ) - for (const event of state.goodbuddyEvents ?? []) { + for (const event of streamEvents) { if (event.type === 'text') { streamedText = true await runOptions.onEvent?.(event) @@ -1653,7 +1724,10 @@ export class ContinueHostAdapter { } const pending = state.pendingPermission if (pending && !handledPermissionIds.has(pending.requestId)) { - if (handledPermissionIds.size >= 100) { + if ( + handledPermissionIds.size >= + this.dependencies.maximumToolCalls + ) { throw new Error('Continue 单次运行的工具调用超过 100 个') } handledPermissionIds.add(pending.requestId) @@ -1667,9 +1741,14 @@ export class ContinueHostAdapter { if ( !observedTools.some((tool) => tool.callId === pendingCallId) ) { - if (observedTools.length >= 100) { + if ( + !observedToolCallIds.has(pendingCallId) && + observedToolCallIds.size >= + this.dependencies.maximumToolCalls + ) { throw new Error('Continue 单次运行的工具调用超过 100 个') } + observedToolCallIds.add(pendingCallId) observedTools = [ ...observedTools, { @@ -1771,7 +1850,7 @@ export class ContinueHostAdapter { signal: cleanupSignal }).catch(() => undefined) } finally { - this.terminate(child) + await this.terminate(child) this.children.delete(child) if (generatedConfigPath) { await rm(generatedConfigPath, { force: true }) @@ -1795,31 +1874,24 @@ export class ContinueHostAdapter { } } - private terminate(child: ContinueHostChild): void { - if (child.exitCode !== null || child.killed) { + private async terminate(child: ContinueHostChild): Promise { + const existing = this.childTerminations.get(child) + if (existing) { + await existing return } - if (process.platform === 'win32' && child.pid) { - const killer = spawn( - 'taskkill.exe', - ['/PID', String(child.pid), '/T', '/F'], - { - shell: false, - stdio: 'ignore', - windowsHide: true - } - ) - killer.unref() - } else { - child.kill('SIGTERM') - } + const termination = this.dependencies + .terminateProcessTree(child) + .catch(() => undefined) + this.childTerminations.set(child, termination) + await termination } - dispose(): void { + async dispose(): Promise { this.pendingQuestions.clear() - for (const child of this.children) { - this.terminate(child) - } + await Promise.all( + [...this.children].map((child) => this.terminate(child)) + ) this.children.clear() } } diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index 6551cd1..ceea44d 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -83,6 +83,7 @@ describe('ContinueAgentRuntime', () => { text: 'Continue response' }) mocks.respondHostQuestion.mockResolvedValue(undefined) + mocks.disposeHost.mockResolvedValue(undefined) }) it('does not launch the CLI for an already-cancelled request', async () => { @@ -104,6 +105,29 @@ describe('ContinueAgentRuntime', () => { expect(mocks.runHost).not.toHaveBeenCalled() }) + it('awaits host process cleanup during Runtime disposal', async () => { + let releaseDispose!: () => void + mocks.disposeHost.mockImplementation( + () => + new Promise((resolve) => { + releaseDispose = resolve + }) + ) + const runtime = createRuntime() + await collectEvents(runtime) + let disposed = false + + const disposal = runtime.dispose().then(() => { + disposed = true + }) + await Promise.resolve() + expect(disposed).toBe(false) + + releaseDispose() + await disposal + expect(disposed).toBe(true) + }) + it('uses the resolved binary through the Continue host adapter', async () => { const runtime = createRuntime() diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index 6fd7530..eac2466 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -784,9 +784,9 @@ export class ContinueAgentRuntime implements AgentRuntime { async dispose(): Promise { this.pendingQuestions.clear() - for (const host of this.hostAdapters.values()) { - host.dispose() - } + await Promise.all( + [...this.hostAdapters.values()].map((host) => host.dispose()) + ) this.hostAdapters.clear() } } diff --git a/src/main/agent/continue-utility-process-adapter.test.ts b/src/main/agent/continue-utility-process-adapter.test.ts new file mode 100644 index 0000000..0727249 --- /dev/null +++ b/src/main/agent/continue-utility-process-adapter.test.ts @@ -0,0 +1,88 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + createContinueUtilityProcessChild, + type ContinueUtilityProcessSource +} from './continue-utility-process-adapter' +import { waitForProcessExit } from './child-process-termination' + +function createSource(): ContinueUtilityProcessSource & EventEmitter { + const emitter = + new EventEmitter() as ContinueUtilityProcessSource & EventEmitter + Object.defineProperties(emitter, { + pid: { value: 42 }, + stderr: { value: undefined } + }) + emitter.kill = vi.fn(() => true) + emitter.onExit = (listener) => { + emitter.on('exit', listener) + } + emitter.onceExit = (listener) => { + emitter.once('exit', listener) + } + emitter.onceError = (listener) => { + emitter.once('utility-error', listener) + } + emitter.removeExitListener = (listener) => { + emitter.removeListener('exit', listener) + } + emitter.removeErrorListener = (listener) => { + emitter.removeListener('utility-error', listener) + } + return emitter +} + +describe('Continue utility process adapter', () => { + it('maps Electron exit to close and completes helper waits immediately', async () => { + const source = createSource() + const child = createContinueUtilityProcessChild(source) + const close = vi.fn() + + child.once('close', close) + const waiting = waitForProcessExit(child) + source.emit('exit', 0) + + expect(close).toHaveBeenCalledWith(0) + expect(child.exitCode).toBe(0) + await expect(waiting).resolves.toBeUndefined() + }) + + it('maps utility errors and removes both listener types', () => { + const source = createSource() + const child = createContinueUtilityProcessChild(source) + const close = vi.fn() + const error = vi.fn() + + child.once('close', close) + child.once('error', error) + child.removeListener?.('close', close) + child.removeListener?.('error', error) + source.emit('exit', 0) + source.emit('utility-error', 'FatalError', 'worker.js:1', 'report') + + expect(close).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + }) + + it('converts utility error details to a bounded Error', () => { + const source = createSource() + const child = createContinueUtilityProcessChild(source) + const error = vi.fn() + + child.once('error', error) + source.emit( + 'utility-error', + 'FatalError', + 'worker.js:1', + 'x'.repeat(1_000) + ) + + expect(error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /^Continue 宿主进程异常(worker\.js:1):x{500}$/u + ) + }) + ) + }) +}) diff --git a/src/main/agent/continue-utility-process-adapter.ts b/src/main/agent/continue-utility-process-adapter.ts new file mode 100644 index 0000000..3d1bd81 --- /dev/null +++ b/src/main/agent/continue-utility-process-adapter.ts @@ -0,0 +1,96 @@ +import type { ContinueHostChild } from './continue-host-adapter' + +type UtilityErrorListener = ( + type: 'FatalError', + location: string, + report: string +) => void + +export type ContinueUtilityProcessSource = { + readonly pid?: number + readonly stderr?: ContinueHostChild['stderr'] + kill(): boolean + onExit(listener: (code: number) => void): void + onceExit(listener: (code: number) => void): void + onceError(listener: UtilityErrorListener): void + removeExitListener(listener: (code: number) => void): void + removeErrorListener(listener: UtilityErrorListener): void +} + +export function createContinueUtilityProcessChild( + utility: ContinueUtilityProcessSource +): ContinueHostChild { + let exitCode: number | null = null + let killed = false + const closeListeners = new Map< + (value: Error | number | null) => void, + (code: number) => void + >() + const errorListeners = new Map< + (value: Error | number | null) => void, + UtilityErrorListener + >() + utility.onExit((code) => { + exitCode = code + }) + + const child: ContinueHostChild = { + get exitCode() { + return exitCode + }, + get killed() { + return killed + }, + get pid() { + return utility.pid + }, + stderr: utility.stderr, + once: (event, listener) => { + if (event === 'close') { + const wrapped = (code: number): void => { + closeListeners.delete(listener) + listener(code) + } + closeListeners.set(listener, wrapped) + utility.onceExit(wrapped) + } else { + const wrapped: UtilityErrorListener = ( + _type, + location, + report + ): void => { + errorListeners.delete(listener) + listener( + new Error( + `Continue 宿主进程异常(${location}):${report.slice(0, 500)}` + ) + ) + } + errorListeners.set(listener, wrapped) + utility.onceError(wrapped) + } + return child + }, + removeListener: (event, listener) => { + if (event === 'close') { + const wrapped = closeListeners.get(listener) + if (wrapped) { + closeListeners.delete(listener) + utility.removeExitListener(wrapped) + } + } else { + const wrapped = errorListeners.get(listener) + if (wrapped) { + errorListeners.delete(listener) + utility.removeErrorListener(wrapped) + } + } + return child + }, + kill: () => { + killed = true + return utility.kill() + } + } + return child +} diff --git a/src/main/agent/dsh-extension-marketplace.ts b/src/main/agent/dsh-extension-marketplace.ts index a1157af..2a217cb 100644 --- a/src/main/agent/dsh-extension-marketplace.ts +++ b/src/main/agent/dsh-extension-marketplace.ts @@ -26,6 +26,7 @@ import type { RuntimeExtensionCatalog, RuntimeExtensionStoreDependencies } from './runtime-extension-store' +import { terminateProcessTreeAndWait } from './child-process-termination' const NPM_REGISTRY_URL = 'https://registry.npmjs.org' const NPM_SEARCH_PAGE_SIZE = 250 @@ -167,61 +168,14 @@ export type PackageManagerRunner = ( } ) => Promise -function waitForProcessClose( - child: ReturnType -): Promise { - if (child.exitCode !== null) { - return Promise.resolve() - } - return new Promise((resolve) => { - const finish = (): void => { - clearTimeout(timer) - child.removeListener('close', finish) - resolve() - } - const timer = setTimeout(finish, 5_000) - child.once('close', finish) - }) -} - async function terminatePackageManager( child: ReturnType ): Promise { - const closed = waitForProcessClose(child) - if (process.platform === 'win32' && child.pid) { - const killer = spawn( - 'taskkill.exe', - ['/PID', String(child.pid), '/T', '/F'], - { - shell: false, - stdio: 'ignore', - windowsHide: true - } - ) - await new Promise((resolve) => { - const finish = (): void => { - clearTimeout(timer) - killer.removeListener('close', finish) - killer.removeListener('error', finish) - resolve() - } - const timer = setTimeout(finish, 5_000) - killer.once('close', finish) - killer.once('error', finish) - }) - } else if (child.pid) { - try { - process.kill(-child.pid, 'SIGKILL') - } catch { - child.kill('SIGKILL') - } - } else { - child.kill('SIGKILL') - } - if (child.exitCode === null) { - child.kill('SIGKILL') - } - await closed + await terminateProcessTreeAndWait(child, { + processGroup: true, + signal: 'SIGKILL', + waitMs: 5_000 + }) } function boundedAppend(current: string, chunk: unknown): string { diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 5d0ed1f..76f4ab2 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1351,19 +1351,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => { expect.any(Array), expect.any(AbortSignal) ) - expect(setup.client.mcp.add).toHaveBeenCalledWith({ - directory: process.cwd(), - name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u), - config: { - type: 'remote', - url: 'http://127.0.0.1:4567/mcp', - enabled: true, - headers: { - Authorization: 'Bearer custom-capability' - }, - oauth: false - } - }) + expect(setup.client.mcp.add).toHaveBeenCalledWith( + { + directory: process.cwd(), + name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u), + config: { + type: 'remote', + url: 'http://127.0.0.1:4567/mcp', + enabled: true, + headers: { + Authorization: 'Bearer custom-capability' + }, + oauth: false + } + }, + { signal: expect.any(AbortSignal) } + ) expect(JSON.stringify( (setup.client.mcp.add as unknown as ReturnType) .mock.calls @@ -1500,21 +1503,24 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await expect(stream.next()).resolves.toMatchObject({ value: { type: 'status' } }) - await expect(stream.next()).resolves.toMatchObject({ - value: { - type: 'question', - questionId: 'question-1', - questions: [ - { - header: '实现方式', - question: '请选择实现方式', - multiple: false, - custom: true - } - ] - } + const questionEvent = await stream.next() + expect(questionEvent.value).toMatchObject({ + type: 'question', + questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u), + questions: [ + { + header: '实现方式', + question: '请选择实现方式', + multiple: false, + custom: true + } + ] }) - await runtime.respondToQuestion('question-1', [['先写测试']]) + const questionId = + questionEvent.value?.type === 'question' + ? questionEvent.value.questionId + : '' + await runtime.respondToQuestion(questionId, [['先写测试']]) expect(setup.questionReply).toHaveBeenCalledWith({ requestID: 'question-1', directory: process.cwd(), @@ -1526,6 +1532,318 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await runtime.dispose() }) + it('namespaces identical upstream question IDs across concurrent external conversations', async () => { + const setup = runClient([]) + ;( + setup.event.subscribe as unknown as ReturnType + ).mockImplementation(async () => ({ + stream: (async function* () { + yield { + id: 'question-event', + type: 'question.asked', + properties: { + id: 'shared-question', + sessionID: 'session-1', + questions: [ + { + header: 'Choice', + question: 'Choose', + options: [], + multiple: false, + custom: true + } + ] + } + } + yield { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + })() + })) + const runtime = new OpenCodeRuntime( + options({ + baseUrl: 'http://127.0.0.1:4096', + embedded: false + }), + dependencies(fakeChild(), { + createClient: vi.fn( + () => setup.client + ) as unknown as typeof createOpencodeClient + }).deps + ) + const first = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'first', + workMode: 'execute' + }, + new AbortController().signal + ) + const second = runtime.run( + { + requestId: '4f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-2', + prompt: 'second', + workMode: 'execute' + }, + new AbortController().signal + ) + + await Promise.all([first.next(), second.next()]) + const [firstQuestion, secondQuestion] = await Promise.all([ + first.next(), + second.next() + ]) + const firstId = + firstQuestion.value?.type === 'question' + ? firstQuestion.value.questionId + : '' + const secondId = + secondQuestion.value?.type === 'question' + ? secondQuestion.value.questionId + : '' + expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u) + expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u) + expect(firstId).not.toBe(secondId) + + await Promise.all([ + runtime.respondToQuestion(firstId, [['first answer']]), + runtime.respondToQuestion(secondId, [['second answer']]) + ]) + expect(setup.questionReply).toHaveBeenCalledTimes(2) + expect(setup.questionReply).toHaveBeenNthCalledWith(1, { + requestID: 'shared-question', + directory: process.cwd(), + answers: [['first answer']] + }) + expect(setup.questionReply).toHaveBeenNthCalledWith(2, { + requestID: 'shared-question', + directory: process.cwd(), + answers: [['second answer']] + }) + await Promise.all([first.next(), second.next()]) + await runtime.dispose() + }) + + it('aborts an OpenCode run at its total execution deadline', async () => { + const setup = runClient([]) + ;( + setup.event.subscribe as unknown as ReturnType + ).mockImplementation( + async ( + _input: unknown, + options: { signal: AbortSignal } + ) => ({ + stream: (async function* () { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => reject(options.signal.reason), + { once: true } + ) + }) + yield { + id: 'unreachable', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + })() + }) + ) + const runtime = new OpenCodeRuntime( + options({ + baseUrl: 'http://127.0.0.1:4096', + embedded: false + }), + dependencies(fakeChild(), { + createClient: vi.fn( + () => setup.client + ) as unknown as typeof createOpencodeClient, + executionTimeoutMs: 20 + }).deps + ) + const stream = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'never finish', + workMode: 'execute' + }, + new AbortController().signal + ) + + await expect(stream.next()).resolves.toMatchObject({ + value: { type: 'status' } + }) + await expect(stream.next()).rejects.toThrow( + 'OpenCode 执行超过 20 毫秒总时限' + ) + expect(setup.session.abort).toHaveBeenCalled() + await runtime.dispose() + }) + + it('applies the total deadline while agent discovery is stalled', async () => { + const setup = runClient([]) + const agents = vi.fn( + () => new Promise(() => undefined) + ) + Object.assign(setup.client, { + app: { agents } + }) + const child = fakeChild() + const runtime = new OpenCodeRuntime( + options({ + customization: { defaultAgent: 'build' } + }), + dependencies(child, { + createClient: vi.fn( + () => setup.client + ) as unknown as typeof createOpencodeClient, + executionTimeoutMs: 20 + }).deps + ) + setTimeout(() => { + stdoutOf(child).write( + 'opencode server listening on http://127.0.0.1:4010\n' + ) + }, 0) + const stream = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'never reach the stream', + workMode: 'execute' + }, + new AbortController().signal + ) + + await expect(stream.next()).rejects.toThrow( + 'OpenCode 执行超过 20 毫秒总时限' + ) + expect(agents).toHaveBeenCalledWith( + { directory: process.cwd() }, + { signal: expect.any(AbortSignal) } + ) + expect(setup.event.subscribe).not.toHaveBeenCalled() + await runtime.dispose() + }) + + it('deletes a session created after timeout and does not reuse it', async () => { + const setup = runClient([ + { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + ]) + let resolveStalledCreation!: (value: { + data: { id: string } + error: undefined + }) => void + ;( + setup.session.create as unknown as ReturnType + ).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStalledCreation = resolve + }) + ) + const runtime = new OpenCodeRuntime( + options({ + baseUrl: 'http://127.0.0.1:4096', + embedded: false + }), + dependencies(fakeChild(), { + createClient: vi.fn( + () => setup.client + ) as unknown as typeof createOpencodeClient, + executionTimeoutMs: 20 + }).deps + ) + const first = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'stalled creation', + workMode: 'execute' + }, + new AbortController().signal + ) + + await expect(first.next()).rejects.toThrow( + 'OpenCode 执行超过 20 毫秒总时限' + ) + resolveStalledCreation({ + data: { id: 'stale-session' }, + error: undefined + }) + await vi.waitFor(() => + expect(setup.session.delete).toHaveBeenCalledWith( + { + sessionID: 'stale-session', + directory: process.cwd() + }, + { signal: expect.any(AbortSignal) } + ) + ) + + const secondEvents = await collectRun(runtime) + expect(secondEvents.at(-1)).toMatchObject({ type: 'done' }) + expect(setup.session.create).toHaveBeenCalledTimes(2) + expect(setup.session.update).not.toHaveBeenCalled() + await runtime.dispose() + }) + + it('fails before emitting text that exceeds the aggregate output budget', async () => { + const setup = runClient([ + { + id: 'first-text', + type: 'message.part.delta', + properties: { + sessionID: 'session-1', + partID: 'part-1', + field: 'text', + delta: 'a'.repeat(600_000) + } + }, + { + id: 'second-text', + type: 'message.part.delta', + properties: { + sessionID: 'session-1', + partID: 'part-1', + field: 'text', + delta: 'b'.repeat(600_000) + } + } + ]) + const runtime = embeddedRuntime(setup.client) + const stream = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'test', + workMode: 'execute' + }, + new AbortController().signal + ) + + await stream.next() + const firstText = await stream.next() + expect(firstText.value).toMatchObject({ + type: 'text', + delta: expect.stringMatching(/^a+$/u) + }) + await expect(stream.next()).rejects.toThrow( + '文本与推理输出超过 1 MB 安全限制' + ) + expect(setup.session.abort).toHaveBeenCalled() + await runtime.dispose() + }) + it('adds only request-scoped built-in read tools for Ask and disconnects them', async () => { const setup = runClient([ { @@ -1592,19 +1910,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => { events.push(event) } - expect(setup.client.mcp.add).toHaveBeenCalledWith({ - directory: process.cwd(), - name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u), - config: { - type: 'remote', - url: 'http://127.0.0.1:4567/mcp', - enabled: true, - headers: { - Authorization: 'Bearer secret-capability' - }, - oauth: false - } - }) + expect(setup.client.mcp.add).toHaveBeenCalledWith( + { + directory: process.cwd(), + name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u), + config: { + type: 'remote', + url: 'http://127.0.0.1:4567/mcp', + enabled: true, + headers: { + Authorization: 'Bearer secret-capability' + }, + oauth: false + } + }, + { signal: expect.any(AbortSignal) } + ) const knowledgeMcpName = ( ( setup.client.mcp.add as unknown as ReturnType @@ -1621,7 +1942,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => { action: 'allow' } ] - }) + }), + { signal: expect.any(AbortSignal) } ) expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -1634,10 +1956,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => { }), expect.anything() ) - expect(setup.client.mcp.disconnect).toHaveBeenCalledWith({ - name: expect.stringMatching(/^goodbuddy-data-/u), - directory: process.cwd() - }) + expect(setup.client.mcp.disconnect).toHaveBeenCalledWith( + { + name: expect.stringMatching(/^goodbuddy-data-/u), + directory: process.cwd() + }, + { signal: expect.any(AbortSignal) } + ) expect(events.at(-1)).toMatchObject({ type: 'done' }) await runtime.dispose() }) @@ -1911,19 +2236,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => { try { await collectRun(runtime, 'ask') - expect(setup.session.create).toHaveBeenCalledWith({ - title: 'GoodBuddy 对话', - directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'deny' }, - { permission: 'skill', pattern: '*', action: 'deny' }, - { - permission: 'skill', - pattern: 'longdoc-docx', - action: 'allow' - } - ] - }) + expect(setup.session.create).toHaveBeenCalledWith( + { + title: 'GoodBuddy 对话', + directory: process.cwd(), + permission: [ + { permission: '*', pattern: '*', action: 'deny' }, + { permission: 'skill', pattern: '*', action: 'deny' }, + { + permission: 'skill', + pattern: 'longdoc-docx', + action: 'allow' + } + ] + }, + { signal: expect.any(AbortSignal) } + ) expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ system: undefined, @@ -2001,13 +2329,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => { const events = await collectRun(runtime, 'execute') expect(callOrder).toEqual(['subscribe', 'prompt']) - expect(session.create).toHaveBeenCalledWith({ - title: 'GoodBuddy 对话', - directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'allow' } - ] - }) + expect(session.create).toHaveBeenCalledWith( + { + title: 'GoodBuddy 对话', + directory: process.cwd(), + permission: [ + { permission: '*', pattern: '*', action: 'allow' } + ] + }, + { signal: expect.any(AbortSignal) } + ) expect(permissionReply).toHaveBeenCalledOnce() expect(permissionReply).toHaveBeenCalledWith({ requestID: 'permission-1', @@ -2341,16 +2672,20 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await collectRun(runtime, 'ask') - expect(session.create).toHaveBeenCalledWith({ - title: 'GoodBuddy 对话', - directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'deny' } - ] - }) - expect(tool.ids).toHaveBeenCalledWith({ - directory: process.cwd() - }) + expect(session.create).toHaveBeenCalledWith( + { + title: 'GoodBuddy 对话', + directory: process.cwd(), + permission: [ + { permission: '*', pattern: '*', action: 'deny' } + ] + }, + { signal: expect.any(AbortSignal) } + ) + expect(tool.ids).toHaveBeenCalledWith( + { directory: process.cwd() }, + { signal: expect.any(AbortSignal) } + ) expect(session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { @@ -2378,13 +2713,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await collectRun(runtime, 'execute') await collectRun(runtime, 'ask') - expect(session.update).toHaveBeenCalledWith({ - sessionID: 'session-1', - directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'deny' } - ] - }) + expect(session.update).toHaveBeenCalledWith( + { + sessionID: 'session-1', + directory: process.cwd(), + permission: [ + { permission: '*', pattern: '*', action: 'deny' } + ] + }, + { signal: expect.any(AbortSignal) } + ) await runtime.dispose() }) @@ -2411,13 +2749,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await collectRun(runtime, 'execute') expect(runtime.requiresToolApproval).toBe(false) - expect(session.create).toHaveBeenCalledWith({ - title: 'GoodBuddy 对话', - directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'allow' } - ] - }) + expect(session.create).toHaveBeenCalledWith( + { + title: 'GoodBuddy 对话', + directory: process.cwd(), + permission: [ + { permission: '*', pattern: '*', action: 'allow' } + ] + }, + { signal: expect.any(AbortSignal) } + ) expect(permissionReply).not.toHaveBeenCalled() await runtime.dispose() }) @@ -2814,7 +3155,8 @@ describe('OpenCodeRuntime native customization', () => { } expect(setup.session.create).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'plan' }) + expect.objectContaining({ agent: 'plan' }), + { signal: expect.any(AbortSignal) } ) expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ agent: 'plan' }), @@ -2853,7 +3195,8 @@ describe('OpenCodeRuntime native customization', () => { await collectRun(runtime) expect(setup.session.create).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'build' }) + expect.objectContaining({ agent: 'build' }), + { signal: expect.any(AbortSignal) } ) expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ agent: 'build' }), @@ -3099,7 +3442,7 @@ describe('OpenCodeRuntime native customization', () => { }) expect(context).toHaveBeenCalledWith( { sessionID: 'session-1' }, - { signal } + { signal: expect.any(AbortSignal) } ) expect(summarize).toHaveBeenCalledWith( { @@ -3109,7 +3452,7 @@ describe('OpenCodeRuntime native customization', () => { modelID: 'claude-sonnet', auto: false }, - { signal } + { signal: expect.any(AbortSignal) } ) await runtime.dispose() }) diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index ea234e6..9134a92 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -56,6 +56,10 @@ import type { RuntimeSkillPackage } from '../capabilities/capability-service' import { stageRuntimeSkillPackages } from './runtime-skill-packages' +import { + requestProcessTreeTermination, + waitForProcessExit +} from './child-process-termination' const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024 const STARTUP_TIMEOUT_MS = 10_000 @@ -65,6 +69,8 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024 const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024 const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024 const MAX_TOOL_CALLS_PER_RUN = 100 +const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024 +const MAX_EXECUTION_MILLISECONDS = 10 * 60_000 const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024 const MAX_QUESTIONS_PER_REQUEST = 4 const MAX_QUESTION_OPTIONS = 20 @@ -243,6 +249,44 @@ function byteLengthWithin(value: string, maximum: number): boolean { return Buffer.byteLength(value) <= maximum } +function createPublicQuestionId( + requestId: string, + sessionId: string, + upstreamQuestionId: string +): string { + return `opencode-${createHash('sha256') + .update(`${requestId}\0${sessionId}\0${upstreamQuestionId}`) + .digest('hex') + .slice(0, 48)}` +} + +function executionDeadlineLabel(milliseconds: number): string { + return milliseconds % 60_000 === 0 + ? `${milliseconds / 60_000} 分钟` + : `${milliseconds} 毫秒` +} + +function awaitWithAbort( + operation: Promise, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + return new Promise((resolveOperation, rejectOperation) => { + const abort = (): void => rejectOperation(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', abort) + resolveOperation(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + rejectOperation(error) + } + ) + }) +} + function areBoundedPatterns(value: unknown): value is string[] { return ( Array.isArray(value) && @@ -416,6 +460,7 @@ export type OpenCodeRuntimeDependencies = { createClient: typeof createOpencodeClient platform: NodeJS.Platform startupTimeoutMs: number + executionTimeoutMs: number } export type OpenCodeRuntimeOptions = { @@ -678,6 +723,7 @@ export class OpenCodeRuntime implements AgentRuntime { client: OpencodeClient directory: string questionCount: number + upstreamQuestionId: string } >() private embeddedRunTail: Promise = Promise.resolve() @@ -694,6 +740,7 @@ export class OpenCodeRuntime implements AgentRuntime { createClient: createOpencodeClient, platform: process.platform, startupTimeoutMs: STARTUP_TIMEOUT_MS, + executionTimeoutMs: MAX_EXECUTION_MILLISECONDS, ...dependencies } } @@ -782,36 +829,14 @@ export class OpenCodeRuntime implements AgentRuntime { } private terminate(child: SpawnedProcess): void { - if (child.exitCode !== null) { - return - } - if (this.dependencies.platform === 'win32' && child.pid) { - const killer = this.dependencies.spawn( - 'taskkill.exe', - ['/PID', String(child.pid), '/T', '/F'], - { - shell: false, - stdio: 'ignore', - windowsHide: true - } - ) - killer.unref() - } else { - child.kill('SIGTERM') - } + requestProcessTreeTermination(child, { + platform: this.dependencies.platform, + spawn: this.dependencies.spawn + }) } private waitForExit(child: SpawnedProcess): Promise { - if (child.exitCode !== null) { - return Promise.resolve() - } - return new Promise((resolveExit) => { - const timeout = setTimeout(resolveExit, 2_000) - child.once('close', () => { - clearTimeout(timeout) - resolveExit() - }) - }) + return waitForProcessExit(child) } private getNativeSkillIds(): string[] { @@ -1083,9 +1108,12 @@ export class OpenCodeRuntime implements AgentRuntime { if (this.client) { return this.client } + const existingInitialization = this.clientInitialization this.clientInitialization ??= this.initializeClient(signal) try { - return await this.clientInitialization + return signal && existingInitialization + ? await awaitWithAbort(this.clientInitialization, signal) + : await this.clientInitialization } catch (error) { this.clientInitialization = undefined throw error @@ -1154,7 +1182,8 @@ export class OpenCodeRuntime implements AgentRuntime { } private async discoverAgents( - client: OpencodeClient + client: OpencodeClient, + signal?: AbortSignal ): Promise< Array<{ id: string @@ -1165,9 +1194,15 @@ export class OpenCodeRuntime implements AgentRuntime { hidden: boolean }> > { - const response = await client.app.agents({ - directory: this.options.defaultWorkspace - }) + const operation = client.app.agents( + { + directory: this.options.defaultWorkspace + }, + signal ? { signal } : undefined + ) + const response = signal + ? await awaitWithAbort(operation, signal) + : await operation if (response.error || !response.data) { throw new Error('OpenCode Agent 清单不可用') } @@ -1197,7 +1232,8 @@ export class OpenCodeRuntime implements AgentRuntime { private async resolveSelectedAgent( client: OpencodeClient, - request: AgentExecutionRequest + request: AgentExecutionRequest, + signal: AbortSignal ): Promise { const control = request.runtimeControl?.provider === 'opencode' @@ -1213,7 +1249,7 @@ export class OpenCodeRuntime implements AgentRuntime { '外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent' ) } - const agents = await this.discoverAgents(client) + const agents = await this.discoverAgents(client, signal) if ( !agents.some( (agent) => @@ -1592,6 +1628,7 @@ export class OpenCodeRuntime implements AgentRuntime { client: OpencodeClient, request: AgentExecutionRequest, directory: string, + signal: AbortSignal, agent?: string, permission?: PermissionRuleset ): Promise<{ id: string; created: boolean }> { @@ -1603,27 +1640,62 @@ export class OpenCodeRuntime implements AgentRuntime { request.conversationId ) if (pending) { - return { id: await pending, created: false } + return { + id: await awaitWithAbort(pending, signal), + created: false + } } - const creation = client.session - .create({ - title: 'GoodBuddy 对话', - directory, - ...(agent ? { agent } : {}), - ...(permission ? { permission } : {}) - }) + const creation: Promise = client.session + .create( + { + title: 'GoodBuddy 对话', + directory, + ...(agent ? { agent } : {}), + ...(permission ? { permission } : {}) + }, + { signal } + ) .then((response) => { if (!response.data) { throw new Error('OpenCode 会话创建失败') } - this.sessions.set(request.conversationId, response.data.id) - return response.data.id + const sessionId = response.data.id + const stillCurrent = + this.sessionInitializations.get(request.conversationId) === + creation + if (signal.aborted || !stillCurrent) { + if (this.sessions.get(request.conversationId) !== sessionId) { + void client.session + .delete( + { + sessionID: sessionId, + directory + }, + { signal: AbortSignal.timeout(1_000) } + ) + .catch(() => undefined) + } + if (signal.aborted) { + throw signal.reason + } + throw new Error('OpenCode 会话初始化已失效') + } + this.sessions.set(request.conversationId, sessionId) + return sessionId }) this.sessionInitializations.set(request.conversationId, creation) try { - return { id: await creation, created: true } + return { + id: await awaitWithAbort(creation, signal), + created: true + } } finally { - this.sessionInitializations.delete(request.conversationId) + if ( + this.sessionInitializations.get(request.conversationId) === + creation + ) { + this.sessionInitializations.delete(request.conversationId) + } } } @@ -1631,17 +1703,42 @@ export class OpenCodeRuntime implements AgentRuntime { request: AgentExecutionRequest, signal: AbortSignal ): AsyncGenerator { - const releaseEmbedded = this.usesEmbeddedPermissionMediation() - ? await this.acquireEmbeddedRun(signal) - : undefined - const releaseConversation = await this.acquireConversationRun( - request.conversationId, - signal + const deadline = new AbortController() + const deadlineTimer = setTimeout( + () => + deadline.abort( + new Error( + `OpenCode 执行超过 ${executionDeadlineLabel( + this.dependencies.executionTimeoutMs + )}总时限` + ) + ), + this.dependencies.executionTimeoutMs ) + deadlineTimer.unref?.() + const executionSignal = AbortSignal.any([ + signal, + deadline.signal + ]) + let releaseEmbedded: (() => void) | undefined + let releaseConversation: (() => void) | undefined try { - yield* this.runUnlocked(request, signal) + releaseEmbedded = this.usesEmbeddedPermissionMediation() + ? await this.acquireEmbeddedRun(executionSignal) + : undefined + releaseConversation = await this.acquireConversationRun( + request.conversationId, + executionSignal + ) + yield* this.runUnlocked(request, executionSignal) + } catch (error) { + if (deadline.signal.aborted && !signal.aborted) { + throw deadline.signal.reason + } + throw error } finally { - releaseConversation() + clearTimeout(deadlineTimer) + releaseConversation?.() releaseEmbedded?.() } } @@ -1674,7 +1771,8 @@ export class OpenCodeRuntime implements AgentRuntime { } const selectedAgent = await this.resolveSelectedAgent( client, - request + request, + signal ) let selectedCommand: | { @@ -1683,7 +1781,10 @@ export class OpenCodeRuntime implements AgentRuntime { } | undefined if (runtimeControl?.command) { - const commandResponse = await client.command.list({ directory }) + const commandResponse = await awaitWithAbort( + client.command.list({ directory }, { signal }), + signal + ) if (commandResponse.error || !commandResponse.data) { throw new Error('OpenCode 无法验证原生命令') } @@ -1725,19 +1826,25 @@ export class OpenCodeRuntime implements AgentRuntime { .update(`${request.conversationId}\0${request.requestId}`) .digest('hex') .slice(0, 20)}` - const added = await client.mcp.add({ - directory, - name: knowledgeMcpName, - config: { - type: 'remote', - url: this.options.knowledgeGateway.getEndpoint()!, - enabled: true, - headers: { - Authorization: `Bearer ${request.knowledgeCapabilityToken}` + const added = await awaitWithAbort( + client.mcp.add( + { + directory, + name: knowledgeMcpName, + config: { + type: 'remote', + url: this.options.knowledgeGateway.getEndpoint()!, + enabled: true, + headers: { + Authorization: `Bearer ${request.knowledgeCapabilityToken}` + }, + oauth: false + } }, - oauth: false - } - }) + { signal } + ), + signal + ) if (added.error || !added.data) { throw new Error('OpenCode 内置只读工具连接失败') } @@ -1776,19 +1883,25 @@ export class OpenCodeRuntime implements AgentRuntime { .update(`${request.conversationId}\0${request.requestId}`) .digest('hex') .slice(0, 20)}` - const added = await client.mcp.add({ - directory, - name: customMcpName, - config: { - type: 'remote', - url: this.options.knowledgeGateway.getEndpoint()!, - enabled: true, - headers: { - Authorization: `Bearer ${customMcpToken}` + const added = await awaitWithAbort( + client.mcp.add( + { + directory, + name: customMcpName, + config: { + type: 'remote', + url: this.options.knowledgeGateway.getEndpoint()!, + enabled: true, + headers: { + Authorization: `Bearer ${customMcpToken}` + }, + oauth: false + } }, - oauth: false - } - }) + { signal } + ), + signal + ) const addedStatus = added.data?.[customMcpName] if ( added.error || @@ -1834,9 +1947,10 @@ export class OpenCodeRuntime implements AgentRuntime { ] let disabledTools: Record | undefined if (request.workMode !== 'execute') { - const tools = await client.tool.ids({ - directory - }) + const tools = await awaitWithAbort( + client.tool.ids({ directory }, { signal }), + signal + ) if (tools.error || !tools.data) { throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求') } @@ -1854,16 +1968,23 @@ export class OpenCodeRuntime implements AgentRuntime { client, request, directory, + signal, selectedAgent, permission ) const sessionId = session.id if (!session.created) { - const update = await client.session.update({ - sessionID: sessionId, - directory, - permission - }) + const update = await awaitWithAbort( + client.session.update( + { + sessionID: sessionId, + directory, + permission + }, + { signal } + ), + signal + ) if (update.error || !update.data) { throw new Error('OpenCode 会话权限配置失败') } @@ -1875,9 +1996,10 @@ export class OpenCodeRuntime implements AgentRuntime { message: 'OpenCode 正在处理请求' } - const subscription = await client.event.subscribe({ - directory - }, { signal }) + const subscription = await awaitWithAbort( + client.event.subscribe({ directory }, { signal }), + signal + ) const abortSession = (): void => { void client.session.abort({ @@ -1898,7 +2020,16 @@ export class OpenCodeRuntime implements AgentRuntime { } >() const reasoningPartIds = new Set() - const reportedQuestionIds = new Set() + const reportedQuestionIds = new Map() + let aggregateOutputBytes = 0 + const consumeOutputBudget = (delta: string): void => { + aggregateOutputBytes += Buffer.byteLength(delta) + if (aggregateOutputBytes > MAX_EXECUTION_OUTPUT_BYTES) { + throw new Error( + 'OpenCode 单次运行的文本与推理输出超过 1 MB 安全限制' + ) + } + } let hasResponseTextAfterFailure = false try { const promptText = @@ -1998,6 +2129,7 @@ export class OpenCodeRuntime implements AgentRuntime { 'thinking' ].includes(event.properties.field) if (reasoning || event.properties.field === 'text') { + consumeOutputBudget(event.properties.delta) if ( !reasoning && /\S/u.test(event.properties.delta) && @@ -2077,6 +2209,7 @@ export class OpenCodeRuntime implements AgentRuntime { event.properties.sessionID === sessionId && event.properties.delta ) { + consumeOutputBudget(event.properties.delta) yield { requestId: request.requestId, type: 'reasoning', @@ -2096,16 +2229,28 @@ export class OpenCodeRuntime implements AgentRuntime { questionRequest && !reportedQuestionIds.has(questionRequest.id) ) { - reportedQuestionIds.add(questionRequest.id) - this.pendingQuestions.set(questionRequest.id, { + const publicQuestionId = createPublicQuestionId( + request.requestId, + sessionId, + questionRequest.id + ) + if (this.pendingQuestions.has(publicQuestionId)) { + throw new Error('OpenCode 提问公开 ID 与另一活动请求冲突') + } + reportedQuestionIds.set( + questionRequest.id, + publicQuestionId + ) + this.pendingQuestions.set(publicQuestionId, { client, directory, - questionCount: questionRequest.questions.length + questionCount: questionRequest.questions.length, + upstreamQuestionId: questionRequest.id }) yield { requestId: request.requestId, type: 'question', - questionId: questionRequest.id, + questionId: publicQuestionId, questions: questionRequest.questions.map((question) => ({ header: question.header, question: question.question, @@ -2125,7 +2270,12 @@ export class OpenCodeRuntime implements AgentRuntime { event.type === 'question.rejected') && event.properties.sessionID === sessionId ) { - this.pendingQuestions.delete(event.properties.requestID) + const publicQuestionId = reportedQuestionIds.get( + event.properties.requestID + ) + if (publicQuestionId) { + this.pendingQuestions.delete(publicQuestionId) + } } if ( @@ -2320,20 +2470,29 @@ export class OpenCodeRuntime implements AgentRuntime { throw error } finally { signal.removeEventListener('abort', abortSession) - for (const questionId of reportedQuestionIds) { + for (const questionId of reportedQuestionIds.values()) { this.pendingQuestions.delete(questionId) } } } finally { + const cleanupSignal = AbortSignal.timeout(1_000) if (knowledgeMcpName) { - await client.mcp - .disconnect({ name: knowledgeMcpName, directory }) - .catch(() => undefined) + await awaitWithAbort( + client.mcp.disconnect( + { name: knowledgeMcpName, directory }, + { signal: cleanupSignal } + ), + cleanupSignal + ).catch(() => undefined) } if (customMcpName) { - await client.mcp - .disconnect({ name: customMcpName, directory }) - .catch(() => undefined) + await awaitWithAbort( + client.mcp.disconnect( + { name: customMcpName, directory }, + { signal: cleanupSignal } + ), + cleanupSignal + ).catch(() => undefined) } if (customMcpToken) { this.options.knowledgeGateway?.revoke(customMcpToken) @@ -2352,13 +2511,13 @@ export class OpenCodeRuntime implements AgentRuntime { const response = answers ? answers.length === pending.questionCount ? await pending.client.question.reply({ - requestID: questionId, + requestID: pending.upstreamQuestionId, directory: pending.directory, answers }) : undefined : await pending.client.question.reject({ - requestID: questionId, + requestID: pending.upstreamQuestionId, directory: pending.directory }) if (!response) { @@ -2382,12 +2541,24 @@ export class OpenCodeRuntime implements AgentRuntime { '外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact' ) } - const releaseEmbedded = await this.acquireEmbeddedRun(signal) + const deadline = new AbortController() + const deadlineTimer = setTimeout( + () => + deadline.abort(new Error('OpenCode 原生 Compact 执行超时')), + this.dependencies.executionTimeoutMs + ) + deadlineTimer.unref?.() + const executionSignal = AbortSignal.any([ + signal, + deadline.signal + ]) + let releaseEmbedded: (() => void) | undefined let releaseConversation: (() => void) | undefined try { + releaseEmbedded = await this.acquireEmbeddedRun(executionSignal) releaseConversation = await this.acquireConversationRun( request.conversationId, - signal + executionSignal ) const sessionId = this.sessions.get(request.conversationId) if (!sessionId) { @@ -2400,10 +2571,10 @@ export class OpenCodeRuntime implements AgentRuntime { } } } - const client = await this.getClient(signal) + const client = await this.getClient(executionSignal) const context = await client.v2.session.context( { sessionID: sessionId }, - { signal } + { signal: executionSignal } ) if (context.error || !context.data) { throw new Error('OpenCode 原生上下文不可用,无法执行 Compact') @@ -2434,13 +2605,13 @@ export class OpenCodeRuntime implements AgentRuntime { } } } - signal.throwIfAborted() + executionSignal.throwIfAborted() const subscriptionController = new AbortController() const subscription = await client.event.subscribe( { directory: this.options.defaultWorkspace }, { signal: AbortSignal.any([ - signal, + executionSignal, subscriptionController.signal ]) } @@ -2482,7 +2653,7 @@ export class OpenCodeRuntime implements AgentRuntime { modelID: configuredModel.modelID, auto: false }, - { signal } + { signal: executionSignal } ) if (compact.error || compact.data !== true) { throw new Error( @@ -2522,9 +2693,15 @@ export class OpenCodeRuntime implements AgentRuntime { subscriptionController.abort() await usageCapture.catch(() => undefined) } + } catch (error) { + if (deadline.signal.aborted && !signal.aborted) { + throw deadline.signal.reason + } + throw error } finally { + clearTimeout(deadlineTimer) releaseConversation?.() - releaseEmbedded() + releaseEmbedded?.() } } diff --git a/src/main/agent/runtime-discovery.test.ts b/src/main/agent/runtime-discovery.test.ts index b673474..27c24e7 100644 --- a/src/main/agent/runtime-discovery.test.ts +++ b/src/main/agent/runtime-discovery.test.ts @@ -1,10 +1,13 @@ +import { EventEmitter } from 'node:events' import { realpath } from 'node:fs/promises' import { basename, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { detectAgentRuntimes, - detectRuntimeBinary + detectRuntimeBinary, + validateRuntimeVersion, + type RuntimeVersionProcess } from './runtime-discovery' const originalPath = process.env.PATH @@ -24,6 +27,54 @@ afterEach(() => { }) describe('runtime discovery', () => { + it('launches a detached POSIX version probe and awaits bounded tree cleanup', async () => { + const child = + new EventEmitter() as RuntimeVersionProcess & EventEmitter + child.exitCode = null + child.pid = 2718 + child.kill = vi.fn() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + const spawnProcess = vi.fn(() => child) + let releaseCleanup!: () => void + const cleanupBlocked = new Promise((resolve) => { + releaseCleanup = resolve + }) + const terminateProcessTree = vi.fn(async () => { + await cleanupBlocked + }) + + const validation = validateRuntimeVersion('runtime', { + platform: 'linux', + spawnProcess, + terminateProcessTree, + outputLimit: 4, + terminationWaitMs: 25 + }) + ;(child.stdout as EventEmitter).emit('data', '12345') + let completed = false + void validation.then(() => { + completed = true + }) + await Promise.resolve() + + expect(completed).toBe(false) + expect(spawnProcess).toHaveBeenCalledWith( + 'runtime', + ['--version'], + expect.objectContaining({ detached: true }) + ) + expect(terminateProcessTree).toHaveBeenCalledWith(child, { + platform: 'linux', + processGroup: true, + signal: 'SIGKILL', + waitMs: 25 + }) + + releaseCleanup() + await expect(validation).resolves.toEqual({ valid: false }) + }) + it('canonicalizes and validates a configured ordinary file first', async () => { process.env.PATH = '' process.env.Path = '' diff --git a/src/main/agent/runtime-discovery.ts b/src/main/agent/runtime-discovery.ts index 1a5b522..78dee9b 100644 --- a/src/main/agent/runtime-discovery.ts +++ b/src/main/agent/runtime-discovery.ts @@ -8,6 +8,10 @@ import { normalize } from 'node:path' import spawn from 'cross-spawn' +import { + terminateProcessTreeAndWait, + type WaitableProcessTreeChild +} from './child-process-termination' import { buildRuntimeEnvironment } from './process-environment' import type { AgentRuntimeDetection, @@ -16,6 +20,7 @@ import type { const VERSION_TIMEOUT_MS = 3_000 const VERSION_OUTPUT_LIMIT = 8 * 1024 +const VERSION_TERMINATION_WAIT_MS = 500 export type RuntimeBinaryDiscoveryInput = { binaryPath: string @@ -31,6 +36,39 @@ type VersionValidation = | { valid: true; version?: string } | { valid: false } +type RuntimeVersionOutput = { + on( + event: 'data', + listener: (chunk: Buffer | string) => void + ): unknown +} + +export type RuntimeVersionProcess = WaitableProcessTreeChild & { + stdout?: RuntimeVersionOutput | null + stderr?: RuntimeVersionOutput | null +} + +export type RuntimeVersionSpawn = ( + command: string, + args: string[], + options: { + detached: boolean + env: NodeJS.ProcessEnv + shell: false + stdio: ['ignore', 'pipe', 'pipe'] + windowsHide: true + } +) => RuntimeVersionProcess + +export type RuntimeVersionValidationDependencies = { + platform?: NodeJS.Platform + spawnProcess?: RuntimeVersionSpawn + terminateProcessTree?: typeof terminateProcessTreeAndWait + timeoutMs?: number + outputLimit?: number + terminationWaitMs?: number +} + function stripUnsafeCharacters(value: string): string { let result = '' let inEscapeSequence = false @@ -66,45 +104,36 @@ function safeVersion(output: string): string | undefined { return (semanticVersion?.[1] ?? firstLine).slice(0, 160) } -function terminate(child: ReturnType): void { - if (child.exitCode !== null || child.killed) { - return - } - - if (process.platform === 'win32' && child.pid) { - const killer = spawn( - 'taskkill.exe', - ['/PID', String(child.pid), '/T', '/F'], - { - shell: false, - stdio: 'ignore', - windowsHide: true - } - ) - killer.unref() - return - } - - child.kill('SIGKILL') -} - -function validateVersion(binaryPath: string): Promise { +export function validateRuntimeVersion( + binaryPath: string, + dependencies: RuntimeVersionValidationDependencies = {} +): Promise { return new Promise((resolve) => { let settled = false + let cleanupStarted = false let stdout = '' let stderr = '' let stdoutBytes = 0 let stderrBytes = 0 + const platform = dependencies.platform ?? process.platform + const timeoutMs = dependencies.timeoutMs ?? VERSION_TIMEOUT_MS + const outputLimit = + dependencies.outputLimit ?? VERSION_OUTPUT_LIMIT - const child = spawn(binaryPath, ['--version'], { - env: buildRuntimeEnvironment({}), - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) + const child = (dependencies.spawnProcess ?? spawn)( + binaryPath, + ['--version'], + { + detached: platform !== 'win32', + env: buildRuntimeEnvironment({}), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ) const finish = (result: VersionValidation): void => { - if (settled) { + if (settled || cleanupStarted) { return } settled = true @@ -112,21 +141,43 @@ function validateVersion(binaryPath: string): Promise { resolve(result) } - const exceedLimit = (): void => { - terminate(child) - finish({ valid: false }) + const failAfterCleanup = (): void => { + if (settled || cleanupStarted) { + return + } + cleanupStarted = true + clearTimeout(timeout) + void ( + dependencies.terminateProcessTree ?? + terminateProcessTreeAndWait + )(child, { + platform, + processGroup: platform !== 'win32', + signal: 'SIGKILL', + waitMs: + dependencies.terminationWaitMs ?? + VERSION_TERMINATION_WAIT_MS + }).then( + () => { + settled = true + resolve({ valid: false }) + }, + () => { + settled = true + resolve({ valid: false }) + } + ) } const timeout = setTimeout(() => { - terminate(child) - finish({ valid: false }) - }, VERSION_TIMEOUT_MS) + failAfterCleanup() + }, timeoutMs) child.stdout?.on('data', (chunk: Buffer | string) => { const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) stdoutBytes += value.byteLength - if (stdoutBytes > VERSION_OUTPUT_LIMIT) { - exceedLimit() + if (stdoutBytes > outputLimit) { + failAfterCleanup() return } stdout += value.toString('utf8') @@ -134,14 +185,22 @@ function validateVersion(binaryPath: string): Promise { child.stderr?.on('data', (chunk: Buffer | string) => { const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) stderrBytes += value.byteLength - if (stderrBytes > VERSION_OUTPUT_LIMIT) { - exceedLimit() + if (stderrBytes > outputLimit) { + failAfterCleanup() return } stderr += value.toString('utf8') }) - child.once('error', () => finish({ valid: false })) + child.once('error', () => { + if (cleanupStarted) { + return + } + finish({ valid: false }) + }) child.once('close', (code) => { + if (cleanupStarted) { + return + } if (code !== 0) { finish({ valid: false }) return @@ -286,7 +345,7 @@ export async function detectRuntimeBinary( 'bundled' ) } - const validation = await validateVersion(canonicalPath) + const validation = await validateRuntimeVersion(canonicalPath) return validation.valid ? availableDetection( input.label, @@ -305,7 +364,7 @@ export async function detectRuntimeBinary( if (!canonicalPath) { configuredPathProblem = 'invalid' } else { - const validation = await validateVersion(canonicalPath) + const validation = await validateRuntimeVersion(canonicalPath) if (validation.valid) { return availableDetection( input.label, @@ -332,7 +391,7 @@ export async function detectRuntimeBinary( continue } foundAutomaticCandidate = true - const validation = await validateVersion(canonicalPath) + const validation = await validateRuntimeVersion(canonicalPath) if (validation.valid) { return availableDetection( input.label, diff --git a/src/main/agent/runtime-extension-store.test.ts b/src/main/agent/runtime-extension-store.test.ts index fc3f439..18b4860 100644 --- a/src/main/agent/runtime-extension-store.test.ts +++ b/src/main/agent/runtime-extension-store.test.ts @@ -3,7 +3,9 @@ import { mkdtemp, readFile, readdir, + rename, rm, + symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -302,6 +304,177 @@ describe('RuntimeExtensionStore', () => { }) }) + it('rolls back an interrupted upgrade whose state was not committed', async () => { + const first = catalogEntry('1.0.0') + const second = catalogEntry('2.0.0') + const { userDataPath, store, dependencies } = await fixture({ + entries: [first], + temporaryIds: ['install-one'] + }) + await store.apply({ + type: 'install', + extensionId: first.id, + package: first.package + }) + const root = join(userDataPath, 'runtime-extensions') + const statePath = join(root, 'store.json') + const before = JSON.parse(await readFile(statePath, 'utf8')) as { + version: 2 + marketplaceEnabled: boolean + installed: Array> + } + const after = structuredClone(before) + after.installed[0] = { + ...after.installed[0], + package: second.package, + installedAt: '2026-08-17T00:00:00.000Z', + integrity: `sha512-${Buffer.from('new').toString('base64')}` + } + const extensionDirectory = join( + root, + 'extensions', + first.id + ) + const backupDirectory = join( + root, + '.staging', + 'upgrade-crash-previous' + ) + await rename(extensionDirectory, backupDirectory) + await mkdir(join(extensionDirectory, 'dist'), { recursive: true }) + await writeFile( + join(extensionDirectory, 'dist', 'index.js'), + 'export default "new"' + ) + await writeFile( + join(root, '.mutation-journal.json'), + JSON.stringify({ + version: 1, + kind: 'install', + extensionId: first.id, + temporaryId: 'upgrade-crash', + before, + after + }) + ) + + const recovered = new RuntimeExtensionStore( + userDataPath, + dependencies + ) + await expect(recovered.getSnapshot()).resolves.toMatchObject({ + installed: [ + expect.objectContaining({ package: first.package }) + ] + }) + await expect( + readFile(join(extensionDirectory, 'dist', 'index.js'), 'utf8') + ).resolves.toBe('export default {}') + await expect(readdir(join(root, '.staging'))).resolves.toEqual([]) + await expect(readdir(root)).resolves.not.toContain( + '.mutation-journal.json' + ) + }) + + it('finishes an interrupted committed removal on initialization', async () => { + const { userDataPath, store, dependencies } = await fixture({ + temporaryIds: ['install-one'] + }) + const entry = catalogEntry() + await store.apply({ + type: 'install', + extensionId: entry.id, + package: entry.package + }) + const root = join(userDataPath, 'runtime-extensions') + const statePath = join(root, 'store.json') + const before = JSON.parse(await readFile(statePath, 'utf8')) as { + version: 2 + marketplaceEnabled: boolean + installed: Array> + } + const after = { ...before, installed: [] } + const trashDirectory = join( + root, + '.staging', + 'remove-crash-removed' + ) + await rename( + join(root, 'extensions', entry.id), + trashDirectory + ) + await writeFile(statePath, JSON.stringify(after)) + await writeFile( + join(root, '.mutation-journal.json'), + JSON.stringify({ + version: 1, + kind: 'remove', + extensionId: entry.id, + temporaryId: 'remove-crash', + before, + after + }) + ) + + const recovered = new RuntimeExtensionStore( + userDataPath, + dependencies + ) + await expect(recovered.getSnapshot()).resolves.toMatchObject({ + installed: [] + }) + await expect(readdir(join(root, '.staging'))).resolves.toEqual([]) + await expect(readdir(join(root, 'extensions'))).resolves.toEqual([]) + await expect(readdir(root)).resolves.not.toContain( + '.mutation-journal.json' + ) + }) + + it('cleans only safe unjournaled managed staging directories', async () => { + const { userDataPath, dependencies } = await fixture({ + marketplaceEnabled: false + }) + const root = join(userDataPath, 'runtime-extensions') + const staging = join(root, '.staging') + const abandoned = + '00000000-0000-4000-8000-000000000101' + const abandonedBackup = + '00000000-0000-4000-8000-000000000102-previous' + const unrelated = 'user-staging-backup' + const outside = join(userDataPath, 'outside-staging') + const linked = + '00000000-0000-4000-8000-000000000103-removed' + await mkdir(staging, { recursive: true }) + await Promise.all([ + mkdir(join(staging, abandoned)), + mkdir(join(staging, abandonedBackup)), + mkdir(join(staging, unrelated)), + mkdir(outside) + ]) + await writeFile(join(staging, abandoned, 'partial.js'), 'stale') + await writeFile(join(staging, unrelated, 'keep.txt'), 'keep') + await writeFile(join(outside, 'keep.txt'), 'outside') + await symlink(outside, join(staging, linked), 'junction') + + const recovered = new RuntimeExtensionStore( + userDataPath, + dependencies + ) + await recovered.getSnapshot() + + expect(await readdir(staging)).toEqual( + expect.arrayContaining([unrelated, linked]) + ) + expect(await readdir(staging)).not.toContain(abandoned) + expect(await readdir(staging)).not.toContain(abandonedBackup) + await expect( + readFile(join(staging, unrelated, 'keep.txt'), 'utf8') + ).resolves.toBe('keep') + await expect( + readFile(join(outside, 'keep.txt'), 'utf8') + ).resolves.toBe('outside') + }) + it('rejects installer entrypoints outside the managed package', async () => { const entry = catalogEntry() const fixtureValue = await fixture({ diff --git a/src/main/agent/runtime-extension-store.ts b/src/main/agent/runtime-extension-store.ts index 4603a7e..cdab159 100644 --- a/src/main/agent/runtime-extension-store.ts +++ b/src/main/agent/runtime-extension-store.ts @@ -33,6 +33,9 @@ import { const managedDirectoryName = 'runtime-extensions' const stateFileName = 'store.json' +const journalFileName = '.mutation-journal.json' +const unjournaledStagingDirectoryPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?:-previous|-removed)?$/iu const version1StoredStateSchema = z .object({ @@ -56,6 +59,19 @@ const storedStateFileSchema = z.union([ type StoredState = z.infer +const mutationJournalSchema = z + .object({ + version: z.literal(1), + kind: z.enum(['install', 'remove']), + extensionId: runtimeExtensionIdSchema, + temporaryId: runtimeExtensionIdSchema, + before: storedStateSchema, + after: storedStateSchema + }) + .strict() + +type MutationJournal = z.infer + export interface RuntimeExtensionCatalog { list(): Promise } @@ -128,6 +144,7 @@ export class RuntimeExtensionStore { readonly managedRoot: string private readonly statePath: string + private readonly journalPath: string private state?: StoredState private stateLoad?: Promise private canonicalRoot?: string @@ -144,6 +161,7 @@ export class RuntimeExtensionStore { } this.managedRoot = resolve(userDataPath, managedDirectoryName) this.statePath = join(this.managedRoot, stateFileName) + this.journalPath = join(this.managedRoot, journalFileName) } async getSnapshot(): Promise { @@ -176,6 +194,7 @@ export class RuntimeExtensionStore { ): Promise { const parsed = runtimeExtensionActionSchema.parse(action) const changed = await this.serialize(async () => { + await this.reconcileMutationJournal() switch (parsed.type) { case 'set-marketplace-enabled': return this.setMarketplaceEnabled(parsed.enabled) @@ -306,6 +325,8 @@ export class RuntimeExtensionStore { this.canonicalRoot = await realpath(this.managedRoot) await this.createManagedDirectory('extensions') await this.createManagedDirectory('.staging') + await this.reconcileMutationJournal() + await this.cleanupUnjournaledStagingDirectories() } private async loadCatalog(): Promise { @@ -354,9 +375,12 @@ export class RuntimeExtensionStore { `${temporaryId}-previous` ) const finalDirectory = this.extensionDirectory(extensionId) - let previousMoved = false - let stagedMoved = false try { + if (await this.pathExists(backupDirectory)) { + throw new Error( + 'Extension upgrade backup path already exists' + ) + } const installedPackage = await this.dependencies.install({ entry, destinationDirectory: stagedDirectory @@ -366,12 +390,6 @@ export class RuntimeExtensionStore { installedPackage.entrypoint ) - if (await this.pathExists(finalDirectory)) { - await rename(finalDirectory, backupDirectory) - previousMoved = true - } - await rename(stagedDirectory, finalDirectory) - stagedMoved = true const entrypoint = resolve( finalDirectory, installedPackage.entrypoint @@ -392,19 +410,29 @@ export class RuntimeExtensionStore { ? { integrity: installedPackage.integrity } : {}) } - await this.persistAndSet( - this.replaceInstalled(state, installed) - ) - if (previousMoved) { - await this.removeManagedTree(backupDirectory).catch(() => undefined) + const nextState = this.replaceInstalled(state, installed) + const journal: MutationJournal = { + version: 1, + kind: 'install', + extensionId, + temporaryId, + before: state, + after: nextState } + await this.writeMutationJournal(journal) + if (await this.pathExists(finalDirectory)) { + await this.assertRealManagedDirectory(finalDirectory) + } + if (await this.pathExists(finalDirectory)) { + await rename(finalDirectory, backupDirectory) + } + await rename(stagedDirectory, finalDirectory) + await this.persistAndSet(nextState) + await this.removeManagedTree(backupDirectory) + await this.clearMutationJournal() } catch (error) { - if (stagedMoved) { - await this.removeManagedTree(finalDirectory) - } - if (previousMoved) { - await rename(backupDirectory, finalDirectory) - } + this.state = undefined + await this.reconcileMutationJournal().catch(() => undefined) throw error } finally { await this.removeManagedTree(stagedDirectory).catch(() => undefined) @@ -488,27 +516,40 @@ export class RuntimeExtensionStore { '.staging', `${randomUUID()}-removed` ) - let moved = false + const temporaryId = trashDirectory + .slice(trashDirectory.lastIndexOf(sep) + 1) + .replace(/-removed$/u, '') + runtimeExtensionIdSchema.parse(temporaryId) + if (await this.pathExists(trashDirectory)) { + throw new Error('Extension removal staging path already exists') + } + const nextState: StoredState = { + ...state, + installed: state.installed.filter( + (extension) => extension.id !== extensionId + ) + } + await this.writeMutationJournal({ + version: 1, + kind: 'remove', + extensionId, + temporaryId, + before: state, + after: nextState + }) if (await this.pathExists(finalDirectory)) { + await this.assertRealManagedDirectory(finalDirectory) await rename(finalDirectory, trashDirectory) - moved = true } try { - await this.persistAndSet({ - ...state, - installed: state.installed.filter( - (extension) => extension.id !== extensionId - ) - }) + await this.persistAndSet(nextState) } catch (error) { - if (moved) { - await rename(trashDirectory, finalDirectory) - } + this.state = undefined + await this.reconcileMutationJournal().catch(() => undefined) throw error } - if (moved) { - await this.removeManagedTree(trashDirectory).catch(() => undefined) - } + await this.removeManagedTree(trashDirectory) + await this.clearMutationJournal() } private requireInstalled( @@ -549,6 +590,171 @@ export class RuntimeExtensionStore { ) } + private writeMutationJournal(journal: MutationJournal): Promise { + return writeJsonFileAtomically( + this.journalPath, + mutationJournalSchema.parse(journal) + ) + } + + private async clearMutationJournal(): Promise { + await unlink(this.journalPath).catch((error: unknown) => { + if (!isMissingFileError(error)) { + throw error + } + }) + } + + private async reconcileMutationJournal(): Promise { + let journal: MutationJournal + try { + const status = await lstat(this.journalPath) + if ( + !status.isFile() || + status.isSymbolicLink() || + status.nlink > 1 + ) { + throw new Error( + 'Extension mutation journal must be a regular file' + ) + } + await this.assertExistingPathContained(this.journalPath) + journal = mutationJournalSchema.parse( + JSON.parse(await readFile(this.journalPath, 'utf8')) as unknown + ) + } catch (error) { + if (isMissingFileError(error)) { + return + } + throw error + } + + const stateStatus = await lstat(this.statePath) + if ( + !stateStatus.isFile() || + stateStatus.isSymbolicLink() || + stateStatus.nlink > 1 + ) { + throw new Error('Extension store state must be a regular file') + } + await this.assertExistingPathContained(this.statePath) + const stored = storedStateFileSchema.parse( + JSON.parse(await readFile(this.statePath, 'utf8')) as unknown + ) + if (stored.version !== 2) { + throw new Error( + 'Extension mutation journal requires current store state' + ) + } + const committed = isDeepStrictEqual(stored, journal.after) + const rolledBack = isDeepStrictEqual(stored, journal.before) + if (!committed && !rolledBack) { + throw new Error( + 'Extension mutation journal does not match store state' + ) + } + + const finalDirectory = this.extensionDirectory(journal.extensionId) + const stagedDirectory = this.managedPath( + '.staging', + journal.temporaryId + ) + const auxiliaryDirectory = this.managedPath( + '.staging', + journal.kind === 'install' + ? `${journal.temporaryId}-previous` + : `${journal.temporaryId}-removed` + ) + + if (journal.kind === 'install') { + if (committed) { + await this.assertInstalledStateOnDisk( + this.requireInstalled(journal.after, journal.extensionId) + ) + await this.removeManagedTree(auxiliaryDirectory) + await this.removeManagedTree(stagedDirectory) + } else { + const hadPrevious = journal.before.installed.some( + (extension) => extension.id === journal.extensionId + ) + if (await this.pathExists(auxiliaryDirectory)) { + await this.assertRealManagedDirectory(auxiliaryDirectory) + await this.removeManagedTree(finalDirectory) + await rename(auxiliaryDirectory, finalDirectory) + } else if (!hadPrevious) { + await this.removeManagedTree(finalDirectory) + } + if (hadPrevious) { + await this.assertInstalledStateOnDisk( + this.requireInstalled( + journal.before, + journal.extensionId + ) + ) + } + await this.removeManagedTree(stagedDirectory) + } + } else if (committed) { + await this.removeManagedTree(finalDirectory) + await this.removeManagedTree(auxiliaryDirectory) + } else { + if (await this.pathExists(auxiliaryDirectory)) { + await this.assertRealManagedDirectory(auxiliaryDirectory) + await this.removeManagedTree(finalDirectory) + await rename(auxiliaryDirectory, finalDirectory) + } + await this.assertInstalledStateOnDisk( + this.requireInstalled(journal.before, journal.extensionId) + ) + } + await this.clearMutationJournal() + this.state = stored + } + + private async assertRealManagedDirectory(path: string): Promise { + this.assertContained(this.managedRoot, path) + const status = await lstat(path) + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error( + 'Extension package path must be a real directory' + ) + } + await this.assertExistingPathContained(path) + } + + private async cleanupUnjournaledStagingDirectories(): Promise { + const stagingDirectory = this.managedPath('.staging') + await this.assertRealManagedDirectory(stagingDirectory) + const entries = await readdir(stagingDirectory, { + withFileTypes: true + }) + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + !unjournaledStagingDirectoryPattern.test(entry.name) + ) { + continue + } + const directory = this.managedPath('.staging', entry.name) + await this.assertRealManagedDirectory(directory) + await this.removeManagedTree(directory) + } + } + + private async assertInstalledStateOnDisk( + extension: RuntimeExtensionInstalledState + ): Promise { + this.assertExtensionEntrypoint(extension) + const directory = this.extensionDirectory(extension.id) + await this.assertRealManagedDirectory(directory) + const relativeEntrypoint = relative( + directory, + extension.entrypoint + ).split(sep).join('/') + await this.resolveEntrypoint(directory, relativeEntrypoint) + } + private assertExtensionEntrypoint( extension: RuntimeExtensionInstalledState ): void { diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index e0467fc..09f8ede 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + builtInDefaultProjectSeedDescription, + builtInDefaultProjectSeedName, + isUntouchedBuiltInDefaultProject +} from '../../shared/assistant-contracts' import { AssistantDatabase } from './assistant-database' const temporaryDirectories: string[] = [] @@ -156,7 +161,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 23', async () => { + it('migrates existing databases to schema version 25', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -185,7 +190,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(23) + ).toBe(25) expect( current .prepare( @@ -200,7 +205,12 @@ describe('AssistantDatabase', () => { .all() ).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: 'runtime_selection_json' }) + expect.objectContaining({ name: 'runtime_selection_json' }), + expect.objectContaining({ + name: 'built_in_default', + notnull: 1, + dflt_value: '0' + }) ]) ) const foreignKeys = current @@ -268,6 +278,174 @@ describe('AssistantDatabase', () => { current.close() }) + it('backfills one exact legacy built-in default candidate', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-default-project-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + DROP INDEX projects_built_in_default_unique; + ALTER TABLE projects DROP COLUMN built_in_default; + PRAGMA user_version = 24; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + const [project] = migrated.listProjects() + expect(project).toMatchObject({ + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, + builtInDefault: true + }) + expect( + project && isUntouchedBuiltInDefaultProject(project) + ).toBe(true) + migrated.close() + }) + + it('does not backfill an ambiguous legacy default identity', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-ambiguous-default-project-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const independent = initial.createProject({ + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, + rootPath: 'D:\\Independent', + defaultWorkMode: 'ask' + }) + expect(independent.builtInDefault).toBe(false) + expect(isUntouchedBuiltInDefaultProject(independent)).toBe(false) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + DROP INDEX projects_built_in_default_unique; + ALTER TABLE projects DROP COLUMN built_in_default; + PRAGMA user_version = 24; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect( + migrated + .listProjects() + .map((project) => project.builtInDefault) + ).toEqual([false, false]) + migrated.close() + }) + + it('does not mark a later exact clone after the original default was edited', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-edited-default-project-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const original = initial.listProjects()[0]! + initial.updateProject(original.id, { + name: '已编辑默认项目', + description: builtInDefaultProjectSeedDescription, + rootPath: original.rootPath, + defaultWorkMode: 'ask' + }) + const clone = initial.createProject({ + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, + rootPath: original.rootPath, + defaultWorkMode: 'ask' + }) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + DROP INDEX projects_built_in_default_unique; + ALTER TABLE projects DROP COLUMN built_in_default; + PRAGMA user_version = 24; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect( + migrated + .listProjects() + .filter((project) => project.builtInDefault) + ).toEqual([]) + expect(migrated.getProject(clone.id).builtInDefault).toBe(false) + migrated.close() + }) + + it('does not mark a later exact clone after the original default was deleted', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-deleted-default-project-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const original = initial.listProjects()[0]! + const clone = initial.createProject({ + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, + rootPath: original.rootPath, + defaultWorkMode: 'ask' + }) + initial.deleteProject(original.id, original.name) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + DROP INDEX projects_built_in_default_unique; + ALTER TABLE projects DROP COLUMN built_in_default; + PRAGMA user_version = 24; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect(migrated.getProject(clone.id).builtInDefault).toBe(false) + migrated.close() + }) + + it('does not backfill when no exact legacy candidate exists', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-missing-default-project-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + UPDATE projects + SET updated_at = created_at || '-edited'; + DROP INDEX projects_built_in_default_unique; + ALTER TABLE projects DROP COLUMN built_in_default; + PRAGMA user_version = 24; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect(migrated.listProjects()[0]?.builtInDefault).toBe(false) + migrated.close() + }) + it('idempotently migrates version 5 databases to computer control audit schema', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-control-audit-migration-') @@ -298,7 +476,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(23) + ).toBe(25) expect( current .prepare( @@ -436,7 +614,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 23 }) + ).toEqual({ user_version: 25 }) expect( inspected .prepare( @@ -566,11 +744,34 @@ describe('AssistantDatabase', () => { const database = await createDatabase() const [defaultProject] = database.listProjects() expect(defaultProject).toMatchObject({ - name: '默认项目', + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, rootPath: 'C:\\Workspace', defaultWorkMode: 'ask', + kind: 'user', + builtInDefault: true, status: 'active' }) + expect(defaultProject?.runtimeSelection).toBeUndefined() + expect(defaultProject?.createdAt).toBe(defaultProject?.updatedAt) + expect( + defaultProject && + isUntouchedBuiltInDefaultProject(defaultProject) + ).toBe(true) + const reconfiguredDefault = database.updateProject( + defaultProject!.id, + { + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, + rootPath: 'D:\\Moved', + defaultWorkMode: 'execute', + runtimeSelection: { provider: 'continue' } + } + ) + expect(reconfiguredDefault.builtInDefault).toBe(true) + expect( + isUntouchedBuiltInDefaultProject(reconfiguredDefault) + ).toBe(true) expect(database.listExperts()).toHaveLength(3) const project = database.createProject({ @@ -579,6 +780,7 @@ describe('AssistantDatabase', () => { rootPath: 'C:\\Release', defaultWorkMode: 'ask' }) + expect(project.builtInDefault).toBe(false) expect(database.listProjects()).toHaveLength(2) const updated = database.updateProject(project.id, { @@ -863,6 +1065,192 @@ describe('AssistantDatabase', () => { reopened.close() }) + it('keeps exhausted channel results terminal and observable', async () => { + const database = await createDatabase() + const entry = database.enqueueChannelResult({ + channel: 'weixin', + eventId: 'terminal-event', + conversationId: 'conversation-1', + recipientId: 'sender-1', + status: 'completed', + output: '已完成', + attachments: [ + { + name: 'result.txt', + mimeType: 'text/plain', + size: 1, + kind: 'file', + dataBase64: 'eA==' + } + ] + }) + for (let attempt = 0; attempt < 5; attempt += 1) { + database.markChannelResult(entry.id, 'failed') + } + + const terminal = database.listUndeliveredChannelResults() + expect(terminal).toEqual([ + expect.objectContaining({ + id: entry.id, + state: 'terminal', + attempts: 5, + message: expect.objectContaining({ + eventId: 'terminal-event', + output: '已完成' + }) + }) + ]) + expect(terminal[0]?.message).not.toHaveProperty('attachments') + database.close() + }) + + it('migrates exhausted legacy outbox failures to terminal state', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-channel-terminal-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const entry = initial.enqueueChannelResult({ + channel: 'weixin', + eventId: 'legacy-terminal-event', + conversationId: 'conversation-1', + recipientId: 'sender-1', + status: 'completed', + output: '已完成', + attachments: [ + { + name: 'legacy.txt', + mimeType: 'text/plain', + size: 1, + kind: 'file', + dataBase64: 'eA==' + } + ] + }) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy + .prepare( + `UPDATE channel_outbox + SET state = 'failed', attempts = 5 + WHERE id = ?` + ) + .run(entry.id) + legacy.exec('PRAGMA user_version = 23') + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + const terminal = migrated.listUndeliveredChannelResults() + expect(terminal).toEqual([ + expect.objectContaining({ + id: entry.id, + state: 'terminal', + attempts: 5 + }) + ]) + expect(terminal[0]?.message).not.toHaveProperty('attachments') + migrated.close() + }) + + it('rolls back both heartbeat failure updates atomically', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-heartbeat-rollback-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const config = database.createHeartbeatConfig( + { + scope: { kind: 'global' }, + name: '事务心跳', + timezone: 'UTC', + recurrence: { type: 'daily', localTime: '09:00' }, + enabled: true, + lookbackHours: 24, + retentionDays: 30 + }, + new Date('2026-08-16T00:00:00.000Z') + ) + const claim = database.claimHeartbeatNow( + config.id, + 'heartbeat-rollback', + 'test-owner', + new Date('2026-08-16T01:00:00.000Z') + ) + const raw = new DatabaseSync(databasePath) + raw.exec(` + CREATE TRIGGER reject_heartbeat_config_failure + BEFORE UPDATE OF last_status ON heartbeat_configs + BEGIN + SELECT RAISE(ABORT, 'forced config update failure'); + END; + `) + raw.close() + + expect(() => + database.failHeartbeatRun( + claim, + 'runtime failed', + new Date('2026-08-16T01:01:00.000Z') + ) + ).toThrow('forced config update failure') + expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({ + status: 'claimed', + attemptCount: 1, + completedAt: undefined, + error: undefined + }) + expect(database.getHeartbeatConfig(config.id).lastStatus).toBe( + 'claimed' + ) + database.close() + }) + + it('rejects heartbeat failure after its lease expires', async () => { + const database = await createDatabase() + const config = database.createHeartbeatConfig( + { + scope: { kind: 'global' }, + name: '租约过期心跳', + timezone: 'UTC', + recurrence: { type: 'daily', localTime: '09:00' }, + enabled: true, + lookbackHours: 24, + retentionDays: 30 + }, + new Date('2026-08-16T00:00:00.000Z') + ) + const claim = database.claimHeartbeatNow( + config.id, + 'expired-heartbeat', + 'expired-owner', + new Date('2026-08-16T01:00:00.000Z'), + 60_000 + ) + + expect(() => + database.failHeartbeatRun( + claim, + 'late worker failure', + new Date('2026-08-16T01:01:00.001Z') + ) + ).toThrow('Heartbeat lease is no longer active') + expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({ + status: 'claimed', + completedAt: undefined, + error: undefined + }) + expect(database.getHeartbeatConfig(config.id).lastStatus).toBe( + 'claimed' + ) + database.close() + }) + it('preserves legacy channel event claims while adding account identity', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-channel-event-migration-') @@ -2299,6 +2687,7 @@ describe('AssistantDatabase', () => { it('explicitly deletes only local conversations and cascades messages', async () => { const database = await createDatabase() const localId = '00000000-0000-4000-8000-000000000521' + const localTaskId = '00000000-0000-4000-8000-000000000523' database.replaceConversations([ { id: localId, @@ -2341,6 +2730,28 @@ describe('AssistantDatabase', () => { recurrence: 'daily', nextRunAt: '2027-01-01T00:00:00.000Z' }) + database.createTask({ + id: localTaskId, + conversationId: localId, + title: '本地对话任务', + instructions: '生成仅属于对话的回复', + workMode: 'ask' + }) + database.updateTaskStatus(localTaskId, 'completed') + const hiddenReply = database.createTextArtifact({ + taskId: localTaskId, + title: '本地对话回复', + content: '删除对话后不得进入成果列表' + }) + database.saveDelegationResult(localTaskId, { + status: 'completed', + output: '不应残留' + }) + expect( + database + .listArtifacts() + .some((artifact) => artifact.id === hiddenReply.id) + ).toBe(false) expect(database.deleteLocalConversation(localId)).toBe(true) expect(database.deleteLocalConversation(localId)).toBe(false) @@ -2357,6 +2768,10 @@ describe('AssistantDatabase', () => { expect(() => database.getConversation(localId) ).toThrow('对话不存在') + expect(() => database.getArtifact(hiddenReply.id)).toThrow( + '成果不存在' + ) + expect(database.listPendingDelegationResults()).toEqual([]) expect(() => database.deleteLocalConversation(remote.id) ).toThrow('远程对话不能作为本地对话删除') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 317901f..5003dd5 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import { + builtInDefaultProjectSeedDescription, + builtInDefaultProjectSeedName, conversationSnapshotSchema, expertCreateSchema, normalizeInteractiveWorkMode, @@ -82,6 +84,7 @@ type ProjectRow = { runtime_selection_json: string | null kind: AssistantProject['kind'] channel: ProjectChannel | null + built_in_default: number status: AssistantProject['status'] created_at: string updated_at: string @@ -427,6 +430,7 @@ function toProject(row: ProjectRow): AssistantProject { : parseRuntimeSelection(row.runtime_selection_json), kind: row.kind, channel: row.channel ?? undefined, + builtInDefault: row.built_in_default === 1, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at @@ -982,12 +986,15 @@ export class AssistantDatabase { .prepare('SELECT COUNT(*) AS count FROM projects') .get() as { count: number } if (count.count === 0) { - this.createProject({ - name: '默认项目', - description: 'GoodBuddy 默认工作区', - rootPath: defaultRootPath, - defaultWorkMode: 'ask' - }) + this.createLocalProject( + { + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, + rootPath: defaultRootPath, + defaultWorkMode: 'ask' + }, + true + ) } const expertCount = database .prepare('SELECT COUNT(*) AS count FROM experts') @@ -1283,6 +1290,13 @@ export class AssistantDatabase { } createProject(input: ProjectCreateInput): AssistantProject { + return this.createLocalProject(input, false) + } + + private createLocalProject( + input: ProjectCreateInput, + builtInDefault: boolean + ): AssistantProject { const database = this.requireDatabase() const id = randomUUID() const now = new Date().toISOString() @@ -1290,9 +1304,9 @@ export class AssistantDatabase { .prepare( `INSERT INTO projects (id, name, description, root_path, default_work_mode, - runtime_selection_json, kind, channel, status, created_at, - updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)` + runtime_selection_json, kind, channel, built_in_default, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)` ) .run( id, @@ -1303,6 +1317,7 @@ export class AssistantDatabase { input.runtimeSelection ? JSON.stringify(input.runtimeSelection) : null, + builtInDefault ? 1 : 0, now, now ) @@ -1913,6 +1928,32 @@ export class AssistantDatabase { )` ) .run(conversationId) + database + .prepare( + `DELETE FROM delegation_outbox + WHERE task_id IN ( + SELECT id FROM tasks WHERE conversation_id = ? + )` + ) + .run(conversationId) + database + .prepare( + `DELETE FROM artifacts + WHERE kind = 'markdown' + AND task_id IN ( + SELECT id + FROM tasks + WHERE conversation_id = ? + AND ( + origin = 'user' + OR ( + origin = 'delegation' + AND conversation_id NOT LIKE 'delegation:%' + ) + ) + )` + ) + .run(conversationId) database .prepare('DELETE FROM tasks WHERE conversation_id = ?') .run(conversationId) @@ -2214,7 +2255,11 @@ export class AssistantDatabase { this.requireDatabase() .prepare( `UPDATE channel_outbox - SET state = ?, + SET state = CASE + WHEN ? = 'failed' AND attempts + 1 >= 5 + THEN 'terminal' + ELSE ? + END, attempts = attempts + 1, message_json = CASE WHEN ? = 'delivered' OR attempts + 1 >= 5 @@ -2223,7 +2268,7 @@ export class AssistantDatabase { END WHERE id = ?` ) - .run(state, state, id) + .run(state, state, state, id) } listUndeliveredChannelResults( @@ -2232,7 +2277,7 @@ export class AssistantDatabase { ): Array<{ id: string message: ChannelResultMessage - state: 'pending' | 'failed' + state: 'pending' | 'failed' | 'terminal' attempts: number createdAt: number }> { @@ -2252,7 +2297,6 @@ export class AssistantDatabase { ) AS cumulative_bytes FROM channel_outbox WHERE state != 'delivered' - AND attempts < 5 ${channel === undefined ? '' : 'AND channel = ?'} ) SELECT id, message_json, state, attempts, created_at @@ -2272,7 +2316,7 @@ export class AssistantDatabase { ) as Array<{ id: string message_json: string - state: 'pending' | 'failed' + state: 'pending' | 'failed' | 'terminal' attempts: number created_at: number }> @@ -5340,32 +5384,46 @@ export class AssistantDatabase { ]! ).toISOString() : null - const result = database - .prepare( - `UPDATE heartbeat_runs - SET status = 'failed', next_attempt_at = ?, - completed_at = ?, error = ?, lease_owner = NULL, - lease_expires_at = NULL, updated_at = ? - WHERE id = ? AND status = 'claimed' AND lease_owner = ?` - ) - .run( - nextAttemptAt, - timestamp, - error.slice(0, 2_000), - timestamp, - claim.run.id, - claim.leaseOwner - ) - if (result.changes !== 1) { - throw new Error('Heartbeat lease is no longer active') + database.exec('BEGIN IMMEDIATE') + try { + const result = database + .prepare( + `UPDATE heartbeat_runs + SET status = 'failed', next_attempt_at = ?, + completed_at = ?, error = ?, lease_owner = NULL, + lease_expires_at = NULL, updated_at = ? + WHERE id = ? AND config_id = ? + AND status = 'claimed' AND lease_owner = ? + AND lease_expires_at > ?` + ) + .run( + nextAttemptAt, + timestamp, + error.slice(0, 2_000), + timestamp, + claim.run.id, + claim.config.id, + claim.leaseOwner, + timestamp + ) + if (result.changes !== 1) { + throw new Error('Heartbeat lease is no longer active') + } + const configResult = database + .prepare( + `UPDATE heartbeat_configs + SET last_status = 'failed', updated_at = ? + WHERE id = ?` + ) + .run(timestamp, claim.config.id) + if (configResult.changes !== 1) { + throw new Error('Heartbeat config no longer exists') + } + database.exec('COMMIT') + } catch (transactionError) { + database.exec('ROLLBACK') + throw transactionError } - database - .prepare( - `UPDATE heartbeat_configs - SET last_status = 'failed', updated_at = ? - WHERE id = ?` - ) - .run(timestamp, claim.config.id) return this.getHeartbeatRun(claim.run.id) } @@ -5741,12 +5799,12 @@ export class AssistantDatabase { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } - if (version.user_version > 23) { + if (version.user_version > 25) { throw new Error( `当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试` ) } - if (version.user_version === 23) { + if (version.user_version === 25) { return } if (version.user_version < 1) { @@ -5760,6 +5818,8 @@ export class AssistantDatabase { default_work_mode TEXT NOT NULL CHECK(default_work_mode IN ('ask', 'execute')), runtime_selection_json TEXT, + built_in_default INTEGER NOT NULL DEFAULT 0 + CHECK(built_in_default IN (0, 1)), status TEXT NOT NULL CHECK(status IN ('active', 'archived')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -7001,6 +7061,122 @@ export class AssistantDatabase { throw error } } + if (version.user_version < 24) { + database.exec('BEGIN IMMEDIATE') + try { + database.exec(` + ALTER TABLE channel_outbox + RENAME TO channel_outbox_legacy; + DROP INDEX IF EXISTS channel_outbox_state_created; + CREATE TABLE channel_outbox ( + id TEXT PRIMARY KEY, + channel TEXT NOT NULL, + event_id TEXT NOT NULL, + message_json TEXT NOT NULL, + state TEXT NOT NULL + CHECK( + state IN ( + 'pending', 'delivered', 'failed', 'terminal' + ) + ), + attempts INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + INSERT INTO channel_outbox + (id, channel, event_id, message_json, state, attempts, + created_at) + SELECT id, channel, event_id, + CASE + WHEN state = 'failed' AND attempts >= 5 + THEN json_remove(message_json, '$.attachments') + ELSE message_json + END, + CASE + WHEN state = 'failed' AND attempts >= 5 + THEN 'terminal' + ELSE state + END, + attempts, created_at + FROM channel_outbox_legacy; + DROP TABLE channel_outbox_legacy; + CREATE INDEX channel_outbox_state_created + ON channel_outbox(state, created_at); + PRAGMA user_version = 24; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + if (version.user_version < 25) { + const projectColumns = new Set( + ( + database.prepare('PRAGMA table_info(projects)').all() as Array<{ + name: string + }> + ).map((column) => column.name) + ) + database.exec('BEGIN IMMEDIATE') + try { + if (!projectColumns.has('built_in_default')) { + database.exec(` + ALTER TABLE projects + ADD COLUMN built_in_default INTEGER NOT NULL DEFAULT 0 + CHECK(built_in_default IN (0, 1)); + `) + } + database.exec('UPDATE projects SET built_in_default = 0') + const legacyCandidates = database + .prepare( + `SELECT id + FROM projects + WHERE name = ? + AND description = ? + AND kind = 'user' + AND channel IS NULL + AND status = 'active' + AND default_work_mode = 'ask' + AND runtime_selection_json IS NULL + AND created_at = updated_at + LIMIT 2` + ) + .all( + builtInDefaultProjectSeedName, + builtInDefaultProjectSeedDescription + ) as Array<{ id: string }> + const originalProject = database + .prepare( + `SELECT id + FROM projects + WHERE rowid = 1` + ) + .get() as { id: string } | undefined + if ( + legacyCandidates.length === 1 && + legacyCandidates[0]!.id === originalProject?.id + ) { + database + .prepare( + `UPDATE projects + SET built_in_default = 1 + WHERE id = ?` + ) + .run(legacyCandidates[0]!.id) + } + database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS + projects_built_in_default_unique + ON projects(built_in_default) + WHERE built_in_default = 1; + PRAGMA user_version = 25; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/heartbeat-database.test.ts b/src/main/assistant/heartbeat-database.test.ts index 6478c77..5bad560 100644 --- a/src/main/assistant/heartbeat-database.test.ts +++ b/src/main/assistant/heartbeat-database.test.ts @@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => { ).count check.close() migrated.close() - expect(version).toBe(23) + expect(version).toBe(25) expect(heartbeatTableCount).toBe(4) }) diff --git a/src/main/channels/channel-driver.ts b/src/main/channels/channel-driver.ts index c357ce5..3df337c 100644 --- a/src/main/channels/channel-driver.ts +++ b/src/main/channels/channel-driver.ts @@ -74,7 +74,7 @@ export class MemoryDedupStore implements DedupStore { export type OutboxEntry = { id: string message: ChannelResultMessage - state: 'pending' | 'delivered' | 'failed' + state: 'pending' | 'delivered' | 'failed' | 'terminal' attempts: number createdAt: number } @@ -129,6 +129,7 @@ export class MemoryOutbox implements Outbox { entry.state = 'failed' entry.attempts += 1 if (entry.attempts >= 5) { + entry.state = 'terminal' entry.message = this.withoutAttachments(entry.message) } } diff --git a/src/main/channels/channel-service.test.ts b/src/main/channels/channel-service.test.ts index 45dc621..0780087 100644 --- a/src/main/channels/channel-service.test.ts +++ b/src/main/channels/channel-service.test.ts @@ -432,6 +432,49 @@ describe('ChannelService', () => { await service.stop() }) + it('reports terminal outbox entries without retrying them', async () => { + const driver = new FakeChannelDriver() + const outbox = new MemoryOutbox() + const entry = outbox.enqueue({ + channel: driver.channel, + eventId: 'terminal-delivery', + conversationId: 'conversation-1', + recipientId: 'allowed-user', + status: 'completed', + output: '完成' + }) + for (let attempt = 0; attempt < 5; attempt += 1) { + outbox.markFailed(entry.id) + } + const deliveryFailure = vi.fn() + const service = new ChannelService( + driver, + async () => ({ status: 'completed' }), + { + allowedSenderIds: ['allowed-user'], + outbox, + onDeliveryFailure: deliveryFailure + } + ) + + await service.start() + + expect(driver.sent).toEqual([]) + expect(deliveryFailure).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('已达到重试上限') + }) + ) + expect(await outbox.listUndelivered()).toEqual([ + expect.objectContaining({ + id: entry.id, + state: 'terminal', + attempts: 5 + }) + ]) + await service.stop() + }) + it('releases the event claim when no durable result can be queued', async () => { const driver = new FakeChannelDriver() const store = new MemoryDedupStore() diff --git a/src/main/channels/channel-service.ts b/src/main/channels/channel-service.ts index e25835d..1d25acf 100644 --- a/src/main/channels/channel-service.ts +++ b/src/main/channels/channel-service.ts @@ -198,7 +198,12 @@ export class ChannelService { if (this.state !== 'running') { return } - if (entry.attempts >= 5) { + if (entry.state === 'terminal' || entry.attempts >= 5) { + this.onDeliveryFailure?.( + new Error( + `通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止` + ) + ) continue } try { diff --git a/src/main/document-ocr-model-manager.test.ts b/src/main/document-ocr-model-manager.test.ts index a1fde79..9de9ef7 100644 --- a/src/main/document-ocr-model-manager.test.ts +++ b/src/main/document-ocr-model-manager.test.ts @@ -2,6 +2,8 @@ import { createHash } from 'node:crypto' import { mkdtemp, mkdir, + readFile, + readdir, rm, writeFile } from 'node:fs/promises' @@ -186,6 +188,26 @@ afterEach(async () => { }) describe('DocumentOcrModelManager', () => { + it('reads active progress without creating or scanning model storage', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-document-ocr-progress-') + ) + temporaryDirectories.push(directory) + const getDownloadSource = vi.fn(() => 'modelscope' as const) + const manager = new DocumentOcrModelManager({ + userDataDirectory: directory, + fetch: vi.fn(), + catalog: [], + getDownloadSource + }) + + expect(manager.getProgressSnapshot()).toEqual({ operations: [] }) + expect(getDownloadSource).not.toHaveBeenCalled() + await expect( + readdir(join(directory, 'models', 'document-ocr')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('reports a removed catalog model as unavailable', async () => { const { manager } = await createManager() @@ -323,6 +345,107 @@ describe('DocumentOcrModelManager', () => { expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/') }) + it('revalidates externally changed OCR files after a successful status check', async () => { + const { directory, manager, modelBytes } = await createManager() + await manager.install('pp-ocrv6-tiny') + await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({ + available: true, + verified: true + }) + + await writeFile( + join( + directory, + 'models', + 'document-ocr', + 'pp-ocrv6-tiny', + 'detection.onnx' + ), + Buffer.alloc(modelBytes.detection.byteLength, 0x7f) + ) + + await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({ + available: false, + verified: false + }) + }) + + it('does not let an invalidated verification survive remove and reinstall', async () => { + const { manager } = await createManager() + await manager.install('pp-ocrv6-tiny') + + const checking = manager.getStatus('pp-ocrv6-tiny') + await manager.remove('pp-ocrv6-tiny') + await expect(checking).resolves.toMatchObject({ + available: false, + verified: false + }) + await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({ + available: false, + verified: false + }) + + await manager.install('pp-ocrv6-tiny') + await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({ + available: true, + verified: true + }) + }) + + it('cleans only manager-owned stale staging and partial artifacts', async () => { + const { directory, manager, modelBytes } = await createManager() + await manager.install('pp-ocrv6-tiny') + const root = join(directory, 'models', 'document-ocr') + const modelDirectory = join(root, 'pp-ocrv6-tiny') + const staleStaging = + '.install-pp-ocrv6-tiny-00000000-0000-4000-8000-000000000001' + const unrelatedStaging = '.install-pp-ocrv6-tiny-user-backup' + await mkdir(join(root, staleStaging)) + await writeFile( + join(root, staleStaging, 'detection.onnx.partial'), + 'stale' + ) + await mkdir(join(root, unrelatedStaging)) + await writeFile(join(root, unrelatedStaging, 'keep.txt'), 'keep') + await writeFile( + join(modelDirectory, 'detection.onnx.partial'), + 'interrupted' + ) + await writeFile(join(modelDirectory, 'notes.partial'), 'keep') + await writeFile(join(root, 'user.partial'), 'keep') + + await expect(manager.getSnapshot()).resolves.toMatchObject({ + installed: [expect.objectContaining({ id: 'pp-ocrv6-tiny' })] + }) + + expect(await readdir(root)).toEqual( + expect.arrayContaining([ + 'pp-ocrv6-tiny', + unrelatedStaging, + 'user.partial' + ]) + ) + expect(await readdir(root)).not.toContain(staleStaging) + expect(await readdir(modelDirectory)).toEqual( + expect.arrayContaining([ + 'manifest.json', + 'detection.onnx', + 'recognition.onnx', + 'dictionary.yml', + 'notes.partial' + ]) + ) + expect(await readdir(modelDirectory)).not.toContain( + 'detection.onnx.partial' + ) + await expect( + readFile(join(modelDirectory, 'detection.onnx')) + ).resolves.toEqual(Buffer.from(modelBytes.detection)) + await expect( + readFile(join(root, unrelatedStaging, 'keep.txt'), 'utf8') + ).resolves.toBe('keep') + }) + it('downloads the same canonical package from Hugging Face', async () => { const { manager } = await createManager() diff --git a/src/main/document-ocr-model-manager.ts b/src/main/document-ocr-model-manager.ts index 85d712e..1846ce9 100644 --- a/src/main/document-ocr-model-manager.ts +++ b/src/main/document-ocr-model-manager.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { createHash } from 'node:crypto' import { copyFile, lstat, @@ -11,11 +11,12 @@ import { stat, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import { resolve } from 'node:path' import { documentOcrAssetsSchema, documentOcrModelCatalogEntrySchema, documentOcrModelCatalogViewEntrySchema, + documentOcrModelProgressSnapshotSchema, documentOcrModelSnapshotSchema, documentParsingModelStatusSchema, installedDocumentOcrModelSchema, @@ -25,6 +26,7 @@ import { type DocumentOcrModelCatalogViewEntry, type DocumentOcrModelFile, type DocumentOcrModelOperation, + type DocumentOcrModelProgressSnapshot, type DocumentOcrModelSnapshot, type InstalledDocumentOcrModel } from '../shared/document-parsing-contracts' @@ -41,10 +43,20 @@ import { extractModelArchive } from './model-archive' import { fetchModelDownloadResponse } from './model-download-transport' +import { + MODEL_PARTIAL_SUFFIX, + attachModelAbortSignal, + cleanupStaleModelInstallArtifacts, + createModelStagingDirectory, + ensureModelOperationNotAborted, + hashModelFile, + managedModelChild, + writeModelBuffer +} from './model-package-utils' +import { isMissingFileError } from './settings-file-utils' const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024 const MANIFEST_FILE_NAME = 'manifest.json' -const PARTIAL_SUFFIX = '.partial' const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024 const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024 const executableExtensionPattern = @@ -55,6 +67,11 @@ type ActiveOperation = { progress: DocumentOcrModelOperation } +type ActiveVerification = { + generation: number + promise: Promise +} + export type DocumentOcrModelManagerOptions = { userDataDirectory: string fetch: typeof fetch @@ -65,16 +82,6 @@ export type DocumentOcrModelManagerOptions = { maxFileBytes?: number } -function abortError(): DOMException { - return new DOMException('The operation was aborted', 'AbortError') -} - -function ensureNotAborted(signal: AbortSignal): void { - if (signal.aborted) { - throw abortError() - } -} - function cloneCatalogEntry( entry: DocumentOcrModelCatalogEntry ): DocumentOcrModelCatalogEntry { @@ -99,43 +106,17 @@ function toCatalogView(entry: DocumentOcrModelCatalogEntry) { } function safeChild(parent: string, name: string): string { - const child = resolve(parent, name) - if (dirname(child) !== resolve(parent)) { - throw new Error('OCR 模型路径超出受管目录') - } - return child + return managedModelChild( + parent, + name, + 'OCR 模型路径超出受管目录' + ) } function toArrayBuffer(buffer: Buffer): ArrayBuffer { return Uint8Array.from(buffer).buffer } -async function hashFile( - path: string, - signal?: AbortSignal -): Promise<{ size: number; sha256: string }> { - const handle = await open(path, 'r') - const hash = createHash('sha256') - const buffer = Buffer.allocUnsafe(64 * 1024) - let size = 0 - try { - while (true) { - if (signal) { - ensureNotAborted(signal) - } - const { bytesRead } = await handle.read(buffer, 0, buffer.length) - if (bytesRead === 0) { - break - } - hash.update(buffer.subarray(0, bytesRead)) - size += bytesRead - } - } finally { - await handle.close() - } - return { size, sha256: hash.digest('hex') } -} - function parseYamlScalar(value: string): string { if (value.startsWith("'") && value.endsWith("'")) { return value.slice(1, -1).replace(/''/gu, "'") @@ -184,7 +165,8 @@ export class DocumentOcrModelManager { | Promise private readonly maxFileBytes: number private readonly operations = new Map() - private readonly verifiedModels = new Map>() + private readonly verifiedModels = new Map() + private readonly verificationGenerations = new Map() constructor(options: DocumentOcrModelManagerOptions) { if (!options.userDataDirectory.trim()) { @@ -220,6 +202,7 @@ export class DocumentOcrModelManager { async getSnapshot(): Promise { await this.ensureRoot() + await this.cleanupStaleArtifacts() const [selectedDownloadSource, installed] = await Promise.all([ this.getDownloadSource(), this.readInstalled() @@ -235,6 +218,14 @@ export class DocumentOcrModelManager { }) } + getProgressSnapshot(): DocumentOcrModelProgressSnapshot { + return documentOcrModelProgressSnapshotSchema.parse({ + operations: [...this.operations.values()].map((operation) => ({ + ...operation.progress + })) + }) + } + async getStatus( modelId: string ): Promise> { @@ -317,7 +308,7 @@ export class DocumentOcrModelManager { await this.assertNotInstalled(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id) for (const file of resolvedPackage.files) { - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) operation.progress.phase = 'transferring' operation.progress.currentFile = file.name await this.downloadFile( @@ -335,10 +326,10 @@ export class DocumentOcrModelManager { stagingDirectory, operation.controller.signal ) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) await rename(stagingDirectory, this.modelDirectory(entry.id)) stagingDirectory = undefined - this.verifiedModels.delete(entry.id) + this.invalidateVerification(entry.id) return installed } finally { detachAbort() @@ -373,7 +364,7 @@ export class DocumentOcrModelManager { stagingDirectory = await this.createStagingDirectory(entry.id) operation.progress.phase = 'transferring' for (const file of entry.files) { - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) operation.progress.currentFile = file.name const sourceFile = safeChild(source, file.name) const destination = safeChild(stagingDirectory, file.name) @@ -391,10 +382,10 @@ export class DocumentOcrModelManager { stagingDirectory, operation.controller.signal ) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) await rename(stagingDirectory, this.modelDirectory(entry.id)) stagingDirectory = undefined - this.verifiedModels.delete(entry.id) + this.invalidateVerification(entry.id) return installed } finally { detachAbort() @@ -521,10 +512,10 @@ export class DocumentOcrModelManager { `${JSON.stringify(installed, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' } ) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) await rename(stagingDirectory, this.modelDirectory(entry.id)) stagingDirectory = undefined - this.verifiedModels.delete(entry.id) + this.invalidateVerification(entry.id) return installed } finally { this.operations.delete(entry.id) @@ -547,7 +538,7 @@ export class DocumentOcrModelManager { async remove(modelId: string): Promise { const id = localOcrModelIdSchema.parse(modelId) this.cancel(id) - this.verifiedModels.delete(id) + this.invalidateVerification(id) await rm(this.modelDirectory(id), { recursive: true, force: true @@ -560,6 +551,7 @@ export class DocumentOcrModelManager { } this.operations.clear() this.verifiedModels.clear() + this.verificationGenerations.clear() } private async ensureRoot(): Promise { @@ -613,16 +605,7 @@ export class DocumentOcrModelManager { signal: AbortSignal | undefined, controller: AbortController ): () => void { - if (!signal) { - return () => undefined - } - const abort = (): void => controller.abort() - if (signal.aborted) { - controller.abort() - } else { - signal.addEventListener('abort', abort, { once: true }) - } - return () => signal.removeEventListener('abort', abort) + return attachModelAbortSignal(signal, controller) } private async assertNotInstalled(modelId: string): Promise { @@ -630,11 +613,7 @@ export class DocumentOcrModelManager { await lstat(this.modelDirectory(modelId)) throw new Error('OCR 模型已安装') } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { + if (isMissingFileError(error)) { return } throw error @@ -642,12 +621,11 @@ export class DocumentOcrModelManager { } private async createStagingDirectory(modelId: string): Promise { - const directory = safeChild( + return createModelStagingDirectory( this.rootDirectory, - `.install-${modelId}-${randomUUID()}` + modelId, + 'OCR 模型路径超出受管目录' ) - await mkdir(directory, { recursive: false }) - return directory } private async downloadFile( @@ -682,29 +660,34 @@ export class DocumentOcrModelManager { throw new Error(`OCR 模型文件大小不匹配:${file.name}`) } - const partialPath = `${destination}${PARTIAL_SUFFIX}` + const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}` const handle = await open(partialPath, 'wx') const reader = response.body.getReader() const hash = createHash('sha256') let written = 0 try { while (true) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) const result = await reader.read() if (result.done) { break } - written += result.value.byteLength if ( - written > file.size || - written > this.maxFileBytes + written + result.value.byteLength > file.size || + written + result.value.byteLength > this.maxFileBytes ) { await reader.cancel() throw new RangeError(`OCR 模型文件过大:${file.name}`) } - await handle.write(result.value) - hash.update(result.value) - operation.progress.completedBytes += result.value.byteLength + const persistedBytes = await writeModelBuffer( + handle, + result.value, + (persisted) => { + hash.update(persisted) + operation.progress.completedBytes += persisted.byteLength + } + ) + written += persistedBytes } } catch (error) { await reader.cancel().catch(() => undefined) @@ -732,7 +715,7 @@ export class DocumentOcrModelManager { } const entries = await readdir(sourceDirectory, { withFileTypes: true }) for (const localEntry of entries) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) if ( localEntry.isSymbolicLink() || executableExtensionPattern.test(localEntry.name) @@ -741,13 +724,13 @@ export class DocumentOcrModelManager { } } for (const file of entry.files) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) const path = safeChild(sourceDirectory, file.name) const info = await lstat(path) if (!info.isFile() || info.isSymbolicLink()) { throw new Error(`OCR 模型文件必须是普通文件:${file.name}`) } - const actual = await hashFile(path, signal) + const actual = await hashModelFile(path, signal) if ( actual.size !== file.size || actual.sha256 !== file.sha256 @@ -765,11 +748,11 @@ export class DocumentOcrModelManager { ): Promise { const files = [] for (const file of entry.files) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) files.push({ name: file.name, role: file.role, - ...(await hashFile( + ...(await hashModelFile( safeChild(stagingDirectory, file.name), signal )) @@ -854,7 +837,9 @@ export class DocumentOcrModelManager { candidate.name === file.name && candidate.role === file.role ) - const actual = await hashFile(safeChild(directory, file.name)) + const actual = await hashModelFile( + safeChild(directory, file.name) + ) if ( !installed || actual.size !== file.size || @@ -870,15 +855,37 @@ export class DocumentOcrModelManager { private getVerifiedStatus( entry: DocumentOcrModelCatalogEntry ): Promise { - let verification = this.verifiedModels.get(entry.id) - if (!verification) { - verification = this.verifyInstalledModel(entry).catch((error) => { - this.verifiedModels.delete(entry.id) - throw error - }) - this.verifiedModels.set(entry.id, verification) + const generation = this.verificationGenerations.get(entry.id) ?? 0 + const active = this.verifiedModels.get(entry.id) + if (active?.generation === generation) { + return active.promise } - return verification + const verification = this.verifyInstalledModel(entry).then(() => { + if ( + (this.verificationGenerations.get(entry.id) ?? 0) !== + generation + ) { + throw new Error('OCR 模型在校验期间已发生变化') + } + }) + const tracked = verification.finally(() => { + if (this.verifiedModels.get(entry.id)?.promise === tracked) { + this.verifiedModels.delete(entry.id) + } + }) + this.verifiedModels.set(entry.id, { + generation, + promise: tracked + }) + return tracked + } + + private invalidateVerification(modelId: string): void { + this.verificationGenerations.set( + modelId, + (this.verificationGenerations.get(modelId) ?? 0) + 1 + ) + this.verifiedModels.delete(modelId) } private async loadVerifiedAssets( @@ -932,4 +939,19 @@ export class DocumentOcrModelManager { dictionary: loaded.get('dictionary') }) } + + private cleanupStaleArtifacts(): Promise { + return cleanupStaleModelInstallArtifacts({ + rootDirectory: this.rootDirectory, + isModelId: (value) => + localOcrModelIdSchema.safeParse(value).success, + activeModelIds: new Set(this.operations.keys()), + partialFileNames: new Set( + this.catalog.flatMap((entry) => + entry.files.map((file) => file.name) + ) + ), + escapeMessage: 'OCR 模型路径超出受管目录' + }) + } } diff --git a/src/main/index.ts b/src/main/index.ts index b547bcd..d392bef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,10 +49,7 @@ import { } from './window' import { createTrayIcon } from './tray-icon' import { resolveBundledRuntimePaths } from './agent/bundled-runtimes' -import type { - ContinueHostChild, - ContinueHostLauncher -} from './agent/continue-host-adapter' +import type { ContinueHostLauncher } from './agent/continue-host-adapter' import { resolvePortableUserDataPath } from './portable-user-data' import { BrowserService } from './browser/browser-service' import { SubagentService } from './assistant/subagent-service' @@ -83,7 +80,11 @@ import { type DeepSeekHarnessFork } from './agent/deepseek-harness-utility-launcher' import { buildControlledHarnessEnvironment } from './agent/process-environment' -import { runStartupPrerequisites } from './startup-prerequisites' +import { + createStartupFailureDiagnostic, + formatStartupFailureMessage, + runStartupPrerequisites +} from './startup-prerequisites' import { RuntimeExtensionStore } from './agent/runtime-extension-store' import { DshNpmExtensionInstaller, @@ -95,8 +96,14 @@ import { repairStaleWindowsNotificationShortcuts, resolveWindowsAppUserModelId } from './windows-notification-identity' +import { ShortcutSettingsStore } from './shortcut-settings-store' +import { ShortcutSettingsService } from './shortcut-settings-service' +import { defaultGlobalShortcutSettings } from '../shared/shortcut' +import { requestProcessTreeTermination } from './agent/child-process-termination' +import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter' -const shortcut = 'CommandOrControl+Shift+Space' +const legacyDefaultShortcut = + defaultGlobalShortcutSettings.accelerator const mainModuleDirectory = dirname(fileURLToPath(import.meta.url)) const portableUserDataPath = resolvePortableUserDataPath({ packaged: app.isPackaged, @@ -205,39 +212,28 @@ const launchContinueHost: ContinueHostLauncher = ( stdio: 'pipe' } ) - let exitCode: number | null = null - let killed = false - utilityChild.on('exit', (code) => { - exitCode = code - }) - - const child: ContinueHostChild = { - get exitCode() { - return exitCode - }, - get killed() { - return killed - }, + return createContinueUtilityProcessChild({ get pid() { return utilityChild.pid }, stderr: utilityChild.stderr, - once: (_event, listener) => { - utilityChild.once('error', (_type, location, report) => { - listener( - new Error( - `Continue 宿主进程异常(${location}):${report.slice(0, 500)}` - ) - ) - }) - return child + kill: () => utilityChild.kill(), + onExit: (listener) => { + utilityChild.on('exit', listener) }, - kill: () => { - killed = true - return utilityChild.kill() + onceExit: (listener) => { + utilityChild.once('exit', listener) + }, + onceError: (listener) => { + utilityChild.once('error', listener) + }, + removeExitListener: (listener) => { + utilityChild.removeListener('exit', listener) + }, + removeErrorListener: (listener) => { + utilityChild.removeListener('error', listener) } - } - return child + }) } const forkDeepSeekHarness: DeepSeekHarnessFork = ( @@ -254,20 +250,7 @@ const forkDeepSeekHarness: DeepSeekHarnessFork = ( function terminateHarnessUtilityProcess( child: ReturnType ): void { - if (process.platform === 'win32' && child.pid) { - const killer = spawn( - 'taskkill.exe', - ['/PID', String(child.pid), '/T', '/F'], - { - shell: false, - stdio: 'ignore', - windowsHide: true - } - ) - killer.unref() - return - } - child.kill() + requestProcessTreeTermination(child, { spawn }) } const launchWechatSidecar: WechatSidecarLauncher = () => { @@ -521,6 +504,12 @@ if (hasSingleInstanceLock) { parseDocument: documentParsingService.parse }) knowledgeService = startupKnowledgeService + let activeEmbeddingProvider: + | ReturnType + | undefined + let activeRerankProvider: + | ReturnType + | undefined const startupAssistantDatabase = new AssistantDatabase( join(app.getPath('userData'), 'assistant.sqlite') ) @@ -617,13 +606,29 @@ if (hasSingleInstanceLock) { }, initializeKnowledgeAndGateway: async () => { await startupKnowledgeService.initialize() + const embeddingProvider = createEmbeddingProvider( + initialResolvedSettings + ) + const rerankProvider = createRerankProvider( + initialResolvedSettings + ) await Promise.all([ startupKnowledgeService.setEmbeddingProvider( - createEmbeddingProvider(initialResolvedSettings) - ).catch(() => undefined), + embeddingProvider + ).then( + () => { + activeEmbeddingProvider = embeddingProvider + }, + () => undefined + ), startupKnowledgeService.setRerankProvider( - createRerankProvider(initialResolvedSettings) - ).catch(() => undefined) + rerankProvider + ).then( + () => { + activeRerankProvider = rerankProvider + }, + () => undefined + ) ]) await startupKnowledgeGateway.start() }, @@ -663,11 +668,19 @@ if (hasSingleInstanceLock) { }) const approvalBroker = new ToolApprovalBroker() - const shortcutRegistered = globalShortcut.register(shortcut, () => { - if (mainWindow) { - toggleWindow(mainWindow) - } - }) + const shortcutSettingsService = new ShortcutSettingsService( + new ShortcutSettingsStore( + join(app.getPath('userData'), 'shortcut-settings.json') + ), + globalShortcut, + () => { + if (mainWindow) { + toggleWindow(mainWindow) + } + }, + process.platform + ) + await shortcutSettingsService.initialize() let runtimeReconfigurationQueue: Promise = Promise.resolve() let runtimeReconfigurationClosing = false @@ -677,24 +690,97 @@ if (hasSingleInstanceLock) { throw new Error('Runtime 配置正在关闭') } const settings = await settingsStore.getResolvedSettings() - if (knowledgeService) { - await knowledgeService.setEmbeddingProvider( - createEmbeddingProvider(settings) - ) - await knowledgeService.setRerankProvider( - createRerankProvider(settings) + const nextEmbeddingProvider = + createEmbeddingProvider(settings) + const nextRerankProvider = createRerankProvider(settings) + let nextRuntime: AgentRuntime | undefined + let nextSubagentRuntime: AgentRuntime | undefined + let nextSubagentProfileRuntimes: + | ReadonlyMap + | undefined + try { + nextSubagentRuntime = createDefaultModelRuntime( + defaultWorkspace, + settings ) + nextSubagentProfileRuntimes = + createSubagentProfileRuntimes( + defaultWorkspace, + settings + ) + if (runtime) { + nextRuntime = await createConfiguredRuntime(settings) + } + } catch (error) { + await Promise.allSettled([ + nextRuntime?.dispose(), + nextSubagentRuntime?.dispose(), + ...[ + ...(nextSubagentProfileRuntimes?.values() ?? []) + ].map((candidate) => candidate.dispose()) + ]) + throw error } - if (runtime) { - await runtime.replace( - await createConfiguredRuntime(settings) + + let runtimeConsumed = false + let subagentRuntimesConsumed = false + try { + if (knowledgeService) { + await Promise.all([ + knowledgeService.setEmbeddingProvider( + nextEmbeddingProvider + ), + knowledgeService.setRerankProvider( + nextRerankProvider + ) + ]) + } + if (runtime && nextRuntime) { + runtimeConsumed = true + await runtime.replace(nextRuntime) + } + subagentRuntimesConsumed = true + await subagentService.replaceRuntimes( + nextSubagentRuntime, + nextSubagentProfileRuntimes ) + await selectedRuntimeManager?.reset() + activeEmbeddingProvider = nextEmbeddingProvider + activeRerankProvider = nextRerankProvider + } catch (activationError) { + const rollbackResults = knowledgeService + ? await Promise.allSettled([ + knowledgeService.setEmbeddingProvider( + activeEmbeddingProvider + ), + knowledgeService.setRerankProvider( + activeRerankProvider + ) + ]) + : [] + await Promise.allSettled([ + runtimeConsumed ? undefined : nextRuntime?.dispose(), + subagentRuntimesConsumed + ? undefined + : nextSubagentRuntime.dispose(), + ...(subagentRuntimesConsumed + ? [] + : [...nextSubagentProfileRuntimes.values()].map( + (candidate) => candidate.dispose() + )) + ]) + const rollbackErrors = rollbackResults.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (rollbackErrors.length > 0) { + throw new AggregateError( + [activationError, ...rollbackErrors], + 'Runtime 激活失败,且模型服务回滚未能完成', + { cause: activationError } + ) + } + throw activationError } - await selectedRuntimeManager?.reset() - await subagentService.replaceRuntimes( - createDefaultModelRuntime(defaultWorkspace, settings), - createSubagentProfileRuntimes(defaultWorkspace, settings) - ) }) runtimeReconfigurationQueue = operation.catch(() => undefined) return operation @@ -707,7 +793,7 @@ if (hasSingleInstanceLock) { removeIpcHandlers = registerIpcHandlers( mainWindow, runtime, - shortcutRegistered ? shortcut : '未注册', + legacyDefaultShortcut, settingsStore, capabilityService, contextManager, @@ -735,7 +821,8 @@ if (hasSingleInstanceLock) { documentOcrBroker, releaseNotesService, goodbuddyConfigService, - runtimeExtensionStore + runtimeExtensionStore, + shortcutSettingsService ) loadMainWindow(mainWindow) setImmediate(() => { @@ -786,10 +873,14 @@ if (hasSingleInstanceLock) { showWindow(mainWindow) } }) - }).catch(() => { + }).catch((error: unknown) => { + console.error( + 'GoodBuddy startup failed', + createStartupFailureDiagnostic(error) + ) dialog.showErrorBox( 'GoodBuddy 启动失败', - '本地数据或 Runtime 服务初始化失败。请重启应用;若问题持续,请备份后清理应用数据。' + formatStartupFailureMessage(error) ) app.quit() }) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 7d70e52..f43c9d0 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -158,6 +158,27 @@ describe('registerIpcHandlers computer capabilities', () => { let browserStateListener: | ((state: BrowserLiveState) => void) | undefined + const shortcutSnapshot = { + settings: { + enabled: true, + accelerator: 'Control+Alt+K' + }, + defaultSettings: { + enabled: true, + accelerator: 'CommandOrControl+Shift+Space' + }, + displayAccelerator: 'Ctrl + Alt + K', + registered: true, + registeredAccelerator: 'Control+Alt+K', + status: 'registered' as const + } + const shortcutSettingsService = { + getSnapshot: vi.fn(() => shortcutSnapshot), + update: vi.fn(async () => ({ + ok: true as const, + snapshot: shortcutSnapshot + })) + } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, @@ -182,13 +203,55 @@ describe('registerIpcHandlers computer capabilities', () => { browserStateListener = listener return vi.fn() } - } + }, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + shortcutSettingsService as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } + expect( + electronMocks.handlers.get(ipcChannels.appInfo)?.(event) + ).toMatchObject({ + shortcut: 'Ctrl + Alt + K', + shortcutStatus: 'registered' + }) + expect( + electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.( + event + ) + ).toEqual(shortcutSnapshot) + await expect( + electronMocks.handlers.get(ipcChannels.shortcutSettingsUpdate)?.( + event, + { enabled: true, accelerator: 'ctrl+alt+k' } + ) + ).resolves.toEqual({ + ok: true, + snapshot: shortcutSnapshot + }) + expect(shortcutSettingsService.update).toHaveBeenCalledWith({ + enabled: true, + accelerator: 'Control+Alt+K' + }) + await expect( electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event) ).resolves.toEqual([]) @@ -493,6 +556,9 @@ describe('registerIpcHandlers model download source routing', () => { } const documentOcrModelManager = { install: vi.fn(async () => undefined), + getProgressSnapshot: vi.fn(() => ({ + operations: [] + })), getRepositoryUrl: vi.fn( () => 'https://huggingface.co/example/ocr' ) @@ -589,6 +655,23 @@ describe('registerIpcHandlers model download source routing', () => { 'hugging-face' ) + expect( + electronMocks.handlers.get( + ipcChannels.documentOcrModelsProgress + )?.(event) + ).toEqual({ operations: [] }) + expect( + documentOcrModelManager.getProgressSnapshot + ).toHaveBeenCalledOnce() + expect(() => + electronMocks.handlers + .get(ipcChannels.documentOcrModelsProgress) + ?.({ + sender: {}, + senderFrame: webContents.mainFrame + }) + ).toThrow('拒绝来自未知窗口的 IPC 请求') + await expect( electronMocks.handlers.get( ipcChannels.speechModelsOpenRepository @@ -815,6 +898,107 @@ describe('registerIpcHandlers DSH runtime extensions', () => { }) }) +function runtimeUpdateFixture( + workspacePath: string, + provider: 'model' | 'opencode' | 'continue' = 'model' +) { + const profileId = '00000000-0000-4000-8000-000000000095' + const profile = { + id: profileId, + name: 'Default', + baseUrl: 'https://model.example/v1', + modelName: 'model', + protocol: 'openai-chat-completions' as const, + authentication: 'api-key' as const, + imageGenerationQuality: 'auto' as const, + apiKeyConfigured: true, + credentialSource: 'encrypted' as const + } + const publicSettings = { + provider, + modelBaseUrl: profile.baseUrl, + modelName: profile.modelName, + modelProtocol: profile.protocol, + modelAuthentication: profile.authentication, + imageGenerationQuality: profile.imageGenerationQuality, + opencodeBaseUrl: '', + opencodeEmbedded: true, + opencodeBinaryPath: '', + opencodeConfigPath: '', + continueBinaryPath: '', + continueConfigPath: '', + continueMode: 'chat' as const, + subagentSmartRoutingEnabled: false, + knowledgeEmbeddingEnabled: false, + knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1', + knowledgeEmbeddingModel: 'embedding', + knowledgeEmbeddingApiKeyConfigured: false, + knowledgeEmbeddingCredentialSource: 'none' as const, + knowledgeRerankEnabled: false, + knowledgeRerankEndpoint: 'https://rerank.example/v1', + knowledgeRerankModel: 'rerank', + knowledgeRerankApiKeyConfigured: false, + knowledgeRerankCredentialSource: 'none' as const, + workspacePath, + apiKeyConfigured: true, + credentialSource: 'encrypted' as const, + modelProfiles: [profile], + defaultModelProfileId: profileId, + opencodeModelSource: { kind: 'platform' as const }, + continueModelSource: { kind: 'platform' as const }, + deepseekHarnessModelSource: { kind: 'platform' as const }, + secureStorageAvailable: true, + toolApproval: 'always' as const + } + return { + publicSettings, + input: { + provider, + modelBaseUrl: profile.baseUrl, + modelName: profile.modelName, + modelProtocol: profile.protocol, + modelAuthentication: profile.authentication, + imageGenerationQuality: profile.imageGenerationQuality, + opencodeBaseUrl: '', + opencodeEmbedded: true, + opencodeBinaryPath: '', + opencodeConfigPath: '', + continueBinaryPath: '', + continueConfigPath: '', + continueMode: 'chat' as const, + subagentSmartRoutingEnabled: false, + knowledgeEmbeddingEnabled: false, + knowledgeEmbeddingBaseUrl: + publicSettings.knowledgeEmbeddingBaseUrl, + knowledgeEmbeddingModel: + publicSettings.knowledgeEmbeddingModel, + knowledgeRerankEnabled: false, + knowledgeRerankEndpoint: + publicSettings.knowledgeRerankEndpoint, + knowledgeRerankModel: publicSettings.knowledgeRerankModel, + workspacePath, + apiKey: { action: 'keep' as const }, + modelProfiles: [ + { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + modelName: profile.modelName, + protocol: profile.protocol, + authentication: profile.authentication, + imageGenerationQuality: profile.imageGenerationQuality, + apiKey: { action: 'keep' as const } + } + ], + defaultModelProfileId: profileId, + opencodeModelSource: { kind: 'platform' as const }, + continueModelSource: { kind: 'platform' as const }, + deepseekHarnessModelSource: { kind: 'platform' as const }, + toolApproval: 'always' as const + } + } +} + describe('registerIpcHandlers lifecycle tracking', () => { afterEach(() => { electronMocks.handlers.clear() @@ -893,7 +1077,13 @@ describe('registerIpcHandlers lifecycle tracking', () => { window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', - { update } as never, + { + captureRollback: vi.fn(async () => ({ + publicSettings: savedSettings, + restore: vi.fn(async () => savedSettings) + })), + update + } as never, {} as never, { clear: vi.fn() } as never, {} as never, @@ -970,6 +1160,332 @@ describe('registerIpcHandlers lifecycle tracking', () => { await rm(workspace, { recursive: true, force: true }) } }) + + it('restores settings and preserves activation plus rollback failures', async () => { + const workspace = await mkdtemp( + join(tmpdir(), 'goodbuddy-ipc-settings-rollback-') + ) + const profileId = '00000000-0000-4000-8000-000000000091' + const profile = { + id: profileId, + name: 'Default', + baseUrl: 'https://model.example/v1', + modelName: 'model', + protocol: 'openai-chat-completions' as const, + authentication: 'api-key' as const, + imageGenerationQuality: 'auto' as const, + apiKeyConfigured: true, + credentialSource: 'encrypted' as const + } + const previousSettings = { + provider: 'model' as const, + modelBaseUrl: profile.baseUrl, + modelName: profile.modelName, + modelProtocol: profile.protocol, + modelAuthentication: profile.authentication, + imageGenerationQuality: profile.imageGenerationQuality, + opencodeBaseUrl: '', + opencodeEmbedded: true, + opencodeBinaryPath: '', + opencodeConfigPath: '', + continueBinaryPath: '', + continueConfigPath: '', + continueMode: 'chat' as const, + subagentSmartRoutingEnabled: false, + knowledgeEmbeddingEnabled: false, + knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1', + knowledgeEmbeddingModel: 'embedding', + knowledgeEmbeddingApiKeyConfigured: false, + knowledgeEmbeddingCredentialSource: 'none' as const, + knowledgeRerankEnabled: false, + knowledgeRerankEndpoint: 'https://rerank.example/v1', + knowledgeRerankModel: 'rerank', + knowledgeRerankApiKeyConfigured: false, + knowledgeRerankCredentialSource: 'none' as const, + workspacePath: workspace, + apiKeyConfigured: true, + credentialSource: 'encrypted' as const, + modelProfiles: [profile], + defaultModelProfileId: profileId, + opencodeModelSource: { kind: 'platform' as const }, + continueModelSource: { kind: 'platform' as const }, + deepseekHarnessModelSource: { kind: 'platform' as const }, + secureStorageAvailable: true, + toolApproval: 'always' as const + } + const candidateSettings = { + ...previousSettings, + provider: 'opencode' as const + } + const update = vi.fn().mockResolvedValue(candidateSettings) + const restore = vi.fn(async () => previousSettings) + const activationError = new Error('candidate activation failed') + const rollbackError = new Error('rollback activation failed') + const onRuntimeSettingsChanged = vi + .fn() + .mockRejectedValueOnce(activationError) + .mockRejectedValueOnce(rollbackError) + const repairs = vi.fn() + const webContents = { + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + send: vi.fn() + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + { + captureRollback: vi.fn(async () => ({ + publicSettings: previousSettings, + restore + })), + update + } as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + { + queueDueSchedules: vi.fn(() => []), + listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []), + repairConversationRuntimeSelections: repairs + } as never, + { clear: vi.fn() } as never, + {} as never, + onRuntimeSettingsChanged + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + const input = { + provider: candidateSettings.provider, + modelBaseUrl: profile.baseUrl, + modelName: profile.modelName, + modelProtocol: profile.protocol, + modelAuthentication: profile.authentication, + imageGenerationQuality: profile.imageGenerationQuality, + opencodeBaseUrl: '', + opencodeEmbedded: true, + opencodeBinaryPath: '', + opencodeConfigPath: '', + continueBinaryPath: '', + continueConfigPath: '', + continueMode: 'chat', + subagentSmartRoutingEnabled: false, + knowledgeEmbeddingEnabled: false, + knowledgeEmbeddingBaseUrl: + previousSettings.knowledgeEmbeddingBaseUrl, + knowledgeEmbeddingModel: + previousSettings.knowledgeEmbeddingModel, + knowledgeRerankEnabled: false, + knowledgeRerankEndpoint: + previousSettings.knowledgeRerankEndpoint, + knowledgeRerankModel: + previousSettings.knowledgeRerankModel, + workspacePath: workspace, + apiKey: { action: 'keep' }, + modelProfiles: [ + { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + modelName: profile.modelName, + protocol: profile.protocol, + authentication: profile.authentication, + imageGenerationQuality: profile.imageGenerationQuality, + apiKey: { action: 'keep' } + } + ], + defaultModelProfileId: profileId, + opencodeModelSource: { kind: 'platform' }, + continueModelSource: { kind: 'platform' }, + deepseekHarnessModelSource: { kind: 'platform' }, + toolApproval: 'always' + } + + try { + const result = Promise.resolve( + electronMocks.handlers.get( + ipcChannels.runtimeSettingsUpdate + )?.(event, input) + ) + await expect(result).rejects.toMatchObject({ + errors: [activationError, rollbackError] + }) + expect(update).toHaveBeenCalledOnce() + expect(restore).toHaveBeenCalledOnce() + expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2) + expect(repairs).not.toHaveBeenCalled() + } finally { + await dispose() + await rm(workspace, { recursive: true, force: true }) + } + }) + + it('serializes failed settings, customization, and a later successful candidate', async () => { + const workspace = await mkdtemp( + join(tmpdir(), 'goodbuddy-ipc-settings-overlap-') + ) + const initial = runtimeUpdateFixture(workspace, 'model') + const candidateA = runtimeUpdateFixture(workspace, 'opencode') + const candidateB = runtimeUpdateFixture(workspace, 'continue') + let currentSettings = initial.publicSettings + const initialCustomization = { + opencode: {}, + continue: { presets: [] } + } + const candidateCustomization = { + opencode: { defaultAgent: 'candidate-agent' }, + continue: { presets: [] } + } + let currentCustomization = initialCustomization + const captureRollback = vi.fn(async () => { + const snapshot = currentSettings + return { + publicSettings: snapshot, + restore: vi.fn(async () => { + currentSettings = snapshot + return snapshot + }) + } + }) + const update = vi.fn( + async (input: { provider: 'model' | 'opencode' | 'continue' }) => { + currentSettings = + input.provider === 'opencode' + ? candidateA.publicSettings + : candidateB.publicSettings + return currentSettings + } + ) + const updateRuntimeCustomization = vi.fn( + async (settings: typeof candidateCustomization) => { + currentCustomization = settings + return settings + } + ) + let releaseCandidateA!: () => void + const candidateABlocked = new Promise((resolve) => { + releaseCandidateA = resolve + }) + const candidateAError = new Error('candidate A activation failed') + let activationCount = 0 + const onRuntimeSettingsChanged = vi.fn(async () => { + activationCount += 1 + if (activationCount === 1) { + await candidateABlocked + throw candidateAError + } + }) + const repairs = vi.fn(() => []) + const reportRepairs = vi.fn() + const webContents = { + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + send: vi.fn() + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + { + captureRollback, + update, + getRuntimeCustomization: vi.fn( + async () => currentCustomization + ), + updateRuntimeCustomization + } as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + { + queueDueSchedules: vi.fn(() => []), + listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []), + repairConversationRuntimeSelections: repairs + } as never, + { clear: vi.fn() } as never, + {} as never, + onRuntimeSettingsChanged, + undefined, + undefined, + undefined, + { + reportRuntimeSelectionRepairs: reportRepairs + } as never + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + + try { + const updateA = Promise.resolve( + electronMocks.handlers.get( + ipcChannels.runtimeSettingsUpdate + )?.(event, candidateA.input) + ) + await vi.waitFor(() => + expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() + ) + const customizationUpdate = Promise.resolve( + electronMocks.handlers.get( + ipcChannels.runtimeCustomizationUpdate + )?.(event, candidateCustomization) + ) + const updateB = Promise.resolve( + electronMocks.handlers.get( + ipcChannels.runtimeSettingsUpdate + )?.(event, candidateB.input) + ) + await Promise.resolve() + + expect(captureRollback).toHaveBeenCalledOnce() + expect(update).toHaveBeenCalledOnce() + + releaseCandidateA() + await expect(updateA).rejects.toBe(candidateAError) + await expect(customizationUpdate).resolves.toEqual( + candidateCustomization + ) + await expect(updateB).resolves.toMatchObject({ + provider: 'continue' + }) + + expect(currentSettings.provider).toBe('continue') + expect(currentCustomization).toEqual(candidateCustomization) + expect(captureRollback).toHaveBeenCalledTimes(2) + expect(update).toHaveBeenCalledTimes(2) + expect(updateRuntimeCustomization).toHaveBeenCalledOnce() + expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4) + expect(repairs).toHaveBeenCalledOnce() + expect(repairs).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'continue' }) + ) + expect(reportRepairs).toHaveBeenCalledOnce() + } finally { + releaseCandidateA() + await dispose() + await rm(workspace, { recursive: true, force: true }) + } + }) }) describe('registerIpcHandlers knowledge snapshot ontology', () => { @@ -2502,7 +3018,9 @@ describe('registerIpcHandlers Runtime customization', () => { } const settingsStore = { getRuntimeCustomization: vi.fn(async () => customization), - updateRuntimeCustomization: vi.fn(async () => customization), + updateRuntimeCustomization: vi.fn( + async (value: unknown) => value + ), getResolvedSettings: vi.fn(async () => ({ provider: 'opencode', modelProfiles: [], @@ -2547,16 +3065,27 @@ describe('registerIpcHandlers Runtime customization', () => { updateTaskStatus: vi.fn(), upsertModelUsageCall: vi.fn() } + let releaseFirstCompaction: (() => void) | undefined + const firstCompactionBlocked = new Promise((resolve) => { + releaseFirstCompaction = resolve + }) + const compactionOutcome = { + result: { + provider: 'opencode' as const, + strategy: 'native' as const, + compacted: true, + detail: 'OpenCode compacted the conversation' + } + } const selectedRuntimes = { getNativeSnapshot: vi.fn(async () => snapshot), - compactConversation: vi.fn(async () => ({ - result: { - provider: 'opencode' as const, - strategy: 'native' as const, - compacted: true, - detail: 'OpenCode compacted the conversation' - } - })) + compactConversation: vi + .fn() + .mockImplementationOnce(async () => { + await firstCompactionBlocked + return compactionOutcome + }) + .mockResolvedValue(compactionOutcome) } const approvalBroker = { clear: vi.fn() } const onRuntimeSettingsChanged = vi.fn(async () => undefined) @@ -2603,6 +3132,26 @@ describe('registerIpcHandlers Runtime customization', () => { ).toHaveBeenCalledWith(customization) expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() expect(approvalBroker.clear).toHaveBeenCalledOnce() + const activationError = new Error('customization activation failed') + onRuntimeSettingsChanged + .mockRejectedValueOnce(activationError) + .mockResolvedValueOnce(undefined) + const failedCustomization = { + ...customization, + opencode: { defaultAgent: 'candidate-agent' } + } + await expect( + electronMocks.handlers.get( + ipcChannels.runtimeCustomizationUpdate + )?.(event, failedCustomization) + ).rejects.toBe(activationError) + expect( + settingsStore.updateRuntimeCustomization + ).toHaveBeenNthCalledWith(2, failedCustomization) + expect( + settingsStore.updateRuntimeCustomization + ).toHaveBeenNthCalledWith(3, customization) + expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3) await expect( electronMocks.handlers.get( ipcChannels.runtimeCustomizationUpdate @@ -2636,11 +3185,19 @@ describe('registerIpcHandlers Runtime customization', () => { })), historyMessageIds: messageIds } + const firstCompaction = electronMocks.handlers.get( + ipcChannels.agentCompactConversation + )?.(event, compactInput) + await vi.waitFor(() => + expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce() + ) await expect( electronMocks.handlers.get( ipcChannels.agentCompactConversation )?.(event, compactInput) - ).resolves.toEqual({ + ).rejects.toThrow('上下文压缩请求正在执行') + releaseFirstCompaction?.() + await expect(firstCompaction).resolves.toEqual({ provider: 'opencode', strategy: 'native', compacted: true, @@ -2861,7 +3418,14 @@ describe('registerIpcHandlers agent terminal state', () => { 'CommandOrControl+Shift+Space', { getPolicySettings, - getResolvedSettings + getResolvedSettings, + getRuntimeCustomization: vi.fn(async () => ({ + opencode: {}, + continue: { presets: [] } + })), + updateRuntimeCustomization: vi.fn( + async (settings: unknown) => settings + ) } as never, (capabilityServiceOverride ?? {}) as never, contextManager as never, @@ -3079,6 +3643,101 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it('fails scheduled runs promptly when a Runtime asks an interactive question', async () => { + const taskId = '00000000-0000-4000-8000-000000000711' + const conversationId = + '00000000-0000-4000-8000-000000000712' + const scheduleId = + '00000000-0000-4000-8000-000000000713' + const runId = '00000000-0000-4000-8000-000000000714' + const respondToQuestion = vi.fn().mockResolvedValue(undefined) + const runtime = { + runtimeId: 'opencode', + capability: 'chat', + supportsToolExecution: true, + respondToQuestion, + async *run(request: { requestId: string }) { + yield { + requestId: request.requestId, + type: 'question', + questionId: 'opencode-background-question', + questions: [ + { + header: 'Choice', + question: 'Choose an implementation', + options: [], + multiple: false, + custom: true + } + ] + } as const + await new Promise(() => undefined) + } + } + const harness = createHarness(runtime) + const schedule = { + id: scheduleId, + projectId: '00000000-0000-4000-8000-000000000401', + taskId, + conversationId, + title: '后台提问测试', + prompt: '执行任务', + workMode: 'execute' as const, + recurrence: 'daily' as const, + nextRunAt: '2026-08-20T00:00:00.000Z', + enabled: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z' + } + const queueItem = { + id: runId, + conversationId, + source: 'schedule' as const, + label: schedule.title, + scheduleRunId: runId, + scheduleId, + taskId, + createdAt: '2026-08-19T00:01:00.000Z' + } + harness.assistantDatabase.queueScheduleNow.mockReturnValue( + queueItem + ) + harness.assistantDatabase.listConversationQueueItems + .mockReturnValueOnce([queueItem]) + .mockReturnValue([]) + harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({ + source: 'schedule', + item: queueItem, + schedule, + runId + }) + + await electronMocks.handlers.get( + ipcChannels.schedulesRunNow + )?.(trustedEvent(harness.webContents), scheduleId) + + await vi.waitFor(() => + expect( + harness.assistantDatabase.completeScheduleRun + ).toHaveBeenCalledWith(runId, 'failed') + ) + expect(respondToQuestion).toHaveBeenCalledWith( + 'opencode-background-question' + ) + expect( + harness.assistantDatabase.appendConversationMessage + ).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId, + state: 'error', + status: '定时任务失败', + content: + '后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。' + }) + ) + await harness.dispose() + }) + it('serializes Agent runs that target the same Conversation', async () => { let finishRun: (() => void) | undefined const runtimeStarted = vi.fn() @@ -3124,6 +3783,179 @@ describe('registerIpcHandlers agent terminal state', () => { finishRun?.() await firstRun + await vi.waitFor(() => + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith( + '00000000-0000-4000-8000-000000000722', + 'completed' + ) + ) + + await expect( + harness.handler?.(trustedEvent(harness.webContents), { + requestId: '00000000-0000-4000-8000-000000000724', + conversationId, + prompt: '第三条', + workMode: 'ask', + knowledgeLibraryIds: [] + }) + ).resolves.toBeUndefined() + await vi.waitFor(() => + expect(runtimeStarted).toHaveBeenCalledTimes(2) + ) + finishRun?.() + await vi.waitFor(() => + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith( + '00000000-0000-4000-8000-000000000724', + 'completed' + ) + ) + await harness.dispose() + }) + + it('keeps a replacement lease owned after the bulk-aborted lease releases', async () => { + const controls = new Map< + string, + { + finish: () => void + signal: AbortSignal + } + >() + const runtimeStarted = vi.fn() + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: false, + async *run( + request: { conversationId: string; requestId: string }, + signal: AbortSignal + ) { + runtimeStarted(request.conversationId) + await new Promise((resolve) => { + controls.set(request.conversationId, { + finish: resolve, + signal + }) + }) + signal.throwIfAborted() + yield { + requestId: request.requestId, + type: 'done' + } as const + } + } + const goodbuddyConfigService = { + takePendingReload: vi.fn(() => 'none'), + revokeRequest: vi.fn(), + clear: vi.fn() + } + const knowledgeGateway = { + grant: vi.fn(() => 'lease-race-capability'), + getAvailableToolNames: vi.fn(() => []), + drainReferences: vi.fn(() => []), + revoke: vi.fn() + } + const harness = createHarness( + runtime, + undefined, + 'always', + undefined, + false, + undefined, + undefined, + knowledgeGateway, + false, + goodbuddyConfigService + ) + const requestId = '00000000-0000-4000-8000-000000000741' + const oldConversationId = + '00000000-0000-4000-8000-000000000742' + const newConversationId = + '00000000-0000-4000-8000-000000000743' + const event = trustedEvent(harness.webContents) + + await harness.handler?.(event, { + requestId, + conversationId: oldConversationId, + prompt: '旧请求', + workMode: 'ask', + knowledgeLibraryIds: [] + }) + await vi.waitFor(() => + expect(controls.has(oldConversationId)).toBe(true) + ) + + await electronMocks.handlers.get( + ipcChannels.runtimeCustomizationUpdate + )?.(event, { + opencode: {}, + continue: { presets: [] } + }) + expect(controls.get(oldConversationId)?.signal.aborted).toBe(true) + + await harness.handler?.(event, { + requestId, + conversationId: newConversationId, + prompt: '替换请求', + workMode: 'ask', + knowledgeLibraryIds: [] + }) + await vi.waitFor(() => + expect(controls.has(newConversationId)).toBe(true) + ) + + controls.get(oldConversationId)?.finish() + await vi.waitFor(() => + expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce() + ) + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') + + harness.cancelHandler?.(event, requestId) + expect(controls.get(newConversationId)?.signal.aborted).toBe(true) + await expect( + harness.handler?.(event, { + requestId: '00000000-0000-4000-8000-000000000744', + conversationId: newConversationId, + prompt: '不能并发', + workMode: 'ask', + knowledgeLibraryIds: [] + }) + ).rejects.toThrow('当前对话已有执行中的请求') + + controls.get(newConversationId)?.finish() + await vi.waitFor(() => { + const cancellations = + harness.assistantDatabase.updateTaskStatus.mock.calls.filter( + ([id, status]) => + id === requestId && status === 'cancelled' + ) + expect(cancellations).toHaveLength(2) + }) + await vi.waitFor(() => + expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledTimes(2) + ) + + await harness.handler?.(event, { + requestId, + conversationId: newConversationId, + prompt: '清理后重试', + workMode: 'ask', + knowledgeLibraryIds: [] + }) + await vi.waitFor(() => + expect(runtimeStarted).toHaveBeenCalledTimes(3) + ) + controls.get(newConversationId)?.finish() + await vi.waitFor(() => + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(requestId, 'completed') + ) await harness.dispose() }) @@ -4355,13 +5187,25 @@ describe('registerIpcHandlers agent terminal state', () => { getStatus: vi.fn(), releaseConversation: vi.fn(async () => undefined) } + const getWebSearchCapabilityStatus = vi.fn(async () => ({ + enabled: true + })) + const getEnabledBuiltinMcpServerIds = vi.fn(async () => []) const harness = createHarness( selectedRuntime, undefined, 'always', undefined, false, - selectedRuntimes + selectedRuntimes, + undefined, + undefined, + false, + undefined, + { + getWebSearchCapabilityStatus, + getEnabledBuiltinMcpServerIds + } ) const event = trustedEvent(harness.webContents) const request = { @@ -4380,6 +5224,8 @@ describe('registerIpcHandlers agent terminal state', () => { expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce() expect(harness.getApplicationSettings).toHaveBeenCalledOnce() + expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce() + expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce() expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce() releaseRun() @@ -4392,6 +5238,117 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it('starts independent interactive request lookups concurrently after enrichment', async () => { + let resolveApplicationSettings!: (value: { + magicNotesEnabled: boolean + }) => void + const applicationSettings = new Promise<{ + magicNotesEnabled: boolean + }>((resolve) => { + resolveApplicationSettings = resolve + }) + let resolveWebSearch!: (value: { enabled: boolean }) => void + const webSearch = new Promise<{ enabled: boolean }>((resolve) => { + resolveWebSearch = resolve + }) + let resolveRuntimeSettings!: (value: { + workspacePath: string + }) => void + const runtimeSettings = new Promise<{ + workspacePath: string + }>((resolve) => { + resolveRuntimeSettings = resolve + }) + let resolveBuiltinMcpServers!: (value: never[]) => void + const builtinMcpServers = new Promise((resolve) => { + resolveBuiltinMcpServers = resolve + }) + const getWebSearchCapabilityStatus = vi.fn(() => webSearch) + const getEnabledBuiltinMcpServerIds = vi.fn( + () => builtinMcpServers + ) + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: true, + async *run(request: { requestId: string }) { + yield { requestId: request.requestId, type: 'done' } + } + } + const goodbuddyConfigService = { + takePendingReload: vi.fn(() => 'none'), + revokeRequest: vi.fn(), + clear: vi.fn() + } + const harness = createHarness( + runtime, + undefined, + 'always', + undefined, + false, + undefined, + undefined, + undefined, + false, + goodbuddyConfigService, + { + getWebSearchCapabilityStatus, + getEnabledBuiltinMcpServerIds + } + ) + harness.getApplicationSettings.mockReturnValueOnce( + applicationSettings + ) + harness.getResolvedSettings.mockReturnValueOnce(runtimeSettings) + const requestId = '00000000-0000-4000-8000-000000000014' + const operation = harness.handler?.( + trustedEvent(harness.webContents), + { + requestId, + conversationId: 'concurrent-request-setup', + prompt: 'prepare concurrently', + workMode: 'ask', + knowledgeLibraryIds: [] + } + ) + + await vi.waitFor(() => { + expect(harness.getApplicationSettings).toHaveBeenCalledOnce() + expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce() + expect(harness.getResolvedSettings).toHaveBeenCalledOnce() + expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce() + }) + const enrichmentOrder = + harness.contextManager.enrichRequest.mock.invocationCallOrder[0] + if (enrichmentOrder === undefined) { + throw new Error('Request was not enriched') + } + for (const lookup of [ + harness.getApplicationSettings, + getWebSearchCapabilityStatus, + harness.getResolvedSettings, + getEnabledBuiltinMcpServerIds + ]) { + const lookupOrder = lookup.mock.invocationCallOrder[0] + if (lookupOrder === undefined) { + throw new Error('Interactive request lookup did not start') + } + expect(enrichmentOrder).toBeLessThan(lookupOrder) + } + + resolveApplicationSettings({ magicNotesEnabled: false }) + resolveWebSearch({ enabled: false }) + resolveRuntimeSettings({ workspacePath: 'C:\\Workspace' }) + resolveBuiltinMcpServers([]) + await expect(operation).resolves.toBeUndefined() + await vi.waitFor(() => + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(requestId, 'completed') + ) + await harness.dispose() + }) + it('aborts active work and clears browser sessions before assistant data', async () => { const lifecycle: string[] = [] let markStarted!: () => void @@ -5409,6 +6366,111 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it.each([ + { + name: 'Runtime status resolution fails', + getStatus: () => Promise.reject(new Error('状态查询失败')), + expectedError: '状态查询失败' + }, + { + name: 'Runtime is available without tool execution', + getStatus: () => + Promise.resolve({ + id: 'model', + label: 'Direct model', + available: true, + supportsToolExecution: false + }), + expectedError: + '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型' + } + ])( + 'finalizes remote Execute consistently when $name', + async ({ getStatus, expectedError }) => { + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: true, + getStatus: vi.fn(getStatus), + run: vi.fn() + } + const harness = createHarness(runtime) + const executor = channelMocks.executor + if (!executor) { + throw new Error('Expected channel executor') + } + + await expect( + executor( + { + channel: 'wecom', + eventId: 'event-execute-unavailable', + senderId: 'user-1', + conversationId: 'conversation-execute-unavailable', + conversationType: 'direct', + text: '/execute 更新 README', + mentioned: false, + workMode: 'ask' + }, + new AbortController().signal + ) + ).resolves.toEqual({ + status: 'failed', + error: expectedError + }) + + const remoteTask = + harness.assistantDatabase.createTask.mock.calls[0]?.[0] + expect(remoteTask).toEqual( + expect.objectContaining({ + projectId: '00000000-0000-4000-8000-000000000401', + conversationId: '00000000-0000-4000-8000-000000000402', + workMode: 'execute', + origin: 'delegation', + visible: false + }) + ) + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(remoteTask?.id, 'failed', expectedError) + expect( + harness.assistantDatabase.appendRemoteConversationMessage + ).toHaveBeenLastCalledWith({ + conversationId: '00000000-0000-4000-8000-000000000402', + role: 'assistant', + content: expectedError, + status: '执行不可用' + }) + const activities = harness.webContents.send.mock.calls + .filter( + ([channel]) => + channel === ipcChannels.remoteChannelActivity + ) + .map(([, activity]) => activity) + expect(activities).toHaveLength(2) + expect(activities[0]).toEqual( + expect.objectContaining({ + requestId: remoteTask?.id, + kind: 'request', + status: 'running' + }) + ) + expect(activities[1]).toEqual({ + requestId: remoteTask?.id, + conversationId: '00000000-0000-4000-8000-000000000402', + projectId: '00000000-0000-4000-8000-000000000401', + projectName: '企业微信', + channel: 'wecom', + kind: 'result', + title: '企业微信远程执行不可用', + detail: expectedError, + status: 'failed' + }) + expect(runtime.run).not.toHaveBeenCalled() + await harness.dispose() + } + ) + it('runs remote Execute immediately with the selected direct model policy', async () => { let authorization: string | undefined const runtime = { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 08d1b72..b9a1333 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -16,7 +16,10 @@ import { randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { basename, extname, isAbsolute, join } from 'node:path' import { z } from 'zod' -import { formatShortcutForDisplay } from '../shared/shortcut' +import { + formatShortcutForDisplay, + globalShortcutSettingsUpdateSchema +} from '../shared/shortcut' import { readBoundedFile } from './workspace-file-access' import { approvalDecisionSchema, @@ -203,6 +206,7 @@ import { type RuntimeExtensionMarketplaceSnapshot } from '../shared/runtime-extension-contracts' import type { RuntimeExtensionStore } from './agent/runtime-extension-store' +import type { ShortcutSettingsService } from './shortcut-settings-service' import type { ContextManager } from './context-manager' import type { KnowledgeService } from './knowledge/knowledge-service' import { @@ -274,6 +278,7 @@ import { import { AgentEventBuffer } from './agent-event-buffer' const requestIdSchema = z.string().uuid() +const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000 const runtimeConfigFileMetadata = { opencode: { filterName: 'OpenCode 配置', @@ -415,6 +420,31 @@ function safeRuntimeError(error: unknown, fallback: string): string { return safeToolErrorDetail(error, 2_000) ?? fallback } +async function activateOrRollback(input: { + previous: T + persistCandidate(): Promise + activate(): Promise + persistPrevious(previous: T): Promise +}): Promise { + const saved = await input.persistCandidate() + try { + await input.activate() + return saved + } catch (activationError) { + try { + await input.persistPrevious(input.previous) + await input.activate() + } catch (rollbackError) { + throw new AggregateError( + [activationError, rollbackError], + 'Runtime 配置激活失败,且回滚未能完成', + { cause: rollbackError } + ) + } + throw activationError + } +} + function createPromiseTracker(): { track(operation: Promise): Promise drain(): Promise @@ -883,10 +913,35 @@ export function registerIpcHandlers( documentOcrBroker?: DocumentOcrBroker, releaseNotesService?: ReleaseNotesService, goodbuddyConfigService?: GoodBuddyConfigService, - runtimeExtensionStore?: RuntimeExtensionStore + runtimeExtensionStore?: RuntimeExtensionStore, + shortcutSettingsService?: ShortcutSettingsService ): () => Promise { - const activeRequests = new Map() - const activeRequestConversations = new Map() + type ActiveRequestLease = { + controller: AbortController + conversationId: string + } + const activeRequests = new Map() + const activeRequestConversations = new Map< + string, + ActiveRequestLease + >() + const leaseActiveRequest = ( + requestId: string, + conversationId: string, + controller: AbortController + ): (() => void) => { + const lease = { controller, conversationId } + activeRequests.set(requestId, lease) + activeRequestConversations.set(requestId, lease) + return (): void => { + if (activeRequests.get(requestId) === lease) { + activeRequests.delete(requestId) + } + if (activeRequestConversations.get(requestId) === lease) { + activeRequestConversations.delete(requestId) + } + } + } const activeEventBuffers = new Map() const pendingAgentQuestions = new Map< string, @@ -900,6 +955,17 @@ export function registerIpcHandlers( const pendingRendererPersistence = new Map void>() let pendingGoodBuddyConfigReload = false let goodBuddyConfigReloadQueue: Promise = Promise.resolve() + let runtimeSettingsUpdateQueue: Promise = Promise.resolve() + const enqueueRuntimeSettingsUpdate = ( + transaction: () => Promise + ): Promise => { + const result = runtimeSettingsUpdateQueue.then(transaction) + runtimeSettingsUpdateQueue = result.then( + () => undefined, + () => undefined + ) + return result + } const executionTracker = createPromiseTracker() const maintenanceTracker = createPromiseTracker() const trackExecution = executionTracker.track @@ -1010,8 +1076,8 @@ export function registerIpcHandlers( } }) const abortActiveRequests = (reason: string): void => { - for (const controller of activeRequests.values()) { - controller.abort(new Error(reason)) + for (const lease of activeRequests.values()) { + lease.controller.abort(new Error(reason)) } activeRequests.clear() } @@ -1316,7 +1382,7 @@ export function registerIpcHandlers( (candidate) => candidate === conversationId ) || [...activeRequestConversations.values()].some( - (candidate) => candidate === conversationId + (candidate) => candidate.conversationId === conversationId ) const pumpConversationQueue = async ( @@ -1538,14 +1604,17 @@ export function registerIpcHandlers( externalSignal?.addEventListener('abort', abortFromExternal, { once: true }) - activeRequests.set(requestId, controller) const runtimeConversationId = remoteContext?.conversationId ?? (input.origin === 'schedule' ? input.schedule.conversationId : undefined) ?? `${origin}:${schedule.id}` - activeRequestConversations.set(requestId, runtimeConversationId) + const releaseActiveRequest = leaseActiveRequest( + requestId, + runtimeConversationId, + controller + ) if (input.origin !== 'delegation') { assistantDatabase.updateTaskStatus(taskId, 'running') } else { @@ -1565,6 +1634,7 @@ export function registerIpcHandlers( } let output = '' let completed = false + let backgroundQuestionError: Error | undefined let knowledgeCapabilityToken: string | undefined const resultAttachments: ChannelMediaAttachment[] = [] const artifactIds: string[] = [] @@ -1766,6 +1836,37 @@ export function registerIpcHandlers( if (taskEvent.type === 'artifact') { artifactIds.push(taskEvent.artifactId) } + if (taskEvent.type === 'question') { + const error = new Error( + '后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。' + ) + backgroundQuestionError = error + const rejection = + requestRuntime + .respondToQuestion?.(taskEvent.questionId) + .catch(() => undefined) ?? Promise.resolve() + let rejectionTimeout: + | ReturnType + | undefined + try { + await Promise.race([ + rejection, + new Promise((resolveTimeout) => { + rejectionTimeout = setTimeout( + resolveTimeout, + BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS + ) + rejectionTimeout.unref?.() + }) + ]) + } finally { + if (rejectionTimeout) { + clearTimeout(rejectionTimeout) + } + } + controller.abort(error) + throw error + } eventBuffer.push(taskEvent) if (taskEvent.type === 'tool' && remoteContext) { publishRemoteActivity({ @@ -1879,8 +1980,11 @@ export function registerIpcHandlers( } } catch (error) { eventBuffer.flush() - const message = safeRuntimeError(error, '定时任务执行失败') - const cancelled = controller.signal.aborted + const message = backgroundQuestionError + ? backgroundQuestionError.message + : safeRuntimeError(error, '定时任务执行失败') + const cancelled = + controller.signal.aborted && !backgroundQuestionError assistantDatabase.updateTaskStatus( taskId, cancelled ? 'cancelled' : 'failed', @@ -1924,8 +2028,7 @@ export function registerIpcHandlers( ) knowledgeGateway?.revoke(knowledgeCapabilityToken) goodbuddyConfigService?.revokeRequest(requestId) - activeRequests.delete(requestId) - activeRequestConversations.delete(requestId) + releaseActiveRequest() await flushGoodBuddyConfigReload().catch(() => undefined) } } @@ -2297,6 +2400,34 @@ export function registerIpcHandlers( detail: parsed.prompt, status: 'running' }) + const finalizeExecutePreflightFailure = ( + unavailable: string + ): { status: 'failed'; error: string } => { + assistantDatabase.updateTaskStatus( + remoteTaskId, + 'failed', + unavailable + ) + assistantDatabase.appendRemoteConversationMessage({ + conversationId: remoteConversation.id, + role: 'assistant', + content: unavailable, + status: '执行不可用' + }) + publishRemoteConversationChange() + publishRemoteActivity({ + requestId: remoteTaskId, + conversationId: remoteConversation.id, + projectId: project.id, + projectName: project.name, + channel, + kind: 'result', + title: `${channelLabel}远程执行不可用`, + detail: unavailable, + status: 'failed' + }) + return { status: 'failed', error: unavailable } + } let executionRuntime: AgentRuntime | undefined if (parsed.workMode === 'execute') { @@ -2316,30 +2447,7 @@ export function registerIpcHandlers( error, '远程 Execute Runtime 不可用' ) - assistantDatabase.updateTaskStatus( - remoteTaskId, - 'failed', - unavailable - ) - assistantDatabase.appendRemoteConversationMessage({ - conversationId: remoteConversation.id, - role: 'assistant', - content: unavailable, - status: '执行不可用' - }) - publishRemoteConversationChange() - publishRemoteActivity({ - requestId: remoteTaskId, - conversationId: remoteConversation.id, - projectId: project.id, - projectName: project.name, - channel, - kind: 'result', - title: `${channelLabel}远程执行不可用`, - detail: unavailable, - status: 'failed' - }) - return { status: 'failed', error: unavailable } + return finalizeExecutePreflightFailure(unavailable) } if ( !executionStatus.available || @@ -2349,30 +2457,7 @@ export function registerIpcHandlers( ? '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型' : executionStatus.detail?.trim() || '所选处理后端当前不可用,请在消息通道设置中检查 Runtime 或模型连接' - assistantDatabase.updateTaskStatus( - remoteTaskId, - 'failed', - unavailable - ) - assistantDatabase.appendRemoteConversationMessage({ - conversationId: remoteConversation.id, - role: 'assistant', - content: unavailable, - status: '执行不可用' - }) - publishRemoteConversationChange() - publishRemoteActivity({ - requestId: remoteTaskId, - conversationId: remoteConversation.id, - projectId: project.id, - projectName: project.name, - channel, - kind: 'result', - title: `${channelLabel}远程执行不可用`, - detail: unavailable, - status: 'failed' - }) - return { status: 'failed', error: unavailable } + return finalizeExecutePreflightFailure(unavailable) } } @@ -2499,12 +2584,20 @@ export function registerIpcHandlers( registerHandler(ipcChannels.appInfo, (event): AppInfo => { assertTrustedSender(event, window) + const shortcutSnapshot = shortcutSettingsService?.getSnapshot() return { name: app.getName(), version: app.getVersion(), platform: process.platform, arch: process.arch, - shortcut: formatShortcutForDisplay(shortcut, process.platform) + shortcut: shortcutSnapshot?.registered + ? shortcutSnapshot.displayAccelerator + : shortcutSnapshot + ? '' + : formatShortcutForDisplay(shortcut, process.platform), + ...(shortcutSnapshot + ? { shortcutStatus: shortcutSnapshot.status } + : {}) } }) @@ -2673,8 +2766,8 @@ export function registerIpcHandlers( reservedConversationQueueItems.get(parsedInput.conversationId) if ( [...activeRequestConversations.values()].some( - (conversationId) => - conversationId === parsedInput.conversationId + (lease) => + lease.conversationId === parsedInput.conversationId ) || [...preparingRequestConversations.values()].some( (conversationId) => @@ -2730,39 +2823,49 @@ export function registerIpcHandlers( const enrichedRequest = contextManager.enrichRequest( parsedRequest ) - const hasKnowledgeScope = knowledgeLibraryIds.length > 0 - const magicNotesToolEnabled = - (await applicationSettingsStore?.get())?.magicNotesEnabled ?? false - const webSearchEnabled = - !agentRuntimeSelected && - ( - await capabilityService.getWebSearchCapabilityStatus?.() - )?.enabled === true if (activeRequests.has(enrichedRequest.requestId)) { throw new Error('请求正在执行') } - - const controller = new AbortController() + const hasKnowledgeScope = knowledgeLibraryIds.length > 0 const configAccess = goodbuddyConfigService && !imageGeneration ? enrichedRequest.workMode === 'execute' ? 'write' : 'read' : 'none' + const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime) + const [ + applicationSettings, + webSearchCapability, + resolvedRuntimeSettings, + enabledBuiltinMcpServers + ] = await Promise.all([ + applicationSettingsStore?.get(), + !agentRuntimeSelected + ? capabilityService.getWebSearchCapabilityStatus?.() + : undefined, + configAccess !== 'none' && !enrichedRequest.projectId + ? settingsStore.getResolvedSettings() + : undefined, + selectedRuntimeTarget + ? capabilityService.getEnabledBuiltinMcpServerIds + ? capabilityService.getEnabledBuiltinMcpServerIds( + selectedRuntimeTarget + ) + : [...builtinMcpServerIdSchema.options] + : [] + ]) + const magicNotesToolEnabled = + applicationSettings?.magicNotesEnabled ?? false + const webSearchEnabled = + webSearchCapability?.enabled === true const configWorkspacePath = configAccess === 'none' ? undefined : enrichedRequest.projectId ? assistantDatabase.getProject(enrichedRequest.projectId).rootPath - : (await settingsStore.getResolvedSettings()).workspacePath - const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime) - const enabledBuiltinMcpServers = selectedRuntimeTarget - ? capabilityService.getEnabledBuiltinMcpServerIds - ? await capabilityService.getEnabledBuiltinMcpServerIds( - selectedRuntimeTarget - ) - : [...builtinMcpServerIdSchema.options] - : [] + : resolvedRuntimeSettings?.workspacePath + const controller = new AbortController() const scopedCapability = grantScopedDataCapability({ gateway: knowledgeGateway, runtime: selectedRuntime, @@ -2823,10 +2926,10 @@ export function registerIpcHandlers( knowledgeGateway?.revoke(knowledgeCapabilityToken) throw error } - activeRequests.set(request.requestId, controller) - activeRequestConversations.set( + const releaseActiveRequest = leaseActiveRequest( request.requestId, - request.conversationId + request.conversationId, + controller ) if (parsedInput.queueItemId) { const dispatchTimeout = queueDispatchTimers.get( @@ -2848,8 +2951,7 @@ export function registerIpcHandlers( ) publishConversationQueueChange(request.conversationId) } catch (error) { - activeRequests.delete(request.requestId) - activeRequestConversations.delete(request.requestId) + releaseActiveRequest() assistantDatabase.updateTaskStatus( request.requestId, 'cancelled', @@ -3336,8 +3438,7 @@ export function registerIpcHandlers( } } knowledgeGateway?.revoke(request.knowledgeCapabilityToken) - activeRequests.delete(request.requestId) - activeRequestConversations.delete(request.requestId) + releaseActiveRequest() const configReload = goodbuddyConfigService?.takePendingReload(request.requestId) ?? 'none' @@ -3360,7 +3461,9 @@ export function registerIpcHandlers( registerHandler(ipcChannels.agentCancel, (event, input: unknown) => { assertTrustedSender(event, window) const requestId = requestIdSchema.parse(input) - activeRequests.get(requestId)?.abort(new Error('用户取消了请求')) + activeRequests + .get(requestId) + ?.controller.abort(new Error('用户取消了请求')) }) registerHandler(ipcChannels.agentApprovalRespond, (event, input: unknown) => { @@ -3453,10 +3556,10 @@ export function registerIpcHandlers( ), 5 * 60_000 ) - activeRequests.set(request.requestId, controller) - activeRequestConversations.set( + const releaseActiveRequest = leaseActiveRequest( request.requestId, - request.conversationId + request.conversationId, + controller ) assistantDatabase.createTask({ id: request.requestId, @@ -3541,8 +3644,7 @@ export function registerIpcHandlers( throw error } finally { clearTimeout(timeout) - activeRequests.delete(request.requestId) - activeRequestConversations.delete(request.requestId) + releaseActiveRequest() readyConversationQueues.add(request.conversationId) void pumpConversationQueue(request.conversationId) } @@ -3571,12 +3673,23 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const settings = runtimeCustomizationSettingsSchema.parse(input) - const saved = - await settingsStore.updateRuntimeCustomization(settings) - abortActiveRequests('Runtime 定制设置已更改') - approvalBroker.clear() - await onRuntimeSettingsChanged() - return saved + return enqueueRuntimeSettingsUpdate(async () => { + const previous = + await settingsStore.getRuntimeCustomization() + return activateOrRollback({ + previous, + persistCandidate: async () => { + const saved = + await settingsStore.updateRuntimeCustomization(settings) + abortActiveRequests('Runtime 定制设置已更改') + approvalBroker.clear() + return saved + }, + activate: onRuntimeSettingsChanged, + persistPrevious: (previousSettings) => + settingsStore.updateRuntimeCustomization(previousSettings) + }) + }) } ) @@ -3611,28 +3724,39 @@ export function registerIpcHandlers( async (event, input: unknown): Promise => { assertTrustedSender(event, window) const settings = runtimeSettingsInputSchema.parse(input) - let workspacePath: string - try { - workspacePath = await realpath(settings.workspacePath) - if (!(await stat(workspacePath)).isDirectory()) { - throw new Error('Not a directory') + return enqueueRuntimeSettingsUpdate(async () => { + let workspacePath: string + try { + workspacePath = await realpath(settings.workspacePath) + if (!(await stat(workspacePath)).isDirectory()) { + throw new Error('Not a directory') + } + } catch { + throw new Error('所选工作区不存在、不可访问或不是文件夹') } - } catch { - throw new Error('所选工作区不存在、不可访问或不是文件夹') - } - const savedSettings = await settingsStore.update({ - ...settings, - workspacePath - }) - channelSettingsStore?.reportRuntimeSelectionRepairs( - assistantDatabase.repairConversationRuntimeSelections( - savedSettings + const rollback = await settingsStore.captureRollback() + const previousSettings = rollback.publicSettings + const savedSettings = await activateOrRollback({ + previous: previousSettings, + persistCandidate: async () => { + const saved = await settingsStore.update({ + ...settings, + workspacePath + }) + abortActiveRequests('运行时设置已更改') + approvalBroker.clear() + return saved + }, + activate: onRuntimeSettingsChanged, + persistPrevious: () => rollback.restore() + }) + channelSettingsStore?.reportRuntimeSelectionRepairs( + assistantDatabase.repairConversationRuntimeSelections( + savedSettings + ) ) - ) - abortActiveRequests('运行时设置已更改') - approvalBroker.clear() - await onRuntimeSettingsChanged() - return savedSettings + return savedSettings + }) } ) @@ -3903,6 +4027,27 @@ export function registerIpcHandlers( } ) + registerHandler(ipcChannels.shortcutSettingsGet, (event) => { + assertTrustedSender(event, window) + if (!shortcutSettingsService) { + throw new Error('快捷键设置服务不可用') + } + return shortcutSettingsService.getSnapshot() + }) + + registerHandler( + ipcChannels.shortcutSettingsUpdate, + (event, input: unknown) => { + assertTrustedSender(event, window) + if (!shortcutSettingsService) { + throw new Error('快捷键设置服务不可用') + } + return shortcutSettingsService.update( + globalShortcutSettingsUpdateSchema.parse(input) + ) + } + ) + registerHandler(ipcChannels.documentParsingGet, (event) => { assertTrustedSender(event, window) if (!documentParsingService) { @@ -3911,6 +4056,14 @@ export function registerIpcHandlers( return documentParsingService.snapshot() }) + registerHandler(ipcChannels.documentOcrModelsProgress, (event) => { + assertTrustedSender(event, window) + if (!documentOcrModelManager) { + throw new Error('本地 OCR 模型服务不可用') + } + return documentOcrModelManager.getProgressSnapshot() + }) + registerHandler( ipcChannels.documentParsingUpdate, (event, input: unknown) => { @@ -4596,11 +4749,13 @@ export function registerIpcHandlers( } preferredConversationQueueItems.set(item.conversationId, item.id) readyConversationQueues.add(item.conversationId) - for (const [requestId, conversationId] of activeRequestConversations) { - if (conversationId === item.conversationId) { + for (const [requestId, lease] of activeRequestConversations) { + if (lease.conversationId === item.conversationId) { activeRequests .get(requestId) - ?.abort(new Error('用户中断当前回复并插入队列项')) + ?.controller.abort( + new Error('用户中断当前回复并插入队列项') + ) } } if (!isConversationExecuting(item.conversationId)) { diff --git a/src/main/model-archive.test.ts b/src/main/model-archive.test.ts index d9c5efb..e523554 100644 --- a/src/main/model-archive.test.ts +++ b/src/main/model-archive.test.ts @@ -77,6 +77,7 @@ describe('model archive', () => { } }) + const progress: number[] = [] await expect( extractModelArchive({ archivePath: archive, @@ -89,7 +90,10 @@ describe('model archive', () => { ], maximumArchiveBytes: 1024 * 1024, maximumFileBytes: 1024, - maximumTotalBytes: 2048 + maximumTotalBytes: 2048, + onProgress: (completedBytes) => { + progress.push(completedBytes) + } }) ).resolves.toMatchObject({ kind: 'speech', @@ -101,6 +105,7 @@ describe('model archive', () => { await expect(readFile(join(extracted, 'tokens.txt'))).resolves.toEqual( tokens ) + expect(progress.at(-1)).toBe(model.byteLength + tokens.byteLength) }) it('preserves an existing archive when source verification fails', async () => { @@ -208,4 +213,39 @@ describe('model archive', () => { }) ).rejects.toThrow('模型 ID 不匹配') }) + + it('handles malformed entry rejection without an unhandled promise', async () => { + const directory = await temporaryDirectory() + const archive = join(directory, 'truncated.zip') + const extracted = join(directory, 'extracted') + await mkdir(extracted) + const complete = zipSync({ + 'goodbuddy-model.json': Buffer.from('{}'), + 'model.onnx': Buffer.alloc(128 * 1024, 7) + }) + await writeFile(archive, complete.subarray(0, complete.length - 17)) + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', onUnhandled) + try { + await expect( + extractModelArchive({ + archivePath: archive, + destinationDirectory: extracted, + expectedKind: 'speech', + expectedModelId: 'test-model', + expectedFiles: [{ name: 'model.onnx', role: 'model' }], + maximumArchiveBytes: 1024 * 1024, + maximumFileBytes: 1024 * 1024, + maximumTotalBytes: 1024 * 1024 + }) + ).rejects.toThrow() + await new Promise((resolve) => setImmediate(resolve)) + expect(unhandled).toEqual([]) + } finally { + process.removeListener('unhandledRejection', onUnhandled) + } + }) }) diff --git a/src/main/model-archive.ts b/src/main/model-archive.ts index 7f30172..29ac74a 100644 --- a/src/main/model-archive.ts +++ b/src/main/model-archive.ts @@ -7,7 +7,7 @@ import { rm, type FileHandle } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import { resolve } from 'node:path' import { Unzip, UnzipInflate, @@ -16,6 +16,13 @@ import { ZipPassThrough } from 'fflate' import { z } from 'zod' +import { + ensureModelOperationNotAborted, + hashModelFile, + managedModelChild, + writeModelBuffer +} from './model-package-utils' +import { isMissingFileError } from './settings-file-utils' const ARCHIVE_MANIFEST_NAME = 'goodbuddy-model.json' const ARCHIVE_FORMAT = 'goodbuddy-model-archive' @@ -108,14 +115,6 @@ type ExtractModelArchiveOptions = { onProgress?: (completedBytes: number) => void } -function safeChild(parent: string, name: string): string { - const child = resolve(parent, name) - if (dirname(child) !== resolve(parent)) { - throw new Error('模型 ZIP 路径超出临时目录') - } - return child -} - function ensureArchiveName(name: string): string { return archiveFileNameSchema.parse(name) } @@ -127,24 +126,6 @@ function ensureUniqueFiles(files: ModelArchiveExpectedFile[]): void { } } -async function hashFile(path: string): Promise { - const handle = await open(path, 'r') - const hash = createHash('sha256') - const buffer = Buffer.allocUnsafe(64 * 1024) - try { - while (true) { - const { bytesRead } = await handle.read(buffer, 0, buffer.length) - if (bytesRead === 0) { - break - } - hash.update(buffer.subarray(0, bytesRead)) - } - } finally { - await handle.close() - } - return hash.digest('hex') -} - function checkedLimit(value: number, label: string): number { if (!Number.isSafeInteger(value) || value <= 0) { throw new RangeError(`${label}无效`) @@ -152,14 +133,6 @@ function checkedLimit(value: number, label: string): number { return value } -function ensureNotAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw signal.reason instanceof Error - ? signal.reason - : new Error('模型 ZIP 导入已取消') - } -} - async function pushFileIntoArchive( archive: Zip, file: ModelArchiveFile, @@ -233,7 +206,7 @@ async function replaceArchiveFile( throw new Error('模型 ZIP 导出目标必须是普通文件') } } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!isMissingFileError(error)) { throw error } } @@ -277,7 +250,7 @@ export async function exportModelArchive( } writeChain = writeChain.then(async () => { if (data.byteLength > 0) { - await output.write(data) + await writeModelBuffer(output, data) } }) if (final) { @@ -310,7 +283,11 @@ export async function exportModelArchive( await pushFileIntoArchive( archive, file, - safeChild(sourceDirectory, file.name), + managedModelChild( + sourceDirectory, + file.name, + '模型 ZIP 路径超出临时目录' + ), waitForOutput ) } @@ -334,7 +311,7 @@ function closeHandle(handle: FileHandle): Promise { export async function extractModelArchive( options: ExtractModelArchiveOptions ): Promise { - ensureNotAborted(options.signal) + ensureModelOperationNotAborted(options.signal) const maximumArchiveBytes = checkedLimit( options.maximumArchiveBytes, '模型 ZIP 大小限制' @@ -399,7 +376,9 @@ export async function extractModelArchive( const destination = resolve(options.destinationDirectory) const seenNames = new Set() const openHandles = new Set() - const completions: Promise[] = [] + const completions: Promise< + { ok: true } | { ok: false; error: Error } + >[] = [] const pendingWrites = new Set>() let entryCount = 0 let totalBytes = 0 @@ -442,7 +421,11 @@ export async function extractModelArchive( throw new Error(`模型 ZIP 条目大小超出限制:${name}`) } const handlePromise = open( - safeChild(destination, name), + managedModelChild( + destination, + name, + '模型 ZIP 路径超出临时目录' + ), 'wx' ).then((handle) => { openHandles.add(handle) @@ -456,7 +439,12 @@ export async function extractModelArchive( resolveEntry = resolveEntryPromise rejectEntry = rejectEntryPromise }) - completions.push(completion) + completions.push( + completion.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + ) file.ondata = (error, data, final) => { if (error) { rejectEntry?.(fail(error)) @@ -480,10 +468,6 @@ export async function extractModelArchive( } written += data.byteLength totalBytes += data.byteLength - if (name !== ARCHIVE_MANIFEST_NAME) { - completedModelBytes += data.byteLength - options.onProgress?.(completedModelBytes) - } if ( written > entryMaximum || totalBytes > maximumTotalBytes @@ -497,7 +481,12 @@ export async function extractModelArchive( writeChain = writeChain.then(async () => { const handle = await handlePromise if (data.byteLength > 0) { - await handle.write(data) + await writeModelBuffer(handle, data, (persisted) => { + if (name !== ARCHIVE_MANIFEST_NAME) { + completedModelBytes += persisted.byteLength + options.onProgress?.(completedModelBytes) + } + }) } }) const pendingWrite = writeChain @@ -529,7 +518,7 @@ export async function extractModelArchive( const buffer = Buffer.allocUnsafe(16 * 1024) try { while (true) { - ensureNotAborted(options.signal) + ensureModelOperationNotAborted(options.signal) if (fatalError) { throw fatalError } @@ -544,7 +533,13 @@ export async function extractModelArchive( ) await Promise.all([...pendingWrites]) } - await Promise.all(completions) + const completionResults = await Promise.all(completions) + const failedCompletion = completionResults.find( + (result) => !result.ok + ) + if (failedCompletion && !failedCompletion.ok) { + throw failedCompletion.error + } if (fatalError) { throw fatalError } @@ -571,7 +566,11 @@ export async function extractModelArchive( manifest = modelArchiveManifestSchema.parse( JSON.parse( await readFile( - safeChild(destination, ARCHIVE_MANIFEST_NAME), + managedModelChild( + destination, + ARCHIVE_MANIFEST_NAME, + '模型 ZIP 路径超出临时目录' + ), 'utf8' ) ) as unknown @@ -597,13 +596,19 @@ export async function extractModelArchive( throw new Error('模型 ZIP 清单与当前模型目录不匹配') } for (const archived of manifest.files) { - const path = safeChild(destination, archived.name) + const path = managedModelChild( + destination, + archived.name, + '模型 ZIP 路径超出临时目录' + ) const metadata = await lstat(path) + const hash = await hashModelFile(path) if ( !metadata.isFile() || metadata.isSymbolicLink() || metadata.size !== archived.size || - (await hashFile(path)) !== archived.sha256 + hash.size !== archived.size || + hash.sha256 !== archived.sha256 ) { throw new Error(`模型 ZIP 文件校验失败:${archived.name}`) } diff --git a/src/main/model-download-transport.ts b/src/main/model-download-transport.ts index 0a6e5e9..12557f2 100644 --- a/src/main/model-download-transport.ts +++ b/src/main/model-download-transport.ts @@ -1,3 +1,5 @@ +import { ensureModelOperationNotAborted } from './model-package-utils' + const MAX_REDIRECTS = 3 const redirectStatuses = new Set([301, 302, 303, 307, 308]) @@ -28,9 +30,7 @@ export async function fetchModelDownloadResponse(options: { const initialHost = url.hostname const allowedRedirectHosts = new Set(options.redirectHosts) for (let redirectCount = 0; ; redirectCount += 1) { - if (options.signal.aborted) { - throw new DOMException('The operation was aborted', 'AbortError') - } + ensureModelOperationNotAborted(options.signal) const response = await options.transport(url, { method: 'GET', redirect: 'manual', diff --git a/src/main/model-package-utils.test.ts b/src/main/model-package-utils.test.ts new file mode 100644 index 0000000..b0c9412 --- /dev/null +++ b/src/main/model-package-utils.test.ts @@ -0,0 +1,154 @@ +import { createHash } from 'node:crypto' +import { + mkdtemp, + mkdir, + readFile, + readdir, + rename, + rm, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cleanupStaleModelInstallArtifacts, + writeModelBuffer +} from './model-package-utils' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +describe('cleanupStaleModelInstallArtifacts', () => { + it('preserves active staging and names outside the manager contract', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cleanup-')) + temporaryDirectories.push(root) + const active = + '.install-active-model-00000000-0000-4000-8000-000000000001' + const stale = + '.install-owned-model-00000000-0000-4000-8000-000000000002' + const userDirectory = '.install-owned-model-backup' + await Promise.all([ + mkdir(join(root, active)), + mkdir(join(root, stale)), + mkdir(join(root, userDirectory)), + mkdir(join(root, 'owned-model')) + ]) + await writeFile(join(root, userDirectory, 'keep.txt'), 'keep') + await writeFile(join(root, 'owned-model', 'package.bin.partial'), 'stale') + await writeFile(join(root, 'owned-model', 'notes.partial'), 'keep') + + await cleanupStaleModelInstallArtifacts({ + rootDirectory: root, + isModelId: (value) => + value === 'active-model' || value === 'owned-model', + activeModelIds: new Set(['active-model']), + partialFileNames: new Set(['package.bin']), + escapeMessage: 'escaped' + }) + + expect(await readdir(root)).toEqual( + expect.arrayContaining([active, userDirectory, 'owned-model']) + ) + expect(await readdir(root)).not.toContain(stale) + await expect( + readFile(join(root, userDirectory, 'keep.txt'), 'utf8') + ).resolves.toBe('keep') + await expect( + readFile(join(root, 'owned-model', 'notes.partial'), 'utf8') + ).resolves.toBe('keep') + await expect( + readFile( + join(root, 'owned-model', 'package.bin.partial'), + 'utf8' + ) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('ignores a selection partial renamed after enumeration', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cleanup-')) + temporaryDirectories.push(root) + const partialName = + '.selection.json.00000000-0000-4000-8000-000000000001.partial' + const partialPath = join(root, partialName) + const renamedPath = join(root, 'selection-completed') + await writeFile(partialPath, 'selection') + + await expect( + cleanupStaleModelInstallArtifacts({ + rootDirectory: root, + isModelId: () => false, + activeModelIds: new Set(), + partialFileNames: new Set(), + cleanSelectionPartials: true, + activeSelectionPartialNames: new Set(), + escapeMessage: 'escaped', + operations: { + unlinkFile: async (path) => { + await rename(path, renamedPath) + await unlink(path) + } + } + }) + ).resolves.toBeUndefined() + await expect(readFile(renamedPath, 'utf8')).resolves.toBe( + 'selection' + ) + }) +}) + +describe('writeModelBuffer', () => { + it('retries short writes until every byte is persisted', async () => { + const persisted: number[] = [] + const write = vi.fn( + async ( + buffer: Uint8Array, + offset = 0, + length = buffer.byteLength - offset + ) => { + const bytesWritten = Math.min(2, length) + persisted.push( + ...buffer.subarray(offset, offset + bytesWritten) + ) + return { bytesWritten, buffer } + } + ) + const value = Uint8Array.from([1, 2, 3, 4, 5]) + const hash = createHash('sha256') + const onPersisted = vi.fn((buffer: Uint8Array) => { + hash.update(buffer) + }) + + await expect( + writeModelBuffer({ write } as never, value, onPersisted) + ).resolves.toBe(value.byteLength) + expect(persisted).toEqual([...value]) + expect(write).toHaveBeenCalledTimes(3) + expect(onPersisted).toHaveBeenCalledOnce() + expect(hash.digest('hex')).toBe( + createHash('sha256').update(value).digest('hex') + ) + }) + + it('fails closed when a write makes no progress', async () => { + await expect( + writeModelBuffer( + { + write: vi.fn(async (buffer: Uint8Array) => ({ + bytesWritten: 0, + buffer + })) + } as never, + Uint8Array.from([1]) + ) + ).rejects.toThrow('写入不完整') + }) +}) diff --git a/src/main/model-package-utils.ts b/src/main/model-package-utils.ts new file mode 100644 index 0000000..f1c3d91 --- /dev/null +++ b/src/main/model-package-utils.ts @@ -0,0 +1,260 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + lstat, + mkdir, + open, + readdir, + rm, + unlink, + type FileHandle +} from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { isMissingFileError } from './settings-file-utils' + +export const MODEL_PARTIAL_SUFFIX = '.partial' + +export type ModelFileFingerprint = { + dev: bigint + ino: bigint + size: bigint + mode: bigint + mtimeNs: bigint + ctimeNs: bigint + isFile: boolean + isSymbolicLink: boolean +} + +const uuidPattern = + '[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' +const stagingNamePattern = new RegExp( + `^\\.install-(.+)-(${uuidPattern})$`, + 'iu' +) +const selectionPartialPattern = new RegExp( + `^\\.selection\\.json\\.(${uuidPattern})\\.partial$`, + 'iu' +) + +export function ensureModelOperationNotAborted( + signal?: AbortSignal +): void { + if (signal?.aborted) { + throw new DOMException('The operation was aborted', 'AbortError') + } +} + +export function managedModelChild( + parent: string, + name: string, + escapeMessage: string +): string { + const child = resolve(parent, name) + if (dirname(child) !== resolve(parent)) { + throw new Error(escapeMessage) + } + return child +} + +export async function fingerprintModelFile( + path: string +): Promise { + const status = await lstat(path, { bigint: true }) + return { + dev: status.dev, + ino: status.ino, + size: status.size, + mode: status.mode, + mtimeNs: status.mtimeNs, + ctimeNs: status.ctimeNs, + isFile: status.isFile(), + isSymbolicLink: status.isSymbolicLink() + } +} + +export function modelFileFingerprintMatches( + left: ModelFileFingerprint, + right: ModelFileFingerprint +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mode === right.mode && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs && + left.isFile === right.isFile && + left.isSymbolicLink === right.isSymbolicLink + ) +} + +export async function writeModelBuffer( + handle: Pick, + buffer: Uint8Array, + onPersisted?: (buffer: Uint8Array) => void +): Promise { + let offset = 0 + while (offset < buffer.byteLength) { + const { bytesWritten } = await handle.write( + buffer, + offset, + buffer.byteLength - offset + ) + if ( + !Number.isSafeInteger(bytesWritten) || + bytesWritten <= 0 || + bytesWritten > buffer.byteLength - offset + ) { + throw new Error('模型文件写入不完整') + } + offset += bytesWritten + } + onPersisted?.(buffer) + return offset +} + +export async function hashModelFile( + path: string, + signal?: AbortSignal +): Promise<{ size: number; sha256: string }> { + const handle = await open(path, 'r') + const hash = createHash('sha256') + const buffer = Buffer.allocUnsafe(64 * 1024) + let size = 0 + try { + while (true) { + if (signal) { + ensureModelOperationNotAborted(signal) + } + const { bytesRead } = await handle.read(buffer, 0, buffer.length) + if (bytesRead === 0) { + break + } + hash.update(buffer.subarray(0, bytesRead)) + size += bytesRead + } + } finally { + await handle.close() + } + return { size, sha256: hash.digest('hex') } +} + +export function attachModelAbortSignal( + signal: AbortSignal | undefined, + controller: AbortController +): () => void { + if (!signal) { + return () => undefined + } + const abort = (): void => controller.abort() + if (signal.aborted) { + controller.abort() + } else { + signal.addEventListener('abort', abort, { once: true }) + } + return () => signal.removeEventListener('abort', abort) +} + +export async function createModelStagingDirectory( + rootDirectory: string, + modelId: string, + escapeMessage: string +): Promise { + const directory = managedModelChild( + rootDirectory, + `.install-${modelId}-${randomUUID()}`, + escapeMessage + ) + await mkdir(directory, { recursive: false }) + return directory +} + +export async function cleanupStaleModelInstallArtifacts(input: { + rootDirectory: string + isModelId: (value: string) => boolean + activeModelIds: ReadonlySet + partialFileNames: ReadonlySet + cleanSelectionPartials?: boolean + activeSelectionPartialNames?: ReadonlySet + escapeMessage: string + operations?: { + unlinkFile?: (path: string) => Promise + } +}): Promise { + const unlinkFile = input.operations?.unlinkFile ?? unlink + const entries = await readdir(input.rootDirectory, { + withFileTypes: true + }) + for (const entry of entries) { + const stagingMatch = stagingNamePattern.exec(entry.name) + if (stagingMatch) { + const modelId = stagingMatch[1]! + if ( + input.isModelId(modelId) && + !input.activeModelIds.has(modelId) && + entry.isDirectory() && + !entry.isSymbolicLink() + ) { + await rm( + managedModelChild( + input.rootDirectory, + entry.name, + input.escapeMessage + ), + { recursive: true, force: true } + ) + } + continue + } + if ( + input.cleanSelectionPartials && + selectionPartialPattern.test(entry.name) && + !input.activeSelectionPartialNames?.has(entry.name) && + entry.isFile() && + !entry.isSymbolicLink() + ) { + try { + await unlinkFile( + managedModelChild( + input.rootDirectory, + entry.name, + input.escapeMessage + ) + ) + } catch (error) { + if (!isMissingFileError(error)) { + throw error + } + } + continue + } + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + !input.isModelId(entry.name) + ) { + continue + } + const modelDirectory = managedModelChild( + input.rootDirectory, + entry.name, + input.escapeMessage + ) + for (const partialName of input.partialFileNames) { + const partialPath = managedModelChild( + modelDirectory, + `${partialName}${MODEL_PARTIAL_SUFFIX}`, + input.escapeMessage + ) + try { + const status = await lstat(partialPath) + if (status.isFile() && !status.isSymbolicLink()) { + await unlinkFile(partialPath) + } + } catch (error) { + if (!isMissingFileError(error)) { + throw error + } + } + } + } +} diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index a568f34..1bbac7f 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -78,6 +78,34 @@ afterEach(async () => { }) describe('RuntimeSettingsStore', () => { + it('restores an exact credential-bearing snapshot after a failed activation', async () => { + const { store } = await createStore() + await store.update( + settings({ + apiKey: { action: 'replace', value: 'previous-key' } + }) + ) + const rollback = await store.captureRollback() + + await store.update( + settings({ + modelBaseUrl: 'https://candidate.example/v1', + modelName: 'candidate', + apiKey: { action: 'replace', value: 'candidate-key' } + }) + ) + await expect(rollback.restore()).resolves.toMatchObject({ + modelBaseUrl: 'https://bigtoken.ai', + modelName: 'sonnet-5', + apiKeyConfigured: true + }) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + modelBaseUrl: 'https://bigtoken.ai', + modelName: 'sonnet-5', + apiKey: 'previous-key' + }) + }) + it('migrates version 17 to empty Runtime customization', async () => { const { filePath, store } = await createStore() await store.update(settings()) diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index d1d8885..00ad7c8 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -235,6 +235,10 @@ const storedSettingsSchema = version17StoredSettingsSchema }) type StoredSettings = z.infer +export type RuntimeSettingsRollback = { + publicSettings: RuntimeSettings + restore(): Promise +} type Version17StoredSettings = z.infer< typeof version17StoredSettingsSchema > @@ -1481,6 +1485,39 @@ export class RuntimeSettingsStore { return this.toPublicSettings(await this.load()) } + captureRollback(): Promise { + let result: RuntimeSettingsRollback | undefined + const operation = this.updateQueue.then(async () => { + const snapshot = structuredClone(await this.load()) + result = { + publicSettings: this.toPublicSettings(snapshot), + restore: () => this.restoreSnapshot(snapshot) + } + }) + this.updateQueue = operation.then( + () => undefined, + () => undefined + ) + return operation.then(() => result!) + } + + private restoreSnapshot( + snapshot: StoredSettings + ): Promise { + const operation = this.updateQueue.then(async () => { + const restored = structuredClone(snapshot) + await writeJsonFileAtomically(this.filePath, restored) + this.settings = restored + this.loadWarnings = [] + return this.toPublicSettings(restored) + }) + this.updateQueue = operation.then( + () => undefined, + () => undefined + ) + return operation + } + async getPolicySettings(): Promise { const settings = await this.load() return { diff --git a/src/main/shortcut-settings-service.test.ts b/src/main/shortcut-settings-service.test.ts new file mode 100644 index 0000000..11dec8c --- /dev/null +++ b/src/main/shortcut-settings-service.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' +import { + areShortcutAcceleratorsEquivalent, + type GlobalShortcutSettings +} from '../shared/shortcut' +import { ShortcutSettingsService } from './shortcut-settings-service' + +function createFixture( + initial: GlobalShortcutSettings = { + enabled: true, + accelerator: 'CommandOrControl+Shift+Space' + }, + platform = 'win32' +) { + let persisted = { ...initial } + const store = { + get: vi.fn(async () => ({ ...persisted })), + update: vi.fn(async (input: unknown) => { + persisted = input as GlobalShortcutSettings + return { ...persisted } + }) + } + const registered = new Set() + const conflicts = new Set() + const registry = { + register: vi.fn((accelerator: string) => { + if ( + conflicts.has(accelerator) || + [...registered].some((current) => + areShortcutAcceleratorsEquivalent( + current, + accelerator, + platform + ) + ) + ) { + return false + } + registered.add(accelerator) + return true + }), + unregister: vi.fn((accelerator: string) => { + registered.delete(accelerator) + }) + } + const service = new ShortcutSettingsService( + store, + registry, + vi.fn(), + platform + ) + return { + service, + store, + registry, + registered, + conflicts, + getPersisted: () => persisted + } +} + +describe('ShortcutSettingsService', () => { + it('registers the persisted shortcut at startup and reports display state', async () => { + const { service, registered } = createFixture() + + await expect(service.initialize()).resolves.toMatchObject({ + registered: true, + registeredAccelerator: 'CommandOrControl+Shift+Space', + displayAccelerator: 'Ctrl+Shift+Space', + status: 'registered' + }) + expect(registered).toEqual( + new Set(['CommandOrControl+Shift+Space']) + ) + }) + + it('keeps the old working registration and setting after a conflict', async () => { + const fixture = createFixture() + await fixture.service.initialize() + fixture.conflicts.add('Control+Alt+K') + + await expect( + fixture.service.update({ + enabled: true, + accelerator: 'Control+Alt+K' + }) + ).resolves.toMatchObject({ + ok: false, + error: 'conflict', + snapshot: { + settings: { + enabled: true, + accelerator: 'CommandOrControl+Shift+Space' + }, + registered: true, + status: 'registered' + } + }) + expect(fixture.store.update).not.toHaveBeenCalled() + expect(fixture.registered).toEqual( + new Set(['CommandOrControl+Shift+Space']) + ) + }) + + it.each([ + [ + 'win32', + 'CommandOrControl+Shift+Space', + 'Control+Shift+Space' + ], + [ + 'linux', + 'CmdOrCtrl+Shift+Space', + 'Control+Shift+Space' + ], + [ + 'darwin', + 'CommandOrControl+Shift+Space', + 'Command+Shift+Space' + ] + ])( + 'updates physically equivalent aliases on %s without self-conflict', + async (platform, initialAccelerator, nextAccelerator) => { + const fixture = createFixture( + { + enabled: true, + accelerator: initialAccelerator + }, + platform + ) + await fixture.service.initialize() + fixture.registry.register.mockClear() + + await expect( + fixture.service.update({ + enabled: true, + accelerator: nextAccelerator + }) + ).resolves.toMatchObject({ + ok: true, + snapshot: { + settings: { accelerator: nextAccelerator }, + registeredAccelerator: initialAccelerator, + status: 'registered' + } + }) + expect(fixture.registry.register).not.toHaveBeenCalled() + expect(fixture.getPersisted().accelerator).toBe(nextAccelerator) + expect(fixture.registered).toEqual( + new Set([initialAccelerator]) + ) + } + ) + + it('rolls back a newly registered shortcut when persistence fails', async () => { + const fixture = createFixture() + await fixture.service.initialize() + fixture.store.update.mockRejectedValueOnce(new Error('disk full')) + + await expect( + fixture.service.update({ + enabled: true, + accelerator: 'Control+Alt+K' + }) + ).resolves.toMatchObject({ + ok: false, + error: 'save-failed', + snapshot: { + settings: { + accelerator: 'CommandOrControl+Shift+Space' + }, + registeredAccelerator: 'CommandOrControl+Shift+Space' + } + }) + expect(fixture.registered).toEqual( + new Set(['CommandOrControl+Shift+Space']) + ) + }) + + it('persists disabling before removing the working registration', async () => { + const fixture = createFixture() + await fixture.service.initialize() + + await expect( + fixture.service.update({ + enabled: false, + accelerator: 'CommandOrControl+Shift+Space' + }) + ).resolves.toMatchObject({ + ok: true, + snapshot: { + registered: false, + status: 'disabled' + } + }) + expect(fixture.getPersisted().enabled).toBe(false) + expect(fixture.registered).toEqual(new Set()) + }) +}) diff --git a/src/main/shortcut-settings-service.ts b/src/main/shortcut-settings-service.ts new file mode 100644 index 0000000..cac553c --- /dev/null +++ b/src/main/shortcut-settings-service.ts @@ -0,0 +1,173 @@ +import { + areShortcutAcceleratorsEquivalent, + defaultGlobalShortcutSettings, + formatShortcutForDisplay, + globalShortcutSettingsSchema, + type GlobalShortcutRegistrationStatus, + type GlobalShortcutSettings, + type GlobalShortcutSettingsSnapshot, + type GlobalShortcutSettingsUpdateResult +} from '../shared/shortcut' + +export interface GlobalShortcutRegistry { + register(accelerator: string, callback: () => void): boolean + unregister(accelerator: string): void +} + +export interface ShortcutSettingsPersistence { + get(): Promise + update(input: unknown): Promise +} + +export class ShortcutSettingsService { + private settings: GlobalShortcutSettings = { + ...defaultGlobalShortcutSettings + } + private registeredAccelerator?: string + private status: GlobalShortcutRegistrationStatus = 'disabled' + private updateQueue: Promise = Promise.resolve() + + constructor( + private readonly store: ShortcutSettingsPersistence, + private readonly registry: GlobalShortcutRegistry, + private readonly callback: () => void, + private readonly platform: string + ) {} + + async initialize(): Promise { + this.settings = await this.store.get() + if (!this.settings.enabled) { + this.status = 'disabled' + return this.snapshot() + } + const result = this.tryRegister(this.settings.accelerator) + if (result === 'registered') { + this.registeredAccelerator = this.settings.accelerator + } + this.status = result + return this.snapshot() + } + + private tryRegister( + accelerator: string + ): Extract< + GlobalShortcutRegistrationStatus, + 'registered' | 'conflict' | 'failed' + > { + try { + return this.registry.register(accelerator, this.callback) + ? 'registered' + : 'conflict' + } catch { + return 'failed' + } + } + + snapshot(): GlobalShortcutSettingsSnapshot { + return { + settings: { ...this.settings }, + defaultSettings: { ...defaultGlobalShortcutSettings }, + platform: this.platform, + displayAccelerator: formatShortcutForDisplay( + this.settings.accelerator, + this.platform + ), + registered: this.registeredAccelerator !== undefined, + ...(this.registeredAccelerator + ? { registeredAccelerator: this.registeredAccelerator } + : {}), + status: this.status + } + } + + getSnapshot(): GlobalShortcutSettingsSnapshot { + return this.snapshot() + } + + update(input: unknown): Promise { + const operation = this.updateQueue.then( + async (): Promise => { + const next = globalShortcutSettingsSchema.parse(input) + const previous = this.settings + const previousRegistered = this.registeredAccelerator + const previousStatus = this.status + + if (!next.enabled) { + try { + await this.store.update(next) + } catch { + return { + ok: false, + error: 'save-failed', + snapshot: this.snapshot() + } + } + if (previousRegistered) { + this.registry.unregister(previousRegistered) + } + this.settings = next + this.registeredAccelerator = undefined + this.status = 'disabled' + return { ok: true, snapshot: this.snapshot() } + } + + const keepsWorkingRegistration = + previousRegistered !== undefined && + areShortcutAcceleratorsEquivalent( + previousRegistered, + next.accelerator, + this.platform + ) + if (!keepsWorkingRegistration) { + const registration = this.tryRegister(next.accelerator) + if (registration !== 'registered') { + this.status = previousRegistered + ? 'registered' + : registration + return { + ok: false, + error: + registration === 'conflict' + ? 'conflict' + : 'registration-failed', + snapshot: this.snapshot() + } + } + } + + try { + await this.store.update(next) + } catch { + if (!keepsWorkingRegistration) { + this.registry.unregister(next.accelerator) + } + this.settings = previous + this.registeredAccelerator = previousRegistered + this.status = previousRegistered + ? 'registered' + : previousStatus + return { + ok: false, + error: 'save-failed', + snapshot: this.snapshot() + } + } + + if (previousRegistered && !keepsWorkingRegistration) { + this.registry.unregister(previousRegistered) + } + this.settings = next + this.registeredAccelerator = keepsWorkingRegistration + ? previousRegistered + : next.accelerator + this.status = 'registered' + return { ok: true, snapshot: this.snapshot() } + } + ) + this.updateQueue = operation.then( + () => undefined, + () => undefined + ) + return operation + } +} diff --git a/src/main/shortcut-settings-store.test.ts b/src/main/shortcut-settings-store.test.ts new file mode 100644 index 0000000..0d82e92 --- /dev/null +++ b/src/main/shortcut-settings-store.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + defaultGlobalShortcutSettings +} from '../shared/shortcut' +import { ShortcutSettingsStore } from './shortcut-settings-store' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +async function createStore(): Promise<{ + filePath: string + store: ShortcutSettingsStore +}> { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-shortcut-')) + directories.push(directory) + const filePath = join(directory, 'shortcut-settings.json') + return { filePath, store: new ShortcutSettingsStore(filePath) } +} + +describe('ShortcutSettingsStore', () => { + it('preserves the legacy shortcut as the non-persisted default', async () => { + const { filePath, store } = await createStore() + + await expect(store.get()).resolves.toEqual( + defaultGlobalShortcutSettings + ) + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('persists a validated versioned shortcut and reloads it', async () => { + const { filePath, store } = await createStore() + await expect( + store.update({ + enabled: false, + accelerator: 'ctrl+alt+k' + }) + ).resolves.toEqual({ + enabled: false, + accelerator: 'Control+Alt+K' + }) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + version: 1, + enabled: false, + accelerator: 'Control+Alt+K' + }) + await expect( + new ShortcutSettingsStore(filePath).get() + ).resolves.toEqual({ + enabled: false, + accelerator: 'Control+Alt+K' + }) + }) + + it('rejects invalid accelerators without replacing saved state', async () => { + const { filePath, store } = await createStore() + await store.update({ + enabled: true, + accelerator: 'Control+Alt+K' + }) + const saved = await readFile(filePath, 'utf8') + + await expect( + store.update({ enabled: true, accelerator: 'K' }) + ).rejects.toThrow() + expect(await readFile(filePath, 'utf8')).toBe(saved) + }) +}) diff --git a/src/main/shortcut-settings-store.ts b/src/main/shortcut-settings-store.ts new file mode 100644 index 0000000..b8bf4a3 --- /dev/null +++ b/src/main/shortcut-settings-store.ts @@ -0,0 +1,122 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { + defaultGlobalShortcutSettings, + globalShortcutSettingsSchema, + type GlobalShortcutSettings +} from '../shared/shortcut' +import { + assertSupportedSettingsVersion, + isolateCorruptSettingsFile, + isMissingFileError, + UnsupportedSettingsVersionError, + writeJsonFileAtomically +} from './settings-file-utils' + +const CURRENT_SETTINGS_VERSION = 1 + +const storedShortcutSettingsSchema = globalShortcutSettingsSchema + .extend({ version: z.literal(CURRENT_SETTINGS_VERSION) }) + .strict() + +type StoredShortcutSettings = z.infer< + typeof storedShortcutSettingsSchema +> + +export class ShortcutSettingsStore { + private settings?: StoredShortcutSettings + private loadOperation?: Promise + private updateQueue: Promise = Promise.resolve() + + constructor(private readonly filePath: string) {} + + private async readStored(): Promise { + try { + const contents = await readFile(this.filePath, 'utf8') + let parsed: unknown + try { + parsed = JSON.parse(contents) as unknown + } catch { + await isolateCorruptSettingsFile( + this.filePath, + 'Shortcut settings are corrupt and could not be isolated' + ) + return { + version: CURRENT_SETTINGS_VERSION, + ...defaultGlobalShortcutSettings + } + } + assertSupportedSettingsVersion( + parsed, + CURRENT_SETTINGS_VERSION, + (version) => + `当前 GoodBuddy 不支持快捷键设置版本 ${version},请升级应用后重试` + ) + const result = storedShortcutSettingsSchema.safeParse(parsed) + if (!result.success) { + await isolateCorruptSettingsFile( + this.filePath, + 'Shortcut settings are corrupt and could not be isolated' + ) + return { + version: CURRENT_SETTINGS_VERSION, + ...defaultGlobalShortcutSettings + } + } + return result.data + } catch (error) { + if (error instanceof UnsupportedSettingsVersionError) { + throw error + } + if (!isMissingFileError(error)) { + throw new Error('Shortcut settings could not be read', { + cause: error + }) + } + return { + version: CURRENT_SETTINGS_VERSION, + ...defaultGlobalShortcutSettings + } + } + } + + private async load(): Promise { + if (this.settings) { + return this.settings + } + if (!this.loadOperation) { + this.loadOperation = this.readStored() + .then((settings) => { + this.settings = settings + return settings + }) + .finally(() => { + this.loadOperation = undefined + }) + } + return this.loadOperation + } + + async get(): Promise { + const { enabled, accelerator } = await this.load() + return { enabled, accelerator } + } + + update(input: unknown): Promise { + const operation = this.updateQueue.then(async () => { + const settings = globalShortcutSettingsSchema.parse(input) + const stored: StoredShortcutSettings = { + version: CURRENT_SETTINGS_VERSION, + ...settings + } + await writeJsonFileAtomically(this.filePath, stored) + this.settings = stored + return settings + }) + this.updateQueue = operation.then( + () => undefined, + () => undefined + ) + return operation + } +} diff --git a/src/main/speech/speech-model-manager.test.ts b/src/main/speech/speech-model-manager.test.ts index 424b8c6..77f3e20 100644 --- a/src/main/speech/speech-model-manager.test.ts +++ b/src/main/speech/speech-model-manager.test.ts @@ -5,6 +5,8 @@ import { readFile, readdir, rm, + stat, + utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -249,6 +251,147 @@ describe('speech model catalog', () => { }) describe('SpeechModelManager downloads', () => { + it('rebuilds a stale selected runtime after valid files are restored', async () => { + const userData = await temporaryDirectory() + const modelBytes = new TextEncoder().encode('verified model bytes') + const tokenBytes = new TextEncoder().encode('verified tokens') + const root = join(userData, 'models', 'speech') + const staleStaging = + '.install-download-test-model-00000000-0000-4000-8000-000000000001' + await mkdir(root, { recursive: true }) + await Promise.all([ + mkdir(join(root, staleStaging)), + writeFile( + join(root, '.selection.json'), + `${JSON.stringify({ + selectedModelId: 'download-test-model' + })}\n` + ) + ]) + const getDownloadSource = vi.fn(() => 'modelscope' as const) + const manager = new SpeechModelManager({ + userDataDirectory: userData, + fetch: vi.fn(async (input) => { + const bytes = String(input).endsWith('model.onnx') + ? modelBytes + : tokenBytes + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) } + }) + }), + catalog: downloadableCatalog(modelBytes, tokenBytes), + getDownloadSource + }) + + await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined() + await manager.install('download-test-model', 'modelscope') + const selected = await manager.getSelectedRuntimeModel() + expect(selected).toMatchObject({ id: 'download-test-model' }) + const modelPath = join( + root, + 'download-test-model', + 'model.onnx' + ) + const originalModelStat = await stat(modelPath) + await writeFile( + modelPath, + Buffer.alloc(modelBytes.byteLength, 0x7f) + ) + await utimes( + modelPath, + originalModelStat.atime, + originalModelStat.mtime + ) + await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined() + await writeFile(modelPath, modelBytes) + await utimes( + modelPath, + originalModelStat.atime, + originalModelStat.mtime + ) + const rebuilt = await Promise.all([ + manager.getSelectedRuntimeModel(), + manager.getSelectedRuntimeModel() + ]) + expect(rebuilt).toEqual([ + expect.objectContaining({ id: 'download-test-model' }), + expect.objectContaining({ id: 'download-test-model' }) + ]) + const manifestPath = join( + root, + 'download-test-model', + 'manifest.json' + ) + const manifest = await readFile(manifestPath) + await rm(manifestPath) + await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined() + await writeFile(manifestPath, manifest) + await expect(manager.getSelectedRuntimeModel()).resolves.toMatchObject({ + id: 'download-test-model' + }) + expect(await readdir(root)).toContain(staleStaging) + expect(getDownloadSource).not.toHaveBeenCalled() + + await writeFile( + join(root, '.selection.json'), + '{"selectedModelId":null}\n' + ) + await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined() + }) + + it('preserves an active selection partial during concurrent snapshot cleanup', async () => { + const userData = await temporaryDirectory() + const modelBytes = new TextEncoder().encode('verified model bytes') + const tokenBytes = new TextEncoder().encode('verified tokens') + let markWriteStarted: (() => void) | undefined + let releaseWrite: (() => void) | undefined + const writeStarted = new Promise((resolveStarted) => { + markWriteStarted = resolveStarted + }) + const writeGate = new Promise((resolveWrite) => { + releaseWrite = resolveWrite + }) + const manager = new SpeechModelManager({ + userDataDirectory: userData, + fetch: vi.fn(async (input) => { + const bytes = String(input).endsWith('model.onnx') + ? modelBytes + : tokenBytes + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) } + }) + }), + catalog: downloadableCatalog(modelBytes, tokenBytes), + selectionFileOperations: { + writeFile: async (path, data, options) => { + await writeFile(path, data, options) + markWriteStarted?.() + await writeGate + } + } + }) + await manager.install('download-test-model') + const selecting = manager.select('download-test-model') + await writeStarted + const root = join(userData, 'models', 'speech') + const activePartial = (await readdir(root)).find( + (name) => + name.startsWith('.selection.json.') && + name.endsWith('.partial') + ) + expect(activePartial).toBeDefined() + + await manager.snapshot() + expect(await readdir(root)).toContain(activePartial) + + releaseWrite?.() + await selecting + await expect(manager.snapshot()).resolves.toMatchObject({ + selectedModelId: 'download-test-model' + }) + expect(await readdir(root)).not.toContain(activePartial) + }) + it('downloads to partial files, verifies hashes, and atomically installs', async () => { const userData = await temporaryDirectory() const modelBytes = new TextEncoder().encode('verified model bytes') @@ -330,6 +473,75 @@ describe('SpeechModelManager downloads', () => { }) }) + it('cleans only manager-owned stale staging and partial artifacts', async () => { + const userData = await temporaryDirectory() + const modelBytes = new TextEncoder().encode('verified model bytes') + const tokenBytes = new TextEncoder().encode('verified tokens') + const manager = new SpeechModelManager({ + userDataDirectory: userData, + fetch: vi.fn(async (input) => { + const bytes = String(input).endsWith('model.onnx') + ? modelBytes + : tokenBytes + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) } + }) + }), + catalog: downloadableCatalog(modelBytes, tokenBytes) + }) + await manager.install('download-test-model') + const root = join(userData, 'models', 'speech') + const modelDirectory = join(root, 'download-test-model') + const staleStaging = + '.install-download-test-model-00000000-0000-4000-8000-000000000001' + const unrelatedStaging = '.install-download-test-model-user-backup' + const selectionPartial = + '.selection.json.00000000-0000-4000-8000-000000000002.partial' + await mkdir(join(root, staleStaging)) + await writeFile(join(root, staleStaging, 'model.onnx.partial'), 'stale') + await mkdir(join(root, unrelatedStaging)) + await writeFile(join(root, unrelatedStaging, 'keep.txt'), 'keep') + await writeFile( + join(modelDirectory, 'model.onnx.partial'), + 'interrupted' + ) + await writeFile(join(modelDirectory, 'notes.partial'), 'keep') + await writeFile(join(root, selectionPartial), 'interrupted') + await writeFile(join(root, 'user.partial'), 'keep') + + await expect(manager.snapshot()).resolves.toMatchObject({ + installed: [expect.objectContaining({ id: 'download-test-model' })] + }) + + expect(await readdir(root)).toEqual( + expect.arrayContaining([ + 'download-test-model', + unrelatedStaging, + 'user.partial' + ]) + ) + expect(await readdir(root)).not.toEqual( + expect.arrayContaining([staleStaging, selectionPartial]) + ) + expect(await readdir(modelDirectory)).toEqual( + expect.arrayContaining([ + 'manifest.json', + 'model.onnx', + 'tokens.txt', + 'notes.partial' + ]) + ) + expect(await readdir(modelDirectory)).not.toContain( + 'model.onnx.partial' + ) + await expect( + readFile(join(modelDirectory, 'model.onnx')) + ).resolves.toEqual(Buffer.from(modelBytes)) + await expect( + readFile(join(root, unrelatedStaging, 'keep.txt'), 'utf8') + ).resolves.toBe('keep') + }) + it('freezes the operation source when the global setting changes', async () => { const userData = await temporaryDirectory() const modelBytes = new TextEncoder().encode('expected') diff --git a/src/main/speech/speech-model-manager.ts b/src/main/speech/speech-model-manager.ts index d54bb82..dacdd5f 100644 --- a/src/main/speech/speech-model-manager.ts +++ b/src/main/speech/speech-model-manager.ts @@ -11,7 +11,7 @@ import { stat, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import { resolve } from 'node:path' import { z } from 'zod' import { installedSpeechModelSchema, @@ -39,11 +39,24 @@ import { extractModelArchive } from '../model-archive' import { fetchModelDownloadResponse } from '../model-download-transport' +import { + MODEL_PARTIAL_SUFFIX, + attachModelAbortSignal, + cleanupStaleModelInstallArtifacts, + createModelStagingDirectory, + ensureModelOperationNotAborted, + fingerprintModelFile, + hashModelFile, + managedModelChild, + modelFileFingerprintMatches, + type ModelFileFingerprint, + writeModelBuffer +} from '../model-package-utils' +import { isMissingFileError } from '../settings-file-utils' const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 const MANIFEST_FILE_NAME = 'manifest.json' const SELECTION_FILE_NAME = '.selection.json' -const PARTIAL_SUFFIX = '.partial' const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1 const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024 @@ -61,6 +74,17 @@ type ActiveOperation = { progress: SpeechModelOperation } +type SpeechSelectionFileOperations = { + writeFile: typeof writeFile + rename: typeof rename +} + +type CachedSelectedSpeechRuntimeModel = { + model: SelectedSpeechRuntimeModel + manifestFingerprint: ModelFileFingerprint + fileFingerprints: Map +} + export type SpeechModelManagerOptions = { userDataDirectory: string fetch: typeof fetch @@ -69,6 +93,7 @@ export type SpeechModelManagerOptions = { | ModelDownloadSource | Promise maxFileBytes?: number + selectionFileOperations?: Partial } export type SelectedSpeechRuntimeModel = { @@ -101,16 +126,6 @@ function toCatalogView(entry: SpeechModelCatalogEntry) { }) } -function abortError(): DOMException { - return new DOMException('The operation was aborted', 'AbortError') -} - -function ensureNotAborted(signal: AbortSignal): void { - if (signal.aborted) { - throw abortError() - } -} - function validateMaximumBytes(value: number | undefined): number { const maximum = value ?? DEFAULT_MAX_FILE_BYTES if ( @@ -124,40 +139,7 @@ function validateMaximumBytes(value: number | undefined): number { } function safeChild(parent: string, name: string): string { - const child = resolve(parent, name) - if (dirname(child) !== resolve(parent)) { - throw new Error('模型路径超出受管目录') - } - return child -} - -async function hashFile( - path: string, - signal?: AbortSignal -): Promise<{ - size: number - sha256: string -}> { - const handle = await open(path, 'r') - const hash = createHash('sha256') - let size = 0 - const buffer = Buffer.allocUnsafe(64 * 1024) - try { - while (true) { - if (signal) { - ensureNotAborted(signal) - } - const { bytesRead } = await handle.read(buffer, 0, buffer.length) - if (bytesRead === 0) { - break - } - hash.update(buffer.subarray(0, bytesRead)) - size += bytesRead - } - } finally { - await handle.close() - } - return { size, sha256: hash.digest('hex') } + return managedModelChild(parent, name, '模型路径超出受管目录') } export class SpeechModelManager { @@ -170,7 +152,13 @@ export class SpeechModelManager { | ModelDownloadSource | Promise private readonly maxFileBytes: number + private readonly selectionFileOperations: SpeechSelectionFileOperations private readonly operations = new Map() + private readonly activeSelectionPartialNames = new Set() + private selectedRuntimeModel?: Promise< + CachedSelectedSpeechRuntimeModel | undefined + > + private selectedRuntimeGeneration = 0 constructor(options: SpeechModelManagerOptions) { if (!options.userDataDirectory.trim()) { @@ -192,10 +180,16 @@ export class SpeechModelManager { } this.catalogViews = this.catalog.map(toCatalogView) this.maxFileBytes = validateMaximumBytes(options.maxFileBytes) + this.selectionFileOperations = { + writeFile, + rename, + ...options.selectionFileOperations + } } async snapshot(): Promise { await this.ensureRoot() + await this.cleanupStaleArtifacts() const [installed, selected, selectedDownloadSource] = await Promise.all([ this.readInstalled(), @@ -236,25 +230,52 @@ export class SpeechModelManager { async getSelectedRuntimeModel(): Promise< SelectedSpeechRuntimeModel | undefined > { - const snapshot = await this.snapshot() - if (!snapshot.selectedModelId) { + const selectedModelId = await this.readSelection() + if (!selectedModelId) { + this.invalidateSelectedRuntimeModel() return undefined } - const catalogEntry = this.catalog.find( - (entry) => entry.id === snapshot.selectedModelId - ) - const installed = snapshot.installed.find( - (entry) => entry.id === snapshot.selectedModelId - ) - if (!catalogEntry || !installed) { - return undefined + const cachedPromise = this.selectedRuntimeModel + if (cachedPromise) { + const cached = await cachedPromise + if ( + cached?.model.id === selectedModelId && + (await this.selectedRuntimeFingerprintsMatch(cached)) + ) { + return this.cloneSelectedRuntimeModel(cached.model) + } + if (this.selectedRuntimeModel === cachedPromise) { + this.invalidateSelectedRuntimeModel() + } } - return { - id: installed.id, - family: catalogEntry.family, - directory: this.modelDirectory(installed.id), - files: installed.files.map((file) => ({ ...file })) + const selected = await this.getOrCreateSelectedRuntimeModel( + selectedModelId + ) + return selected + ? this.cloneSelectedRuntimeModel(selected.model) + : undefined + } + + private getOrCreateSelectedRuntimeModel( + selectedModelId: string + ): Promise { + const current = this.selectedRuntimeModel + if (current) { + return current } + const generation = this.selectedRuntimeGeneration + const resolution = this.resolveSelectedRuntimeModel( + selectedModelId, + generation + ) + const tracked = resolution.catch((error) => { + if (this.selectedRuntimeModel === tracked) { + this.selectedRuntimeModel = undefined + } + throw error + }) + this.selectedRuntimeModel = tracked + return tracked } async install( @@ -290,7 +311,7 @@ export class SpeechModelManager { await this.assertNotInstalled(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id) for (const file of resolvedPackage.files) { - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) operation.progress.phase = 'transferring' operation.progress.currentFile = file.name const destination = safeChild(stagingDirectory, file.name) @@ -309,12 +330,13 @@ export class SpeechModelManager { stagingDirectory, operation.controller.signal ) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) await rename( stagingDirectory, this.modelDirectory(entry.id) ) stagingDirectory = undefined + this.invalidateSelectedRuntimeModel() return installed } finally { detachExternalAbort() @@ -338,6 +360,7 @@ export class SpeechModelManager { async remove(modelId: string): Promise { speechModelIdSchema.parse(modelId) this.cancel(modelId) + this.invalidateSelectedRuntimeModel() await this.ensureRoot() const target = this.modelDirectory(modelId) await rm(target, { recursive: true, force: true }) @@ -356,6 +379,7 @@ export class SpeechModelManager { } } await this.writeSelection(modelId) + this.invalidateSelectedRuntimeModel() } async registerLocalDirectory( @@ -382,12 +406,12 @@ export class SpeechModelManager { stagingDirectory = await this.createStagingDirectory(entry.id) operation.progress.phase = 'transferring' for (const file of entry.files) { - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) operation.progress.currentFile = file.name const sourceFile = safeChild(source, file.name) const destination = safeChild(stagingDirectory, file.name) await copyFile(sourceFile, destination) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) const copied = await stat(destination) if (copied.size > this.maxFileBytes) { throw new RangeError(`模型文件过大:${file.name}`) @@ -404,12 +428,13 @@ export class SpeechModelManager { stagingDirectory, operation.controller.signal ) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) await rename( stagingDirectory, this.modelDirectory(entry.id) ) stagingDirectory = undefined + this.invalidateSelectedRuntimeModel() return installed } finally { detachExternalAbort() @@ -542,9 +567,10 @@ export class SpeechModelManager { `${JSON.stringify(installed, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' } ) - ensureNotAborted(operation.controller.signal) + ensureModelOperationNotAborted(operation.controller.signal) await rename(stagingDirectory, this.modelDirectory(entry.id)) stagingDirectory = undefined + this.invalidateSelectedRuntimeModel() return installed } finally { this.operations.delete(entry.id) @@ -558,6 +584,163 @@ export class SpeechModelManager { await mkdir(this.rootDirectory, { recursive: true }) } + private invalidateSelectedRuntimeModel(): void { + this.selectedRuntimeGeneration += 1 + this.selectedRuntimeModel = undefined + } + + private async resolveSelectedRuntimeModel( + selectedModelId: string, + generation: number + ): Promise { + await this.ensureRoot() + const catalogEntry = this.catalog.find( + (entry) => entry.id === selectedModelId + ) + if (!catalogEntry) { + return undefined + } + try { + const directory = this.modelDirectory(selectedModelId) + const manifestPath = safeChild(directory, MANIFEST_FILE_NAME) + const manifestFingerprintBefore = await fingerprintModelFile( + manifestPath + ) + if ( + !manifestFingerprintBefore.isFile || + manifestFingerprintBefore.isSymbolicLink + ) { + return undefined + } + const installed = installedSpeechModelSchema.parse( + JSON.parse( + await readFile(manifestPath, 'utf8') + ) as unknown + ) + const manifestFingerprint = await fingerprintModelFile(manifestPath) + if ( + !modelFileFingerprintMatches( + manifestFingerprintBefore, + manifestFingerprint + ) + ) { + return undefined + } + if (installed.id !== selectedModelId) { + return undefined + } + if ( + installed.files.length !== catalogEntry.files.length || + catalogEntry.files.some((expected) => { + const recorded = installed.files.find( + (file) => + file.name === expected.name && + file.role === expected.role + ) + return ( + !recorded || + recorded.size !== expected.size || + recorded.sha256 !== expected.sha256 + ) + }) + ) { + return undefined + } + const fileFingerprints = new Map() + for (const file of installed.files) { + const path = safeChild(directory, file.name) + const fingerprintBefore = await fingerprintModelFile(path) + if ( + !fingerprintBefore.isFile || + fingerprintBefore.isSymbolicLink || + fingerprintBefore.size !== BigInt(file.size) + ) { + return undefined + } + const actual = await hashModelFile(path) + const fingerprint = await fingerprintModelFile(path) + if ( + actual.size !== file.size || + actual.sha256 !== file.sha256 || + !modelFileFingerprintMatches( + fingerprintBefore, + fingerprint + ) + ) { + return undefined + } + fileFingerprints.set(file.name, fingerprint) + } + if ( + generation !== this.selectedRuntimeGeneration || + (await this.readSelection()) !== selectedModelId + ) { + return undefined + } + return { + model: { + id: installed.id, + family: catalogEntry.family, + directory, + files: installed.files.map((file) => ({ ...file })) + }, + manifestFingerprint, + fileFingerprints + } + } catch { + return undefined + } + } + + private cloneSelectedRuntimeModel( + model: SelectedSpeechRuntimeModel + ): SelectedSpeechRuntimeModel { + return { + ...model, + files: model.files.map((file) => ({ ...file })) + } + } + + private async selectedRuntimeFingerprintsMatch( + cached: CachedSelectedSpeechRuntimeModel + ): Promise { + try { + const manifestFingerprint = await fingerprintModelFile( + safeChild(cached.model.directory, MANIFEST_FILE_NAME) + ) + if ( + !manifestFingerprint.isFile || + manifestFingerprint.isSymbolicLink || + !modelFileFingerprintMatches( + manifestFingerprint, + cached.manifestFingerprint + ) + ) { + return false + } + for (const file of cached.model.files) { + const expected = cached.fileFingerprints.get(file.name) + if (!expected) { + return false + } + const actual = await fingerprintModelFile( + safeChild(cached.model.directory, file.name) + ) + if ( + !actual.isFile || + actual.isSymbolicLink || + actual.size !== BigInt(file.size) || + !modelFileFingerprintMatches(actual, expected) + ) { + return false + } + } + return true + } catch { + return false + } + } + private modelDirectory(modelId: string): string { const parsedId = speechModelIdSchema.parse(modelId) return safeChild(this.rootDirectory, parsedId) @@ -601,16 +784,7 @@ export class SpeechModelManager { signal: AbortSignal | undefined, controller: AbortController ): () => void { - if (!signal) { - return () => undefined - } - const abort = (): void => controller.abort() - if (signal.aborted) { - controller.abort() - } else { - signal.addEventListener('abort', abort, { once: true }) - } - return () => signal.removeEventListener('abort', abort) + return attachModelAbortSignal(signal, controller) } private async assertNotInstalled(modelId: string): Promise { @@ -618,11 +792,7 @@ export class SpeechModelManager { await lstat(this.modelDirectory(modelId)) throw new Error('语音模型已安装') } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { + if (isMissingFileError(error)) { return } throw error @@ -630,12 +800,11 @@ export class SpeechModelManager { } private async createStagingDirectory(modelId: string): Promise { - const directory = safeChild( + return createModelStagingDirectory( this.rootDirectory, - `.install-${modelId}-${randomUUID()}` + modelId, + '模型路径超出受管目录' ) - await mkdir(directory, { recursive: false }) - return directory } private async downloadFile( @@ -676,29 +845,34 @@ export class SpeechModelManager { } } - const partialPath = `${destination}${PARTIAL_SUFFIX}` + const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}` const handle = await open(partialPath, 'wx') const reader = response.body.getReader() const hash = createHash('sha256') let written = 0 try { while (true) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) const result = await reader.read() if (result.done) { break } - written += result.value.byteLength if ( - written > file.size || - written > this.maxFileBytes + written + result.value.byteLength > file.size || + written + result.value.byteLength > this.maxFileBytes ) { await reader.cancel() throw new RangeError(`模型文件过大:${file.name}`) } - await handle.write(result.value) - hash.update(result.value) - operation.progress.completedBytes += result.value.byteLength + const persistedBytes = await writeModelBuffer( + handle, + result.value, + (persisted) => { + hash.update(persisted) + operation.progress.completedBytes += persisted.byteLength + } + ) + written += persistedBytes } } catch (error) { await reader.cancel().catch(() => undefined) @@ -728,7 +902,7 @@ export class SpeechModelManager { visited: 0 }) for (const expectedFile of entry.files) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) const sourceFile = safeChild(sourceDirectory, expectedFile.name) const sourceFileInfo = await lstat(sourceFile) if ( @@ -745,7 +919,7 @@ export class SpeechModelManager { } if ( sourceFileInfo.size !== expectedFile.size || - (await hashFile(sourceFile, signal)).sha256 !== + (await hashModelFile(sourceFile, signal)).sha256 !== expectedFile.sha256 ) { throw new Error(`本地模型文件校验失败:${expectedFile.name}`) @@ -760,7 +934,7 @@ export class SpeechModelManager { ): Promise { const entries = await readdir(directory, { withFileTypes: true }) for (const entry of entries) { - ensureNotAborted(signal) + ensureModelOperationNotAborted(signal) counter.visited += 1 if (counter.visited > 4_096) { throw new Error('本地模型目录包含过多条目') @@ -830,8 +1004,8 @@ export class SpeechModelManager { ): Promise { const files = [] for (const file of entry.files) { - ensureNotAborted(signal) - const metadata = await hashFile( + ensureModelOperationNotAborted(signal) + const metadata = await hashModelFile( safeChild(stagingDirectory, file.name), signal ) @@ -899,11 +1073,7 @@ export class SpeechModelManager { ) return value.selectedModelId } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { + if (isMissingFileError(error)) { return null } return null @@ -913,24 +1083,45 @@ export class SpeechModelManager { private async writeSelection(modelId: string | null): Promise { await this.ensureRoot() const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME) + const partialName = + `${SELECTION_FILE_NAME}.${randomUUID()}${MODEL_PARTIAL_SUFFIX}` const partial = safeChild( this.rootDirectory, - `${SELECTION_FILE_NAME}.${randomUUID()}${PARTIAL_SUFFIX}` - ) - await writeFile( - partial, - `${JSON.stringify( - selectionSchema.parse({ selectedModelId: modelId }) - )}\n`, - { encoding: 'utf8', flag: 'wx' } + partialName ) + this.activeSelectionPartialNames.add(partialName) try { - await rename(partial, target) + await this.selectionFileOperations.writeFile( + partial, + `${JSON.stringify( + selectionSchema.parse({ selectedModelId: modelId }) + )}\n`, + { encoding: 'utf8', flag: 'wx' } + ) + await this.selectionFileOperations.rename(partial, target) } catch (error) { await rm(partial, { force: true }) throw error + } finally { + this.activeSelectionPartialNames.delete(partialName) } } + + private cleanupStaleArtifacts(): Promise { + return cleanupStaleModelInstallArtifacts({ + rootDirectory: this.rootDirectory, + isModelId: (value) => speechModelIdSchema.safeParse(value).success, + activeModelIds: new Set(this.operations.keys()), + partialFileNames: new Set( + this.catalog.flatMap((entry) => + entry.files.map((file) => file.name) + ) + ), + cleanSelectionPartials: true, + activeSelectionPartialNames: this.activeSelectionPartialNames, + escapeMessage: '模型路径超出受管目录' + }) + } } export function createSpeechModelManager( diff --git a/src/main/startup-prerequisites.test.ts b/src/main/startup-prerequisites.test.ts index e007d85..107d9fa 100644 --- a/src/main/startup-prerequisites.test.ts +++ b/src/main/startup-prerequisites.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { runStartupPrerequisites } from './startup-prerequisites' +import { + createStartupFailureDiagnostic, + formatStartupFailureMessage, + runStartupPrerequisites, + StartupPrerequisiteError +} from './startup-prerequisites' function deferred(): { promise: Promise @@ -83,7 +88,11 @@ describe('runStartupPrerequisites', () => { expect(rejected).not.toHaveBeenCalled() configuredRuntime.resolve({ id: 'unused' }) - await expect(result).rejects.toBe(assistantError) + await expect(result).rejects.toMatchObject({ + name: 'StartupPrerequisiteError', + stage: 'assistant-database', + cause: assistantError + }) expect(rejected).toHaveBeenCalledOnce() }) @@ -108,7 +117,188 @@ describe('runStartupPrerequisites', () => { deepSeekHome.resolve() knowledgeAndGateway.resolve() - await expect(result).rejects.toBe(runtimeError) + await expect(result).rejects.toMatchObject({ + name: 'StartupPrerequisiteError', + stage: 'runtime', + cause: runtimeError + }) expect(rejected).toHaveBeenCalledOnce() }) + + it('collects simultaneous async failures in deterministic stage order', async () => { + const runtimeHomeError = new TypeError('runtime home failed') + const knowledgeError = new RangeError('knowledge failed') + const runtimeError = new SyntaxError('runtime failed') + const deepSeekHome = deferred() + const knowledgeAndGateway = deferred() + const configuredRuntime = deferred<{ id: string }>() + + const result = runStartupPrerequisites({ + prepareDeepSeekHome: () => deepSeekHome.promise, + initializeKnowledgeAndGateway: () => + knowledgeAndGateway.promise, + hydrateConfiguredRuntime: () => configuredRuntime.promise, + initializeAssistant: () => undefined + }) + + configuredRuntime.reject(runtimeError) + knowledgeAndGateway.reject(knowledgeError) + deepSeekHome.reject(runtimeHomeError) + + await expect(result).rejects.toMatchObject({ + name: 'StartupPrerequisiteError', + stage: 'runtime-home', + stages: ['runtime-home', 'knowledge', 'runtime'], + cause: runtimeHomeError + }) + }) + + it('preserves assistant initialization as the primary failure', async () => { + const assistantError = new Error('assistant failed') + const runtimeHomeError = new Error('runtime home failed') + + await expect( + runStartupPrerequisites({ + prepareDeepSeekHome: () => Promise.reject(runtimeHomeError), + initializeKnowledgeAndGateway: () => + Promise.reject(new Error('knowledge failed')), + hydrateConfiguredRuntime: () => + Promise.reject(new Error('runtime failed')), + initializeAssistant: () => { + throw assistantError + } + }) + ).rejects.toMatchObject({ + stage: 'assistant-database', + stages: [ + 'assistant-database', + 'runtime-home', + 'knowledge', + 'runtime' + ], + cause: assistantError + }) + }) + + it.each([ + ['runtime-home', 'prepareDeepSeekHome'], + ['knowledge', 'initializeKnowledgeAndGateway'], + ['runtime', 'hydrateConfiguredRuntime'] + ] as const)( + 'identifies a failed %s startup branch', + async (stage, operation) => { + const failure = new Error(`${stage} failed`) + const dependencies = { + prepareDeepSeekHome: () => Promise.resolve(), + initializeKnowledgeAndGateway: () => Promise.resolve(), + hydrateConfiguredRuntime: () => Promise.resolve({ id: 'configured' }), + initializeAssistant: () => undefined + } + dependencies[operation] = () => Promise.reject(failure) as never + + const result = runStartupPrerequisites(dependencies) + + await expect(result).rejects.toEqual( + expect.objectContaining>({ + name: 'StartupPrerequisiteError', + stage, + cause: failure + }) + ) + } + ) + + it.each([ + ['runtime-home', 'prepareDeepSeekHome'], + ['knowledge', 'initializeKnowledgeAndGateway'], + ['runtime', 'hydrateConfiguredRuntime'] + ] as const)( + 'observes a synchronous throw from the %s promise dependency', + async (stage, operation) => { + const failure = new Error(`${stage} synchronous failure`) + const dependencies = { + prepareDeepSeekHome: () => Promise.resolve(), + initializeKnowledgeAndGateway: () => Promise.resolve(), + hydrateConfiguredRuntime: () => + Promise.resolve({ id: 'configured' }), + initializeAssistant: () => undefined + } + dependencies[operation] = (() => { + throw failure + }) as never + + await expect( + runStartupPrerequisites(dependencies) + ).rejects.toMatchObject({ + name: 'StartupPrerequisiteError', + stage, + stages: [stage], + cause: failure + }) + } + ) +}) + +describe('startup failure reporting', () => { + it('keeps secret-bearing causes out of user-facing formatting', () => { + const secret = 'provider-key=sk-secret-value' + const failure = new Error(secret) + const error = new StartupPrerequisiteError( + 'runtime', + failure, + ['runtime'] + ) + + const message = formatStartupFailureMessage(error) + + expect(message).toContain('阶段:runtime') + expect(message).not.toContain(secret) + expect(Object.isFrozen(error.stages)).toBe(true) + }) + + it('formats all prerequisite stages and logs only bounded metadata', async () => { + const secret = 'provider-key=sk-secret-value' + let startupError: unknown + try { + await runStartupPrerequisites({ + prepareDeepSeekHome: () => + Promise.reject(new TypeError(secret)), + initializeKnowledgeAndGateway: () => + Promise.reject(new Error('another secret')), + hydrateConfiguredRuntime: () => + Promise.resolve({ id: 'configured' }), + initializeAssistant: () => undefined + }) + } catch (error) { + startupError = error + } + + const message = formatStartupFailureMessage(startupError) + const diagnostic = createStartupFailureDiagnostic(startupError) + + expect(message).toContain('阶段:runtime-home, knowledge') + expect(message).not.toContain(secret) + expect(diagnostic).toEqual({ + stages: ['runtime-home', 'knowledge'], + errorName: 'StartupPrerequisiteError', + causeName: 'TypeError' + }) + expect(JSON.stringify(diagnostic)).not.toContain(secret) + expect(Object.isFrozen(diagnostic)).toBe(true) + expect(Object.isFrozen(diagnostic.stages)).toBe(true) + }) + + it('maps generic startup errors to the closed application stage', () => { + const secret = 'provider-key=sk-secret-value' + const error = new Error(secret) + + expect(formatStartupFailureMessage(error)).toContain( + '阶段:application' + ) + expect(createStartupFailureDiagnostic(error)).toEqual({ + stages: ['application'], + errorName: 'Error' + }) + expect(formatStartupFailureMessage(error)).not.toContain(secret) + }) }) diff --git a/src/main/startup-prerequisites.ts b/src/main/startup-prerequisites.ts index c21a4b2..db9d6bf 100644 --- a/src/main/startup-prerequisites.ts +++ b/src/main/startup-prerequisites.ts @@ -5,6 +5,101 @@ export type StartupPrerequisiteDependencies = { initializeAssistant: () => void } +export type StartupPrerequisiteStage = + | 'runtime-home' + | 'knowledge' + | 'runtime' + | 'assistant-database' + +export type StartupFailureStage = + | StartupPrerequisiteStage + | 'application' + +export type StartupFailureDiagnostic = Readonly<{ + stages: readonly StartupFailureStage[] + errorName: string + causeName?: string +}> + +export class StartupPrerequisiteError extends Error { + readonly stage: StartupPrerequisiteStage + readonly stages: readonly StartupPrerequisiteStage[] + + constructor( + stage: StartupPrerequisiteStage, + cause: unknown, + stages: readonly StartupPrerequisiteStage[] = [stage] + ) { + super(`Startup prerequisite failed: ${stage}`, { cause }) + this.name = 'StartupPrerequisiteError' + this.stage = stage + this.stages = Object.freeze([...stages]) + } +} + +const applicationFailureStages = Object.freeze([ + 'application' +] satisfies StartupFailureStage[]) + +const safeErrorNames = new Set([ + 'AbortError', + 'AggregateError', + 'Error', + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TimeoutError', + 'TypeError', + 'URIError' +]) + +function safeErrorName(error: unknown): string { + if (!(error instanceof Error)) { + return 'NonError' + } + + let name: unknown + try { + name = error.name + } catch { + return 'Error' + } + return typeof name === 'string' && safeErrorNames.has(name) + ? name + : 'Error' +} + +export function getStartupFailureStages( + error: unknown +): readonly StartupFailureStage[] { + return error instanceof StartupPrerequisiteError + ? error.stages + : applicationFailureStages +} + +export function formatStartupFailureMessage(error: unknown): string { + const stages = getStartupFailureStages(error) + return `启动初始化未完成(阶段:${stages.join(', ')})。请重启应用;若问题持续,请记录阶段标识,并在备份数据后排查应用数据或 Runtime 配置。` +} + +export function createStartupFailureDiagnostic( + error: unknown +): StartupFailureDiagnostic { + const stages = Object.freeze([...getStartupFailureStages(error)]) + if (error instanceof StartupPrerequisiteError) { + return Object.freeze({ + stages, + errorName: 'StartupPrerequisiteError', + causeName: safeErrorName(error.cause) + }) + } + return Object.freeze({ + stages, + errorName: safeErrorName(error) + }) +} + function startObserved(operation: () => Promise): Promise { let started: Promise try { @@ -45,17 +140,48 @@ export async function runStartupPrerequisites( configuredRuntimeReady ] as const) + const failures: Array< + Readonly<{ + stage: StartupPrerequisiteStage + cause: unknown + }> + > = [] if (assistantInitializationFailed) { - throw assistantInitializationError + failures.push({ + stage: 'assistant-database', + cause: assistantInitializationError + }) } if (deepSeekHome.status === 'rejected') { - throw deepSeekHome.reason + failures.push({ + stage: 'runtime-home', + cause: deepSeekHome.reason + }) } if (knowledgeAndGateway.status === 'rejected') { - throw knowledgeAndGateway.reason + failures.push({ + stage: 'knowledge', + cause: knowledgeAndGateway.reason + }) } if (configuredRuntime.status === 'rejected') { - throw configuredRuntime.reason + failures.push({ + stage: 'runtime', + cause: configuredRuntime.reason + }) } - return configuredRuntime.value + + const primaryFailure = failures[0] + if (primaryFailure) { + throw new StartupPrerequisiteError( + primaryFailure.stage, + primaryFailure.cause, + failures.map(({ stage }) => stage) + ) + } + + if (configuredRuntime.status === 'fulfilled') { + return configuredRuntime.value + } + throw new Error('Unreachable startup prerequisite state') } diff --git a/src/preload/index.ts b/src/preload/index.ts index 23d104c..d74dd2d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -89,6 +89,7 @@ import type { import type { DocumentOcrAssets, DocumentOcrFailure, + DocumentOcrModelProgressSnapshot, DocumentOcrRequest, DocumentOcrResult, DocumentParsingDiagnostic, @@ -125,6 +126,11 @@ import type { RuntimeExtensionAction, RuntimeExtensionMarketplaceSnapshot } from '../shared/runtime-extension-contracts' +import type { + GlobalShortcutSettings, + GlobalShortcutSettingsSnapshot, + GlobalShortcutSettingsUpdateResult +} from '../shared/shortcut' const desktopApi: DesktopApi = { app: { @@ -388,6 +394,17 @@ const desktopApi: DesktopApi = { ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler) } }, + shortcuts: { + getSettings: () => + ipcRenderer.invoke( + ipcChannels.shortcutSettingsGet + ) as Promise, + updateSettings: (input: GlobalShortcutSettings) => + ipcRenderer.invoke( + ipcChannels.shortcutSettingsUpdate, + input + ) as Promise + }, releaseNotes: { getPending: () => ipcRenderer.invoke( @@ -475,6 +492,10 @@ const desktopApi: DesktopApi = { ipcRenderer.invoke( ipcChannels.documentParsingGet ) as Promise, + getOcrModelProgress: () => + ipcRenderer.invoke( + ipcChannels.documentOcrModelsProgress + ) as Promise, update: (input: DocumentParsingSettings) => ipcRenderer.invoke( ipcChannels.documentParsingUpdate, diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index c60e1ce..37ed48e 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -63,6 +63,15 @@ describe('sandboxed preload', () => { ) }) + it('exposes a narrow OCR operation progress snapshot', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + expect(source).toContain('getOcrModelProgress:') + expect(source).toContain('ipcChannels.documentOcrModelsProgress') + }) + it('exposes a removable attachment parsing progress listener', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), @@ -85,6 +94,17 @@ describe('sandboxed preload', () => { expect(source).toContain('ipcChannels.releaseNotesAcknowledge') }) + it('exposes only get and validated-update shortcut settings methods', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + expect(source).toContain('shortcuts: {') + expect(source).toContain('ipcChannels.shortcutSettingsGet') + expect(source).toContain('ipcChannels.shortcutSettingsUpdate') + expect(source).not.toContain('globalShortcut.') + }) + it('exposes bounded knowledge task actions', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index dac866b..a275f62 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -7,6 +7,10 @@ import { } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TokenUsageSummary } from '../../shared/assistant-contracts' +import { + builtInDefaultProjectSeedDescription, + builtInDefaultProjectSeedName +} from '../../shared/assistant-contracts' import { ActivityPanel } from './ActivityPanel' import { MAX_ACTIVITY_RECORDS, @@ -361,6 +365,139 @@ describe('ActivityPanel', () => { expect(screen.getByText('范围不可用')).toBeInTheDocument() }) + it('localizes current project IDs while preserving unknown snapshots', async () => { + await i18n.changeLanguage('en-US') + const currentProjectId = + '00000000-0000-4000-8000-000000000101' + const currentRecord: ActivityRecord = { + ...makeRecord(1), + scope: { + kind: 'project', + projectId: currentProjectId, + projectName: builtInDefaultProjectSeedName + } + } + const deletedRecord: ActivityRecord = { + ...makeRecord(2), + scope: { + kind: 'project', + projectId: 'deleted-project', + projectName: 'Deleted project snapshot' + } + } + const usage = makeTokenUsage() + usage.records = [ + { + ...usage.records[0]!, + projectId: currentProjectId, + projectName: builtInDefaultProjectSeedName + }, + { + ...usage.records[0]!, + requestId: 'request-deleted', + projectId: 'deleted-project', + projectName: 'Deleted project snapshot' + } + ] + + render( + + ) + + expect(screen.getByText('Project: Default project')).toBeInTheDocument() + expect( + screen.getByText('Project: Deleted project snapshot') + ).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('tab', { name: 'Usage analytics' }) + ) + expect(screen.getByText('Default project')).toBeInTheDocument() + expect( + screen.getByText('Deleted project snapshot') + ).toBeInTheDocument() + }) + + it('preserves ordinary activity and token snapshots after a project rename', async () => { + await i18n.changeLanguage('en-US') + const projectId = 'ordinary-project' + const usage = makeTokenUsage() + usage.records = [ + { + ...usage.records[0]!, + projectId, + projectName: 'Original project snapshot' + } + ] + + render( + + ) + + expect( + screen.getByText('Project: Original project snapshot') + ).toBeInTheDocument() + expect( + screen.queryByText('Project: Renamed current project') + ).not.toBeInTheDocument() + + fireEvent.click( + screen.getByRole('tab', { name: 'Usage analytics' }) + ) + expect( + screen.getByText('Original project snapshot') + ).toBeInTheDocument() + expect( + screen.queryByText('Renamed current project') + ).not.toBeInTheDocument() + }) + it('uses shared page tabs and explicit global scope', () => { render( void @@ -234,12 +240,14 @@ function groupActivityRecordsByProject( } export function ActivityPanel({ + projects = [], records, tokenUsage, onClear, onOpenConversation }: ActivityPanelProps): React.JSX.Element { const { t, i18n } = useTranslation('activity') + const { t: tWorkspace } = useTranslation('workspace') const [activeView, setActiveView] = useState('tasks') const [filter, setFilter] = useState('all') @@ -329,17 +337,66 @@ export function ActivityPanel({ } ] + const projectDisplayNames = useMemo( + () => + new Map( + projects.flatMap((project) => { + const displayName = getProjectDisplayText( + project, + tWorkspace + ).name + return isUntouchedBuiltInDefaultProject(project) && + displayName !== project.name + ? [[project.id, displayName] as const] + : [] + }) + ), + [projects, tWorkspace] + ) + const displayRecords = useMemo( + () => + records.map((record) => { + if (record.scope.kind !== 'project') { + return record + } + const projectName = projectDisplayNames.get( + record.scope.projectId + ) + return projectName + ? { + ...record, + scope: { + ...record.scope, + projectName + } + } + : record + }), + [projectDisplayNames, records] + ) + const displayTokenUsage = useMemo( + () => ({ + ...tokenUsage, + records: tokenUsage.records.map((record) => { + const projectName = record.projectId + ? projectDisplayNames.get(record.projectId) + : undefined + return projectName ? { ...record, projectName } : record + }) + }), + [projectDisplayNames, tokenUsage] + ) const visibleRecords = useMemo( - () => records.slice(0, MAX_ACTIVITY_RECORDS), - [records] + () => displayRecords.slice(0, MAX_ACTIVITY_RECORDS), + [displayRecords] ) const filteredRecords = useMemo( () => visibleRecords.filter((record) => matchesFilter(record, filter)), [filter, visibleRecords] ) const projectGroups = useMemo( - () => groupActivityRecordsByProject(filteredRecords, records), - [filteredRecords, records] + () => groupActivityRecordsByProject(filteredRecords, displayRecords), + [displayRecords, filteredRecords] ) const timelineBounds = useMemo(() => { const timestamps = filteredRecords.map((record) => record.createdAt) @@ -366,8 +423,8 @@ export function ActivityPanel({ (record) => record.id === selectedTimelineRecordId ) const conversationTitles = useMemo( - () => getConversationTitles(records), - [records] + () => getConversationTitles(displayRecords), + [displayRecords] ) const activeCount = visibleRecords.filter(isActive).length const failedCount = visibleRecords.filter(isFailed).length @@ -376,8 +433,8 @@ export function ActivityPanel({ [tokenUsage] ) const tokenRows = useMemo( - () => groupTokenUsage(tokenUsage, tokenGroup), - [tokenGroup, tokenUsage] + () => groupTokenUsage(displayTokenUsage, tokenGroup), + [displayTokenUsage, tokenGroup] ) const tokenGroupLabel = tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ?? diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 6f0f8b5..69704f3 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -16,12 +16,17 @@ import type { DesktopApi } from '../../shared/contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts' +import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' import type { AssistantProject, AssistantSchedule, AssistantTask, ConversationSnapshot } from '../../shared/assistant-contracts' +import { + builtInDefaultProjectSeedDescription, + builtInDefaultProjectSeedName +} from '../../shared/assistant-contracts' import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' const speechRecognitionMocks = vi.hoisted(() => ({ @@ -48,6 +53,14 @@ const lazyRouteMocks = vi.hoisted(() => { } }) +const routeModuleLoads = vi.hoisted(() => ({ + activity: 0, + heartbeat: 0, + knowledge: 0, + magicNotes: 0, + settings: 0 +})) + vi.mock('./speech-recognition', async (importOriginal) => ({ ...(await importOriginal< typeof import('./speech-recognition') @@ -57,9 +70,30 @@ vi.mock('./speech-recognition', async (importOriginal) => ({ vi.mock('./KnowledgeWorkspace', async (importOriginal) => { await lazyRouteMocks.waitForKnowledgeRoute() + routeModuleLoads.knowledge += 1 return importOriginal() }) +vi.mock('./HeartbeatCenter', async (importOriginal) => { + routeModuleLoads.heartbeat += 1 + return importOriginal() +}) + +vi.mock('./MagicNotesWorkspace', async (importOriginal) => { + routeModuleLoads.magicNotes += 1 + return importOriginal() +}) + +vi.mock('./SettingsPanel', async (importOriginal) => { + routeModuleLoads.settings += 1 + return importOriginal() +}) + +vi.mock('./ActivityPanel', async (importOriginal) => { + routeModuleLoads.activity += 1 + return importOriginal() +}) + import App from './App' import { loadActivityRecords } from './activity-store' import { changeUiLocale } from './i18n' @@ -89,11 +123,12 @@ const modelProfileId = '00000000-0000-4000-8000-000000000001' const projectId = '00000000-0000-4000-8000-000000000101' const project = { id: projectId, - name: '默认项目', - description: '测试项目', + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription, rootPath: 'C:\\Users\\test', defaultWorkMode: 'ask' as const, kind: 'user' as const, + builtInDefault: true, status: 'active' as const, createdAt: '2026-07-31T00:00:00.000Z', updatedAt: '2026-07-31T00:00:00.000Z' @@ -981,9 +1016,22 @@ describe('App', () => { expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument() expect( screen.getByRole('heading', { + level: 1, + name: 'Conversation' + }) + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { + level: 2, name: 'What would you like to accomplish today?' }) ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Current project' }) + ).toHaveTextContent('Default project') + expect(screen.getByText('Project: Default project')).toHaveClass( + 'scope-badge' + ) expect( screen.getByText(/Hi, I’m GoodBuddy/u) ).toBeInTheDocument() @@ -999,6 +1047,140 @@ describe('App', () => { } }) + it('localizes untouched project labels but keeps settings and destructive values raw', async () => { + const secondProject = { + ...project, + id: '00000000-0000-4000-8000-000000000102', + name: 'Second project', + description: 'Another workspace', + rootPath: 'C:\\Second' + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + secondProject + ]) + vi.mocked(api.tasks.list).mockResolvedValueOnce([ + { + id: '00000000-0000-4000-8000-000000000901', + projectId, + title: 'Seed project task', + instructions: 'Verify the project label', + origin: 'schedule', + status: 'queued', + workMode: 'ask', + createdAt: '2026-07-31T01:00:00.000Z' + } + ]) + await changeUiLocale('en-US') + try { + render() + + const projectTrigger = await screen.findByRole('button', { + name: 'Current project' + }) + expect(projectTrigger).toHaveTextContent('Default project') + fireEvent.click(projectTrigger) + expect( + within( + screen.getByRole('menu', { name: 'Current project' }) + ).getByText('Default project', { selector: 'b' }) + ).toBeInTheDocument() + fireEvent.click(projectTrigger) + + fireEvent.click( + screen.getByLabelText('Toggle assistant workspace') + ) + fireEvent.click( + await screen.findByRole('tab', { name: 'Task center' }) + ) + const assistantSidebar = screen.getByLabelText( + 'Assistant workspace' + ) + expect( + within(assistantSidebar).getByText('Project: Default project') + ).toBeInTheDocument() + fireEvent.click( + within(assistantSidebar).getByRole('button', { + name: 'New custom task' + }) + ) + const taskDialog = screen.getByRole('dialog', { + name: 'New custom task' + }) + expect( + within(taskDialog).getByText('Default project') + ).toBeInTheDocument() + fireEvent.click( + within(taskDialog).getByRole('button', { + name: 'Close new custom task' + }) + ) + fireEvent.click( + within(assistantSidebar).getByRole('button', { + name: 'Close assistant workspace' + }) + ) + + fireEvent.click(screen.getByLabelText('Project settings')) + const settingsDialog = screen.getByRole('dialog', { + name: 'Project settings' + }) + expect( + within(settingsDialog).getByLabelText('Name') + ).toHaveValue(builtInDefaultProjectSeedName) + expect( + within(settingsDialog).getByLabelText('Description') + ).toHaveValue(builtInDefaultProjectSeedDescription) + + fireEvent.click( + within(settingsDialog).getByRole('button', { + name: 'Delete project' + }) + ) + const confirmation = within(settingsDialog).getByLabelText( + `Enter “${builtInDefaultProjectSeedName}” to confirm deletion` + ) + const deleteButton = within(settingsDialog).getByRole('button', { + name: 'Permanently delete project' + }) + fireEvent.change(confirmation, { + target: { value: 'Default project' } + }) + expect(deleteButton).toBeDisabled() + fireEvent.change(confirmation, { + target: { value: builtInDefaultProjectSeedName } + }) + expect(deleteButton).toBeEnabled() + fireEvent.click( + within(settingsDialog).getByRole('button', { + name: 'Cancel deletion' + }) + ) + fireEvent.click( + within(settingsDialog).getByRole('button', { + name: 'Save project' + }) + ) + + await waitFor(() => + expect(api.projects.update).toHaveBeenCalledWith( + projectId, + expect.objectContaining({ + name: builtInDefaultProjectSeedName, + description: builtInDefaultProjectSeedDescription + }) + ) + ) + expect( + screen.getByRole('button', { name: 'Current project' }) + ).toHaveTextContent('Default project') + expect(api.projects.delete).not.toHaveBeenCalled() + } finally { + cleanup() + await changeUiLocale('zh-CN') + } + }) + it('renders localized workspace branding in Chinese', async () => { render() @@ -1347,13 +1529,41 @@ describe('App', () => { .toBeInTheDocument() }) - it('schedules lazy workspace routes for idle preloading', () => { + it('idle-preloads only the small Heartbeat route at startup', async () => { render() expect(window.requestIdleCallback).toHaveBeenCalledWith( expect.any(Function), { timeout: 2000 } ) + const idleCallback = vi.mocked(window.requestIdleCallback) + .mock.calls[0]?.[0] + await act(async () => { + idleCallback?.({ + didTimeout: false, + timeRemaining: () => 50 + }) + }) + await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1)) + expect(routeModuleLoads).toMatchObject({ + activity: 0, + knowledge: 0, + magicNotes: 0, + settings: 0 + }) + }) + + it('preloads a heavy route once across repeated pointer and focus intent', async () => { + render() + const activity = await screen.findByRole('button', { + name: '运行记录' + }) + + fireEvent.pointerEnter(activity) + fireEvent.focus(activity) + fireEvent.pointerEnter(activity) + + await waitFor(() => expect(routeModuleLoads.activity).toBe(1)) }) it('waits for project bootstrap before project-scoped startup loads', async () => { @@ -1440,6 +1650,33 @@ describe('App', () => { expect(route).not.toHaveAttribute('hidden') }) + it('enforces the workspace KeepAlive cap during rapid visits', async () => { + let now = 2_000_000 + vi.spyOn(Date, 'now').mockImplementation(() => ++now) + render() + + fireEvent.click( + await screen.findByRole('button', { name: '知识库' }) + ) + await screen.findByRole('heading', { name: '知识库' }) + fireEvent.click(screen.getByRole('button', { name: '智能心跳' })) + await screen.findByRole('heading', { name: '智能心跳' }) + fireEvent.click(screen.getByRole('button', { name: '运行记录' })) + await screen.findByRole('heading', { name: '运行记录' }) + fireEvent.click(screen.getByRole('button', { name: '对话' })) + fireEvent.click( + screen.getByRole('button', { name: /本地工作区/u }) + ) + await screen.findByRole('heading', { name: '设置中心' }) + + expect( + document.querySelectorAll('.workspace-route-cache') + ).toHaveLength(4) + expect( + document.querySelector('[data-route="knowledge"]') + ).not.toBeInTheDocument() + }) + it('preserves title, message, and project filtering with deferred search', async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { @@ -2403,14 +2640,9 @@ describe('App', () => { const cancel = screen.getByRole('button', { name: '取消删除对话 新对话' }) - const confirm = screen.getByRole('button', { - name: '确认永久删除对话 新对话' - }) expect(cancel).toHaveFocus() - fireEvent.keyDown(dialog, { key: 'Tab' }) - expect(confirm).toHaveFocus() - fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true) expect(cancel).toHaveFocus() fireEvent.keyDown(dialog, { key: 'Escape' }) await waitFor(() => @@ -3309,6 +3541,136 @@ describe('App', () => { ).toBeInTheDocument() }) + it('guards sidebar navigation away from dirty Settings drafts', async () => { + render() + + fireEvent.click(await screen.findByText('本地工作区')) + await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.change(await screen.findByLabelText('默认工作区目录'), { + target: { value: 'C:\\Unsaved from App' } + }) + fireEvent.click( + screen.getByRole('button', { name: '知识库' }) + ) + + expect( + screen.getByRole('heading', { name: '设置中心' }) + ).toBeVisible() + expect(screen.getByRole('alert')).toHaveTextContent( + '当前设置有未保存更改' + ) + fireEvent.click( + screen.getByRole('button', { name: '放弃更改并关闭' }) + ) + expect( + await screen.findByRole('heading', { name: '知识库' }) + ).toBeVisible() + }) + + it('updates and removes the composer shortcut hint immediately after saving Settings', async () => { + let applicationSettings: ApplicationSettings = { + checkUpdatesOnStartup: false, + updateSource: 'github', + modelDownloadSource: 'modelscope', + magicNotesEnabled: false, + magicNoteCommentMode: 'immediate', + magicNoteCommentFormat: 'combined' + } + let shortcutSnapshot: GlobalShortcutSettingsSnapshot = { + settings: { + enabled: true, + accelerator: 'CommandOrControl+Shift+Space' + }, + defaultSettings: { + enabled: true, + accelerator: 'CommandOrControl+Shift+Space' + }, + platform: 'win32', + displayAccelerator: 'Ctrl+Shift+Space', + registered: true, + registeredAccelerator: 'CommandOrControl+Shift+Space', + status: 'registered' + } + api.updates = { + getSettings: vi.fn(async () => ({ ...applicationSettings })), + updateSettings: vi.fn(async (input) => { + applicationSettings = { ...applicationSettings, ...input } + return { ...applicationSettings } + }), + check: vi.fn(), + openReleasePage: vi.fn(async () => {}), + onResult: vi.fn(() => () => {}) + } + api.shortcuts = { + getSettings: vi.fn(async () => shortcutSnapshot), + updateSettings: vi.fn(async (input) => { + shortcutSnapshot = { + ...shortcutSnapshot, + settings: input, + displayAccelerator: + input.accelerator === 'Control+Alt+K' + ? 'Ctrl+Alt+K' + : 'Ctrl+Shift+Space', + registered: input.enabled, + registeredAccelerator: input.enabled + ? input.accelerator + : undefined, + status: input.enabled ? 'registered' : 'disabled' + } + return { ok: true as const, snapshot: shortcutSnapshot } + }) + } + try { + render() + expect( + await screen.findByText('Ctrl+Shift+Space') + ).toBeInTheDocument() + fireEvent.click(await screen.findByText('本地工作区')) + await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) + const shortcutInput = await screen.findByLabelText('快捷键') + fireEvent.change(shortcutInput, { + target: { value: 'Control+Alt+K' } + }) + fireEvent.click( + screen.getByRole('button', { name: '保存快捷键' }) + ) + await waitFor(() => + expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({ + enabled: true, + accelerator: 'Control+Alt+K' + }) + ) + fireEvent.click(screen.getByRole('button', { name: '对话' })) + expect(await screen.findByText('Ctrl+Alt+K')).toBeInTheDocument() + + fireEvent.click(await screen.findByText('本地工作区')) + await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) + const shortcutSwitch = await screen.findByRole('switch', { + name: '启用全局快捷键' + }) + fireEvent.click(shortcutSwitch) + fireEvent.click( + screen.getByRole('button', { name: '保存快捷键' }) + ) + await waitFor(() => + expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({ + enabled: false, + accelerator: 'Control+Alt+K' + }) + ) + fireEvent.click(screen.getByRole('button', { name: '对话' })) + await screen.findByLabelText('向 GoodBuddy 提问') + expect( + screen.queryByText('Ctrl+Alt+K') + ).not.toBeInTheDocument() + } finally { + delete api.shortcuts + delete api.updates + } + }) + it('restores persisted context usage and compression state after restart', async () => { const settings = await api.settings.getRuntime() const profile = settings.modelProfiles[0]! @@ -3486,11 +3848,52 @@ describe('App', () => { evidence: [] }) render() - await screen.findByRole('button', { + const knowledgeScopeTrigger = await screen.findByRole('button', { name: '选择知识库,本次已启用 1 个' }) + expect(knowledgeScopeTrigger).toHaveAttribute( + 'aria-haspopup', + 'dialog' + ) + fireEvent.click(knowledgeScopeTrigger) + expect(knowledgeScopeTrigger).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect( + screen.getByRole('dialog', { + name: '本次对话检索范围' + }) + ).toBeInTheDocument() + await waitFor(() => + expect( + screen.getByRole('checkbox', { name: /产品知识/u }) + ).toHaveFocus() + ) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(knowledgeScopeTrigger).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(knowledgeScopeTrigger).toHaveFocus() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + fireEvent.click(knowledgeScopeTrigger) + const scopeCheckbox = screen.getByRole('checkbox', { + name: /产品知识/u + }) + await waitFor(() => expect(scopeCheckbox).toHaveFocus()) + const composerInput = screen.getByLabelText('向 GoodBuddy 提问') + fireEvent.focusOut(scopeCheckbox, { + relatedTarget: composerInput + }) + composerInput.focus() + expect(knowledgeScopeTrigger).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(composerInput).toHaveFocus() + + fireEvent.change(composerInput, { target: { value: '发布流程是什么?' } }) fireEvent.click(await screen.findByLabelText('发送')) @@ -3896,25 +4299,35 @@ describe('App', () => { expect( within(userArticle).getByRole('img', { name: '页面截图.png' }) ).toHaveAttribute('src', imageAttachment.contentUrl) - fireEvent.click( - within(userArticle).getByRole('button', { - name: '查看图片 页面截图.png' - }) - ) + const viewerTrigger = within(userArticle).getByRole('button', { + name: '查看图片 页面截图.png' + }) + fireEvent.click(viewerTrigger) const imageDialog = await screen.findByRole('dialog', { name: '页面截图.png' }) expect( within(imageDialog).getByRole('img', { name: '页面截图.png' }) ).toHaveAttribute('src', imageAttachment.contentUrl) - fireEvent.click( + const closeViewer = within(imageDialog).getByRole('button', { + name: '关闭图片查看器' + }) + expect(closeViewer).toHaveFocus() + expect(document.querySelector('main')?.inert).toBe(true) + fireEvent.keyDown(closeViewer, { key: 'Tab' }) + expect( within(imageDialog).getByRole('button', { - name: '关闭图片查看器' + name: '下载图片' }) + ).toHaveFocus() + fireEvent.keyDown(imageDialog, { key: 'Escape' }) + await waitFor(() => + expect(viewerTrigger).toHaveFocus() ) expect( screen.queryByRole('dialog', { name: '页面截图.png' }) ).not.toBeInTheDocument() + expect(document.querySelector('main')?.inert).toBe(false) fireEvent.click( within(userArticle).getByRole('button', { name: '下载图片 页面截图.png' @@ -4402,6 +4815,7 @@ describe('App', () => { await waitFor(() => expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId) ) + fireEvent.click(screen.getByLabelText('关闭助手工作栏')) selectProjectOption(secondProject.name) await waitFor(() => expect(api.workspace.getChanges).toHaveBeenCalledWith( @@ -4417,6 +4831,7 @@ describe('App', () => { files: [{ path: 'second.md', status: '??' }], truncated: false }) + fireEvent.click(screen.getByLabelText('切换助手工作栏')) expect(await screen.findByText('second.md')).toBeInTheDocument() resolveFirst?.({ rootPath: project.rootPath, @@ -7296,8 +7711,19 @@ describe('App', () => { const sidebar = screen.getByLabelText('助手工作栏') expect(sidebar).not.toHaveClass('assistant-sidebar--open') - fireEvent.click(screen.getByLabelText('切换助手工作栏')) + const assistantTrigger = + screen.getByLabelText('切换助手工作栏') + fireEvent.click(assistantTrigger) expect(sidebar).toHaveClass('assistant-sidebar--open') + expect(sidebar).toHaveAttribute('role', 'dialog') + expect(sidebar).toHaveAttribute('aria-modal', 'true') + const main = document.querySelector('main') + expect(main).toHaveAttribute('inert') + await waitFor(() => + expect( + screen.getByRole('tab', { name: '任务中心' }) + ).toHaveFocus() + ) expect( screen.getByRole('tab', { name: '任务中心' }) @@ -7328,6 +7754,12 @@ describe('App', () => { ).not.toBeInTheDocument() fireEvent.click(screen.getByLabelText('关闭助手工作栏')) expect(sidebar).not.toHaveClass('assistant-sidebar--open') + expect(main).not.toHaveAttribute('inert') + await waitFor(() => expect(assistantTrigger).toHaveFocus()) + + fireEvent.click(assistantTrigger) + fireEvent.keyDown(sidebar, { key: 'Escape' }) + expect(sidebar).not.toHaveClass('assistant-sidebar--open') }) it('keeps completed chat replies out of the results sidebar', async () => { @@ -7757,7 +8189,7 @@ describe('App', () => { ) fireEvent.click( - screen.getByRole('button', { name: '关闭侧栏' }) + within(sidebar).getByRole('button', { name: '知识库' }) ) await waitFor(() => expect(toggle).toHaveFocus()) expect(sidebar).toHaveClass('sidebar--closed') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b99bfca..4f5fb75 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -127,7 +127,6 @@ import { normalizeInteractiveWorkMode, projectChannelLabels } from '../../shared/assistant-contracts' -import { ActivityPanel } from './ActivityPanel' import { ChatTimeline, type ImageViewerItem, @@ -171,6 +170,8 @@ import { ConversationInputQueue } from './ConversationInputQueue' import { OverflowMarquee } from './OverflowMarquee' import { findTaskSchedule } from './TaskScheduleActions' import type { SettingsCategoryId } from './settings-categories' +import type { SettingsLeaveRequester } from './SettingsPanel' +import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png' import { @@ -202,10 +203,13 @@ import { import { formatMediumDateTime } from './locale-formatters' import { formatCompactTokens } from './token-format' import { + filterKeepAliveEntries, pruneKeepAliveEntries, - touchKeepAliveEntry, + touchAndPruneKeepAliveEntries, type KeepAliveCacheEntry } from './keep-alive-cache' +import { activateModalFocus, trapTabFocus } from './dialog-focus' +import { getProjectDisplayText } from './project-display' const knowledgeWorkspaceRoute = createPreloadableComponent( () => import('./KnowledgeWorkspace'), @@ -223,17 +227,19 @@ const settingsPanelRoute = createPreloadableComponent( () => import('./SettingsPanel'), (module) => module.SettingsPanel ) +const activityPanelRoute = createPreloadableComponent( + () => import('./ActivityPanel'), + (module) => module.ActivityPanel +) const idleRouteModuleLoaders = [ - knowledgeWorkspaceRoute.preload, - heartbeatCenterRoute.preload, - magicNotesWorkspaceRoute.preload, - settingsPanelRoute.preload + heartbeatCenterRoute.preload ] as const const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component const HeartbeatCenter = heartbeatCenterRoute.Component const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component const SettingsPanel = settingsPanelRoute.Component +const ActivityPanel = activityPanelRoute.Component const messageRenderBatchSize = 80 const conversationPersistenceIntervalMs = 500 @@ -501,6 +507,24 @@ type WorkspaceView = | 'activity' | 'settings' +const intentRoutePreloaders: Partial< + Record Promise> +> = { + 'magic-notes': magicNotesWorkspaceRoute.preload, + knowledge: knowledgeWorkspaceRoute.preload, + activity: activityPanelRoute.preload, + settings: settingsPanelRoute.preload +} + +function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void { + const preload = intentRoutePreloaders[view] + if (preload) { + void preload().catch(() => { + // Click and programmatic navigation retain their local retry boundary. + }) + } +} + const emptyTokenUsage: TokenUsageSummary = { totals: { callCount: 0, @@ -743,6 +767,7 @@ function ChatHistoryPane({ visibleMessageCount: number }): React.JSX.Element { const { t } = useTranslation('app') + const headingId = `chat-heading-${conversation.id}` const scrollRef = useRef(null) const pinnedToBottomRef = useRef( scrollSnapshot?.pinnedToBottom ?? true @@ -948,8 +973,12 @@ function ChatHistoryPane({ hidden={!active} inert={!active} > +

+ {t('chat.heading')} +

{taskStrip}

{t('chat.welcome.eyebrow')}

-

{t('chat.welcome.title')}

+

{t('chat.welcome.title')}

{t('chat.welcome.description')}

@@ -2112,12 +2141,17 @@ function App(): React.JSX.Element { const [assistantSidebarOpen, setAssistantSidebarOpen] = useState( () => window.innerWidth >= 1280 ) + const [assistantSidebarOverlay, setAssistantSidebarOverlay] = + useState(() => window.innerWidth < 1280) const [assistantSidebarTab, setAssistantSidebarTab] = useState('tasks') const [browserStates, setBrowserStates] = useState< Record >({}) const [view, setViewState] = useState('chat') + const settingsLeaveRequesterRef = useRef< + SettingsLeaveRequester | undefined + >(undefined) const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState< KeepAliveCacheEntry[] >(() => [{ key: 'chat', lastVisitedAt: Date.now() }]) @@ -2128,17 +2162,83 @@ function App(): React.JSX.Element { ? [{ key: activeId, lastVisitedAt: Date.now() }] : [] ) + const commitView = useCallback( + (next: WorkspaceView): void => { + const now = Date.now() + const runningConversationIds = new Set( + [...activeRuns.current.values()].map((run) => run.conversationId) + ) + preparingConversations.current.forEach((conversationId) => + runningConversationIds.add(conversationId) + ) + const protectedWorkspaceViews = new Set() + if (runningConversationIds.size > 0) { + protectedWorkspaceViews.add('chat') + protectedWorkspaceViews.add('activity') + } + if (knowledgeOperationCountRef.current > 0) { + protectedWorkspaceViews.add('knowledge') + protectedWorkspaceViews.add('activity') + } + if ( + assistantTasksRef.current.some( + (task) => + task.status === 'queued' || + task.status === 'running' || + task.status === 'waiting_approval' + ) + ) { + protectedWorkspaceViews.add('activity') + } + viewRef.current = next + setCachedWorkspaceViews((current) => + touchAndPruneKeepAliveEntries(current, next, now, { + expiresAfterMs: keepAliveExpirationMs, + maximumEntries: maximumCachedWorkspaceViews, + protectedKeys: protectedWorkspaceViews, + recentEntries: recentCachedWorkspaceViews + }) + ) + setViewState(next) + }, + [] + ) const setView = useCallback( (update: SetStateAction): void => { const next = typeof update === 'function' ? update(viewRef.current) : update - viewRef.current = next - setCachedWorkspaceViews((current) => - touchKeepAliveEntry(current, next, Date.now()) + if (viewRef.current === 'settings' && next !== 'settings') { + const requestLeave = settingsLeaveRequesterRef.current + if (requestLeave) { + requestLeave(() => commitView(next)) + return + } + } + commitView(next) + }, + [commitView] + ) + const registerSettingsLeaveRequester = useCallback( + (requester: SettingsLeaveRequester | undefined): void => { + settingsLeaveRequesterRef.current = requester + }, + [] + ) + const handleShortcutSettingsChanged = useCallback( + (snapshot: GlobalShortcutSettingsSnapshot): void => { + setAppInfo((current) => + current + ? { + ...current, + shortcut: snapshot.registered + ? snapshot.displayAccelerator + : '', + shortcutStatus: snapshot.status + } + : current ) - setViewState(next) }, [] ) @@ -2150,8 +2250,20 @@ function App(): React.JSX.Element { : update activeConversationIdRef.current = next if (next) { + const now = Date.now() + const runningConversationIds = new Set( + [...activeRuns.current.values()].map((run) => run.conversationId) + ) + preparingConversations.current.forEach((conversationId) => + runningConversationIds.add(conversationId) + ) setCachedConversationViews((current) => - touchKeepAliveEntry(current, next, Date.now()) + touchAndPruneKeepAliveEntries(current, next, now, { + expiresAfterMs: keepAliveExpirationMs, + maximumEntries: maximumCachedConversations, + protectedKeys: runningConversationIds, + recentEntries: recentCachedConversations + }) ) } setActiveIdState(next) @@ -2234,6 +2346,14 @@ function App(): React.JSX.Element { const imageViewerTriggerRef = useRef( undefined ) + const imageViewerDialogRef = useRef(null) + const imageViewerCloseRef = useRef(null) + useEffect(() => { + if (!imageViewerItem) { + return + } + return activateModalFocus(() => imageViewerCloseRef.current) + }, [imageViewerItem]) useEffect( () => window.goodbuddy.context.onFileSelectionProgress((progress) => { @@ -2255,6 +2375,7 @@ function App(): React.JSX.Element { const [knowledgeLoading, setKnowledgeLoading] = useState(true) const [knowledgeLoadError, setKnowledgeLoadError] = useState() const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0) + const knowledgeOperationCountRef = useRef(knowledgeOperationCount) const knowledgeLoadRequestRef = useRef(0) const failedKnowledgeLibraryIdRef = useRef( undefined @@ -2263,6 +2384,8 @@ function App(): React.JSX.Element { string[] >([]) const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false) + const knowledgeScopeTriggerRef = useRef(null) + const knowledgeScopePopoverRef = useRef(null) const [activityRecords, setActivityRecords] = useState( loadActivityRecords ) @@ -2314,6 +2437,7 @@ function App(): React.JSX.Element { >({}) const sidebarRef = useRef(null) const sidebarToggleRef = useRef(null) + const assistantSidebarToggleRef = useRef(null) const conversationActionTriggerRefs = useRef( new Map() ) @@ -2339,6 +2463,15 @@ function App(): React.JSX.Element { setSidebarOpen(false) requestAnimationFrame(() => sidebarToggleRef.current?.focus()) }, []) + const navigateFromSidebar = useCallback( + (nextView: WorkspaceView): void => { + setView(nextView) + if (narrowWindow) { + closeNarrowSidebar() + } + }, + [closeNarrowSidebar, narrowWindow, setView] + ) useEffect(() => { if (!conversationStoreReady) { @@ -2399,7 +2532,9 @@ function App(): React.JSX.Element { } setCachedConversationViews((current) => pruneKeepAliveEntries( - current.filter((entry) => conversationIds.has(entry.key)), + filterKeepAliveEntries(current, (entry) => + conversationIds.has(entry.key) + ), { currentKey: activeId, expiresAfterMs: keepAliveExpirationMs, @@ -2429,6 +2564,7 @@ function App(): React.JSX.Element { const collapseSidebarAtNarrowWidth = (): void => { const narrow = window.innerWidth < 900 setNarrowWindow(narrow) + setAssistantSidebarOverlay(window.innerWidth < 1280) if (narrow) { setSidebarOpen(false) } @@ -2462,6 +2598,41 @@ function App(): React.JSX.Element { } }, [closeNarrowSidebar, narrowWindow, sidebarOpen]) + useEffect(() => { + if (!knowledgeScopeOpen) { + return + } + const focusFrame = requestAnimationFrame(() => { + knowledgeScopePopoverRef.current + ?.querySelector('input') + ?.focus() + }) + const isScopeTarget = (target: EventTarget | null): boolean => + target instanceof Node && + (knowledgeScopePopoverRef.current?.contains(target) === true || + knowledgeScopeTriggerRef.current?.contains(target) === true) + const closeOnOutsidePointer = (event: PointerEvent): void => { + if (!isScopeTarget(event.target)) { + setKnowledgeScopeOpen(false) + } + } + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') { + return + } + event.preventDefault() + setKnowledgeScopeOpen(false) + knowledgeScopeTriggerRef.current?.focus() + } + document.addEventListener('pointerdown', closeOnOutsidePointer) + document.addEventListener('keydown', closeOnEscape) + return () => { + cancelAnimationFrame(focusFrame) + document.removeEventListener('pointerdown', closeOnOutsidePointer) + document.removeEventListener('keydown', closeOnEscape) + } + }, [knowledgeScopeOpen]) + useLayoutEffect(() => { conversationsRef.current = conversations }, [conversations]) @@ -3094,6 +3265,9 @@ function App(): React.JSX.Element { () => projects.find((project) => project.id === activeProjectId), [activeProjectId, projects] ) + const activeProjectDisplayName = activeProject + ? getProjectDisplayText(activeProject, tWorkspace).name + : undefined const filteredConversations = useMemo(() => { const query = deferredSearchQuery.trim().toLocaleLowerCase() const candidates = query @@ -3157,8 +3331,13 @@ function App(): React.JSX.Element { ) const projectNames = useMemo( () => - new Map(projects.map((project) => [project.id, project.name])), - [projects] + new Map( + projects.map((project) => [ + project.id, + getProjectDisplayText(project, tWorkspace).name + ]) + ), + [projects, tWorkspace] ) const pendingSidebarApprovals = useMemo( () => @@ -5680,11 +5859,10 @@ function App(): React.JSX.Element { }, []) const closeImageViewer = (): void => { + const trigger = imageViewerTriggerRef.current setImageViewerItem(undefined) - requestAnimationFrame(() => { - imageViewerTriggerRef.current?.focus() - imageViewerTriggerRef.current = undefined - }) + imageViewerTriggerRef.current = undefined + requestAnimationFrame(() => trigger?.focus()) } const openCitationContext = useCallback(async ( @@ -6665,7 +6843,11 @@ function App(): React.JSX.Element { const runKnowledgeSourceAction = async ( action: () => Promise ): Promise => { - setKnowledgeOperationCount((count) => count + 1) + setKnowledgeOperationCount((count) => { + const next = count + 1 + knowledgeOperationCountRef.current = next + return next + }) try { const result = await action() await refreshSelectedKnowledge() @@ -6674,7 +6856,11 @@ function App(): React.JSX.Element { await refreshSelectedKnowledge().catch(() => undefined) throw error } finally { - setKnowledgeOperationCount((count) => Math.max(0, count - 1)) + setKnowledgeOperationCount((count) => { + const next = Math.max(0, count - 1) + knowledgeOperationCountRef.current = next + return next + }) } } @@ -7079,16 +7265,28 @@ function App(): React.JSX.Element { runtimeSettings ]) + const assistantOverlayOpen = + assistantSidebarOverlay && + assistantSidebarOpen && + view === 'chat' + const mainSidebarOpen = narrowWindow && sidebarOpen + const backgroundIsolated = mainSidebarOpen || assistantOverlayOpen + return (
{knowledgeSnapshot.libraries.length > 0 && ( -
+
{ + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + setKnowledgeScopeOpen(false) + } + }} + > {knowledgeScopeOpen && ( -
+
{recentTrend.length === 0 ? ( @@ -760,9 +810,9 @@ export function HeartbeatCenter({

{t('center.latest.eyebrow')}

-

+

{t('center.latest.title')} -

+
{latestEntry && (
{t('center.suggestions.memoryCount', { @@ -943,9 +993,9 @@ export function HeartbeatCenter({

{t('center.suggestions.taskEyebrow')}

-

+

{t('center.suggestions.taskTitle')} -

+ {t('center.suggestions.taskCount', { @@ -1065,10 +1115,10 @@ export function HeartbeatCenter({

{t('center.history.timelineEyebrow')}

-

+

+ {t('center.history.reportCount', { @@ -1168,9 +1218,9 @@ export function HeartbeatCenter({

{t('center.history.auditEyebrow')}

-

+

{t('center.history.auditTitle')} -

+ {t('center.history.runCount', { diff --git a/src/renderer/src/HeartbeatSettings.tsx b/src/renderer/src/HeartbeatSettings.tsx index d51acb6..296aaf6 100644 --- a/src/renderer/src/HeartbeatSettings.tsx +++ b/src/renderer/src/HeartbeatSettings.tsx @@ -12,6 +12,7 @@ import { DestructiveConfirmActions, SegmentedControl } from './WorkspacePrimitives' +import { getProjectDisplayText } from './project-display' type HeartbeatSettingsProps = { heartbeats: AssistantHeartbeatConfig[] @@ -38,6 +39,7 @@ export function HeartbeatSettings({ onRunNow }: HeartbeatSettingsProps): React.JSX.Element { const { t, i18n } = useTranslation('heartbeat') + const { t: tWorkspace } = useTranslation('workspace') const [editingId, setEditingId] = useState() const [name, setName] = useState(t('settings.defaultName')) const [time, setTime] = useState('09:00') @@ -162,11 +164,12 @@ export function HeartbeatSettings({ if (heartbeat.scope.kind === 'global') { return t('settings.scope.global') } - const names = heartbeat.scope.projectIds.map( - (projectId) => - projectById.get(projectId)?.name ?? - t('settings.scope.unavailableProject') - ) + const names = heartbeat.scope.projectIds.map((projectId) => { + const project = projectById.get(projectId) + return project + ? getProjectDisplayText(project, tWorkspace).name + : t('settings.scope.unavailableProject') + }) return t('settings.scope.selectedProjectsSummary', { count: names.length, names: names.join(t('settings.scope.nameSeparator')) @@ -176,10 +179,10 @@ export function HeartbeatSettings({ return (
-

+

{t('settings.title')} -

+

{t('settings.description')}

@@ -250,7 +253,9 @@ export function HeartbeatSettings({ } type="checkbox" /> - {project.name} + + {getProjectDisplayText(project, tWorkspace).name} + {project.status !== 'active' && ( {t('settings.scope.archived')} )} diff --git a/src/renderer/src/KnowledgeGraphChart.tsx b/src/renderer/src/KnowledgeGraphChart.tsx index f8cb2d8..95d0510 100644 --- a/src/renderer/src/KnowledgeGraphChart.tsx +++ b/src/renderer/src/KnowledgeGraphChart.tsx @@ -30,7 +30,7 @@ type ChartKnowledgeGraphRelation = Omit< evidenceIds?: readonly string[] } -type KnowledgeGraphChartProps = { +export type KnowledgeGraphChartProps = { nodes: readonly ChartKnowledgeGraphNode[] relations: readonly ChartKnowledgeGraphRelation[] selectedNodeId?: string diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx index 5730c5c..3328df4 100644 --- a/src/renderer/src/KnowledgeWorkspace.test.tsx +++ b/src/renderer/src/KnowledgeWorkspace.test.tsx @@ -7,8 +7,11 @@ import { waitFor, within } from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { + KnowledgeGraphChartLoader, KnowledgeWorkspace, type KnowledgeWorkspaceProps } from './KnowledgeWorkspace' @@ -17,6 +20,17 @@ import { defaultKnowledgeOntologySettings } from '../../shared/knowledge-ontology' +const knowledgeWorkspaceSource = readFileSync( + join( + process.cwd(), + 'src', + 'renderer', + 'src', + 'KnowledgeWorkspace.tsx' + ), + 'utf8' +) + const g6Mock = vi.hoisted(() => { const handlers = new Map void>() const graph = { @@ -460,11 +474,13 @@ describe('KnowledgeWorkspace', () => { expect(screen.getByText('第二份文档内容')).toBeInTheDocument() }) - it('switches to the graph and opens entity details', () => { + it('switches to the graph and opens entity details', async () => { render() fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) - expect(screen.getByLabelText('实体关系图')).toBeInTheDocument() + expect( + await screen.findByLabelText('实体关系图') + ).toBeInTheDocument() expect( screen.getByRole('tab', { name: /拓扑/u }) ).toHaveAttribute('aria-selected', 'true') @@ -1217,6 +1233,9 @@ describe('KnowledgeWorkspace', () => { render() fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + await waitFor(() => + expect(g6Mock.graph.setOptions).toHaveBeenCalled() + ) expect( screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' }) ).toBeInTheDocument() @@ -1265,9 +1284,7 @@ describe('KnowledgeWorkspace', () => { const workspace = screen.getByLabelText('知识工作区') expect(workspace).toHaveClass('knowledge-workspace') - expect(workspace).toHaveStyle({ - background: 'var(--surface-canvas)' - }) + expect(workspace).not.toHaveAttribute('style') expect(workspace.querySelector('aside')).toHaveClass( 'knowledge-workspace__sidebar' ) @@ -1276,9 +1293,7 @@ describe('KnowledgeWorkspace', () => { name: '知识库详情' }) expect(detailRegion).toHaveClass('knowledge-workspace__main') - expect(detailRegion).toHaveStyle({ - background: 'var(--surface-raised)' - }) + expect(detailRegion).not.toHaveAttribute('style') expect(screen.getByText('全局')).toHaveClass('scope-badge') const mobileBack = screen.getByRole('button', { name: '返回知识库列表' @@ -1286,11 +1301,14 @@ describe('KnowledgeWorkspace', () => { expect(mobileBack).toHaveClass('knowledge-workspace__mobile-back') fireEvent.click(mobileBack) expect(workspace).toHaveClass('knowledge-workspace--mobile-list') - fireEvent.click( - screen.getByRole('button', { - name: /^产品知识 1 个文档/u - }) + const selectedLibraryButton = screen.getByRole('button', { + name: /^产品知识 1 个文档/u + }) + expect(selectedLibraryButton).toHaveClass( + 'knowledge-workspace__library-button--selected' ) + expect(selectedLibraryButton).not.toHaveAttribute('style') + fireEvent.click(selectedLibraryButton) expect(workspace).not.toHaveClass('knowledge-workspace--mobile-list') expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass( 'page-tabs' @@ -1324,13 +1342,19 @@ describe('KnowledgeWorkspace', () => { target: { value: 'entity-1' } }) expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass( - 'knowledge-graph--with-details' + 'knowledge-graph' ) + expect(screen.getByLabelText('知识图谱画布').parentElement) + .not.toHaveClass('knowledge-graph--with-details') expect(screen.getByLabelText('实体详情')).toHaveClass( 'knowledge-graph__detail' ) }) + it('keeps Knowledge workspace presentation in semantic classes', () => { + expect(knowledgeWorkspaceSource).not.toMatch(/\bstyle\s*=/u) + }) + it('manages the G6 graph, zoom, selection, movement, and cleanup', async () => { const onMoveNode = vi.fn() const { rerender, unmount } = render( @@ -1338,7 +1362,7 @@ describe('KnowledgeWorkspace', () => { ) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) - const graph = screen.getByLabelText('实体关系图') + const graph = await screen.findByLabelText('实体关系图') expect(graph).toHaveClass('knowledge-graph__chart') expect(g6Mock.Graph).toHaveBeenCalledWith( expect.objectContaining({ @@ -1491,6 +1515,7 @@ describe('KnowledgeWorkspace', () => { it('preserves the G6 instance and refreshes theme colors', async () => { render() fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + await screen.findByLabelText('实体关系图') g6Mock.graph.getZoom.mockReturnValueOnce(1.3) act(() => { @@ -1523,7 +1548,7 @@ describe('KnowledgeWorkspace', () => { delete document.documentElement.dataset.theme }) - it('sizes dense nodes by degree and labels key entities', () => { + it('sizes dense nodes by degree and labels key entities', async () => { const graphNodes = Array.from({ length: 30 }, (_, index) => ({ id: `entity-${index}`, label: `实体 ${index}`, @@ -1553,6 +1578,7 @@ describe('KnowledgeWorkspace', () => { /> ) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + await screen.findByLabelText('实体关系图') expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -1598,6 +1624,50 @@ describe('KnowledgeWorkspace', () => { ) }) + it('shows a graph-local loading state and retries a failed chart chunk', async () => { + let attempts = 0 + const loadModule = vi.fn(async () => { + attempts += 1 + if (attempts <= 2) { + throw new Error('chunk unavailable') + } + return { + KnowledgeGraphChart: () => ( +
+ ) + } as unknown as typeof import('./KnowledgeGraphChart') + }) + const props = createProps() + + render( + + ) + + expect( + screen.getByRole('status') + ).toHaveTextContent('正在加载知识图谱…') + expect( + await screen.findByRole('alert') + ).toHaveTextContent('知识图谱未能加载,请重试。') + expect(loadModule).toHaveBeenCalledTimes(2) + + fireEvent.click(screen.getByRole('button', { name: '重试' })) + + expect( + await screen.findByLabelText('已加载的图谱测试组件') + ).toBeInTheDocument() + expect(loadModule).toHaveBeenCalledTimes(3) + }) + it('creates relationships, merges entities, and opens graph evidence', async () => { const onCreateRelation = vi.fn() const onMergeEntities = vi.fn() @@ -1644,7 +1714,118 @@ describe('KnowledgeWorkspace', () => { target: { value: 'entity-2' } }) fireEvent.click(screen.getByRole('button', { name: '合并到目标实体' })) - expect(onMergeEntities).toHaveBeenCalledWith('entity-1', 'entity-2') + expect( + screen.getByRole('alertdialog', { + name: '将“GoodBuddy”合并到“Electron”?' + }) + ).toHaveAccessibleDescription( + '“GoodBuddy”的关系、别名和证据将并入“Electron”,随后删除源实体。此操作无法恢复。' + ) + expect(onMergeEntities).not.toHaveBeenCalled() + fireEvent.click( + screen.getByRole('button', { name: '合并实体' }) + ) + await waitFor(() => + expect(onMergeEntities).toHaveBeenCalledWith( + 'entity-1', + 'entity-2' + ) + ) + await waitFor(() => + expect(screen.getByLabelText('选择图谱实体')).toHaveFocus() + ) + }) + + it('confirms graph entity and relation deletion with concrete impact', async () => { + const onDeleteEntity = vi.fn(async () => {}) + const onDeleteRelation = vi.fn(async () => {}) + render( + + ) + + fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + fireEvent.change(screen.getByLabelText('选择图谱实体'), { + target: { value: 'entity-1' } + }) + fireEvent.click( + screen.getByRole('button', { name: '删除实体 GoodBuddy' }) + ) + expect( + screen.getByRole('alertdialog', { + name: '删除实体“GoodBuddy”?' + }) + ).toHaveAccessibleDescription( + '将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。' + ) + expect(onDeleteEntity).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '取消' })) + + fireEvent.click( + screen.getByRole('button', { name: '删除关系 使用' }) + ) + expect( + screen.getByRole('alertdialog', { + name: '删除关系“使用 (USES)”?' + }) + ).toHaveAccessibleDescription( + '将永久删除“GoodBuddy”到“Electron”的“使用 (USES)”关系及其图谱证据引用,且无法恢复。两个实体本身会保留。' + ) + fireEvent.click( + screen.getByRole('button', { name: '删除关系' }) + ) + await waitFor(() => + expect(onDeleteRelation).toHaveBeenCalledWith('relation-1') + ) + await waitFor(() => + expect(screen.getByLabelText('选择图谱实体')).toHaveFocus() + ) + + fireEvent.click( + screen.getByRole('button', { name: '删除实体 GoodBuddy' }) + ) + fireEvent.click( + screen.getByRole('button', { name: '删除实体' }) + ) + await waitFor(() => + expect(onDeleteEntity).toHaveBeenCalledWith('entity-1') + ) + await waitFor(() => + expect(screen.getByLabelText('选择图谱实体')).toHaveFocus() + ) + }) + + it('keeps a failed graph confirmation open and focused', async () => { + const onDeleteEntity = vi.fn(async () => { + throw new Error('删除实体失败') + }) + render( + + ) + + fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + fireEvent.change(screen.getByLabelText('选择图谱实体'), { + target: { value: 'entity-1' } + }) + fireEvent.click( + screen.getByRole('button', { name: '删除实体 GoodBuddy' }) + ) + const confirm = screen.getByRole('button', { + name: '删除实体' + }) + confirm.focus() + fireEvent.click(confirm) + + expect(await screen.findByText('删除实体失败')).toBeInTheDocument() + expect( + screen.getByRole('alertdialog', { + name: '删除实体“GoodBuddy”?' + }) + ).toBeInTheDocument() + expect(confirm).toHaveFocus() }) it('renders an explicit empty graph state', () => { @@ -1660,6 +1841,25 @@ describe('KnowledgeWorkspace', () => { ).toBeInTheDocument() }) + it('keeps one primary creation action in the empty library state', () => { + render( + + ) + + expect(screen.getByText('建立第一个知识库')).toBeInTheDocument() + expect( + screen.getAllByRole('button', { name: '新建知识库' }) + ).toHaveLength(1) + expect( + screen.queryByRole('button', { name: '创建知识库' }) + ).not.toBeInTheDocument() + }) + it('keeps loading distinct from the first-library empty state', () => { render( { ).toHaveAttribute('aria-current', 'page') }) + it('isolates and traps focus in the library edit dialog', async () => { + render() + + const trigger = screen.getByRole('button', { name: '编辑' }) + trigger.focus() + fireEvent.click(trigger) + + const dialog = screen.getByRole('dialog', { + name: '编辑知识库' + }) + const nameInput = screen.getByLabelText('名称') + expect(nameInput).toHaveFocus() + expect( + document.querySelector( + '.knowledge-workspace__main' + )?.inert + ).toBe(true) + fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true }) + expect( + screen.getByRole('button', { name: '保存修改' }) + ).toHaveFocus() + + fireEvent.keyDown(dialog, { key: 'Escape' }) + await waitFor(() => expect(trigger).toHaveFocus()) + expect( + document.querySelector( + '.knowledge-workspace__main' + )?.inert + ).toBe(false) + }) + it('confirms that deleting a managed library removes managed copies', async () => { const onDeleteLibrary = vi.fn() render( @@ -1734,8 +1965,18 @@ describe('KnowledgeWorkspace', () => { name: '删除知识库确认' }) expect(screen.getByRole('button', { name: '取消' })).toHaveFocus() + expect( + document.querySelector( + '.knowledge-workspace__main' + )?.inert + ).toBe(true) fireEvent.keyDown(dialog, { key: 'Escape' }) await waitFor(() => expect(trigger).toHaveFocus()) + expect( + document.querySelector( + '.knowledge-workspace__main' + )?.inert + ).toBe(false) fireEvent.click(trigger) expect( screen.getByText( diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx index 1ec7446..1ef5616 100644 --- a/src/renderer/src/KnowledgeWorkspace.tsx +++ b/src/renderer/src/KnowledgeWorkspace.tsx @@ -29,8 +29,12 @@ import { ZoomOut } from 'lucide-react' import { + Component, + Suspense, + type ReactNode, useCallback, useEffect, + useId, useMemo, useRef, useState @@ -75,7 +79,10 @@ import { SegmentedControl, type PageTab } from './WorkspacePrimitives' -import { KnowledgeGraphChart } from './KnowledgeGraphChart' +import { createPreloadableComponent } from './preloadable-component' +import type { + KnowledgeGraphChartProps +} from './KnowledgeGraphChart' import { KnowledgeChunkManager } from './KnowledgeChunkManager' @@ -84,9 +91,103 @@ import { type KnowledgeRetrievalWorkbenchResponse, type KnowledgeRetrievalWorkbenchSettings } from './KnowledgeRetrievalWorkbench' -import { trapTabFocus } from './dialog-focus' +import { activateModalFocus, trapTabFocus } from './dialog-focus' import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection' +type KnowledgeGraphChartModule = typeof import('./KnowledgeGraphChart') + +type KnowledgeGraphChartModuleLoader = ( +) => Promise + +function createKnowledgeGraphChartRoute( + loadModule: KnowledgeGraphChartModuleLoader +) { + return createPreloadableComponent( + loadModule, + (module) => module.KnowledgeGraphChart + ) +} + +const loadKnowledgeGraphChartModule: KnowledgeGraphChartModuleLoader = + () => import('./KnowledgeGraphChart') + +class KnowledgeGraphChunkErrorBoundary extends Component< + { + children: ReactNode + onRetry: () => void + retryLabel: string + errorMessage: string + }, + { failed: boolean } +> { + state = { failed: false } + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true } + } + + render(): ReactNode { + if (!this.state.failed) { + return this.props.children + } + return ( +
+
+ ) + } +} + +export function KnowledgeGraphChartLoader({ + loadModule = loadKnowledgeGraphChartModule, + ...props +}: KnowledgeGraphChartProps & { + loadModule?: KnowledgeGraphChartModuleLoader +} +): React.JSX.Element { + const { t } = useTranslation('knowledge') + const [loaderState, setLoaderState] = useState(() => ({ + generation: 0, + route: createKnowledgeGraphChartRoute(loadModule) + })) + const route = loaderState.route + const Chart = route.Component + + return ( + + setLoaderState((current) => ({ + generation: current.generation + 1, + route: createKnowledgeGraphChartRoute(loadModule) + })) + } + retryLabel={t('actions.retry')} + > + +
+ } + > + + + + ) +} + export type KnowledgeLibrary = SharedKnowledgeLibrary export type KnowledgeStorageMode = KnowledgeLibrary['storageMode'] export type KnowledgeGraphStrategy = KnowledgeLibrary['graphStrategy'] @@ -453,51 +554,6 @@ function formatPercent(value: number, locale: string): string { return getLocaleFormatters(locale).percent.format(value) } -const styles = { - workspace: { - display: 'grid', - overflow: 'hidden', - border: '1px solid var(--border-default)', - borderRadius: 'var(--radius-card)', - background: 'var(--surface-canvas)', - color: 'var(--text-primary)' - }, - surface: { - border: '1px solid var(--border-default)', - borderRadius: 'var(--radius-control)', - background: 'var(--surface-raised)' - }, - button: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: 'var(--space-2)' - }, - input: { - width: '100%', - boxSizing: 'border-box' as const, - minHeight: 'var(--control-height)', - padding: 'var(--space-2) var(--space-3)', - border: '1px solid var(--border-control)', - borderRadius: 'var(--radius-control)', - background: 'var(--surface-raised)', - color: 'var(--text-primary)', - font: 'inherit' - }, - label: { - display: 'grid', - gap: 'var(--space-2)', - color: 'var(--text-secondary)', - fontSize: 'var(--font-body)', - fontWeight: 650 - }, - muted: { - color: 'var(--text-muted)', - fontSize: 'var(--font-body)', - lineHeight: 1.55 - } -} as const - function clampProgress(progress: number | undefined): number { if (!Number.isFinite(progress)) { return 0 @@ -624,53 +680,37 @@ function CreateLibraryWizard({ return (
void submit(event)} - style={{ - ...styles.surface, - display: 'grid', - gap: 14, - padding: 16, - margin: 20 - }} >
- + {t('create.eyebrow')} -

+

{t('create.title')}

-