feat: improve desktop reliability and customization

Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely.

Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility.

Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
mesalogo
2026-08-20 10:11:06 +08:00
parent 20c15f74c6
commit f44a0dc907
129 changed files with 15511 additions and 2112 deletions
+55 -1
View File
@@ -115,7 +115,61 @@ jobs:
name: goodbuddy-production-bundle name: goodbuddy-production-bundle
path: out 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 run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build
env: env:
ELECTRON_CACHE: ${{ runner.temp }}/electron ELECTRON_CACHE: ${{ runner.temp }}/electron
+47 -2
View File
@@ -195,8 +195,32 @@ git push origin "$tag"
git push github "$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` 如需覆盖发布包内置 npm 路径,再设置 `GOODBUDDY_DSH_NPM_CLI`
`GOODBUDDY_DSH_NODE_EXECUTABLE` 指向已解包应用中的 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 调大数值。
+3 -2
View File
@@ -12,6 +12,7 @@
### 桌面基础、工作空间与上下文 ### 桌面基础、工作空间与上下文
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64``arm64` 发布目标。 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64``arm64` 发布目标。
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。 - [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
@@ -21,7 +22,7 @@
### Agent Runtime 与模型连接 ### Agent Runtime 与模型连接
- [x] **直连模型 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] **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] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
- [x] **Ask 与 Execute 工作模式**Ask 保持只读;Execute 运行已启用且受边界约束的工具。 - [x] **Ask 与 Execute 工作模式**Ask 保持只读;Execute 运行已启用且受边界约束的工具。
@@ -30,7 +31,7 @@
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。 - [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
- [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、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 与 ResourcesTools 单独显示读取、文件修改、命令、网络、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] **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 代理。 - [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 或 TaskJob/Run 保持内部,不形成树或独立操作对象。 - [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Task 级委派、后台执行、Workflow/Hook、长任务与原生会话监督;用户只选择 Conversation 或 TaskJob/Run 保持内部,不形成树或独立操作对象。
+22 -3
View File
@@ -300,16 +300,20 @@
自动增删入口。产品契约见 自动增删入口。产品契约见
[通用助手工作栏与执行空间 PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 [通用助手工作栏与执行空间 PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。
- 默认固定提供 Task Center、监督、Runtime、终端、进程、工作区、浏览器成果和上下文九个标准栏目。 - 默认固定提供任务中心、上下文、工作区、浏览器成果五个标准栏目,不根据当前页面或
Runtime 能力自动增删入口。
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task - Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容, 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
也不得把 Job/Run 提升为可导航 UI 对象。 也不得把 Job/Run 提升为可导航 UI 对象。
- 其他可绑定目标的栏目独立支持“跟随当前上下文”和“固定到指定对象”。当前会话项目和 Runtime 只提供默认目标,不能成为进入栏目或切换目标的前提 - 各栏目读取当前会话项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 - 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 - 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。
- 个栏目优先使用稳定图标与标签的纵向工具导航,并保留 `tablist``tab``tabpanel`、方向键、Home、End 和焦点恢复语义。 - 个栏目使用稳定标签并保留 `tablist``tab``tabpanel`、方向键、Home、End 和焦点恢复语义。
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。 - 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
- 宽窗口可停靠并调整宽度,中等窗口可停靠或覆盖,窄窗口使用全屏或接近全屏抽屉;所有尺寸下均须保留全部栏目入口。 - 宽窗口可停靠并调整宽度,中等窗口可停靠或覆盖,窄窗口使用全屏或接近全屏抽屉;所有尺寸下均须保留全部栏目入口。
- 覆盖和抽屉布局打开后焦点进入工作栏并限制在其中,Escape 或背景点击关闭,关闭后焦点
返回触发按钮;覆盖期间背景内容必须从指针与辅助技术导航中隔离。宽窗口停靠布局不得
获得对话框语义或隔离主工作区。
- 终端、宽日志和大型成果可以由用户切换到底部停靠或独立窗口,应用不得因内容变化自动改变用户已选布局。 - 终端、宽日志和大型成果可以由用户切换到底部停靠或独立窗口,应用不得因内容变化自动改变用户已选布局。
### 6.10 应用顶栏与全局操作 ### 6.10 应用顶栏与全局操作
@@ -579,6 +583,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs``segmented` 视觉变体,与模型设置的分段控件保持同一外观。 - “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs``segmented` 视觉变体,与模型设置的分段控件保持同一外观。
- 页签切换保留 `tablist``tab``tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。 - 页签切换保留 `tablist``tab``tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。 - 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。 - 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
@@ -594,6 +599,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供 - 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。 “打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。 - 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
@@ -698,3 +704,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
4. 全局或项目范围在浏览、创建、编辑和危险操作中均可见。 4. 全局或项目范围在浏览、创建、编辑和危险操作中均可见。
5. 浅色、深色、键盘和各窗口宽度下均可完成核心任务。 5. 浅色、深色、键盘和各窗口宽度下均可完成核心任务。
6. 空状态、错误状态和危险操作符合本文规则。 6. 空状态、错误状态和危险操作符合本文规则。
## 17. 一级页面加载性能
- 首次启动只可在低优先级空闲时预加载轻量一级页面;Knowledge、Magic Notes、
Settings、Activity 等较重页面应在对应导航控件获得指针意图或键盘焦点时预加载。
- 点击、快捷键和程序化导航不能依赖预加载完成,必须保留页面级 `Suspense` 加载
状态、错误边界和 KeepAlive 行为。
- Workspace 与 Conversation 的 KeepAlive 缓存必须在每次访问时立即执行容量上限
与 LRU 保护规则;定时清理只负责过期与数据失效兜底,不能作为容量门禁。
- 页面内大型可选视图应使用局部加载边界。知识图谱画布加载失败时,只替换画布
区域并提供可访问的重试操作,不得替换整个 Knowledge 页面或丢失其余页面状态。
- 加载状态使用 `role="status"``aria-live="polite"``aria-busy="true"`
局部加载失败使用 `role="alert"`,并保留明确的恢复操作。
+386
View File
@@ -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<string, object> }}
*/
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
}
}
@@ -219,6 +219,10 @@ GoodBuddy 控制面自身不导出 `apply(ctx, config)`,也不提供默认 std
- 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。 - 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。
GoodBuddy 不扫描任意目录、不读取用户 profile 插件清单,也不接受 Renderer 直接提供文件路径。 GoodBuddy 不扫描任意目录、不读取用户 profile 插件清单,也不接受 Renderer 直接提供文件路径。
插件安装、升级和移除在目录重命名前写入受管变更日志。Main 下次初始化时以持久
Store 是否已经提交为准,确定性完成新目录或恢复旧目录,并在处理前重新验证受管
目录、入口真实路径、符号链接和根目录包含关系。旧版 `store.json` 继续原地迁移,
不要求用户重新安装插件。
## 8. 协议设计 ## 8. 协议设计
@@ -523,14 +527,14 @@ OpenCode、Continue 和 DeepSeek Harness 的后续能力按操作生命周期放
| 表面 | 负责内容 | 不负责内容 | | 表面 | 负责内容 | 不负责内容 |
| --- | --- | --- | | --- | --- | --- |
| Composer 通用行 | 附件、语音、知识范围、专家、Ask/Execute、Runtime 和发送 | Session 监督、后台进度、历史任务管理 | | Composer 通用行 | 附件、语音、知识范围、专家、Ask/Execute、Runtime 和发送 | Session 监督、后台进度、历史任务管理 |
| Composer Runtime 专属行 | 仅对当前消息生效且需要高频选择的 Agent、预设、Prompt/Command 快捷操作 | Subagent 树、后台 Job、Workflow/Hook 生命周期 | | Composer Runtime 专属行 | 仅对当前消息生效且需要高频选择的 Agent、预设、Prompt/Command 快捷操作 | Task 级委派、后台执行、Workflow/Hook 生命周期 |
| 助手工作栏固定“Runtime”栏目 | 用户所选会话或 Run 的 Runtime 状态、Subagent 层级与取消、后台 Job 队列/进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止和会话监督 | 持久模型、程序路径、默认 Agent/预设配置 | | 助手工作栏固定“Runtime”栏目 | 用户所选 Conversation 或 Task 的 Runtime 状态、Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止和会话监督;不显示 Job/Run 树 | 持久模型、程序路径、默认 Agent/预设配置 |
| 设置 > Agent Runtime | 持久 Runtime 配置、默认值、插件管理、能力清单和连接诊断 | 某次活动会话的实时控制 | | 设置 > Agent Runtime | 持久 Runtime 配置、默认值、插件管理、能力清单和连接诊断 | 某次活动会话的实时控制 |
Runtime 栏目入口始终存在,并采用统一监督模型;内部再按用户所选目标及其 Runtime 的真实能力 Runtime 栏目入口始终存在,并采用统一监督模型;内部再按用户所选目标及其 Runtime 的真实能力
显示 OpenCode、Continue 或 DSH 的具体区域。未支持能力不渲染空卡片或一排禁用按钮,而是 显示 OpenCode、Continue 或 DSH 的具体区域。未支持能力不渲染空卡片或一排禁用按钮,而是
在用户需要理解缺口时显示原因和可执行入口。跟随模式切换 Runtime 或会话时必须清理上一归属 在用户需要理解缺口时显示原因和可执行入口。跟随模式切换 Runtime、Conversation 或 Task
的 Job/Subagent 状态,固定目标则保持不变。完整工作栏契约见 时必须清理上一归属的聚合执行状态,固定目标则保持不变。完整工作栏契约见
[通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。
所有未来的 Subagent、Job、Workflow、Hook 和会话操作仍须经过 Main 的 Runtime 边界,保留取消、超时、权限、Task/Job/Subjob 层级、用量和活动审计。高风险动作在侧栏就地确认,运行结果进入活动与成果记录,不以 Composer 按钮代替监督面板。DeepSeek Harness 首版仍不加载这些服务,本节只确定未来跨 Runtime 的产品位置和协议归属。 所有未来的 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 文件和命令工具没有 Runtime OS 隔离,会继承 GoodBuddy 客户端当前用户能够访问的主机资源。
- 首版不恢复 Harness 原生 SessionRuntime 重启后由 GoodBuddy 历史重建。 - 首版不恢复 Harness 原生 SessionRuntime 重启后由 GoodBuddy 历史重建。
- 图片输入仅在所选模型连接明确声明支持时可用;首版仍不支持知识库、浏览器控制和 Harness Subagent。Web Search/Fetch 仅使用 Main 代理,MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。 - 图片输入仅在所选模型连接明确声明支持时可用;首版仍不支持知识库、浏览器控制和 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 保证。 - 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。
- 市场来自公共 npm 关键字搜索,不是精选目录;包的质量、兼容性和维护状态由发布者负责。 - 市场来自公共 npm 关键字搜索,不是精选目录;包的质量、兼容性和维护状态由发布者负责。
- 插件安装、初始化、后台生命周期和 Execute 工具使用当前用户权限,不受 Runtime OS 沙箱保护;Ask 只控制模型工具调用。 - 插件安装、初始化、后台生命周期和 Execute 工具使用当前用户权限,不受 Runtime OS 沙箱保护;Ask 只控制模型工具调用。
@@ -918,6 +918,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
- Renderer 提交过期来源时零网络请求。 - Renderer 提交过期来源时零网络请求。
- 两个来源的安装 Manifest 文件摘要相同。 - 两个来源的安装 Manifest 文件摘要相同。
- 取消、关闭、错误和摘要不匹配不留下正式模型目录。 - 取消、关闭、错误和摘要不匹配不留下正式模型目录。
- 快照准备会清理名称同时匹配受管模型 ID 与安装 UUID 的陈旧
`.install-*` 目录,以及受管文件名对应的孤立 `.partial` 文件;已安装模型、
非目录条目和用户自建文件不参与清理。
### 17.5 回归 ### 17.5 回归
@@ -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 自动更新 ### 5.16 自动更新
#### 功能项 #### 功能项
@@ -397,6 +397,8 @@ Execute 消息通过身份、长度、去重和并发检查后:
- 失败:回传经过脱敏、长度受限的用户可处理错误。 - 失败:回传经过脱敏、长度受限的用户可处理错误。
- 取消:回传“任务已取消”。 - 取消:回传“任务已取消”。
- 结果投递失败时保留发件箱记录并显示通道错误,不重复执行任务。 - 结果投递失败时保留发件箱记录并显示通道错误,不重复执行任务。
- 发件箱达到五次投递尝试后进入可查询的终止状态,并继续通过现有通道错误回调
暴露;终止记录不再发送,也不会从未投递查询中静默消失。
### 9.6 媒体与文件 ### 9.6 媒体与文件
@@ -286,13 +286,14 @@ Supervisor 不能:
实验工作台页签: 实验工作台页签:
1. **设计**:问题、协议、变量、指标和预算。 1. **设计**:问题、协议、变量、指标和预算。
2. **运行**:总体进度、Run 表和状态。 2. **运行**:总体进度、候选执行和聚合状态。
3. **比较**:指标表、图表、差异和 Pareto 候选。 3. **比较**:指标表、图表、差异和 Pareto 候选。
4. **证据**:按结论、指标和 Run 查看证据。 4. **证据**:按结论、指标和候选查看证据。
5. **结论**:总结、限制和后续操作。 5. **结论**:总结、限制和后续操作。
Run 详情展示参数、协议版本、时间线、消息、任务、成果、监督记录、指标、评估理由、 候选详情在 Experiment 工作台内展示参数、协议版本、时间线、消息、Task、成果、监督记录、
上下文和记忆快照、Token、耗时与错误。 指标、评估理由、上下文和记忆快照、Token、耗时与错误。内部 Run ID 只用于关联和审计,
不提供独立 Run 路由、页面或操作菜单。
## 15. 后续操作 ## 15. 后续操作
@@ -302,7 +303,7 @@ Run 详情展示参数、协议版本、时间线、消息、任务、成果、
- 创建自动化计划草稿。 - 创建自动化计划草稿。
- 保存实验模板。 - 保存实验模板。
- 创建记忆候选。 - 创建记忆候选。
- 追加确认 Run - 追加确认执行
- 导出脱敏结果摘要。 - 导出脱敏结果摘要。
不得自动启用新计划、覆盖现有计划、确认长期记忆、应用工作区 Patch 或扩大权限。 不得自动启用新计划、覆盖现有计划、确认长期记忆、应用工作区 Patch 或扩大权限。
+1 -1
View File
@@ -84,7 +84,7 @@ Observe
- 用户对回答、任务或 Supervisor 意见的显式反馈。 - 用户对回答、任务或 Supervisor 意见的显式反馈。
- 用户对心跳报告或建议的显式反馈。 - 用户对心跳报告或建议的显式反馈。
- Task/Job Run 的成功与失败比较。 - Task 执行的成功与失败比较。
- 并行实验结论。 - 并行实验结论。
- 回放评估发现的稳定差异。 - 回放评估发现的稳定差异。
- 用户手动创建。 - 用户手动创建。
+5 -5
View File
@@ -140,10 +140,10 @@ agent:{expertId}
Conversation → Project → Global Conversation → Project → Global
``` ```
Task/Job Run Task 执行(内部 Job/Run
```text ```text
Run → Automation → Conversation(可选)→ Project → Global Run → Job → Task → Conversation → Project → Global
``` ```
实验 Run 实验 Run
@@ -232,7 +232,7 @@ type MemorySource =
- 用户明确“记住这个”。 - 用户明确“记住这个”。
- 会话结束总结。 - 会话结束总结。
- Task/Job Run 结束反思。 - Task 执行结束反思。
- 实验结论。 - 实验结论。
- Supervisor 建议后用户采纳。 - Supervisor 建议后用户采纳。
- 智能心跳。 - 智能心跳。
@@ -481,7 +481,7 @@ Project 记忆与 Global 偏好冲突时:
- [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。 - [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。
- [ ] 智能心跳配置只能属于 Global 或 Main 已验证的一个、多个 Project。 - [ ] 智能心跳配置只能属于 Global 或 Main 已验证的一个、多个 Project。
- [ ] 未来分区记忆完成独立设计前,不新增相关表、状态或检索行为。 - [ ] 未来分区记忆完成独立设计前,不新增相关表、状态或检索行为。
- [ ] Task/Job Run 只读取运行快照绑定的分区。 - [ ] Task 执行只读取内部 Run 快照绑定的分区。
- [ ] 实验 Run 不能读取其他 Run 的消息或记忆。 - [ ] 实验 Run 不能读取其他 Run 的消息或记忆。
- [ ] 每条非手动记忆都有可追溯来源。 - [ ] 每条非手动记忆都有可追溯来源。
- [ ] 候选和被拒绝记忆不进入普通上下文。 - [ ] 候选和被拒绝记忆不进入普通上下文。
@@ -489,6 +489,6 @@ Project 记忆与 Global 偏好冲突时:
- [ ] 冲突事实不被静默覆盖。 - [ ] 冲突事实不被静默覆盖。
- [ ] 当前有效事实可通过有效时间正确选择。 - [ ] 当前有效事实可通过有效时间正确选择。
- [ ] 上下文组装遵守各层和总字符预算。 - [ ] 上下文组装遵守各层和总字符预算。
- [ ] UI 能显示某次 Run 实际使用的记忆 - [ ] UI 能在 Task 执行记录中显示实际使用的记忆,不把 Run 暴露为独立导航对象
- [ ] 删除或忘记后,文本、索引和缓存不再可检索。 - [ ] 删除或忘记后,文本、索引和缓存不再可检索。
- [ ] Restricted 记忆不会自动生成或发送给外部 Embedding 服务。 - [ ] Restricted 记忆不会自动生成或发送给外部 Embedding 服务。
@@ -197,6 +197,8 @@ type HeartbeatScope =
- 多项目汇总后统一应用上限,不能按项目倍增预算。 - 多项目汇总后统一应用上限,不能按项目倍增预算。
- 心跳结果默认不在系统通知中暴露私人正文。 - 心跳结果默认不在系统通知中暴露私人正文。
- 数据迁移必须使用 SQLite 事务,保留外键、级联删除和现有历史。 - 数据迁移必须使用 SQLite 事务,保留外键、级联删除和现有历史。
- 心跳运行失败时,运行记录与配置的 `last_status` 必须在同一 SQLite 事务中
更新;任一写入失败时两者一起回滚,不能留下半提交状态。
## 7. 实施状态与后续顺序 ## 7. 实施状态与后续顺序
@@ -28,9 +28,10 @@ GoodBuddy 的魔法笔记已经提供一种有价值的交互:用户持续写
## 2. 产品定义 ## 2. 产品定义
会话监督是在明确范围和策略下,对普通 Conversation、Task、Job/Run 或 ExperimentRun 会话监督是在明确范围和策略下,对普通 Conversation、Task 或 Experiment 的可见事件
的可见事件进行独立观察,产生带证据的评论、告警和人工介入请求。个 Task 与唯一 进行独立观察,产生带证据的评论、告警和人工介入请求。个 Task 只关联一条 Conversation
Conversation 一对一绑定Job/Run 是内部执行和审计对象 一条 Conversation 可以承载多个 TaskJob/Run 是内部执行和审计对象,不作为当前 UI
监督目标。
它不是: 它不是:
@@ -55,7 +56,7 @@ Conversation 一对一绑定;Job/Run 是内部执行和审计对象。
## 4. 已确认的产品决策 ## 4. 已确认的产品决策
1. 监督默认关闭,由用户对 Conversation、Task、Job/Run 或实验显式启用。 1. 监督默认关闭,由用户对 Conversation、Task 或实验显式启用。
2. 监督只读取用户可查看的消息、工具事件、状态、指标、成果摘要和目标。 2. 监督只读取用户可查看的消息、工具事件、状态、指标、成果摘要和目标。
3. 不读取、推断或保存模型隐藏推理链。 3. 不读取、推断或保存模型隐藏推理链。
4. 每条重要判断必须引用具体消息、工具、步骤、指标或成果。 4. 每条重要判断必须引用具体消息、工具、步骤、指标或成果。
@@ -100,9 +101,7 @@ Conversation 一对一绑定;Job/Run 是内部执行和审计对象。
| --- | --- | --- | | --- | --- | --- |
| 普通会话 | 用户消息、助手回答、引用、工具事件 | 质量和证据评论 | | 普通会话 | 用户消息、助手回答、引用、工具事件 | 质量和证据评论 |
| Task | 目标、状态、Conversation、成果 | 偏离、循环和失败分析 | | Task | 目标、状态、Conversation、成果 | 偏离、循环和失败分析 |
| Job/Run | 触发、步骤、协议、预算、审批、指标 | 无人值守或内部执行关注 | | Experiment | 协议、变量、各候选执行、指标和证据 | 协议一致性、评估公平性与无结论提示 |
| 实验 Run | 协议、变量、指标、证据 | 协议一致性 |
| 实验整体 | 各 Run 结算和比较 | 评估公平性与无结论提示 |
每个监督会话只能绑定一个主对象,并继承其项目范围。 每个监督会话只能绑定一个主对象,并继承其项目范围。
@@ -254,7 +253,7 @@ type SupervisorDecision = {
### 13.1 工作栏监督栏目评论流 ### 13.1 工作栏监督栏目评论流
监督是助手工作栏中固定且始终可访问的栏目,不是只在聊天页面出现的附属面板。栏目默认 监督是助手工作栏中固定且始终可访问的栏目,不是只在聊天页面出现的附属面板。栏目默认
跟随当前会话,用户也可以固定到其他普通 Conversation、Task、Job/Run 或 ExperimentRun 跟随当前会话,用户也可以固定到其他普通 Conversation、Task 或 Experiment。
切换页面不会改变固定目标;目标失效时必须显示修复状态,不能静默回到当前会话。 切换页面不会改变固定目标;目标失效时必须显示修复状态,不能静默回到当前会话。
复用魔法笔记的体验方向: 复用魔法笔记的体验方向:
+1 -1
View File
@@ -12,5 +12,5 @@
## 阅读顺序 ## 阅读顺序
先阅读统一领域模型。其他三份文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。 先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。
若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。 若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。
+7 -5
View File
@@ -11,8 +11,8 @@
## 1. 产品定义 ## 1. 产品定义
Goal Task 是围绕可验证结果持续推进的 Task。它仍然只有一个 Task Conversation;每轮观察、 Goal Task 是围绕可验证结果持续推进的 Task。它只关联一条 Conversation,但该 Conversation
计划、行动和评估由 Job/Run 表达,不创建一串顶层 Task。 也可以承载其他 Task;每轮观察、计划、行动和评估由内部 Job/Run 表达,不创建一串顶层 Task。
## 2. 必要配置 ## 2. 必要配置
@@ -35,8 +35,9 @@ Observe Job
→ Complete, pause, revise or continue → Complete, pause, revise or continue
``` ```
循环内的所有 Job 共享 Task Conversation。只有协调器把有意义的阶段进展写入消息时间线, 循环内的所有 Job 通过所属 Task 写入同一关联 Conversation。只有协调器把有意义的阶段进展
避免每个内部步骤产生一条顶层任务或杂乱消息。 写入消息时间线,避免每个内部步骤产生一条顶层 Task 或杂乱消息。当前 UI 只显示 Goal Task
及其聚合状态,不显示 Job/Run 层级。
## 4. 完成和无进展 ## 4. 完成和无进展
@@ -47,7 +48,8 @@ Observe Job
## 5. 验收原则 ## 5. 验收原则
- [ ] Goal Task 只有一个 Task Conversation - [ ] Goal Task 只关联一条 ConversationConversation 可以承载其他 Task
- [ ] 循环步骤以 Job 表达,不创建顶层子 Task。 - [ ] 循环步骤以 Job 表达,不创建顶层子 Task。
- [ ] 当前 UI 不展示 Goal Task 内部 Job/Run 层级。
- [ ] 没有成功标准和停止条件时不能启用。 - [ ] 没有成功标准和停止条件时不能启用。
- [ ] 无进展和预算耗尽不会伪装为成功。 - [ ] 无进展和预算耗尽不会伪装为成功。
+12 -8
View File
@@ -29,7 +29,7 @@
## 3. 并行模型 ## 3. 并行模型
```text ```text
Task Conversation 关联 Conversation
└─ Coordinating Job └─ Coordinating Job
├─ Parallel Job A ├─ Parallel Job A
├─ Parallel Job B ├─ Parallel Job B
@@ -41,7 +41,8 @@ Task Conversation
- 每个 Job 有独立输入快照、状态、Run、预算和输出缓冲。 - 每个 Job 有独立输入快照、状态、Run、预算和输出缓冲。
- 并行 Job 不直接同时追加助手消息。 - 并行 Job 不直接同时追加助手消息。
- Aggregation Job 或 Task 协调器按确定顺序生成一条进展或结果消息。 - Aggregation Job 或 Task 协调器按确定顺序生成一条进展或结果消息。
- 用户可以查看每个 Job 的详细活动,但主 Conversation 保持可读。 - 用户可以按 Task 查看有界活动和聚合状态,但不选择或展开单个 Job;主 Conversation
保持可读。
## 4. Subjob ## 4. Subjob
@@ -78,21 +79,24 @@ queued → running → waiting_approval → completed
## 7. 界面 ## 7. 界面
Task Conversation 显示: 当前产品 UI 的对象层级止于 Task,不提供 Job/Subjob 树、独立页面或导航入口。
关联 Conversation 和 Task Center 只显示:
- 当前总体进展。 - 当前总体进展。
- 并行 Job 数量和聚合状态。 - 并行执行数量和聚合状态。
- 需要审批或用户输入的 Job - 需要审批或用户输入的 Task 状态
- 完成后的统一结果。 - 完成后的统一结果。
详细活动视图显示 Job 树、执行者、Runtime、耗时、预算、Run、错误和成果。Task Center 只显示 活动与 Runtime 可以按 Task 显示执行者、工具、耗时、预算、错误、审批和成果事件,但不把
Task 聚合状态,不展开 Job 树 Job、Subjob 或 Run 暴露为可选择、可展开或可操作的产品对象。内部标识只用于关联与审计
## 8. 验收标准 ## 8. 验收标准
- [ ] 并行 Job 共享所属 Task Conversation。 - [ ] 并行 Job 通过所属 Task 写入同一关联 Conversation。
- [ ] Job 不创建顶层 Task。 - [ ] Job 不创建顶层 Task。
- [ ] 并行输出不会无序污染消息时间线。 - [ ] 并行输出不会无序污染消息时间线。
- [ ] Subjob 深度、并发、预算和输出有界。 - [ ] Subjob 深度、并发、预算和输出有界。
- [ ] Subagent 失败能够返回部分输出和明确状态。 - [ ] Subagent 失败能够返回部分输出和明确状态。
- [ ] 父级取消传播到所有活动子级。 - [ ] 父级取消传播到所有活动子级。
- [ ] 当前 UI 只展示到 Task,不显示 Job/Subjob/Run 层级。
+9 -5
View File
@@ -9,7 +9,7 @@
| 版本 | 0.3 | | 版本 | 0.3 |
| 日期 | 2026-08-19 | | 日期 | 2026-08-19 |
| 适用产品 | GoodBuddy 桌面端 | | 适用产品 | 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. 文档目标 ## 1. 文档目标
@@ -202,9 +202,12 @@ GoodBuddy 应能够:
### 4.10 语音 ### 4.10 语音
- 首期提供按住说话和语音转文字 - 当前已提供点击开始、再次点击停止或到达 20 秒上限后停止的本地一次性语音听写
- 转写结果先进入可编辑输入框,不自动发送。 - 转写结果先进入可编辑输入框,不自动发送。
- 后续增加流式语音对话和文本转语音。 - 后续按[全双工实时语音交互设计](../architecture/full-duplex-voice-design.md)增加持续听说、
Barge-in、流式文本转语音、本地与云端显式语音引擎。
- 活动会话冻结引擎、Provider、模型、地域、数据位置和能力;引擎失败时明确停止或重试
当前选择,不在本地/云端、原生/模块化、语音/文本之间静默降级。
- 麦克风权限仅在可信主窗口、显式语音会话和用户操作后开启。 - 麦克风权限仅在可信主窗口、显式语音会话和用户操作后开启。
- 音频转写完成后默认删除。 - 音频转写完成后默认删除。
@@ -304,8 +307,9 @@ GoodBuddy 应能够:
### 阶段 5:语音 ### 阶段 5:语音
- 按住说话、转写适配器和可编辑转写。 - 以现有点击式一次性听写、本地转写适配器和可编辑转写作为实施基线
- 后续扩展实时语音与 TTS - 实现全双工会话契约、AudioWorklet 音频平面、Barge-in 和播放提交语义
- 接入本地模块化、本地原生和云端原生语音引擎;所有引擎均由用户显式选择,不静默降级。
### 阶段 6:专家与远程委派 ### 阶段 6:专家与远程委派
+79 -1
View File
@@ -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({ export default defineConfig({
main: { main: {
plugins: [ plugins: [
@@ -161,6 +236,9 @@ export default defineConfig({
worker: { worker: {
format: 'es' format: 'es'
}, },
plugins: [react()] build: {
manifest: true
},
plugins: [react(), rendererBundleModuleManifestPlugin()]
} }
}) })
+4 -1
View File
@@ -27,7 +27,7 @@
"test:watch": "vitest", "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", "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": "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": "npm run build:bundle && node build/run-deepseek-harness-utility-smoke.cjs",
"smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs", "smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs",
"release:notes:verify": "node build/release-notes.cjs", "release:notes:verify": "node build/release-notes.cjs",
@@ -208,6 +208,9 @@
"dmg" "dmg"
], ],
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"notarize": true,
"extendInfo": { "extendInfo": {
"NSMicrophoneUsageDescription": "GoodBuddy 需要访问麦克风,将语音转换为可编辑文字。" "NSMicrophoneUsageDescription": "GoodBuddy 需要访问麦克风,将语音转换为可编辑文字。"
} }
+66 -12
View File
@@ -1,14 +1,16 @@
# GoodBuddy 静态官网 # GoodBuddy 静态官网
`sites` 是无需构建步骤或额外依赖的静态官网源码,可直接托管整个目录。 `sites` 是无需构建步骤或额外依赖的中英文静态官网源码,可直接托管整个目录。
正式站点地址:<https://mesalogo.github.io/goodbuddy/> 正式站点地址:<https://mesalogo.github.io/goodbuddy/>
首页将 GoodBuddy 定位为“桌面助手|AI 编程工具台”,优先展示三大桌面 首页将 GoodBuddy 定位为“免注册、支持信创软硬件的一站式 AI 助手”,
系统与双架构下载入口、统一 Agent Runtime,以及知识库、魔法笔记、 优先展示三大桌面系统与双架构下载入口、统一 Agent Runtime,以及知识库、
智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于主要 魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于
功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾 主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾
对应的 Linux x64 / arm64 包。页面不重复设置底部下载推广区。 对应的 Linux x64 / arm64 包。页面不重复设置底部下载推广区。
英文页面位于 `en.html`,不展示信创适配文案,三个平台的下载按钮始终前往
GitHub 最新正式 Release。
首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、 首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、
柔和跟随光效和同步浮动标签;触屏设备保持静态布局,系统启用“减少动态 柔和跟随光效和同步浮动标签;触屏设备保持静态布局,系统启用“减少动态
效果”时不运行该交互。 效果”时不运行该交互。
@@ -22,6 +24,14 @@
设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件 设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件
或自定义域名 DNS 配置。 或自定义域名 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 ```powershell
node sites/scripts/validate.mjs node sites/scripts/validate.mjs
node --check sites/app.js 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 下载卡片分别提供处理器架构与安装包类型选择器, Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类型选择器,
选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、 选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、
格式无效或返回非受信任的官方下载地址时,按钮继续指向 GitHub 最新正式 过大、发生重定向、格式无效或任一字段返回非受信任的官方下载地址时,
Release 整组按钮会以 fail-closed 方式继续指向 GitHub 最新正式 Release,不会混用
部分 OSS 数据:
```text ```text
https://github.com/mesalogo/goodbuddy/releases/latest 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`:页面结构与简体中文内容 - `index.html`:页面结构与简体中文内容
- `en.html`:不包含信创适配文案的英文页面
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局 - `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/goodbuddy-light.png``assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标
- `assets/linux-plain.svg`Devicon v2.17.0 提供的黑白 Linux 图标,许可见 `assets/devicon-LICENSE` - `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`:发布索引行为回归测试
+207 -66
View File
@@ -5,6 +5,7 @@
const header = document.querySelector("[data-site-header]"); const header = document.querySelector("[data-site-header]");
const menuToggle = document.querySelector("[data-menu-toggle]"); const menuToggle = document.querySelector("[data-menu-toggle]");
const navigation = document.querySelector("[data-navigation]"); const navigation = document.querySelector("[data-navigation]");
const menuBackdrop = document.querySelector("[data-menu-backdrop]");
const themeToggle = document.querySelector("[data-theme-toggle]"); const themeToggle = document.querySelector("[data-theme-toggle]");
const themeColor = document.querySelector('meta[name="theme-color"]'); const themeColor = document.querySelector('meta[name="theme-color"]');
const tiltStage = document.querySelector("[data-tilt-stage]"); const tiltStage = document.querySelector("[data-tilt-stage]");
@@ -12,14 +13,30 @@
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)"); const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const mobileMenu = window.matchMedia("(max-width: 719px)");
const isEnglish = root.lang.toLowerCase().startsWith("en");
const releaseManifestUrl = const releaseManifestUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json"; "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
const releaseFallbackUrl = const releaseFallbackUrl =
"https://github.com/mesalogo/goodbuddy/releases/latest"; "https://github.com/mesalogo/goodbuddy/releases/latest";
const releaseStatus = document.querySelector("[data-release-status]"); const releaseRequestTimeoutMs = 10_000;
const releaseIndexApi = window.GoodBuddyReleaseIndex;
const downloadCards = [ const downloadCards = [
...document.querySelectorAll("[data-download-card]"), ...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 = { const platformNames = {
windows: "Windows", windows: "Windows",
macos: "macOS", macos: "macOS",
@@ -39,29 +56,93 @@
return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`; return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`;
}; };
const isTrustedReleaseUrl = (value) => { const listenMediaQuery = (query, listener) => {
try { if (typeof query.addEventListener === "function") {
const url = new URL(value); query.addEventListener("change", listener);
return ( } else if (typeof query.addListener === "function") {
url.protocol === "https:" && query.addListener(listener);
url.hostname === "goodbuddy.oss-cn-beijing.aliyuncs.com" &&
url.pathname.startsWith("/releases/")
);
} catch {
return false;
} }
}; };
const configureDownloads = (release) => { const readBoundedJson = async (response) => {
if ( const maximumBytes = releaseIndexApi?.maximumIndexBytes;
release?.formatVersion !== 1 || if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
release?.productName !== "GoodBuddy" || throw new Error("发布索引大小上限无效");
typeof release?.version !== "string" ||
!release?.targets
) {
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 updateCard = (card) => {
const platform = card.dataset.downloadCard; const platform = card.dataset.downloadCard;
const archSelect = card.querySelector("[data-download-arch]"); const archSelect = card.querySelector("[data-download-arch]");
@@ -75,26 +156,15 @@
!(link instanceof HTMLAnchorElement) || !(link instanceof HTMLAnchorElement) ||
!(meta instanceof HTMLElement) !(meta instanceof HTMLElement)
) { ) {
return; throw new Error("下载卡片结构无效");
} }
const target = release.targets[`${platform}-${archSelect.value}`]; const target = release.targets[`${platform}-${archSelect.value}`];
const file = target?.files?.[formatSelect.value]; const file = target?.files?.[formatSelect.value];
if ( if (!file) {
!file || throw new Error("下载选项不在已校验的发布索引中");
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;
} }
link.href = file.url;
const platformName = platformNames[platform] ?? platform; const platformName = platformNames[platform] ?? platform;
const archName = const archName =
platform === "macos" && archSelect.value === "arm64" platform === "macos" && archSelect.value === "arm64"
@@ -103,7 +173,11 @@
? "ARM64" ? "ARM64"
: "x64"; : "x64";
const formatName = formatNames[formatSelect.value] ?? formatSelect.value; const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
link.textContent = `下载 ${platformName} ${archName} ${formatName}`; setReleaseLink(
link,
file.url,
`下载 ${platformName} ${archName} ${formatName}`,
);
meta.textContent = meta.textContent =
`GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` + `GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`; `${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
@@ -112,35 +186,40 @@
for (const card of downloadCards) { for (const card of downloadCards) {
const selects = card.querySelectorAll("select"); const selects = card.querySelectorAll("select");
for (const select of selects) { for (const select of selects) {
select.addEventListener("change", () => updateCard(card)); select.addEventListener("change", () => {
try {
updateCard(card);
} catch {
setFallbackDownloads();
}
});
} }
updateCard(card); updateCard(card);
} }
if (releaseStatus instanceof HTMLElement) {
releaseStatus.textContent =
`官方下载源已就绪:GoodBuddy ${release.version}` +
"请选择处理器和安装包类型。";
releaseStatus.classList.add("is-ready");
}
}; };
const loadRelease = async () => { const loadRelease = async () => {
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
releaseRequestTimeoutMs,
);
try { try {
const response = await fetch(releaseManifestUrl, { const response = await fetch(releaseManifestUrl, {
cache: "no-store", cache: "no-store",
credentials: "omit", credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`发布索引请求失败:${response.status}`); throw new Error(`发布索引请求失败:${response.status}`);
} }
configureDownloads(await response.json()); configureDownloads(await readBoundedJson(response));
} catch { } catch {
if (releaseStatus instanceof HTMLElement) { setFallbackDownloads();
releaseStatus.textContent = } finally {
"官方下载源暂不可用,下载按钮已切换到 GitHub Release。"; window.clearTimeout(timeout);
releaseStatus.classList.add("is-fallback");
}
} }
}; };
@@ -157,7 +236,7 @@
root.dataset.theme = theme; root.dataset.theme = theme;
themeToggle?.setAttribute( themeToggle?.setAttribute(
"aria-label", "aria-label",
theme === "dark" ? "切换为浅色主题" : "切换为深色主题", theme === "dark" ? interfaceCopy.themeLight : interfaceCopy.themeDark,
); );
themeColor?.setAttribute("content", theme === "dark" ? "#07101f" : "#f6f8fb"); 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"); header?.classList.remove("is-menu-open");
menuToggle?.setAttribute("aria-expanded", "false"); 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 = () => { const setHeaderState = () => {
@@ -182,35 +310,37 @@
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light")); applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
setHeaderState(); setHeaderState();
void loadRelease(); if (!isEnglish) {
void loadRelease();
}
themeToggle?.addEventListener("click", () => { themeToggle?.addEventListener("click", () => {
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true); applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
}); });
systemTheme.addEventListener("change", (event) => {
if (!getSavedTheme()) {
applyTheme(event.matches ? "dark" : "light");
}
});
menuToggle?.addEventListener("click", () => { menuToggle?.addEventListener("click", () => {
const willOpen = !header?.classList.contains("is-menu-open"); if (header?.classList.contains("is-menu-open")) {
header?.classList.toggle("is-menu-open", willOpen); closeMenu();
menuToggle.setAttribute("aria-expanded", String(willOpen)); } else {
menuToggle.setAttribute("aria-label", willOpen ? "关闭导航" : "打开导航"); openMenu();
}
}); });
navigation?.addEventListener("click", (event) => { navigation?.addEventListener("click", (event) => {
if (event.target instanceof HTMLAnchorElement) { 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) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && header?.classList.contains("is-menu-open")) { if (event.key === "Escape" && header?.classList.contains("is-menu-open")) {
closeMenu(); 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 }); window.addEventListener("scroll", setHeaderState, { passive: true });
if (tiltStage instanceof HTMLElement && tiltCard instanceof HTMLElement) { if (tiltStage instanceof HTMLElement && tiltCard instanceof HTMLElement) {
@@ -266,8 +407,8 @@
tiltStage.addEventListener("pointermove", updateTilt, { passive: true }); tiltStage.addEventListener("pointermove", updateTilt, { passive: true });
tiltStage.addEventListener("pointerleave", resetTilt); tiltStage.addEventListener("pointerleave", resetTilt);
finePointer.addEventListener("change", resetTilt); listenMediaQuery(finePointer, resetTilt);
reducedMotion.addEventListener("change", resetTilt); listenMediaQuery(reducedMotion, resetTilt);
} }
const sections = [...document.querySelectorAll("main section[id]")]; const sections = [...document.querySelectorAll("main section[id]")];
+93
View File
@@ -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.
Binary file not shown.
+451
View File
@@ -0,0 +1,451 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="GoodBuddy is an all-in-one desktop AI assistant that requires no GoodBuddy account, with a unified Agent Runtime for direct models, OpenCode, Continue, and DeepSeek Harness."
/>
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/en.html" />
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
<meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy | All-in-one AI assistant, no account required</title>
<link
rel="icon"
href="./assets/goodbuddy-light.png"
type="image/png"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
href="./assets/goodbuddy-dark.png"
type="image/png"
media="(prefers-color-scheme: dark)"
/>
<link rel="stylesheet" href="./styles.css" />
<script src="./language.js"></script>
<script>
(() => {
try {
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme =
savedTheme === "light" || savedTheme === "dark"
? savedTheme
: systemDark
? "dark"
: "light";
} catch {
document.documentElement.dataset.theme = "light";
}
})();
</script>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<header class="site-header" data-site-header>
<div class="header-inner">
<a class="brand" href="#home" aria-label="GoodBuddy home">
<span class="brand-icon" aria-hidden="true">
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
</span>
<span>GoodBuddy</span>
</a>
<button
class="icon-button menu-toggle"
type="button"
aria-label="Open navigation"
aria-expanded="false"
aria-controls="site-navigation"
data-menu-toggle
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<nav class="site-navigation" id="site-navigation" aria-label="Main navigation" data-navigation>
<a href="#download">Download</a>
<a href="#features">Agent Runtime</a>
<a href="#assistant">Desktop assistant</a>
</nav>
<div class="header-actions">
<a
class="language-link"
href="./index.html?lang=zh"
lang="zh-CN"
hreflang="zh-CN"
aria-label="切换到中文"
data-language-link
>
</a>
<button class="icon-button" type="button" aria-label="Switch to dark theme" data-theme-toggle>
<svg class="theme-icon theme-icon--sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
<svg class="theme-icon theme-icon--moon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.4 14.6A8.5 8.5 0 0 1 9.4 3.6a8.5 8.5 0 1 0 11 11Z" />
</svg>
</button>
<a
class="button button--quiet header-github"
href="https://github.com/mesalogo/goodbuddy"
target="_blank"
rel="noreferrer"
>
GitHub
<span class="sr-only">(opens in a new window)</span>
</a>
</div>
</div>
</header>
<div class="menu-backdrop" aria-hidden="true" data-menu-backdrop></div>
<main id="main-content">
<section class="hero section" id="home" aria-labelledby="hero-title">
<div class="section-inner hero-grid">
<div class="hero-copy">
<div class="eyebrow">
<span class="status-dot" aria-hidden="true"></span>
Windows · macOS · Linux
</div>
<h1 id="hero-title">
No account required.<br />
Your all-in-one<br />
<span>AI assistant.</span>
</h1>
<p class="hero-lead">
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.
</p>
<div class="hero-actions">
<a class="button button--primary" href="#download">Download now</a>
<a class="button button--secondary" href="#features">Explore Agent Runtime</a>
</div>
<ul class="hero-facts" aria-label="Product highlights">
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
Three desktop platforms, x64 and arm64
</li>
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
Four Agent Runtime options
</li>
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
No GoodBuddy account required
</li>
</ul>
</div>
<div
class="product-stage"
role="img"
aria-label="GoodBuddy desktop app showing an AI coding task running through the unified Agent Runtime"
data-tilt-stage
>
<div class="stage-glow stage-glow--one"></div>
<div class="stage-glow stage-glow--two"></div>
<div class="app-window" data-tilt-card>
<div class="window-bar">
<div class="window-dots" aria-hidden="true"><span></span><span></span><span></span></div>
<div class="window-title">GoodBuddy</div>
<div class="window-status"><span></span> Runtime connected</div>
</div>
<div class="app-layout">
<aside class="app-sidebar" aria-hidden="true">
<div class="mini-brand">
<span class="brand-icon" aria-hidden="true">
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
</span>
</div>
<div class="side-item is-active"><span></span>Chat</div>
<div class="side-item"><span></span>Knowledge</div>
<div class="side-item"><span></span>Notes</div>
<div class="side-item"><span></span>Heartbeat</div>
<div class="side-item"><span></span>Run history</div>
<div class="sidebar-spacer"></div>
<div class="side-item"><span></span>Settings</div>
</aside>
<div class="app-content">
<div class="app-content-header">
<div>
<strong>Fix cross-platform build</strong>
<span>Project: Desktop client</span>
</div>
<div class="mode-pill">Continue · Execute</div>
</div>
<div class="message-area">
<div class="message message--user">Fix the build and validate all three desktop platforms.</div>
<div class="message message--assistant">
<div class="assistant-label">
<span class="assistant-avatar" aria-hidden="true">
<span class="brand-icon">
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
</span>
</span>
<strong>GoodBuddy</strong>
</div>
<p>Agent Runtime loaded the project, Skills, and controlled tools.</p>
<div class="tool-card">
<div class="tool-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7" /></svg>
</div>
<div><strong>Preparing environment</strong><span>Continue · Project scope · Controlled tools</span></div>
<span class="tool-state">Ready</span>
</div>
<div class="plan-lines" aria-hidden="true"><span></span><span></span><span></span></div>
</div>
</div>
<div class="composer">
<span>Describe your coding task…</span>
<div class="composer-actions"><span>Execute</span><b></b></div>
</div>
</div>
</div>
</div>
<div class="floating-card floating-card--approval">
<span class="floating-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" /><path d="m9 12 2 2 4-4" /></svg>
</span>
<span><strong>Unified Agent Runtime</strong><small>Direct models · OpenCode · Continue · DSH</small></span>
</div>
<div class="floating-card floating-card--scope">
<span class="scope-dot"></span>
<span><strong>Cross-platform</strong><small>Windows · macOS · Linux</small></span>
</div>
</div>
</div>
</section>
<section class="proof-strip" aria-label="Platform and Runtime support">
<div class="section-inner proof-grid">
<div><strong>4 Runtimes</strong><span>Multiple AI coding paths</span></div>
<div><strong>3 platforms</strong><span>Windows / macOS / Linux</span></div>
<div><strong>2 architectures</strong><span>x64 / arm64</span></div>
<div><strong>1 workspace</strong><span>Select, configure, run, audit</span></div>
</div>
</section>
<section class="section download-section" id="download" aria-label="Cross-platform downloads">
<div class="section-inner">
<div class="download-grid">
<article class="download-card">
<div class="platform-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 5 8-1v8H3V5Zm10-1.3L21 3v9h-8V3.7ZM3 14h8v8l-8-1v-7Zm10 0h8v9l-8-1v-8Z" />
</svg>
</div>
<div><h3>Windows</h3><p>x64 / arm64 · Installer / portable ZIP</p></div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
>Download from GitHub →<span class="sr-only">(opens in a new window)</span></a>
</article>
<article class="download-card">
<div class="platform-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M16.8 12.7c0-2.7 2.2-4 2.3-4.1A5 5 0 0 0 15.2 6c-1.7-.2-3.2 1-4.1 1-.9 0-2.2-1-3.6-1-1.8 0-3.5 1.1-4.5 2.7-2 3.5-.5 8.7 1.4 11.5.9 1.4 2 2.8 3.5 2.7 1.4 0 1.9-.9 3.7-.9 1.7 0 2.2.9 3.7.9s2.5-1.4 3.4-2.7a10 10 0 0 0 1.6-3.3 4.6 4.6 0 0 1-3.5-4.2ZM14.1 4.3A4.7 4.7 0 0 0 15.2 1a4.8 4.8 0 0 0-3.1 1.6A4.4 4.4 0 0 0 11 5.8c1.2.1 2.3-.5 3.1-1.5Z" />
</svg>
</div>
<div><h3>macOS</h3><p>Apple silicon / Intel · DMG / ZIP</p></div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
>Download from GitHub →<span class="sr-only">(opens in a new window)</span></a>
</article>
<article class="download-card">
<div class="platform-icon">
<img src="./assets/linux-plain.svg" alt="" />
</div>
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
>Download from GitHub →<span class="sr-only">(opens in a new window)</span></a>
</article>
</div>
</div>
</section>
<section class="section features-section" id="features" aria-labelledby="features-title">
<div class="section-inner">
<div class="section-heading">
<div>
<p class="kicker">Unified Agent Runtime</p>
<h2 id="features-title">Different tools, one workflow</h2>
</div>
<p>
Bring Runtime selection, model connections, Skills, MCP, and permissions into one desktop interface.
</p>
</div>
<div class="feature-grid">
<article class="feature-card feature-card--wide feature-card--accent">
<div class="feature-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" />
</svg>
</div>
<span class="feature-number">01</span>
<h3>One entry point for multiple Agent Runtimes</h3>
<p>Choose direct models, OpenCode, Continue, or DeepSeek Harness for each task without learning a new entry point.</p>
<div class="provider-pills" aria-label="Supported Agent Runtimes">
<span>Direct models</span><span>OpenCode</span><span>Continue</span><span>DeepSeek Harness</span>
</div>
</article>
<article class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5h16v14H4zM8 22h8M12 19v3" />
<path d="M7 9h2M11 9h2M15 9h2M7 13h10" />
</svg>
</div>
<span class="feature-number">02</span>
<h3>Built for desktop platforms</h3>
<p>Windows, macOS, and Linux releases are available for both x64 and arm64.</p>
</article>
<article class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 4h14v16H5zM8 8h8M8 12h5" />
<path d="m14 16 2 2 3-4" />
</svg>
</div>
<span class="feature-number">03</span>
<h3>Lower setup overhead</h3>
<p>Select the Runtime, model, work mode, and project in a graphical interface instead of memorizing commands.</p>
</article>
<article class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" />
<path d="M9 12h6M12 9v6" />
</svg>
</div>
<span class="feature-number">04</span>
<h3>Shared capabilities, preserved boundaries</h3>
<p>Skills, MCP, and tools follow each Runtime. Ask stays read-only, while Execute remains approval-controlled and auditable.</p>
<div class="mode-row" aria-label="Two work modes">
<span>Ask <small>Read-only</small></span>
<span class="is-accent">Execute <small>Controlled</small></span>
</div>
</article>
<article class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6.5C4 5.1 5.1 4 6.5 4H10l2 2h5.5C18.9 6 20 7.1 20 8.5v9c0 1.4-1.1 2.5-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-11Z" />
<path d="M8 11h8M8 15h5" />
</svg>
</div>
<span class="feature-number">05</span>
<h3>Project context in one place</h3>
<p>Organize conversations, knowledge, tasks, and run history by project without losing context when switching Runtime.</p>
</article>
<article class="feature-card feature-card--wide">
<div class="feature-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 13h3l2-6 4 12 2-6h5" />
<path d="M4 4h16v16H4z" />
</svg>
</div>
<span class="feature-number">06</span>
<h3>Trace every run from action to result</h3>
<p>Review tool calls, cancellation, timeouts, token usage, and run history in one place.</p>
</article>
</div>
</div>
</section>
<section class="section assistant-section" id="assistant" aria-labelledby="assistant-title">
<div class="section-inner assistant-grid">
<div class="assistant-intro">
<div class="assistant-mark" aria-hidden="true">
<span class="brand-icon">
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
</span>
</div>
<p class="kicker">Desktop assistant</p>
<h2 id="assistant-title">Conversations, knowledge, notes, and tasks on your desktop</h2>
<p>
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.
</p>
<a class="text-link" href="#download">
Choose your desktop release
<span aria-hidden="true"></span>
</a>
</div>
<div class="assistant-list">
<article>
<span class="assistant-number">01</span>
<div><h3>Turn sources into searchable knowledge</h3><p>Import files, folders, and web pages, then search with full text, vectors, and a knowledge graph.</p></div>
</article>
<article>
<span class="assistant-number">02</span>
<div><h3>Notes, to-dos, and long-term follow-up</h3><p>Magic Notes captures ideas and tasks, while Heartbeat reviews progress, builds memory, and proposes next steps.</p></div>
</article>
<article>
<span class="assistant-number">03</span>
<div><h3>Understand what is on your desktop</h3><p>Add files, screenshots, app windows, clipboard content, and offline voice input when needed.</p></div>
</article>
<article>
<span class="assistant-number">04</span>
<div><h3>Keep working away from your computer</h3><p>Connect messaging channels to separate conversations and hand tasks to GoodBuddy on your desktop.</p></div>
</article>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="section-inner footer-inner">
<a class="brand brand--footer" href="#home" aria-label="Back to GoodBuddy home">
<span class="brand-icon" aria-hidden="true">
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
</span>
<span>GoodBuddy</span>
</a>
<p>No account required. One AI assistant for everything.</p>
<div class="footer-links">
<a href="#download">Download</a>
<a href="#features">Agent Runtime</a>
<a href="#assistant">Desktop assistant</a>
<a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer">
GitHub<span class="sr-only">(opens in a new window)</span>
</a>
</div>
<small>© <span data-current-year></span> GoodBuddy. This site uses no third-party analytics.</small>
</div>
</footer>
<script src="./app.js"></script>
</body>
</html>
+30 -14
View File
@@ -5,11 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta <meta
name="description" name="description"
content="GoodBuddy 是跨平台桌面助手与 AI 编程工具台,以统一 Agent Runtime 连接直连模型、OpenCode、Continue 与 DeepSeek Harness。" content="GoodBuddy 是免注册、支持信创软硬件的一站式 AI 助手,以统一 Agent Runtime 连接直连模型、OpenCode、Continue 与 DeepSeek Harness。"
/> />
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" /> <link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
<meta name="theme-color" content="#f6f8fb" /> <meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy桌面助手与 AI 编程工具台</title> <title>GoodBuddy免注册、支持信创软硬件的一站式 AI 助手</title>
<link <link
rel="icon" rel="icon"
href="./assets/goodbuddy-light.png" href="./assets/goodbuddy-light.png"
@@ -23,6 +26,7 @@
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<link rel="stylesheet" href="./styles.css" /> <link rel="stylesheet" href="./styles.css" />
<script src="./language.js"></script>
<script> <script>
(() => { (() => {
try { try {
@@ -73,6 +77,16 @@
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<a
class="language-link"
href="./en.html?lang=en"
lang="en"
hreflang="en"
aria-label="Switch to English"
data-language-link
>
EN
</a>
<button class="icon-button" type="button" aria-label="切换为深色主题" data-theme-toggle> <button class="icon-button" type="button" aria-label="切换为深色主题" data-theme-toggle>
<svg class="theme-icon theme-icon--sun" viewBox="0 0 24 24" aria-hidden="true"> <svg class="theme-icon theme-icon--sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" /> <circle cx="12" cy="12" r="4" />
@@ -94,6 +108,7 @@
</div> </div>
</div> </div>
</header> </header>
<div class="menu-backdrop" aria-hidden="true" data-menu-backdrop></div>
<main id="main-content"> <main id="main-content">
<section class="hero section" id="home" aria-labelledby="hero-title"> <section class="hero section" id="home" aria-labelledby="hero-title">
@@ -103,10 +118,14 @@
<span class="status-dot" aria-hidden="true"></span> <span class="status-dot" aria-hidden="true"></span>
Windows · macOS · Linux Windows · macOS · Linux
</div> </div>
<h1 id="hero-title">桌面助手,<br /><span>也是 AI 编程工具台。</span></h1> <h1 id="hero-title">
免注册,<br />
支持信创软硬件的<br />
<span>一站式 AI 助手。</span>
</h1>
<p class="hero-lead"> <p class="hero-lead">
GoodBuddy 管理对话、知识、笔记与任务,也通过独创的统一 Agent Runtime 作为桌面助手与 AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
接入直连模型、OpenCode、Continue 和 DeepSeek Harness。 也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
无需反复配置命令行,选择工具和项目即可开始。 无需反复配置命令行,选择工具和项目即可开始。
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
@@ -124,7 +143,7 @@
</li> </li>
<li> <li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
图形化配置,执行仍受控 无需注册 GoodBuddy 账号
</li> </li>
</ul> </ul>
</div> </div>
@@ -155,7 +174,7 @@
<div class="side-item"><span></span>知识库</div> <div class="side-item"><span></span>知识库</div>
<div class="side-item"><span></span>魔法笔记</div> <div class="side-item"><span></span>魔法笔记</div>
<div class="side-item"><span></span>智能心跳</div> <div class="side-item"><span></span>智能心跳</div>
<div class="side-item"><span></span>任务与活动</div> <div class="side-item"><span></span>运行记录</div>
<div class="sidebar-spacer"></div> <div class="sidebar-spacer"></div>
<div class="side-item"><span></span>设置</div> <div class="side-item"><span></span>设置</div>
</aside> </aside>
@@ -324,13 +343,9 @@
</p> </p>
</article> </article>
</div> </div>
<div class="download-release-status" data-release-status role="status">
正在连接官方下载源…
</div>
<aside class="domestic-support" aria-labelledby="domestic-support-title"> <aside class="domestic-support" aria-labelledby="domestic-support-title">
<div> <div>
<p class="kicker">国产化适配</p> <p class="kicker">信创软硬件支持</p>
<h3 id="domestic-support-title">统信 UOS、银河麒麟,覆盖国产 x64 与 ARM64</h3> <h3 id="domestic-support-title">统信 UOS、银河麒麟,覆盖国产 x64 与 ARM64</h3>
<p>使用对应的 Linux x64 或 arm64 安装包。</p> <p>使用对应的 Linux x64 或 arm64 安装包。</p>
</div> </div>
@@ -435,7 +450,7 @@
</div> </div>
<span class="feature-number">06</span> <span class="feature-number">06</span>
<h3>从运行到结果,全程可追踪</h3> <h3>从运行到结果,全程可追踪</h3>
<p>统一查看工具调用、取消、超时、Token 用量和活动记录,知道 Runtime 做了什么。</p> <p>统一查看工具调用、取消、超时、Token 用量和运行记录,知道 Runtime 做了什么。</p>
</article> </article>
</div> </div>
</div> </div>
@@ -496,7 +511,7 @@
</span> </span>
<span>GoodBuddy</span> <span>GoodBuddy</span>
</a> </a>
<p>桌面助手|AI 编程工具台</p> <p>免注册、支持信创软硬件的一站式 AI 助手</p>
<div class="footer-links"> <div class="footer-links">
<a href="#download">下载</a> <a href="#download">下载</a>
<a href="#features">Agent Runtime</a> <a href="#features">Agent Runtime</a>
@@ -509,6 +524,7 @@
</div> </div>
</footer> </footer>
<script src="./release-index.js"></script>
<script src="./app.js"></script> <script src="./app.js"></script>
</body> </body>
</html> </html>
+49
View File
@@ -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}`);
}
})();
+206
View File
@@ -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,
});
})();
+151
View File
@@ -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(
`<!doctype html>
<html lang="en">
<head><meta name="theme-color" content="#f6f8fb"></head>
<body>
<a id="skip" href="#main">Skip</a>
<header data-site-header>
<button
type="button"
aria-label="Open navigation"
aria-expanded="false"
data-menu-toggle
>Menu</button>
<nav data-navigation>
<a id="first-nav-link" href="#download">Download</a>
<a href="#features">Features</a>
</nav>
<button type="button" data-theme-toggle>Theme</button>
</header>
<div data-menu-backdrop></div>
<main id="main">
<section id="download"></section>
<section id="features"></section>
</main>
<footer id="footer">Footer</footer>
<span data-current-year></span>
</body>
</html>`,
{
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();
},
);
}
+204
View File
@@ -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";
});
});
+449 -46
View File
@@ -7,12 +7,19 @@ const errors = [];
const requiredFiles = [ const requiredFiles = [
"index.html", "index.html",
"en.html",
"styles.css", "styles.css",
"app.js", "app.js",
"language.js",
"release-index.js",
"assets/goodbuddy-light.png", "assets/goodbuddy-light.png",
"assets/goodbuddy-dark.png", "assets/goodbuddy-dark.png",
"assets/linux-plain.svg", "assets/linux-plain.svg",
"assets/devicon-LICENSE", "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", "README.md",
]; ];
@@ -42,34 +49,92 @@ await Promise.all(
}), }),
); );
const [html, css, appJs] = await Promise.all([ const [html, englishHtml, css, appJs, languageJs, releaseIndexJs, fontLicense] =
readSiteFile("index.html"), await Promise.all([
readSiteFile("styles.css"), readSiteFile("index.html"),
readSiteFile("app.js"), 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 [ for (const [relativePath, content] of [
["index.html", html], ["index.html", html],
["en.html", englishHtml],
["styles.css", css], ["styles.css", css],
["app.js", appJs], ["app.js", appJs],
["language.js", languageJs],
["release-index.js", releaseIndexJs],
]) { ]) {
report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`); report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`);
report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`); report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`);
} }
report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN"); report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN");
report(/<html\s+lang="en">/.test(englishHtml), "英文页面语言必须是 en");
report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息"); report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息");
report(/<meta\s+name="viewport"/.test(englishHtml), "英文页面缺少 viewport 元信息");
report( report(
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"\s*\/>/.test( /<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"\s*\/>/.test(
html, html,
), ),
"canonical 地址必须指向 GitHub Pages 正式站点", "canonical 地址必须指向 GitHub Pages 正式站点",
); );
report(
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/en\.html"\s*\/>/.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\s+src="\.\/language\.js"><\/script>/.test(content),
`${relativePath} 缺少语言选择脚本`,
);
}
report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1"); report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1");
report(
(englishHtml.match(/<h1[\s>]/g) ?? []).length === 1,
"英文页面必须且只能包含一个 h1",
);
report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接"); report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接");
report(
/class="skip-link"\s+href="#main-content"/.test(englishHtml),
"英文页面缺少跳到主要内容链接",
);
report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域"); report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域");
report(/<main\s+id="main-content">/.test(englishHtml), "英文页面缺少 main-content 主区域");
report(/aria-label="主导航"/.test(html), "主导航缺少可访问名称"); report(/aria-label="主导航"/.test(html), "主导航缺少可访问名称");
report(/aria-label="Main navigation"/.test(englishHtml), "英文主导航缺少可访问名称");
report(/data-theme-toggle/.test(html), "缺少主题切换控件"); 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( report(
(html.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5, (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, (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(html), "官网不得使用自绘品牌标志");
report(!/class="brand-mark"/.test(englishHtml), "英文官网不得使用自绘品牌标志");
report(/data-tilt-stage/.test(html), "首屏产品界面缺少倾斜交互区域"); report(/data-tilt-stage/.test(html), "首屏产品界面缺少倾斜交互区域");
report(/data-tilt-stage/.test(englishHtml), "英文首屏产品界面缺少倾斜交互区域");
report(/data-tilt-card/.test(html), "首屏产品界面缺少倾斜卡片"); report(/data-tilt-card/.test(html), "首屏产品界面缺少倾斜卡片");
report(/data-tilt-card/.test(englishHtml), "英文首屏产品界面缺少倾斜卡片");
report(/prefers-reduced-motion:\s*reduce/.test(css), "缺少减少动态效果规则"); report(/prefers-reduced-motion:\s*reduce/.test(css), "缺少减少动态效果规则");
report(/\[data-theme="dark"\]/.test(css), "缺少深色主题令牌"); report(/\[data-theme="dark"\]/.test(css), "缺少深色主题令牌");
report(/--scene-tilt-x/.test(css), "缺少产品界面横向倾斜变量"); report(/--scene-tilt-x/.test(css), "缺少产品界面横向倾斜变量");
@@ -90,12 +166,90 @@ report(
"浮动标签必须跟随产品界面倾斜", "浮动标签必须跟随产品界面倾斜",
); );
report(/requestAnimationFrame/.test(appJs), "产品界面倾斜交互必须按帧更新"); 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"]) { for (const breakpoint of ["1199px", "959px", "719px"]) {
report(css.includes(`max-width: ${breakpoint}`), `缺少 ${breakpoint} 响应式断点`); report(css.includes(`max-width: ${breakpoint}`), `缺少 ${breakpoint} 响应式断点`);
} }
const requiredCopy = [ const requiredCopy = [
"免注册",
"支持信创软硬件的一站式 AI 助手",
"桌面助手", "桌面助手",
"AI 编程工具台", "AI 编程工具台",
"Windows、macOS、Linux", "Windows、macOS、Linux",
@@ -116,11 +270,37 @@ for (const copy of requiredCopy) {
report(html.includes(copy), `缺少准确文案:${copy}`); report(html.includes(copy), `缺少准确文案:${copy}`);
} }
const htmlWithoutSvg = html.replace(/<svg\b[\s\S]*?<\/svg>/g, ""); const requiredEnglishCopy = [
report( "No account required.",
!/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg), "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(/<svg\b[\s\S]*?<\/svg>/g, "");
report(
!/\bv?\d+\.\d+\.\d+\b/.test(contentWithoutSvg),
`${relativePath} 正文不得写入需要随发布更新的具体版本号`,
);
}
const releaseLinks = [ const releaseLinks = [
...html.matchAll(/<a\b(?=[^>]*data-release-link)[^>]*>/g), ...html.matchAll(/<a\b(?=[^>]*data-release-link)[^>]*>/g),
@@ -146,7 +326,34 @@ report(
(html.match(/data-download-format/g) ?? []).length === 3, (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(
/<a\b(?=[^>]*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+src="\.\/release-index\.js"><\/script>\s*<script\s+src="\.\/app\.js"><\/script>/.test(
html,
),
"中文页面必须在交互脚本前加载发布索引校验器",
);
report(
!/release-index\.js/.test(englishHtml),
"英文页面不得加载动态发布索引校验器",
);
report( report(
appJs.includes( appJs.includes(
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json", "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json",
@@ -158,52 +365,248 @@ report(
"官网必须保留 GitHub Release 回退地址", "官网必须保留 GitHub Release 回退地址",
); );
report(/credentials:\s*"omit"/.test(appJs), "OSS 发布索引请求不得携带凭据"); report(/credentials:\s*"omit"/.test(appJs), "OSS 发布索引请求不得携带凭据");
report(/isTrustedReleaseUrl/.test(appJs), "OSS 下载链接缺少来源校验"); report(/redirect:\s*"error"/.test(appJs), "OSS 发布索引请求不得跟随重定向");
report(/referrerPolicy:\s*"no-referrer"/.test(appJs), "OSS 发布索引请求必须禁用来源信息");
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); report(/maximumIndexBytes/.test(appJs), "OSS 发布索引响应缺少大小上限");
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); report(/response\.body\.getReader\(\)/.test(appJs), "OSS 发布索引响应必须在读取时限制大小");
report(duplicateIds.length === 0, `存在重复 id${[...new Set(duplicateIds)].join(", ")}`); report(/AbortController/.test(appJs), "OSS 发布索引请求必须设置超时取消");
report(
const attributes = [...html.matchAll(/\s(?:href|src)="([^"]+)"/g)].map((match) => match[1]); /validateReleaseIndex\(payload\)/.test(appJs),
const fragmentLinks = attributes.filter((value) => value.startsWith("#") && value.length > 1); "动态下载链接必须先通过完整发布索引校验",
);
for (const fragment of fragmentLinks) { report(
report(ids.includes(fragment.slice(1)), `页内链接目标不存在:${fragment}`); /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}`);
} }
for (const menuRule of [
const localAssets = attributes.filter( "isolatedMenuContent = new Map()",
(value) => "element === menuBackdrop",
!value.startsWith("#") && "element.inert = true",
!value.startsWith("https://") && "element.inert = wasInert",
!value.startsWith("http://") && 'navigation?.querySelector("a")?.focus()',
!value.startsWith("mailto:") && "closeMenu({ restoreFocus: false })",
!value.startsWith("data:"), '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) { for (const [relativePath, content] of [
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, ""); ["index.html", html],
try { ["en.html", englishHtml],
const assetStats = await stat(path.join(siteRoot, cleanAsset)); ]) {
report(assetStats.isFile(), `本地资源不是文件:${asset}`); const menuButton = content.match(
} catch { /<button\b(?=[^>]*data-menu-toggle)[^>]*>/,
errors.push(`本地资源不存在:${asset}`); )?.[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\s+class="menu-backdrop"\s+aria-hidden="true"\s+data-menu-backdrop><\/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(/<option\s+value="([^"]+)"/g),
].map((match) => 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(/<select\b[^>]*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(/<a\b(?=[^>]*target="_blank")[^>]*>/g),
].map((match) => match[0]);
for (const link of externalBlankLinks) {
report(
/rel="[^"]*noreferrer[^"]*"/.test(link),
`${relativePath} 新窗口链接缺少 noreferrer${link}`,
);
} }
} }
const externalBlankLinks = [ const cssAssets = [...css.matchAll(/url\(["']?([^"')]+)["']?\)/g)].map(
...html.matchAll(/<a\b(?=[^>]*target="_blank")[^>]*>/g), (match) => match[1],
].map((match) => match[0]); );
for (const asset of cssAssets) {
for (const link of externalBlankLinks) { if (/^(?:data:|https?:)/u.test(asset)) {
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `新窗口链接缺少 noreferrer${link}`); 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( report(
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html), !/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(
`${html}\n${englishHtml}`,
),
"具体安装资产链接应由 OSS 发布索引动态提供", "具体安装资产链接应由 OSS 发布索引动态提供",
); );
report( 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; process.exitCode = 1;
} else { } else {
console.log( console.log(
`官网静态检查通过:${requiredFiles.length} 个必需文件,${ids.length} 个唯一 id${localAssets.length} 个本地资源引用。`, `官网静态检查通过:${requiredFiles.length} 个必需文件,${totalIds} 个唯一 id${totalLocalAssets} 个本地资源引用。`,
); );
} }
+132 -29
View File
@@ -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 { :root {
color-scheme: light; color-scheme: light;
--surface-canvas: #f6f8fb; --surface-canvas: #f6f8fb;
@@ -8,11 +20,11 @@
--surface-overlay: rgba(255, 255, 255, 0.82); --surface-overlay: rgba(255, 255, 255, 0.82);
--text-primary: #10213a; --text-primary: #10213a;
--text-secondary: #4f6178; --text-secondary: #4f6178;
--text-muted: #738198; --text-muted: #5b6c82;
--text-on-accent: #ffffff; --text-on-accent: #ffffff;
--text-on-inverse: #f7faff; --text-on-inverse: #f7faff;
--border-default: #d6dee9; --border-default: #d6dee9;
--border-control: #bcc8d7; --border-control: #7c8b9e;
--border-subtle: #e6ebf2; --border-subtle: #e6ebf2;
--accent: #0877e8; --accent: #0877e8;
--accent-hover: #0567ca; --accent-hover: #0567ca;
@@ -59,8 +71,9 @@
--z-header: 20; --z-header: 20;
--z-menu: 30; --z-menu: 30;
font-family: font-family:
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter Variable", "PingFang SC", "Microsoft YaHei UI",
"PingFang SC", "Microsoft YaHei", sans-serif; "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", system-ui,
sans-serif;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
@@ -327,13 +340,36 @@ p {
gap: var(--space-2); 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 { .icon-button {
display: inline-grid; display: inline-grid;
width: 40px; width: 40px;
height: 40px; height: 40px;
padding: 0; padding: 0;
place-items: center; place-items: center;
border: 1px solid var(--border-default); border: 1px solid var(--border-control);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--surface-raised); background: var(--surface-raised);
color: var(--text-primary); color: var(--text-primary);
@@ -379,6 +415,10 @@ p {
display: none; display: none;
} }
.menu-backdrop {
display: none;
}
.button { .button {
display: inline-flex; display: inline-flex;
min-height: 44px; min-height: 44px;
@@ -430,7 +470,7 @@ p {
.button--quiet { .button--quiet {
min-height: 40px; min-height: 40px;
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
border-color: var(--border-default); border-color: var(--border-control);
background: var(--surface-raised); background: var(--surface-raised);
color: var(--text-primary); color: var(--text-primary);
} }
@@ -511,7 +551,7 @@ p {
.hero h1 { .hero h1 {
max-width: 760px; max-width: 760px;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
font-size: var(--font-page-title); font-size: clamp(2.45rem, 4vw, 3.5rem);
letter-spacing: -0.065em; letter-spacing: -0.065em;
} }
@@ -1351,28 +1391,6 @@ p {
text-align: left; 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 { .domestic-support {
display: grid; display: grid;
grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.1fr); grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.1fr);
@@ -1724,6 +1742,8 @@ p {
} }
.header-inner { .header-inner {
position: relative;
z-index: 1;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
min-height: 64px; min-height: 64px;
} }
@@ -1763,6 +1783,14 @@ p {
box-shadow: var(--shadow-dialog); 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 { .site-header.is-menu-open .site-navigation {
display: flex; display: flex;
} }
@@ -1972,3 +2000,78 @@ p {
transform: none !important; 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;
}
}
@@ -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()
})
})
+193
View File
@@ -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<ProcessExitResult> {
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<void> {
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<void> {
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
}
@@ -1124,6 +1124,263 @@ describe('ContinueHostAdapter', () => {
).rejects.toThrow('流式事件超过安全限制') ).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<void>((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 () => { it('uses auto mode and returns audit metadata for agent tools', async () => {
const distribution = await createDistribution() const distribution = await createDistribution()
let launchArgs: string[] = [] let launchArgs: string[] = []
+105 -33
View File
@@ -50,6 +50,7 @@ import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { readBoundedResponseText } from './bounded-response' import { readBoundedResponseText } from './bounded-response'
import { scopedReadToolNames } from '../../shared/scoped-data-tools' import { scopedReadToolNames } from '../../shared/scoped-data-tools'
import { readBoundedFile } from '../workspace-file-access' import { readBoundedFile } from '../workspace-file-access'
import { terminateProcessTreeAndWait } from './child-process-termination'
const supportedVersion = '1.5.47' const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([ const supportedBundleHashes = new Set([
@@ -65,6 +66,7 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
const maximumStreamEvents = 5_000 const maximumStreamEvents = 5_000
const maximumStreamEventBytes = 2 * 1024 * 1024 const maximumStreamEventBytes = 2 * 1024 * 1024
const maximumToolCalls = 100
const maximumExecutionMilliseconds = 10 * 60_000 const maximumExecutionMilliseconds = 10 * 60_000
const knowledgeMcpName = 'goodbuddy-knowledge' const knowledgeMcpName = 'goodbuddy-knowledge'
const customMcpName = 'goodbuddy-custom-mcp' const customMcpName = 'goodbuddy-custom-mcp'
@@ -239,6 +241,13 @@ export type ContinueHostAdapterOptions = {
skillPackages?: RuntimeSkillPackage[] skillPackages?: RuntimeSkillPackage[]
} }
export type ContinueHostAdapterDependencies = {
terminateProcessTree: typeof terminateProcessTreeAndWait
maximumStreamEvents: number
maximumStreamEventBytes: number
maximumToolCalls: number
}
export type ContinueHostRunOptions = { export type ContinueHostRunOptions = {
workMode?: 'ask' | 'execute' workMode?: 'ask' | 'execute'
images?: AgentImage[] images?: AgentImage[]
@@ -504,8 +513,12 @@ export type ContinueHostChild = {
) => unknown ) => unknown
} | null } | null
once: ( once: (
event: 'error', event: 'error' | 'close',
listener: (error: Error) => void listener: (error: Error | number | null) => void
) => unknown
removeListener?: (
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown ) => unknown
kill: (signal?: NodeJS.Signals) => unknown kill: (signal?: NodeJS.Signals) => unknown
} }
@@ -792,6 +805,10 @@ function extractUsageDelta(
export class ContinueHostAdapter { export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>() private readonly children = new Set<ContinueHostChild>()
private readonly childTerminations = new WeakMap<
ContinueHostChild,
Promise<void>
>()
private readonly pendingQuestions = new Map< private readonly pendingQuestions = new Map<
string, string,
{ {
@@ -802,7 +819,20 @@ export class ContinueHostAdapter {
>() >()
private preparation?: Promise<PreparedHost> private preparation?: Promise<PreparedHost>
constructor(private readonly options: ContinueHostAdapterOptions) {} private readonly dependencies: ContinueHostAdapterDependencies
constructor(
private readonly options: ContinueHostAdapterOptions,
dependencies: Partial<ContinueHostAdapterDependencies> = {}
) {
this.dependencies = {
terminateProcessTree: terminateProcessTreeAndWait,
maximumStreamEvents,
maximumStreamEventBytes,
maximumToolCalls,
...dependencies
}
}
private async prepare(): Promise<PreparedHost> { private async prepare(): Promise<PreparedHost> {
if (!isAbsolute(this.options.cacheRoot)) { if (!isAbsolute(this.options.cacheRoot)) {
@@ -1515,17 +1545,20 @@ export class ContinueHostAdapter {
child.stderr?.on('data', (chunk: Buffer | string) => { child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk) stderrBytes += Buffer.byteLength(chunk)
if (stderrBytes > 64 * 1024) { if (stderrBytes > 64 * 1024) {
this.terminate(child) void this.terminate(child)
} }
}) })
const abort = (): void => { const abort = (): void => {
this.terminate(child) void this.terminate(child)
} }
signal.addEventListener('abort', abort, { once: true }) signal.addEventListener('abort', abort, { once: true })
let observedTools: ContinueHostTool[] = [] let observedTools: ContinueHostTool[] = []
const reportedQuestionIds = new Set<string>() const reportedQuestionIds = new Set<string>()
let streamedText = false let streamedText = false
let streamEventCount = 0
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
let executionTimeoutSignal: AbortSignal | undefined let executionTimeoutSignal: AbortSignal | undefined
try { try {
const initialState = await this.waitForStartup( const initialState = await this.waitForStartup(
@@ -1585,17 +1618,55 @@ export class ContinueHostAdapter {
if (state.goodbuddyEventsOverflow) { if (state.goodbuddyEventsOverflow) {
throw new Error('Continue 宿主流式事件超过安全限制') throw new Error('Continue 宿主流式事件超过安全限制')
} }
const streamEventBytes = Buffer.byteLength( const streamEvents = state.goodbuddyEvents ?? []
JSON.stringify(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 宿主流式事件超过安全限制') 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 = mergeContinueTools(
observedTools, observedTools,
extractContinueTools(state.session.history, startIndex) historyTools
) )
for (const event of state.goodbuddyEvents ?? []) { for (const event of streamEvents) {
if (event.type === 'text') { if (event.type === 'text') {
streamedText = true streamedText = true
await runOptions.onEvent?.(event) await runOptions.onEvent?.(event)
@@ -1653,7 +1724,10 @@ export class ContinueHostAdapter {
} }
const pending = state.pendingPermission const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) { if (pending && !handledPermissionIds.has(pending.requestId)) {
if (handledPermissionIds.size >= 100) { if (
handledPermissionIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个') throw new Error('Continue 单次运行的工具调用超过 100 个')
} }
handledPermissionIds.add(pending.requestId) handledPermissionIds.add(pending.requestId)
@@ -1667,9 +1741,14 @@ export class ContinueHostAdapter {
if ( if (
!observedTools.some((tool) => tool.callId === pendingCallId) !observedTools.some((tool) => tool.callId === pendingCallId)
) { ) {
if (observedTools.length >= 100) { if (
!observedToolCallIds.has(pendingCallId) &&
observedToolCallIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个') throw new Error('Continue 单次运行的工具调用超过 100 个')
} }
observedToolCallIds.add(pendingCallId)
observedTools = [ observedTools = [
...observedTools, ...observedTools,
{ {
@@ -1771,7 +1850,7 @@ export class ContinueHostAdapter {
signal: cleanupSignal signal: cleanupSignal
}).catch(() => undefined) }).catch(() => undefined)
} finally { } finally {
this.terminate(child) await this.terminate(child)
this.children.delete(child) this.children.delete(child)
if (generatedConfigPath) { if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true }) await rm(generatedConfigPath, { force: true })
@@ -1795,31 +1874,24 @@ export class ContinueHostAdapter {
} }
} }
private terminate(child: ContinueHostChild): void { private async terminate(child: ContinueHostChild): Promise<void> {
if (child.exitCode !== null || child.killed) { const existing = this.childTerminations.get(child)
if (existing) {
await existing
return return
} }
if (process.platform === 'win32' && child.pid) { const termination = this.dependencies
const killer = spawn( .terminateProcessTree(child)
'taskkill.exe', .catch(() => undefined)
['/PID', String(child.pid), '/T', '/F'], this.childTerminations.set(child, termination)
{ await termination
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
} }
dispose(): void { async dispose(): Promise<void> {
this.pendingQuestions.clear() this.pendingQuestions.clear()
for (const child of this.children) { await Promise.all(
this.terminate(child) [...this.children].map((child) => this.terminate(child))
} )
this.children.clear() this.children.clear()
} }
} }
+24
View File
@@ -83,6 +83,7 @@ describe('ContinueAgentRuntime', () => {
text: 'Continue response' text: 'Continue response'
}) })
mocks.respondHostQuestion.mockResolvedValue(undefined) mocks.respondHostQuestion.mockResolvedValue(undefined)
mocks.disposeHost.mockResolvedValue(undefined)
}) })
it('does not launch the CLI for an already-cancelled request', async () => { it('does not launch the CLI for an already-cancelled request', async () => {
@@ -104,6 +105,29 @@ describe('ContinueAgentRuntime', () => {
expect(mocks.runHost).not.toHaveBeenCalled() expect(mocks.runHost).not.toHaveBeenCalled()
}) })
it('awaits host process cleanup during Runtime disposal', async () => {
let releaseDispose!: () => void
mocks.disposeHost.mockImplementation(
() =>
new Promise<void>((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 () => { it('uses the resolved binary through the Continue host adapter', async () => {
const runtime = createRuntime() const runtime = createRuntime()
+3 -3
View File
@@ -784,9 +784,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.pendingQuestions.clear() this.pendingQuestions.clear()
for (const host of this.hostAdapters.values()) { await Promise.all(
host.dispose() [...this.hostAdapters.values()].map((host) => host.dispose())
} )
this.hostAdapters.clear() this.hostAdapters.clear()
} }
} }
@@ -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:1x{500}$/u
)
})
)
})
})
@@ -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
}
+6 -52
View File
@@ -26,6 +26,7 @@ import type {
RuntimeExtensionCatalog, RuntimeExtensionCatalog,
RuntimeExtensionStoreDependencies RuntimeExtensionStoreDependencies
} from './runtime-extension-store' } from './runtime-extension-store'
import { terminateProcessTreeAndWait } from './child-process-termination'
const NPM_REGISTRY_URL = 'https://registry.npmjs.org' const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
const NPM_SEARCH_PAGE_SIZE = 250 const NPM_SEARCH_PAGE_SIZE = 250
@@ -167,61 +168,14 @@ export type PackageManagerRunner = (
} }
) => Promise<PackageManagerRunResult> ) => Promise<PackageManagerRunResult>
function waitForProcessClose(
child: ReturnType<typeof spawn>
): Promise<void> {
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( async function terminatePackageManager(
child: ReturnType<typeof spawn> child: ReturnType<typeof spawn>
): Promise<void> { ): Promise<void> {
const closed = waitForProcessClose(child) await terminateProcessTreeAndWait(child, {
if (process.platform === 'win32' && child.pid) { processGroup: true,
const killer = spawn( signal: 'SIGKILL',
'taskkill.exe', waitMs: 5_000
['/PID', String(child.pid), '/T', '/F'], })
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
await new Promise<void>((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
} }
function boundedAppend(current: string, chunk: unknown): string { function boundedAppend(current: string, chunk: unknown): string {
+436 -93
View File
@@ -1351,19 +1351,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
expect.any(Array), expect.any(Array),
expect.any(AbortSignal) expect.any(AbortSignal)
) )
expect(setup.client.mcp.add).toHaveBeenCalledWith({ expect(setup.client.mcp.add).toHaveBeenCalledWith(
directory: process.cwd(), {
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u), directory: process.cwd(),
config: { name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
type: 'remote', config: {
url: 'http://127.0.0.1:4567/mcp', type: 'remote',
enabled: true, url: 'http://127.0.0.1:4567/mcp',
headers: { enabled: true,
Authorization: 'Bearer custom-capability' headers: {
}, Authorization: 'Bearer custom-capability'
oauth: false },
} oauth: false
}) }
},
{ signal: expect.any(AbortSignal) }
)
expect(JSON.stringify( expect(JSON.stringify(
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>) (setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
.mock.calls .mock.calls
@@ -1500,21 +1503,24 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await expect(stream.next()).resolves.toMatchObject({ await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' } value: { type: 'status' }
}) })
await expect(stream.next()).resolves.toMatchObject({ const questionEvent = await stream.next()
value: { expect(questionEvent.value).toMatchObject({
type: 'question', type: 'question',
questionId: 'question-1', questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u),
questions: [ questions: [
{ {
header: '实现方式', header: '实现方式',
question: '请选择实现方式', question: '请选择实现方式',
multiple: false, multiple: false,
custom: true custom: true
} }
] ]
}
}) })
await runtime.respondToQuestion('question-1', [['先写测试']]) const questionId =
questionEvent.value?.type === 'question'
? questionEvent.value.questionId
: ''
await runtime.respondToQuestion(questionId, [['先写测试']])
expect(setup.questionReply).toHaveBeenCalledWith({ expect(setup.questionReply).toHaveBeenCalledWith({
requestID: 'question-1', requestID: 'question-1',
directory: process.cwd(), directory: process.cwd(),
@@ -1526,6 +1532,318 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose() await runtime.dispose()
}) })
it('namespaces identical upstream question IDs across concurrent external conversations', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).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<typeof vi.fn>
).mockImplementation(
async (
_input: unknown,
options: { signal: AbortSignal }
) => ({
stream: (async function* () {
await new Promise<void>((_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<never>(() => 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<typeof vi.fn>
).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 () => { it('adds only request-scoped built-in read tools for Ask and disconnects them', async () => {
const setup = runClient([ const setup = runClient([
{ {
@@ -1592,19 +1910,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
events.push(event) events.push(event)
} }
expect(setup.client.mcp.add).toHaveBeenCalledWith({ expect(setup.client.mcp.add).toHaveBeenCalledWith(
directory: process.cwd(), {
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u), directory: process.cwd(),
config: { name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
type: 'remote', config: {
url: 'http://127.0.0.1:4567/mcp', type: 'remote',
enabled: true, url: 'http://127.0.0.1:4567/mcp',
headers: { enabled: true,
Authorization: 'Bearer secret-capability' headers: {
}, Authorization: 'Bearer secret-capability'
oauth: false },
} oauth: false
}) }
},
{ signal: expect.any(AbortSignal) }
)
const knowledgeMcpName = ( const knowledgeMcpName = (
( (
setup.client.mcp.add as unknown as ReturnType<typeof vi.fn> setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>
@@ -1621,7 +1942,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
action: 'allow' action: 'allow'
} }
] ]
}) }),
{ signal: expect.any(AbortSignal) }
) )
expect(setup.session.promptAsync).toHaveBeenCalledWith( expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -1634,10 +1956,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
}), }),
expect.anything() expect.anything()
) )
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith({ expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
name: expect.stringMatching(/^goodbuddy-data-/u), {
directory: process.cwd() name: expect.stringMatching(/^goodbuddy-data-/u),
}) directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose() await runtime.dispose()
}) })
@@ -1911,19 +2236,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
try { try {
await collectRun(runtime, 'ask') await collectRun(runtime, 'ask')
expect(setup.session.create).toHaveBeenCalledWith({ expect(setup.session.create).toHaveBeenCalledWith(
title: 'GoodBuddy 对话', {
directory: process.cwd(), title: 'GoodBuddy 对话',
permission: [ directory: process.cwd(),
{ permission: '*', pattern: '*', action: 'deny' }, permission: [
{ permission: 'skill', pattern: '*', action: 'deny' }, { permission: '*', pattern: '*', action: 'deny' },
{ { permission: 'skill', pattern: '*', action: 'deny' },
permission: 'skill', {
pattern: 'longdoc-docx', permission: 'skill',
action: 'allow' pattern: 'longdoc-docx',
} action: 'allow'
] }
}) ]
},
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith( expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
system: undefined, system: undefined,
@@ -2001,13 +2329,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
const events = await collectRun(runtime, 'execute') const events = await collectRun(runtime, 'execute')
expect(callOrder).toEqual(['subscribe', 'prompt']) expect(callOrder).toEqual(['subscribe', 'prompt'])
expect(session.create).toHaveBeenCalledWith({ expect(session.create).toHaveBeenCalledWith(
title: 'GoodBuddy 对话', {
directory: process.cwd(), title: 'GoodBuddy 对话',
permission: [ directory: process.cwd(),
{ permission: '*', pattern: '*', action: 'allow' } permission: [
] { permission: '*', pattern: '*', action: 'allow' }
}) ]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).toHaveBeenCalledOnce() expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith({ expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1', requestID: 'permission-1',
@@ -2341,16 +2672,20 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'ask') await collectRun(runtime, 'ask')
expect(session.create).toHaveBeenCalledWith({ expect(session.create).toHaveBeenCalledWith(
title: 'GoodBuddy 对话', {
directory: process.cwd(), title: 'GoodBuddy 对话',
permission: [ directory: process.cwd(),
{ permission: '*', pattern: '*', action: 'deny' } permission: [
] { permission: '*', pattern: '*', action: 'deny' }
}) ]
expect(tool.ids).toHaveBeenCalledWith({ },
directory: process.cwd() { signal: expect.any(AbortSignal) }
}) )
expect(tool.ids).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(session.promptAsync).toHaveBeenCalledWith( expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
tools: { tools: {
@@ -2378,13 +2713,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute') await collectRun(runtime, 'execute')
await collectRun(runtime, 'ask') await collectRun(runtime, 'ask')
expect(session.update).toHaveBeenCalledWith({ expect(session.update).toHaveBeenCalledWith(
sessionID: 'session-1', {
directory: process.cwd(), sessionID: 'session-1',
permission: [ directory: process.cwd(),
{ permission: '*', pattern: '*', action: 'deny' } permission: [
] { permission: '*', pattern: '*', action: 'deny' }
}) ]
},
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose() await runtime.dispose()
}) })
@@ -2411,13 +2749,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute') await collectRun(runtime, 'execute')
expect(runtime.requiresToolApproval).toBe(false) expect(runtime.requiresToolApproval).toBe(false)
expect(session.create).toHaveBeenCalledWith({ expect(session.create).toHaveBeenCalledWith(
title: 'GoodBuddy 对话', {
directory: process.cwd(), title: 'GoodBuddy 对话',
permission: [ directory: process.cwd(),
{ permission: '*', pattern: '*', action: 'allow' } permission: [
] { permission: '*', pattern: '*', action: 'allow' }
}) ]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).not.toHaveBeenCalled() expect(permissionReply).not.toHaveBeenCalled()
await runtime.dispose() await runtime.dispose()
}) })
@@ -2814,7 +3155,8 @@ describe('OpenCodeRuntime native customization', () => {
} }
expect(setup.session.create).toHaveBeenCalledWith( expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' }) expect.objectContaining({ agent: 'plan' }),
{ signal: expect.any(AbortSignal) }
) )
expect(setup.session.promptAsync).toHaveBeenCalledWith( expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' }), expect.objectContaining({ agent: 'plan' }),
@@ -2853,7 +3195,8 @@ describe('OpenCodeRuntime native customization', () => {
await collectRun(runtime) await collectRun(runtime)
expect(setup.session.create).toHaveBeenCalledWith( expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' }) expect.objectContaining({ agent: 'build' }),
{ signal: expect.any(AbortSignal) }
) )
expect(setup.session.promptAsync).toHaveBeenCalledWith( expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' }), expect.objectContaining({ agent: 'build' }),
@@ -3099,7 +3442,7 @@ describe('OpenCodeRuntime native customization', () => {
}) })
expect(context).toHaveBeenCalledWith( expect(context).toHaveBeenCalledWith(
{ sessionID: 'session-1' }, { sessionID: 'session-1' },
{ signal } { signal: expect.any(AbortSignal) }
) )
expect(summarize).toHaveBeenCalledWith( expect(summarize).toHaveBeenCalledWith(
{ {
@@ -3109,7 +3452,7 @@ describe('OpenCodeRuntime native customization', () => {
modelID: 'claude-sonnet', modelID: 'claude-sonnet',
auto: false auto: false
}, },
{ signal } { signal: expect.any(AbortSignal) }
) )
await runtime.dispose() await runtime.dispose()
}) })
+291 -114
View File
@@ -56,6 +56,10 @@ import type {
RuntimeSkillPackage RuntimeSkillPackage
} from '../capabilities/capability-service' } from '../capabilities/capability-service'
import { stageRuntimeSkillPackages } from './runtime-skill-packages' import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import {
requestProcessTreeTermination,
waitForProcessExit
} from './child-process-termination'
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024 const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
const STARTUP_TIMEOUT_MS = 10_000 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_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024 const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_TOOL_CALLS_PER_RUN = 100 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_QUESTION_REQUEST_BYTES = 32 * 1_024
const MAX_QUESTIONS_PER_REQUEST = 4 const MAX_QUESTIONS_PER_REQUEST = 4
const MAX_QUESTION_OPTIONS = 20 const MAX_QUESTION_OPTIONS = 20
@@ -243,6 +249,44 @@ function byteLengthWithin(value: string, maximum: number): boolean {
return Buffer.byteLength(value) <= maximum 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<T>(
operation: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
return new Promise<T>((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[] { function areBoundedPatterns(value: unknown): value is string[] {
return ( return (
Array.isArray(value) && Array.isArray(value) &&
@@ -416,6 +460,7 @@ export type OpenCodeRuntimeDependencies = {
createClient: typeof createOpencodeClient createClient: typeof createOpencodeClient
platform: NodeJS.Platform platform: NodeJS.Platform
startupTimeoutMs: number startupTimeoutMs: number
executionTimeoutMs: number
} }
export type OpenCodeRuntimeOptions = { export type OpenCodeRuntimeOptions = {
@@ -678,6 +723,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient client: OpencodeClient
directory: string directory: string
questionCount: number questionCount: number
upstreamQuestionId: string
} }
>() >()
private embeddedRunTail: Promise<void> = Promise.resolve() private embeddedRunTail: Promise<void> = Promise.resolve()
@@ -694,6 +740,7 @@ export class OpenCodeRuntime implements AgentRuntime {
createClient: createOpencodeClient, createClient: createOpencodeClient,
platform: process.platform, platform: process.platform,
startupTimeoutMs: STARTUP_TIMEOUT_MS, startupTimeoutMs: STARTUP_TIMEOUT_MS,
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
...dependencies ...dependencies
} }
} }
@@ -782,36 +829,14 @@ export class OpenCodeRuntime implements AgentRuntime {
} }
private terminate(child: SpawnedProcess): void { private terminate(child: SpawnedProcess): void {
if (child.exitCode !== null) { requestProcessTreeTermination(child, {
return platform: this.dependencies.platform,
} spawn: this.dependencies.spawn
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')
}
} }
private waitForExit(child: SpawnedProcess): Promise<void> { private waitForExit(child: SpawnedProcess): Promise<void> {
if (child.exitCode !== null) { return waitForProcessExit(child)
return Promise.resolve()
}
return new Promise((resolveExit) => {
const timeout = setTimeout(resolveExit, 2_000)
child.once('close', () => {
clearTimeout(timeout)
resolveExit()
})
})
} }
private getNativeSkillIds(): string[] { private getNativeSkillIds(): string[] {
@@ -1083,9 +1108,12 @@ export class OpenCodeRuntime implements AgentRuntime {
if (this.client) { if (this.client) {
return this.client return this.client
} }
const existingInitialization = this.clientInitialization
this.clientInitialization ??= this.initializeClient(signal) this.clientInitialization ??= this.initializeClient(signal)
try { try {
return await this.clientInitialization return signal && existingInitialization
? await awaitWithAbort(this.clientInitialization, signal)
: await this.clientInitialization
} catch (error) { } catch (error) {
this.clientInitialization = undefined this.clientInitialization = undefined
throw error throw error
@@ -1154,7 +1182,8 @@ export class OpenCodeRuntime implements AgentRuntime {
} }
private async discoverAgents( private async discoverAgents(
client: OpencodeClient client: OpencodeClient,
signal?: AbortSignal
): Promise< ): Promise<
Array<{ Array<{
id: string id: string
@@ -1165,9 +1194,15 @@ export class OpenCodeRuntime implements AgentRuntime {
hidden: boolean hidden: boolean
}> }>
> { > {
const response = await client.app.agents({ const operation = client.app.agents(
directory: this.options.defaultWorkspace {
}) directory: this.options.defaultWorkspace
},
signal ? { signal } : undefined
)
const response = signal
? await awaitWithAbort(operation, signal)
: await operation
if (response.error || !response.data) { if (response.error || !response.data) {
throw new Error('OpenCode Agent 清单不可用') throw new Error('OpenCode Agent 清单不可用')
} }
@@ -1197,7 +1232,8 @@ export class OpenCodeRuntime implements AgentRuntime {
private async resolveSelectedAgent( private async resolveSelectedAgent(
client: OpencodeClient, client: OpencodeClient,
request: AgentExecutionRequest request: AgentExecutionRequest,
signal: AbortSignal
): Promise<string | undefined> { ): Promise<string | undefined> {
const control = const control =
request.runtimeControl?.provider === 'opencode' request.runtimeControl?.provider === 'opencode'
@@ -1213,7 +1249,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent' '外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent'
) )
} }
const agents = await this.discoverAgents(client) const agents = await this.discoverAgents(client, signal)
if ( if (
!agents.some( !agents.some(
(agent) => (agent) =>
@@ -1592,6 +1628,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient, client: OpencodeClient,
request: AgentExecutionRequest, request: AgentExecutionRequest,
directory: string, directory: string,
signal: AbortSignal,
agent?: string, agent?: string,
permission?: PermissionRuleset permission?: PermissionRuleset
): Promise<{ id: string; created: boolean }> { ): Promise<{ id: string; created: boolean }> {
@@ -1603,27 +1640,62 @@ export class OpenCodeRuntime implements AgentRuntime {
request.conversationId request.conversationId
) )
if (pending) { if (pending) {
return { id: await pending, created: false } return {
id: await awaitWithAbort(pending, signal),
created: false
}
} }
const creation = client.session const creation: Promise<string> = client.session
.create({ .create(
title: 'GoodBuddy 对话', {
directory, title: 'GoodBuddy 对话',
...(agent ? { agent } : {}), directory,
...(permission ? { permission } : {}) ...(agent ? { agent } : {}),
}) ...(permission ? { permission } : {})
},
{ signal }
)
.then((response) => { .then((response) => {
if (!response.data) { if (!response.data) {
throw new Error('OpenCode 会话创建失败') throw new Error('OpenCode 会话创建失败')
} }
this.sessions.set(request.conversationId, response.data.id) const sessionId = response.data.id
return 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) this.sessionInitializations.set(request.conversationId, creation)
try { try {
return { id: await creation, created: true } return {
id: await awaitWithAbort(creation, signal),
created: true
}
} finally { } 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, request: AgentExecutionRequest,
signal: AbortSignal signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> { ): AsyncGenerator<RuntimeEvent, void, void> {
const releaseEmbedded = this.usesEmbeddedPermissionMediation() const deadline = new AbortController()
? await this.acquireEmbeddedRun(signal) const deadlineTimer = setTimeout(
: undefined () =>
const releaseConversation = await this.acquireConversationRun( deadline.abort(
request.conversationId, new Error(
signal `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 { 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 { } finally {
releaseConversation() clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded?.() releaseEmbedded?.()
} }
} }
@@ -1674,7 +1771,8 @@ export class OpenCodeRuntime implements AgentRuntime {
} }
const selectedAgent = await this.resolveSelectedAgent( const selectedAgent = await this.resolveSelectedAgent(
client, client,
request request,
signal
) )
let selectedCommand: let selectedCommand:
| { | {
@@ -1683,7 +1781,10 @@ export class OpenCodeRuntime implements AgentRuntime {
} }
| undefined | undefined
if (runtimeControl?.command) { 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) { if (commandResponse.error || !commandResponse.data) {
throw new Error('OpenCode 无法验证原生命令') throw new Error('OpenCode 无法验证原生命令')
} }
@@ -1725,19 +1826,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`) .update(`${request.conversationId}\0${request.requestId}`)
.digest('hex') .digest('hex')
.slice(0, 20)}` .slice(0, 20)}`
const added = await client.mcp.add({ const added = await awaitWithAbort(
directory, client.mcp.add(
name: knowledgeMcpName, {
config: { directory,
type: 'remote', name: knowledgeMcpName,
url: this.options.knowledgeGateway.getEndpoint()!, config: {
enabled: true, type: 'remote',
headers: { url: this.options.knowledgeGateway.getEndpoint()!,
Authorization: `Bearer ${request.knowledgeCapabilityToken}` enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
},
oauth: false
}
}, },
oauth: false { signal }
} ),
}) signal
)
if (added.error || !added.data) { if (added.error || !added.data) {
throw new Error('OpenCode 内置只读工具连接失败') throw new Error('OpenCode 内置只读工具连接失败')
} }
@@ -1776,19 +1883,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`) .update(`${request.conversationId}\0${request.requestId}`)
.digest('hex') .digest('hex')
.slice(0, 20)}` .slice(0, 20)}`
const added = await client.mcp.add({ const added = await awaitWithAbort(
directory, client.mcp.add(
name: customMcpName, {
config: { directory,
type: 'remote', name: customMcpName,
url: this.options.knowledgeGateway.getEndpoint()!, config: {
enabled: true, type: 'remote',
headers: { url: this.options.knowledgeGateway.getEndpoint()!,
Authorization: `Bearer ${customMcpToken}` enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
},
oauth: false
}
}, },
oauth: false { signal }
} ),
}) signal
)
const addedStatus = added.data?.[customMcpName] const addedStatus = added.data?.[customMcpName]
if ( if (
added.error || added.error ||
@@ -1834,9 +1947,10 @@ export class OpenCodeRuntime implements AgentRuntime {
] ]
let disabledTools: Record<string, boolean> | undefined let disabledTools: Record<string, boolean> | undefined
if (request.workMode !== 'execute') { if (request.workMode !== 'execute') {
const tools = await client.tool.ids({ const tools = await awaitWithAbort(
directory client.tool.ids({ directory }, { signal }),
}) signal
)
if (tools.error || !tools.data) { if (tools.error || !tools.data) {
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求') throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
} }
@@ -1854,16 +1968,23 @@ export class OpenCodeRuntime implements AgentRuntime {
client, client,
request, request,
directory, directory,
signal,
selectedAgent, selectedAgent,
permission permission
) )
const sessionId = session.id const sessionId = session.id
if (!session.created) { if (!session.created) {
const update = await client.session.update({ const update = await awaitWithAbort(
sessionID: sessionId, client.session.update(
directory, {
permission sessionID: sessionId,
}) directory,
permission
},
{ signal }
),
signal
)
if (update.error || !update.data) { if (update.error || !update.data) {
throw new Error('OpenCode 会话权限配置失败') throw new Error('OpenCode 会话权限配置失败')
} }
@@ -1875,9 +1996,10 @@ export class OpenCodeRuntime implements AgentRuntime {
message: 'OpenCode 正在处理请求' message: 'OpenCode 正在处理请求'
} }
const subscription = await client.event.subscribe({ const subscription = await awaitWithAbort(
directory client.event.subscribe({ directory }, { signal }),
}, { signal }) signal
)
const abortSession = (): void => { const abortSession = (): void => {
void client.session.abort({ void client.session.abort({
@@ -1898,7 +2020,16 @@ export class OpenCodeRuntime implements AgentRuntime {
} }
>() >()
const reasoningPartIds = new Set<string>() const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Set<string>() const reportedQuestionIds = new Map<string, string>()
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 let hasResponseTextAfterFailure = false
try { try {
const promptText = const promptText =
@@ -1998,6 +2129,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'thinking' 'thinking'
].includes(event.properties.field) ].includes(event.properties.field)
if (reasoning || event.properties.field === 'text') { if (reasoning || event.properties.field === 'text') {
consumeOutputBudget(event.properties.delta)
if ( if (
!reasoning && !reasoning &&
/\S/u.test(event.properties.delta) && /\S/u.test(event.properties.delta) &&
@@ -2077,6 +2209,7 @@ export class OpenCodeRuntime implements AgentRuntime {
event.properties.sessionID === sessionId && event.properties.sessionID === sessionId &&
event.properties.delta event.properties.delta
) { ) {
consumeOutputBudget(event.properties.delta)
yield { yield {
requestId: request.requestId, requestId: request.requestId,
type: 'reasoning', type: 'reasoning',
@@ -2096,16 +2229,28 @@ export class OpenCodeRuntime implements AgentRuntime {
questionRequest && questionRequest &&
!reportedQuestionIds.has(questionRequest.id) !reportedQuestionIds.has(questionRequest.id)
) { ) {
reportedQuestionIds.add(questionRequest.id) const publicQuestionId = createPublicQuestionId(
this.pendingQuestions.set(questionRequest.id, { 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, client,
directory, directory,
questionCount: questionRequest.questions.length questionCount: questionRequest.questions.length,
upstreamQuestionId: questionRequest.id
}) })
yield { yield {
requestId: request.requestId, requestId: request.requestId,
type: 'question', type: 'question',
questionId: questionRequest.id, questionId: publicQuestionId,
questions: questionRequest.questions.map((question) => ({ questions: questionRequest.questions.map((question) => ({
header: question.header, header: question.header,
question: question.question, question: question.question,
@@ -2125,7 +2270,12 @@ export class OpenCodeRuntime implements AgentRuntime {
event.type === 'question.rejected') && event.type === 'question.rejected') &&
event.properties.sessionID === sessionId event.properties.sessionID === sessionId
) { ) {
this.pendingQuestions.delete(event.properties.requestID) const publicQuestionId = reportedQuestionIds.get(
event.properties.requestID
)
if (publicQuestionId) {
this.pendingQuestions.delete(publicQuestionId)
}
} }
if ( if (
@@ -2320,20 +2470,29 @@ export class OpenCodeRuntime implements AgentRuntime {
throw error throw error
} finally { } finally {
signal.removeEventListener('abort', abortSession) signal.removeEventListener('abort', abortSession)
for (const questionId of reportedQuestionIds) { for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId) this.pendingQuestions.delete(questionId)
} }
} }
} finally { } finally {
const cleanupSignal = AbortSignal.timeout(1_000)
if (knowledgeMcpName) { if (knowledgeMcpName) {
await client.mcp await awaitWithAbort(
.disconnect({ name: knowledgeMcpName, directory }) client.mcp.disconnect(
.catch(() => undefined) { name: knowledgeMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
} }
if (customMcpName) { if (customMcpName) {
await client.mcp await awaitWithAbort(
.disconnect({ name: customMcpName, directory }) client.mcp.disconnect(
.catch(() => undefined) { name: customMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
} }
if (customMcpToken) { if (customMcpToken) {
this.options.knowledgeGateway?.revoke(customMcpToken) this.options.knowledgeGateway?.revoke(customMcpToken)
@@ -2352,13 +2511,13 @@ export class OpenCodeRuntime implements AgentRuntime {
const response = answers const response = answers
? answers.length === pending.questionCount ? answers.length === pending.questionCount
? await pending.client.question.reply({ ? await pending.client.question.reply({
requestID: questionId, requestID: pending.upstreamQuestionId,
directory: pending.directory, directory: pending.directory,
answers answers
}) })
: undefined : undefined
: await pending.client.question.reject({ : await pending.client.question.reject({
requestID: questionId, requestID: pending.upstreamQuestionId,
directory: pending.directory directory: pending.directory
}) })
if (!response) { if (!response) {
@@ -2382,12 +2541,24 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact' '外部 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 let releaseConversation: (() => void) | undefined
try { try {
releaseEmbedded = await this.acquireEmbeddedRun(executionSignal)
releaseConversation = await this.acquireConversationRun( releaseConversation = await this.acquireConversationRun(
request.conversationId, request.conversationId,
signal executionSignal
) )
const sessionId = this.sessions.get(request.conversationId) const sessionId = this.sessions.get(request.conversationId)
if (!sessionId) { 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( const context = await client.v2.session.context(
{ sessionID: sessionId }, { sessionID: sessionId },
{ signal } { signal: executionSignal }
) )
if (context.error || !context.data) { if (context.error || !context.data) {
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact') throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
@@ -2434,13 +2605,13 @@ export class OpenCodeRuntime implements AgentRuntime {
} }
} }
} }
signal.throwIfAborted() executionSignal.throwIfAborted()
const subscriptionController = new AbortController() const subscriptionController = new AbortController()
const subscription = await client.event.subscribe( const subscription = await client.event.subscribe(
{ directory: this.options.defaultWorkspace }, { directory: this.options.defaultWorkspace },
{ {
signal: AbortSignal.any([ signal: AbortSignal.any([
signal, executionSignal,
subscriptionController.signal subscriptionController.signal
]) ])
} }
@@ -2482,7 +2653,7 @@ export class OpenCodeRuntime implements AgentRuntime {
modelID: configuredModel.modelID, modelID: configuredModel.modelID,
auto: false auto: false
}, },
{ signal } { signal: executionSignal }
) )
if (compact.error || compact.data !== true) { if (compact.error || compact.data !== true) {
throw new Error( throw new Error(
@@ -2522,9 +2693,15 @@ export class OpenCodeRuntime implements AgentRuntime {
subscriptionController.abort() subscriptionController.abort()
await usageCapture.catch(() => undefined) await usageCapture.catch(() => undefined)
} }
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally { } finally {
clearTimeout(deadlineTimer)
releaseConversation?.() releaseConversation?.()
releaseEmbedded() releaseEmbedded?.()
} }
} }
+53 -2
View File
@@ -1,10 +1,13 @@
import { EventEmitter } from 'node:events'
import { realpath } from 'node:fs/promises' import { realpath } from 'node:fs/promises'
import { basename, dirname } from 'node:path' import { basename, dirname } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
detectAgentRuntimes, detectAgentRuntimes,
detectRuntimeBinary detectRuntimeBinary,
validateRuntimeVersion,
type RuntimeVersionProcess
} from './runtime-discovery' } from './runtime-discovery'
const originalPath = process.env.PATH const originalPath = process.env.PATH
@@ -24,6 +27,54 @@ afterEach(() => {
}) })
describe('runtime discovery', () => { 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<void>((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 () => { it('canonicalizes and validates a configured ordinary file first', async () => {
process.env.PATH = '' process.env.PATH = ''
process.env.Path = '' process.env.Path = ''
+103 -44
View File
@@ -8,6 +8,10 @@ import {
normalize normalize
} from 'node:path' } from 'node:path'
import spawn from 'cross-spawn' import spawn from 'cross-spawn'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from './child-process-termination'
import { buildRuntimeEnvironment } from './process-environment' import { buildRuntimeEnvironment } from './process-environment'
import type { import type {
AgentRuntimeDetection, AgentRuntimeDetection,
@@ -16,6 +20,7 @@ import type {
const VERSION_TIMEOUT_MS = 3_000 const VERSION_TIMEOUT_MS = 3_000
const VERSION_OUTPUT_LIMIT = 8 * 1024 const VERSION_OUTPUT_LIMIT = 8 * 1024
const VERSION_TERMINATION_WAIT_MS = 500
export type RuntimeBinaryDiscoveryInput = { export type RuntimeBinaryDiscoveryInput = {
binaryPath: string binaryPath: string
@@ -31,6 +36,39 @@ type VersionValidation =
| { valid: true; version?: string } | { valid: true; version?: string }
| { valid: false } | { 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 { function stripUnsafeCharacters(value: string): string {
let result = '' let result = ''
let inEscapeSequence = false let inEscapeSequence = false
@@ -66,45 +104,36 @@ function safeVersion(output: string): string | undefined {
return (semanticVersion?.[1] ?? firstLine).slice(0, 160) return (semanticVersion?.[1] ?? firstLine).slice(0, 160)
} }
function terminate(child: ReturnType<typeof spawn>): void { export function validateRuntimeVersion(
if (child.exitCode !== null || child.killed) { binaryPath: string,
return dependencies: RuntimeVersionValidationDependencies = {}
} ): Promise<VersionValidation> {
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<VersionValidation> {
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false let settled = false
let cleanupStarted = false
let stdout = '' let stdout = ''
let stderr = '' let stderr = ''
let stdoutBytes = 0 let stdoutBytes = 0
let stderrBytes = 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'], { const child = (dependencies.spawnProcess ?? spawn)(
env: buildRuntimeEnvironment({}), binaryPath,
shell: false, ['--version'],
stdio: ['ignore', 'pipe', 'pipe'], {
windowsHide: true detached: platform !== 'win32',
}) env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
const finish = (result: VersionValidation): void => { const finish = (result: VersionValidation): void => {
if (settled) { if (settled || cleanupStarted) {
return return
} }
settled = true settled = true
@@ -112,21 +141,43 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
resolve(result) resolve(result)
} }
const exceedLimit = (): void => { const failAfterCleanup = (): void => {
terminate(child) if (settled || cleanupStarted) {
finish({ valid: false }) 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(() => { const timeout = setTimeout(() => {
terminate(child) failAfterCleanup()
finish({ valid: false }) }, timeoutMs)
}, VERSION_TIMEOUT_MS)
child.stdout?.on('data', (chunk: Buffer | string) => { child.stdout?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stdoutBytes += value.byteLength stdoutBytes += value.byteLength
if (stdoutBytes > VERSION_OUTPUT_LIMIT) { if (stdoutBytes > outputLimit) {
exceedLimit() failAfterCleanup()
return return
} }
stdout += value.toString('utf8') stdout += value.toString('utf8')
@@ -134,14 +185,22 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
child.stderr?.on('data', (chunk: Buffer | string) => { child.stderr?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stderrBytes += value.byteLength stderrBytes += value.byteLength
if (stderrBytes > VERSION_OUTPUT_LIMIT) { if (stderrBytes > outputLimit) {
exceedLimit() failAfterCleanup()
return return
} }
stderr += value.toString('utf8') stderr += value.toString('utf8')
}) })
child.once('error', () => finish({ valid: false })) child.once('error', () => {
if (cleanupStarted) {
return
}
finish({ valid: false })
})
child.once('close', (code) => { child.once('close', (code) => {
if (cleanupStarted) {
return
}
if (code !== 0) { if (code !== 0) {
finish({ valid: false }) finish({ valid: false })
return return
@@ -286,7 +345,7 @@ export async function detectRuntimeBinary(
'bundled' 'bundled'
) )
} }
const validation = await validateVersion(canonicalPath) const validation = await validateRuntimeVersion(canonicalPath)
return validation.valid return validation.valid
? availableDetection( ? availableDetection(
input.label, input.label,
@@ -305,7 +364,7 @@ export async function detectRuntimeBinary(
if (!canonicalPath) { if (!canonicalPath) {
configuredPathProblem = 'invalid' configuredPathProblem = 'invalid'
} else { } else {
const validation = await validateVersion(canonicalPath) const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) { if (validation.valid) {
return availableDetection( return availableDetection(
input.label, input.label,
@@ -332,7 +391,7 @@ export async function detectRuntimeBinary(
continue continue
} }
foundAutomaticCandidate = true foundAutomaticCandidate = true
const validation = await validateVersion(canonicalPath) const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) { if (validation.valid) {
return availableDetection( return availableDetection(
input.label, input.label,
@@ -3,7 +3,9 @@ import {
mkdtemp, mkdtemp,
readFile, readFile,
readdir, readdir,
rename,
rm, rm,
symlink,
writeFile writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
import { tmpdir } from 'node:os' 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<Record<string, unknown>>
}
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<Record<string, unknown>>
}
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 () => { it('rejects installer entrypoints outside the managed package', async () => {
const entry = catalogEntry() const entry = catalogEntry()
const fixtureValue = await fixture({ const fixtureValue = await fixture({
+239 -33
View File
@@ -33,6 +33,9 @@ import {
const managedDirectoryName = 'runtime-extensions' const managedDirectoryName = 'runtime-extensions'
const stateFileName = 'store.json' 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 const version1StoredStateSchema = z
.object({ .object({
@@ -56,6 +59,19 @@ const storedStateFileSchema = z.union([
type StoredState = z.infer<typeof storedStateSchema> type StoredState = z.infer<typeof storedStateSchema>
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<typeof mutationJournalSchema>
export interface RuntimeExtensionCatalog { export interface RuntimeExtensionCatalog {
list(): Promise<readonly RuntimeExtensionCatalogEntry[]> list(): Promise<readonly RuntimeExtensionCatalogEntry[]>
} }
@@ -128,6 +144,7 @@ export class RuntimeExtensionStore {
readonly managedRoot: string readonly managedRoot: string
private readonly statePath: string private readonly statePath: string
private readonly journalPath: string
private state?: StoredState private state?: StoredState
private stateLoad?: Promise<StoredState> private stateLoad?: Promise<StoredState>
private canonicalRoot?: string private canonicalRoot?: string
@@ -144,6 +161,7 @@ export class RuntimeExtensionStore {
} }
this.managedRoot = resolve(userDataPath, managedDirectoryName) this.managedRoot = resolve(userDataPath, managedDirectoryName)
this.statePath = join(this.managedRoot, stateFileName) this.statePath = join(this.managedRoot, stateFileName)
this.journalPath = join(this.managedRoot, journalFileName)
} }
async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> { async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> {
@@ -176,6 +194,7 @@ export class RuntimeExtensionStore {
): Promise<RuntimeExtensionApplyResult> { ): Promise<RuntimeExtensionApplyResult> {
const parsed = runtimeExtensionActionSchema.parse(action) const parsed = runtimeExtensionActionSchema.parse(action)
const changed = await this.serialize(async () => { const changed = await this.serialize(async () => {
await this.reconcileMutationJournal()
switch (parsed.type) { switch (parsed.type) {
case 'set-marketplace-enabled': case 'set-marketplace-enabled':
return this.setMarketplaceEnabled(parsed.enabled) return this.setMarketplaceEnabled(parsed.enabled)
@@ -306,6 +325,8 @@ export class RuntimeExtensionStore {
this.canonicalRoot = await realpath(this.managedRoot) this.canonicalRoot = await realpath(this.managedRoot)
await this.createManagedDirectory('extensions') await this.createManagedDirectory('extensions')
await this.createManagedDirectory('.staging') await this.createManagedDirectory('.staging')
await this.reconcileMutationJournal()
await this.cleanupUnjournaledStagingDirectories()
} }
private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> { private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> {
@@ -354,9 +375,12 @@ export class RuntimeExtensionStore {
`${temporaryId}-previous` `${temporaryId}-previous`
) )
const finalDirectory = this.extensionDirectory(extensionId) const finalDirectory = this.extensionDirectory(extensionId)
let previousMoved = false
let stagedMoved = false
try { try {
if (await this.pathExists(backupDirectory)) {
throw new Error(
'Extension upgrade backup path already exists'
)
}
const installedPackage = await this.dependencies.install({ const installedPackage = await this.dependencies.install({
entry, entry,
destinationDirectory: stagedDirectory destinationDirectory: stagedDirectory
@@ -366,12 +390,6 @@ export class RuntimeExtensionStore {
installedPackage.entrypoint installedPackage.entrypoint
) )
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
previousMoved = true
}
await rename(stagedDirectory, finalDirectory)
stagedMoved = true
const entrypoint = resolve( const entrypoint = resolve(
finalDirectory, finalDirectory,
installedPackage.entrypoint installedPackage.entrypoint
@@ -392,19 +410,29 @@ export class RuntimeExtensionStore {
? { integrity: installedPackage.integrity } ? { integrity: installedPackage.integrity }
: {}) : {})
} }
await this.persistAndSet( const nextState = this.replaceInstalled(state, installed)
this.replaceInstalled(state, installed) const journal: MutationJournal = {
) version: 1,
if (previousMoved) { kind: 'install',
await this.removeManagedTree(backupDirectory).catch(() => undefined) 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) { } catch (error) {
if (stagedMoved) { this.state = undefined
await this.removeManagedTree(finalDirectory) await this.reconcileMutationJournal().catch(() => undefined)
}
if (previousMoved) {
await rename(backupDirectory, finalDirectory)
}
throw error throw error
} finally { } finally {
await this.removeManagedTree(stagedDirectory).catch(() => undefined) await this.removeManagedTree(stagedDirectory).catch(() => undefined)
@@ -488,27 +516,40 @@ export class RuntimeExtensionStore {
'.staging', '.staging',
`${randomUUID()}-removed` `${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)) { if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
await rename(finalDirectory, trashDirectory) await rename(finalDirectory, trashDirectory)
moved = true
} }
try { try {
await this.persistAndSet({ await this.persistAndSet(nextState)
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
})
} catch (error) { } catch (error) {
if (moved) { this.state = undefined
await rename(trashDirectory, finalDirectory) await this.reconcileMutationJournal().catch(() => undefined)
}
throw error throw error
} }
if (moved) { await this.removeManagedTree(trashDirectory)
await this.removeManagedTree(trashDirectory).catch(() => undefined) await this.clearMutationJournal()
}
} }
private requireInstalled( private requireInstalled(
@@ -549,6 +590,171 @@ export class RuntimeExtensionStore {
) )
} }
private writeMutationJournal(journal: MutationJournal): Promise<void> {
return writeJsonFileAtomically(
this.journalPath,
mutationJournalSchema.parse(journal)
)
}
private async clearMutationJournal(): Promise<void> {
await unlink(this.journalPath).catch((error: unknown) => {
if (!isMissingFileError(error)) {
throw error
}
})
}
private async reconcileMutationJournal(): Promise<void> {
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<void> {
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<void> {
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<void> {
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( private assertExtensionEntrypoint(
extension: RuntimeExtensionInstalledState extension: RuntimeExtensionInstalledState
): void { ): void {
+421 -6
View File
@@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import { AssistantDatabase } from './assistant-database' import { AssistantDatabase } from './assistant-database'
const temporaryDirectories: string[] = [] const temporaryDirectories: string[] = []
@@ -156,7 +161,7 @@ describe('AssistantDatabase', () => {
database.close() database.close()
}) })
it('migrates existing databases to schema version 23', async () => { it('migrates existing databases to schema version 25', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-') join(tmpdir(), 'goodbuddy-assistant-migration-')
) )
@@ -185,7 +190,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(23) ).toBe(25)
expect( expect(
current current
.prepare( .prepare(
@@ -200,7 +205,12 @@ describe('AssistantDatabase', () => {
.all() .all()
).toEqual( ).toEqual(
expect.arrayContaining([ 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 const foreignKeys = current
@@ -268,6 +278,174 @@ describe('AssistantDatabase', () => {
current.close() 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 () => { it('idempotently migrates version 5 databases to computer control audit schema', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-control-audit-migration-') join(tmpdir(), 'goodbuddy-control-audit-migration-')
@@ -298,7 +476,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(23) ).toBe(25)
expect( expect(
current current
.prepare( .prepare(
@@ -436,7 +614,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath) const inspected = new DatabaseSync(databasePath)
expect( expect(
inspected.prepare('PRAGMA user_version').get() inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 23 }) ).toEqual({ user_version: 25 })
expect( expect(
inspected inspected
.prepare( .prepare(
@@ -566,11 +744,34 @@ describe('AssistantDatabase', () => {
const database = await createDatabase() const database = await createDatabase()
const [defaultProject] = database.listProjects() const [defaultProject] = database.listProjects()
expect(defaultProject).toMatchObject({ expect(defaultProject).toMatchObject({
name: '默认项目', name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace', rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask', defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active' 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) expect(database.listExperts()).toHaveLength(3)
const project = database.createProject({ const project = database.createProject({
@@ -579,6 +780,7 @@ describe('AssistantDatabase', () => {
rootPath: 'C:\\Release', rootPath: 'C:\\Release',
defaultWorkMode: 'ask' defaultWorkMode: 'ask'
}) })
expect(project.builtInDefault).toBe(false)
expect(database.listProjects()).toHaveLength(2) expect(database.listProjects()).toHaveLength(2)
const updated = database.updateProject(project.id, { const updated = database.updateProject(project.id, {
@@ -863,6 +1065,192 @@ describe('AssistantDatabase', () => {
reopened.close() 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 () => { it('preserves legacy channel event claims while adding account identity', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-event-migration-') join(tmpdir(), 'goodbuddy-channel-event-migration-')
@@ -2299,6 +2687,7 @@ describe('AssistantDatabase', () => {
it('explicitly deletes only local conversations and cascades messages', async () => { it('explicitly deletes only local conversations and cascades messages', async () => {
const database = await createDatabase() const database = await createDatabase()
const localId = '00000000-0000-4000-8000-000000000521' const localId = '00000000-0000-4000-8000-000000000521'
const localTaskId = '00000000-0000-4000-8000-000000000523'
database.replaceConversations([ database.replaceConversations([
{ {
id: localId, id: localId,
@@ -2341,6 +2730,28 @@ describe('AssistantDatabase', () => {
recurrence: 'daily', recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00.000Z' 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(true)
expect(database.deleteLocalConversation(localId)).toBe(false) expect(database.deleteLocalConversation(localId)).toBe(false)
@@ -2357,6 +2768,10 @@ describe('AssistantDatabase', () => {
expect(() => expect(() =>
database.getConversation(localId) database.getConversation(localId)
).toThrow('对话不存在') ).toThrow('对话不存在')
expect(() => database.getArtifact(hiddenReply.id)).toThrow(
'成果不存在'
)
expect(database.listPendingDelegationResults()).toEqual([])
expect(() => expect(() =>
database.deleteLocalConversation(remote.id) database.deleteLocalConversation(remote.id)
).toThrow('远程对话不能作为本地对话删除') ).toThrow('远程对话不能作为本地对话删除')
+217 -41
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
conversationSnapshotSchema, conversationSnapshotSchema,
expertCreateSchema, expertCreateSchema,
normalizeInteractiveWorkMode, normalizeInteractiveWorkMode,
@@ -82,6 +84,7 @@ type ProjectRow = {
runtime_selection_json: string | null runtime_selection_json: string | null
kind: AssistantProject['kind'] kind: AssistantProject['kind']
channel: ProjectChannel | null channel: ProjectChannel | null
built_in_default: number
status: AssistantProject['status'] status: AssistantProject['status']
created_at: string created_at: string
updated_at: string updated_at: string
@@ -427,6 +430,7 @@ function toProject(row: ProjectRow): AssistantProject {
: parseRuntimeSelection(row.runtime_selection_json), : parseRuntimeSelection(row.runtime_selection_json),
kind: row.kind, kind: row.kind,
channel: row.channel ?? undefined, channel: row.channel ?? undefined,
builtInDefault: row.built_in_default === 1,
status: row.status, status: row.status,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
@@ -982,12 +986,15 @@ export class AssistantDatabase {
.prepare('SELECT COUNT(*) AS count FROM projects') .prepare('SELECT COUNT(*) AS count FROM projects')
.get() as { count: number } .get() as { count: number }
if (count.count === 0) { if (count.count === 0) {
this.createProject({ this.createLocalProject(
name: '默认项目', {
description: 'GoodBuddy 默认工作区', name: builtInDefaultProjectSeedName,
rootPath: defaultRootPath, description: builtInDefaultProjectSeedDescription,
defaultWorkMode: 'ask' rootPath: defaultRootPath,
}) defaultWorkMode: 'ask'
},
true
)
} }
const expertCount = database const expertCount = database
.prepare('SELECT COUNT(*) AS count FROM experts') .prepare('SELECT COUNT(*) AS count FROM experts')
@@ -1283,6 +1290,13 @@ export class AssistantDatabase {
} }
createProject(input: ProjectCreateInput): AssistantProject { createProject(input: ProjectCreateInput): AssistantProject {
return this.createLocalProject(input, false)
}
private createLocalProject(
input: ProjectCreateInput,
builtInDefault: boolean
): AssistantProject {
const database = this.requireDatabase() const database = this.requireDatabase()
const id = randomUUID() const id = randomUUID()
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -1290,9 +1304,9 @@ export class AssistantDatabase {
.prepare( .prepare(
`INSERT INTO projects `INSERT INTO projects
(id, name, description, root_path, default_work_mode, (id, name, description, root_path, default_work_mode,
runtime_selection_json, kind, channel, status, created_at, runtime_selection_json, kind, channel, built_in_default,
updated_at) status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)` VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)`
) )
.run( .run(
id, id,
@@ -1303,6 +1317,7 @@ export class AssistantDatabase {
input.runtimeSelection input.runtimeSelection
? JSON.stringify(input.runtimeSelection) ? JSON.stringify(input.runtimeSelection)
: null, : null,
builtInDefault ? 1 : 0,
now, now,
now now
) )
@@ -1913,6 +1928,32 @@ export class AssistantDatabase {
)` )`
) )
.run(conversationId) .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 database
.prepare('DELETE FROM tasks WHERE conversation_id = ?') .prepare('DELETE FROM tasks WHERE conversation_id = ?')
.run(conversationId) .run(conversationId)
@@ -2214,7 +2255,11 @@ export class AssistantDatabase {
this.requireDatabase() this.requireDatabase()
.prepare( .prepare(
`UPDATE channel_outbox `UPDATE channel_outbox
SET state = ?, SET state = CASE
WHEN ? = 'failed' AND attempts + 1 >= 5
THEN 'terminal'
ELSE ?
END,
attempts = attempts + 1, attempts = attempts + 1,
message_json = CASE message_json = CASE
WHEN ? = 'delivered' OR attempts + 1 >= 5 WHEN ? = 'delivered' OR attempts + 1 >= 5
@@ -2223,7 +2268,7 @@ export class AssistantDatabase {
END END
WHERE id = ?` WHERE id = ?`
) )
.run(state, state, id) .run(state, state, state, id)
} }
listUndeliveredChannelResults( listUndeliveredChannelResults(
@@ -2232,7 +2277,7 @@ export class AssistantDatabase {
): Array<{ ): Array<{
id: string id: string
message: ChannelResultMessage message: ChannelResultMessage
state: 'pending' | 'failed' state: 'pending' | 'failed' | 'terminal'
attempts: number attempts: number
createdAt: number createdAt: number
}> { }> {
@@ -2252,7 +2297,6 @@ export class AssistantDatabase {
) AS cumulative_bytes ) AS cumulative_bytes
FROM channel_outbox FROM channel_outbox
WHERE state != 'delivered' WHERE state != 'delivered'
AND attempts < 5
${channel === undefined ? '' : 'AND channel = ?'} ${channel === undefined ? '' : 'AND channel = ?'}
) )
SELECT id, message_json, state, attempts, created_at SELECT id, message_json, state, attempts, created_at
@@ -2272,7 +2316,7 @@ export class AssistantDatabase {
) as Array<{ ) as Array<{
id: string id: string
message_json: string message_json: string
state: 'pending' | 'failed' state: 'pending' | 'failed' | 'terminal'
attempts: number attempts: number
created_at: number created_at: number
}> }>
@@ -5340,32 +5384,46 @@ export class AssistantDatabase {
]! ]!
).toISOString() ).toISOString()
: null : null
const result = database database.exec('BEGIN IMMEDIATE')
.prepare( try {
`UPDATE heartbeat_runs const result = database
SET status = 'failed', next_attempt_at = ?, .prepare(
completed_at = ?, error = ?, lease_owner = NULL, `UPDATE heartbeat_runs
lease_expires_at = NULL, updated_at = ? SET status = 'failed', next_attempt_at = ?,
WHERE id = ? AND status = 'claimed' AND lease_owner = ?` completed_at = ?, error = ?, lease_owner = NULL,
) lease_expires_at = NULL, updated_at = ?
.run( WHERE id = ? AND config_id = ?
nextAttemptAt, AND status = 'claimed' AND lease_owner = ?
timestamp, AND lease_expires_at > ?`
error.slice(0, 2_000), )
timestamp, .run(
claim.run.id, nextAttemptAt,
claim.leaseOwner timestamp,
) error.slice(0, 2_000),
if (result.changes !== 1) { timestamp,
throw new Error('Heartbeat lease is no longer active') 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) return this.getHeartbeatRun(claim.run.id)
} }
@@ -5741,12 +5799,12 @@ export class AssistantDatabase {
const version = database const version = database
.prepare('PRAGMA user_version') .prepare('PRAGMA user_version')
.get() as { user_version: number } .get() as { user_version: number }
if (version.user_version > 23) { if (version.user_version > 25) {
throw new Error( throw new Error(
` GoodBuddy ${version.user_version}` ` GoodBuddy ${version.user_version}`
) )
} }
if (version.user_version === 23) { if (version.user_version === 25) {
return return
} }
if (version.user_version < 1) { if (version.user_version < 1) {
@@ -5760,6 +5818,8 @@ export class AssistantDatabase {
default_work_mode TEXT NOT NULL default_work_mode TEXT NOT NULL
CHECK(default_work_mode IN ('ask', 'execute')), CHECK(default_work_mode IN ('ask', 'execute')),
runtime_selection_json TEXT, 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')), status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@@ -7001,6 +7061,122 @@ export class AssistantDatabase {
throw error 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 { private requireDatabase(): DatabaseSync {
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count ).count
check.close() check.close()
migrated.close() migrated.close()
expect(version).toBe(23) expect(version).toBe(25)
expect(heartbeatTableCount).toBe(4) expect(heartbeatTableCount).toBe(4)
}) })
+2 -1
View File
@@ -74,7 +74,7 @@ export class MemoryDedupStore implements DedupStore {
export type OutboxEntry = { export type OutboxEntry = {
id: string id: string
message: ChannelResultMessage message: ChannelResultMessage
state: 'pending' | 'delivered' | 'failed' state: 'pending' | 'delivered' | 'failed' | 'terminal'
attempts: number attempts: number
createdAt: number createdAt: number
} }
@@ -129,6 +129,7 @@ export class MemoryOutbox implements Outbox {
entry.state = 'failed' entry.state = 'failed'
entry.attempts += 1 entry.attempts += 1
if (entry.attempts >= 5) { if (entry.attempts >= 5) {
entry.state = 'terminal'
entry.message = this.withoutAttachments(entry.message) entry.message = this.withoutAttachments(entry.message)
} }
} }
+43
View File
@@ -432,6 +432,49 @@ describe('ChannelService', () => {
await service.stop() 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 () => { it('releases the event claim when no durable result can be queued', async () => {
const driver = new FakeChannelDriver() const driver = new FakeChannelDriver()
const store = new MemoryDedupStore() const store = new MemoryDedupStore()
+6 -1
View File
@@ -198,7 +198,12 @@ export class ChannelService {
if (this.state !== 'running') { if (this.state !== 'running') {
return return
} }
if (entry.attempts >= 5) { if (entry.state === 'terminal' || entry.attempts >= 5) {
this.onDeliveryFailure?.(
new Error(
`通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
)
)
continue continue
} }
try { try {
+123
View File
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto'
import { import {
mkdtemp, mkdtemp,
mkdir, mkdir,
readFile,
readdir,
rm, rm,
writeFile writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
@@ -186,6 +188,26 @@ afterEach(async () => {
}) })
describe('DocumentOcrModelManager', () => { 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<typeof fetch>(),
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 () => { it('reports a removed catalog model as unavailable', async () => {
const { manager } = await createManager() const { manager } = await createManager()
@@ -323,6 +345,107 @@ describe('DocumentOcrModelManager', () => {
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/') 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 () => { it('downloads the same canonical package from Hugging Face', async () => {
const { manager } = await createManager() const { manager } = await createManager()
+117 -95
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash } from 'node:crypto'
import { import {
copyFile, copyFile,
lstat, lstat,
@@ -11,11 +11,12 @@ import {
stat, stat,
writeFile writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { resolve } from 'node:path'
import { import {
documentOcrAssetsSchema, documentOcrAssetsSchema,
documentOcrModelCatalogEntrySchema, documentOcrModelCatalogEntrySchema,
documentOcrModelCatalogViewEntrySchema, documentOcrModelCatalogViewEntrySchema,
documentOcrModelProgressSnapshotSchema,
documentOcrModelSnapshotSchema, documentOcrModelSnapshotSchema,
documentParsingModelStatusSchema, documentParsingModelStatusSchema,
installedDocumentOcrModelSchema, installedDocumentOcrModelSchema,
@@ -25,6 +26,7 @@ import {
type DocumentOcrModelCatalogViewEntry, type DocumentOcrModelCatalogViewEntry,
type DocumentOcrModelFile, type DocumentOcrModelFile,
type DocumentOcrModelOperation, type DocumentOcrModelOperation,
type DocumentOcrModelProgressSnapshot,
type DocumentOcrModelSnapshot, type DocumentOcrModelSnapshot,
type InstalledDocumentOcrModel type InstalledDocumentOcrModel
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
@@ -41,10 +43,20 @@ import {
extractModelArchive extractModelArchive
} from './model-archive' } from './model-archive'
import { fetchModelDownloadResponse } from './model-download-transport' 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 DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json' const MANIFEST_FILE_NAME = 'manifest.json'
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024 const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024 const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
const executableExtensionPattern = const executableExtensionPattern =
@@ -55,6 +67,11 @@ type ActiveOperation = {
progress: DocumentOcrModelOperation progress: DocumentOcrModelOperation
} }
type ActiveVerification = {
generation: number
promise: Promise<void>
}
export type DocumentOcrModelManagerOptions = { export type DocumentOcrModelManagerOptions = {
userDataDirectory: string userDataDirectory: string
fetch: typeof fetch fetch: typeof fetch
@@ -65,16 +82,6 @@ export type DocumentOcrModelManagerOptions = {
maxFileBytes?: number 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( function cloneCatalogEntry(
entry: DocumentOcrModelCatalogEntry entry: DocumentOcrModelCatalogEntry
): DocumentOcrModelCatalogEntry { ): DocumentOcrModelCatalogEntry {
@@ -99,43 +106,17 @@ function toCatalogView(entry: DocumentOcrModelCatalogEntry) {
} }
function safeChild(parent: string, name: string): string { function safeChild(parent: string, name: string): string {
const child = resolve(parent, name) return managedModelChild(
if (dirname(child) !== resolve(parent)) { parent,
throw new Error('OCR 模型路径超出受管目录') name,
} 'OCR 模型路径超出受管目录'
return child )
} }
function toArrayBuffer(buffer: Buffer): ArrayBuffer { function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return Uint8Array.from(buffer).buffer 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 { function parseYamlScalar(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) { if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/''/gu, "'") return value.slice(1, -1).replace(/''/gu, "'")
@@ -184,7 +165,8 @@ export class DocumentOcrModelManager {
| Promise<ModelDownloadSource> | Promise<ModelDownloadSource>
private readonly maxFileBytes: number private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>() private readonly operations = new Map<string, ActiveOperation>()
private readonly verifiedModels = new Map<string, Promise<void>>() private readonly verifiedModels = new Map<string, ActiveVerification>()
private readonly verificationGenerations = new Map<string, number>()
constructor(options: DocumentOcrModelManagerOptions) { constructor(options: DocumentOcrModelManagerOptions) {
if (!options.userDataDirectory.trim()) { if (!options.userDataDirectory.trim()) {
@@ -220,6 +202,7 @@ export class DocumentOcrModelManager {
async getSnapshot(): Promise<DocumentOcrModelSnapshot> { async getSnapshot(): Promise<DocumentOcrModelSnapshot> {
await this.ensureRoot() await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [selectedDownloadSource, installed] = await Promise.all([ const [selectedDownloadSource, installed] = await Promise.all([
this.getDownloadSource(), this.getDownloadSource(),
this.readInstalled() this.readInstalled()
@@ -235,6 +218,14 @@ export class DocumentOcrModelManager {
}) })
} }
getProgressSnapshot(): DocumentOcrModelProgressSnapshot {
return documentOcrModelProgressSnapshotSchema.parse({
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
}))
})
}
async getStatus( async getStatus(
modelId: string modelId: string
): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> { ): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> {
@@ -317,7 +308,7 @@ export class DocumentOcrModelManager {
await this.assertNotInstalled(entry.id) await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of resolvedPackage.files) { for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring' operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name operation.progress.currentFile = file.name
await this.downloadFile( await this.downloadFile(
@@ -335,10 +326,10 @@ export class DocumentOcrModelManager {
stagingDirectory, stagingDirectory,
operation.controller.signal operation.controller.signal
) )
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id)) await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined stagingDirectory = undefined
this.verifiedModels.delete(entry.id) this.invalidateVerification(entry.id)
return installed return installed
} finally { } finally {
detachAbort() detachAbort()
@@ -373,7 +364,7 @@ export class DocumentOcrModelManager {
stagingDirectory = await this.createStagingDirectory(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring' operation.progress.phase = 'transferring'
for (const file of entry.files) { for (const file of entry.files) {
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name) const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name) const destination = safeChild(stagingDirectory, file.name)
@@ -391,10 +382,10 @@ export class DocumentOcrModelManager {
stagingDirectory, stagingDirectory,
operation.controller.signal operation.controller.signal
) )
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id)) await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined stagingDirectory = undefined
this.verifiedModels.delete(entry.id) this.invalidateVerification(entry.id)
return installed return installed
} finally { } finally {
detachAbort() detachAbort()
@@ -521,10 +512,10 @@ export class DocumentOcrModelManager {
`${JSON.stringify(installed, null, 2)}\n`, `${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' } { encoding: 'utf8', flag: 'wx' }
) )
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id)) await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined stagingDirectory = undefined
this.verifiedModels.delete(entry.id) this.invalidateVerification(entry.id)
return installed return installed
} finally { } finally {
this.operations.delete(entry.id) this.operations.delete(entry.id)
@@ -547,7 +538,7 @@ export class DocumentOcrModelManager {
async remove(modelId: string): Promise<void> { async remove(modelId: string): Promise<void> {
const id = localOcrModelIdSchema.parse(modelId) const id = localOcrModelIdSchema.parse(modelId)
this.cancel(id) this.cancel(id)
this.verifiedModels.delete(id) this.invalidateVerification(id)
await rm(this.modelDirectory(id), { await rm(this.modelDirectory(id), {
recursive: true, recursive: true,
force: true force: true
@@ -560,6 +551,7 @@ export class DocumentOcrModelManager {
} }
this.operations.clear() this.operations.clear()
this.verifiedModels.clear() this.verifiedModels.clear()
this.verificationGenerations.clear()
} }
private async ensureRoot(): Promise<void> { private async ensureRoot(): Promise<void> {
@@ -613,16 +605,7 @@ export class DocumentOcrModelManager {
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
controller: AbortController controller: AbortController
): () => void { ): () => void {
if (!signal) { return attachModelAbortSignal(signal, controller)
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
} }
private async assertNotInstalled(modelId: string): Promise<void> { private async assertNotInstalled(modelId: string): Promise<void> {
@@ -630,11 +613,7 @@ export class DocumentOcrModelManager {
await lstat(this.modelDirectory(modelId)) await lstat(this.modelDirectory(modelId))
throw new Error('OCR 模型已安装') throw new Error('OCR 模型已安装')
} catch (error) { } catch (error) {
if ( if (isMissingFileError(error)) {
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return return
} }
throw error throw error
@@ -642,12 +621,11 @@ export class DocumentOcrModelManager {
} }
private async createStagingDirectory(modelId: string): Promise<string> { private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild( return createModelStagingDirectory(
this.rootDirectory, this.rootDirectory,
`.install-${modelId}-${randomUUID()}` modelId,
'OCR 模型路径超出受管目录'
) )
await mkdir(directory, { recursive: false })
return directory
} }
private async downloadFile( private async downloadFile(
@@ -682,29 +660,34 @@ export class DocumentOcrModelManager {
throw new Error(`OCR 模型文件大小不匹配:${file.name}`) throw new Error(`OCR 模型文件大小不匹配:${file.name}`)
} }
const partialPath = `${destination}${PARTIAL_SUFFIX}` const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx') const handle = await open(partialPath, 'wx')
const reader = response.body.getReader() const reader = response.body.getReader()
const hash = createHash('sha256') const hash = createHash('sha256')
let written = 0 let written = 0
try { try {
while (true) { while (true) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
const result = await reader.read() const result = await reader.read()
if (result.done) { if (result.done) {
break break
} }
written += result.value.byteLength
if ( if (
written > file.size || written + result.value.byteLength > file.size ||
written > this.maxFileBytes written + result.value.byteLength > this.maxFileBytes
) { ) {
await reader.cancel() await reader.cancel()
throw new RangeError(`OCR 模型文件过大:${file.name}`) throw new RangeError(`OCR 模型文件过大:${file.name}`)
} }
await handle.write(result.value) const persistedBytes = await writeModelBuffer(
hash.update(result.value) handle,
operation.progress.completedBytes += result.value.byteLength result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
} }
} catch (error) { } catch (error) {
await reader.cancel().catch(() => undefined) await reader.cancel().catch(() => undefined)
@@ -732,7 +715,7 @@ export class DocumentOcrModelManager {
} }
const entries = await readdir(sourceDirectory, { withFileTypes: true }) const entries = await readdir(sourceDirectory, { withFileTypes: true })
for (const localEntry of entries) { for (const localEntry of entries) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
if ( if (
localEntry.isSymbolicLink() || localEntry.isSymbolicLink() ||
executableExtensionPattern.test(localEntry.name) executableExtensionPattern.test(localEntry.name)
@@ -741,13 +724,13 @@ export class DocumentOcrModelManager {
} }
} }
for (const file of entry.files) { for (const file of entry.files) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
const path = safeChild(sourceDirectory, file.name) const path = safeChild(sourceDirectory, file.name)
const info = await lstat(path) const info = await lstat(path)
if (!info.isFile() || info.isSymbolicLink()) { if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`OCR 模型文件必须是普通文件:${file.name}`) throw new Error(`OCR 模型文件必须是普通文件:${file.name}`)
} }
const actual = await hashFile(path, signal) const actual = await hashModelFile(path, signal)
if ( if (
actual.size !== file.size || actual.size !== file.size ||
actual.sha256 !== file.sha256 actual.sha256 !== file.sha256
@@ -765,11 +748,11 @@ export class DocumentOcrModelManager {
): Promise<InstalledDocumentOcrModel> { ): Promise<InstalledDocumentOcrModel> {
const files = [] const files = []
for (const file of entry.files) { for (const file of entry.files) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
files.push({ files.push({
name: file.name, name: file.name,
role: file.role, role: file.role,
...(await hashFile( ...(await hashModelFile(
safeChild(stagingDirectory, file.name), safeChild(stagingDirectory, file.name),
signal signal
)) ))
@@ -854,7 +837,9 @@ export class DocumentOcrModelManager {
candidate.name === file.name && candidate.name === file.name &&
candidate.role === file.role candidate.role === file.role
) )
const actual = await hashFile(safeChild(directory, file.name)) const actual = await hashModelFile(
safeChild(directory, file.name)
)
if ( if (
!installed || !installed ||
actual.size !== file.size || actual.size !== file.size ||
@@ -870,15 +855,37 @@ export class DocumentOcrModelManager {
private getVerifiedStatus( private getVerifiedStatus(
entry: DocumentOcrModelCatalogEntry entry: DocumentOcrModelCatalogEntry
): Promise<void> { ): Promise<void> {
let verification = this.verifiedModels.get(entry.id) const generation = this.verificationGenerations.get(entry.id) ?? 0
if (!verification) { const active = this.verifiedModels.get(entry.id)
verification = this.verifyInstalledModel(entry).catch((error) => { if (active?.generation === generation) {
this.verifiedModels.delete(entry.id) return active.promise
throw error
})
this.verifiedModels.set(entry.id, verification)
} }
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( private async loadVerifiedAssets(
@@ -932,4 +939,19 @@ export class DocumentOcrModelManager {
dictionary: loaded.get('dictionary') dictionary: loaded.get('dictionary')
}) })
} }
private cleanupStaleArtifacts(): Promise<void> {
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 模型路径超出受管目录'
})
}
} }
+165 -74
View File
@@ -49,10 +49,7 @@ import {
} from './window' } from './window'
import { createTrayIcon } from './tray-icon' import { createTrayIcon } from './tray-icon'
import { resolveBundledRuntimePaths } from './agent/bundled-runtimes' import { resolveBundledRuntimePaths } from './agent/bundled-runtimes'
import type { import type { ContinueHostLauncher } from './agent/continue-host-adapter'
ContinueHostChild,
ContinueHostLauncher
} from './agent/continue-host-adapter'
import { resolvePortableUserDataPath } from './portable-user-data' import { resolvePortableUserDataPath } from './portable-user-data'
import { BrowserService } from './browser/browser-service' import { BrowserService } from './browser/browser-service'
import { SubagentService } from './assistant/subagent-service' import { SubagentService } from './assistant/subagent-service'
@@ -83,7 +80,11 @@ import {
type DeepSeekHarnessFork type DeepSeekHarnessFork
} from './agent/deepseek-harness-utility-launcher' } from './agent/deepseek-harness-utility-launcher'
import { buildControlledHarnessEnvironment } from './agent/process-environment' 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 { RuntimeExtensionStore } from './agent/runtime-extension-store'
import { import {
DshNpmExtensionInstaller, DshNpmExtensionInstaller,
@@ -95,8 +96,14 @@ import {
repairStaleWindowsNotificationShortcuts, repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId resolveWindowsAppUserModelId
} from './windows-notification-identity' } 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 mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
const portableUserDataPath = resolvePortableUserDataPath({ const portableUserDataPath = resolvePortableUserDataPath({
packaged: app.isPackaged, packaged: app.isPackaged,
@@ -205,39 +212,28 @@ const launchContinueHost: ContinueHostLauncher = (
stdio: 'pipe' stdio: 'pipe'
} }
) )
let exitCode: number | null = null return createContinueUtilityProcessChild({
let killed = false
utilityChild.on('exit', (code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
get pid() { get pid() {
return utilityChild.pid return utilityChild.pid
}, },
stderr: utilityChild.stderr, stderr: utilityChild.stderr,
once: (_event, listener) => { kill: () => utilityChild.kill(),
utilityChild.once('error', (_type, location, report) => { onExit: (listener) => {
listener( utilityChild.on('exit', listener)
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
})
return child
}, },
kill: () => { onceExit: (listener) => {
killed = true utilityChild.once('exit', listener)
return utilityChild.kill() },
onceError: (listener) => {
utilityChild.once('error', listener)
},
removeExitListener: (listener) => {
utilityChild.removeListener('exit', listener)
},
removeErrorListener: (listener) => {
utilityChild.removeListener('error', listener)
} }
} })
return child
} }
const forkDeepSeekHarness: DeepSeekHarnessFork = ( const forkDeepSeekHarness: DeepSeekHarnessFork = (
@@ -254,20 +250,7 @@ const forkDeepSeekHarness: DeepSeekHarnessFork = (
function terminateHarnessUtilityProcess( function terminateHarnessUtilityProcess(
child: ReturnType<DeepSeekHarnessFork> child: ReturnType<DeepSeekHarnessFork>
): void { ): void {
if (process.platform === 'win32' && child.pid) { requestProcessTreeTermination(child, { spawn })
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill()
} }
const launchWechatSidecar: WechatSidecarLauncher = () => { const launchWechatSidecar: WechatSidecarLauncher = () => {
@@ -521,6 +504,12 @@ if (hasSingleInstanceLock) {
parseDocument: documentParsingService.parse parseDocument: documentParsingService.parse
}) })
knowledgeService = startupKnowledgeService knowledgeService = startupKnowledgeService
let activeEmbeddingProvider:
| ReturnType<typeof createEmbeddingProvider>
| undefined
let activeRerankProvider:
| ReturnType<typeof createRerankProvider>
| undefined
const startupAssistantDatabase = new AssistantDatabase( const startupAssistantDatabase = new AssistantDatabase(
join(app.getPath('userData'), 'assistant.sqlite') join(app.getPath('userData'), 'assistant.sqlite')
) )
@@ -617,13 +606,29 @@ if (hasSingleInstanceLock) {
}, },
initializeKnowledgeAndGateway: async () => { initializeKnowledgeAndGateway: async () => {
await startupKnowledgeService.initialize() await startupKnowledgeService.initialize()
const embeddingProvider = createEmbeddingProvider(
initialResolvedSettings
)
const rerankProvider = createRerankProvider(
initialResolvedSettings
)
await Promise.all([ await Promise.all([
startupKnowledgeService.setEmbeddingProvider( startupKnowledgeService.setEmbeddingProvider(
createEmbeddingProvider(initialResolvedSettings) embeddingProvider
).catch(() => undefined), ).then(
() => {
activeEmbeddingProvider = embeddingProvider
},
() => undefined
),
startupKnowledgeService.setRerankProvider( startupKnowledgeService.setRerankProvider(
createRerankProvider(initialResolvedSettings) rerankProvider
).catch(() => undefined) ).then(
() => {
activeRerankProvider = rerankProvider
},
() => undefined
)
]) ])
await startupKnowledgeGateway.start() await startupKnowledgeGateway.start()
}, },
@@ -663,11 +668,19 @@ if (hasSingleInstanceLock) {
}) })
const approvalBroker = new ToolApprovalBroker() const approvalBroker = new ToolApprovalBroker()
const shortcutRegistered = globalShortcut.register(shortcut, () => { const shortcutSettingsService = new ShortcutSettingsService(
if (mainWindow) { new ShortcutSettingsStore(
toggleWindow(mainWindow) join(app.getPath('userData'), 'shortcut-settings.json')
} ),
}) globalShortcut,
() => {
if (mainWindow) {
toggleWindow(mainWindow)
}
},
process.platform
)
await shortcutSettingsService.initialize()
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve() let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
let runtimeReconfigurationClosing = false let runtimeReconfigurationClosing = false
@@ -677,24 +690,97 @@ if (hasSingleInstanceLock) {
throw new Error('Runtime 配置正在关闭') throw new Error('Runtime 配置正在关闭')
} }
const settings = await settingsStore.getResolvedSettings() const settings = await settingsStore.getResolvedSettings()
if (knowledgeService) { const nextEmbeddingProvider =
await knowledgeService.setEmbeddingProvider( createEmbeddingProvider(settings)
createEmbeddingProvider(settings) const nextRerankProvider = createRerankProvider(settings)
) let nextRuntime: AgentRuntime | undefined
await knowledgeService.setRerankProvider( let nextSubagentRuntime: AgentRuntime | undefined
createRerankProvider(settings) let nextSubagentProfileRuntimes:
| ReadonlyMap<string, AgentRuntime>
| 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( let runtimeConsumed = false
await createConfiguredRuntime(settings) 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) runtimeReconfigurationQueue = operation.catch(() => undefined)
return operation return operation
@@ -707,7 +793,7 @@ if (hasSingleInstanceLock) {
removeIpcHandlers = registerIpcHandlers( removeIpcHandlers = registerIpcHandlers(
mainWindow, mainWindow,
runtime, runtime,
shortcutRegistered ? shortcut : '未注册', legacyDefaultShortcut,
settingsStore, settingsStore,
capabilityService, capabilityService,
contextManager, contextManager,
@@ -735,7 +821,8 @@ if (hasSingleInstanceLock) {
documentOcrBroker, documentOcrBroker,
releaseNotesService, releaseNotesService,
goodbuddyConfigService, goodbuddyConfigService,
runtimeExtensionStore runtimeExtensionStore,
shortcutSettingsService
) )
loadMainWindow(mainWindow) loadMainWindow(mainWindow)
setImmediate(() => { setImmediate(() => {
@@ -786,10 +873,14 @@ if (hasSingleInstanceLock) {
showWindow(mainWindow) showWindow(mainWindow)
} }
}) })
}).catch(() => { }).catch((error: unknown) => {
console.error(
'GoodBuddy startup failed',
createStartupFailureDiagnostic(error)
)
dialog.showErrorBox( dialog.showErrorBox(
'GoodBuddy 启动失败', 'GoodBuddy 启动失败',
'本地数据或 Runtime 服务初始化失败。请重启应用;若问题持续,请备份后清理应用数据。' formatStartupFailureMessage(error)
) )
app.quit() app.quit()
}) })
+1076 -14
View File
File diff suppressed because it is too large Load Diff
+280 -125
View File
@@ -16,7 +16,10 @@ import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path' import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { formatShortcutForDisplay } from '../shared/shortcut' import {
formatShortcutForDisplay,
globalShortcutSettingsUpdateSchema
} from '../shared/shortcut'
import { readBoundedFile } from './workspace-file-access' import { readBoundedFile } from './workspace-file-access'
import { import {
approvalDecisionSchema, approvalDecisionSchema,
@@ -203,6 +206,7 @@ import {
type RuntimeExtensionMarketplaceSnapshot type RuntimeExtensionMarketplaceSnapshot
} from '../shared/runtime-extension-contracts' } from '../shared/runtime-extension-contracts'
import type { RuntimeExtensionStore } from './agent/runtime-extension-store' import type { RuntimeExtensionStore } from './agent/runtime-extension-store'
import type { ShortcutSettingsService } from './shortcut-settings-service'
import type { ContextManager } from './context-manager' import type { ContextManager } from './context-manager'
import type { KnowledgeService } from './knowledge/knowledge-service' import type { KnowledgeService } from './knowledge/knowledge-service'
import { import {
@@ -274,6 +278,7 @@ import {
import { AgentEventBuffer } from './agent-event-buffer' import { AgentEventBuffer } from './agent-event-buffer'
const requestIdSchema = z.string().uuid() const requestIdSchema = z.string().uuid()
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
const runtimeConfigFileMetadata = { const runtimeConfigFileMetadata = {
opencode: { opencode: {
filterName: 'OpenCode 配置', filterName: 'OpenCode 配置',
@@ -415,6 +420,31 @@ function safeRuntimeError(error: unknown, fallback: string): string {
return safeToolErrorDetail(error, 2_000) ?? fallback return safeToolErrorDetail(error, 2_000) ?? fallback
} }
async function activateOrRollback<T>(input: {
previous: T
persistCandidate(): Promise<T>
activate(): Promise<void>
persistPrevious(previous: T): Promise<unknown>
}): Promise<T> {
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(): { function createPromiseTracker(): {
track<T>(operation: Promise<T>): Promise<T> track<T>(operation: Promise<T>): Promise<T>
drain(): Promise<void> drain(): Promise<void>
@@ -883,10 +913,35 @@ export function registerIpcHandlers(
documentOcrBroker?: DocumentOcrBroker, documentOcrBroker?: DocumentOcrBroker,
releaseNotesService?: ReleaseNotesService, releaseNotesService?: ReleaseNotesService,
goodbuddyConfigService?: GoodBuddyConfigService, goodbuddyConfigService?: GoodBuddyConfigService,
runtimeExtensionStore?: RuntimeExtensionStore runtimeExtensionStore?: RuntimeExtensionStore,
shortcutSettingsService?: ShortcutSettingsService
): () => Promise<void> { ): () => Promise<void> {
const activeRequests = new Map<string, AbortController>() type ActiveRequestLease = {
const activeRequestConversations = new Map<string, string>() controller: AbortController
conversationId: string
}
const activeRequests = new Map<string, ActiveRequestLease>()
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<string, { flush(): void }>() const activeEventBuffers = new Map<string, { flush(): void }>()
const pendingAgentQuestions = new Map< const pendingAgentQuestions = new Map<
string, string,
@@ -900,6 +955,17 @@ export function registerIpcHandlers(
const pendingRendererPersistence = new Map<string, () => void>() const pendingRendererPersistence = new Map<string, () => void>()
let pendingGoodBuddyConfigReload = false let pendingGoodBuddyConfigReload = false
let goodBuddyConfigReloadQueue: Promise<void> = Promise.resolve() let goodBuddyConfigReloadQueue: Promise<void> = Promise.resolve()
let runtimeSettingsUpdateQueue: Promise<void> = Promise.resolve()
const enqueueRuntimeSettingsUpdate = <T>(
transaction: () => Promise<T>
): Promise<T> => {
const result = runtimeSettingsUpdateQueue.then(transaction)
runtimeSettingsUpdateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
const executionTracker = createPromiseTracker() const executionTracker = createPromiseTracker()
const maintenanceTracker = createPromiseTracker() const maintenanceTracker = createPromiseTracker()
const trackExecution = executionTracker.track const trackExecution = executionTracker.track
@@ -1010,8 +1076,8 @@ export function registerIpcHandlers(
} }
}) })
const abortActiveRequests = (reason: string): void => { const abortActiveRequests = (reason: string): void => {
for (const controller of activeRequests.values()) { for (const lease of activeRequests.values()) {
controller.abort(new Error(reason)) lease.controller.abort(new Error(reason))
} }
activeRequests.clear() activeRequests.clear()
} }
@@ -1316,7 +1382,7 @@ export function registerIpcHandlers(
(candidate) => candidate === conversationId (candidate) => candidate === conversationId
) || ) ||
[...activeRequestConversations.values()].some( [...activeRequestConversations.values()].some(
(candidate) => candidate === conversationId (candidate) => candidate.conversationId === conversationId
) )
const pumpConversationQueue = async ( const pumpConversationQueue = async (
@@ -1538,14 +1604,17 @@ export function registerIpcHandlers(
externalSignal?.addEventListener('abort', abortFromExternal, { externalSignal?.addEventListener('abort', abortFromExternal, {
once: true once: true
}) })
activeRequests.set(requestId, controller)
const runtimeConversationId = const runtimeConversationId =
remoteContext?.conversationId ?? remoteContext?.conversationId ??
(input.origin === 'schedule' (input.origin === 'schedule'
? input.schedule.conversationId ? input.schedule.conversationId
: undefined) ?? : undefined) ??
`${origin}:${schedule.id}` `${origin}:${schedule.id}`
activeRequestConversations.set(requestId, runtimeConversationId) const releaseActiveRequest = leaseActiveRequest(
requestId,
runtimeConversationId,
controller
)
if (input.origin !== 'delegation') { if (input.origin !== 'delegation') {
assistantDatabase.updateTaskStatus(taskId, 'running') assistantDatabase.updateTaskStatus(taskId, 'running')
} else { } else {
@@ -1565,6 +1634,7 @@ export function registerIpcHandlers(
} }
let output = '' let output = ''
let completed = false let completed = false
let backgroundQuestionError: Error | undefined
let knowledgeCapabilityToken: string | undefined let knowledgeCapabilityToken: string | undefined
const resultAttachments: ChannelMediaAttachment[] = [] const resultAttachments: ChannelMediaAttachment[] = []
const artifactIds: string[] = [] const artifactIds: string[] = []
@@ -1766,6 +1836,37 @@ export function registerIpcHandlers(
if (taskEvent.type === 'artifact') { if (taskEvent.type === 'artifact') {
artifactIds.push(taskEvent.artifactId) 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<typeof setTimeout>
| undefined
try {
await Promise.race([
rejection,
new Promise<void>((resolveTimeout) => {
rejectionTimeout = setTimeout(
resolveTimeout,
BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS
)
rejectionTimeout.unref?.()
})
])
} finally {
if (rejectionTimeout) {
clearTimeout(rejectionTimeout)
}
}
controller.abort(error)
throw error
}
eventBuffer.push(taskEvent) eventBuffer.push(taskEvent)
if (taskEvent.type === 'tool' && remoteContext) { if (taskEvent.type === 'tool' && remoteContext) {
publishRemoteActivity({ publishRemoteActivity({
@@ -1879,8 +1980,11 @@ export function registerIpcHandlers(
} }
} catch (error) { } catch (error) {
eventBuffer.flush() eventBuffer.flush()
const message = safeRuntimeError(error, '定时任务执行失败') const message = backgroundQuestionError
const cancelled = controller.signal.aborted ? backgroundQuestionError.message
: safeRuntimeError(error, '定时任务执行失败')
const cancelled =
controller.signal.aborted && !backgroundQuestionError
assistantDatabase.updateTaskStatus( assistantDatabase.updateTaskStatus(
taskId, taskId,
cancelled ? 'cancelled' : 'failed', cancelled ? 'cancelled' : 'failed',
@@ -1924,8 +2028,7 @@ export function registerIpcHandlers(
) )
knowledgeGateway?.revoke(knowledgeCapabilityToken) knowledgeGateway?.revoke(knowledgeCapabilityToken)
goodbuddyConfigService?.revokeRequest(requestId) goodbuddyConfigService?.revokeRequest(requestId)
activeRequests.delete(requestId) releaseActiveRequest()
activeRequestConversations.delete(requestId)
await flushGoodBuddyConfigReload().catch(() => undefined) await flushGoodBuddyConfigReload().catch(() => undefined)
} }
} }
@@ -2297,6 +2400,34 @@ export function registerIpcHandlers(
detail: parsed.prompt, detail: parsed.prompt,
status: 'running' 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 let executionRuntime: AgentRuntime | undefined
if (parsed.workMode === 'execute') { if (parsed.workMode === 'execute') {
@@ -2316,30 +2447,7 @@ export function registerIpcHandlers(
error, error,
'远程 Execute Runtime 不可用' '远程 Execute Runtime 不可用'
) )
assistantDatabase.updateTaskStatus( return finalizeExecutePreflightFailure(unavailable)
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 }
} }
if ( if (
!executionStatus.available || !executionStatus.available ||
@@ -2349,30 +2457,7 @@ export function registerIpcHandlers(
? '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型' ? '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型'
: executionStatus.detail?.trim() || : executionStatus.detail?.trim() ||
'所选处理后端当前不可用,请在消息通道设置中检查 Runtime 或模型连接' '所选处理后端当前不可用,请在消息通道设置中检查 Runtime 或模型连接'
assistantDatabase.updateTaskStatus( return finalizeExecutePreflightFailure(unavailable)
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 }
} }
} }
@@ -2499,12 +2584,20 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.appInfo, (event): AppInfo => { registerHandler(ipcChannels.appInfo, (event): AppInfo => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
const shortcutSnapshot = shortcutSettingsService?.getSnapshot()
return { return {
name: app.getName(), name: app.getName(),
version: app.getVersion(), version: app.getVersion(),
platform: process.platform, platform: process.platform,
arch: process.arch, 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) reservedConversationQueueItems.get(parsedInput.conversationId)
if ( if (
[...activeRequestConversations.values()].some( [...activeRequestConversations.values()].some(
(conversationId) => (lease) =>
conversationId === parsedInput.conversationId lease.conversationId === parsedInput.conversationId
) || ) ||
[...preparingRequestConversations.values()].some( [...preparingRequestConversations.values()].some(
(conversationId) => (conversationId) =>
@@ -2730,39 +2823,49 @@ export function registerIpcHandlers(
const enrichedRequest = contextManager.enrichRequest( const enrichedRequest = contextManager.enrichRequest(
parsedRequest 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)) { if (activeRequests.has(enrichedRequest.requestId)) {
throw new Error('请求正在执行') throw new Error('请求正在执行')
} }
const hasKnowledgeScope = knowledgeLibraryIds.length > 0
const controller = new AbortController()
const configAccess = const configAccess =
goodbuddyConfigService && !imageGeneration goodbuddyConfigService && !imageGeneration
? enrichedRequest.workMode === 'execute' ? enrichedRequest.workMode === 'execute'
? 'write' ? 'write'
: 'read' : 'read'
: 'none' : '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 = const configWorkspacePath =
configAccess === 'none' configAccess === 'none'
? undefined ? undefined
: enrichedRequest.projectId : enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId).rootPath ? assistantDatabase.getProject(enrichedRequest.projectId).rootPath
: (await settingsStore.getResolvedSettings()).workspacePath : resolvedRuntimeSettings?.workspacePath
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime) const controller = new AbortController()
const enabledBuiltinMcpServers = selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
const scopedCapability = grantScopedDataCapability({ const scopedCapability = grantScopedDataCapability({
gateway: knowledgeGateway, gateway: knowledgeGateway,
runtime: selectedRuntime, runtime: selectedRuntime,
@@ -2823,10 +2926,10 @@ export function registerIpcHandlers(
knowledgeGateway?.revoke(knowledgeCapabilityToken) knowledgeGateway?.revoke(knowledgeCapabilityToken)
throw error throw error
} }
activeRequests.set(request.requestId, controller) const releaseActiveRequest = leaseActiveRequest(
activeRequestConversations.set(
request.requestId, request.requestId,
request.conversationId request.conversationId,
controller
) )
if (parsedInput.queueItemId) { if (parsedInput.queueItemId) {
const dispatchTimeout = queueDispatchTimers.get( const dispatchTimeout = queueDispatchTimers.get(
@@ -2848,8 +2951,7 @@ export function registerIpcHandlers(
) )
publishConversationQueueChange(request.conversationId) publishConversationQueueChange(request.conversationId)
} catch (error) { } catch (error) {
activeRequests.delete(request.requestId) releaseActiveRequest()
activeRequestConversations.delete(request.requestId)
assistantDatabase.updateTaskStatus( assistantDatabase.updateTaskStatus(
request.requestId, request.requestId,
'cancelled', 'cancelled',
@@ -3336,8 +3438,7 @@ export function registerIpcHandlers(
} }
} }
knowledgeGateway?.revoke(request.knowledgeCapabilityToken) knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
activeRequests.delete(request.requestId) releaseActiveRequest()
activeRequestConversations.delete(request.requestId)
const configReload = const configReload =
goodbuddyConfigService?.takePendingReload(request.requestId) ?? goodbuddyConfigService?.takePendingReload(request.requestId) ??
'none' 'none'
@@ -3360,7 +3461,9 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.agentCancel, (event, input: unknown) => { registerHandler(ipcChannels.agentCancel, (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
const requestId = requestIdSchema.parse(input) const requestId = requestIdSchema.parse(input)
activeRequests.get(requestId)?.abort(new Error('用户取消了请求')) activeRequests
.get(requestId)
?.controller.abort(new Error('用户取消了请求'))
}) })
registerHandler(ipcChannels.agentApprovalRespond, (event, input: unknown) => { registerHandler(ipcChannels.agentApprovalRespond, (event, input: unknown) => {
@@ -3453,10 +3556,10 @@ export function registerIpcHandlers(
), ),
5 * 60_000 5 * 60_000
) )
activeRequests.set(request.requestId, controller) const releaseActiveRequest = leaseActiveRequest(
activeRequestConversations.set(
request.requestId, request.requestId,
request.conversationId request.conversationId,
controller
) )
assistantDatabase.createTask({ assistantDatabase.createTask({
id: request.requestId, id: request.requestId,
@@ -3541,8 +3644,7 @@ export function registerIpcHandlers(
throw error throw error
} finally { } finally {
clearTimeout(timeout) clearTimeout(timeout)
activeRequests.delete(request.requestId) releaseActiveRequest()
activeRequestConversations.delete(request.requestId)
readyConversationQueues.add(request.conversationId) readyConversationQueues.add(request.conversationId)
void pumpConversationQueue(request.conversationId) void pumpConversationQueue(request.conversationId)
} }
@@ -3571,12 +3673,23 @@ export function registerIpcHandlers(
assertTrustedSender(event, window) assertTrustedSender(event, window)
const settings = const settings =
runtimeCustomizationSettingsSchema.parse(input) runtimeCustomizationSettingsSchema.parse(input)
const saved = return enqueueRuntimeSettingsUpdate(async () => {
await settingsStore.updateRuntimeCustomization(settings) const previous =
abortActiveRequests('Runtime 定制设置已更改') await settingsStore.getRuntimeCustomization()
approvalBroker.clear() return activateOrRollback({
await onRuntimeSettingsChanged() previous,
return saved 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<RuntimeSettings> => { async (event, input: unknown): Promise<RuntimeSettings> => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
const settings = runtimeSettingsInputSchema.parse(input) const settings = runtimeSettingsInputSchema.parse(input)
let workspacePath: string return enqueueRuntimeSettingsUpdate(async () => {
try { let workspacePath: string
workspacePath = await realpath(settings.workspacePath) try {
if (!(await stat(workspacePath)).isDirectory()) { workspacePath = await realpath(settings.workspacePath)
throw new Error('Not a directory') if (!(await stat(workspacePath)).isDirectory()) {
throw new Error('Not a directory')
}
} catch {
throw new Error('所选工作区不存在、不可访问或不是文件夹')
} }
} catch { const rollback = await settingsStore.captureRollback()
throw new Error('所选工作区不存在、不可访问或不是文件夹') const previousSettings = rollback.publicSettings
} const savedSettings = await activateOrRollback({
const savedSettings = await settingsStore.update({ previous: previousSettings,
...settings, persistCandidate: async () => {
workspacePath const saved = await settingsStore.update({
}) ...settings,
channelSettingsStore?.reportRuntimeSelectionRepairs( workspacePath
assistantDatabase.repairConversationRuntimeSelections( })
savedSettings abortActiveRequests('运行时设置已更改')
approvalBroker.clear()
return saved
},
activate: onRuntimeSettingsChanged,
persistPrevious: () => rollback.restore()
})
channelSettingsStore?.reportRuntimeSelectionRepairs(
assistantDatabase.repairConversationRuntimeSelections(
savedSettings
)
) )
) return savedSettings
abortActiveRequests('运行时设置已更改') })
approvalBroker.clear()
await onRuntimeSettingsChanged()
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) => { registerHandler(ipcChannels.documentParsingGet, (event) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
if (!documentParsingService) { if (!documentParsingService) {
@@ -3911,6 +4056,14 @@ export function registerIpcHandlers(
return documentParsingService.snapshot() return documentParsingService.snapshot()
}) })
registerHandler(ipcChannels.documentOcrModelsProgress, (event) => {
assertTrustedSender(event, window)
if (!documentOcrModelManager) {
throw new Error('本地 OCR 模型服务不可用')
}
return documentOcrModelManager.getProgressSnapshot()
})
registerHandler( registerHandler(
ipcChannels.documentParsingUpdate, ipcChannels.documentParsingUpdate,
(event, input: unknown) => { (event, input: unknown) => {
@@ -4596,11 +4749,13 @@ export function registerIpcHandlers(
} }
preferredConversationQueueItems.set(item.conversationId, item.id) preferredConversationQueueItems.set(item.conversationId, item.id)
readyConversationQueues.add(item.conversationId) readyConversationQueues.add(item.conversationId)
for (const [requestId, conversationId] of activeRequestConversations) { for (const [requestId, lease] of activeRequestConversations) {
if (conversationId === item.conversationId) { if (lease.conversationId === item.conversationId) {
activeRequests activeRequests
.get(requestId) .get(requestId)
?.abort(new Error('用户中断当前回复并插入队列项')) ?.controller.abort(
new Error('用户中断当前回复并插入队列项')
)
} }
} }
if (!isConversationExecuting(item.conversationId)) { if (!isConversationExecuting(item.conversationId)) {
+41 -1
View File
@@ -77,6 +77,7 @@ describe('model archive', () => {
} }
}) })
const progress: number[] = []
await expect( await expect(
extractModelArchive({ extractModelArchive({
archivePath: archive, archivePath: archive,
@@ -89,7 +90,10 @@ describe('model archive', () => {
], ],
maximumArchiveBytes: 1024 * 1024, maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024, maximumFileBytes: 1024,
maximumTotalBytes: 2048 maximumTotalBytes: 2048,
onProgress: (completedBytes) => {
progress.push(completedBytes)
}
}) })
).resolves.toMatchObject({ ).resolves.toMatchObject({
kind: 'speech', kind: 'speech',
@@ -101,6 +105,7 @@ describe('model archive', () => {
await expect(readFile(join(extracted, 'tokens.txt'))).resolves.toEqual( await expect(readFile(join(extracted, 'tokens.txt'))).resolves.toEqual(
tokens tokens
) )
expect(progress.at(-1)).toBe(model.byteLength + tokens.byteLength)
}) })
it('preserves an existing archive when source verification fails', async () => { it('preserves an existing archive when source verification fails', async () => {
@@ -208,4 +213,39 @@ describe('model archive', () => {
}) })
).rejects.toThrow('模型 ID 不匹配') ).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<void>((resolve) => setImmediate(resolve))
expect(unhandled).toEqual([])
} finally {
process.removeListener('unhandledRejection', onUnhandled)
}
})
}) })
+57 -52
View File
@@ -7,7 +7,7 @@ import {
rm, rm,
type FileHandle type FileHandle
} from 'node:fs/promises' } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { resolve } from 'node:path'
import { import {
Unzip, Unzip,
UnzipInflate, UnzipInflate,
@@ -16,6 +16,13 @@ import {
ZipPassThrough ZipPassThrough
} from 'fflate' } from 'fflate'
import { z } from 'zod' 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_MANIFEST_NAME = 'goodbuddy-model.json'
const ARCHIVE_FORMAT = 'goodbuddy-model-archive' const ARCHIVE_FORMAT = 'goodbuddy-model-archive'
@@ -108,14 +115,6 @@ type ExtractModelArchiveOptions = {
onProgress?: (completedBytes: number) => void 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 { function ensureArchiveName(name: string): string {
return archiveFileNameSchema.parse(name) return archiveFileNameSchema.parse(name)
} }
@@ -127,24 +126,6 @@ function ensureUniqueFiles(files: ModelArchiveExpectedFile[]): void {
} }
} }
async function hashFile(path: string): Promise<ModelArchiveFile['sha256']> {
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 { function checkedLimit(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) { if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError(`${label}无效`) throw new RangeError(`${label}无效`)
@@ -152,14 +133,6 @@ function checkedLimit(value: number, label: string): number {
return value return value
} }
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error('模型 ZIP 导入已取消')
}
}
async function pushFileIntoArchive( async function pushFileIntoArchive(
archive: Zip, archive: Zip,
file: ModelArchiveFile, file: ModelArchiveFile,
@@ -233,7 +206,7 @@ async function replaceArchiveFile(
throw new Error('模型 ZIP 导出目标必须是普通文件') throw new Error('模型 ZIP 导出目标必须是普通文件')
} }
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { if (!isMissingFileError(error)) {
throw error throw error
} }
} }
@@ -277,7 +250,7 @@ export async function exportModelArchive(
} }
writeChain = writeChain.then(async () => { writeChain = writeChain.then(async () => {
if (data.byteLength > 0) { if (data.byteLength > 0) {
await output.write(data) await writeModelBuffer(output, data)
} }
}) })
if (final) { if (final) {
@@ -310,7 +283,11 @@ export async function exportModelArchive(
await pushFileIntoArchive( await pushFileIntoArchive(
archive, archive,
file, file,
safeChild(sourceDirectory, file.name), managedModelChild(
sourceDirectory,
file.name,
'模型 ZIP 路径超出临时目录'
),
waitForOutput waitForOutput
) )
} }
@@ -334,7 +311,7 @@ function closeHandle(handle: FileHandle): Promise<void> {
export async function extractModelArchive( export async function extractModelArchive(
options: ExtractModelArchiveOptions options: ExtractModelArchiveOptions
): Promise<ModelArchiveDescriptor> { ): Promise<ModelArchiveDescriptor> {
ensureNotAborted(options.signal) ensureModelOperationNotAborted(options.signal)
const maximumArchiveBytes = checkedLimit( const maximumArchiveBytes = checkedLimit(
options.maximumArchiveBytes, options.maximumArchiveBytes,
'模型 ZIP 大小限制' '模型 ZIP 大小限制'
@@ -399,7 +376,9 @@ export async function extractModelArchive(
const destination = resolve(options.destinationDirectory) const destination = resolve(options.destinationDirectory)
const seenNames = new Set<string>() const seenNames = new Set<string>()
const openHandles = new Set<FileHandle>() const openHandles = new Set<FileHandle>()
const completions: Promise<void>[] = [] const completions: Promise<
{ ok: true } | { ok: false; error: Error }
>[] = []
const pendingWrites = new Set<Promise<void>>() const pendingWrites = new Set<Promise<void>>()
let entryCount = 0 let entryCount = 0
let totalBytes = 0 let totalBytes = 0
@@ -442,7 +421,11 @@ export async function extractModelArchive(
throw new Error(`模型 ZIP 条目大小超出限制:${name}`) throw new Error(`模型 ZIP 条目大小超出限制:${name}`)
} }
const handlePromise = open( const handlePromise = open(
safeChild(destination, name), managedModelChild(
destination,
name,
'模型 ZIP 路径超出临时目录'
),
'wx' 'wx'
).then((handle) => { ).then((handle) => {
openHandles.add(handle) openHandles.add(handle)
@@ -456,7 +439,12 @@ export async function extractModelArchive(
resolveEntry = resolveEntryPromise resolveEntry = resolveEntryPromise
rejectEntry = rejectEntryPromise 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) => { file.ondata = (error, data, final) => {
if (error) { if (error) {
rejectEntry?.(fail(error)) rejectEntry?.(fail(error))
@@ -480,10 +468,6 @@ export async function extractModelArchive(
} }
written += data.byteLength written += data.byteLength
totalBytes += data.byteLength totalBytes += data.byteLength
if (name !== ARCHIVE_MANIFEST_NAME) {
completedModelBytes += data.byteLength
options.onProgress?.(completedModelBytes)
}
if ( if (
written > entryMaximum || written > entryMaximum ||
totalBytes > maximumTotalBytes totalBytes > maximumTotalBytes
@@ -497,7 +481,12 @@ export async function extractModelArchive(
writeChain = writeChain.then(async () => { writeChain = writeChain.then(async () => {
const handle = await handlePromise const handle = await handlePromise
if (data.byteLength > 0) { 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 const pendingWrite = writeChain
@@ -529,7 +518,7 @@ export async function extractModelArchive(
const buffer = Buffer.allocUnsafe(16 * 1024) const buffer = Buffer.allocUnsafe(16 * 1024)
try { try {
while (true) { while (true) {
ensureNotAborted(options.signal) ensureModelOperationNotAborted(options.signal)
if (fatalError) { if (fatalError) {
throw fatalError throw fatalError
} }
@@ -544,7 +533,13 @@ export async function extractModelArchive(
) )
await Promise.all([...pendingWrites]) 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) { if (fatalError) {
throw fatalError throw fatalError
} }
@@ -571,7 +566,11 @@ export async function extractModelArchive(
manifest = modelArchiveManifestSchema.parse( manifest = modelArchiveManifestSchema.parse(
JSON.parse( JSON.parse(
await readFile( await readFile(
safeChild(destination, ARCHIVE_MANIFEST_NAME), managedModelChild(
destination,
ARCHIVE_MANIFEST_NAME,
'模型 ZIP 路径超出临时目录'
),
'utf8' 'utf8'
) )
) as unknown ) as unknown
@@ -597,13 +596,19 @@ export async function extractModelArchive(
throw new Error('模型 ZIP 清单与当前模型目录不匹配') throw new Error('模型 ZIP 清单与当前模型目录不匹配')
} }
for (const archived of manifest.files) { 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 metadata = await lstat(path)
const hash = await hashModelFile(path)
if ( if (
!metadata.isFile() || !metadata.isFile() ||
metadata.isSymbolicLink() || metadata.isSymbolicLink() ||
metadata.size !== archived.size || metadata.size !== archived.size ||
(await hashFile(path)) !== archived.sha256 hash.size !== archived.size ||
hash.sha256 !== archived.sha256
) { ) {
throw new Error(`模型 ZIP 文件校验失败:${archived.name}`) throw new Error(`模型 ZIP 文件校验失败:${archived.name}`)
} }
+3 -3
View File
@@ -1,3 +1,5 @@
import { ensureModelOperationNotAborted } from './model-package-utils'
const MAX_REDIRECTS = 3 const MAX_REDIRECTS = 3
const redirectStatuses = new Set([301, 302, 303, 307, 308]) const redirectStatuses = new Set([301, 302, 303, 307, 308])
@@ -28,9 +30,7 @@ export async function fetchModelDownloadResponse(options: {
const initialHost = url.hostname const initialHost = url.hostname
const allowedRedirectHosts = new Set(options.redirectHosts) const allowedRedirectHosts = new Set(options.redirectHosts)
for (let redirectCount = 0; ; redirectCount += 1) { for (let redirectCount = 0; ; redirectCount += 1) {
if (options.signal.aborted) { ensureModelOperationNotAborted(options.signal)
throw new DOMException('The operation was aborted', 'AbortError')
}
const response = await options.transport(url, { const response = await options.transport(url, {
method: 'GET', method: 'GET',
redirect: 'manual', redirect: 'manual',
+154
View File
@@ -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('写入不完整')
})
})
+260
View File
@@ -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<ModelFileFingerprint> {
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<FileHandle, 'write'>,
buffer: Uint8Array,
onPersisted?: (buffer: Uint8Array) => void
): Promise<number> {
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<string> {
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<string>
partialFileNames: ReadonlySet<string>
cleanSelectionPartials?: boolean
activeSelectionPartialNames?: ReadonlySet<string>
escapeMessage: string
operations?: {
unlinkFile?: (path: string) => Promise<void>
}
}): Promise<void> {
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
}
}
}
}
}
+28
View File
@@ -78,6 +78,34 @@ afterEach(async () => {
}) })
describe('RuntimeSettingsStore', () => { 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 () => { it('migrates version 17 to empty Runtime customization', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
await store.update(settings()) await store.update(settings())
+37
View File
@@ -235,6 +235,10 @@ const storedSettingsSchema = version17StoredSettingsSchema
}) })
type StoredSettings = z.infer<typeof storedSettingsSchema> type StoredSettings = z.infer<typeof storedSettingsSchema>
export type RuntimeSettingsRollback = {
publicSettings: RuntimeSettings
restore(): Promise<RuntimeSettings>
}
type Version17StoredSettings = z.infer< type Version17StoredSettings = z.infer<
typeof version17StoredSettingsSchema typeof version17StoredSettingsSchema
> >
@@ -1481,6 +1485,39 @@ export class RuntimeSettingsStore {
return this.toPublicSettings(await this.load()) return this.toPublicSettings(await this.load())
} }
captureRollback(): Promise<RuntimeSettingsRollback> {
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<RuntimeSettings> {
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<RuntimePolicySettings> { async getPolicySettings(): Promise<RuntimePolicySettings> {
const settings = await this.load() const settings = await this.load()
return { return {
+199
View File
@@ -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<string>()
const conflicts = new Set<string>()
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())
})
})
+173
View File
@@ -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<GlobalShortcutSettings>
update(input: unknown): Promise<GlobalShortcutSettings>
}
export class ShortcutSettingsService {
private settings: GlobalShortcutSettings = {
...defaultGlobalShortcutSettings
}
private registeredAccelerator?: string
private status: GlobalShortcutRegistrationStatus = 'disabled'
private updateQueue: Promise<void> = Promise.resolve()
constructor(
private readonly store: ShortcutSettingsPersistence,
private readonly registry: GlobalShortcutRegistry,
private readonly callback: () => void,
private readonly platform: string
) {}
async initialize(): Promise<GlobalShortcutSettingsSnapshot> {
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<GlobalShortcutSettingsUpdateResult> {
const operation = this.updateQueue.then(
async (): Promise<GlobalShortcutSettingsUpdateResult> => {
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
}
}
+79
View File
@@ -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)
})
})
+122
View File
@@ -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<StoredShortcutSettings>
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
private async readStored(): Promise<StoredShortcutSettings> {
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<StoredShortcutSettings> {
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<GlobalShortcutSettings> {
const { enabled, accelerator } = await this.load()
return { enabled, accelerator }
}
update(input: unknown): Promise<GlobalShortcutSettings> {
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
}
}
@@ -5,6 +5,8 @@ import {
readFile, readFile,
readdir, readdir,
rm, rm,
stat,
utimes,
writeFile writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
@@ -249,6 +251,147 @@ describe('speech model catalog', () => {
}) })
describe('SpeechModelManager downloads', () => { 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<typeof fetch>(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<void>((resolveStarted) => {
markWriteStarted = resolveStarted
})
const writeGate = new Promise<void>((resolveWrite) => {
releaseWrite = resolveWrite
})
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(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 () => { it('downloads to partial files, verifies hashes, and atomically installs', async () => {
const userData = await temporaryDirectory() const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes') 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<typeof fetch>(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 () => { it('freezes the operation source when the global setting changes', async () => {
const userData = await temporaryDirectory() const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected') const modelBytes = new TextEncoder().encode('expected')
+304 -113
View File
@@ -11,7 +11,7 @@ import {
stat, stat,
writeFile writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { resolve } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { import {
installedSpeechModelSchema, installedSpeechModelSchema,
@@ -39,11 +39,24 @@ import {
extractModelArchive extractModelArchive
} from '../model-archive' } from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport' 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 DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json' const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json' const SELECTION_FILE_NAME = '.selection.json'
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1 const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024 const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
@@ -61,6 +74,17 @@ type ActiveOperation = {
progress: SpeechModelOperation progress: SpeechModelOperation
} }
type SpeechSelectionFileOperations = {
writeFile: typeof writeFile
rename: typeof rename
}
type CachedSelectedSpeechRuntimeModel = {
model: SelectedSpeechRuntimeModel
manifestFingerprint: ModelFileFingerprint
fileFingerprints: Map<string, ModelFileFingerprint>
}
export type SpeechModelManagerOptions = { export type SpeechModelManagerOptions = {
userDataDirectory: string userDataDirectory: string
fetch: typeof fetch fetch: typeof fetch
@@ -69,6 +93,7 @@ export type SpeechModelManagerOptions = {
| ModelDownloadSource | ModelDownloadSource
| Promise<ModelDownloadSource> | Promise<ModelDownloadSource>
maxFileBytes?: number maxFileBytes?: number
selectionFileOperations?: Partial<SpeechSelectionFileOperations>
} }
export type SelectedSpeechRuntimeModel = { 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 { function validateMaximumBytes(value: number | undefined): number {
const maximum = value ?? DEFAULT_MAX_FILE_BYTES const maximum = value ?? DEFAULT_MAX_FILE_BYTES
if ( if (
@@ -124,40 +139,7 @@ function validateMaximumBytes(value: number | undefined): number {
} }
function safeChild(parent: string, name: string): string { function safeChild(parent: string, name: string): string {
const child = resolve(parent, name) return managedModelChild(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') }
} }
export class SpeechModelManager { export class SpeechModelManager {
@@ -170,7 +152,13 @@ export class SpeechModelManager {
| ModelDownloadSource | ModelDownloadSource
| Promise<ModelDownloadSource> | Promise<ModelDownloadSource>
private readonly maxFileBytes: number private readonly maxFileBytes: number
private readonly selectionFileOperations: SpeechSelectionFileOperations
private readonly operations = new Map<string, ActiveOperation>() private readonly operations = new Map<string, ActiveOperation>()
private readonly activeSelectionPartialNames = new Set<string>()
private selectedRuntimeModel?: Promise<
CachedSelectedSpeechRuntimeModel | undefined
>
private selectedRuntimeGeneration = 0
constructor(options: SpeechModelManagerOptions) { constructor(options: SpeechModelManagerOptions) {
if (!options.userDataDirectory.trim()) { if (!options.userDataDirectory.trim()) {
@@ -192,10 +180,16 @@ export class SpeechModelManager {
} }
this.catalogViews = this.catalog.map(toCatalogView) this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes) this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
this.selectionFileOperations = {
writeFile,
rename,
...options.selectionFileOperations
}
} }
async snapshot(): Promise<SpeechModelSnapshot> { async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot() await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [installed, selected, selectedDownloadSource] = const [installed, selected, selectedDownloadSource] =
await Promise.all([ await Promise.all([
this.readInstalled(), this.readInstalled(),
@@ -236,25 +230,52 @@ export class SpeechModelManager {
async getSelectedRuntimeModel(): Promise< async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined SelectedSpeechRuntimeModel | undefined
> { > {
const snapshot = await this.snapshot() const selectedModelId = await this.readSelection()
if (!snapshot.selectedModelId) { if (!selectedModelId) {
this.invalidateSelectedRuntimeModel()
return undefined return undefined
} }
const catalogEntry = this.catalog.find( const cachedPromise = this.selectedRuntimeModel
(entry) => entry.id === snapshot.selectedModelId if (cachedPromise) {
) const cached = await cachedPromise
const installed = snapshot.installed.find( if (
(entry) => entry.id === snapshot.selectedModelId cached?.model.id === selectedModelId &&
) (await this.selectedRuntimeFingerprintsMatch(cached))
if (!catalogEntry || !installed) { ) {
return undefined return this.cloneSelectedRuntimeModel(cached.model)
}
if (this.selectedRuntimeModel === cachedPromise) {
this.invalidateSelectedRuntimeModel()
}
} }
return { const selected = await this.getOrCreateSelectedRuntimeModel(
id: installed.id, selectedModelId
family: catalogEntry.family, )
directory: this.modelDirectory(installed.id), return selected
files: installed.files.map((file) => ({ ...file })) ? this.cloneSelectedRuntimeModel(selected.model)
: undefined
}
private getOrCreateSelectedRuntimeModel(
selectedModelId: string
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
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( async install(
@@ -290,7 +311,7 @@ export class SpeechModelManager {
await this.assertNotInstalled(entry.id) await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of resolvedPackage.files) { for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring' operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name operation.progress.currentFile = file.name
const destination = safeChild(stagingDirectory, file.name) const destination = safeChild(stagingDirectory, file.name)
@@ -309,12 +330,13 @@ export class SpeechModelManager {
stagingDirectory, stagingDirectory,
operation.controller.signal operation.controller.signal
) )
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
await rename( await rename(
stagingDirectory, stagingDirectory,
this.modelDirectory(entry.id) this.modelDirectory(entry.id)
) )
stagingDirectory = undefined stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed return installed
} finally { } finally {
detachExternalAbort() detachExternalAbort()
@@ -338,6 +360,7 @@ export class SpeechModelManager {
async remove(modelId: string): Promise<void> { async remove(modelId: string): Promise<void> {
speechModelIdSchema.parse(modelId) speechModelIdSchema.parse(modelId)
this.cancel(modelId) this.cancel(modelId)
this.invalidateSelectedRuntimeModel()
await this.ensureRoot() await this.ensureRoot()
const target = this.modelDirectory(modelId) const target = this.modelDirectory(modelId)
await rm(target, { recursive: true, force: true }) await rm(target, { recursive: true, force: true })
@@ -356,6 +379,7 @@ export class SpeechModelManager {
} }
} }
await this.writeSelection(modelId) await this.writeSelection(modelId)
this.invalidateSelectedRuntimeModel()
} }
async registerLocalDirectory( async registerLocalDirectory(
@@ -382,12 +406,12 @@ export class SpeechModelManager {
stagingDirectory = await this.createStagingDirectory(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring' operation.progress.phase = 'transferring'
for (const file of entry.files) { for (const file of entry.files) {
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name) const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name) const destination = safeChild(stagingDirectory, file.name)
await copyFile(sourceFile, destination) await copyFile(sourceFile, destination)
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
const copied = await stat(destination) const copied = await stat(destination)
if (copied.size > this.maxFileBytes) { if (copied.size > this.maxFileBytes) {
throw new RangeError(`模型文件过大:${file.name}`) throw new RangeError(`模型文件过大:${file.name}`)
@@ -404,12 +428,13 @@ export class SpeechModelManager {
stagingDirectory, stagingDirectory,
operation.controller.signal operation.controller.signal
) )
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
await rename( await rename(
stagingDirectory, stagingDirectory,
this.modelDirectory(entry.id) this.modelDirectory(entry.id)
) )
stagingDirectory = undefined stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed return installed
} finally { } finally {
detachExternalAbort() detachExternalAbort()
@@ -542,9 +567,10 @@ export class SpeechModelManager {
`${JSON.stringify(installed, null, 2)}\n`, `${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' } { encoding: 'utf8', flag: 'wx' }
) )
ensureNotAborted(operation.controller.signal) ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id)) await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed return installed
} finally { } finally {
this.operations.delete(entry.id) this.operations.delete(entry.id)
@@ -558,6 +584,163 @@ export class SpeechModelManager {
await mkdir(this.rootDirectory, { recursive: true }) await mkdir(this.rootDirectory, { recursive: true })
} }
private invalidateSelectedRuntimeModel(): void {
this.selectedRuntimeGeneration += 1
this.selectedRuntimeModel = undefined
}
private async resolveSelectedRuntimeModel(
selectedModelId: string,
generation: number
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
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<string, ModelFileFingerprint>()
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<boolean> {
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 { private modelDirectory(modelId: string): string {
const parsedId = speechModelIdSchema.parse(modelId) const parsedId = speechModelIdSchema.parse(modelId)
return safeChild(this.rootDirectory, parsedId) return safeChild(this.rootDirectory, parsedId)
@@ -601,16 +784,7 @@ export class SpeechModelManager {
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
controller: AbortController controller: AbortController
): () => void { ): () => void {
if (!signal) { return attachModelAbortSignal(signal, controller)
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
} }
private async assertNotInstalled(modelId: string): Promise<void> { private async assertNotInstalled(modelId: string): Promise<void> {
@@ -618,11 +792,7 @@ export class SpeechModelManager {
await lstat(this.modelDirectory(modelId)) await lstat(this.modelDirectory(modelId))
throw new Error('语音模型已安装') throw new Error('语音模型已安装')
} catch (error) { } catch (error) {
if ( if (isMissingFileError(error)) {
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return return
} }
throw error throw error
@@ -630,12 +800,11 @@ export class SpeechModelManager {
} }
private async createStagingDirectory(modelId: string): Promise<string> { private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild( return createModelStagingDirectory(
this.rootDirectory, this.rootDirectory,
`.install-${modelId}-${randomUUID()}` modelId,
'模型路径超出受管目录'
) )
await mkdir(directory, { recursive: false })
return directory
} }
private async downloadFile( 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 handle = await open(partialPath, 'wx')
const reader = response.body.getReader() const reader = response.body.getReader()
const hash = createHash('sha256') const hash = createHash('sha256')
let written = 0 let written = 0
try { try {
while (true) { while (true) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
const result = await reader.read() const result = await reader.read()
if (result.done) { if (result.done) {
break break
} }
written += result.value.byteLength
if ( if (
written > file.size || written + result.value.byteLength > file.size ||
written > this.maxFileBytes written + result.value.byteLength > this.maxFileBytes
) { ) {
await reader.cancel() await reader.cancel()
throw new RangeError(`模型文件过大:${file.name}`) throw new RangeError(`模型文件过大:${file.name}`)
} }
await handle.write(result.value) const persistedBytes = await writeModelBuffer(
hash.update(result.value) handle,
operation.progress.completedBytes += result.value.byteLength result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
} }
} catch (error) { } catch (error) {
await reader.cancel().catch(() => undefined) await reader.cancel().catch(() => undefined)
@@ -728,7 +902,7 @@ export class SpeechModelManager {
visited: 0 visited: 0
}) })
for (const expectedFile of entry.files) { for (const expectedFile of entry.files) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
const sourceFile = safeChild(sourceDirectory, expectedFile.name) const sourceFile = safeChild(sourceDirectory, expectedFile.name)
const sourceFileInfo = await lstat(sourceFile) const sourceFileInfo = await lstat(sourceFile)
if ( if (
@@ -745,7 +919,7 @@ export class SpeechModelManager {
} }
if ( if (
sourceFileInfo.size !== expectedFile.size || sourceFileInfo.size !== expectedFile.size ||
(await hashFile(sourceFile, signal)).sha256 !== (await hashModelFile(sourceFile, signal)).sha256 !==
expectedFile.sha256 expectedFile.sha256
) { ) {
throw new Error(`本地模型文件校验失败:${expectedFile.name}`) throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
@@ -760,7 +934,7 @@ export class SpeechModelManager {
): Promise<void> { ): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true }) const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) { for (const entry of entries) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
counter.visited += 1 counter.visited += 1
if (counter.visited > 4_096) { if (counter.visited > 4_096) {
throw new Error('本地模型目录包含过多条目') throw new Error('本地模型目录包含过多条目')
@@ -830,8 +1004,8 @@ export class SpeechModelManager {
): Promise<InstalledSpeechModel> { ): Promise<InstalledSpeechModel> {
const files = [] const files = []
for (const file of entry.files) { for (const file of entry.files) {
ensureNotAborted(signal) ensureModelOperationNotAborted(signal)
const metadata = await hashFile( const metadata = await hashModelFile(
safeChild(stagingDirectory, file.name), safeChild(stagingDirectory, file.name),
signal signal
) )
@@ -899,11 +1073,7 @@ export class SpeechModelManager {
) )
return value.selectedModelId return value.selectedModelId
} catch (error) { } catch (error) {
if ( if (isMissingFileError(error)) {
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return null return null
} }
return null return null
@@ -913,24 +1083,45 @@ export class SpeechModelManager {
private async writeSelection(modelId: string | null): Promise<void> { private async writeSelection(modelId: string | null): Promise<void> {
await this.ensureRoot() await this.ensureRoot()
const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME) const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME)
const partialName =
`${SELECTION_FILE_NAME}.${randomUUID()}${MODEL_PARTIAL_SUFFIX}`
const partial = safeChild( const partial = safeChild(
this.rootDirectory, this.rootDirectory,
`${SELECTION_FILE_NAME}.${randomUUID()}${PARTIAL_SUFFIX}` partialName
)
await writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
) )
this.activeSelectionPartialNames.add(partialName)
try { 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) { } catch (error) {
await rm(partial, { force: true }) await rm(partial, { force: true })
throw error throw error
} finally {
this.activeSelectionPartialNames.delete(partialName)
} }
} }
private cleanupStaleArtifacts(): Promise<void> {
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( export function createSpeechModelManager(
+193 -3
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { runStartupPrerequisites } from './startup-prerequisites' import {
createStartupFailureDiagnostic,
formatStartupFailureMessage,
runStartupPrerequisites,
StartupPrerequisiteError
} from './startup-prerequisites'
function deferred<T = void>(): { function deferred<T = void>(): {
promise: Promise<T> promise: Promise<T>
@@ -83,7 +88,11 @@ describe('runStartupPrerequisites', () => {
expect(rejected).not.toHaveBeenCalled() expect(rejected).not.toHaveBeenCalled()
configuredRuntime.resolve({ id: 'unused' }) 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() expect(rejected).toHaveBeenCalledOnce()
}) })
@@ -108,7 +117,188 @@ describe('runStartupPrerequisites', () => {
deepSeekHome.resolve() deepSeekHome.resolve()
knowledgeAndGateway.resolve() knowledgeAndGateway.resolve()
await expect(result).rejects.toBe(runtimeError) await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'runtime',
cause: runtimeError
})
expect(rejected).toHaveBeenCalledOnce() 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<Partial<StartupPrerequisiteError>>({
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)
})
}) })
+131 -5
View File
@@ -5,6 +5,101 @@ export type StartupPrerequisiteDependencies<ConfiguredRuntime> = {
initializeAssistant: () => void 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<T>(operation: () => Promise<T>): Promise<T> { function startObserved<T>(operation: () => Promise<T>): Promise<T> {
let started: Promise<T> let started: Promise<T>
try { try {
@@ -45,17 +140,48 @@ export async function runStartupPrerequisites<ConfiguredRuntime>(
configuredRuntimeReady configuredRuntimeReady
] as const) ] as const)
const failures: Array<
Readonly<{
stage: StartupPrerequisiteStage
cause: unknown
}>
> = []
if (assistantInitializationFailed) { if (assistantInitializationFailed) {
throw assistantInitializationError failures.push({
stage: 'assistant-database',
cause: assistantInitializationError
})
} }
if (deepSeekHome.status === 'rejected') { if (deepSeekHome.status === 'rejected') {
throw deepSeekHome.reason failures.push({
stage: 'runtime-home',
cause: deepSeekHome.reason
})
} }
if (knowledgeAndGateway.status === 'rejected') { if (knowledgeAndGateway.status === 'rejected') {
throw knowledgeAndGateway.reason failures.push({
stage: 'knowledge',
cause: knowledgeAndGateway.reason
})
} }
if (configuredRuntime.status === 'rejected') { 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')
} }
+21
View File
@@ -89,6 +89,7 @@ import type {
import type { import type {
DocumentOcrAssets, DocumentOcrAssets,
DocumentOcrFailure, DocumentOcrFailure,
DocumentOcrModelProgressSnapshot,
DocumentOcrRequest, DocumentOcrRequest,
DocumentOcrResult, DocumentOcrResult,
DocumentParsingDiagnostic, DocumentParsingDiagnostic,
@@ -125,6 +126,11 @@ import type {
RuntimeExtensionAction, RuntimeExtensionAction,
RuntimeExtensionMarketplaceSnapshot RuntimeExtensionMarketplaceSnapshot
} from '../shared/runtime-extension-contracts' } from '../shared/runtime-extension-contracts'
import type {
GlobalShortcutSettings,
GlobalShortcutSettingsSnapshot,
GlobalShortcutSettingsUpdateResult
} from '../shared/shortcut'
const desktopApi: DesktopApi = { const desktopApi: DesktopApi = {
app: { app: {
@@ -388,6 +394,17 @@ const desktopApi: DesktopApi = {
ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler) ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler)
} }
}, },
shortcuts: {
getSettings: () =>
ipcRenderer.invoke(
ipcChannels.shortcutSettingsGet
) as Promise<GlobalShortcutSettingsSnapshot>,
updateSettings: (input: GlobalShortcutSettings) =>
ipcRenderer.invoke(
ipcChannels.shortcutSettingsUpdate,
input
) as Promise<GlobalShortcutSettingsUpdateResult>
},
releaseNotes: { releaseNotes: {
getPending: () => getPending: () =>
ipcRenderer.invoke( ipcRenderer.invoke(
@@ -475,6 +492,10 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.documentParsingGet ipcChannels.documentParsingGet
) as Promise<DocumentParsingSnapshot>, ) as Promise<DocumentParsingSnapshot>,
getOcrModelProgress: () =>
ipcRenderer.invoke(
ipcChannels.documentOcrModelsProgress
) as Promise<DocumentOcrModelProgressSnapshot>,
update: (input: DocumentParsingSettings) => update: (input: DocumentParsingSettings) =>
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.documentParsingUpdate, ipcChannels.documentParsingUpdate,
+20
View File
@@ -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', () => { it('exposes a removable attachment parsing progress listener', () => {
const source = readFileSync( const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'), join(process.cwd(), 'src', 'preload', 'index.ts'),
@@ -85,6 +94,17 @@ describe('sandboxed preload', () => {
expect(source).toContain('ipcChannels.releaseNotesAcknowledge') 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', () => { it('exposes bounded knowledge task actions', () => {
const source = readFileSync( const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'), join(process.cwd(), 'src', 'preload', 'index.ts'),
+137
View File
@@ -7,6 +7,10 @@ import {
} from '@testing-library/react' } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TokenUsageSummary } from '../../shared/assistant-contracts' import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel' import { ActivityPanel } from './ActivityPanel'
import { import {
MAX_ACTIVITY_RECORDS, MAX_ACTIVITY_RECORDS,
@@ -361,6 +365,139 @@ describe('ActivityPanel', () => {
expect(screen.getByText('范围不可用')).toBeInTheDocument() 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(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
projects={[
{
id: currentProjectId,
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
]}
records={[currentRecord, deletedRecord]}
tokenUsage={usage}
/>
)
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(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
projects={[
{
id: projectId,
name: 'Renamed current project',
description: '',
rootPath: 'C:\\Renamed',
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z'
}
]}
records={[
{
...makeRecord(1),
scope: {
kind: 'project',
projectId,
projectName: 'Original project snapshot'
}
}
]}
tokenUsage={usage}
/>
)
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', () => { it('uses shared page tabs and explicit global scope', () => {
render( render(
<ActivityPanel <ActivityPanel
+66 -9
View File
@@ -1,7 +1,11 @@
import { Activity, Trash2 } from 'lucide-react' import { Activity, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TokenUsageSummary } from '../../shared/assistant-contracts' import type {
AssistantProject,
TokenUsageSummary
} from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
import { import {
MAX_ACTIVITY_RECORDS, MAX_ACTIVITY_RECORDS,
type ActivityRecord type ActivityRecord
@@ -20,6 +24,7 @@ import {
SegmentedControl, SegmentedControl,
type WorkspaceScope type WorkspaceScope
} from './WorkspacePrimitives' } from './WorkspacePrimitives'
import { getProjectDisplayText } from './project-display'
type ActivityFilter = 'all' | 'active' | 'failed' type ActivityFilter = 'all' | 'active' | 'failed'
type ActivityView = 'tasks' | 'timeline' | 'usage' type ActivityView = 'tasks' | 'timeline' | 'usage'
@@ -31,6 +36,7 @@ type ActivityActorKind =
| 'approval' | 'approval'
export type ActivityPanelProps = { export type ActivityPanelProps = {
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[] records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary tokenUsage: TokenUsageSummary
onClear: () => void onClear: () => void
@@ -234,12 +240,14 @@ function groupActivityRecordsByProject(
} }
export function ActivityPanel({ export function ActivityPanel({
projects = [],
records, records,
tokenUsage, tokenUsage,
onClear, onClear,
onOpenConversation onOpenConversation
}: ActivityPanelProps): React.JSX.Element { }: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity') const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
const [activeView, setActiveView] = const [activeView, setActiveView] =
useState<ActivityView>('tasks') useState<ActivityView>('tasks')
const [filter, setFilter] = useState<ActivityFilter>('all') const [filter, setFilter] = useState<ActivityFilter>('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( const visibleRecords = useMemo(
() => records.slice(0, MAX_ACTIVITY_RECORDS), () => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
[records] [displayRecords]
) )
const filteredRecords = useMemo( const filteredRecords = useMemo(
() => visibleRecords.filter((record) => matchesFilter(record, filter)), () => visibleRecords.filter((record) => matchesFilter(record, filter)),
[filter, visibleRecords] [filter, visibleRecords]
) )
const projectGroups = useMemo( const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, records), () => groupActivityRecordsByProject(filteredRecords, displayRecords),
[filteredRecords, records] [displayRecords, filteredRecords]
) )
const timelineBounds = useMemo(() => { const timelineBounds = useMemo(() => {
const timestamps = filteredRecords.map((record) => record.createdAt) const timestamps = filteredRecords.map((record) => record.createdAt)
@@ -366,8 +423,8 @@ export function ActivityPanel({
(record) => record.id === selectedTimelineRecordId (record) => record.id === selectedTimelineRecordId
) )
const conversationTitles = useMemo( const conversationTitles = useMemo(
() => getConversationTitles(records), () => getConversationTitles(displayRecords),
[records] [displayRecords]
) )
const activeCount = visibleRecords.filter(isActive).length const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length const failedCount = visibleRecords.filter(isFailed).length
@@ -376,8 +433,8 @@ export function ActivityPanel({
[tokenUsage] [tokenUsage]
) )
const tokenRows = useMemo( const tokenRows = useMemo(
() => groupTokenUsage(tokenUsage, tokenGroup), () => groupTokenUsage(displayTokenUsage, tokenGroup),
[tokenGroup, tokenUsage] [displayTokenUsage, tokenGroup]
) )
const tokenGroupLabel = const tokenGroupLabel =
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ?? tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
+452 -20
View File
@@ -16,12 +16,17 @@ import type {
DesktopApi DesktopApi
} from '../../shared/contracts' } from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import type { import type {
AssistantProject, AssistantProject,
AssistantSchedule, AssistantSchedule,
AssistantTask, AssistantTask,
ConversationSnapshot ConversationSnapshot
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const speechRecognitionMocks = vi.hoisted(() => ({ 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) => ({ vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal< ...(await importOriginal<
typeof import('./speech-recognition') typeof import('./speech-recognition')
@@ -57,9 +70,30 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
vi.mock('./KnowledgeWorkspace', async (importOriginal) => { vi.mock('./KnowledgeWorkspace', async (importOriginal) => {
await lazyRouteMocks.waitForKnowledgeRoute() await lazyRouteMocks.waitForKnowledgeRoute()
routeModuleLoads.knowledge += 1
return importOriginal<typeof import('./KnowledgeWorkspace')>() return importOriginal<typeof import('./KnowledgeWorkspace')>()
}) })
vi.mock('./HeartbeatCenter', async (importOriginal) => {
routeModuleLoads.heartbeat += 1
return importOriginal<typeof import('./HeartbeatCenter')>()
})
vi.mock('./MagicNotesWorkspace', async (importOriginal) => {
routeModuleLoads.magicNotes += 1
return importOriginal<typeof import('./MagicNotesWorkspace')>()
})
vi.mock('./SettingsPanel', async (importOriginal) => {
routeModuleLoads.settings += 1
return importOriginal<typeof import('./SettingsPanel')>()
})
vi.mock('./ActivityPanel', async (importOriginal) => {
routeModuleLoads.activity += 1
return importOriginal<typeof import('./ActivityPanel')>()
})
import App from './App' import App from './App'
import { loadActivityRecords } from './activity-store' import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n' import { changeUiLocale } from './i18n'
@@ -89,11 +123,12 @@ const modelProfileId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000101' const projectId = '00000000-0000-4000-8000-000000000101'
const project = { const project = {
id: projectId, id: projectId,
name: '默认项目', name: builtInDefaultProjectSeedName,
description: '测试项目', description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Users\\test', rootPath: 'C:\\Users\\test',
defaultWorkMode: 'ask' as const, defaultWorkMode: 'ask' as const,
kind: 'user' as const, kind: 'user' as const,
builtInDefault: true,
status: 'active' as const, status: 'active' as const,
createdAt: '2026-07-31T00:00:00.000Z', createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '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.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect( expect(
screen.getByRole('heading', { screen.getByRole('heading', {
level: 1,
name: 'Conversation'
})
).toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 2,
name: 'What would you like to accomplish today?' name: 'What would you like to accomplish today?'
}) })
).toBeInTheDocument() ).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Current project' })
).toHaveTextContent('Default project')
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect( expect(
screen.getByText(/Hi, Im GoodBuddy/u) screen.getByText(/Hi, Im GoodBuddy/u)
).toBeInTheDocument() ).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(<App />)
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 () => { it('renders localized workspace branding in Chinese', async () => {
render(<App />) render(<App />)
@@ -1347,13 +1529,41 @@ describe('App', () => {
.toBeInTheDocument() .toBeInTheDocument()
}) })
it('schedules lazy workspace routes for idle preloading', () => { it('idle-preloads only the small Heartbeat route at startup', async () => {
render(<App />) render(<App />)
expect(window.requestIdleCallback).toHaveBeenCalledWith( expect(window.requestIdleCallback).toHaveBeenCalledWith(
expect.any(Function), expect.any(Function),
{ timeout: 2000 } { 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(<App />)
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 () => { it('waits for project bootstrap before project-scoped startup loads', async () => {
@@ -1440,6 +1650,33 @@ describe('App', () => {
expect(route).not.toHaveAttribute('hidden') 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(<App />)
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 () => { it('preserves title, message, and project filtering with deferred search', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
@@ -2403,14 +2640,9 @@ describe('App', () => {
const cancel = screen.getByRole('button', { const cancel = screen.getByRole('button', {
name: '取消删除对话 新对话' name: '取消删除对话 新对话'
}) })
const confirm = screen.getByRole('button', {
name: '确认永久删除对话 新对话'
})
expect(cancel).toHaveFocus() expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' }) expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(confirm).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
expect(cancel).toHaveFocus() expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' }) fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => await waitFor(() =>
@@ -3309,6 +3541,136 @@ describe('App', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('guards sidebar navigation away from dirty Settings drafts', async () => {
render(<App />)
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(<App />)
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 () => { it('restores persisted context usage and compression state after restart', async () => {
const settings = await api.settings.getRuntime() const settings = await api.settings.getRuntime()
const profile = settings.modelProfiles[0]! const profile = settings.modelProfiles[0]!
@@ -3486,11 +3848,52 @@ describe('App', () => {
evidence: [] evidence: []
}) })
render(<App />) render(<App />)
await screen.findByRole('button', { const knowledgeScopeTrigger = await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个' 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: '发布流程是什么?' } target: { value: '发布流程是什么?' }
}) })
fireEvent.click(await screen.findByLabelText('发送')) fireEvent.click(await screen.findByLabelText('发送'))
@@ -3896,25 +4299,35 @@ describe('App', () => {
expect( expect(
within(userArticle).getByRole('img', { name: '页面截图.png' }) within(userArticle).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl) ).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click( const viewerTrigger = within(userArticle).getByRole('button', {
within(userArticle).getByRole('button', { name: '查看图片 页面截图.png'
name: '查看图片 页面截图.png' })
}) fireEvent.click(viewerTrigger)
)
const imageDialog = await screen.findByRole('dialog', { const imageDialog = await screen.findByRole('dialog', {
name: '页面截图.png' name: '页面截图.png'
}) })
expect( expect(
within(imageDialog).getByRole('img', { name: '页面截图.png' }) within(imageDialog).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl) ).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', { within(imageDialog).getByRole('button', {
name: '关闭图片查看器' name: '下载图片'
}) })
).toHaveFocus()
fireEvent.keyDown(imageDialog, { key: 'Escape' })
await waitFor(() =>
expect(viewerTrigger).toHaveFocus()
) )
expect( expect(
screen.queryByRole('dialog', { name: '页面截图.png' }) screen.queryByRole('dialog', { name: '页面截图.png' })
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect(document.querySelector('main')?.inert).toBe(false)
fireEvent.click( fireEvent.click(
within(userArticle).getByRole('button', { within(userArticle).getByRole('button', {
name: '下载图片 页面截图.png' name: '下载图片 页面截图.png'
@@ -4402,6 +4815,7 @@ describe('App', () => {
await waitFor(() => await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId) expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
) )
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
selectProjectOption(secondProject.name) selectProjectOption(secondProject.name)
await waitFor(() => await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith( expect(api.workspace.getChanges).toHaveBeenCalledWith(
@@ -4417,6 +4831,7 @@ describe('App', () => {
files: [{ path: 'second.md', status: '??' }], files: [{ path: 'second.md', status: '??' }],
truncated: false truncated: false
}) })
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
expect(await screen.findByText('second.md')).toBeInTheDocument() expect(await screen.findByText('second.md')).toBeInTheDocument()
resolveFirst?.({ resolveFirst?.({
rootPath: project.rootPath, rootPath: project.rootPath,
@@ -7296,8 +7711,19 @@ describe('App', () => {
const sidebar = screen.getByLabelText('助手工作栏') const sidebar = screen.getByLabelText('助手工作栏')
expect(sidebar).not.toHaveClass('assistant-sidebar--open') 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).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( expect(
screen.getByRole('tab', { name: '任务中心' }) screen.getByRole('tab', { name: '任务中心' })
@@ -7328,6 +7754,12 @@ describe('App', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('关闭助手工作栏')) fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
expect(sidebar).not.toHaveClass('assistant-sidebar--open') 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 () => { it('keeps completed chat replies out of the results sidebar', async () => {
@@ -7757,7 +8189,7 @@ describe('App', () => {
) )
fireEvent.click( fireEvent.click(
screen.getByRole('button', { name: '关闭侧栏' }) within(sidebar).getByRole('button', { name: '知识库' })
) )
await waitFor(() => expect(toggle).toHaveFocus()) await waitFor(() => expect(toggle).toHaveFocus())
expect(sidebar).toHaveClass('sidebar--closed') expect(sidebar).toHaveClass('sidebar--closed')
+325 -44
View File
@@ -127,7 +127,6 @@ import {
normalizeInteractiveWorkMode, normalizeInteractiveWorkMode,
projectChannelLabels projectChannelLabels
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import { import {
ChatTimeline, ChatTimeline,
type ImageViewerItem, type ImageViewerItem,
@@ -171,6 +170,8 @@ import { ConversationInputQueue } from './ConversationInputQueue'
import { OverflowMarquee } from './OverflowMarquee' import { OverflowMarquee } from './OverflowMarquee'
import { findTaskSchedule } from './TaskScheduleActions' import { findTaskSchedule } from './TaskScheduleActions'
import type { SettingsCategoryId } from './settings-categories' 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 goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import { import {
@@ -202,10 +203,13 @@ import {
import { formatMediumDateTime } from './locale-formatters' import { formatMediumDateTime } from './locale-formatters'
import { formatCompactTokens } from './token-format' import { formatCompactTokens } from './token-format'
import { import {
filterKeepAliveEntries,
pruneKeepAliveEntries, pruneKeepAliveEntries,
touchKeepAliveEntry, touchAndPruneKeepAliveEntries,
type KeepAliveCacheEntry type KeepAliveCacheEntry
} from './keep-alive-cache' } from './keep-alive-cache'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { getProjectDisplayText } from './project-display'
const knowledgeWorkspaceRoute = createPreloadableComponent( const knowledgeWorkspaceRoute = createPreloadableComponent(
() => import('./KnowledgeWorkspace'), () => import('./KnowledgeWorkspace'),
@@ -223,17 +227,19 @@ const settingsPanelRoute = createPreloadableComponent(
() => import('./SettingsPanel'), () => import('./SettingsPanel'),
(module) => module.SettingsPanel (module) => module.SettingsPanel
) )
const activityPanelRoute = createPreloadableComponent(
() => import('./ActivityPanel'),
(module) => module.ActivityPanel
)
const idleRouteModuleLoaders = [ const idleRouteModuleLoaders = [
knowledgeWorkspaceRoute.preload, heartbeatCenterRoute.preload
heartbeatCenterRoute.preload,
magicNotesWorkspaceRoute.preload,
settingsPanelRoute.preload
] as const ] as const
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component
const HeartbeatCenter = heartbeatCenterRoute.Component const HeartbeatCenter = heartbeatCenterRoute.Component
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component
const SettingsPanel = settingsPanelRoute.Component const SettingsPanel = settingsPanelRoute.Component
const ActivityPanel = activityPanelRoute.Component
const messageRenderBatchSize = 80 const messageRenderBatchSize = 80
const conversationPersistenceIntervalMs = 500 const conversationPersistenceIntervalMs = 500
@@ -501,6 +507,24 @@ type WorkspaceView =
| 'activity' | 'activity'
| 'settings' | 'settings'
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
> = {
'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 = { const emptyTokenUsage: TokenUsageSummary = {
totals: { totals: {
callCount: 0, callCount: 0,
@@ -743,6 +767,7 @@ function ChatHistoryPane({
visibleMessageCount: number visibleMessageCount: number
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('app') const { t } = useTranslation('app')
const headingId = `chat-heading-${conversation.id}`
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const pinnedToBottomRef = useRef( const pinnedToBottomRef = useRef(
scrollSnapshot?.pinnedToBottom ?? true scrollSnapshot?.pinnedToBottom ?? true
@@ -948,8 +973,12 @@ function ChatHistoryPane({
hidden={!active} hidden={!active}
inert={!active} inert={!active}
> >
<h1 className="sr-only" id={headingId}>
{t('chat.heading')}
</h1>
{taskStrip} {taskStrip}
<section <section
aria-labelledby={headingId}
className="chat" className="chat"
id={active ? 'chat-message-list' : undefined} id={active ? 'chat-message-list' : undefined}
onScroll={updateScrollPosition} onScroll={updateScrollPosition}
@@ -961,7 +990,7 @@ function ChatHistoryPane({
<Sparkles size={18} /> <Sparkles size={18} />
</div> </div>
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p> <p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h1>{t('chat.welcome.title')}</h1> <h2>{t('chat.welcome.title')}</h2>
<p className="welcome__description"> <p className="welcome__description">
{t('chat.welcome.description')} {t('chat.welcome.description')}
</p> </p>
@@ -2112,12 +2141,17 @@ function App(): React.JSX.Element {
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState( const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280 () => window.innerWidth >= 1280
) )
const [assistantSidebarOverlay, setAssistantSidebarOverlay] =
useState(() => window.innerWidth < 1280)
const [assistantSidebarTab, setAssistantSidebarTab] = const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>('tasks') useState<AssistantSidebarTab>('tasks')
const [browserStates, setBrowserStates] = useState< const [browserStates, setBrowserStates] = useState<
Record<string, BrowserLiveState> Record<string, BrowserLiveState>
>({}) >({})
const [view, setViewState] = useState<WorkspaceView>('chat') const [view, setViewState] = useState<WorkspaceView>('chat')
const settingsLeaveRequesterRef = useRef<
SettingsLeaveRequester | undefined
>(undefined)
const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState< const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState<
KeepAliveCacheEntry<WorkspaceView>[] KeepAliveCacheEntry<WorkspaceView>[]
>(() => [{ key: 'chat', lastVisitedAt: Date.now() }]) >(() => [{ key: 'chat', lastVisitedAt: Date.now() }])
@@ -2128,17 +2162,83 @@ function App(): React.JSX.Element {
? [{ key: activeId, lastVisitedAt: Date.now() }] ? [{ 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<WorkspaceView>()
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( const setView = useCallback(
(update: SetStateAction<WorkspaceView>): void => { (update: SetStateAction<WorkspaceView>): void => {
const next = const next =
typeof update === 'function' typeof update === 'function'
? update(viewRef.current) ? update(viewRef.current)
: update : update
viewRef.current = next if (viewRef.current === 'settings' && next !== 'settings') {
setCachedWorkspaceViews((current) => const requestLeave = settingsLeaveRequesterRef.current
touchKeepAliveEntry(current, next, Date.now()) 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 : update
activeConversationIdRef.current = next activeConversationIdRef.current = next
if (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) => setCachedConversationViews((current) =>
touchKeepAliveEntry(current, next, Date.now()) touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
protectedKeys: runningConversationIds,
recentEntries: recentCachedConversations
})
) )
} }
setActiveIdState(next) setActiveIdState(next)
@@ -2234,6 +2346,14 @@ function App(): React.JSX.Element {
const imageViewerTriggerRef = useRef<HTMLElement | undefined>( const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
undefined undefined
) )
const imageViewerDialogRef = useRef<HTMLElement>(null)
const imageViewerCloseRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!imageViewerItem) {
return
}
return activateModalFocus(() => imageViewerCloseRef.current)
}, [imageViewerItem])
useEffect( useEffect(
() => () =>
window.goodbuddy.context.onFileSelectionProgress((progress) => { window.goodbuddy.context.onFileSelectionProgress((progress) => {
@@ -2255,6 +2375,7 @@ function App(): React.JSX.Element {
const [knowledgeLoading, setKnowledgeLoading] = useState(true) const [knowledgeLoading, setKnowledgeLoading] = useState(true)
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>() const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0) const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0)
const knowledgeOperationCountRef = useRef(knowledgeOperationCount)
const knowledgeLoadRequestRef = useRef(0) const knowledgeLoadRequestRef = useRef(0)
const failedKnowledgeLibraryIdRef = useRef<string | undefined>( const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined undefined
@@ -2263,6 +2384,8 @@ function App(): React.JSX.Element {
string[] string[]
>([]) >([])
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false) const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>( const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords loadActivityRecords
) )
@@ -2314,6 +2437,7 @@ function App(): React.JSX.Element {
>({}) >({})
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null) const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef( const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>() new Map<string, HTMLButtonElement>()
) )
@@ -2339,6 +2463,15 @@ function App(): React.JSX.Element {
setSidebarOpen(false) setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus()) requestAnimationFrame(() => sidebarToggleRef.current?.focus())
}, []) }, [])
const navigateFromSidebar = useCallback(
(nextView: WorkspaceView): void => {
setView(nextView)
if (narrowWindow) {
closeNarrowSidebar()
}
},
[closeNarrowSidebar, narrowWindow, setView]
)
useEffect(() => { useEffect(() => {
if (!conversationStoreReady) { if (!conversationStoreReady) {
@@ -2399,7 +2532,9 @@ function App(): React.JSX.Element {
} }
setCachedConversationViews((current) => setCachedConversationViews((current) =>
pruneKeepAliveEntries( pruneKeepAliveEntries(
current.filter((entry) => conversationIds.has(entry.key)), filterKeepAliveEntries(current, (entry) =>
conversationIds.has(entry.key)
),
{ {
currentKey: activeId, currentKey: activeId,
expiresAfterMs: keepAliveExpirationMs, expiresAfterMs: keepAliveExpirationMs,
@@ -2429,6 +2564,7 @@ function App(): React.JSX.Element {
const collapseSidebarAtNarrowWidth = (): void => { const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900 const narrow = window.innerWidth < 900
setNarrowWindow(narrow) setNarrowWindow(narrow)
setAssistantSidebarOverlay(window.innerWidth < 1280)
if (narrow) { if (narrow) {
setSidebarOpen(false) setSidebarOpen(false)
} }
@@ -2462,6 +2598,41 @@ function App(): React.JSX.Element {
} }
}, [closeNarrowSidebar, narrowWindow, sidebarOpen]) }, [closeNarrowSidebar, narrowWindow, sidebarOpen])
useEffect(() => {
if (!knowledgeScopeOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
knowledgeScopePopoverRef.current
?.querySelector<HTMLInputElement>('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(() => { useLayoutEffect(() => {
conversationsRef.current = conversations conversationsRef.current = conversations
}, [conversations]) }, [conversations])
@@ -3094,6 +3265,9 @@ function App(): React.JSX.Element {
() => projects.find((project) => project.id === activeProjectId), () => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects] [activeProjectId, projects]
) )
const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name
: undefined
const filteredConversations = useMemo(() => { const filteredConversations = useMemo(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase() const query = deferredSearchQuery.trim().toLocaleLowerCase()
const candidates = query const candidates = query
@@ -3157,8 +3331,13 @@ function App(): React.JSX.Element {
) )
const projectNames = useMemo( const projectNames = useMemo(
() => () =>
new Map(projects.map((project) => [project.id, project.name])), new Map(
[projects] projects.map((project) => [
project.id,
getProjectDisplayText(project, tWorkspace).name
])
),
[projects, tWorkspace]
) )
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>( const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() => () =>
@@ -5680,11 +5859,10 @@ function App(): React.JSX.Element {
}, []) }, [])
const closeImageViewer = (): void => { const closeImageViewer = (): void => {
const trigger = imageViewerTriggerRef.current
setImageViewerItem(undefined) setImageViewerItem(undefined)
requestAnimationFrame(() => { imageViewerTriggerRef.current = undefined
imageViewerTriggerRef.current?.focus() requestAnimationFrame(() => trigger?.focus())
imageViewerTriggerRef.current = undefined
})
} }
const openCitationContext = useCallback(async ( const openCitationContext = useCallback(async (
@@ -6665,7 +6843,11 @@ function App(): React.JSX.Element {
const runKnowledgeSourceAction = async <T,>( const runKnowledgeSourceAction = async <T,>(
action: () => Promise<T> action: () => Promise<T>
): Promise<T> => { ): Promise<T> => {
setKnowledgeOperationCount((count) => count + 1) setKnowledgeOperationCount((count) => {
const next = count + 1
knowledgeOperationCountRef.current = next
return next
})
try { try {
const result = await action() const result = await action()
await refreshSelectedKnowledge() await refreshSelectedKnowledge()
@@ -6674,7 +6856,11 @@ function App(): React.JSX.Element {
await refreshSelectedKnowledge().catch(() => undefined) await refreshSelectedKnowledge().catch(() => undefined)
throw error throw error
} finally { } 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 runtimeSettings
]) ])
const assistantOverlayOpen =
assistantSidebarOverlay &&
assistantSidebarOpen &&
view === 'chat'
const mainSidebarOpen = narrowWindow && sidebarOpen
const backgroundIsolated = mainSidebarOpen || assistantOverlayOpen
return ( return (
<div className="app-shell"> <div className="app-shell">
<aside <aside
aria-label={ aria-label={
narrowWindow && sidebarOpen ? t('sidebar.label') : undefined narrowWindow && sidebarOpen ? t('sidebar.label') : undefined
} }
aria-hidden={!sidebarOpen} aria-hidden={!sidebarOpen || assistantOverlayOpen}
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined} aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'} className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
inert={!sidebarOpen} inert={!sidebarOpen || assistantOverlayOpen}
onKeyDown={(event) => {
if (mainSidebarOpen) {
trapTabFocus(event, sidebarRef.current)
}
}}
ref={sidebarRef} ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined} role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
> >
@@ -7152,7 +7350,7 @@ function App(): React.JSX.Element {
className={ className={
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item' view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
} }
onClick={() => setView('chat')} onClick={() => navigateFromSidebar('chat')}
type="button" type="button"
> >
<MessageSquare aria-hidden="true" size={17} /> <MessageSquare aria-hidden="true" size={17} />
@@ -7166,7 +7364,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active' ? 'nav-item nav-item--active'
: 'nav-item' : 'nav-item'
} }
onClick={() => setView('magic-notes')} onFocus={() => preloadWorkspaceRouteOnIntent('magic-notes')}
onClick={() => navigateFromSidebar('magic-notes')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('magic-notes')
}
type="button" type="button"
> >
<Sparkles aria-hidden="true" size={17} /> <Sparkles aria-hidden="true" size={17} />
@@ -7180,7 +7382,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active' ? 'nav-item nav-item--active'
: 'nav-item' : 'nav-item'
} }
onClick={() => setView('knowledge')} onFocus={() => preloadWorkspaceRouteOnIntent('knowledge')}
onClick={() => navigateFromSidebar('knowledge')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('knowledge')
}
type="button" type="button"
> >
<Library aria-hidden="true" size={17} /> <Library aria-hidden="true" size={17} />
@@ -7193,7 +7399,7 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active' ? 'nav-item nav-item--active'
: 'nav-item' : 'nav-item'
} }
onClick={() => setView('heartbeat')} onClick={() => navigateFromSidebar('heartbeat')}
type="button" type="button"
> >
<HeartPulse aria-hidden="true" size={17} /> <HeartPulse aria-hidden="true" size={17} />
@@ -7216,7 +7422,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active' ? 'nav-item nav-item--active'
: 'nav-item' : 'nav-item'
} }
onClick={() => setView('activity')} onFocus={() => preloadWorkspaceRouteOnIntent('activity')}
onClick={() => navigateFromSidebar('activity')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('activity')
}
type="button" type="button"
> >
<TerminalSquare aria-hidden="true" size={17} /> <TerminalSquare aria-hidden="true" size={17} />
@@ -7292,6 +7502,9 @@ function App(): React.JSX.Element {
return next return next
}) })
setView('chat') setView('chat')
if (narrowWindow) {
closeNarrowSidebar()
}
}} }}
> >
<span className="conversation-item__primary"> <span className="conversation-item__primary">
@@ -7517,7 +7730,12 @@ function App(): React.JSX.Element {
? 'conversation-task-child conversation-task-child--active' ? 'conversation-task-child conversation-task-child--active'
: 'conversation-task-child' : 'conversation-task-child'
} }
onClick={() => openAssistantTask(task)} onClick={() => {
openAssistantTask(task)
if (narrowWindow) {
closeNarrowSidebar()
}
}}
type="button" type="button"
> >
<span <span
@@ -7549,6 +7767,9 @@ function App(): React.JSX.Element {
) )
setActiveId(conversation.id) setActiveId(conversation.id)
setView('chat') setView('chat')
if (narrowWindow) {
closeNarrowSidebar()
}
}} }}
type="button" type="button"
> >
@@ -7577,7 +7798,11 @@ function App(): React.JSX.Element {
<button <button
className="user-card" className="user-card"
type="button" type="button"
onClick={() => setView('settings')} onFocus={() => preloadWorkspaceRouteOnIntent('settings')}
onClick={() => navigateFromSidebar('settings')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('settings')
}
> >
<span className="avatar">GB</span> <span className="avatar">GB</span>
<span className="user-card__copy"> <span className="user-card__copy">
@@ -7602,9 +7827,9 @@ function App(): React.JSX.Element {
)} )}
<main <main
aria-hidden={narrowWindow && sidebarOpen ? 'true' : undefined} aria-hidden={backgroundIsolated ? 'true' : undefined}
className="workspace" className="workspace"
inert={narrowWindow && sidebarOpen} inert={backgroundIsolated}
> >
<header className="topbar"> <header className="topbar">
<button <button
@@ -7648,7 +7873,8 @@ function App(): React.JSX.Element {
activeProject activeProject
? { ? {
kind: 'project', kind: 'project',
projectName: activeProject.name projectName:
activeProjectDisplayName ?? activeProject.name
} }
: { : {
kind: 'unavailable', kind: 'unavailable',
@@ -7689,6 +7915,7 @@ function App(): React.JSX.Element {
onClick={() => onClick={() =>
setAssistantSidebarOpen((current) => !current) setAssistantSidebarOpen((current) => !current)
} }
ref={assistantSidebarToggleRef}
type="button" type="button"
> >
<PanelRightOpen size={18} /> <PanelRightOpen size={18} />
@@ -8065,8 +8292,20 @@ function App(): React.JSX.Element {
</button> </button>
</div> </div>
{knowledgeSnapshot.libraries.length > 0 && ( {knowledgeSnapshot.libraries.length > 0 && (
<div className="knowledge-scope"> <div
className="knowledge-scope"
onBlurCapture={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setKnowledgeScopeOpen(false)
}
}}
>
<button <button
aria-controls="knowledge-scope-popover"
aria-haspopup="dialog"
aria-label={t('composer.knowledge.select', { aria-label={t('composer.knowledge.select', {
count: enabledKnowledgeLibraryIds.length count: enabledKnowledgeLibraryIds.length
})} })}
@@ -8074,6 +8313,7 @@ function App(): React.JSX.Element {
onClick={() => onClick={() =>
setKnowledgeScopeOpen((current) => !current) setKnowledgeScopeOpen((current) => !current)
} }
ref={knowledgeScopeTriggerRef}
title={t('composer.knowledge.title')} title={t('composer.knowledge.title')}
type="button" type="button"
> >
@@ -8084,7 +8324,13 @@ function App(): React.JSX.Element {
</span> </span>
</button> </button>
{knowledgeScopeOpen && ( {knowledgeScopeOpen && (
<div className="knowledge-scope__popover"> <div
aria-label={t('composer.knowledge.scope')}
className="knowledge-scope__popover"
id="knowledge-scope-popover"
ref={knowledgeScopePopoverRef}
role="dialog"
>
<strong>{t('composer.knowledge.scope')}</strong> <strong>{t('composer.knowledge.scope')}</strong>
{knowledgeSnapshot.libraries.map((library) => ( {knowledgeSnapshot.libraries.map((library) => (
<label key={library.id}> <label key={library.id}>
@@ -9132,7 +9378,7 @@ function App(): React.JSX.Element {
onClose={() => { onClose={() => {
setSettingsInitialCategory(undefined) setSettingsInitialCategory(undefined)
setSettingsInitialChannel(undefined) setSettingsInitialChannel(undefined)
setView('chat') commitView('chat')
}} }}
onExpertsChanged={(experts) => { onExpertsChanged={(experts) => {
setAssistantExperts(experts) setAssistantExperts(experts)
@@ -9151,9 +9397,11 @@ function App(): React.JSX.Element {
setMagicNotesEnabled(enabled) setMagicNotesEnabled(enabled)
}} }}
onNotify={notify} onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSaved={(settings) => { onSaved={(settings) => {
setRuntimeSettings(settings) setRuntimeSettings(settings)
}} }}
onShortcutSettingsChanged={handleShortcutSettingsChanged}
onUpdateProject={updateProject} onUpdateProject={updateProject}
open={view === 'settings'} open={view === 'settings'}
presentation="page" presentation="page"
@@ -9170,12 +9418,29 @@ function App(): React.JSX.Element {
route="activity" route="activity"
> >
<PageShell variant="dashboard"> <PageShell variant="dashboard">
<ActivityPanel <RouteErrorBoundary
onClear={() => setActivityRecords([])} key="activity"
onOpenConversation={openActivityConversation} fallback={
records={activityRecords} <RouteLoadError
tokenUsage={tokenUsage} message={t('route.loadFailed')}
/> reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
tokenUsage={tokenUsage}
/>
</Suspense>
</RouteErrorBoundary>
</PageShell> </PageShell>
</KeepAliveRoute> </KeepAliveRoute>
)} )}
@@ -9233,9 +9498,13 @@ function App(): React.JSX.Element {
className="image-viewer-dialog" className="image-viewer-dialog"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault()
closeImageViewer() closeImageViewer()
return
} }
trapTabFocus(event, imageViewerDialogRef.current)
}} }}
ref={imageViewerDialogRef}
role="dialog" role="dialog"
> >
<header className="image-viewer-dialog__header"> <header className="image-viewer-dialog__header">
@@ -9253,9 +9522,9 @@ function App(): React.JSX.Element {
</button> </button>
<button <button
aria-label={t('chat.images.closeViewer')} aria-label={t('chat.images.closeViewer')}
autoFocus
className="icon-button" className="icon-button"
onClick={closeImageViewer} onClick={closeImageViewer}
ref={imageViewerCloseRef}
type="button" type="button"
> >
<X size={16} /> <X size={16} />
@@ -9283,7 +9552,9 @@ function App(): React.JSX.Element {
onClose={() => setCustomTaskDialog(undefined)} onClose={() => setCustomTaskDialog(undefined)}
onCreate={createCustomTask} onCreate={createCustomTask}
projectId={activeProject.id} projectId={activeProject.id}
projectName={activeProject.name} projectName={
activeProjectDisplayName ?? activeProject.name
}
runtimeLabel={activeRuntimeLabel} runtimeLabel={activeRuntimeLabel}
supportsToolExecution={Boolean( supportsToolExecution={Boolean(
runtime?.supportsToolExecution runtime?.supportsToolExecution
@@ -9293,6 +9564,14 @@ function App(): React.JSX.Element {
} }
/> />
)} )}
{assistantOverlayOpen && (
<button
aria-label={tWorkspace('sidebar.dismissOverlay')}
className="assistant-sidebar-backdrop"
onClick={() => setAssistantSidebarOpen(false)}
type="button"
/>
)}
<RightAssistantSidebar <RightAssistantSidebar
approvals={pendingSidebarApprovals} approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts} artifacts={sidebarArtifacts}
@@ -9386,6 +9665,8 @@ function App(): React.JSX.Element {
open={ open={
assistantSidebarOpen && view === 'chat' assistantSidebarOpen && view === 'chat'
} }
overlay={assistantSidebarOverlay}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab} tab={assistantSidebarTab}
workspaceChanges={workspaceChanges} workspaceChanges={workspaceChanges}
workspaceProjectId={activeProjectId || undefined} workspaceProjectId={activeProjectId || undefined}
@@ -123,6 +123,7 @@ const snapshot: DocumentParsingSnapshot = {
} }
const getSnapshot = vi.fn(async () => snapshot) const getSnapshot = vi.fn(async () => snapshot)
const getOcrModelProgress = vi.fn(async () => ({ operations: [] }))
const update = vi.fn(async (input: DocumentParsingSettings) => ({ const update = vi.fn(async (input: DocumentParsingSettings) => ({
...snapshot, ...snapshot,
settings: input settings: input
@@ -181,6 +182,7 @@ describe('DocumentParsingSettingsSection', () => {
value: { value: {
documentParsing: { documentParsing: {
getSnapshot, getSnapshot,
getOcrModelProgress,
update, update,
test, test,
installOcrModel, installOcrModel,
@@ -319,6 +321,51 @@ describe('DocumentParsingSettingsSection', () => {
) )
}) })
it('waits for each OCR progress request before scheduling the next', async () => {
let resolveProgress:
| ((value: { operations: [] }) => void)
| undefined
let resolveInstall:
| ((value: DocumentParsingSnapshot) => void)
| undefined
getOcrModelProgress.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProgress = resolve
})
)
installOcrModel.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInstall = resolve
})
)
render(<DocumentParsingSettingsSection />)
const installButton = await screen.findByRole('button', {
name: '下载 PP-OCRv6 Tiny'
})
vi.useFakeTimers()
try {
fireEvent.click(installButton)
await vi.advanceTimersByTimeAsync(300)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_200)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
resolveProgress?.({ operations: [] })
await vi.advanceTimersByTimeAsync(299)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getOcrModelProgress).toHaveBeenCalledTimes(2)
resolveInstall?.(snapshot)
await vi.runAllTimersAsync()
} finally {
vi.useRealTimers()
}
})
it('downloads and selects an uninstalled model in one action', async () => { it('downloads and selects an uninstalled model in one action', async () => {
const installedMedium = { const installedMedium = {
...snapshot, ...snapshot,
@@ -463,17 +510,35 @@ describe('DocumentParsingSettingsSection', () => {
render(<DocumentParsingSettingsSection />) render(<DocumentParsingSettingsSection />)
await screen.findByText('PP-OCRv6 Tiny') await screen.findByText('PP-OCRv6 Tiny')
fireEvent.click( const trigger = screen.getByRole('button', {
screen.getByRole('button', { name: '测试聊天与成果模式'
name: '测试聊天与成果模式' })
}) trigger.focus()
) fireEvent.click(trigger)
const dialog = await screen.findByRole('dialog', {
name: '解析测试结果'
})
expect(dialog).toHaveTextContent('扫描件识别正文')
const close = screen.getByRole('button', {
name: '关闭结果'
})
expect(close).toHaveFocus()
const backgroundSection = trigger.closest<HTMLElement>(
'.settings-section'
)
expect(backgroundSection?.inert).toBe(true)
expect( expect(
await screen.findByRole('dialog', { fireEvent.keyDown(dialog, { key: 'Tab' })
name: '解析测试结果' ).toBe(false)
}) expect(close).toHaveFocus()
).toHaveTextContent('扫描件识别正文')
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
expect(backgroundSection?.inert).toBe(false)
expect(test).toHaveBeenCalledWith('chat-attachment') expect(test).toHaveBeenCalledWith('chat-attachment')
expect(update).not.toHaveBeenCalled() expect(update).not.toHaveBeenCalled()
}) })
@@ -29,6 +29,7 @@ import type {
DocumentParsingTestPurpose DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts' } from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { import {
SettingsCategoryHeader, SettingsCategoryHeader,
SettingsWarningList SettingsWarningList
@@ -37,6 +38,7 @@ import {
type DocumentParsingSettingsSectionProps = { type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
onOpenModelDownloadSourceSettings?: () => void onOpenModelDownloadSourceSettings?: () => void
onDirtyChange?: (dirty: boolean) => void
} }
function errorMessage(reason: unknown, fallback: string): string { function errorMessage(reason: unknown, fallback: string): string {
@@ -117,9 +119,10 @@ function DiagnosticDialog({
onClose: () => void onClose: () => void
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null) const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => { useEffect(() => {
closeRef.current?.focus() return activateModalFocus(() => closeRef.current)
}, []) }, [])
return ( return (
<div <div
@@ -136,9 +139,13 @@ function DiagnosticDialog({
className="document-parsing-diagnostic" className="document-parsing-diagnostic"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault()
onClose() onClose()
return
} }
trapTabFocus(event, dialogRef.current)
}} }}
ref={dialogRef}
role="dialog" role="dialog"
> >
<header> <header>
@@ -209,7 +216,8 @@ function DiagnosticDialog({
export function DocumentParsingSettingsSection({ export function DocumentParsingSettingsSection({
onNotify, onNotify,
onOpenModelDownloadSourceSettings onOpenModelDownloadSourceSettings,
onDirtyChange
}: DocumentParsingSettingsSectionProps): React.JSX.Element { }: DocumentParsingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>() const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
@@ -223,6 +231,14 @@ export function DocumentParsingSettingsSection({
const [diagnostic, setDiagnostic] = const [diagnostic, setDiagnostic] =
useState<DocumentParsingDiagnostic>() useState<DocumentParsingDiagnostic>()
const mountedRef = useRef(false) const mountedRef = useRef(false)
const settingsDirty =
snapshot !== undefined &&
draft !== undefined &&
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
useEffect(() => {
onDirtyChange?.(settingsDirty)
}, [onDirtyChange, settingsDirty])
const refresh = useCallback(async (): Promise<void> => { const refresh = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing const api = window.goodbuddy.documentParsing
@@ -235,6 +251,27 @@ export function DocumentParsingSettingsSection({
} }
}, [t]) }, [t])
const refreshProgress = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
return
}
const progress = await api.getOcrModelProgress()
if (mountedRef.current) {
setSnapshot((current) =>
current
? {
...current,
ocrModels: {
...current.ocrModels,
operations: progress.operations
}
}
: current
)
}
}, [])
useEffect(() => { useEffect(() => {
const api = window.goodbuddy.documentParsing const api = window.goodbuddy.documentParsing
let active = true let active = true
@@ -274,11 +311,27 @@ export function DocumentParsingSettingsSection({
if (!shouldPoll) { if (!shouldPoll) {
return return
} }
const timer = window.setInterval(() => { let active = true
void refresh().catch(() => undefined) let timer: number | undefined
}, 300) const poll = async (): Promise<void> => {
return () => window.clearInterval(timer) try {
}, [refresh, shouldPoll]) await refreshProgress()
} catch {
// The final full refresh reports actionable operation errors.
} finally {
if (active) {
timer = window.setTimeout(() => void poll(), 300)
}
}
}
timer = window.setTimeout(() => void poll(), 300)
return () => {
active = false
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [refreshProgress, shouldPoll])
const updateDraft = <Key extends keyof DocumentParsingSettings>( const updateDraft = <Key extends keyof DocumentParsingSettings>(
key: Key, key: Key,
@@ -450,8 +503,6 @@ export function DocumentParsingSettingsSection({
modelDownloadAvailability?.available === true modelDownloadAvailability?.available === true
const pendingModelSelection = const pendingModelSelection =
draft.localOcrModelId !== snapshot.settings.localOcrModelId draft.localOcrModelId !== snapshot.settings.localOcrModelId
const settingsDirty =
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
const selectedModelReady = installedModel !== undefined const selectedModelReady = installedModel !== undefined
const invalidPendingModel = const invalidPendingModel =
pendingModelSelection && !selectedModelReady pendingModelSelection && !selectedModelReady
+114 -3
View File
@@ -15,6 +15,10 @@ import type {
AssistantMemory, AssistantMemory,
AssistantTask AssistantTask
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter' import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
import i18n from './i18n' import i18n from './i18n'
@@ -110,11 +114,12 @@ function createProps(
projects: [ projects: [
{ {
id: '00000000-0000-4000-8000-000000000101', id: '00000000-0000-4000-8000-000000000101',
name: '默认项目', name: builtInDefaultProjectSeedName,
description: '', description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace', rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask', defaultWorkMode: 'ask',
kind: 'user', kind: 'user',
builtInDefault: true,
status: 'active', status: 'active',
createdAt: '2026-07-31T01:00:00.000Z', createdAt: '2026-07-31T01:00:00.000Z',
updatedAt: '2026-07-31T01:00:00.000Z' updatedAt: '2026-07-31T01:00:00.000Z'
@@ -155,6 +160,12 @@ describe('HeartbeatCenter', () => {
screen.getByRole('button', { name: 'Run heartbeat now' }) screen.getByRole('button', { name: 'Run heartbeat now' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText(entry.summary)).toBeInTheDocument() expect(screen.getByText(entry.summary)).toBeInTheDocument()
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect(
screen.getByText(/Every day at 09:00 · Default project/u)
).toBeInTheDocument()
const englishDate = new Intl.DateTimeFormat('en-US', { const englishDate = new Intl.DateTimeFormat('en-US', {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -171,6 +182,14 @@ describe('HeartbeatCenter', () => {
expect( expect(
screen.getByRole('button', { name: /Handle in conversation/ }) screen.getByRole('button', { name: /Handle in conversation/ })
).toBeInTheDocument() ).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Heartbeat plans' })
)
fireEvent.click(
screen.getByRole('button', { name: 'Selected projects' })
)
expect(screen.getByLabelText('Default project')).toBeInTheDocument()
}) })
it('shows heartbeat health, growth dimensions, and the latest report', () => { it('shows heartbeat health, growth dimensions, and the latest report', () => {
@@ -179,7 +198,7 @@ describe('HeartbeatCenter', () => {
expect( expect(
screen.getByRole('heading', { level: 1, name: '智能心跳' }) screen.getByRole('heading', { level: 1, name: '智能心跳' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText('全局')).toHaveClass( expect(screen.getByText('项目:默认项目')).toHaveClass(
'scope-badge' 'scope-badge'
) )
expect(screen.getByText(/ 09:00 · /u)).toBeInTheDocument() expect(screen.getByText(/ 09:00 · /u)).toBeInTheDocument()
@@ -199,6 +218,98 @@ describe('HeartbeatCenter', () => {
expect(within(dimensions).getByText('2')).toBeInTheDocument() expect(within(dimensions).getByText('2')).toBeInTheDocument()
}) })
it('distinguishes multi-project-only scope from global and project scope', async () => {
const secondProject = {
...createProps().projects[0]!,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
const projectConfig: AssistantHeartbeatConfig = {
...config,
scope: {
kind: 'projects',
projectIds: [createProps().projects[0]!.id, secondProject.id]
}
}
const { rerender } = render(
<HeartbeatCenter
{...createProps({
configs: [projectConfig],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(//u)).not.toBeInTheDocument()
rerender(
<HeartbeatCenter
{...createProps({
configs: [
projectConfig,
{
...config,
id: 'heartbeat-global',
scope: { kind: 'global' }
}
],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
await i18n.changeLanguage('en-US')
rerender(
<HeartbeatCenter
{...createProps({
configs: [projectConfig],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('2 projects')).toBeInTheDocument()
})
it('uses level-two headings for direct tab panel sections', () => {
render(<HeartbeatCenter {...createProps()} />)
expect(
screen.getByRole('heading', { level: 2, name: '当前状态' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '成长趋势' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '本次心跳' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: //u }))
expect(
screen.getByRole('heading', { level: 2, name: '待确认记忆' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '行动建议' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳轨迹' }))
expect(
screen.getByRole('heading', { level: 2, name: '成长轨迹' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '运行记录' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
expect(
screen.getByRole('heading', { level: 2, name: '智能心跳' })
).toBeInTheDocument()
})
it('turns heartbeat findings into explicit user actions', async () => { it('turns heartbeat findings into explicit user actions', async () => {
const onSetMemoryStatus = vi.fn(async () => {}) const onSetMemoryStatus = vi.fn(async () => {})
const onSetTaskStatus = vi.fn(async () => {}) const onSetTaskStatus = vi.fn(async () => {})
+71 -21
View File
@@ -24,10 +24,12 @@ import type {
HeartbeatUpdateInput HeartbeatUpdateInput
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings' import { HeartbeatSettings } from './HeartbeatSettings'
import { getProjectDisplayText } from './project-display'
import { import {
EmptyState, EmptyState,
PageHeader, PageHeader,
PageTabs PageTabs,
type WorkspaceScope
} from './WorkspacePrimitives' } from './WorkspacePrimitives'
type HeartbeatCenterTab = type HeartbeatCenterTab =
@@ -101,6 +103,7 @@ export function HeartbeatCenter({
onRetryLoad onRetryLoad
}: HeartbeatCenterProps): React.JSX.Element { }: HeartbeatCenterProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat') const { t, i18n } = useTranslation('heartbeat')
const { t: tWorkspace } = useTranslation('workspace')
const [tab, setTab] = useState<HeartbeatCenterTab>('overview') const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
const [pendingAction, setPendingAction] = useState<string>() const [pendingAction, setPendingAction] = useState<string>()
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
@@ -194,11 +197,14 @@ export function HeartbeatCenter({
if (config.scope.kind === 'global') { if (config.scope.kind === 'global') {
return t('center.scope.global') return t('center.scope.global')
} }
const projectNames = config.scope.projectIds.map( const projectNames = config.scope.projectIds.map((projectId) => {
(projectId) => const project = projects.find(
projects.find((project) => project.id === projectId)?.name ?? (candidate) => candidate.id === projectId
t('settings.scope.unavailableProject') )
) return project
? getProjectDisplayText(project, tWorkspace).name
: t('settings.scope.unavailableProject')
})
return projectNames.join(t('settings.scope.nameSeparator')) return projectNames.join(t('settings.scope.nameSeparator'))
} }
@@ -276,6 +282,50 @@ export function HeartbeatCenter({
) )
const hasHeartbeatData = const hasHeartbeatData =
configs.length > 0 || runs.length > 0 || entries.length > 0 configs.length > 0 || runs.length > 0 || entries.length > 0
const heartbeatScope = useMemo<WorkspaceScope>(() => {
if (
configs.length === 0 ||
configs.every((config) => config.scope.kind === 'global')
) {
return { kind: 'global' }
}
const includesGlobal = configs.some(
(config) => config.scope.kind === 'global'
)
const projectIds = [
...new Set(
configs.flatMap((config) =>
config.scope.kind === 'projects'
? config.scope.projectIds
: []
)
)
]
if (!includesGlobal && projectIds.length === 1) {
const project = projects.find(
(candidate) => candidate.id === projectIds[0]
)
return project
? {
kind: 'project',
projectName: getProjectDisplayText(
project,
tWorkspace
).name
}
: {
kind: 'unavailable',
explanation: t('settings.scope.unavailableProject')
}
}
if (!includesGlobal && projectIds.length > 1) {
return {
kind: 'projects',
projectCount: projectIds.length
}
}
return { kind: 'mixed' }
}, [configs, projects, t, tWorkspace])
const initialLoadBlocked = const initialLoadBlocked =
!hasHeartbeatData && (loading || loadError !== undefined) !hasHeartbeatData && (loading || loadError !== undefined)
@@ -368,7 +418,7 @@ export function HeartbeatCenter({
eyebrow={t('center.eyebrow')} eyebrow={t('center.eyebrow')}
headingId="heartbeat-center-title" headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />} icon={<HeartPulse size={22} />}
scope={{ kind: 'global' }} scope={heartbeatScope}
title={t('center.title')} title={t('center.title')}
/> />
@@ -445,9 +495,9 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.currentStatus.eyebrow')} {t('center.currentStatus.eyebrow')}
</p> </p>
<h3 id="heartbeat-status-title"> <h2 id="heartbeat-status-title">
{t('center.currentStatus.title')} {t('center.currentStatus.title')}
</h3> </h2>
</div> </div>
<span <span
className={ className={
@@ -666,9 +716,9 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.trend.eyebrow')} {t('center.trend.eyebrow')}
</p> </p>
<h3 id="heartbeat-trend-title"> <h2 id="heartbeat-trend-title">
{t('center.trend.title')} {t('center.trend.title')}
</h3> </h2>
</div> </div>
</div> </div>
{recentTrend.length === 0 ? ( {recentTrend.length === 0 ? (
@@ -760,9 +810,9 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.latest.eyebrow')} {t('center.latest.eyebrow')}
</p> </p>
<h3 id="latest-heartbeat-title"> <h2 id="latest-heartbeat-title">
{t('center.latest.title')} {t('center.latest.title')}
</h3> </h2>
</div> </div>
{latestEntry && ( {latestEntry && (
<time dateTime={latestEntry.createdAt}> <time dateTime={latestEntry.createdAt}>
@@ -829,9 +879,9 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.suggestions.memoryEyebrow')} {t('center.suggestions.memoryEyebrow')}
</p> </p>
<h3 id="heartbeat-memory-title"> <h2 id="heartbeat-memory-title">
{t('center.suggestions.memoryTitle')} {t('center.suggestions.memoryTitle')}
</h3> </h2>
</div> </div>
<span> <span>
{t('center.suggestions.memoryCount', { {t('center.suggestions.memoryCount', {
@@ -943,9 +993,9 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.suggestions.taskEyebrow')} {t('center.suggestions.taskEyebrow')}
</p> </p>
<h3 id="heartbeat-task-title"> <h2 id="heartbeat-task-title">
{t('center.suggestions.taskTitle')} {t('center.suggestions.taskTitle')}
</h3> </h2>
</div> </div>
<span> <span>
{t('center.suggestions.taskCount', { {t('center.suggestions.taskCount', {
@@ -1065,10 +1115,10 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.history.timelineEyebrow')} {t('center.history.timelineEyebrow')}
</p> </p>
<h3 id="heartbeat-reports-title"> <h2 id="heartbeat-reports-title">
<History aria-hidden="true" size={16} /> <History aria-hidden="true" size={16} />
{t('center.history.timelineTitle')} {t('center.history.timelineTitle')}
</h3> </h2>
</div> </div>
<span> <span>
{t('center.history.reportCount', { {t('center.history.reportCount', {
@@ -1168,9 +1218,9 @@ export function HeartbeatCenter({
<p className="eyebrow"> <p className="eyebrow">
{t('center.history.auditEyebrow')} {t('center.history.auditEyebrow')}
</p> </p>
<h3 id="heartbeat-runs-title"> <h2 id="heartbeat-runs-title">
{t('center.history.auditTitle')} {t('center.history.auditTitle')}
</h3> </h2>
</div> </div>
<span> <span>
{t('center.history.runCount', { {t('center.history.runCount', {
+13 -8
View File
@@ -12,6 +12,7 @@ import {
DestructiveConfirmActions, DestructiveConfirmActions,
SegmentedControl SegmentedControl
} from './WorkspacePrimitives' } from './WorkspacePrimitives'
import { getProjectDisplayText } from './project-display'
type HeartbeatSettingsProps = { type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[] heartbeats: AssistantHeartbeatConfig[]
@@ -38,6 +39,7 @@ export function HeartbeatSettings({
onRunNow onRunNow
}: HeartbeatSettingsProps): React.JSX.Element { }: HeartbeatSettingsProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat') const { t, i18n } = useTranslation('heartbeat')
const { t: tWorkspace } = useTranslation('workspace')
const [editingId, setEditingId] = useState<string>() const [editingId, setEditingId] = useState<string>()
const [name, setName] = useState(t('settings.defaultName')) const [name, setName] = useState(t('settings.defaultName'))
const [time, setTime] = useState('09:00') const [time, setTime] = useState('09:00')
@@ -162,11 +164,12 @@ export function HeartbeatSettings({
if (heartbeat.scope.kind === 'global') { if (heartbeat.scope.kind === 'global') {
return t('settings.scope.global') return t('settings.scope.global')
} }
const names = heartbeat.scope.projectIds.map( const names = heartbeat.scope.projectIds.map((projectId) => {
(projectId) => const project = projectById.get(projectId)
projectById.get(projectId)?.name ?? return project
t('settings.scope.unavailableProject') ? getProjectDisplayText(project, tWorkspace).name
) : t('settings.scope.unavailableProject')
})
return t('settings.scope.selectedProjectsSummary', { return t('settings.scope.selectedProjectsSummary', {
count: names.length, count: names.length,
names: names.join(t('settings.scope.nameSeparator')) names: names.join(t('settings.scope.nameSeparator'))
@@ -176,10 +179,10 @@ export function HeartbeatSettings({
return ( return (
<div className="heartbeat-settings"> <div className="heartbeat-settings">
<div className="heartbeat-settings__intro"> <div className="heartbeat-settings__intro">
<h3> <h2>
<HeartPulse size={15} /> <HeartPulse size={15} />
{t('settings.title')} {t('settings.title')}
</h3> </h2>
<p>{t('settings.description')}</p> <p>{t('settings.description')}</p>
</div> </div>
<div className="heartbeat-settings__editor"> <div className="heartbeat-settings__editor">
@@ -250,7 +253,9 @@ export function HeartbeatSettings({
} }
type="checkbox" type="checkbox"
/> />
<span>{project.name}</span> <span>
{getProjectDisplayText(project, tWorkspace).name}
</span>
{project.status !== 'active' && ( {project.status !== 'active' && (
<small>{t('settings.scope.archived')}</small> <small>{t('settings.scope.archived')}</small>
)} )}
+1 -1
View File
@@ -30,7 +30,7 @@ type ChartKnowledgeGraphRelation = Omit<
evidenceIds?: readonly string[] evidenceIds?: readonly string[]
} }
type KnowledgeGraphChartProps = { export type KnowledgeGraphChartProps = {
nodes: readonly ChartKnowledgeGraphNode[] nodes: readonly ChartKnowledgeGraphNode[]
relations: readonly ChartKnowledgeGraphRelation[] relations: readonly ChartKnowledgeGraphRelation[]
selectedNodeId?: string selectedNodeId?: string
+257 -16
View File
@@ -7,8 +7,11 @@ import {
waitFor, waitFor,
within within
} from '@testing-library/react' } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
KnowledgeGraphChartLoader,
KnowledgeWorkspace, KnowledgeWorkspace,
type KnowledgeWorkspaceProps type KnowledgeWorkspaceProps
} from './KnowledgeWorkspace' } from './KnowledgeWorkspace'
@@ -17,6 +20,17 @@ import {
defaultKnowledgeOntologySettings defaultKnowledgeOntologySettings
} from '../../shared/knowledge-ontology' } from '../../shared/knowledge-ontology'
const knowledgeWorkspaceSource = readFileSync(
join(
process.cwd(),
'src',
'renderer',
'src',
'KnowledgeWorkspace.tsx'
),
'utf8'
)
const g6Mock = vi.hoisted(() => { const g6Mock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown) => void>() const handlers = new Map<string, (event: unknown) => void>()
const graph = { const graph = {
@@ -460,11 +474,13 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('第二份文档内容')).toBeInTheDocument() expect(screen.getByText('第二份文档内容')).toBeInTheDocument()
}) })
it('switches to the graph and opens entity details', () => { it('switches to the graph and opens entity details', async () => {
render(<KnowledgeWorkspace {...createProps()} />) render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(screen.getByLabelText('实体关系图')).toBeInTheDocument() expect(
await screen.findByLabelText('实体关系图')
).toBeInTheDocument()
expect( expect(
screen.getByRole('tab', { name: //u }) screen.getByRole('tab', { name: //u })
).toHaveAttribute('aria-selected', 'true') ).toHaveAttribute('aria-selected', 'true')
@@ -1217,6 +1233,9 @@ describe('KnowledgeWorkspace', () => {
render(<KnowledgeWorkspace {...createProps()} />) render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await waitFor(() =>
expect(g6Mock.graph.setOptions).toHaveBeenCalled()
)
expect( expect(
screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' }) screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' })
).toBeInTheDocument() ).toBeInTheDocument()
@@ -1265,9 +1284,7 @@ describe('KnowledgeWorkspace', () => {
const workspace = screen.getByLabelText('知识工作区') const workspace = screen.getByLabelText('知识工作区')
expect(workspace).toHaveClass('knowledge-workspace') expect(workspace).toHaveClass('knowledge-workspace')
expect(workspace).toHaveStyle({ expect(workspace).not.toHaveAttribute('style')
background: 'var(--surface-canvas)'
})
expect(workspace.querySelector('aside')).toHaveClass( expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar' 'knowledge-workspace__sidebar'
) )
@@ -1276,9 +1293,7 @@ describe('KnowledgeWorkspace', () => {
name: '知识库详情' name: '知识库详情'
}) })
expect(detailRegion).toHaveClass('knowledge-workspace__main') expect(detailRegion).toHaveClass('knowledge-workspace__main')
expect(detailRegion).toHaveStyle({ expect(detailRegion).not.toHaveAttribute('style')
background: 'var(--surface-raised)'
})
expect(screen.getByText('全局')).toHaveClass('scope-badge') expect(screen.getByText('全局')).toHaveClass('scope-badge')
const mobileBack = screen.getByRole('button', { const mobileBack = screen.getByRole('button', {
name: '返回知识库列表' name: '返回知识库列表'
@@ -1286,11 +1301,14 @@ describe('KnowledgeWorkspace', () => {
expect(mobileBack).toHaveClass('knowledge-workspace__mobile-back') expect(mobileBack).toHaveClass('knowledge-workspace__mobile-back')
fireEvent.click(mobileBack) fireEvent.click(mobileBack)
expect(workspace).toHaveClass('knowledge-workspace--mobile-list') expect(workspace).toHaveClass('knowledge-workspace--mobile-list')
fireEvent.click( const selectedLibraryButton = screen.getByRole('button', {
screen.getByRole('button', { name: /^ 1 /u
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(workspace).not.toHaveClass('knowledge-workspace--mobile-list')
expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass( expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass(
'page-tabs' 'page-tabs'
@@ -1324,13 +1342,19 @@ describe('KnowledgeWorkspace', () => {
target: { value: 'entity-1' } target: { value: 'entity-1' }
}) })
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass( 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( expect(screen.getByLabelText('实体详情')).toHaveClass(
'knowledge-graph__detail' '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 () => { it('manages the G6 graph, zoom, selection, movement, and cleanup', async () => {
const onMoveNode = vi.fn() const onMoveNode = vi.fn()
const { rerender, unmount } = render( const { rerender, unmount } = render(
@@ -1338,7 +1362,7 @@ describe('KnowledgeWorkspace', () => {
) )
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
const graph = screen.getByLabelText('实体关系图') const graph = await screen.findByLabelText('实体关系图')
expect(graph).toHaveClass('knowledge-graph__chart') expect(graph).toHaveClass('knowledge-graph__chart')
expect(g6Mock.Graph).toHaveBeenCalledWith( expect(g6Mock.Graph).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -1491,6 +1515,7 @@ describe('KnowledgeWorkspace', () => {
it('preserves the G6 instance and refreshes theme colors', async () => { it('preserves the G6 instance and refreshes theme colors', async () => {
render(<KnowledgeWorkspace {...createProps()} />) render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await screen.findByLabelText('实体关系图')
g6Mock.graph.getZoom.mockReturnValueOnce(1.3) g6Mock.graph.getZoom.mockReturnValueOnce(1.3)
act(() => { act(() => {
@@ -1523,7 +1548,7 @@ describe('KnowledgeWorkspace', () => {
delete document.documentElement.dataset.theme 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) => ({ const graphNodes = Array.from({ length: 30 }, (_, index) => ({
id: `entity-${index}`, id: `entity-${index}`,
label: `实体 ${index}`, label: `实体 ${index}`,
@@ -1553,6 +1578,7 @@ describe('KnowledgeWorkspace', () => {
/> />
) )
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await screen.findByLabelText('实体关系图')
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith( expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ 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: () => (
<div aria-label="已加载的图谱测试组件" />
)
} as unknown as typeof import('./KnowledgeGraphChart')
})
const props = createProps()
render(
<KnowledgeGraphChartLoader
fitViewRequest={0}
loadModule={loadModule}
nodes={props.graphNodes}
onMoveNode={props.onMoveNode}
onSelectNode={vi.fn()}
onZoomChange={vi.fn()}
relations={props.graphRelations}
zoom={1}
/>
)
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 () => { it('creates relationships, merges entities, and opens graph evidence', async () => {
const onCreateRelation = vi.fn() const onCreateRelation = vi.fn()
const onMergeEntities = vi.fn() const onMergeEntities = vi.fn()
@@ -1644,7 +1714,118 @@ describe('KnowledgeWorkspace', () => {
target: { value: 'entity-2' } target: { value: 'entity-2' }
}) })
fireEvent.click(screen.getByRole('button', { name: '合并到目标实体' })) 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(
<KnowledgeWorkspace
{...createProps({ onDeleteEntity, onDeleteRelation })}
/>
)
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(
<KnowledgeWorkspace
{...createProps({ onDeleteEntity })}
/>
)
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', () => { it('renders an explicit empty graph state', () => {
@@ -1660,6 +1841,25 @@ describe('KnowledgeWorkspace', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('keeps one primary creation action in the empty library state', () => {
render(
<KnowledgeWorkspace
{...createProps({
libraries: [],
selectedLibraryId: undefined
})}
/>
)
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', () => { it('keeps loading distinct from the first-library empty state', () => {
render( render(
<KnowledgeWorkspace <KnowledgeWorkspace
@@ -1718,6 +1918,37 @@ describe('KnowledgeWorkspace', () => {
).toHaveAttribute('aria-current', 'page') ).toHaveAttribute('aria-current', 'page')
}) })
it('isolates and traps focus in the library edit dialog', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
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<HTMLElement>(
'.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<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
})
it('confirms that deleting a managed library removes managed copies', async () => { it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn() const onDeleteLibrary = vi.fn()
render( render(
@@ -1734,8 +1965,18 @@ describe('KnowledgeWorkspace', () => {
name: '删除知识库确认' name: '删除知识库确认'
}) })
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus() expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
fireEvent.keyDown(dialog, { key: 'Escape' }) fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus()) await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
fireEvent.click(trigger) fireEvent.click(trigger)
expect( expect(
screen.getByText( screen.getByText(
File diff suppressed because it is too large Load Diff
@@ -151,8 +151,10 @@ const summaryFromDetail = (
const list = vi.fn<() => Promise<MagicNotesSnapshot>>() const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>() const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>() const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
const create = vi.fn<DesktopApi['magicNotes']['create']>()
const remove = vi.fn<DesktopApi['magicNotes']['remove']>() const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>() const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
const updateEntry = vi.fn<DesktopApi['magicNotes']['updateEntry']>()
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>() const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>() const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>() const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
@@ -189,6 +191,7 @@ beforeEach(() => {
list.mockResolvedValue({ notes: [detail] }) list.mockResolvedValue({ notes: [detail] })
get.mockResolvedValue(detail) get.mockResolvedValue(detail)
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] }) listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
create.mockResolvedValue(alternateDetail(thirdNoteId, '新笔记'))
remove.mockResolvedValue() remove.mockResolvedValue()
const createdDetail: MagicNoteDetail = { const createdDetail: MagicNoteDetail = {
...detail, ...detail,
@@ -213,6 +216,19 @@ beforeEach(() => {
] ]
} }
createEntry.mockResolvedValue(createdDetail) createEntry.mockResolvedValue(createdDetail)
updateEntry.mockResolvedValue({
...detail,
revision: detail.revision + 1,
entries: detail.entries.map((entry) => ({
...entry,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
},
plainText: '新的句子',
revision: entry.revision + 1
}))
})
updateTodo.mockImplementation(async (input) => ({ updateTodo.mockImplementation(async (input) => ({
...noteTodo, ...noteTodo,
completed: input.completed, completed: input.completed,
@@ -267,8 +283,10 @@ beforeEach(() => {
list, list,
get, get,
listTodos, listTodos,
create,
remove, remove,
createEntry, createEntry,
updateEntry,
analyze, analyze,
updateTodo, updateTodo,
analyzeTodo, analyzeTodo,
@@ -586,6 +604,298 @@ describe('MagicNotesWorkspace', () => {
) )
}) })
it('keeps a non-empty composer draft until note switching is confirmed', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({
notes: [summaryFromDetail(detail), summaryFromDetail(second)]
})
get.mockImplementation((requestedId) =>
Promise.resolve(requestedId === second.id ? second : detail)
)
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
const callsBeforeSwitch = get.mock.calls.length
fireEvent.click(screen.getByText(second.title).closest('button')!)
const confirmation = screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
const continueEditing = screen.getByRole('button', {
name: '继续编辑'
})
const discardAndSwitch = screen.getByRole('button', {
name: '放弃草稿并切换'
})
expect(confirmation).toHaveAccessibleDescription(
'切换后,当前记录草稿中的文字和附件将被丢弃。'
)
expect(continueEditing).toHaveFocus()
expect(get).toHaveBeenCalledTimes(callsBeforeSwitch)
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
discardAndSwitch.focus()
fireEvent.keyDown(discardAndSwitch, { key: 'Tab' })
expect(continueEditing).toHaveFocus()
fireEvent.keyDown(continueEditing, { key: 'Tab', shiftKey: true })
expect(discardAndSwitch).toHaveFocus()
fireEvent.keyDown(confirmation, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await waitFor(() =>
expect(screen.getByTestId('magic-note-editor')).toHaveFocus()
)
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
await waitFor(() =>
expect(createEntry).toHaveBeenCalledWith({
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
)
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByText(second.title).closest('button')!)
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /第二篇笔记/ })
).toHaveAttribute('aria-pressed', 'true')
})
it('guards dirty existing-entry edits and keeps the first pending target', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
const third = alternateDetail(thirdNoteId, '第三篇笔记')
list.mockResolvedValue({
notes: [
summaryFromDetail(detail),
summaryFromDetail(second),
summaryFromDetail(third)
]
})
get.mockImplementation((requestedId) =>
Promise.resolve(
requestedId === second.id
? second
: requestedId === third.id
? third
: detail
)
)
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
const editEditor = container.querySelector<HTMLButtonElement>(
'.magic-note-entry__editor [data-testid="magic-note-editor"]'
)!
fireEvent.click(editEditor)
const secondButton = screen.getByText(second.title).closest('button')!
const thirdButton = screen.getByText(third.title).closest('button')!
fireEvent.click(secondButton)
const dialog = screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(
container.querySelector<HTMLElement>('.magic-notes-list-pane')?.inert
).toBe(true)
fireEvent.click(thirdButton)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(
container.querySelector<HTMLElement>('.magic-notes-list-pane')?.inert
).toBe(false)
await waitFor(() => expect(editEditor).toHaveFocus())
fireEvent.click(secondButton)
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
await waitFor(() => expect(secondButton).toHaveFocus())
expect(get).not.toHaveBeenCalledWith(third.id)
})
it('guards keyboard tab selection and switches only after discard', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
const notesTab = screen.getByRole('tab', { name: '笔记' })
const todosTab = screen.getByRole('tab', { name: '待办' })
notesTab.focus()
fireEvent.keyDown(notesTab, { key: 'ArrowRight' })
expect(todosTab).toHaveAttribute('aria-selected', 'false')
expect(notesTab).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(todosTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('准备演示')).toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('does not create and select another note before the draft is discarded', async () => {
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
fireEvent.change(
container.querySelector<HTMLInputElement>(
'.magic-notes-create input'
)!,
{
target: { value: '新笔记' }
}
)
fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
expect(create).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
await waitFor(() =>
expect(create).toHaveBeenCalledWith({ title: '新笔记' })
)
expect(await screen.findByDisplayValue('新笔记')).toBeInTheDocument()
})
it('preserves the composer draft across save errors and unrelated rerenders', async () => {
createEntry.mockRejectedValueOnce(new Error('暂时无法保存'))
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
await i18n.changeLanguage('en-US')
fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '暂时无法保存'
})
)
)
fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2))
expect(createEntry).toHaveBeenNthCalledWith(1, {
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
expect(createEntry).toHaveBeenNthCalledWith(2, {
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
await i18n.changeLanguage('zh-CN')
})
it('finalizes a created entry before a to-do refresh failure', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '记录已保存'
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '待办刷新失败'
})
)
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
expect(createEntry).toHaveBeenCalledOnce()
expect(screen.getByText('请先输入记录内容')).toBeInTheDocument()
})
it('finalizes an edited entry before a to-do refresh failure', async () => {
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
fireEvent.click(
container.querySelector<HTMLButtonElement>(
'.magic-note-entry__editor [data-testid="magic-note-editor"]'
)!
)
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
expect(updateEntry).toHaveBeenCalledWith({
entryId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
},
expectedRevision: detail.entries[0]!.revision
})
expect(
screen.queryByRole('button', { name: '保存修改' })
).not.toBeInTheDocument()
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '记录已更新,原 AI 评论已清除'
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '待办刷新失败'
})
)
})
it('does not override a note selected while retrying a refresh', async () => { it('does not override a note selected while retrying a refresh', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记') const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({ list.mockResolvedValue({
+350 -59
View File
@@ -19,6 +19,7 @@ import {
import { import {
useCallback, useCallback,
useEffect, useEffect,
useId,
useMemo, useMemo,
useRef, useRef,
useState useState
@@ -40,6 +41,7 @@ import type { MagicNoteCommentMode } from '../../shared/application-settings-con
import { MagicNoteContent } from './MagicNoteContent' import { MagicNoteContent } from './MagicNoteContent'
import { MagicNoteEditor } from './MagicNoteEditor' import { MagicNoteEditor } from './MagicNoteEditor'
import { MarkdownRenderer } from './MarkdownRenderer' import { MarkdownRenderer } from './MarkdownRenderer'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { import {
EmptyState, EmptyState,
@@ -61,6 +63,12 @@ type ValidationTarget =
| 'note-title' | 'note-title'
| 'new-entry' | 'new-entry'
| 'edit-entry' | 'edit-entry'
type DraftSwitchTarget =
| { kind: 'library-view'; value: LibraryView }
| { kind: 'create-note'; title: string }
| { kind: 'edit-entry'; entry: MagicNoteEntry }
| { kind: 'note'; noteId: string; entryId?: string }
| { kind: 'todo'; todoId: string }
const defaultAiPaneWidth = 280 const defaultAiPaneWidth = 280
const minimumAiPaneWidth = 240 const minimumAiPaneWidth = 240
@@ -117,6 +125,13 @@ function hasContent(content?: MagicNoteRichContent): boolean {
) )
} }
function richContentEqual(
left: MagicNoteRichContent | undefined,
right: MagicNoteRichContent | undefined
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function errorMessage(error: unknown, fallback: string): string { function errorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string') { if (typeof error === 'string') {
return error return error
@@ -174,12 +189,14 @@ function AiComment({
function TodoListItem({ function TodoListItem({
disabled, disabled,
id,
onSelect, onSelect,
onToggle, onToggle,
selected, selected,
todo todo
}: { }: {
disabled: boolean disabled: boolean
id: string
onSelect: () => void onSelect: () => void
onToggle: () => void onToggle: () => void
selected: boolean selected: boolean
@@ -214,6 +231,7 @@ function TodoListItem({
<button <button
aria-pressed={selected} aria-pressed={selected}
className="magic-todo-list-item__content" className="magic-todo-list-item__content"
id={id}
onClick={onSelect} onClick={onSelect}
type="button" type="button"
> >
@@ -351,6 +369,8 @@ export function MagicNotesWorkspace({
target: ValidationTarget target: ValidationTarget
message: string message: string
}>() }>()
const [pendingDraftSwitch, setPendingDraftSwitch] =
useState<DraftSwitchTarget>()
const detailRequestRef = useRef(0) const detailRequestRef = useRef(0)
const requestedNoteIdRef = useRef('') const requestedNoteIdRef = useRef('')
const refreshRequestRef = useRef(0) const refreshRequestRef = useRef(0)
@@ -374,6 +394,12 @@ export function MagicNotesWorkspace({
const magicNotesLayoutRef = useRef<HTMLDivElement>(null) const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth) const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
const aiResizePointerIdRef = useRef<number | undefined>(undefined) const aiResizePointerIdRef = useRef<number | undefined>(undefined)
const composerRef = useRef<HTMLDivElement>(null)
const continueEditingRef = useRef<HTMLButtonElement>(null)
const discardDraftRef = useRef<HTMLButtonElement>(null)
const discardDraftDialogRef = useRef<HTMLDivElement>(null)
const discardDraftTitleId = useId()
const discardDraftDescriptionId = useId()
const runDraftAnalysisRef = useRef< const runDraftAnalysisRef = useRef<
(content: MagicNoteRichContent) => Promise<void> (content: MagicNoteRichContent) => Promise<void>
>(async () => undefined) >(async () => undefined)
@@ -716,6 +742,8 @@ export function MagicNotesWorkspace({
window.clearTimeout(draftAnalysisTimerRef.current) window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined draftAnalysisTimerRef.current = undefined
} }
setEditingEntry(undefined)
editingContentRef.current = undefined
setSelectedNoteId(noteId) setSelectedNoteId(noteId)
applyDetail(nextDetail) applyDetail(nextDetail)
} }
@@ -734,6 +762,226 @@ export function MagicNotesWorkspace({
[applyDetail] [applyDetail]
) )
const discardComposerDraft = useCallback((): void => {
composerContentRef.current = undefined
draftAnalysisArmedRef.current = false
draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1
setDraftAnalyses([])
if (draftAnalysisTimerRef.current !== undefined) {
window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined
}
setComposerKey((current) => current + 1)
}, [])
const discardEditingDraft = useCallback((): void => {
setEditingEntry(undefined)
editingContentRef.current = undefined
clearValidation('edit-entry')
}, [clearValidation])
const hasDirtyEditingDraft = useCallback(
(): boolean =>
Boolean(
editingEntry &&
!richContentEqual(
editingContentRef.current,
editingEntry.content
)
),
[editingEntry]
)
const createNote = useCallback(
async (title: string, discardDraft: boolean): Promise<void> => {
const operation = 'create-note'
if (!beginBusy(operation)) {
return
}
try {
const created = await window.goodbuddy.magicNotes.create({
title
})
if (discardDraft) {
discardComposerDraft()
discardEditingDraft()
}
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
} catch (createError) {
notifyError(createError)
} finally {
endBusy(operation)
}
},
[
applyDetail,
beginBusy,
discardComposerDraft,
discardEditingDraft,
endBusy,
notifyError,
notifySuccess,
t
]
)
const focusSwitchTarget = useCallback(
(target: DraftSwitchTarget): void => {
requestAnimationFrame(() => {
const focusTarget =
target.kind === 'library-view'
? document.getElementById(`magic-library-tab-${target.value}`)
: target.kind === 'note'
? document.getElementById(
`magic-note-select-${target.noteId}`
)
: target.kind === 'todo'
? document.getElementById(
`magic-todo-select-${target.todoId}`
)
: target.kind === 'edit-entry'
? document
.getElementById(
`magic-note-entry-${target.entry.id}`
)
?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
: composerRef.current?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
focusTarget?.focus()
})
},
[]
)
const performDraftSwitch = useCallback(
(target: DraftSwitchTarget): void => {
setPendingDraftSwitch(undefined)
setValidation(undefined)
if (target.kind === 'library-view') {
if (target.value === 'todos') {
discardComposerDraft()
discardEditingDraft()
}
setLibraryView(target.value)
setCreating(false)
setSearch('')
focusSwitchTarget(target)
return
}
if (target.kind === 'todo') {
setSelectedTodoId(target.todoId)
focusSwitchTarget(target)
return
}
if (target.kind === 'edit-entry') {
discardEditingDraft()
setDeletingEntryId('')
setEditingEntry(target.entry)
editingContentRef.current = target.entry.content
focusSwitchTarget(target)
return
}
if (target.kind === 'create-note') {
void createNote(target.title, true)
.then(() => focusSwitchTarget(target))
return
}
setDeletingNote(false)
setLibraryView('notes')
void loadDetail(target.noteId).then(() => {
focusSwitchTarget(target)
if (!target.entryId) {
return
}
requestAnimationFrame(() =>
document
.getElementById(`magic-note-entry-${target.entryId}`)
?.scrollIntoView({ block: 'center' })
)
})
},
[
createNote,
discardComposerDraft,
discardEditingDraft,
focusSwitchTarget,
loadDetail
]
)
const requestDraftSwitch = useCallback(
(target: DraftSwitchTarget): void => {
if (pendingDraftSwitch) {
return
}
const changesContext =
target.kind === 'library-view'
? target.value !== libraryView
: target.kind === 'note'
? target.noteId !== selectedNoteId ||
target.entryId !== undefined
: target.kind === 'todo'
? target.todoId !== selectedTodoId
: target.kind === 'edit-entry'
? target.entry.id !== editingEntry?.id
: true
if (!changesContext) {
return
}
const wouldClearComposer = target.kind !== 'edit-entry'
const wouldClearEditing = target.kind !== 'todo'
if (
(wouldClearComposer && hasContent(composerContentRef.current)) ||
(wouldClearEditing && hasDirtyEditingDraft())
) {
setPendingDraftSwitch(target)
return
}
performDraftSwitch(target)
},
[
editingEntry?.id,
hasDirtyEditingDraft,
libraryView,
pendingDraftSwitch,
performDraftSwitch,
selectedNoteId,
selectedTodoId
]
)
const continueEditing = useCallback((): void => {
setPendingDraftSwitch(undefined)
requestAnimationFrame(() => {
const editor = editingEntry
? document
.getElementById(`magic-note-entry-${editingEntry.id}`)
?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
: composerRef.current?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
editor?.focus()
})
}, [editingEntry])
useEffect(() => {
if (!pendingDraftSwitch) {
return
}
return activateModalFocus(() => continueEditingRef.current)
}, [pendingDraftSwitch])
const refreshNotes = useCallback( const refreshNotes = useCallback(
async (preferredId?: string): Promise<void> => { async (preferredId?: string): Promise<void> => {
const requestId = ++refreshRequestRef.current const requestId = ++refreshRequestRef.current
@@ -884,7 +1132,7 @@ export function MagicNotesWorkspace({
const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth) const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth)
const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800 const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800
const createNote = async (): Promise<void> => { const submitCreateNote = async (): Promise<void> => {
const title = newTitle.trim() const title = newTitle.trim()
if (!title) { if (!title) {
setValidation({ setValidation({
@@ -894,25 +1142,10 @@ export function MagicNotesWorkspace({
return return
} }
clearValidation('create-note') clearValidation('create-note')
const operation = 'create-note' requestDraftSwitch({
if (!beginBusy(operation)) { kind: 'create-note',
return title
} })
try {
const created = await window.goodbuddy.magicNotes.create({
title
})
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
} catch (createError) {
notifyError(createError)
} finally {
endBusy(operation)
}
} }
const analyzeTodo = async (todoId: string): Promise<void> => { const analyzeTodo = async (todoId: string): Promise<void> => {
@@ -1028,8 +1261,8 @@ export function MagicNotesWorkspace({
content: composerContent content: composerContent
}) })
applyDetail(updated) applyDetail(updated)
await reloadTodos()
composerContentRef.current = undefined composerContentRef.current = undefined
setPendingDraftSwitch(undefined)
draftAnalysisArmedRef.current = false draftAnalysisArmedRef.current = false
draftAnalysisQueuedRef.current = false draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1 draftAnalysisContextRef.current += 1
@@ -1040,6 +1273,11 @@ export function MagicNotesWorkspace({
} }
setComposerKey((current) => current + 1) setComposerKey((current) => current + 1)
notifySuccess(t('notifications.entrySaved')) notifySuccess(t('notifications.entrySaved'))
try {
await reloadTodos()
} catch (refreshTodosError) {
notifyError(refreshTodosError)
}
const createdEntry = updated.entries.find( const createdEntry = updated.entries.find(
(entry) => !existingEntryIds.has(entry.id) (entry) => !existingEntryIds.has(entry.id)
) )
@@ -1097,10 +1335,14 @@ export function MagicNotesWorkspace({
expectedRevision: editingEntry.revision expectedRevision: editingEntry.revision
}) })
applyDetail(updated) applyDetail(updated)
await reloadTodos()
setEditingEntry(undefined) setEditingEntry(undefined)
editingContentRef.current = undefined editingContentRef.current = undefined
notifySuccess(t('notifications.entryUpdated')) notifySuccess(t('notifications.entryUpdated'))
try {
await reloadTodos()
} catch (refreshTodosError) {
notifyError(refreshTodosError)
}
if (commentMode === 'after-save-auto') { if (commentMode === 'after-save-auto') {
const options = await createAnalysisOptions() const options = await createAnalysisOptions()
setLiveAnalysis({ setLiveAnalysis({
@@ -1268,12 +1510,12 @@ export function MagicNotesWorkspace({
<PageTabs <PageTabs
ariaLabel={t('page.contentLabel')} ariaLabel={t('page.contentLabel')}
idPrefix="magic-library" idPrefix="magic-library"
onChange={(value) => { onChange={(value) =>
setLibraryView(value) requestDraftSwitch({
setCreating(false) kind: 'library-view',
setValidation(undefined) value
setSearch('') })
}} }
tabs={libraryTabs} tabs={libraryTabs}
value={libraryView} value={libraryView}
variant="segmented" variant="segmented"
@@ -1303,7 +1545,7 @@ export function MagicNotesWorkspace({
className="magic-notes-create" className="magic-notes-create"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
void createNote() void submitCreateNote()
}} }}
> >
<label> <label>
@@ -1380,6 +1622,7 @@ export function MagicNotesWorkspace({
visibleNotes.map((note) => ( visibleNotes.map((note) => (
<button <button
key={note.id} key={note.id}
id={`magic-note-select-${note.id}`}
aria-pressed={selectedNoteId === note.id} aria-pressed={selectedNoteId === note.id}
className={`magic-note-list-item ${ className={`magic-note-list-item ${
selectedNoteId === note.id selectedNoteId === note.id
@@ -1387,13 +1630,12 @@ export function MagicNotesWorkspace({
: '' : ''
}`} }`}
type="button" type="button"
onClick={() => { onClick={() =>
setValidation(undefined) requestDraftSwitch({
setDeletingNote(false) kind: 'note',
setEditingEntry(undefined) noteId: note.id
editingContentRef.current = undefined })
void loadDetail(note.id) }
}}
> >
<span className="magic-note-list-item__title"> <span className="magic-note-list-item__title">
{note.pinned && ( {note.pinned && (
@@ -1487,11 +1729,14 @@ export function MagicNotesWorkspace({
{directory.todos.map((todo) => ( {directory.todos.map((todo) => (
<TodoListItem <TodoListItem
disabled={busy === `update-todo-${todo.id}`} disabled={busy === `update-todo-${todo.id}`}
id={`magic-todo-select-${todo.id}`}
key={todo.id} key={todo.id}
onSelect={() => { onSelect={() =>
setValidation(undefined) requestDraftSwitch({
setSelectedTodoId(todo.id) kind: 'todo',
}} todoId: todo.id
})
}
onToggle={() => onToggle={() =>
void updateTodoCompletion(todo) void updateTodoCompletion(todo)
} }
@@ -1538,7 +1783,12 @@ export function MagicNotesWorkspace({
</span> </span>
<button <button
className="secondary-button" className="secondary-button"
onClick={() => void loadDetail(detailLoadError.noteId)} onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: detailLoadError.noteId
})
}
type="button" type="button"
> >
{t('actions.retry')} {t('actions.retry')}
@@ -1670,7 +1920,7 @@ export function MagicNotesWorkspace({
</div> </div>
)} )}
<div className="magic-note-composer"> <div className="magic-note-composer" ref={composerRef}>
<MagicNoteEditor <MagicNoteEditor
key={`${detail.id}-${composerKey}`} key={`${detail.id}-${composerKey}`}
ariaDescribedBy={ ariaDescribedBy={
@@ -1703,6 +1953,52 @@ export function MagicNotesWorkspace({
} }
}} }}
/> />
{pendingDraftSwitch && (
<div
aria-describedby={discardDraftDescriptionId}
aria-labelledby={discardDraftTitleId}
aria-modal="true"
className="magic-note-draft-confirmation"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
continueEditing()
return
}
trapTabFocus(event, discardDraftDialogRef.current)
}}
ref={discardDraftDialogRef}
role="alertdialog"
tabIndex={-1}
>
<strong id={discardDraftTitleId}>
{t('confirmations.discardDraftTitle')}
</strong>
<span id={discardDraftDescriptionId}>
{t('confirmations.discardDraftDescription')}
</span>
<div>
<button
className="secondary-button"
onClick={continueEditing}
ref={continueEditingRef}
type="button"
>
{t('actions.continueEditing')}
</button>
<button
className="danger-button"
onClick={() =>
performDraftSwitch(pendingDraftSwitch)
}
ref={discardDraftRef}
type="button"
>
{t('actions.discardAndSwitch')}
</button>
</div>
</div>
)}
{validation?.target === 'new-entry' && ( {validation?.target === 'new-entry' && (
<p <p
className="magic-notes-field-error" className="magic-notes-field-error"
@@ -1764,12 +2060,12 @@ export function MagicNotesWorkspace({
<button <button
className="secondary-button" className="secondary-button"
type="button" type="button"
onClick={() => { onClick={() =>
clearValidation('edit-entry') requestDraftSwitch({
setDeletingEntryId('') kind: 'edit-entry',
setEditingEntry(entry) entry
editingContentRef.current = entry.content })
}} }
> >
{t('actions.edit')} {t('actions.edit')}
</button> </button>
@@ -1975,18 +2271,13 @@ export function MagicNotesWorkspace({
</p> </p>
<button <button
className="secondary-button" className="secondary-button"
onClick={() => { onClick={() =>
setLibraryView('notes') requestDraftSwitch({
void loadDetail(selectedTodo.noteId).then(() => { kind: 'note',
requestAnimationFrame(() => noteId: selectedTodo.noteId,
document entryId: selectedTodo.entryId
.getElementById(
`magic-note-entry-${selectedTodo.entryId}`
)
?.scrollIntoView({ block: 'center' })
)
}) })
}} }
type="button" type="button"
> >
<BookOpen size={14} /> <BookOpen size={14} />
@@ -241,6 +241,8 @@ flowchart LR
) )
expect(mermaidMock.initialize).toHaveBeenCalledWith( expect(mermaidMock.initialize).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
fontFamily:
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
htmlLabels: false, htmlLabels: false,
maxEdges: 300, maxEdges: 300,
maxTextSize: 20_000, maxTextSize: 20_000,
+1 -1
View File
@@ -192,7 +192,7 @@ function mermaidConfig(darkTheme: boolean): MermaidConfig {
deterministicIds: true, deterministicIds: true,
dompurifyConfig: sharedSanitizerConfig, dompurifyConfig: sharedSanitizerConfig,
fontFamily: fontFamily:
'"Inter Variable", "Noto Sans SC Variable", "Segoe UI Variable", sans-serif', '"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
htmlLabels: false, htmlLabels: false,
logLevel: 'fatal', logLevel: 'fatal',
maxEdges: MAX_MERMAID_EDGES, maxEdges: MAX_MERMAID_EDGES,
@@ -1,3 +1,4 @@
import { RotateCcw, Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { import type {
@@ -6,6 +7,12 @@ import type {
ModelDownloadSource ModelDownloadSource
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts' import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import {
canonicalizeShortcutAccelerator,
type GlobalShortcutSettings,
type GlobalShortcutSettingsSnapshot,
type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { import {
PageTabs, PageTabs,
@@ -19,18 +26,47 @@ import {
type PlatformFeaturesSettingsSectionProps = { type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void onMagicNotesEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
} }
type PlatformFeaturesTab = 'general' | 'magic-notes' type PlatformFeaturesTab = 'general' | 'magic-notes'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
| 'platformFeatures.shortcut.errors.conflict'
| 'platformFeatures.shortcut.errors.registrationFailed'
| 'platformFeatures.shortcut.errors.saveFailed'
> = {
conflict: 'platformFeatures.shortcut.errors.conflict',
'registration-failed':
'platformFeatures.shortcut.errors.registrationFailed',
'save-failed': 'platformFeatures.shortcut.errors.saveFailed'
}
export function PlatformFeaturesSettingsSection({ export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange, onMagicNotesEnabledChange,
onNotify onNotify,
onDirtyChange,
onShortcutSettingsChanged
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element { }: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections') const { t } = useTranslation('settingsSections')
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general') useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>() const [settings, setSettings] = useState<ApplicationSettings>()
const [shortcutSnapshot, setShortcutSnapshot] =
useState<GlobalShortcutSettingsSnapshot>()
const [shortcutDraft, setShortcutDraft] =
useState<GlobalShortcutSettings>()
const [shortcutSaving, setShortcutSaving] = useState(false)
const [shortcutError, setShortcutError] = useState<string | undefined>(
() =>
window.goodbuddy.shortcuts
? undefined
: t('platformFeatures.shortcut.errors.serviceUnavailable')
)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>() const [sourceError, setSourceError] = useState<string>()
const [error, setError] = useState<string | undefined>(() => const [error, setError] = useState<string | undefined>(() =>
@@ -64,6 +100,139 @@ export function PlatformFeaturesSettingsSection({
} }
}, [t]) }, [t])
useEffect(() => {
const shortcuts = window.goodbuddy.shortcuts
let active = true
if (!shortcuts) {
return () => {
active = false
}
}
void shortcuts
.getSettings()
.then((snapshot) => {
if (active) {
setShortcutSnapshot(snapshot)
setShortcutDraft(snapshot.settings)
}
})
.catch(() => {
if (active) {
setShortcutError(
t('platformFeatures.shortcut.errors.readFailed')
)
}
})
return () => {
active = false
}
}, [t])
const shortcutDirty =
shortcutSnapshot !== undefined &&
shortcutDraft !== undefined &&
(shortcutSnapshot.settings.enabled !== shortcutDraft.enabled ||
shortcutSnapshot.settings.accelerator !==
shortcutDraft.accelerator)
useEffect(() => {
onDirtyChange?.(shortcutDirty)
}, [onDirtyChange, shortcutDirty])
const saveShortcut = async (): Promise<void> => {
const shortcuts = window.goodbuddy.shortcuts
if (!shortcuts || !shortcutDraft) {
return
}
let input: GlobalShortcutSettings
try {
input = {
...shortcutDraft,
accelerator: canonicalizeShortcutAccelerator(
shortcutDraft.accelerator
)
}
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.invalidAccelerator')
)
return
}
setShortcutSaving(true)
setShortcutError(undefined)
try {
setShortcutDraft(input)
const result = await shortcuts.updateSettings(input)
setShortcutSnapshot(result.snapshot)
if (!result.ok) {
setShortcutError(t(shortcutErrorTranslationKeys[result.error]))
return
}
setShortcutDraft(result.snapshot.settings)
onShortcutSettingsChanged?.(result.snapshot)
onNotify?.({
tone: 'success',
message: t('platformFeatures.shortcut.saved'),
dedupeKey: 'global-shortcut-saved'
})
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.saveFailed')
)
} finally {
setShortcutSaving(false)
}
}
const recordShortcut = (
event: React.KeyboardEvent<HTMLInputElement>
): void => {
if (
['Control', 'Shift', 'Alt', 'Meta'].includes(event.key) ||
event.key === 'Escape' ||
event.key === 'Tab'
) {
return
}
if (
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
return
}
event.preventDefault()
const isMac = shortcutSnapshot?.platform === 'darwin'
const parts = [
event.ctrlKey
? isMac
? 'Control'
: 'CommandOrControl'
: undefined,
event.metaKey
? isMac
? 'Command'
: 'Super'
: undefined,
event.altKey ? 'Alt' : undefined,
event.shiftKey ? 'Shift' : undefined,
event.key === ' ' ? 'Space' : event.key
].filter((part): part is string => Boolean(part))
try {
const accelerator = canonicalizeShortcutAccelerator(
parts.join('+')
)
setShortcutDraft((current) =>
current ? { ...current, accelerator } : current
)
setShortcutError(undefined)
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.invalidAccelerator')
)
}
}
const changeModelDownloadSource = async ( const changeModelDownloadSource = async (
modelDownloadSource: ModelDownloadSource modelDownloadSource: ModelDownloadSource
): Promise<void> => { ): Promise<void> => {
@@ -198,6 +367,112 @@ export function PlatformFeaturesSettingsSection({
id="platform-features-panel-general" id="platform-features-panel-general"
role="tabpanel" role="tabpanel"
> >
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>{t('platformFeatures.shortcut.title')}</strong>
<small>
{t('platformFeatures.shortcut.description')}
</small>
</div>
</div>
{shortcutDraft && shortcutSnapshot ? (
<>
<label className="toggle-row">
<input
checked={shortcutDraft.enabled}
disabled={shortcutSaving}
onChange={(event) =>
setShortcutDraft({
...shortcutDraft,
enabled: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('platformFeatures.shortcut.enabled')}</span>
</label>
<label className="field">
<span>{t('platformFeatures.shortcut.accelerator')}</span>
<input
aria-label={t(
'platformFeatures.shortcut.accelerator'
)}
aria-describedby="global-shortcut-recorder-help"
disabled={!shortcutDraft.enabled || shortcutSaving}
onChange={(event) =>
setShortcutDraft({
...shortcutDraft,
accelerator: event.target.value
})
}
onKeyDown={recordShortcut}
value={shortcutDraft.accelerator}
/>
<small id="global-shortcut-recorder-help">
{t('platformFeatures.shortcut.recorderHelp')}
</small>
</label>
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={shortcutSaving}
onClick={() => {
setShortcutDraft({
...shortcutSnapshot.defaultSettings
})
setShortcutError(undefined)
}}
type="button"
>
<RotateCcw aria-hidden="true" size={13} />
{t('platformFeatures.shortcut.reset')}
</button>
<button
className="primary-button"
disabled={!shortcutDirty || shortcutSaving}
onClick={() => void saveShortcut()}
type="button"
>
<Save aria-hidden="true" size={13} />
{shortcutSaving
? t('platformFeatures.shortcut.saving')
: t('platformFeatures.shortcut.save')}
</button>
</div>
<p
className={
shortcutError
? 'settings-warning'
: 'settings-notice'
}
role={shortcutError ? 'alert' : 'status'}
>
{shortcutError ??
t(
`platformFeatures.shortcut.status.${shortcutSnapshot.status}`,
{
shortcut:
shortcutSnapshot.displayAccelerator
}
)}
</p>
</>
) : (
<p
className={
shortcutError
? 'settings-warning'
: 'settings-notice'
}
role={shortcutError ? 'alert' : 'status'}
>
{shortcutError ??
t('platformFeatures.shortcut.loading')}
</p>
)}
</article>
{settings ? ( {settings ? (
<article className="capability-card"> <article className="capability-card">
<div className="capability-card__header"> <div className="capability-card__header">
+14 -3
View File
@@ -32,6 +32,7 @@ import {
channelProjectDraft, channelProjectDraft,
ChannelProjectSettingsFields ChannelProjectSettingsFields
} from './ChannelProjectSettingsFields' } from './ChannelProjectSettingsFields'
import { getProjectDisplayText } from './project-display'
type ProjectSwitcherProps = { type ProjectSwitcherProps = {
projects: AssistantProject[] projects: AssistantProject[]
@@ -88,6 +89,9 @@ export function ProjectSwitcher({
const activeProject = projects.find( const activeProject = projects.find(
(project) => project.id === activeProjectId (project) => project.id === activeProjectId
) )
const activeProjectDisplay = activeProject
? getProjectDisplayText(activeProject, t)
: undefined
const userProjects = projects.filter( const userProjects = projects.filter(
(project) => project.kind === 'user' (project) => project.kind === 'user'
) )
@@ -270,10 +274,13 @@ export function ProjectSwitcher({
} }
}} }}
ref={projectPickerButtonRef} ref={projectPickerButtonRef}
title={activeProject?.name} title={activeProjectDisplay?.name}
type="button" type="button"
> >
<span>{activeProject?.name ?? t('projectSwitcher.selector.empty')}</span> <span>
{activeProjectDisplay?.name ??
t('projectSwitcher.selector.empty')}
</span>
<ChevronDown aria-hidden="true" size={14} /> <ChevronDown aria-hidden="true" size={14} />
</button> </button>
{projectMenuOpen && ( {projectMenuOpen && (
@@ -340,6 +347,10 @@ export function ProjectSwitcher({
{group.projects.map((project) => { {group.projects.map((project) => {
const selected = project.id === activeProjectId const selected = project.id === activeProjectId
const ProjectIcon = group.icon const ProjectIcon = group.icon
const projectDisplay = getProjectDisplayText(
project,
t
)
const detail = const detail =
project.kind === 'channel' project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', { ? t('projectSwitcher.selector.remoteDetail', {
@@ -372,7 +383,7 @@ export function ProjectSwitcher({
> >
<ProjectIcon aria-hidden="true" size={16} /> <ProjectIcon aria-hidden="true" size={16} />
<span> <span>
<b>{project.name}</b> <b>{projectDisplay.name}</b>
<small>{detail}</small> <small>{detail}</small>
</span> </span>
{selected && ( {selected && (
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
RightAssistantSidebar, RightAssistantSidebar,
@@ -18,11 +24,17 @@ beforeEach(() => {
function renderSidebar({ function renderSidebar({
tab = 'context', tab = 'context',
artifacts = [], artifacts = [],
onLoadArtifact = vi.fn(async () => undefined) onLoadArtifact = vi.fn(async () => undefined),
onClose = vi.fn(),
overlay = false,
restoreFocusRef
}: { }: {
tab?: AssistantSidebarTab tab?: AssistantSidebarTab
artifacts?: SidebarArtifact[] artifacts?: SidebarArtifact[]
onLoadArtifact?: (artifactId: string) => Promise<void> onLoadArtifact?: (artifactId: string) => Promise<void>
onClose?: () => void
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement { } = {}): HTMLElement {
render( render(
<RightAssistantSidebar <RightAssistantSidebar
@@ -35,7 +47,7 @@ function renderSidebar({
tasks={[]} tasks={[]}
conversationTitles={new Map()} conversationTitles={new Map()}
projectNames={new Map()} projectNames={new Map()}
onClose={vi.fn()} onClose={onClose}
onCreateCustomTask={vi.fn()} onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)} onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({ onListWorkspaceDirectory={vi.fn(async (path: string) => ({
@@ -57,11 +69,13 @@ function renderSidebar({
onStopBrowser={vi.fn(async () => undefined)} onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()} onTabChange={vi.fn()}
open open
overlay={overlay}
restoreFocusRef={restoreFocusRef}
tab={tab} tab={tab}
/> />
) )
return screen.getByRole('complementary', { return screen.getByRole(overlay ? 'dialog' : 'complementary', {
name: '助手工作栏' name: '助手工作栏'
}) })
} }
@@ -166,6 +180,27 @@ describe('RightAssistantSidebar resizing', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('treats overlay mode as a focus-trapped dismissible dialog', async () => {
const onClose = vi.fn()
const trigger = document.createElement('button')
document.body.append(trigger)
const sidebar = renderSidebar({
onClose,
overlay: true,
restoreFocusRef: { current: trigger }
})
expect(sidebar).toHaveAttribute('aria-modal', 'true')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '上下文' })
).toHaveFocus()
)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
trigger.remove()
})
it('keeps the product Task index in the task center', () => { it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' }) renderSidebar({ tab: 'tasks' })
@@ -33,6 +33,7 @@ import type {
KnowledgeLibrary KnowledgeLibrary
} from '../../shared/contracts' } from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel' import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives' import { SegmentedControl } from './WorkspacePrimitives'
import { import {
findTaskSchedule, findTaskSchedule,
@@ -79,6 +80,8 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string workspaceProjectId?: string
browserState?: BrowserLiveState browserState?: BrowserLiveState
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
onClose: () => void onClose: () => void
onInteractBrowser: () => Promise<void> onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void> onStopBrowser: () => Promise<void>
@@ -156,6 +159,8 @@ export function RightAssistantSidebar({
workspaceChanges, workspaceChanges,
workspaceProjectId, workspaceProjectId,
browserState, browserState,
overlay = false,
restoreFocusRef,
onClose, onClose,
onInteractBrowser, onInteractBrowser,
onStopBrowser, onStopBrowser,
@@ -197,6 +202,8 @@ export function RightAssistantSidebar({
const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth) const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false)
const wasOverlayOpen = useRef(false)
const liveSidebarWidth = useRef(defaultSidebarWidth) const liveSidebarWidth = useRef(defaultSidebarWidth)
const resizePointerId = useRef<number | undefined>(undefined) const resizePointerId = useRef<number | undefined>(undefined)
const [selectedArtifactId, setSelectedArtifactId] = useState<string>() const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
@@ -287,6 +294,24 @@ export function RightAssistantSidebar({
return () => window.removeEventListener('resize', handleViewportResize) return () => window.removeEventListener('resize', handleViewportResize)
}, []) }, [])
useEffect(() => {
if (open && overlay && !wasOverlayOpen.current) {
const focusFrame = requestAnimationFrame(() => {
document
.getElementById(`assistant-sidebar-tab-${tab}`)
?.focus()
})
wasOpen.current = open
wasOverlayOpen.current = true
return () => cancelAnimationFrame(focusFrame)
}
if (!open && wasOpen.current) {
requestAnimationFrame(() => restoreFocusRef?.current?.focus())
}
wasOpen.current = open
wasOverlayOpen.current = open && overlay
}, [open, overlay, restoreFocusRef, tab])
const resizeFromClientX = ( const resizeFromClientX = (
clientX: number, clientX: number,
commit: boolean commit: boolean
@@ -430,12 +455,25 @@ export function RightAssistantSidebar({
ref={sidebarRef} ref={sidebarRef}
aria-label={t('sidebar.ariaLabel')} aria-label={t('sidebar.ariaLabel')}
aria-hidden={!open} aria-hidden={!open}
aria-modal={open && overlay ? 'true' : undefined}
className={ className={
open open
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}` ? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
: 'assistant-sidebar' : 'assistant-sidebar'
} }
inert={!open} inert={!open}
onKeyDown={(event) => {
if (!open || !overlay) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, sidebarRef.current)
}}
role={open && overlay ? 'dialog' : undefined}
style={ style={
{ {
'--assistant-sidebar-width': `${sidebarWidth}px` '--assistant-sidebar-width': `${sidebarWidth}px`
@@ -33,6 +33,7 @@ type RuntimeCustomizationSectionProps = {
export type RuntimeCustomizationSectionHandle = { export type RuntimeCustomizationSectionHandle = {
save: () => Promise<boolean> save: () => Promise<boolean>
discard: () => void
} }
type RuntimeCustomizationError = { type RuntimeCustomizationError = {
@@ -534,9 +535,7 @@ export const RuntimeCustomizationSection = forwardRef<
} }
}, [settings, settingsDirty, t]) }, [settings, settingsDirty, t])
useImperativeHandle(ref, () => ({ save }), [save]) const discardChanges = useCallback((): void => {
const discardChanges = (): void => {
if (!persistedSettings) { if (!persistedSettings) {
return return
} }
@@ -547,7 +546,13 @@ export const RuntimeCustomizationSection = forwardRef<
'' ''
) )
setError(undefined) setError(undefined)
} }, [persistedSettings])
useImperativeHandle(
ref,
() => ({ save, discard: discardChanges }),
[discardChanges, save]
)
const addPreset = (): void => { const addPreset = (): void => {
if ( if (
+286 -1
View File
@@ -20,6 +20,9 @@ import type {
CapabilitySnapshot CapabilitySnapshot
} from '../../shared/capability-contracts' } from '../../shared/capability-contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type {
GlobalShortcutSettingsSnapshot
} from '../../shared/shortcut'
import type { import type {
EmbeddingDiagnosticResult, EmbeddingDiagnosticResult,
EmbeddingSettingsSnapshot EmbeddingSettingsSnapshot
@@ -27,7 +30,10 @@ import type {
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts' import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers' import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools' import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel' import {
SettingsPanel,
type SettingsLeaveRequester
} from './SettingsPanel'
import { import {
RuntimeCustomizationSection, RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle type RuntimeCustomizationSectionHandle
@@ -413,6 +419,37 @@ const updateApplicationSettings = vi.fn<
} }
return { ...applicationSettings } return { ...applicationSettings }
}) })
let shortcutSettingsSnapshot: 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'
}
const getShortcutSettings = vi.fn(async () => shortcutSettingsSnapshot)
const updateShortcutSettings = vi.fn<
NonNullable<DesktopApi['shortcuts']>['updateSettings']
>(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
const speechCatalog: SpeechModelSnapshot['catalog'] = [ const speechCatalog: SpeechModelSnapshot['catalog'] = [
{ {
id: 'sensevoice-small-int8', id: 'sensevoice-small-int8',
@@ -592,6 +629,37 @@ describe('SettingsPanel runtime files', () => {
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
} }
speechModelSnapshot = createSpeechModelSnapshot() speechModelSnapshot = createSpeechModelSnapshot()
shortcutSettingsSnapshot = {
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'
}
getShortcutSettings.mockImplementation(
async () => shortcutSettingsSnapshot
)
updateShortcutSettings.mockImplementation(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
Object.defineProperty(window, 'goodbuddy', { Object.defineProperty(window, 'goodbuddy', {
configurable: true, configurable: true,
value: { value: {
@@ -668,6 +736,10 @@ describe('SettingsPanel runtime files', () => {
check: vi.fn(), check: vi.fn(),
openReleasePage: vi.fn(), openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {}) onResult: vi.fn(() => () => {})
},
shortcuts: {
getSettings: getShortcutSettings,
updateSettings: updateShortcutSettings
} }
} as unknown as DesktopApi } as unknown as DesktopApi
}) })
@@ -1167,6 +1239,12 @@ describe('SettingsPanel runtime files', () => {
expect( expect(
screen.queryByText('当前选择:ModelScope') screen.queryByText('当前选择:ModelScope')
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(screen.getByLabelText('快捷键')).toHaveValue(
'CommandOrControl+Shift+Space'
)
}) })
it('keeps the confirmed model download source when saving fails', async () => { it('keeps the confirmed model download source when saving fails', async () => {
@@ -1495,6 +1573,213 @@ describe('SettingsPanel runtime files', () => {
) )
}) })
it('records and saves the global shortcut in General settings', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
await waitFor(() =>
expect(
screen.queryByText('正在读取快捷键状态…')
).not.toBeInTheDocument()
)
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(
screen.queryByText('读取快捷键设置失败,请重试')
).not.toBeInTheDocument()
const accelerator = await screen.findByLabelText('快捷键')
expect(accelerator).toHaveValue(
'CommandOrControl+Shift+Space'
)
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
altKey: true
})
expect(accelerator).toHaveValue('CommandOrControl+Alt+K')
const platformTab = screen.getByRole('tab', {
name: '平台功能'
})
fireEvent.keyDown(platformTab, { key: 'ArrowRight' })
expect(platformTab).toHaveAttribute('aria-selected', 'true')
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(updateShortcutSettings).toHaveBeenCalledWith({
enabled: true,
accelerator: 'CommandOrControl+Alt+K'
})
)
expect(
await screen.findByText('已注册:CommandOrControl+Alt+K')
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
platform: 'darwin',
displayAccelerator: 'Command+Shift+Space'
}
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true
})
expect(accelerator).toHaveValue('Control+K')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
metaKey: true
})
expect(accelerator).toHaveValue('Control+Command+K')
})
it('preserves runtime drafts across navigation and protects them on close', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
const workspace = await screen.findByLabelText('默认工作区目录')
fireEvent.change(workspace, {
target: { value: 'C:\\Unsaved workspace' }
})
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('tab', { name: '外观' })
).toHaveAttribute('aria-selected', 'true')
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).not.toHaveBeenCalled()
expect(
screen.getByRole('alert')
).toHaveTextContent('当前设置有未保存更改')
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(runtimeTab)
expect(await screen.findByLabelText('默认工作区目录')).toHaveValue(
'C:\\Unsaved workspace'
)
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(
screen.getByRole('button', { name: '放弃更改并关闭' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(onClose).toHaveBeenCalledOnce()
})
it('routes external leave requests through the existing dirty confirmation', async () => {
let requestLeave: SettingsLeaveRequester | undefined
const proceed = vi.fn()
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onLeaveRequestReady={(requester) => {
requestLeave = requester
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Pending external navigation' }
})
act(() => requestLeave?.(proceed))
expect(proceed).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(proceed).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({
ok: false,
error: 'conflict',
snapshot: shortcutSettingsSnapshot
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.change(accelerator, {
target: { value: 'Control+Alt+K' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
expect(
await screen.findByText(//u)
).toBeInTheDocument()
expect(accelerator).toHaveValue('Control+Alt+K')
})
it('keeps a speech model draft when saving the selection fails', async () => { it('keeps a speech model draft when saving the selection fails', async () => {
selectSpeechModel.mockRejectedValueOnce( selectSpeechModel.mockRejectedValueOnce(
new Error('语音模型切换失败') new Error('语音模型切换失败')
+301 -58
View File
@@ -9,7 +9,13 @@ import {
Trash2, Trash2,
X X
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { import type {
AssistantExpert, AssistantExpert,
@@ -63,6 +69,8 @@ import type {
EmbeddingConfigurationSummary EmbeddingConfigurationSummary
} from '../../shared/embedding-contracts' } from '../../shared/embedding-contracts'
import { useUiLocale } from './i18n/UiLocaleProvider' import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech' type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType = type AgentRuntimeType =
@@ -156,6 +164,8 @@ function normalizeContextCompressionTokenDrafts(
} }
} }
export type SettingsLeaveRequester = (proceed: () => void) => void
type SettingsPanelProps = { type SettingsPanelProps = {
open: boolean open: boolean
presentation?: 'modal' | 'page' presentation?: 'modal' | 'page'
@@ -175,6 +185,12 @@ type SettingsPanelProps = {
onAppearanceThemeChange?: (theme: AppearanceTheme) => void onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean magicNotesEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void onMagicNotesEnabledChange?: (enabled: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
onLeaveRequestReady?: (
requester: SettingsLeaveRequester | undefined
) => void
} }
function settingsErrorMessage(reason: unknown, fallback: string): string { function settingsErrorMessage(reason: unknown, fallback: string): string {
@@ -525,7 +541,9 @@ export function SettingsPanel({
appearanceTheme = 'system', appearanceTheme = 'system',
onAppearanceThemeChange = () => {}, onAppearanceThemeChange = () => {},
magicNotesEnabled = false, magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {} onMagicNotesEnabledChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null { }: SettingsPanelProps): React.JSX.Element | null {
const { i18n, t } = useTranslation('settings') const { i18n, t } = useTranslation('settings')
const { const {
@@ -647,6 +665,15 @@ export function SettingsPanel({
useState<AgentRuntimeType>('opencode') useState<AgentRuntimeType>('opencode')
const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] = const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] =
useState(false) useState(false)
const [platformFeaturesDirty, setPlatformFeaturesDirty] =
useState(false)
const [documentParsingDirty, setDocumentParsingDirty] =
useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId }
| { kind: 'external'; proceed: () => void }
>()
const runtimeCustomizationRef = const runtimeCustomizationRef =
useRef<RuntimeCustomizationSectionHandle>(null) useRef<RuntimeCustomizationSectionHandle>(null)
const handleRuntimeCustomizationDirtyChange = useCallback( const handleRuntimeCustomizationDirtyChange = useCallback(
@@ -725,6 +752,141 @@ export function SettingsPanel({
activeTab === 'mcp' || activeTab === 'mcp' ||
activeTab === 'about' activeTab === 'about'
const savedConfiguredSettings = settings
? configuredRuntimeSettings(settings)
: undefined
const runtimeDraftDirty =
settings !== undefined &&
JSON.stringify({
provider,
modelProfiles,
defaultModelProfileId,
opencodeModelSource,
continueModelSource,
deepseekHarnessModelSource,
opencodeBaseUrl,
opencodeBinaryPath,
opencodeConfigPath,
continueBinaryPath,
continueConfigPath,
continueMode,
knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey,
knowledgeRerankEnabled,
knowledgeRerankEndpoint,
knowledgeRerankModel,
knowledgeRerankApiKey,
clearKnowledgeRerankApiKey,
workspacePath,
toolApproval,
subagentSmartRoutingEnabled,
contextCompression,
contextCompressionTokenInput
}) !==
JSON.stringify({
provider: settings.provider,
modelProfiles: toModelProfileDrafts(settings),
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelSource:
savedConfiguredSettings?.opencodeModelSource,
continueModelSource:
savedConfiguredSettings?.continueModelSource,
deepseekHarnessModelSource:
savedConfiguredSettings?.deepseekHarnessModelSource ?? {
kind: 'platform'
},
opencodeBaseUrl: savedConfiguredSettings?.opencodeBaseUrl,
opencodeBinaryPath:
savedConfiguredSettings?.opencodeBinaryPath,
opencodeConfigPath:
savedConfiguredSettings?.opencodeConfigPath,
continueBinaryPath:
savedConfiguredSettings?.continueBinaryPath,
continueConfigPath:
savedConfiguredSettings?.continueConfigPath,
continueMode: settings.continueMode,
knowledgeEmbeddingEnabled:
settings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl:
settings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: '',
clearKnowledgeEmbeddingApiKey: false,
knowledgeRerankEnabled:
settings.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled,
knowledgeRerankEndpoint:
settings.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint,
knowledgeRerankModel:
settings.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel,
knowledgeRerankApiKey: '',
clearKnowledgeRerankApiKey: false,
workspacePath: savedConfiguredSettings?.workspacePath,
toolApproval:
settings.toolApproval === 'policy' ? 'policy' : 'always',
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
contextCompression:
settings.contextCompression ??
defaultContextCompressionSettings,
contextCompressionTokenInput: contextCompressionTokenDrafts(
settings.contextCompression ??
defaultContextCompressionSettings
)
})
const hasUnsavedDrafts =
runtimeDraftDirty ||
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
const unsavedCloseMessage =
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!platformFeaturesDirty &&
!documentParsingDirty
? t('runtime.customization.unsavedClose')
: t('unsaved.close')
useEffect(() => {
if (!open || !hasUnsavedDrafts) {
return
}
const preventUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', preventUnload)
return () =>
window.removeEventListener('beforeunload', preventUnload)
}, [hasUnsavedDrafts, open])
const requestTabChange = (
category: SettingsCategoryId
): boolean => {
if (category === activeTab) {
return true
}
if (navigationWouldLoseDraft) {
setPendingLeave({ kind: 'navigate', category })
return false
}
setPendingLeave(undefined)
setError(undefined)
setActiveTab(category)
return true
}
const handleTabKeyDown = ( const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>, event: React.KeyboardEvent<HTMLButtonElement>,
tab: SettingsCategoryId tab: SettingsCategoryId
@@ -747,8 +909,9 @@ export function SettingsPanel({
} }
event.preventDefault() event.preventDefault()
const nextTab = settingsTabs[nextIndex]! const nextTab = settingsTabs[nextIndex]!
setError(undefined) if (!requestTabChange(nextTab)) {
setActiveTab(nextTab) return
}
event.currentTarget.parentElement event.currentTarget.parentElement
?.querySelector<HTMLButtonElement>( ?.querySelector<HTMLButtonElement>(
`#settings-tab-${nextTab}` `#settings-tab-${nextTab}`
@@ -770,6 +933,9 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined) setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined) setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false) setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setPendingLeave(undefined)
setAgentRuntimeType('opencode') setAgentRuntimeType('opencode')
hydrateSettings(value) hydrateSettings(value)
}) })
@@ -831,10 +997,6 @@ export function SettingsPanel({
} }
}, [i18n, open]) }, [i18n, open])
if (!open) {
return null
}
const normalizedContextCompression = const normalizedContextCompression =
normalizeContextCompressionTokenDrafts( normalizeContextCompressionTokenDrafts(
contextCompression, contextCompression,
@@ -852,11 +1014,14 @@ export function SettingsPanel({
} }
const close = (): void => { const close = (): void => {
if (runtimeCustomizationDirty) { if (hasUnsavedDrafts) {
setActiveTab('runtime') setPendingLeave({ kind: 'close' })
setError(t('runtime.customization.unsavedClose'))
return return
} }
closeImmediately()
}
const resetSensitiveDrafts = (): void => {
setModelProfiles((profiles) => setModelProfiles((profiles) =>
profiles.map((profile) => ({ profiles.map((profile) => ({
...profile, ...profile,
@@ -871,10 +1036,66 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined) setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined) setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false) setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setPendingLeave(undefined)
setError(undefined) setError(undefined)
}
const closeImmediately = (): void => {
resetSensitiveDrafts()
onClose() onClose()
} }
const leaveSettings = (proceed: () => void): void => {
resetSensitiveDrafts()
proceed()
}
const discardAndLeave = (): void => {
if (settings) {
hydrateSettings(settings)
}
setSpeechModelDraftId(persistedSpeechModelId)
setSpeechModelSelectionDirty(false)
runtimeCustomizationRef.current?.discard()
setRuntimeCustomizationDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
const leave = pendingLeave
setPendingLeave(undefined)
setError(undefined)
if (leave?.kind === 'navigate') {
setActiveTab(leave.category)
return
}
if (leave?.kind === 'external') {
setActiveTab('runtime')
leaveSettings(leave.proceed)
return
}
if (leave?.kind === 'close') {
closeImmediately()
}
}
useLayoutEffect(() => {
const requestLeave: SettingsLeaveRequester = (proceed) => {
if (hasUnsavedDrafts) {
setPendingLeave({ kind: 'external', proceed })
return
}
resetSensitiveDrafts()
proceed()
}
onLeaveRequestReady(requestLeave)
return () => onLeaveRequestReady(undefined)
}, [hasUnsavedDrafts, onLeaveRequestReady])
if (!open) {
return null
}
const save = async ( const save = async (
notifySuccess = true notifySuccess = true
): Promise<RuntimeSettings | undefined> => { ): Promise<RuntimeSettings | undefined> => {
@@ -996,6 +1217,7 @@ export function SettingsPanel({
selectedSpeechModelId = speechSnapshot.selectedModelId selectedSpeechModelId = speechSnapshot.selectedModelId
} }
hydrateSettings(value, true) hydrateSettings(value, true)
setPendingLeave(undefined)
if (speechModelSelectionDirty) { if (speechModelSelectionDirty) {
setSpeechModelDraftId(selectedSpeechModelId) setSpeechModelDraftId(selectedSpeechModelId)
setPersistedSpeechModelId(selectedSpeechModelId) setPersistedSpeechModelId(selectedSpeechModelId)
@@ -1215,15 +1437,12 @@ export function SettingsPanel({
(profile) => profile.id === id (profile) => profile.id === id
) )
const remaining = modelProfiles.filter((profile) => profile.id !== id) const remaining = modelProfiles.filter((profile) => profile.id !== id)
const compatibleFallback = const compatibleFallback = findPreferredCompatibleModelProfile(
remaining.find( remaining,
(profile) => defaultModelProfileId,
profile.id === defaultModelProfileId && (profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
) ??
remaining.find((profile) =>
isAgentRuntimeModelProtocol(profile.protocol) isAgentRuntimeModelProtocol(profile.protocol)
) )
const runtimeFallback: RuntimeModelSource = compatibleFallback const runtimeFallback: RuntimeModelSource = compatibleFallback
? { kind: 'profile', profileId: compatibleFallback.id } ? { kind: 'profile', profileId: compatibleFallback.id }
: { kind: 'platform' } : { kind: 'platform' }
@@ -1255,13 +1474,10 @@ export function SettingsPanel({
deepseekHarnessModelSource.kind === 'profile' && deepseekHarnessModelSource.kind === 'profile' &&
deepseekHarnessModelSource.profileId === id deepseekHarnessModelSource.profileId === id
) { ) {
const harnessFallback = remaining.find( const harnessFallback = findPreferredCompatibleModelProfile(
(profile) => remaining,
profile.id === defaultModelProfileId && defaultModelProfileId,
profile.protocol === 'openai-chat-completions' (profile) => profile.protocol === 'openai-chat-completions'
) ?? remaining.find(
(profile) =>
profile.protocol === 'openai-chat-completions'
) )
setDeepseekHarnessModelSource( setDeepseekHarnessModelSource(
harnessFallback harnessFallback
@@ -1284,13 +1500,12 @@ export function SettingsPanel({
profile: ModelProfileDraft profile: ModelProfileDraft
): void => { ): void => {
const previousDefaultProfileId = defaultModelProfileId const previousDefaultProfileId = defaultModelProfileId
const compatibleProfile = isAgentRuntimeModelProtocol( const compatibleProfile = findPreferredCompatibleModelProfile(
profile.protocol modelProfiles,
profile.id,
(candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
) )
? profile
: modelProfiles.find((candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
)
const nextRuntimeSource: RuntimeModelSource = compatibleProfile const nextRuntimeSource: RuntimeModelSource = compatibleProfile
? { kind: 'profile', profileId: compatibleProfile.id } ? { kind: 'profile', profileId: compatibleProfile.id }
: { kind: 'platform' } : { kind: 'platform' }
@@ -1341,21 +1556,18 @@ export function SettingsPanel({
(profile) => profile.id === selectedModelProfileId (profile) => profile.id === selectedModelProfileId
) ?? modelProfiles[0] ) ?? modelProfiles[0]
const defaultTextModelProfile = const defaultTextModelProfile =
modelProfiles.find( findPreferredCompatibleModelProfile(
modelProfiles,
defaultModelProfileId,
(profile) => (profile) =>
profile.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(profile.protocol) isAgentRuntimeModelProtocol(profile.protocol)
) ??
modelProfiles.find((profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
) )
const defaultDeepseekHarnessModelProfile = const defaultDeepseekHarnessModelProfile =
modelProfiles.find( findPreferredCompatibleModelProfile(
(profile) => modelProfiles,
profile.id === defaultModelProfileId && defaultModelProfileId,
isDeepseekHarnessCompatible(profile) isDeepseekHarnessCompatible
) ?? )
modelProfiles.find(isDeepseekHarnessCompatible)
const activeRuntimeModelSource = const activeRuntimeModelSource =
agentRuntimeType === 'opencode' agentRuntimeType === 'opencode'
? opencodeModelSource ? opencodeModelSource
@@ -1440,8 +1652,7 @@ export function SettingsPanel({
id={`settings-tab-${category.id}`} id={`settings-tab-${category.id}`}
key={category.id} key={category.id}
onClick={() => { onClick={() => {
setError(undefined) requestTabChange(category.id)
setActiveTab(category.id)
}} }}
onKeyDown={(event) => onKeyDown={(event) =>
handleTabKeyDown(event, category.id) handleTabKeyDown(event, category.id)
@@ -1469,6 +1680,39 @@ export function SettingsPanel({
ref={settingsBodyRef} ref={settingsBodyRef}
role="tabpanel" role="tabpanel"
> >
{pendingLeave && (
<div
className="settings-warning"
role="alert"
>
<p>
{pendingLeave.kind !== 'navigate'
? unsavedCloseMessage
: t('unsaved.navigation')}
</p>
<div className="update-settings__actions">
<button
className="secondary-button"
onClick={() => {
setPendingLeave(undefined)
setError(undefined)
}}
type="button"
>
{t('unsaved.keepEditing')}
</button>
<button
className="danger-button"
onClick={discardAndLeave}
type="button"
>
{pendingLeave.kind !== 'navigate'
? t('unsaved.discardAndClose')
: t('unsaved.discardAndNavigate')}
</button>
</div>
</div>
)}
{!categoryRendersOwnHeader && ( {!categoryRendersOwnHeader && (
<SettingsCategoryHeader <SettingsCategoryHeader
actions={ actions={
@@ -1609,8 +1853,10 @@ export function SettingsPanel({
)} )}
{activeTab === 'platform-features' && ( {activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection <PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onMagicNotesEnabledChange={onMagicNotesEnabledChange} onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onNotify={onNotify} onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/> />
)} )}
{activeTab === 'runtime' && ( {activeTab === 'runtime' && (
@@ -2423,17 +2669,13 @@ export function SettingsPanel({
.value as ModelProfileDraft['protocol'] .value as ModelProfileDraft['protocol']
updateModelProfile(profile.id, { protocol }) updateModelProfile(profile.id, { protocol })
const compatibleFallback = const compatibleFallback =
modelProfiles.find( findPreferredCompatibleModelProfile(
modelProfiles.filter(
(candidate) =>
candidate.id !== profile.id
),
defaultModelProfileId,
(candidate) => (candidate) =>
candidate.id !== profile.id &&
candidate.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(
candidate.protocol
)
) ??
modelProfiles.find(
(candidate) =>
candidate.id !== profile.id &&
isAgentRuntimeModelProtocol( isAgentRuntimeModelProtocol(
candidate.protocol candidate.protocol
) )
@@ -2926,7 +3168,7 @@ export function SettingsPanel({
<SpeechModelSettingsSection <SpeechModelSettingsSection
onNotify={onNotify} onNotify={onNotify}
onOpenModelDownloadSourceSettings={() => onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features') requestTabChange('platform-features')
} }
onSelectedModelIdChange={(modelId, changed) => { onSelectedModelIdChange={(modelId, changed) => {
setSpeechModelDraftId(modelId) setSpeechModelDraftId(modelId)
@@ -3074,7 +3316,7 @@ export function SettingsPanel({
className="secondary-button" className="secondary-button"
onClick={() => { onClick={() => {
setModelType('llm') setModelType('llm')
setActiveTab('model') requestTabChange('model')
}} }}
type="button" type="button"
> >
@@ -3124,9 +3366,10 @@ export function SettingsPanel({
{activeTab === 'document-parsing' && ( {activeTab === 'document-parsing' && (
<DocumentParsingSettingsSection <DocumentParsingSettingsSection
onDirtyChange={setDocumentParsingDirty}
onNotify={onNotify} onNotify={onNotify}
onOpenModelDownloadSourceSettings={() => onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features') requestTabChange('platform-features')
} }
/> />
)} )}
+124 -7
View File
@@ -15,7 +15,8 @@ import {
PageHeader, PageHeader,
PageShell, PageShell,
PageTabs, PageTabs,
SegmentedControl SegmentedControl,
ScopeBadge
} from './WorkspacePrimitives' } from './WorkspacePrimitives'
const stylesheet = readFileSync( const stylesheet = readFileSync(
@@ -92,6 +93,10 @@ describe('WorkspacePrimitives', () => {
) )
expect(stylesheet).toMatch(/--font-body:\s*13px/u) expect(stylesheet).toMatch(/--font-body:\s*13px/u)
expect(stylesheet).toMatch(/--font-caption:\s*11px/u) expect(stylesheet).toMatch(/--font-caption:\s*11px/u)
expect(stylesheet).toMatch(
/--text-disabled:\s*#[\da-f]{6};/iu
)
expect(stylesheet).toMatch(/--z-dialog:\s*130;/u)
expect(stylesheet).toMatch(/font-synthesis:\s*style/u) expect(stylesheet).toMatch(/font-synthesis:\s*style/u)
expect(stylesheet).toContain( expect(stylesheet).toContain(
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC"' '"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC"'
@@ -119,6 +124,76 @@ describe('WorkspacePrimitives', () => {
) )
}) })
it('exposes an unavailable scope explanation to assistive technology', () => {
render(
<ScopeBadge
scope={{
kind: 'unavailable',
explanation: '项目已归档,请选择其他项目。'
}}
/>
)
expect(
screen.getByText('范围不可用')
).toHaveAccessibleDescription('项目已归档,请选择其他项目。')
})
it('labels multi-project scope distinctly from mixed global scope', () => {
const { rerender } = render(
<ScopeBadge
scope={{
kind: 'projects',
projectCount: 2
}}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(//u)).not.toBeInTheDocument()
rerender(<ScopeBadge scope={{ kind: 'mixed' }} />)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
})
it('keeps forced-color and narrow assistant overrides authoritative', () => {
const forcedColorsStart = stylesheet.indexOf(
'@media (forced-colors: active)'
)
const forcedColorsEnd = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)',
forcedColorsStart
)
const forcedColors = stylesheet.slice(
forcedColorsStart,
forcedColorsEnd
)
expect(forcedColors).toContain(':root :is(')
expect(forcedColors).not.toContain(':where(')
expect(forcedColors).toMatch(
/html:root\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\),\s*html:root\[data-theme='dark'\]\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\)\s*\{[^}]*border:\s*2px solid Highlight;[^}]*background:\s*Highlight;[^}]*color:\s*HighlightText;[^}]*forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.task-status-dot--failed,[\s\S]*?forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u
)
const narrowAssistant = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 719px)'),
stylesheet.indexOf(
'@media (max-width: 720px)',
stylesheet.indexOf('@media (max-width: 719px)')
)
)
expect(narrowAssistant).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*calc\(100vw - 16px\);[^}]*flex-basis:\s*calc\(100vw - 16px\);/u
)
})
it('floats Runtime context compaction without shifting the composer', () => { it('floats Runtime context compaction without shifting the composer', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u /\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
@@ -159,6 +234,48 @@ describe('WorkspacePrimitives', () => {
) )
}) })
it('keeps project forms and disabled actions visually consistent', () => {
expect(stylesheet).toMatch(
/\.project-create-card label > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card input,\s*\.project-create-card textarea,\s*\.project-create-card select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card label > small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card__error\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field input,\s*\.field textarea,\s*\.field select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.field small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.secondary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.danger-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toContain(
'.primary-button:hover:not(:disabled)'
)
expect(stylesheet).toContain(
'.danger-button:hover:not(:disabled)'
)
expect(stylesheet).not.toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*wait;/u
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => { it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u /\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -434,13 +551,13 @@ describe('WorkspacePrimitives', () => {
const confirmButton = screen.getByRole('button', { const confirmButton = screen.getByRole('button', {
name: '永久删除对象' name: '永久删除对象'
}) })
expect(dialog).toHaveAttribute('aria-modal', 'true') expect(dialog).not.toHaveAttribute('aria-modal')
expect(dialog).toHaveAccessibleDescription('删除此对象?') expect(dialog).toHaveAccessibleDescription('删除此对象?')
expect(cancelButton).toHaveFocus() expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true }) expect(
expect(confirmButton).toHaveFocus() fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
fireEvent.keyDown(confirmButton, { key: 'Tab' }) ).toBe(true)
expect(cancelButton).toHaveFocus() expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Escape' }) fireEvent.keyDown(cancelButton, { key: 'Escape' })
@@ -462,7 +579,7 @@ describe('WorkspacePrimitives', () => {
expect(screen.getByRole('button', { name: '删除' })).toHaveFocus() expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
}) })
it('keeps focus on the dialog while destructive actions are disabled', () => { it('keeps focus on the inline confirmation while actions are disabled', () => {
const onCancel = vi.fn() const onCancel = vi.fn()
const { rerender } = render( const { rerender } = render(
<DestructiveConfirmActions <DestructiveConfirmActions
@@ -491,7 +608,7 @@ describe('WorkspacePrimitives', () => {
name: '正在删除' name: '正在删除'
}) })
expect(dialog).toHaveFocus() expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' }) expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(dialog).toHaveFocus() expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' }) fireEvent.keyDown(dialog, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled() expect(onCancel).not.toHaveBeenCalled()
+19 -34
View File
@@ -17,6 +17,7 @@ export type WorkspaceScope =
| { kind: 'global' } | { kind: 'global' }
| { kind: 'all-projects' } | { kind: 'all-projects' }
| { kind: 'project'; projectName: string } | { kind: 'project'; projectName: string }
| { kind: 'projects'; projectCount: number }
| { kind: 'mixed'; projectName?: string } | { kind: 'mixed'; projectName?: string }
| { kind: 'unavailable'; explanation: string } | { kind: 'unavailable'; explanation: string }
@@ -75,6 +76,7 @@ export function ScopeBadge({
scope: WorkspaceScope scope: WorkspaceScope
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('workspace') const { t } = useTranslation('workspace')
const explanationId = useId()
const content = const content =
scope.kind === 'global' scope.kind === 'global'
? { ? {
@@ -93,7 +95,14 @@ export function ScopeBadge({
projectName: scope.projectName projectName: scope.projectName
}) })
} }
: scope.kind === 'mixed' : scope.kind === 'projects'
? {
icon: <Layers3 size={12} />,
label: t('primitives.scope.projects', {
count: scope.projectCount
})
}
: scope.kind === 'mixed'
? { ? {
icon: <Layers3 size={12} />, icon: <Layers3 size={12} />,
label: scope.projectName label: scope.projectName
@@ -109,11 +118,20 @@ export function ScopeBadge({
return ( return (
<span <span
aria-label={content.label}
aria-describedby={
scope.kind === 'unavailable' ? explanationId : undefined
}
className="scope-badge" className="scope-badge"
title={scope.kind === 'unavailable' ? scope.explanation : undefined} title={scope.kind === 'unavailable' ? scope.explanation : undefined}
> >
<span aria-hidden="true">{content.icon}</span> <span aria-hidden="true">{content.icon}</span>
{content.label} {content.label}
{scope.kind === 'unavailable' && (
<span className="sr-only" id={explanationId}>
{scope.explanation}
</span>
)}
</span> </span>
) )
} }
@@ -363,7 +381,6 @@ export function DestructiveConfirmActions({
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('workspace') const { t } = useTranslation('workspace')
const cancelRef = useRef<HTMLButtonElement>(null) const cancelRef = useRef<HTMLButtonElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null) const dialogRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null) const triggerRef = useRef<HTMLButtonElement>(null)
const wasConfirming = useRef(confirming) const wasConfirming = useRef(confirming)
@@ -406,43 +423,12 @@ export function DestructiveConfirmActions({
aria-describedby={descriptionId} aria-describedby={descriptionId}
aria-labelledby={titleId} aria-labelledby={titleId}
aria-live="assertive" aria-live="assertive"
aria-modal="true"
className="danger-confirm" className="danger-confirm"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape' && !disabled) { if (event.key === 'Escape' && !disabled) {
event.preventDefault() event.preventDefault()
onCancel() onCancel()
return
} }
if (event.key !== 'Tab') {
return
}
if (disabled) {
event.preventDefault()
dialogRef.current?.focus()
return
}
const cancelButton = cancelRef.current
const confirmButton = confirmRef.current
if (
!cancelButton ||
!confirmButton ||
cancelButton.disabled ||
confirmButton.disabled
) {
return
}
event.preventDefault()
const nextButton = event.shiftKey
? document.activeElement === cancelButton
? confirmButton
: cancelButton
: document.activeElement === confirmButton
? cancelButton
: confirmButton
nextButton.focus()
}} }}
ref={dialogRef} ref={dialogRef}
role="alertdialog" role="alertdialog"
@@ -472,7 +458,6 @@ export function DestructiveConfirmActions({
className="danger-button" className="danger-button"
disabled={disabled} disabled={disabled}
onClick={onConfirm} onClick={onConfirm}
ref={confirmRef}
type="button" type="button"
> >
{confirmLabel} {confirmLabel}

Some files were not shown because too many files have changed in this diff Show More