diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index d434245..9196ed4 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -7,14 +7,6 @@ on: - main tags: - 'v*' - paths: - - '.github/workflows/packages.yml' - - 'build/build-release.cjs' - - 'build/aggregate-release.cjs' - - 'build/file-hash.cjs' - - 'build/runtime-hooks.cjs' - - 'package.json' - - 'package-lock.json' permissions: contents: read @@ -54,6 +46,7 @@ jobs: run: npm run build:bundle - name: Upload production bundle + if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' uses: actions/upload-artifact@v7 with: name: goodbuddy-production-bundle @@ -63,6 +56,7 @@ jobs: package: name: ${{ matrix.platform }} ${{ matrix.arch }} + if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' needs: validate strategy: fail-fast: false diff --git a/AGENTS.md b/AGENTS.md index 31685ce..73e4d81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,16 +70,17 @@ Keep Electron security boundaries intact: `npm run release:package -- --platform --arch `. It only packages for the native host and writes to `dist/release/-`. -- Default deliverables are NSIS and portable EXE for Windows, DMG and ZIP for +- Default deliverables are NSIS and portable ZIP for Windows, DMG and ZIP for macOS, and AppImage and DEB for Linux. Every target includes `release-manifest.json` with SHA-256 hashes. - `build/build-release.cjs` verifies the unpacked application, `app.asar`, bundled Continue and OpenCode runtimes, executable architecture, and package signatures before atomically replacing a release directory. - Keep electron-builder invocations on `--publish never`. Main-branch builds - upload 30-day GitHub Actions artifacts. Version-tag builds additionally - verify and aggregate packages before publishing GitHub Release assets. - Signing and macOS notarization are not configured. + run validation and build the production bundle without running the native + package matrix. Manual builds upload 30-day GitHub Actions artifacts. + Version-tag builds verify and aggregate packages before publishing GitHub + Release assets. Signing and macOS notarization are not configured. - Keep `ELECTRON_CACHE` and `ELECTRON_BUILDER_CACHE` under `${{ runner.temp }}` in step-level workflow contexts. A cache beneath the repository inherits the root `"type": "module"` and breaks electron-builder's diff --git a/BUILD.md b/BUILD.md index cc73682..4ecda31 100644 --- a/BUILD.md +++ b/BUILD.md @@ -79,7 +79,7 @@ npm run dist npm run dist:win ``` -生成 Windows 便携目录: +生成用于本机调试的 Windows 便携目录: ```bash npm run portable @@ -138,23 +138,30 @@ Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifac npm run release:package -- --platform --arch ``` -默认产物为 Windows 的 NSIS 与 portable EXE、macOS 的 DMG 与 ZIP,以及 -Linux 的 AppImage 与 DEB。每个目标目录都包含带文件大小和 SHA-256 的 +默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG 与 +ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直接 +运行 `GoodBuddy.exe`,并包含启用便携数据目录的 +`.goodbuddy-portable.json`。每个目标目录都包含带文件大小和 SHA-256 的 `release-manifest.json`。 -推送 `v${package.version}` 标签时,只有在六个打包目标全部成功后,工作流 -才会严格校验并聚合所有平台产物,生成按平台重命名的 manifests、总 -`release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或更新 draft -GitHub Release,上传全部资产成功后才发布。重跑会保留人工编辑的 Release -notes 和未知附件。推送 `main` 或普通手动触发只构建 Actions artifacts, -不会创建或更新 Release。 +推送 `main` 时只运行源码验证和 production bundle 构建,不运行六平台 +打包矩阵,避免随后推送版本标签时对同一提交重复完整打包。手动触发会运行 +验证和六平台打包,并保留 30 天 Actions artifacts,但不会创建 Release。 + +推送 `v${package.version}` 标签时,工作流运行验证和六平台打包。只有在 +全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的 +manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或 +更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工 +编辑的 Release notes 和未知附件。 发布标签必须与 `package.json` 版本完全一致。实际推送标签和触发发布前仍 需人工确认,例如当前版本应使用: ```bash -git tag v$(node -p "require('./package.json').version") -git push origin v$(node -p "require('./package.json').version") +tag="v$(node -p "require('./package.json').version")" +git tag "$tag" +git push origin "$tag" +git push github "$tag" ``` 当前未配置 Windows/macOS 代码签名或 macOS notarization。对外分发前应按 diff --git a/FEATURES.md b/FEATURES.md index 68fd206..79f9aee 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,51 +4,65 @@ ## 状态说明 -| 状态 | 含义 | -| --- | --- | -| 已提供 | 已在当前代码和产品流程中提供 | -| 开发中 | 已进入实现或集成阶段,完整交付前仍可能调整 | -| 规划中 | 已确认产品方向,尚未承诺具体发布时间 | +- [x] **已提供**:已在当前代码和产品流程中提供。 +- [ ] **开发中**:已进入实现或集成阶段,完整交付前仍可能调整。 +- [ ] **规划中**:已确认产品方向,尚未承诺具体发布时间。 ## 功能总表 -| 领域 | 功能 | 状态 | 当前范围或目标 | -| --- | --- | --- | --- | -| 桌面基础 | 跨平台桌面应用 | 已提供 | 支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标 | -| 工作空间 | Projects 与独立对话 | 已提供 | 按项目隔离上下文,管理会话、附件和 Git 工作区变更 | -| 上下文 | 文件、截图、窗口、剪贴板 | 已提供 | 用户明确选择后才加入模型上下文 | -| Agent Runtime | 直连模型 Runtime | 已提供 | 支持问答、知识总结、受控工具执行和图像生成 | -| Agent Runtime | OpenCode 与 Continue | 已提供 | 使用隔离子进程、环境变量白名单、取消、超时和活动记录 | -| Agent Runtime | Ask 与 Execute 工作模式 | 已提供 | Ask 保持只读;Execute 运行已启用且受边界约束的工具 | -| Agent Runtime | 专家与 Subagent | 已提供 | 支持显式专家、团队分析和最多三个只读专家并行分析 | -| Agent Runtime | 角色绑定模型连接 | 已提供 | 每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型 | -| Agent Runtime | 可执行 Subagent 与结构化委派 | 规划中 | 在现有只读专家之外提供显式 Execute 委派,限制嵌套深度、并行数、Token、时间和工具权限,并保留父子任务审计 | -| 模型连接 | 多协议模型配置 | 已提供 | Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型 | -| 模型连接 | Main-only 凭据保护 | 已提供 | API Key 使用系统安全存储加密,不暴露给 Renderer | -| Skills 与 MCP | Skills 按需接入 | 已提供 | 使用有界资源和受控 Runtime 边界 | -| Skills 与 MCP | MCP Tools | 已提供 | 直连模型可使用显式启用的 MCP Tools | -| Skills 与 MCP | MCP Server Control Plane | 规划中 | 扩展 MCP Agent Runtime Broker,统一生命周期、健康检查、重连、Schema 缓存、按项目或任务隔离、审批和审计,并受控接入 OpenCode、Continue | -| 知识库 | 本地知识库 | 已提供 | 文件、目录和网页导入,SQLite FTS5 检索及来源追溯 | -| 知识库 | 知识图谱 | 已提供 | 规则、模型和混合抽取,支持实体、关系、别名和证据维护 | -| 知识库 | 向量模型配置与检索 | 已提供 | 可配置兼容 Embeddings 接口并用于语义检索 | -| 知识库 | 向量诊断与索引任务 | 已提供 | 提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索 | -| 工作管理 | 任务、活动与成果 | 已提供 | 集中管理任务状态、审计活动和成果文件 | -| 工作管理 | 批量运行与对比实验室 | 规划中 | 对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异 | -| 长期协作 | 记忆与智能心跳 | 已提供 | 周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹 | -| 长期协作 | 时态记忆与事实冲突检测 | 规划中 | 为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯 | -| 工作空间 | 项目 Agent Space 与策略包 | 规划中 | 在现有 Project 中统一角色、知识、Skills/MCP、模型、变量、审批策略、预算和超时,并支持模板化复用 | -| 工作流 | 可视化受控工作流 | 规划中 | 提供版本化 DAG、条件分支、审批检查点、取消、恢复和成果节点;所有执行节点继续经过 Main Runtime 边界 | -| 可观测 | 统一运行追踪与回放 | 规划中 | 关联任务、Subagent、模型调用、知识命中、工具审批、活动和成果,提供节点级耗时、失败定位、重试和脱敏导出 | -| 开放接口 | Headless Runtime API 与受控分享 | 规划中 | 提供本机优先的任务提交、流式事件、状态和成果 API,并使用带范围、有效期、限流和撤销能力的访问令牌 | -| 团队协作 | GoodBuddy Team Hub | 规划中 | 以可选独立服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计 | -| 浏览器与系统 | 浏览器和桌面受控工具 | 已提供 | 保留范围、取消、超时、输出边界和执行记录 | -| 企业通信 | 企业微信与钉钉 | 已提供 | 支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断 | -| 语音 | 可选本地语音模型管理 | 已提供 | 应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除 | -| 语音 | 本地录音与离线转写 | 开发中 | 采集麦克风音频并使用已选择的本地模型离线转写,补齐取消、资源释放和 Electron 打包验证 | -| 应用维护 | 版本检查 | 已提供 | 仅检查固定官方 Release 和当前平台清单,不自动下载或安装 | -| 安全与网络 | 内网兼容模式 | 已提供 | 默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验 | -| 远程执行 | 远程任务委派 | 已提供 | 仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱 | -| 远程执行 | 多云远程沙盒 Agent | 规划中 | 管理阿里云 ECS、腾讯云 CVM、AWS EC2,并通过 SSH + Agent 提供专用自主沙盒 | +### 桌面基础、工作空间与上下文 + +- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 +- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更。 +- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 +- [ ] **项目 Agent Space 与策略包**(规划中):在现有 Project 中统一角色、知识、Skills/MCP、模型、变量、审批策略、预算和超时,并支持模板化复用。 + +### Agent Runtime 与模型连接 + +- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 +- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、取消、超时和活动记录。 +- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。 +- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 +- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 +- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 +- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。 +- [ ] **可执行 Subagent 与结构化委派**(规划中):在现有只读专家之外提供显式 Execute 委派,限制嵌套深度、并行数、Token、时间和工具权限,并保留父子任务审计。 + +### Skills、MCP 与知识库 + +- [x] **Skills 按需接入**:使用有界资源和受控 Runtime 边界。 +- [x] **MCP Tools**:直连模型可使用显式启用的 MCP Tools。 +- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。 +- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。 +- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。 +- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。 +- [ ] **魔法笔记 / Magic Notes**(规划中):提供本地优先的结构化笔记工作空间,可摘录选中的对话、知识、文档和网页内容并保留来源追溯;AI 总结、改写、续写、整理和关联知识均由用户明确触发,不会静默修改来源知识。 +- [ ] **MCP Server Control Plane**(规划中):扩展 MCP Agent Runtime Broker,统一生命周期、健康检查、重连、Schema 缓存、按项目或任务隔离、审批和审计,并受控接入 OpenCode、Continue。 + +### 工作管理、长期协作与工作流 + +- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件。 +- [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 +- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。 +- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。 +- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批检查点、取消、恢复和成果节点;所有执行节点继续经过 Main Runtime 边界。 +- [ ] **统一运行追踪与回放**(规划中):关联任务、Subagent、模型调用、知识命中、工具审批、活动和成果,提供节点级耗时、失败定位、重试和脱敏导出。 + +### 浏览器、通信、语音与应用维护 + +- [x] **浏览器和桌面受控工具**:保留范围、取消、超时、输出边界和执行记录。 +- [x] **企业微信与钉钉**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。 +- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。 +- [ ] **本地录音与离线转写**(开发中):采集麦克风音频并使用已选择的本地模型离线转写,补齐取消、资源释放和 Electron 打包验证。 +- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。 +- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 + +### 开放接口、团队协作与远程执行 + +- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。 +- [ ] **Headless Runtime API 与受控分享**(规划中):提供本机优先的任务提交、流式事件、状态和成果 API,并使用带范围、有效期、限流和撤销能力的访问令牌。 +- [ ] **GoodBuddy Team Hub**(规划中):以可选独立服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计。 +- [ ] **多云远程沙盒 Agent**(规划中):管理阿里云 ECS、腾讯云 CVM、AWS EC2,并通过 SSH + Agent 提供专用自主沙盒。 ## 重大功能规划 @@ -56,25 +70,23 @@ 参考 MesaLogo 中已经存在或正在验证的 Action Space、受控工作流、Subagent、MCP 管理、批量实验和运行观测思路,GoodBuddy 计划在现有本地优先架构上逐步增加以下能力。这里列出的项目均为 GoodBuddy 自身规划,不表示 MesaLogo 的原型或路线图已在 GoodBuddy 中提供。 -1. **项目 Agent Space 与策略包**:不新增与 Project 重复的一级概念,而是在现有 Project 中统一角色、知识集合、Skills/MCP、默认模型、变量、工作模式、审批策略、预算和超时,并支持模板化复用。 -2. **统一 Run Graph**:先统一父子任务、节点、模型调用、知识命中、工具审批、用量、成果和取消事件,作为工作流、可执行 Subagent、批量实验和回放的共同基础。 -3. **MCP Server Control Plane**:由 Main 进程统一管理 `stdio`、HTTP 和 SSE Server,执行连接验证、健康检查、重连、Schema 缓存、环境变量白名单、资源配额、项目或任务隔离以及逐次审批和审计。 -4. **可视化受控工作流**:首版只支持开始、Agent/Subagent、知识、声明式条件、审批、成果和结束节点;流程需要版本化、校验、取消和恢复。任意网络请求或执行节点不得绕过现有 Runtime 与审批边界。 -5. **可执行 Subagent**:保留现有专家默认只读语义,只在显式 Execute 委派中允许受控工具,限制深度、并行数、Token、时间、成果范围和父子权限继承。 -6. **批量运行与对比实验室**:对模型、Prompt、角色和工作流版本进行参数扫描与 A/B 对比,展示质量评分、耗时、Token、费用、失败率和成果差异。 -7. **时态记忆与事实冲突检测**:在现有知识图谱和证据链上增加事实有效期、当前状态、过期与矛盾检测、核验流程及来源回溯。 -8. **Headless Runtime API**:作为可选、本机默认仅监听 loopback 的服务,提供任务提交、流式事件、状态和成果下载;访问令牌必须具有 scope、有效期、速率限制、项目限制和撤销能力。 -9. **GoodBuddy Team Hub**:作为独立可选服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计,不把 Electron Renderer 或云端服务改造成用户凭据持有者。 - -建议实施顺序为: - -- **P0**:项目策略 schema、统一 Run Graph、MCP Server Control Plane。 -- **P1**:可视化工作流 MVP、可执行 Subagent MVP。 -- **P2**:时态记忆、批量实验、Headless Runtime API。 -- **P3**:GoodBuddy Team Hub。 +- [ ] **项目 Agent Space 与策略包**:不新增与 Project 重复的一级概念,而是在现有 Project 中统一角色、知识集合、Skills/MCP、默认模型、变量、工作模式、审批策略、预算和超时,并支持模板化复用。 +- [ ] **统一 Run Graph**:先统一父子任务、节点、模型调用、知识命中、工具审批、用量、成果和取消事件,作为工作流、可执行 Subagent、批量实验和回放的共同基础。 +- [ ] **MCP Server Control Plane**:由 Main 进程统一管理 `stdio`、HTTP 和 SSE Server,执行连接验证、健康检查、重连、Schema 缓存、环境变量白名单、资源配额、项目或任务隔离以及逐次审批和审计。 +- [ ] **可视化受控工作流**:首版只支持开始、Agent/Subagent、知识、声明式条件、审批、成果和结束节点;流程需要版本化、校验、取消和恢复。任意网络请求或执行节点不得绕过现有 Runtime 与审批边界。 +- [ ] **可执行 Subagent**:保留现有专家默认只读语义,只在显式 Execute 委派中允许受控工具,限制深度、并行数、Token、时间、成果范围和父子权限继承。 +- [ ] **批量运行与对比实验室**:对模型、Prompt、角色和工作流版本进行参数扫描与 A/B 对比,展示质量评分、耗时、Token、费用、失败率和成果差异。 +- [ ] **时态记忆与事实冲突检测**:在现有知识图谱和证据链上增加事实有效期、当前状态、过期与矛盾检测、核验流程及来源回溯。 +- [ ] **Headless Runtime API**:作为可选、本机默认仅监听 loopback 的服务,提供任务提交、流式事件、状态和成果下载;访问令牌必须具有 scope、有效期、速率限制、项目限制和撤销能力。 +- [ ] **GoodBuddy Team Hub**:作为独立可选服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计,不把 Electron Renderer 或云端服务改造成用户凭据持有者。 安全边界保持不变:Ask/Plan 必须在 Runtime 边界只读;Execute、MCP、网络和 Subagent 工具均经过 Main 进程审批与审计;不得照搬进程内脚本执行、任意 URL 请求、仅以 `created_by` 模拟多租户或共享无隔离 MCP 会话等做法。 +### 知识工作空间与魔法笔记 + +- [ ] **魔法笔记 / Magic Notes**:建设本地优先的结构化笔记工作空间,支持将用户选中的对话片段、知识条目、文档摘录和网页摘录收集为可编辑笔记,并持续保留来源、位置和引用关系。 +- [ ] **受控 AI 笔记操作**:提供总结、改写、续写、整理和关联知识等显式操作;操作结果先进入笔记或待确认变更,不静默回写或修改来源知识。 + ### 多云远程沙盒 Agent GoodBuddy 将支持把专用云主机作为模型可自主使用的远程沙盒。首批计划接入: @@ -86,16 +98,10 @@ GoodBuddy 将支持把专用云主机作为模型可自主使用的远程沙盒 计划包含以下能力: -1. **云主机控制面**:通过云厂商官方 API 发现、创建、启动、停止和删除实例,创建前展示地域、规格、镜像、网络和费用相关配置。 -2. **SSH + GoodBuddy Agent**:校验 SSH 主机指纹后,以专用非 root 账户安装、升级和配对 Agent,不要求模型接触 SSH 私钥。 -3. **沙盒内自主执行**:用户可将明确指定的专用实例设为自主沙盒。模型在该沙盒的工作目录内连续执行命令、修改文件和运行任务,不逐条请求审批。 -4. **控制面与执行面隔离**:云 API 密钥、临时凭据和 SSH 私钥只保存在 Main 进程的系统加密存储中,不下发给 Renderer、模型或远程任务。模型的自主权限不包含云账户管理权限。 -5. **高风险操作保护**:删除实例、修改安全组或网络、扩缩容和其他可能产生额外费用或数据损失的控制面操作仍需单独确认。 -6. **可观测与可恢复**:实时回传心跳、日志、进度、退出状态和有界成果文件,支持取消、超时、断线重连、失败诊断和完整活动审计。 -7. **跨云一致体验**:使用统一的实例状态、Agent 能力和任务协议;阿里云、腾讯云、AWS 的差异由独立 Provider Adapter 隔离。 - -实施将分为三个可验收阶段: - -- **阶段一**:连接已有 Linux 云主机,完成 SSH 引导、Agent 配对、自主任务、日志和取消闭环。 -- **阶段二**:接入阿里云 ECS、腾讯云 CVM 和 AWS EC2 控制面,并支持安全的实例生命周期管理。 -- **阶段三**:增加可复用沙盒模板、资源与费用上限、自动关机策略、快照恢复和更多云厂商。 +- [ ] **云主机控制面**:通过云厂商官方 API 发现、创建、启动、停止和删除实例,创建前展示地域、规格、镜像、网络和费用相关配置。 +- [ ] **SSH + GoodBuddy Agent**:校验 SSH 主机指纹后,以专用非 root 账户安装、升级和配对 Agent,不要求模型接触 SSH 私钥。 +- [ ] **沙盒内自主执行**:用户可将明确指定的专用实例设为自主沙盒。模型在该沙盒的工作目录内连续执行命令、修改文件和运行任务,不逐条请求审批。 +- [ ] **控制面与执行面隔离**:云 API 密钥、临时凭据和 SSH 私钥只保存在 Main 进程的系统加密存储中,不下发给 Renderer、模型或远程任务。模型的自主权限不包含云账户管理权限。 +- [ ] **高风险操作保护**:删除实例、修改安全组或网络、扩缩容和其他可能产生额外费用或数据损失的控制面操作仍需单独确认。 +- [ ] **可观测与可恢复**:实时回传心跳、日志、进度、退出状态和有界成果文件,支持取消、超时、断线重连、失败诊断和完整活动审计。 +- [ ] **跨云一致体验**:使用统一的实例状态、Agent 能力和任务协议;阿里云、腾讯云、AWS 的差异由独立 Provider Adapter 隔离。 diff --git a/README.md b/README.md index 2ce6e46..07db4f7 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,18 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode ## 功能矩阵与路线图 -完整的现有能力、开发中功能和重大规划统一记录在 [FEATURES.md](FEATURES.md)。其中包含 Agent Runtime、模型连接、Skills 与 MCP、知识库与知识图谱、企业通信、可选语音模型,以及阿里云 ECS、腾讯云 CVM、AWS EC2 多云远程沙盒 Agent 路线图。 +以下为仓库首页的简要路线图;完整能力说明、状态和重大规划统一记录在 [FEATURES.md](FEATURES.md)。 + +- [x] [跨平台桌面工作空间与安全上下文](FEATURES.md#桌面基础工作空间与上下文) +- [x] [多 Runtime、模型连接、Skills 与 MCP](FEATURES.md#agent-runtime-与模型连接) +- [x] [本地知识库、向量检索与知识图谱](FEATURES.md#skillsmcp-与知识库) +- [x] [任务、成果、记忆与智能心跳](FEATURES.md#工作管理长期协作与工作流) +- [ ] [本地录音与离线转写](FEATURES.md#浏览器通信语音与应用维护) +- [ ] [魔法笔记 / Magic Notes](FEATURES.md#知识工作空间与魔法笔记):本地优先的结构化笔记、可追溯摘录与受控 AI 整理。 +- [ ] [Agent 框架、受控工作流与团队协作](FEATURES.md#agent-框架与协作能力) +- [ ] [多云远程沙盒 Agent](FEATURES.md#多云远程沙盒-agent) + +`[x]` 表示当前已提供,`[ ]` 表示开发中或规划中;未完成项目不代表已包含在当前发布版本中。 ## 隐私说明 diff --git a/build/aggregate-release.cjs b/build/aggregate-release.cjs index 12cb908..f49bf39 100644 --- a/build/aggregate-release.cjs +++ b/build/aggregate-release.cjs @@ -26,7 +26,7 @@ const targetDefinitions = [ ] const allowedExtensions = { nsis: '.exe', - portable: '.exe', + portable: '.zip', dmg: '.dmg', zip: '.zip', AppImage: '.AppImage', @@ -117,7 +117,7 @@ function expectedFormatForFile(name, target) { if (/-setup\.exe$/u.test(name)) { return 'nsis' } - if (/-portable\.exe$/u.test(name)) { + if (/-portable\.zip$/u.test(name)) { return 'portable' } return undefined diff --git a/build/build-portable.cjs b/build/build-portable.cjs index 8341b85..8a2b443 100644 --- a/build/build-portable.cjs +++ b/build/build-portable.cjs @@ -283,6 +283,50 @@ const electronDist = ensureElectronRuntime() mkdirSync(outputRoot, { recursive: true }) rmSync(stagingRoot, { recursive: true, force: true }) +for (const [label, script, args] of [ + [ + 'Node 类型检查', + join(root, 'node_modules', 'typescript', 'bin', 'tsc'), + ['--noEmit', '-p', 'tsconfig.node.json'] + ], + [ + 'Renderer 类型检查', + join(root, 'node_modules', 'typescript', 'bin', 'tsc'), + ['--noEmit', '-p', 'tsconfig.web.json'] + ], + [ + 'Production bundle', + join( + root, + 'node_modules', + 'electron-vite', + 'bin', + 'electron-vite.js' + ), + ['build'] + ] +]) { + const buildResult = spawnSync( + process.execPath, + [script, ...args], + { + cwd: root, + env: process.env, + shell: false, + stdio: 'inherit', + windowsHide: true + } + ) + if (buildResult.error) { + throw buildResult.error + } + if (buildResult.status !== 0) { + throw new Error( + `${label}失败(code ${buildResult.status ?? 1})` + ) + } +} + const result = spawnSync( process.execPath, [ diff --git a/build/build-release.cjs b/build/build-release.cjs index f5d80df..7d2ddb4 100644 --- a/build/build-release.cjs +++ b/build/build-release.cjs @@ -1,5 +1,7 @@ const { spawn } = require('node:child_process') const { + createReadStream, + createWriteStream, existsSync, closeSync, openSync, @@ -11,7 +13,18 @@ const { statSync, writeFileSync } = require('node:fs') -const { basename, dirname, join, parse, resolve } = require('node:path') +const { once } = require('node:events') +const { + basename, + dirname, + join, + parse, + relative, + resolve, + sep +} = require('node:path') +const { finished } = require('node:stream/promises') +const { Zip, ZipDeflate } = require('fflate') const { sha256File } = require('./file-hash.cjs') const root = join(__dirname, '..') @@ -21,6 +34,17 @@ const packageJson = JSON.parse( const productName = packageJson.build?.productName ?? packageJson.name const releaseRoot = join(root, 'dist', 'release') const manifestName = 'release-manifest.json' +const portableMarkerName = '.goodbuddy-portable.json' +const portableRequiredFiles = [ + `${productName}.exe`, + 'resources/app.asar', + 'resources/icon.ico', + 'resources/tray-icon.png', + 'resources/runtimes/opencode/opencode.exe', + 'resources/runtimes/continue/package.json' +] +const maxPortableZipEntries = 50_000 +const maxPortableCentralDirectoryBytes = 64 * 1024 * 1024 const ansiEscapeCharacter = String.fromCharCode(27) const ansiSequenceSuffixPattern = /\[[0-9;]*[A-Za-z]/gu const supportedArchitectures = new Set(['x64', 'arm64']) @@ -71,7 +95,7 @@ const platformDefinitions = { } const formatExtensions = { nsis: '.exe', - portable: '.exe', + portable: '.zip', dmg: '.dmg', zip: '.zip', AppImage: '.AppImage', @@ -201,10 +225,17 @@ function run(command, args, environment = process.env) { function buildElectronBuilderArguments(options, outputDirectory) { const definition = platformDefinitions[options.platform] + const builderFormats = [...new Set( + options.formats.map((format) => + options.platform === 'windows' && format === 'portable' + ? 'dir' + : format + ) + )] const builderArguments = [ join(root, 'node_modules', 'electron-builder', 'cli.js'), definition.builderFlag, - ...options.formats, + ...builderFormats, `--${options.arch}`, `--config.directories.output=${outputDirectory}`, '--publish', @@ -218,14 +249,6 @@ function buildElectronBuilderArguments(options, outputDirectory) { `--config.nsis.artifactName=${productName}-\${version}-windows-\${arch}-setup.\${ext}` ) } - if ( - options.platform === 'windows' && - options.formats.includes('portable') - ) { - builderArguments.push( - `--config.portable.artifactName=${productName}-\${version}-windows-\${arch}-portable.\${ext}` - ) - } return builderArguments } @@ -376,6 +399,300 @@ function verifyUnpackedOutput(directory, options) { return unpackedDirectory } +function toArchivePath(rootDirectory, filePath) { + return relative(rootDirectory, filePath).split(sep).join('/') +} + +function listPortableFiles(rootDirectory) { + const files = [] + const pending = [rootDirectory] + while (pending.length > 0) { + const directory = pending.pop() + const entries = readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => right.name.localeCompare(left.name)) + for (const entry of entries) { + const filePath = join(directory, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`Portable 目录不能包含符号链接:${filePath}`) + } + if (entry.isDirectory()) { + pending.push(filePath) + } else if (entry.isFile()) { + files.push(filePath) + if (files.length > maxPortableZipEntries) { + throw new Error( + `Portable ZIP 文件数量超过限制:${files.length}` + ) + } + } else { + throw new Error(`Portable 目录包含不支持的文件类型:${filePath}`) + } + } + } + return files.sort((left, right) => + toArchivePath(rootDirectory, left).localeCompare( + toArchivePath(rootDirectory, right) + ) + ) +} + +async function addFileToZip( + zip, + rootDirectory, + filePath, + waitForDrain +) { + const input = new ZipDeflate( + toArchivePath(rootDirectory, filePath), + { level: 6 } + ) + zip.add(input) + const stream = createReadStream(filePath) + try { + for await (const chunk of stream) { + input.push( + new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), + false + ) + await waitForDrain() + } + input.push(new Uint8Array(), true) + await waitForDrain() + } catch (error) { + stream.destroy() + throw error + } +} + +function openExclusiveWriteStream(filePath) { + const descriptor = openSync(filePath, 'wx') + try { + return createWriteStream(filePath, { + fd: descriptor, + autoClose: true + }) + } catch (error) { + closeSync(descriptor) + rmSync(filePath, { force: true }) + throw error + } +} + +async function createPortableZip( + unpackedDirectory, + zipPath, + dependencies = {} +) { + const markerPath = join(unpackedDirectory, portableMarkerName) + writeFileSync( + markerPath, + `${JSON.stringify({ + formatVersion: 1, + productName, + version: packageJson.version + }, null, 2)}\n`, + 'utf8' + ) + const portableFiles = listPortableFiles(unpackedDirectory) + const output = ( + dependencies.openOutput ?? openExclusiveWriteStream + )(zipPath) + let zipError + let pendingDrain + let zipFinal = false + const outputCompletion = finished(output).then( + () => undefined, + (error) => { + zipError ??= error + } + ) + const waitForDrain = async () => { + if (pendingDrain) { + await pendingDrain + } + if (zipError) { + throw zipError + } + } + const zip = new Zip((error, chunk, final) => { + if (error) { + zipError ??= error + output.destroy(error) + return + } + try { + if (!output.write(chunk) && !pendingDrain) { + const drain = once(output, 'drain').then( + () => undefined, + (writeError) => { + zipError ??= writeError + } + ) + const currentDrain = Promise.race([ + drain, + outputCompletion + ]).finally(() => { + if (pendingDrain === currentDrain) { + pendingDrain = undefined + } + }) + pendingDrain = currentDrain + } + if (final) { + zipFinal = true + } + } catch (writeError) { + zipError ??= writeError + output.destroy(writeError) + } + }) + try { + for (const filePath of portableFiles) { + await addFileToZip( + zip, + unpackedDirectory, + filePath, + waitForDrain + ) + if (zipError) { + throw zipError + } + } + zip.end() + await waitForDrain() + if (zipError) { + throw zipError + } + if (!zipFinal) { + throw new Error('Portable ZIP 未正常结束') + } + output.end() + await outputCompletion + if (zipError) { + throw zipError + } + } catch (error) { + zip.terminate() + output.destroy() + await outputCompletion + if (!dependencies.openOutput) { + rmSync(zipPath, { force: true }) + } + throw error + } +} + +function readZipEntryNames(filePath) { + const fileSize = statSync(filePath).size + if (fileSize < 22) { + throw new Error('Portable ZIP 缺少中央目录') + } + const endChunkSize = Math.min(fileSize, 65_557) + const endChunkStart = fileSize - endChunkSize + const endChunk = readChunk( + filePath, + endChunkSize, + endChunkStart + ) + let endOffset = -1 + for (let index = endChunk.length - 22; index >= 0; index -= 1) { + if ( + endChunk.readUInt32LE(index) === 0x06054b50 && + index + 22 + endChunk.readUInt16LE(index + 20) === + endChunk.length + ) { + endOffset = index + break + } + } + if (endOffset < 0) { + throw new Error('Portable ZIP 缺少中央目录') + } + const diskNumber = endChunk.readUInt16LE(endOffset + 4) + const centralDisk = endChunk.readUInt16LE(endOffset + 6) + const diskEntryCount = endChunk.readUInt16LE(endOffset + 8) + const entryCount = endChunk.readUInt16LE(endOffset + 10) + const centralSize = endChunk.readUInt32LE(endOffset + 12) + const centralOffset = endChunk.readUInt32LE(endOffset + 16) + const absoluteEndOffset = endChunkStart + endOffset + if ( + diskNumber !== 0 || + centralDisk !== 0 || + diskEntryCount !== entryCount || + entryCount === 0xffff || + centralSize === 0xffffffff || + centralOffset === 0xffffffff || + entryCount < portableRequiredFiles.length + 1 || + entryCount > maxPortableZipEntries || + centralSize < 46 || + centralSize > maxPortableCentralDirectoryBytes || + centralOffset + centralSize !== absoluteEndOffset + ) { + throw new Error('Portable ZIP 中央目录无效') + } + const centralDirectory = readChunk( + filePath, + centralSize, + centralOffset + ) + const names = [] + let offset = 0 + for (let index = 0; index < entryCount; index += 1) { + if ( + offset + 46 > centralDirectory.length || + centralDirectory.readUInt32LE(offset) !== 0x02014b50 + ) { + throw new Error('Portable ZIP 中央目录条目无效') + } + const nameLength = centralDirectory.readUInt16LE(offset + 28) + const extraLength = centralDirectory.readUInt16LE(offset + 30) + const commentLength = centralDirectory.readUInt16LE(offset + 32) + const entryLength = 46 + nameLength + extraLength + commentLength + if (offset + entryLength > centralDirectory.length) { + throw new Error('Portable ZIP 中央目录条目越界') + } + const name = centralDirectory + .subarray(offset + 46, offset + 46 + nameLength) + .toString( + centralDirectory.readUInt16LE(offset + 8) & 0x0800 + ? 'utf8' + : 'latin1' + ) + .replaceAll('\\', '/') + if ( + !name || + name.startsWith('/') || + /^[a-z]:\//iu.test(name) || + name.includes('\0') || + name.split('/').some((part) => part === '..') + ) { + throw new Error(`Portable ZIP 包含不安全路径:${name}`) + } + names.push(name) + offset += entryLength + } + if (offset !== centralDirectory.length) { + throw new Error('Portable ZIP 中央目录数量不一致') + } + return names +} + +function verifyPortableZip(filePath) { + const entries = readZipEntryNames(filePath) + const names = new Set(entries) + if (names.size !== entries.length) { + throw new Error('Portable ZIP 包含重复文件') + } + for (const required of [ + portableMarkerName, + ...portableRequiredFiles + ]) { + if (!names.has(required)) { + throw new Error(`Portable ZIP 缺少必要文件:${required}`) + } + } +} + function verifyArtifacts(directory, options) { const files = readdirSync(directory, { withFileTypes: true }) .filter((entry) => entry.isFile()) @@ -388,7 +705,7 @@ function verifyArtifacts(directory, options) { ? candidates.filter((name) => format === 'nsis' ? /-setup\.exe$/iu.test(name) - : /-portable\.exe$/iu.test(name) + : /-portable\.zip$/iu.test(name) ) : candidates if (matches.length !== 1) { @@ -401,17 +718,20 @@ function verifyArtifacts(directory, options) { format, options.arch ) + if (format === 'portable') { + verifyPortableZip(join(directory, matches[0])) + } } } function verifyArtifactSignature(filePath, format, arch) { - if (format === 'nsis' || format === 'portable') { + if (format === 'nsis') { if (readChunk(filePath, 2).toString('ascii') !== 'MZ') { throw new Error(`${format} 产物不是有效的 Windows PE 文件`) } return } - if (format === 'zip') { + if (format === 'portable' || format === 'zip') { const signature = readChunk(filePath, 4).toString('hex') if ( !['504b0304', '504b0506', '504b0708'].includes(signature) @@ -575,7 +895,7 @@ function printHelp() { --dry-run 仅显示目标与 electron-builder 参数 默认格式: - windows: nsis, portable + windows: nsis, portable (ZIP) macos: dmg, zip linux: AppImage, deb`) } @@ -634,6 +954,18 @@ async function main(argv = process.argv.slice(2)) { stagingDirectory, options ) + if ( + options.platform === 'windows' && + options.formats.includes('portable') + ) { + await createPortableZip( + unpackedDirectory, + join( + stagingDirectory, + `${productName}-${packageJson.version}-windows-${options.arch}-portable.zip` + ) + ) + } verifyArtifacts(stagingDirectory, options) rmSync(unpackedDirectory, { recursive: true, force: true }) const manifest = await writeManifest(stagingDirectory, options) @@ -652,6 +984,7 @@ async function main(argv = process.argv.slice(2)) { module.exports = { assertReplaceableOutput, buildElectronBuilderArguments, + createPortableZip, detectBinaryArchitecture, normalizePlatform, parseArguments, @@ -659,6 +992,7 @@ module.exports = { replaceOutput, verifyArtifacts, verifyArtifactSignature, + verifyPortableZip, writeManifest } diff --git a/package-lock.json b/package-lock.json index 4b34ff5..43a6c6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.8.2", + "version": "0.8.3", "license": "UNLICENSED", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", @@ -14,6 +14,7 @@ "@wecom/aibot-node-sdk": "^1.0.6", "cross-spawn": "^7.0.6", "dingtalk-stream": "^2.1.6-beta.1", + "echarts": "^6.1.0", "fflate": "^0.8.3", "html-to-text": "^10.0.0", "json5": "^2.2.3", @@ -5056,6 +5057,22 @@ "readable-stream": "^2.0.2" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -11708,6 +11725,21 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index b9182f4..cfbe114 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.8.2", + "version": "0.8.3", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", @@ -28,7 +28,7 @@ "dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb --arm64", "icons": "node build/generate-icons.mjs", "release:package": "node build/build-release.cjs", - "portable": "npm run build && node build/build-portable.cjs" + "portable": "node build/build-portable.cjs" }, "build": { "appId": "live.digiman.goodbuddy", @@ -134,6 +134,7 @@ "@wecom/aibot-node-sdk": "^1.0.6", "cross-spawn": "^7.0.6", "dingtalk-stream": "^2.1.6-beta.1", + "echarts": "^6.1.0", "fflate": "^0.8.3", "html-to-text": "^10.0.0", "json5": "^2.2.3", diff --git a/src/main/speech/speech-model-catalog.ts b/src/main/speech/speech-model-catalog.ts index 87aec2d..40e0ebe 100644 --- a/src/main/speech/speech-model-catalog.ts +++ b/src/main/speech/speech-model-catalog.ts @@ -19,7 +19,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = family: 'sensevoice', quantization: 'int8', repositoryUrl: - 'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17', + 'https://modelscope.cn/models/pengzhendong/' + + 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue', license: { name: '模型仓库自定义许可(Model License)', notice: @@ -33,9 +34,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = role: 'model', download: { url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/' + - 'resolve/6a65851692da9706cbddfac66ea9b96ebb1dee21/' + + 'https://modelscope.cn/models/pengzhendong/' + + 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' + + 'resolve/73eca47697f980daa3d16112404174b6b950b514/' + 'model.int8.onnx', size: 239_233_841, sha256: @@ -47,9 +48,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = role: 'tokens', download: { url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/' + - 'resolve/86f7114c4a83bcba0c609dd8d8dfa730d967ade9/' + + 'https://modelscope.cn/models/pengzhendong/' + + 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' + + 'resolve/73eca47697f980daa3d16112404174b6b950b514/' + 'tokens.txt', size: 315_894, sha256: @@ -67,7 +68,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = family: 'whisper', quantization: 'int8', repositoryUrl: - 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-tiny', + 'https://modelscope.cn/models/pengzhendong/' + + 'sherpa-onnx-whisper-tiny', license: { name: 'MIT License', notice: @@ -81,9 +83,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = role: 'encoder', download: { url: - 'https://huggingface.co/csukuangfj/' + + 'https://modelscope.cn/models/pengzhendong/' + 'sherpa-onnx-whisper-tiny/resolve/' + - '65176e2deb88badc814a94058666cadccc29b61c/' + + '33a655645234f82ce833cf27b689d9c2212e693f/' + 'tiny-encoder.int8.onnx', size: 12_937_772, sha256: @@ -95,9 +97,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = role: 'decoder', download: { url: - 'https://huggingface.co/csukuangfj/' + + 'https://modelscope.cn/models/pengzhendong/' + 'sherpa-onnx-whisper-tiny/resolve/' + - '65176e2deb88badc814a94058666cadccc29b61c/' + + '33a655645234f82ce833cf27b689d9c2212e693f/' + 'tiny-decoder.int8.onnx', size: 89_855_401, sha256: @@ -109,9 +111,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = role: 'tokens', download: { url: - 'https://huggingface.co/csukuangfj/' + + 'https://modelscope.cn/models/pengzhendong/' + 'sherpa-onnx-whisper-tiny/resolve/' + - '65176e2deb88badc814a94058666cadccc29b61c/' + + '33a655645234f82ce833cf27b689d9c2212e693f/' + 'tiny-tokens.txt', size: 816_730, sha256: diff --git a/src/main/speech/speech-model-manager.test.ts b/src/main/speech/speech-model-manager.test.ts index 27af7cd..81c22ff 100644 --- a/src/main/speech/speech-model-manager.test.ts +++ b/src/main/speech/speech-model-manager.test.ts @@ -55,7 +55,8 @@ function downloadableCatalog( languages: ['中文'], family: 'whisper', quantization: 'int8', - repositoryUrl: 'https://huggingface.co/example/download-test-model', + repositoryUrl: + 'https://modelscope.cn/models/example/download-test-model', license: { name: 'MIT License', notice: 'Test-only model metadata.', @@ -68,8 +69,8 @@ function downloadableCatalog( role: 'model', download: { url: - 'https://huggingface.co/example/download-test-model/' + - 'resolve/revision/model.onnx', + 'https://modelscope.cn/models/example/download-test-model/' + + `resolve/${'a'.repeat(40)}/model.onnx`, size: modelBytes.byteLength, sha256: sha256(modelBytes) } @@ -79,8 +80,8 @@ function downloadableCatalog( role: 'tokens', download: { url: - 'https://huggingface.co/example/download-test-model/' + - 'resolve/revision/tokens.txt', + 'https://modelscope.cn/models/example/download-test-model/' + + `resolve/${'a'.repeat(40)}/tokens.txt`, size: tokenBytes.byteLength, sha256: sha256(tokenBytes) } @@ -124,6 +125,16 @@ describe('speech model catalog', () => { 'tiny-decoder.int8.onnx', 'tiny-tokens.txt' ]) + for (const entry of SPEECH_MODEL_CATALOG) { + expect(entry.repositoryUrl).toMatch( + /^https:\/\/modelscope\.cn\/models\//u + ) + for (const file of entry.files) { + expect(file.download?.url).toMatch( + /^https:\/\/modelscope\.cn\/models\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u + ) + } + } }) }) @@ -168,7 +179,9 @@ describe('SpeechModelManager downloads', () => { }) expect(transport).toHaveBeenCalledTimes(2) for (const [input, init] of transport.mock.calls) { - expect(String(input)).toMatch(/^https:\/\/huggingface\.co\//u) + expect(String(input)).toMatch( + /^https:\/\/modelscope\.cn\/models\//u + ) expect(init).toMatchObject({ method: 'GET', redirect: 'manual', @@ -203,26 +216,66 @@ describe('SpeechModelManager downloads', () => { }) }) - it('rejects untrusted redirects and bad digests without installing', async () => { + it('accepts arbitrary HTTP hosts and cross-host redirects', async () => { const userData = await temporaryDirectory() const modelBytes = new TextEncoder().encode('expected') - const catalog = downloadableCatalog(modelBytes) + const tokenBytes = new TextEncoder().encode('tokens') + const catalog = downloadableCatalog(modelBytes).map((entry) => ({ + ...entry, + files: entry.files.map((file) => ({ + ...file, + download: file.download + ? { + ...file.download, + url: file.download.url.replace( + 'https://modelscope.cn', + 'http://models.internal.example' + ) + } + : undefined + })) + })) + const transport = vi.fn(async (input) => { + const url = new URL(String(input)) + if (url.hostname === 'models.internal.example') { + return new Response(null, { + status: 302, + headers: { + location: `https://cdn.example.net${url.pathname}` + } + }) + } + const bytes = url.pathname.endsWith('model.onnx') + ? modelBytes + : tokenBytes + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) } + }) + }) const redirected = new SpeechModelManager({ userDataDirectory: userData, catalog, - fetch: vi.fn(async () => - new Response(null, { - status: 302, - headers: { - location: 'https://attacker.invalid/model.onnx' - } - }) - ) + fetch: transport }) + await expect( redirected.install('download-test-model') - ).rejects.toThrow('允许的 Hugging Face HTTPS') + ).resolves.toMatchObject({ id: 'download-test-model' }) + expect(transport).toHaveBeenCalledTimes(4) + expect( + transport.mock.calls.map(([input]) => new URL(String(input)).hostname) + ).toEqual([ + 'models.internal.example', + 'cdn.example.net', + 'models.internal.example', + 'cdn.example.net' + ]) + }) + it('rejects bad digests without installing', async () => { + const userData = await temporaryDirectory() + const modelBytes = new TextEncoder().encode('expected') + const catalog = downloadableCatalog(modelBytes) const badDigest = new SpeechModelManager({ userDataDirectory: userData, catalog, diff --git a/src/main/speech/speech-model-manager.ts b/src/main/speech/speech-model-manager.ts index ee51524..f8d18f6 100644 --- a/src/main/speech/speech-model-manager.ts +++ b/src/main/speech/speech-model-manager.ts @@ -32,17 +32,6 @@ const MANIFEST_FILE_NAME = 'manifest.json' const SELECTION_FILE_NAME = '.selection.json' const PARTIAL_SUFFIX = '.partial' -const SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS = new Set([ - 'huggingface.co', - 'cdn-lfs.huggingface.co', - 'cdn-lfs-us-1.huggingface.co', - 'cdn-lfs-eu-1.huggingface.co', - 'cdn-lfs.hf.co', - 'cdn-lfs-us-1.hf.co', - 'cdn-lfs-eu-1.hf.co', - 'cas-bridge.xethub.hf.co' -]) - const selectionSchema = z .object({ selectedModelId: speechModelIdSchema.nullable() @@ -110,13 +99,10 @@ function safeChild(parent: string, name: string): string { function validateDownloadUrl(value: string): URL { const url = new URL(value) if ( - url.protocol !== 'https:' || - url.username || - url.password || - url.hash || - !SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase()) + url.protocol !== 'http:' && + url.protocol !== 'https:' ) { - throw new Error('模型下载地址必须是允许的 Hugging Face HTTPS 地址') + throw new Error('模型下载地址必须使用 HTTP 或 HTTPS') } return url } diff --git a/src/main/version-checker.test.ts b/src/main/version-checker.test.ts index 6d94524..9a2f6fe 100644 --- a/src/main/version-checker.test.ts +++ b/src/main/version-checker.test.ts @@ -22,7 +22,7 @@ const files = [ sha256: 'a'.repeat(64) }, { - name: `GoodBuddy-${latestVersion}-windows-x64-portable.exe`, + name: `GoodBuddy-${latestVersion}-windows-x64-portable.zip`, size: 102, sha256: 'b'.repeat(64) } diff --git a/src/main/version-checker.ts b/src/main/version-checker.ts index d11fd5d..b4ef98d 100644 --- a/src/main/version-checker.ts +++ b/src/main/version-checker.ts @@ -310,7 +310,7 @@ function hasExpectedFileFormats( if (platform === 'windows') { return ( files.filter((file) => /-setup\.exe$/u.test(file.name)).length === 1 && - files.filter((file) => /-portable\.exe$/u.test(file.name)).length === 1 + files.filter((file) => /-portable\.zip$/u.test(file.name)).length === 1 ) } const extensions = diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index ee9b4ba..382d194 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -549,6 +549,94 @@ describe('App', () => { expect(removeMaximizedChangedListener).toHaveBeenCalledOnce() }) + it('checks for updates silently on startup and only reports a new version', async () => { + const check = vi.fn(async () => ({ + updateAvailable: true, + currentVersion: '0.8.3', + latestVersion: '0.9.0', + releaseUrl: + 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0', + target: { + platform: 'windows' as const, + arch: 'x64' as const, + formats: ['nsis', 'portable'], + files: [ + { + name: 'GoodBuddy-0.9.0-windows-x64-portable.zip', + size: 1, + sha256: 'a'.repeat(64) + } + ] + } + })) + api.updates = { + getSettings: vi.fn(async () => ({ + checkUpdatesOnStartup: true + })), + updateSettings: vi.fn(async (input) => input), + check, + openReleasePage: vi.fn(async () => {}), + onResult: vi.fn(() => () => {}) + } + try { + render() + await waitFor(() => expect(check).toHaveBeenCalledOnce()) + expect( + await screen.findByText( + '发现 GoodBuddy 0.9.0,可在“关于与更新”中查看' + ) + ).toBeInTheDocument() + expect(api.updates.onResult).not.toHaveBeenCalled() + } finally { + delete api.updates + } + }) + + it('does not disturb startup when updates are current or offline', async () => { + const currentResult = { + updateAvailable: false, + currentVersion: '0.8.3', + latestVersion: '0.8.3', + releaseUrl: + 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.3', + target: { + platform: 'windows' as const, + arch: 'x64' as const, + formats: ['nsis', 'portable'], + files: [] + } + } + const check = vi + .fn() + .mockResolvedValueOnce(currentResult) + .mockRejectedValueOnce(new Error('offline')) + api.updates = { + getSettings: vi.fn(async () => ({ + checkUpdatesOnStartup: true + })), + updateSettings: vi.fn(async (input) => input), + check, + openReleasePage: vi.fn(async () => {}), + onResult: vi.fn(() => () => {}) + } + try { + const first = render() + await waitFor(() => expect(check).toHaveBeenCalledTimes(1)) + expect( + screen.queryByText(/发现 GoodBuddy|版本检查失败/u) + ).not.toBeInTheDocument() + + first.unmount() + render() + await waitFor(() => expect(check).toHaveBeenCalledTimes(2)) + expect( + screen.queryByText(/发现 GoodBuddy|版本检查失败/u) + ).not.toBeInTheDocument() + } finally { + delete api.updates + } + }) + it('keeps rendering when an older preload has no browser bridge', async () => { Object.defineProperty(window, 'goodbuddy', { configurable: true, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9c97c97..acc5491 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1021,24 +1021,22 @@ function App(): React.JSX.Element { if (!updates) { return } - const removeListener = updates.onResult((result) => { - if (result.updateAvailable) { - notify({ - tone: 'info', - message: `发现 GoodBuddy ${result.latestVersion},可在“关于与更新”中查看`, - dedupeKey: 'update-available' - }) - } - }) void updates .getSettings() - .then((settings) => - settings.checkUpdatesOnStartup - ? updates.check() - : undefined - ) + .then(async (settings) => { + if (!settings.checkUpdatesOnStartup) { + return + } + const result = await updates.check() + if (result.updateAvailable) { + notify({ + tone: 'info', + message: `发现 GoodBuddy ${result.latestVersion},可在“关于与更新”中查看`, + dedupeKey: 'update-available' + }) + } + }) .catch(() => undefined) - return removeListener }, []) useEffect(() => { @@ -2647,7 +2645,7 @@ function App(): React.JSX.Element { const transcript = conversation.messages .map( (message) => - `${message.role === 'user' ? '你' : 'GoodBuddy'}:\n${message.content}${formatAttachmentList(message.attachments)}` + `${message.role === 'user' ? '用户' : 'GoodBuddy'}:\n${message.content}${formatAttachmentList(message.attachments)}` ) .join('\n\n') try { @@ -2668,7 +2666,7 @@ function App(): React.JSX.Element { `# ${conversation.title}`, '', ...conversation.messages.flatMap((message) => [ - `## ${message.role === 'user' ? '你' : 'GoodBuddy'}`, + `## ${message.role === 'user' ? '用户' : 'GoodBuddy'}`, '', `${message.content}${formatAttachmentList(message.attachments)}`, '' @@ -3802,7 +3800,9 @@ function App(): React.JSX.Element {
- {message.role === 'assistant' ? 'GoodBuddy' : '你'} + {message.role === 'assistant' + ? 'GoodBuddy' + : '用户'} {formatTime(message.createdAt)}
diff --git a/src/renderer/src/KnowledgeGraphChart.tsx b/src/renderer/src/KnowledgeGraphChart.tsx new file mode 100644 index 0000000..f84a244 --- /dev/null +++ b/src/renderer/src/KnowledgeGraphChart.tsx @@ -0,0 +1,488 @@ +import { GraphChart } from 'echarts/charts' +import { TooltipComponent } from 'echarts/components' +import { + init, + use as registerECharts, + type ECElementEvent, + type ECharts, + type EChartsCoreOption +} from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { useEffect, useRef, useState } from 'react' +import type { + KnowledgeGraphNode, + KnowledgeGraphRelation +} from '../../shared/contracts' + +registerECharts([GraphChart, TooltipComponent, CanvasRenderer]) + +type ChartKnowledgeGraphNode = Omit< + KnowledgeGraphNode, + 'aliases' | 'evidenceIds' +> & { + aliases?: readonly string[] + evidenceIds?: readonly string[] +} + +type ChartKnowledgeGraphRelation = Omit< + KnowledgeGraphRelation, + 'evidenceIds' +> & { + evidenceIds?: readonly string[] +} + +type KnowledgeGraphChartProps = { + nodes: readonly ChartKnowledgeGraphNode[] + relations: readonly ChartKnowledgeGraphRelation[] + selectedNodeId?: string + zoom: number + onMoveNode: (nodeId: string, position: { x: number; y: number }) => void + onSelectNode: (nodeId: string) => void + onZoomChange: (zoom: number) => void +} + +type GraphViewport = { + center?: [number | string, number | string] +} + +type NodeDrag = { + id: string + pointerX: number + pointerY: number + x: number + y: number +} + +function readToken(name: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() +} + +function createOption({ + nodes, + relations, + selectedNodeId, + zoom +}: Pick< + KnowledgeGraphChartProps, + 'nodes' | 'relations' | 'selectedNodeId' | 'zoom' +>): EChartsCoreOption { + const textPrimary = readToken('--text-primary') + const textSecondary = readToken('--text-secondary') + const textMuted = readToken('--text-muted') + const accent = readToken('--accent') + const accentSelected = readToken('--accent-selected') + const accentSubtle = readToken('--accent-subtle') + const surfaceRaised = readToken('--surface-raised') + const borderDefault = readToken('--border-default') + const dense = nodes.length > 24 + const veryDense = nodes.length > 60 + const showEdgeLabels = + nodes.length <= 18 && relations.length <= 24 + + return { + animation: !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, + tooltip: { + trigger: 'item', + renderMode: 'richText', + backgroundColor: surfaceRaised, + borderColor: borderDefault, + textStyle: { color: textPrimary }, + formatter: (params: { + dataType?: string + data?: { name?: string; type?: string; value?: string } + }) => { + if (params.dataType === 'edge') { + return params.data?.value ?? '关系' + } + return [params.data?.name, params.data?.type] + .filter(Boolean) + .join(' · ') + } + }, + series: [ + { + type: 'graph', + layout: 'force', + roam: true, + zoom, + scaleLimit: { + min: 0.5, + max: 2 + }, + force: { + repulsion: dense + ? Math.min(520, 130 + nodes.length * 3) + : 220, + gravity: dense ? 0.14 : 0.08, + edgeLength: dense + ? veryDense + ? [45, 80] + : [60, 110] + : [110, 190], + friction: dense ? 0.5 : 0.6, + layoutAnimation: + !window.matchMedia?.('(prefers-reduced-motion: reduce)') + .matches + }, + selectedMode: 'single', + symbol: 'circle', + data: nodes.map((node) => { + const selected = node.id === selectedNodeId + return { + id: node.id, + name: node.label, + type: node.type, + ...(dense ? {} : { x: node.x, y: node.y }), + draggable: true, + selected, + symbolSize: selected + ? dense + ? 34 + : 60 + : dense + ? veryDense + ? 18 + : 24 + : 52, + itemStyle: { + color: selected ? accentSelected : accentSubtle, + borderColor: accent, + borderWidth: selected ? 3 : 2 + }, + label: { + show: !dense || selected, + color: textPrimary, + fontSize: dense ? 11 : 12, + fontWeight: 700, + formatter: + node.label.length > 8 + ? `${node.label.slice(0, 8)}…` + : node.label + }, + emphasis: { + focus: 'adjacency', + label: { + show: true + } + }, + select: { + itemStyle: { + color: accentSelected, + borderColor: accent, + borderWidth: 3 + }, + label: { + show: true + } + } + } + }), + links: relations.map((relation) => ({ + id: relation.id, + source: relation.sourceId, + target: relation.targetId, + value: relation.type, + description: relation.description, + lineStyle: { + color: textMuted, + width: 1.5, + curveness: 0.08 + } + })), + edgeSymbol: ['none', 'arrow'], + edgeSymbolSize: 8, + autoCurveness: true, + edgeLabel: { + show: showEdgeLabels, + color: textSecondary, + fontSize: 11, + formatter: (params: { data?: { value?: string } }) => + params.data?.value ?? '' + }, + lineStyle: { + color: textMuted + }, + emphasis: { + focus: 'adjacency', + lineStyle: { + width: 3 + } + } + } + ] + } +} + +export function KnowledgeGraphChart({ + nodes, + relations, + selectedNodeId, + zoom, + onMoveNode, + onSelectNode, + onZoomChange +}: KnowledgeGraphChartProps): React.JSX.Element { + const containerRef = useRef(null) + const chartRef = useRef(null) + const onMoveNodeRef = useRef(onMoveNode) + const onSelectNodeRef = useRef(onSelectNode) + const onZoomChangeRef = useRef(onZoomChange) + const dragRef = useRef(undefined) + const viewportRef = useRef({}) + const zoomRef = useRef(zoom) + const appliedZoomRef = useRef(undefined) + const [themeRevision, setThemeRevision] = useState(0) + + useEffect(() => { + onMoveNodeRef.current = onMoveNode + onSelectNodeRef.current = onSelectNode + onZoomChangeRef.current = onZoomChange + }, [onMoveNode, onSelectNode, onZoomChange]) + + useEffect(() => { + zoomRef.current = zoom + }, [zoom]) + + useEffect(() => { + if (typeof MutationObserver !== 'function') { + return + } + const observer = new MutationObserver(() => { + setThemeRevision((revision) => revision + 1) + }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const container = containerRef.current + if (!container) { + return + } + + const chart = init(container, undefined, { renderer: 'canvas' }) + chartRef.current = chart + + const selectNode = (event: ECElementEvent): void => { + const data = event.data as { id?: unknown } | undefined + if (event.dataType === 'node' && typeof data?.id === 'string') { + onSelectNodeRef.current(data.id) + } + } + const beginNodeDrag = (event: ECElementEvent): void => { + const data = event.data as { id?: unknown } | undefined + const pointerEvent = event.event + if ( + event.dataType !== 'node' || + typeof data?.id !== 'string' || + !pointerEvent || + !Number.isFinite(pointerEvent.offsetX) || + !Number.isFinite(pointerEvent.offsetY) + ) { + return + } + const pointer = chart.convertFromPixel( + { seriesIndex: 0 }, + [pointerEvent.offsetX, pointerEvent.offsetY] + ) + const centerPixel = + pointerEvent.target?.transformCoordToGlobal(0, 0) + const center = centerPixel + ? chart.convertFromPixel( + { seriesIndex: 0 }, + centerPixel + ) + : undefined + if ( + Array.isArray(pointer) && + Number.isFinite(pointer[0]) && + Number.isFinite(pointer[1]) && + Array.isArray(center) && + Number.isFinite(center[0]) && + Number.isFinite(center[1]) + ) { + dragRef.current = { + id: data.id, + pointerX: Number(pointer[0]), + pointerY: Number(pointer[1]), + x: Number(center[0]), + y: Number(center[1]) + } + } + } + const persistNodePosition = (event: ECElementEvent): void => { + const drag = dragRef.current + dragRef.current = undefined + const pointerEvent = event.event + if ( + !drag || + !pointerEvent || + !Number.isFinite(pointerEvent.offsetX) || + !Number.isFinite(pointerEvent.offsetY) + ) { + return + } + const pointer = chart.convertFromPixel( + { seriesIndex: 0 }, + [pointerEvent.offsetX, pointerEvent.offsetY] + ) + if ( + !Array.isArray(pointer) || + !Number.isFinite(pointer[0]) || + !Number.isFinite(pointer[1]) + ) { + return + } + const deltaX = Number(pointer[0]) - drag.pointerX + const deltaY = Number(pointer[1]) - drag.pointerY + if (Math.hypot(deltaX, deltaY) < 2) { + return + } + onMoveNodeRef.current(drag.id, { + x: drag.x + deltaX, + y: drag.y + deltaY + }) + } + const persistViewport = (): void => { + const option = chart.getOption() + const series = Array.isArray(option.series) + ? option.series[0] + : option.series + if (!series || typeof series !== 'object') { + return + } + const nextViewport: GraphViewport = {} + if ( + 'center' in series && + Array.isArray(series.center) && + series.center.length === 2 && + series.center.every( + (value: unknown) => + typeof value === 'number' || typeof value === 'string' + ) + ) { + nextViewport.center = [ + series.center[0] as number | string, + series.center[1] as number | string + ] + } + if ( + 'zoom' in series && + typeof series.zoom === 'number' && + Number.isFinite(series.zoom) + ) { + if (Math.abs(series.zoom - zoomRef.current) >= 0.001) { + zoomRef.current = series.zoom + appliedZoomRef.current = series.zoom + onZoomChangeRef.current(series.zoom) + } + } + viewportRef.current = nextViewport + } + const resize = (): void => chart.resize() + + chart.on('click', selectNode) + chart.on('mousedown', beginNodeDrag) + chart.on('mouseup', persistNodePosition) + chart.on('graphRoam', persistViewport) + + let resizeObserver: ResizeObserver | undefined + if (typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(resize) + resizeObserver.observe(container) + } else { + window.addEventListener('resize', resize) + } + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', resize) + chart.off('click', selectNode) + chart.off('mousedown', beginNodeDrag) + chart.off('mouseup', persistNodePosition) + chart.off('graphRoam', persistViewport) + chart.dispose() + chartRef.current = null + } + }, []) + + useEffect(() => { + const chart = chartRef.current + if (!chart) { + return + } + const option = createOption({ + nodes, + relations, + selectedNodeId: undefined, + zoom: zoomRef.current + }) + const series = Array.isArray(option.series) + ? option.series[0] + : option.series + if ( + series && + typeof series === 'object' && + viewportRef.current.center + ) { + series.center = viewportRef.current.center + } + chart.setOption( + option, + { notMerge: true } + ) + appliedZoomRef.current = zoomRef.current + }, [nodes, relations, themeRevision]) + + useEffect(() => { + const chart = chartRef.current + if (!chart) { + return + } + if ( + appliedZoomRef.current !== undefined && + Math.abs(appliedZoomRef.current - zoom) < 0.001 + ) { + return + } + chart.setOption({ + series: [{ zoom }] + }) + appliedZoomRef.current = zoom + }, [zoom]) + + useEffect(() => { + const chart = chartRef.current + if (!chart) { + return + } + chart.dispatchAction({ + type: 'unselect', + seriesIndex: 0 + }) + const dataIndex = selectedNodeId + ? nodes.findIndex((node) => node.id === selectedNodeId) + : -1 + if (dataIndex >= 0) { + chart.dispatchAction({ + type: 'select', + seriesIndex: 0, + dataIndex + }) + } + }, [nodes, selectedNodeId, themeRevision]) + + return ( +
+ ) +} diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx index adde8c7..6a6952e 100644 --- a/src/renderer/src/KnowledgeWorkspace.test.tsx +++ b/src/renderer/src/KnowledgeWorkspace.test.tsx @@ -1,4 +1,5 @@ import { + act, cleanup, fireEvent, render, @@ -11,6 +12,38 @@ import { type KnowledgeWorkspaceProps } from './KnowledgeWorkspace' +const echartsMock = vi.hoisted(() => { + const handlers = new Map void>() + const chart = { + convertFromPixel: vi.fn(() => [240, 320]), + dispose: vi.fn(), + dispatchAction: vi.fn(), + getOption: vi.fn(() => ({ + series: [{ center: ['50%', '50%'], zoom: 1 }] + })), + off: vi.fn((eventName: string) => handlers.delete(eventName)), + on: vi.fn((eventName: string, handler: (event: unknown) => void) => { + handlers.set(eventName, handler) + }), + resize: vi.fn(), + setOption: vi.fn() + } + return { + chart, + handlers, + init: vi.fn(() => chart), + use: vi.fn() + } +}) + +vi.mock('echarts/core', () => ({ + init: echartsMock.init, + use: echartsMock.use +})) +vi.mock('echarts/charts', () => ({ GraphChart: {} })) +vi.mock('echarts/components', () => ({ TooltipComponent: {} })) +vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} })) + const library: KnowledgeWorkspaceProps['libraries'][number] = { id: 'library-1', name: '产品知识', @@ -117,6 +150,8 @@ function createProps( describe('KnowledgeWorkspace', () => { afterEach(() => { cleanup() + vi.clearAllMocks() + echartsMock.handlers.clear() }) it('creates a configured knowledge library', async () => { @@ -178,31 +213,49 @@ describe('KnowledgeWorkspace', () => { fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) expect(screen.getByLabelText('实体关系图')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' })) + fireEvent.change(screen.getByLabelText('选择图谱实体'), { + target: { value: 'entity-1' } + }) expect(screen.getByLabelText('实体详情')).toBeInTheDocument() expect(screen.getByText('跨平台 AI 桌面助手')).toBeInTheDocument() expect(screen.getByText('架构说明.md')).toBeInTheDocument() }) - it('renders and filters graph nodes with their relationships', () => { + it('renders and filters graph nodes with their relationships', async () => { render() fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) expect( - screen.getByRole('button', { name: '实体 GoodBuddy' }) + screen.getByRole('option', { name: 'GoodBuddy · 产品' }) ).toBeInTheDocument() expect( - screen.getByRole('button', { name: '实体 Electron' }) + screen.getByRole('option', { name: 'Electron · 技术' }) ).toBeInTheDocument() - expect(screen.getByText('使用')).toBeInTheDocument() + fireEvent.click(screen.getByText('可见关系 1 条')) + expect(await screen.findByText('使用')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('搜索图谱实体'), { target: { value: 'Electron' } }) expect( - screen.queryByRole('button', { name: '实体 GoodBuddy' }) + screen.queryByRole('option', { name: 'GoodBuddy · 产品' }) ).not.toBeInTheDocument() expect(screen.queryByText('使用')).not.toBeInTheDocument() + expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith( + expect.objectContaining({ + series: [ + expect.objectContaining({ + data: [ + expect.objectContaining({ + id: 'entity-2' + }) + ], + links: [] + }) + ] + }), + { notMerge: true } + ) fireEvent.change(screen.getByLabelText('搜索图谱实体'), { target: { value: '' } @@ -211,10 +264,10 @@ describe('KnowledgeWorkspace', () => { target: { value: '产品' } }) expect( - screen.getByRole('button', { name: '实体 GoodBuddy' }) + screen.getByRole('option', { name: 'GoodBuddy · 产品' }) ).toBeInTheDocument() expect( - screen.queryByRole('button', { name: '实体 Electron' }) + screen.queryByRole('option', { name: 'Electron · 技术' }) ).not.toBeInTheDocument() }) @@ -263,7 +316,9 @@ describe('KnowledgeWorkspace', () => { expect(screen.queryByTitle('D:\\Private\\产品手册')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) - fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' })) + fireEvent.change(screen.getByLabelText('选择图谱实体'), { + target: { value: 'entity-1' } + }) expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass( 'knowledge-graph--with-details' ) @@ -272,26 +327,204 @@ describe('KnowledgeWorkspace', () => { ) }) - it('supports graph zoom, keyboard selection, and related-node navigation', () => { - render() + it('manages the graph chart, zoom, selection, movement, and cleanup', () => { + const onMoveNode = vi.fn() + const { unmount } = render( + + ) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) const graph = screen.getByLabelText('实体关系图') - expect(graph).toHaveAttribute('viewBox', '0 0 900 560') + expect(graph).toHaveClass('knowledge-graph__chart') + expect(echartsMock.init).toHaveBeenCalledWith( + graph, + undefined, + { renderer: 'canvas' } + ) + expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith( + expect.objectContaining({ + series: [ + expect.objectContaining({ + layout: 'force', + symbol: 'circle', + type: 'graph', + data: expect.arrayContaining([ + expect.objectContaining({ + id: 'entity-1', + name: 'GoodBuddy' + }) + ]), + links: [ + expect.objectContaining({ + id: 'relation-1', + value: '使用' + }) + ] + }) + ] + }), + { notMerge: true } + ) fireEvent.click(screen.getByRole('button', { name: '放大图谱' })) expect(screen.getByText('115%')).toBeInTheDocument() - expect(graph.getAttribute('viewBox')).not.toBe('0 0 900 560') - - fireEvent.keyDown( - screen.getByRole('button', { name: '实体 GoodBuddy' }), - { key: 'Enter' } + expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith( + expect.objectContaining({ + series: [ + expect.objectContaining({ + zoom: 1.15 + }) + ] + }) ) + + act(() => { + echartsMock.handlers.get('click')?.({ + dataType: 'node', + data: { id: 'entity-1' } + }) + }) expect(screen.getByLabelText('实体详情')).toBeInTheDocument() + expect(echartsMock.chart.dispatchAction).toHaveBeenCalledWith({ + type: 'select', + seriesIndex: 0, + dataIndex: 0 + }) + expect(onMoveNode).not.toHaveBeenCalled() + + act(() => { + echartsMock.chart.convertFromPixel + .mockReturnValueOnce([100, 100]) + .mockReturnValueOnce([220, 260]) + .mockReturnValueOnce([120, 160]) + echartsMock.handlers.get('mousedown')?.({ + dataType: 'node', + data: { id: 'entity-1' }, + event: { + offsetX: 100, + offsetY: 100, + target: { + transformCoordToGlobal: () => [220, 260] + } + } + }) + echartsMock.handlers.get('mouseup')?.({ + dataType: 'node', + data: { id: 'entity-1' }, + event: { offsetX: 120, offsetY: 160 } + }) + }) + expect(onMoveNode).toHaveBeenCalledWith('entity-1', { + x: 240, + y: 320 + }) + fireEvent.click(screen.getByRole('button', { name: '查看 Electron' })) expect( screen.getByRole('heading', { name: 'Electron' }) ).toBeInTheDocument() + + unmount() + expect(echartsMock.chart.off).toHaveBeenCalledWith( + 'click', + expect.any(Function) + ) + expect(echartsMock.chart.off).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function) + ) + expect(echartsMock.chart.off).toHaveBeenCalledWith( + 'mouseup', + expect.any(Function) + ) + expect(echartsMock.chart.off).toHaveBeenCalledWith( + 'graphRoam', + expect.any(Function) + ) + expect(echartsMock.chart.dispose).toHaveBeenCalled() + }) + + it('preserves the graph viewport and refreshes theme colors', async () => { + render() + fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + + echartsMock.chart.getOption.mockReturnValueOnce({ + series: [{ center: ['46%', '54%'], zoom: 1.3 }] + }) + act(() => { + echartsMock.handlers.get('graphRoam')?.({}) + }) + + await waitFor(() => + expect(screen.getByText('130%')).toBeInTheDocument() + ) + fireEvent.change(screen.getByLabelText('搜索图谱实体'), { + target: { value: 'Electron' } + }) + expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith( + expect.objectContaining({ + series: [ + expect.objectContaining({ + center: ['46%', '54%'], + zoom: 1.3 + }) + ] + }), + { notMerge: true } + ) + + const optionCalls = echartsMock.chart.setOption.mock.calls.length + act(() => { + document.documentElement.dataset.theme = 'dark' + }) + await waitFor(() => + expect(echartsMock.chart.setOption.mock.calls.length).toBeGreaterThan( + optionCalls + ) + ) + delete document.documentElement.dataset.theme + }) + + it('reduces labels and node size for dense graphs', () => { + const graphNodes = Array.from({ length: 30 }, (_, index) => ({ + id: `entity-${index}`, + label: `实体 ${index}`, + type: '概念', + x: index * 10, + y: index * 5 + })) + render( + + ) + fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) + + expect(echartsMock.chart.setOption).toHaveBeenLastCalledWith( + expect.objectContaining({ + series: [ + expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ + id: 'entity-0', + symbolSize: 24, + label: expect.objectContaining({ show: false }) + }) + ]), + edgeLabel: expect.objectContaining({ show: false }), + force: expect.objectContaining({ + repulsion: 220 + }) + }) + ] + }), + { notMerge: true } + ) + const option = echartsMock.chart.setOption.mock.calls.at(-1)?.[0] as { + series?: Array<{ data?: Array> }> + } + expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('x') + expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('y') }) it('creates relationships, merges entities, and opens graph evidence', async () => { @@ -309,7 +542,9 @@ describe('KnowledgeWorkspace', () => { ) fireEvent.click(screen.getByRole('tab', { name: '知识图谱' })) - fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' })) + fireEvent.change(screen.getByLabelText('选择图谱实体'), { + target: { value: 'entity-1' } + }) fireEvent.click( screen.getByRole('button', { name: /架构说明\.md/u }) ) diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx index b658ee1..2264c61 100644 --- a/src/renderer/src/KnowledgeWorkspace.tsx +++ b/src/renderer/src/KnowledgeWorkspace.tsx @@ -37,6 +37,7 @@ import { PageTabs, type PageTab } from './WorkspacePrimitives' +import { KnowledgeGraphChart } from './KnowledgeGraphChart' import { trapTabFocus } from './dialog-focus' export type KnowledgeStorageMode = 'reference' | 'managed' @@ -265,14 +266,13 @@ const styles = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - gap: 7, - font: 'inherit' + gap: 'var(--space-2)' }, input: { width: '100%', boxSizing: 'border-box' as const, - minHeight: 40, - padding: '9px 11px', + minHeight: 'var(--control-height)', + padding: 'var(--space-2) var(--space-3)', border: '1px solid var(--border-control)', borderRadius: 'var(--radius-control)', outline: 'none', @@ -282,14 +282,14 @@ const styles = { }, label: { display: 'grid', - gap: 7, + gap: 'var(--space-2)', color: 'var(--text-secondary)', - fontSize: 13, + fontSize: 'var(--font-body)', fontWeight: 650 }, muted: { color: 'var(--text-muted)', - fontSize: 13, + fontSize: 'var(--font-body)', lineHeight: 1.55 } } as const @@ -1524,12 +1524,7 @@ function GraphView({ useState() const [mergeTargetId, setMergeTargetId] = useState('') const [zoom, setZoom] = useState(1) - const [draggingNode, setDraggingNode] = useState<{ - id: string - offsetX: number - offsetY: number - }>() - const svgRef = useRef(null) + const [relationsExpanded, setRelationsExpanded] = useState(false) const nodeMap = useMemo( () => new Map(graphNodes.map((node) => [node.id, node])), @@ -1554,10 +1549,14 @@ function GraphView({ () => new Set(visibleNodes.map((node) => node.id)), [visibleNodes] ) - const visibleRelations = graphRelations.filter( - (relation) => - visibleIds.has(relation.sourceId) && - visibleIds.has(relation.targetId) + const visibleRelations = useMemo( + () => + graphRelations.filter( + (relation) => + visibleIds.has(relation.sourceId) && + visibleIds.has(relation.targetId) + ), + [graphRelations, visibleIds] ) const selectedNode = selectedNodeId ? nodeMap.get(selectedNodeId) @@ -1577,21 +1576,11 @@ function GraphView({ selectedEvidenceIds.has(item.id) ) - const pointerPosition = ( - event: React.PointerEvent - ): { x: number; y: number } | undefined => { - const svg = svgRef.current - if (!svg) { - return undefined - } - const rect = svg.getBoundingClientRect() - if (!rect.width || !rect.height) { - return undefined - } - return { - x: ((event.clientX - rect.left) / rect.width) * 900 / zoom, - y: ((event.clientY - rect.top) / rect.height) * 560 / zoom - } + const selectNode = (nodeId: string): void => { + setSelectedNodeId(nodeId) + setCreatingEntity(false) + setEditingEntity(false) + setRelationForm(undefined) } return ( @@ -1608,7 +1597,7 @@ function GraphView({ style={{ ...styles.surface, display: 'grid', - gridTemplateRows: 'auto minmax(0, 1fr)', + gridTemplateRows: 'auto minmax(0, 1fr) auto', overflow: 'hidden' }} > @@ -1647,6 +1636,27 @@ function GraphView({ ))} + {Math.round(zoom * 100)}% @@ -1710,153 +1720,50 @@ function GraphView({
) : ( - { - if (!draggingNode) { - return - } - const point = pointerPosition(event) - if (!point) { - return - } - onMoveNode(draggingNode.id, { - x: Math.max(38, Math.min(862, point.x - draggingNode.offsetX)), - y: Math.max(28, Math.min(532, point.y - draggingNode.offsetY)) - }) - }} - onPointerUp={(event) => { - if (draggingNode) { - event.currentTarget.releasePointerCapture(event.pointerId) - setDraggingNode(undefined) - } - }} - ref={svgRef} - role="img" - className="knowledge-graph__svg" - style={{ - width: '100%', - background: 'var(--surface-subtle)', - touchAction: 'none' - }} - viewBox={`0 0 ${900 / zoom} ${560 / zoom}`} - > - - + + {visibleRelations.length > 0 && ( +
+ setRelationsExpanded(event.currentTarget.open) + } + open={relationsExpanded} > - -
-
- {visibleRelations.map((relation) => { - const source = nodeMap.get(relation.sourceId) - const target = nodeMap.get(relation.targetId) - if (!source || !target) { - return null - } - return ( - - - + 可见关系 {visibleRelations.length} 条 + + {relationsExpanded && ( +
    - {relation.type} - - - ) - })} - {visibleNodes.map((node) => { - const selected = selectedNodeId === node.id - return ( - { - setSelectedNodeId(node.id) - setCreatingEntity(false) - setEditingEntity(false) - setRelationForm(undefined) - }} - onPointerDown={(event) => { - const point = pointerPosition(event) - if (!point) { - return - } - event.currentTarget.ownerSVGElement?.setPointerCapture( - event.pointerId - ) - setDraggingNode({ - id: node.id, - offsetX: point.x - node.x, - offsetY: point.y - node.y - }) - }} - role="button" - style={{ cursor: 'grab', outline: 'none' }} - tabIndex={0} - transform={`translate(${node.x} ${node.y})`} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - setSelectedNodeId(node.id) - setCreatingEntity(false) - } - }} - > - - - {node.label.length > 8 - ? `${node.label.slice(0, 8)}…` - : node.label} - - - {node.type} - - - ) - })} - + {visibleRelations.map((relation) => ( +
  • + + {nodeMap.get(relation.sourceId)?.label} + +
  • + ))} +
+ )} + + )} + )} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 8ba4c4a..bd1d3fd 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -4570,6 +4570,7 @@ details.settings-section > :not(summary) { .knowledge-workspace { width: 100%; min-height: max(520px, calc(100dvh - 114px)); + font-size: var(--font-body); grid-template-columns: clamp(280px, 24vw, 340px) minmax(0, 1fr); container-type: inline-size; } @@ -4615,9 +4616,14 @@ details.settings-section > :not(summary) { .knowledge-workspace__header h2 { overflow: hidden; + font-size: var(--font-page-title); text-overflow: ellipsis; } +.knowledge-workspace h3 { + font-size: var(--font-section-title); +} + .knowledge-workspace__header-actions { display: flex; align-items: center; @@ -4684,7 +4690,7 @@ details.settings-section > :not(summary) { .knowledge-graph { display: grid; min-height: clamp(500px, calc(100dvh - 260px), 780px); - gap: 14px; + gap: var(--space-4); grid-template-columns: minmax(0, 1fr); } @@ -4700,10 +4706,10 @@ details.settings-section > :not(summary) { display: flex; align-items: center; flex-wrap: wrap; - padding: 10px; + padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); background: var(--surface-subtle); - gap: 8px; + gap: var(--space-2); } .knowledge-graph__search { @@ -4716,8 +4722,70 @@ details.settings-section > :not(summary) { flex: 0 1 150px; } -.knowledge-graph__svg { +.knowledge-graph__entity-picker { + width: 180px; + min-height: var(--control-height); + flex: 0 1 180px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border-control); + border-radius: var(--radius-control); + background: var(--surface-raised); + color: var(--text-primary); + font: inherit; +} + +.knowledge-graph__zoom { + font-size: var(--font-body); + text-align: center; +} + +.knowledge-graph__chart { + width: 100%; min-height: clamp(420px, calc(100dvh - 330px), 720px); + background: var(--surface-subtle); +} + +.knowledge-graph__accessible-surface { + padding: var(--space-2); + border-top: 1px solid var(--border-subtle); + background: var(--surface-raised); +} + +.knowledge-graph__relation-list { + display: flex; + min-width: 0; + padding: 0; + margin: 0; + overflow-x: auto; + gap: var(--space-2); + list-style: none; +} + +.knowledge-graph__accessible-surface > summary { + cursor: pointer; + color: var(--text-secondary); + font-weight: 650; +} + +.knowledge-graph__accessible-surface[open] > summary { + margin-bottom: var(--space-2); +} + +.knowledge-graph__relation-list li { + display: inline-flex; + min-height: 24px; + align-items: center; + padding: 2px var(--space-2); + border: 1px solid var(--border-subtle); + border-radius: 999px; + color: var(--text-secondary); + font-size: var(--font-caption); + gap: var(--space-1); + white-space: nowrap; +} + +.knowledge-graph__relation-list strong { + color: var(--text-primary); } .knowledge-graph__detail { @@ -4831,7 +4899,8 @@ details.settings-section > :not(summary) { min-width: 100%; } - .knowledge-graph__filter { + .knowledge-graph__filter, + .knowledge-graph__entity-picker { width: min(180px, 100%); flex-basis: 180px; } diff --git a/tests/aggregate-release.test.ts b/tests/aggregate-release.test.ts index e82b17d..97eb801 100644 --- a/tests/aggregate-release.test.ts +++ b/tests/aggregate-release.test.ts @@ -58,7 +58,7 @@ function artifactName( return `${base}-setup.exe` } if (format === 'portable') { - return `${base}-portable.exe` + return `${base}-portable.zip` } const extension = format === 'zip' ? 'zip' : format return `${base}.${extension}` diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts index c4d2751..18ff98d 100644 --- a/tests/build-release.test.ts +++ b/tests/build-release.test.ts @@ -9,6 +9,7 @@ import { import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Writable } from 'node:stream' import { describe, expect, it } from 'vitest' interface ReleaseOptions { @@ -29,6 +30,13 @@ interface ReleaseBuilderModule { options: ReleaseOptions, outputDirectory: string ) => string[] + createPortableZip: ( + unpackedDirectory: string, + zipPath: string, + dependencies?: { + openOutput: (filePath: string) => Writable + } + ) => Promise detectBinaryArchitecture: ( buffer: Buffer ) => 'x64' | 'arm64' | undefined @@ -45,6 +53,7 @@ interface ReleaseBuilderModule { directory: string, options: ReleaseOptions ) => void + verifyPortableZip: (filePath: string) => void writeManifest: ( directory: string, options: ReleaseOptions @@ -101,6 +110,43 @@ function machO(cpuType: number): Buffer { return buffer } +function portableDirectory(parent: string): string { + const directory = join(parent, 'portable') + mkdirSync( + join(directory, 'resources', 'runtimes', 'opencode'), + { recursive: true } + ) + mkdirSync( + join(directory, 'resources', 'runtimes', 'continue'), + { recursive: true } + ) + for (const [path, content] of [ + ['GoodBuddy.exe', 'MZ'], + ['resources/app.asar', 'asar'], + ['resources/icon.ico', 'icon'], + ['resources/tray-icon.png', 'tray'], + ['resources/runtimes/opencode/opencode.exe', 'MZ'], + ['resources/runtimes/continue/package.json', '{}'] + ] satisfies Array<[string, string]>) { + writeFileSync(join(directory, ...path.split('/')), content) + } + return directory +} + +function endOfCentralDirectory( + entryCount: number, + centralSize: number, + centralOffset = 0 +): Buffer { + const buffer = Buffer.alloc(22) + buffer.writeUInt32LE(0x06054b50, 0) + buffer.writeUInt16LE(entryCount, 8) + buffer.writeUInt16LE(entryCount, 10) + buffer.writeUInt32LE(centralSize, 12) + buffer.writeUInt32LE(centralOffset, 16) + return buffer +} + describe('release build arguments', () => { it.each([ ['win32', 'x64', 'windows', ['nsis', 'portable']], @@ -175,15 +221,20 @@ describe('release build arguments', () => { expect.arrayContaining([ '--win', 'nsis', - 'portable', + 'dir', '--arm64', '--config.directories.output=C:\\release-stage', '--publish', 'never', - expect.stringContaining('nsis.artifactName='), - expect.stringContaining('portable.artifactName=') + expect.stringContaining('nsis.artifactName=') ]) ) + expect(arguments_).not.toContain('portable') + expect( + arguments_.some((argument) => + argument.includes('portable.artifactName=') + ) + ).toBe(false) }) }) @@ -204,6 +255,75 @@ describe('release binary architecture detection', () => { }) describe('release output safety', () => { + it('preserves an existing portable ZIP when exclusive creation fails', async () => { + const directory = mkdtempSync( + join(tmpdir(), 'goodbuddy-portable-existing-') + ) + try { + const unpacked = portableDirectory(directory) + const zipPath = join(directory, 'portable.zip') + writeFileSync(zipPath, 'keep-existing-output') + + await expect( + releaseBuilder.createPortableZip(unpacked, zipPath) + ).rejects.toMatchObject({ code: 'EEXIST' }) + expect(readFileSync(zipPath, 'utf8')).toBe( + 'keep-existing-output' + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('handles portable ZIP output stream failures without hanging', async () => { + const directory = mkdtempSync( + join(tmpdir(), 'goodbuddy-portable-write-error-') + ) + try { + const unpacked = portableDirectory(directory) + const failure = new Error('simulated ZIP write failure') + const output = new Writable({ + write(_chunk, _encoding, callback) { + callback(failure) + } + }) + + await expect( + releaseBuilder.createPortableZip( + unpacked, + join(directory, 'unused.zip'), + { openOutput: () => output } + ) + ).rejects.toThrow('simulated ZIP write failure') + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it.each([ + [ + 'an excessive entry count', + endOfCentralDirectory(50_001, 46) + ], + [ + 'an oversized central directory', + endOfCentralDirectory(7, 64 * 1024 * 1024 + 1) + ] + ])('rejects %s before reading ZIP central data', (_case, bytes) => { + const directory = mkdtempSync( + join(tmpdir(), 'goodbuddy-portable-invalid-') + ) + try { + const zipPath = join(directory, 'portable.zip') + writeFileSync(zipPath, bytes) + expect(() => + releaseBuilder.verifyPortableZip(zipPath) + ).toThrow('中央目录无效') + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + it('writes a deterministic artifact manifest with streaming hashes', async () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-release-manifest-') @@ -292,7 +412,7 @@ describe('release output safety', () => { } }) - it('requires one artifact for every requested format', () => { + it('requires one artifact for every requested format', async () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-release-artifacts-') ) @@ -304,12 +424,14 @@ describe('release output safety', () => { ), 'MZ' ) - writeFileSync( - join( - directory, - `GoodBuddy-${packageVersion}-windows-x64-portable.exe` - ), - 'MZ' + const unpacked = portableDirectory(directory) + const portableZip = join( + directory, + `GoodBuddy-${packageVersion}-windows-x64-portable.zip` + ) + await releaseBuilder.createPortableZip( + unpacked, + portableZip ) expect(() => releaseBuilder.verifyArtifacts( @@ -317,12 +439,7 @@ describe('release output safety', () => { windowsOptions ) ).not.toThrow() - rmSync( - join( - directory, - `GoodBuddy-${packageVersion}-windows-x64-portable.exe` - ) - ) + rmSync(portableZip) expect(() => releaseBuilder.verifyArtifacts( directory,