From 8dce1d700706c7061fcfabad304e7a5ac4bc311e Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 21 Sep 2026 09:19:30 +0800 Subject: [PATCH] feat: improve model connections and settings workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model settings can now duplicate saved connections with encrypted API-key copying, while settings navigation stays usable across long forms and preserves the separate Platform Features tabs. The assistant storage startup worker now gates historical migration and compaction on actual pending legacy data, so ordinary chat free pages do not reopen the migration screen. Desktop and Agent release metadata is prepared for 0.13.8 and 0.13.2. Release note: 改进模型连接复制、长表单设置导航和历史数据迁移判断;普通聊天退出并重开后不再重复显示迁移页面。 --- FEATURES.md | 12 +- FEATURES.zh-CN.md | 10 +- agent-runtime-lock.json | 2 +- docs/features/assistant-workbar/progress.md | 11 + docs/features/model-connections/README.md | 1 + docs/features/model-connections/progress.md | 10 + .../model-connections/technical-design.md | 12 +- docs/features/model-connections/ui-design.md | 10 +- package-lock.json | 4 +- package.json | 2 +- resources/agent-release-notes/0.13.2.md | 23 ++ resources/release-notes.json | 14 +- src/main/assistant-storage-startup.ts | 2 + .../assistant-storage-upgrade.test.ts | 42 ++- .../assistant/assistant-storage-upgrade.ts | 48 +++- src/main/runtime-settings-store.test.ts | 69 +++++ src/main/runtime-settings-store.ts | 18 +- src/renderer/src/App.test.tsx | 10 +- .../CapabilitiesAndToolsSettingsSection.tsx | 64 ++--- src/renderer/src/ChannelSettingsSection.tsx | 23 +- src/renderer/src/McpSettingsSection.test.tsx | 16 +- src/renderer/src/McpSettingsSection.tsx | 49 ++-- .../src/PlatformFeaturesSettingsSection.tsx | 135 ++++++--- src/renderer/src/SettingsPanel.test.tsx | 257 ++++++++++++++---- src/renderer/src/SettingsPanel.tsx | 215 +++++++++------ src/renderer/src/SettingsPrimitives.tsx | 15 +- .../src/SkillsSettingsSection.test.tsx | 19 +- src/renderer/src/SkillsSettingsSection.tsx | 78 +++--- .../src/i18n/locales/en-US/settings.ts | 7 + .../src/i18n/locales/zh-CN/settings.ts | 7 + src/renderer/src/styles.css | 29 ++ src/shared/contracts.ts | 41 +-- tests/support/inline-help-driver.mjs | 9 +- 33 files changed, 908 insertions(+), 356 deletions(-) create mode 100644 resources/agent-release-notes/0.13.2.md diff --git a/FEATURES.md b/FEATURES.md index 43214e60..26aa1876 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,7 +24,7 @@ records are listed separately and do not introduce another feature status. packages target compatible x64 and arm64 environments, including UOS, Kylin, Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification. LoongArch has a separate experimental loong64 preview outside standard - releases and automatic updates; no preview is planned for 0.13.7. See the + releases and automatic updates; no preview is planned for 0.13.8. See the [preview boundaries](./docs/development/loongarch-preview-build.md). - [x] **Configurable global shortcut**: Enable, disable, or record an Electron accelerator under Platform Features / General. The default remains @@ -315,7 +315,7 @@ records are listed separately and do not introduce another feature status. failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite database. Successful tool START/END events appear exactly once, with no Prompt, provider, or tool replay observed. The current Agent source lock is - `0.13.1`, while the current Desktop release candidate is `0.13.7`; formal + `0.13.2`, while the current Desktop release candidate is `0.13.8`; formal publication status follows the separate Agent and Desktop release channels. Previous macOS validation covered native package installation, detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in @@ -825,11 +825,11 @@ records are listed separately and do not introduce another feature status. ### Open source, builds, and releases -- Current source candidates are Desktop `0.13.7` and Agent `0.13.1`, with +- Current source candidates are Desktop `0.13.8` and Agent `0.13.2`, with OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent Desktop and Agent release channels. -- Agent `0.13.1` packages both OpenCode and Continue and delivers native execution - checklists. Its packages require Desktop `0.13.7`; +- Agent `0.13.2` packages both OpenCode and Continue and delivers native execution + checklists. Its packages require Desktop `0.13.8`; update Desktop first, then the Host environment. Node remains `24.19.0`. - Validation records remain separate from implementation status. Current-source storage validation covers real local and Linux x64 Host tool workloads, @@ -878,7 +878,7 @@ records are listed separately and do not introduce another feature status. Windows desktop-to-Linux checks cover installation, checklist updates, long lists, cancellation and restart recovery. Execute session MCP delivery passed real text-model Host checks with a substituted image service, not real image generation. - Candidate Agent `0.13.1` requires Desktop `0.13.7`; publication and native + Candidate Agent `0.13.2` requires Desktop `0.13.8`; publication and native platform acceptance remain separate from Linux development evidence. See the [validation record](./docs/features/remote-host/runtime-checklist-validation.md). diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 892e79a7..d4b56958 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -16,7 +16,7 @@ ### 桌面基础、工作空间与上下文 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 -- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.7` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。 +- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.8` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。 - [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。 - [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。 - [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时隐藏摘要,长会话子菜单独立滚动,项目选择器继续显示活动数量。 @@ -50,7 +50,7 @@ - [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。 - [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。 -- [x] **托管 SSH OpenCode 与 Continue(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.1`、Desktop `0.13.7`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。 +- [x] **托管 SSH OpenCode 与 Continue(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.2`、Desktop `0.13.8`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。 - [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。 - **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。 - [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。 @@ -140,8 +140,8 @@ ### 开源、构建与发布 -- 当前源码候选为 Desktop `0.13.7`、Agent `0.13.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。 -- Agent `0.13.1` 同时携带 OpenCode 和 Continue,并交付原生执行清单;复合包要求 Desktop `0.13.7`,请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。 +- 当前源码候选为 Desktop `0.13.8`、Agent `0.13.2`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。 +- Agent `0.13.2` 同时携带 OpenCode 和 Continue,并交付原生执行清单;复合包要求 Desktop `0.13.8`,请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。 - 验证记录与功能状态分开。存储已有本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;进程复用有 Windows 完整 App 与 Linux x64 Host 证据,PPTX OCR 与空闲内存释放有 Windows 真实文件证据。图片工具已有真实服务商生成/编辑及 Host 传输证据,这些记录未覆盖完整界面自然语言调用和本地/远程切换。候选仍需主分支 CI 及原生发布打包,详见[Runtime 验证记录](./docs/features/assistant-workbar/progress.md)与[图片工具验证记录](./docs/features/conversation-media-generation/progress.md)。 - 发布准备前,`9836a4c` 已通过本地 4,858 项测试(67 项跳过)、完整类型检查和 lint;真实服务商画布分析仍未验证。该提交的三个原生 Agent CI 构建通过;桌面 CI `35485936807` 为 4,875 项通过、8 项 Electron 测试失败,均因 Linux 沙箱辅助程序未配置,在页面断言前退出。候选配置该程序并在 Xvfb 下运行测试,不关闭沙箱;候选本地验证、主分支 CI 与原生标签打包分别记录,不在发布准备中运行本地生产构建或打包。 - [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。 @@ -152,7 +152,7 @@ ### 开放接口、团队协作与远程执行 -- [x] **远程 Continue 交付**:已接入 CN 1.5.47 受管包、Agent HTTP-to-ACP 适配、模型路由、项目选择和原生清单。默认包同时携带 OC/CN;Windows 桌面到 Linux x64 已通过安装、真实模型清单更新、长清单、取消及重启恢复验证。Execute 会话 MCP 已通过真实文本模型 Host 往返,图片服务使用替代接收端,不代表真实生图验证。候选 Agent `0.13.1` 需要 Desktop `0.13.7`;正式发布及原生平台验收与 Linux 开发证据分开,详见[验证记录](./docs/features/remote-host/runtime-checklist-validation.md)。 +- [x] **远程 Continue 交付**:已接入 CN 1.5.47 受管包、Agent HTTP-to-ACP 适配、模型路由、项目选择和原生清单。默认包同时携带 OC/CN;Windows 桌面到 Linux x64 已通过安装、真实模型清单更新、长清单、取消及重启恢复验证。Execute 会话 MCP 已通过真实文本模型 Host 往返,图片服务使用替代接收端,不代表真实生图验证。候选 Agent `0.13.2` 需要 Desktop `0.13.8`;正式发布及原生平台验收与 Linux 开发证据分开,详见[验证记录](./docs/features/remote-host/runtime-checklist-validation.md)。 - [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。 - [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 21b6b247..20d95c49 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.13.1", + "agentVersion": "0.13.2", "protocol": { "major": 2, "minor": 0 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index cd2f9e2c..07937be1 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,16 @@ # 工作栏实现与验证进度 +## 2026-09-21 修复重复历史迁移 + +- 启动时历史优化 Worker 之前只判断 `assistant.sqlite` 是否存在,没有先判断 + `PRAGMA user_version` 是否已经达到当前 Assistant schema,因此已完成迁移的数据库仍会 + 重复进入启动准备流程。现在仅对低于当前 schema 的数据库启动 Worker;已完成迁移的 + 数据库直接进入正常启动,聊天、退出和再次打开不会重新显示历史迁移页面。 +- 该问题来自 `49377206` 引入的启动迁移入口;本次修复不改变旧数据库的实际迁移逻辑, + 仍保留首次升级、取消后重试和已提交批次续跑行为。 +- `npx vitest run src/main/assistant-storage-startup.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant/assistant-database.test.ts`: + **115 项通过**;`npm run typecheck`、`npm run lint`:通过。 + ## 2026-09-19 问答位置持久化 - Main 在收到问题时保存消息位置块,而不是等回答成功才追加;前台及重启恢复的实时待答 diff --git a/docs/features/model-connections/README.md b/docs/features/model-connections/README.md index 5f6c8892..1f625a30 100644 --- a/docs/features/model-connections/README.md +++ b/docs/features/model-connections/README.md @@ -42,6 +42,7 @@ User Stories 和逻辑设计;本页定义产品边界,技术设计定义唯 认证信息。 - 自定义值作为普通连接设置保存。API Key 和其他密钥必须使用专用凭据字段,不应写入 自定义请求头或请求体。 +- LLM 连接可以复制,已保存的 API Key 由 Main 进程安全复制到新连接。 ## 相关功能 diff --git a/docs/features/model-connections/progress.md b/docs/features/model-connections/progress.md index ab6f93b0..62c80770 100644 --- a/docs/features/model-connections/progress.md +++ b/docs/features/model-connections/progress.md @@ -1,5 +1,15 @@ # 模型连接请求定制进度 +## 2026-09-20:模型设置可用性修复 + +- 模型接口 URL 保存失败时按连接名称显示错误,URL 字段同步提供 `aria-invalid` 和就地 + 错误;共享契约同时拒绝空值、非 HTTP/HTTPS 地址和超长地址。 +- 模型连接分类标题与 LLM、向量、重排、语音分段页签在模型内容滚动时共同固定;自定义 + Header/Body 默认收纳在折叠的“高级设置”中,无效时自动展开。 +- LLM 连接新增复制入口。复制保留当前草稿、请求定制和认证设置;已保存 API Key 由 Main + 进程从加密凭据复制,不进入 Renderer 或错误信息。 +- 已新增 SettingsPanel 与 RuntimeSettingsStore 回归覆盖;完整验证结果见后续条目。 + ## 2026-09-11:发布前固定模型说明修复 - 项目已保存固定 Runtime 模型时,恢复显示实际固定模型的帮助文字,保留简化后的菜单; diff --git a/docs/features/model-connections/technical-design.md b/docs/features/model-connections/technical-design.md index 21377f5e..b9c9f887 100644 --- a/docs/features/model-connections/technical-design.md +++ b/docs/features/model-connections/technical-design.md @@ -76,6 +76,15 @@ API Key 继续由现有 Main 加密设置和 Prompt-scoped Agent 凭据路径管 Body 是普通可见连接设置,不提供第二套通用秘密存储;UI 必须提示不要把密钥放入这两个 JSON 对象,标准认证 Header 也由 schema 拒绝。 +模型连接输入使用 `modelConnectionUrlSchema`,先在 Renderer 以连接名称标识无效 URL, +再由共享契约拒绝空值、超过 2048 个字符或非 HTTP/HTTPS 地址。页头错误保留完整错误上下文, +字段错误通过 `aria-describedby` 关联到 URL 控件。 + +复制连接不在 Renderer 读取或暴露已保存 API Key。复制草稿提交 +`{ action: 'copy', sourceProfileId }`,Main 在同一设置更新事务中从当前加密凭据或默认连接 +的环境凭据创建目标凭据。源连接不存在时整个更新失败;目标连接的其他字段仍由普通输入 +校验。正在编辑但尚未保存的 Key 仍以一次性 `replace` 值提交,因而不与旧凭据混合。 + ## 7. 验证 - 共享 schema:JSON 类型、边界、保留字段和优先级。 @@ -84,7 +93,8 @@ JSON 对象,标准认证 Header 也由 schema 拒绝。 - Runtime:真实 bundled OpenCode/Continue 对 loopback Provider 的请求探针;DeepSeek Utility 启动配置和 Pi-AI Provider 配置。 - 远程:Main/Agent 网关合并、认证优先级、摘要绑定、大小限制和不重放。 -- UI:默认值、保存、无效 JSON 保留和 Runtime 支持说明。 +- UI:默认值、保存、无效 JSON 保留、连接级 URL 错误、粘附标题/页签、折叠高级设置和 + 包含凭据的连接复制。 ## 8. 通道直连模型选择修复 diff --git a/docs/features/model-connections/ui-design.md b/docs/features/model-connections/ui-design.md index 54068132..33c79b35 100644 --- a/docs/features/model-connections/ui-design.md +++ b/docs/features/model-connections/ui-design.md @@ -6,7 +6,8 @@ ## 信息架构 “自定义请求头”和“自定义请求体”位于当前 LLM 连接详情的最底部、连接兼容性摘要之前。 -它们不新增页签、弹窗或高级模式。每个编辑器使用可纵向调整大小的等宽文本域,默认值为 +它们收纳在默认折叠的“高级设置”中,不新增页签或弹窗。只要其中一项无效,保存时自动 +展开该区域并保留对应字段错误。每个编辑器使用可纵向调整大小的等宽文本域,默认值为 `{}`。 ## 编辑与保存 @@ -17,6 +18,13 @@ `aria-invalid` 和就地错误;点击保存时阻止写入,并在现有设置错误区域显示对应连接。 - 保存成功后,编辑器使用两个空格缩进重新格式化已保存对象。 - 新连接从两个空对象开始。 +- 保存前校验失败时,页头错误和对应字段错误都显示连接名称;模型接口 URL 同时标记 + `aria-invalid` 并关联就地错误,避免只显示无法定位的“Invalid URL”。 +- LLM 模型连接的分类标题和模型类型分段页签在内容滚动时共同粘附在内容区顶部;左侧连接 + 列表仍可独立滚动。 +- 每个已保存或正在编辑的 LLM 连接提供“复制连接”。复制会生成新的连接名称和 ID,保留 + 地址、模型、协议、请求定制、认证方式及当前草稿 API Key。已保存的 API Key 通过 Main + 进程的加密凭据复制路径保存,不回传到 Renderer;原连接和默认连接不改变。 ## 兼容性说明 diff --git a/package-lock.json b/package-lock.json index d6bc6d81..554af4cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.13.7", + "version": "0.13.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.13.7", + "version": "0.13.8", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index ca81f666..be5c6a08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.13.7", + "version": "0.13.8", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/resources/agent-release-notes/0.13.2.md b/resources/agent-release-notes/0.13.2.md new file mode 100644 index 00000000..27e6dc7e --- /dev/null +++ b/resources/agent-release-notes/0.13.2.md @@ -0,0 +1,23 @@ +# GoodBuddy Agent 0.13.2 更新内容 + +## 维护更新 + +- **配套 Desktop 0.13.8**:本版本是与 Desktop 0.13.8 配套的 Agent patch 发布,保持 OpenCode、Continue、原生执行清单和远程权限边界不变。 + +## 使用说明 + +- 请先升级至 **GoodBuddy 0.13.8**,再更新 Host 环境。旧进程不会热更新。 +- 提供 Linux x64、Linux arm64 和 macOS arm64 包,内含 Node **24.19.0**、OpenCode **1.18.29**、Continue **1.5.47**。 +- 远程项目仍为技术预览,Execute 使用所选 SSH 账号的权限。Agent 独立发布,不替代桌面端的 Latest 版本。 + +# What's New in GoodBuddy Agent 0.13.2 + +## Maintenance + +- **Paired with Desktop 0.13.8**: This is the Agent patch release paired with Desktop 0.13.8. OpenCode, Continue, native execution checklists, and remote permission boundaries remain unchanged. + +## Usage Notes + +- Upgrade to **GoodBuddy 0.13.8** before updating the Host environment. Older processes are not hot-updated. +- Packages cover Linux x64, Linux arm64, and macOS arm64, with Node **24.19.0**, OpenCode **1.18.29**, and Continue **1.5.47**. +- Remote projects remain a technical preview, and Execute uses the selected SSH account’s permissions. Agent releases remain separate and do not replace the Desktop release marked Latest. diff --git a/resources/release-notes.json b/resources/release-notes.json index 1250f2da..e1b66e9d 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -2,12 +2,14 @@ "formatVersion": 1, "releases": [ { - "version": "0.13.7", + "version": "0.13.8", "releasedAt": "2026-09-20", "notes": { "zh-CN": { "highlights": [], "features": [ + "**设置页面滚动更顺手**:设置分类标题和当前分类的页签会固定在内容区顶部,平台功能、能力与工具、消息通道以及 Runtime 的子页签滚动长表单时仍可切换。低频配置继续放在默认收起的高级设置中。", + "**复制模型连接**:在模型连接详情中复制已有连接,地址、模型、协议、请求定制和认证设置会带到新连接。已保存的 API Key 由主进程从加密凭据复制,不需要再次输入,也不会暴露给界面。", "**分页画布笔记**:在魔法笔记中混排正文、笔迹、高亮和图片,也可导入 PDF 页面进行批注。支持手动保存后继续编辑、25%–300% 缩放、适应宽度和 PDF 导出。", "**选择送给 AI 的画布页数**:在笔记设置中选择发送前 1~8 页,默认 1 页,文字与图片使用相同范围。保存和导出仍保留完整画布,最多支持 50 页;页面图像分析需要支持图片输入的模型。", "**接入 HTTP PaddleOCR-VL**:在文档解析设置中配置 OCR 服务,供聊天附件和知识库导入共用,无需在本机加载该服务的模型权重。可用实际文件测试解析结果,并配置服务支持的版面、表格等处理选项。", @@ -21,6 +23,8 @@ "**说明按需查看**:页面和设置中的补充说明移到标题或字段旁的帮助入口,支持鼠标与键盘操作。减少常驻说明文字,同时保留关键操作后果和错误提示。" ], "fixes": [ + "**历史数据迁移只在需要时执行**:首次升级旧数据库时仍会整理重复的执行记录;数据库完成当前版本升级后,聊天、退出并重新打开应用不会再次显示历史迁移页面。", + "**模型 URL 错误更容易处理**:保存模型连接时,空白、过长或不支持的地址会明确指出具体连接,并在 URL 字段旁保留可访问的错误提示,不再只显示笼统的“Invalid URL”。", "**较早会话和长历史不再被数量截断**:会话列表与搜索不再只覆盖最近 100 个会话,连续发送不再截断超过 500 条的历史。模型上下文限制仍然有效,已经丢失的历史无法恢复。", "**保留草稿和画布编辑**:恢复队列文字时不再覆盖新输入,画布删页保留正文与清单格式。超限输入被拒绝后仍保留已有撤销记录,图片 OCR 没有正文时保留原图发送方式。", "**问答卡片保持原位置**:回答后,交互问题不再被排到后续输出末尾。重新打开会话仍保留原有问答顺序。", @@ -34,13 +38,15 @@ "首次启动会升级数据库并将笔记正文迁移到本地文件,可能需要时间和额外磁盘空间。升级前请备份完整用户数据;旧客户端不能打开升级后的数据库,回退应使用升级前备份。", "后续笔记备份必须同时包含一致的数据库与相邻 `notes/` 目录。画布需手动保存,导出的 PDF 为栅格图像,不保留可搜索文字层。", "HTTP OCR 会把待识别内容发送到配置的服务,可能产生费用;高级选项效果取决于实际部署。快速文本/索引模式跳过 OCR,显式图片文字提取仍会调用所选 OCR。", - "远程用户请先升级 **Desktop 0.13.7**,再下载并更新 **Agent 0.13.1**。远程项目仍为技术预览,Execute 使用所选 SSH 账号的权限。", + "远程用户请先升级 **Desktop 0.13.8**,再下载并更新 **Agent 0.13.2**。远程项目仍为技术预览,Execute 使用所选 SSH 账号的权限。", "标准桌面包覆盖 Windows、macOS、Linux 的 x64/arm64,本次不构建龙芯实验预览。" ] }, "en-US": { "highlights": [], "features": [ + "**Smoother settings scrolling**: Settings category titles and the active category tabs stay pinned at the top of the content area. Platform Features, Capabilities and Tools, message channels, and Runtime sub-tabs remain available while long forms scroll. Less frequently used controls stay in collapsed Advanced settings.", + "**Copy model connections**: Copy an existing model connection from its detail view, including its endpoint, model, protocol, request customization, and authentication settings. Main copies a saved API key from encrypted credentials without exposing it to the UI or requiring re-entry.", "**Paged canvas notes**: Combine body text, handwriting, highlights, and images in Magic Notes, or import PDF pages for annotation. Manually save and resume editing, zoom from 25% to 300%, fit the width, and export to PDF.", "**Choose canvas pages for AI analysis**: Select the first 1–8 pages in note settings, with a default of one page and the same range for text and images. Saving and export retain the complete canvas of up to 50 pages; visual page analysis requires an image-capable model.", "**Connect HTTP PaddleOCR-VL**: Configure an OCR service in Document Parsing for both chat attachments and knowledge imports, without loading that service’s model weights locally. Test parsing with real files and configure layout, table, and other options supported by the service.", @@ -54,6 +60,8 @@ "**Read supplementary help on demand**: Page and settings explanations move into help controls beside titles or fields, accessible by mouse and keyboard. This reduces persistent explanatory text while keeping important operation consequences and errors visible." ], "fixes": [ + "**Run history migration only when needed**: The first launch after upgrading an older database still compacts repeated execution records. Once the database reaches the current schema, chatting and reopening GoodBuddy no longer show the history migration screen again.", + "**Make model URL errors actionable**: Saving a model connection now identifies the specific connection when its endpoint is blank, too long, or unsupported, and keeps an accessible error beside the URL field instead of showing only a generic “Invalid URL”.", "**Keep older conversations and long histories accessible**: Conversation lists and search are no longer limited to the latest 100 conversations, and consecutive sends no longer truncate history beyond 500 messages. Model context limits still apply, and previously lost history cannot be recovered.", "**Preserve drafts and canvas edits**: Restoring queued text no longer overwrites newer input, and deleting canvas pages preserves body and checklist formatting. Rejected oversized input retains existing undo history, and image OCR without usable text preserves image delivery.", "**Keep question cards in their original position**: Answered interactive questions no longer move behind later output. Reopening the conversation preserves the question-and-answer order.", @@ -67,7 +75,7 @@ "First launch upgrades the database and moves note bodies into local files, which may take time and require additional disk space. Back up all user data first; older clients cannot open the upgraded database, so use a pre-upgrade backup for rollback.", "Subsequent note backups must include a consistent database and its adjacent `notes/` directory. Canvas notes require manual saving, and exported PDFs are raster images without a searchable text layer.", "HTTP OCR sends recognition content to the configured service and may incur charges; advanced-option results depend on the deployment. Fast text/index modes skip OCR, while explicit image text extraction still invokes the selected OCR.", - "Remote users should upgrade to **Desktop 0.13.7** before downloading and updating to **Agent 0.13.1**. Remote projects remain a technical preview, and Execute uses the selected SSH account’s permissions.", + "Remote users should upgrade to **Desktop 0.13.8** before downloading and updating to **Agent 0.13.2**. Remote projects remain a technical preview, and Execute uses the selected SSH account’s permissions.", "Standard desktop packages cover Windows, macOS, and Linux on x64/arm64, without a LoongArch experimental preview." ] } diff --git a/src/main/assistant-storage-startup.ts b/src/main/assistant-storage-startup.ts index 1eb2cd20..87b61ec4 100644 --- a/src/main/assistant-storage-startup.ts +++ b/src/main/assistant-storage-startup.ts @@ -10,6 +10,7 @@ import { } from '../shared/assistant-storage-contracts' import { ipcChannels } from '../shared/ipc-channels' import { assertTrustedSender } from './trusted-ipc-sender' +import { hasPendingAssistantStorageUpgrade } from './assistant/assistant-storage-upgrade' import { loadMainWindow } from './window' export async function prepareAssistantStorage( @@ -22,6 +23,7 @@ export async function prepareAssistantStorage( throw error }) if (!file) return + if (!hasPendingAssistantStorageUpgrade(databasePath)) return let progress: AssistantStorageProgress = { stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size } diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts index fcef2c58..b5afdcec 100644 --- a/src/main/assistant/assistant-storage-upgrade.test.ts +++ b/src/main/assistant/assistant-storage-upgrade.test.ts @@ -4,11 +4,14 @@ import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ConversationMessageBlock } from '../../shared/assistant-contracts' import type { SubagentEvent } from '../../shared/contracts' import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database' -import { upgradeAssistantStorage } from './assistant-storage-upgrade' +import { + hasPendingAssistantStorageUpgrade, + upgradeAssistantStorage +} from './assistant-storage-upgrade' import { compactSubagentPayload, restoreSubagentPayload, @@ -18,6 +21,7 @@ import { const directories: string[] = [] const databases: AssistantDatabase[] = [] afterEach(async () => { + vi.restoreAllMocks() for (const database of databases.splice(0)) database.close() await Promise.all(directories.splice(0).map( (path) => rm(path, { recursive: true, force: true }) @@ -50,6 +54,40 @@ function subagent(requestId: string): SubagentEvent { } describe('subagent progress storage', () => { + it('does not migrate or vacuum ordinary chat free pages on repeated startup', async () => { + const { path, database } = await fixture() + const header = { id: randomUUID(), title: 'Chat restart', updatedAt: 1000 } + const message = { + id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, + content: 'Long draft'.repeat(100_000), createdAt: 1000 + } + database.saveLocalConversations([{ header, messages: [message] }]) + database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }]) + const expected = database.getConversation(header.id) + database.close() + const sql = new DatabaseSync(path) + try { + const free = sql.prepare('PRAGMA freelist_count').get()!.freelist_count + expect(free).toBeGreaterThan(0) + expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) + expect(hasPendingAssistantStorageUpgrade(path)).toBe(false) + const exec = vi.spyOn(DatabaseSync.prototype, 'exec') + for (let restart = 0; restart < 2; restart++) { + const progress = vi.fn() + upgradeAssistantStorage(path, progress) + expect(progress).not.toHaveBeenCalled() + expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false) + const reopened = new AssistantDatabase(path) + try { + reopened.initialize(tmpdir()) + expect(reopened.getConversation(header.id)).toEqual(expected) + } finally { reopened.close() } + } + expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBe(free) + expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + } finally { sql.close() } + }) + it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => { const event = subagent(randomUUID()) const textId = randomUUID() diff --git a/src/main/assistant/assistant-storage-upgrade.ts b/src/main/assistant/assistant-storage-upgrade.ts index 82280ec9..5300c91e 100644 --- a/src/main/assistant/assistant-storage-upgrade.ts +++ b/src/main/assistant/assistant-storage-upgrade.ts @@ -2,18 +2,64 @@ import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import type { ConversationMessageBlock } from '../../shared/assistant-contracts' import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts' -import { AssistantDatabase } from './assistant-database' +import { + ASSISTANT_DATABASE_SCHEMA_VERSION, + AssistantDatabase +} from './assistant-database' import { compactSubagentPayload, restoreSubagentPayload, SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION } from './subagent-progress-storage' +export function hasPendingAssistantStorageUpgrade( + databasePath: string +): boolean { + const database = new DatabaseSync(databasePath, { + readOnly: true, + timeout: 5_000 + }) + try { + const row = database.prepare('PRAGMA user_version').get() as { + user_version: number + } + if (row.user_version < ASSISTANT_DATABASE_SCHEMA_VERSION) { + return true + } + const noteTable = database.prepare( + `SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = 'magic_note_entries' + LIMIT 1` + ).get() + if (!noteTable) return false + const noteEntry = database.prepare( + 'SELECT 1 FROM magic_note_entries LIMIT 1' + ).get() + if (!noteEntry) return false + const hasLegacyPayload = Boolean(database.prepare( + `SELECT 1 + FROM magic_note_entries + WHERE json_extract(content_json, '$.storage') IS NULL + OR json_extract(content_json, '$.storage') <> 'file' + LIMIT 1` + ).get()) + if (hasLegacyPayload) return true + const free = database.prepare('PRAGMA freelist_count').get() as { + freelist_count: number + } + return free.freelist_count > 0 + } finally { + database.close() + } +} + export function upgradeAssistantStorage( databasePath: string, onProgress: (progress: AssistantStorageProgress) => void, isCancelled: () => boolean = () => false ): void { + if (!hasPendingAssistantStorageUpgrade(databasePath)) return upgradeSubagentStorage(databasePath, onProgress, isCancelled) new AssistantDatabase(databasePath).upgradeMagicNoteStorage(onProgress, isCancelled) } diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 824cf14a..2083d5fe 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -126,6 +126,75 @@ describe('RuntimeSettingsStore', () => { expect((await reloaded.update({ ...input, modelProfiles: [text], defaultImageModelProfileId: null })).defaultImageModelProfileId).toBeNull() }) + it('copies an existing model credential to a new profile without exposing it', async () => { + const { store } = await createStore() + const initial = await store.getPublicSettings() + const source = initial.modelProfiles[0]! + const copiedId = '00000000-0000-4000-8000-000000000123' + await store.update( + runtimeSettingsInputSchema.parse({ + ...settings(), + modelAuthentication: 'api-key', + modelProfiles: [ + { + id: source.id, + name: source.name, + baseUrl: source.baseUrl, + modelName: source.modelName, + protocol: source.protocol, + authentication: 'api-key', + imageGenerationQuality: source.imageGenerationQuality, + apiKey: { action: 'replace', value: 'source-secret' } + } + ], + defaultModelProfileId: source.id + }) + ) + const copied = await store.update( + runtimeSettingsInputSchema.parse({ + ...settings(), + modelAuthentication: 'api-key', + modelProfiles: [ + { + id: source.id, + name: source.name, + baseUrl: source.baseUrl, + modelName: source.modelName, + protocol: source.protocol, + authentication: 'api-key', + imageGenerationQuality: source.imageGenerationQuality, + apiKey: { action: 'keep' } + }, + { + id: copiedId, + name: 'Copied model', + baseUrl: source.baseUrl, + modelName: source.modelName, + protocol: source.protocol, + authentication: 'api-key', + imageGenerationQuality: source.imageGenerationQuality, + apiKey: { + action: 'copy', + sourceProfileId: source.id + } + } + ], + defaultModelProfileId: source.id + }) + ) + expect(copied.modelProfiles.find((profile) => profile.id === copiedId)) + .toMatchObject({ + apiKeyConfigured: true, + credentialSource: 'encrypted' + }) + expect(JSON.stringify(copied)).not.toContain('source-secret') + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + modelProfiles: expect.arrayContaining([ + expect.objectContaining({ id: copiedId, apiKey: 'source-secret' }) + ]) + }) + }) + it('rejects malformed conversation image settings', () => { expect(runtimeSettingsInputSchema.safeParse(settings({ defaultImageModelProfileId: 'not-a-uuid' })).success).toBe(false) expect(runtimeSettingsInputSchema.safeParse({ ...settings(), modelProfiles: [{ id: '00000000-0000-4000-8000-000000000001', name: 'Image', baseUrl: 'https://example.com', modelName: 'image', protocol: 'openai-images-generations', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'keep' }, allowConversationInvocation: 'true' }] }).success).toBe(false) diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index bf12166c..e5d30b78 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -2230,7 +2230,23 @@ export class RuntimeSettingsStore { requestHeaders: profile.requestHeaders ?? {}, requestBody: profile.requestBody ?? {} } - if ( + if (profile.apiKey.action === 'copy') { + const sourceProfileId = profile.apiKey.sourceProfileId + const source = current.modelProfiles.find( + (candidate) => candidate.id === sourceProfileId + ) + if (!source) { + throw new Error('复制凭据的源模型连接不存在,请重新选择连接') + } + const environmentKey = source.id === current.defaultModelProfileId + ? this.getEnvironmentApiKey() + : undefined + if (environmentKey) { + nextProfile.credential = encryptSavedApiKey(this.cipher, environmentKey) + } else if (source.credential) { + nextProfile.credential = source.credential + } + } else if ( profile.apiKey.action === 'keep' && existing?.credential ) { diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 072558a0..5f78b37e 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1561,12 +1561,15 @@ describe("App", () => { await screen.findByRole("button", { name: /全项目活动/u }); fireEvent.click(await screen.findByRole('button', { name: '设置' })); await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } }); fireEvent.click(screen.getByRole("button", { name: /全项目活动/u })); fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u })); fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u })); expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible(); - expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改"); + expect(screen.getAllByRole("alert").some((alert) => + alert.textContent?.includes("当前设置有未保存更改") + )).toBe(true); expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project"); expect(screen.getByLabelText("默认工作区目录")).toHaveValue("C:\\Unsaved activity draft"); fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" })); @@ -5444,6 +5447,7 @@ describe("App", () => { fireEvent.click(await screen.findByRole('button', { name: '设置' })); await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved from App" }, }); @@ -5453,7 +5457,9 @@ describe("App", () => { fireEvent.click(knowledgeNavigation); expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible(); - expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改"); + expect(screen.getAllByRole("alert").some((alert) => + alert.textContent?.includes("当前设置有未保存更改") + )).toBe(true); fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" })); expect( await screen.findByRole("heading", { name: "知识库" }), diff --git a/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx b/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx index c84b92c7..9e95b4e8 100644 --- a/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx +++ b/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx @@ -29,38 +29,40 @@ export function CapabilitiesAndToolsSettingsSection({ + { + setActiveTab(tab) + if (tab === 'mcp') { + setMcpVisited(true) + } + if (tab === 'tools') { + setToolsVisited(true) + } + }} + tabs={[ + { + id: 'skills', + label: t('capabilities.tabs.skills') + }, + { + id: 'mcp', + label: t('capabilities.tabs.mcp') + }, + { + id: 'tools', + label: t('capabilities.tabs.tools') + } + ]} + value={activeTab} + variant="segmented" + /> + + } /> -
- { - setActiveTab(tab) - if (tab === 'mcp') { - setMcpVisited(true) - } - if (tab === 'tools') { - setToolsVisited(true) - } - }} - tabs={[ - { - id: 'skills', - label: t('capabilities.tabs.skills') - }, - { - id: 'mcp', - label: t('capabilities.tabs.mcp') - }, - { - id: 'tools', - label: t('capabilities.tabs.tools') - } - ]} - value={activeTab} - variant="segmented" - /> -
+ + + } />
-
- -
-
{ cleanup(); vi.unstubAllGlobals() }) -it('moves the MCP introduction to title help and retains the custom server notice', async () => { +it('removes the redundant MCP title and places the custom action in content', async () => { const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] }) vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } }) render() await waitFor(() => expect(getSnapshot).toHaveBeenCalledOnce()) - expect(screen.getByRole('heading', { name: i18n.t('integrations:mcp.title') }).closest('header')?.querySelector('p')).toBeNull() + expect( + screen.queryByRole('heading', { + name: i18n.t('integrations:mcp.title') + }) + ).not.toBeInTheDocument() expect(screen.queryByText(i18n.t('integrations:mcp.description'))).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.title') })) - expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('integrations:mcp.description')) - expect(screen.getByRole('tooltip').querySelector('button')).toBeNull() fireEvent.click(screen.getByRole('tab', { name: i18n.t('integrations:mcp.tabs.custom') })) expect(screen.getByText(i18n.t('integrations:mcp.customNotice'))).toBeVisible() + expect( + screen.getByRole('button', { + name: i18n.t('integrations:mcp.addServer') + }) + ).toBeVisible() }) diff --git a/src/renderer/src/McpSettingsSection.tsx b/src/renderer/src/McpSettingsSection.tsx index 352df035..e083c259 100644 --- a/src/renderer/src/McpSettingsSection.tsx +++ b/src/renderer/src/McpSettingsSection.tsx @@ -31,10 +31,6 @@ import type { WebSearchTestResult } from '../../shared/capability-contracts' import { trapTabFocus } from './dialog-focus' -import { - SettingsSectionHeader, - SettingsWarningList -} from './SettingsPrimitives' import { PageTabs } from './WorkspacePrimitives' const configurableMcpTargets: RuntimeTarget[] = [ @@ -393,29 +389,6 @@ export function McpSettingsSection({ return ( <> - - openEditor({ ...emptyEditor }, event.currentTarget) - } - type="button" - > -
) } diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index b9c1f4df..d0147f98 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -135,6 +135,7 @@ export const settings = { discardAndNavigate: 'Discard changes and continue' }, errors: { + invalidModelUrl: 'Model connection “{{name}}” has an invalid endpoint URL. Enter a complete HTTP or HTTPS URL (up to 2048 characters).', readSettings: 'Could not load settings', detectRuntimes: 'Could not detect Agent Runtimes', readEmbeddingStatus: 'Could not load embedding model status', @@ -718,6 +719,12 @@ export const settings = { }, profile: { seededDefaultName: 'Default model', + copy: 'Copy connection', + copyAriaLabel: 'Copy model connection {{name}}', + copySuffix: ' (copy {{index}})', + copyDescription: 'Copy the current configuration and API key; changes take effect when saved', + credentialCopyPending: 'The source API key will be copied on save', + advanced: 'Advanced settings', generatedName: 'Model connection {{count}}', title: 'LLM model connections', description: diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index c5603318..df4bb11d 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -121,6 +121,7 @@ export const settings = { discardAndNavigate: '放弃更改并切换' }, errors: { + invalidModelUrl: '模型连接“{{name}}”的模型接口 URL 无效,请填写完整的 HTTP 或 HTTPS 地址(最多 2048 个字符)。', readSettings: '读取设置失败', detectRuntimes: 'Runtime 自动检测失败', readEmbeddingStatus: '读取向量模型状态失败', @@ -663,6 +664,12 @@ export const settings = { }, profile: { seededDefaultName: '默认模型', + copy: '复制连接', + copyAriaLabel: '复制模型连接 {{name}}', + copySuffix: '(副本 {{index}})', + copyDescription: '复制当前配置和 API Key,保存后生效', + credentialCopyPending: '保存时复制源连接的 API Key', + advanced: '高级设置', generatedName: '模型连接 {{count}}', title: 'LLM 模型连接', description: diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index ca3d970b..3bf58eb8 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -8403,6 +8403,21 @@ button > svg { min-width: 0; } +.settings-category-header--sticky { + position: sticky; + top: 0; + z-index: 1; + flex: 0 0 auto; + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-raised); +} + +.settings-category-header__navigation { + min-width: 0; + grid-column: 1 / -1; +} + .settings-category-header__content :is(h2, h3) { margin: 0; color: var(--text-primary); @@ -9220,6 +9235,9 @@ button > svg { .model-type-navigation { display: flex; + min-width: 0; + flex: 0 0 auto; + overflow-x: auto; align-items: center; justify-content: space-between; gap: var(--space-3); @@ -10274,6 +10292,17 @@ details.settings-section > :not(summary) + :not(summary) { flex-wrap: wrap; gap: var(--space-2); } +.settings-content-actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--space-2); +} +.settings-content-actions button { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} .feedback-entry-card__actions { display: flex; diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 5f85da73..5b2ff911 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -617,11 +617,27 @@ export { export const defaultAnthropicMaximumOutputTokens = 32_000 export const maximumModelOutputTokens = 10_000_000 +export const modelConnectionUrlSchema = z + .string() + .trim() + .min(1, '模型服务地址不能为空') + .max(2_048, '模型服务地址不能超过 2048 个字符') + .refine( + (value) => { + try { + return ['http:', 'https:'].includes(new URL(value).protocol) + } catch { + return false + } + }, + '模型服务地址必须是完整的 HTTP 或 HTTPS 地址' + ) + const modelProfileInputSchema = z .object({ id: modelProfileIdSchema, name: z.string().trim().min(1).max(64), - baseUrl: z.string().url().max(2_048), + baseUrl: modelConnectionUrlSchema, modelName: z .string() .trim() @@ -647,7 +663,13 @@ const modelProfileInputSchema = z imageGenerationQuality: imageGenerationQualitySchema, requestHeaders: modelRequestHeadersSchema.optional(), requestBody: modelRequestBodySchema.optional(), - apiKey: modelApiKeyUpdateSchema + apiKey: z.union([ + modelApiKeyUpdateSchema, + z.object({ + action: z.literal('copy'), + sourceProfileId: modelProfileIdSchema + }).strict() + ]) }) .strict() @@ -694,7 +716,7 @@ export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [ export const runtimeSettingsInputSchema = z .object({ provider: runtimeProviderSchema, - modelBaseUrl: z.string().url().max(2_048), + modelBaseUrl: modelConnectionUrlSchema, modelName: z .string() .trim() @@ -766,19 +788,6 @@ export const runtimeSettingsInputSchema = z message: '无认证模型连接不得配置 API Key' }) } - const endpoints = settings.modelProfiles?.map((profile, index) => ({ - path: ['modelProfiles', index, 'baseUrl'] as (string | number)[], - value: profile.baseUrl - })) ?? [{ path: ['modelBaseUrl'], value: settings.modelBaseUrl }] - for (const endpoint of endpoints) { - if (!['http:', 'https:'].includes(new URL(endpoint.value).protocol)) { - context.addIssue({ - code: 'custom', - path: endpoint.path, - message: '模型服务地址必须使用 HTTP 或 HTTPS' - }) - } - } if (settings.modelProfiles) { for (const [index, profile] of settings.modelProfiles.entries()) { if ( diff --git a/tests/support/inline-help-driver.mjs b/tests/support/inline-help-driver.mjs index 8b86941a..18783513 100644 --- a/tests/support/inline-help-driver.mjs +++ b/tests/support/inline-help-driver.mjs @@ -51,6 +51,8 @@ app.whenReady().then(async () => { await settle() } const key = async keyCode => { + win.focus() + win.webContents.focus() win.webContents.sendInputEvent({ type: 'keyDown', keyCode }) win.webContents.sendInputEvent({ type: 'keyUp', keyCode }) await settle() @@ -78,7 +80,9 @@ app.whenReady().then(async () => { await key('Escape') await wait('!document.querySelector("[role=tooltip]")') assert(await js('!!document.querySelector("[aria-modal=true]")'), `Hover Escape closed modal: ${theme} ${width}x${height}`) - await mouse('#outside') + // Re-establish a native keyboard starting point after hover-only Escape. + await mouse('#outside', true) + assert(await js('document.activeElement.id === "outside"')) await key('Tab') await wait('!!document.querySelector("[role=tooltip]")') assert(await js('document.activeElement.matches(".inline-help")')) @@ -199,6 +203,9 @@ app.whenReady().then(async () => { await wait(`!!document.querySelector('${context}')`) assert.equal(await js(`document.querySelector('${context}').value`), '128') checked.push(await checkHelp('.inline-help[aria-label="上下文上限(可选)"]', '.settings-panel', context)) + assert(await js('!document.querySelector(".model-connection-detail details.settings-section").open'), 'Advanced model settings should start collapsed') + await mouse('.model-connection-detail details.settings-section > summary', true) + await wait('document.querySelector(".model-connection-detail details.settings-section").open') checked.push(await checkHelp('.inline-help[aria-label="自定义请求体"]', '.settings-panel', '#model-request-body-' + profileId)) for (const applicationHelp of checked.slice(0, 2)) { assert.deepEqual(applicationHelp.appearance, checked[2].appearance, 'Application/settings help button styles differ')