Compare commits
39
Commits
377b14fca6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47306c5b0e | ||
|
|
a63060e097 | ||
|
|
3ba1ef305d | ||
|
|
af86e2f8a1 | ||
|
|
5e255747ee | ||
|
|
2e204e6a4b | ||
|
|
05838d2c50 | ||
|
|
89bf5840aa | ||
|
|
a70fb2c13b | ||
|
|
b22be5146b | ||
|
|
4edc34c625 | ||
|
|
6694aa141e | ||
|
|
bcb1ebbe62 | ||
|
|
ae46cccc20 | ||
|
|
8b10ed583e | ||
|
|
f2f4c1273e | ||
|
|
ba30852058 | ||
|
|
12a0291279 | ||
|
|
3ebb4f0b79 | ||
|
|
c7f09e1e53 | ||
|
|
d9dcb65d10 | ||
|
|
1044a32b3b | ||
|
|
c6a8455f88 | ||
|
|
e07a17f79f | ||
|
|
4205ad6317 | ||
|
|
8f1b600798 | ||
|
|
dc582b7acf | ||
|
|
46a40e7fbf | ||
|
|
3556df8895 | ||
|
|
d7fb4299a3 | ||
|
|
4b4531abeb | ||
|
|
69f64df279 | ||
|
|
adb58d9200 | ||
|
|
47917094ad | ||
|
|
e2a5f83666 | ||
|
|
670b598530 | ||
|
|
1a59c2d880 | ||
|
|
13b58627c0 | ||
|
|
4266ff5bea |
@@ -206,8 +206,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends --yes rpm
|
||||
sudo apt-get install --no-install-recommends --yes rpm file
|
||||
rpmbuild --version
|
||||
file --version
|
||||
|
||||
- name: Resolve macOS signing mode
|
||||
id: macos-signing
|
||||
@@ -312,6 +313,15 @@ jobs:
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
|
||||
|
||||
- name: Verify AppImage uses SquashFS
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
appimage="$(find "dist/release/${{ matrix.platform }}-${{ matrix.arch }}" -maxdepth 1 -type f -name '*.AppImage' -print -quit)"
|
||||
test -n "$appimage"
|
||||
file "$appimage" | grep -qi squashfs
|
||||
|
||||
- name: Upload release packages
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'sites/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/pages.yml'
|
||||
|
||||
permissions:
|
||||
@@ -29,8 +31,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
|
||||
- name: Validate website
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm run docs:build
|
||||
node sites/scripts/validate.mjs
|
||||
node --check sites/app.js
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ shareserver/build-server/
|
||||
coverage/
|
||||
/temp/
|
||||
.vite/
|
||||
sites/docs-dist/
|
||||
sites/docs/.vitepress/
|
||||
.runtime-resources/
|
||||
.remote-runtime-resources/
|
||||
.agent-resources/
|
||||
|
||||
+25
-5
@@ -291,8 +291,8 @@ records are listed separately and do not introduce another feature status.
|
||||
Runtime activation reuses the registry and manifest verified during setup
|
||||
instead of rehashing or rechecking the complete OpenCode binary.
|
||||
- [x] **Managed SSH OpenCode and Continue**: Controlled by the
|
||||
separate Remote Projects tab under Settings / Platform
|
||||
Features and disabled by default. Disabling it does not affect local
|
||||
Enable remote capability switch in the Hosts application settings
|
||||
and disabled by default. Disabling it does not affect local
|
||||
projects, ordinary desktop capabilities, or desktop releases. When enabled,
|
||||
users can manage SSH Hosts with pinned Host Keys, browse bounded remote
|
||||
directories, and create remote projects. Requests start the signed Runtime
|
||||
@@ -983,9 +983,27 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.13`; Agent candidate `0.15.6` requires Desktop `0.15.13`, with
|
||||
- The current Desktop candidate is `0.16.3`; Agent `0.15.6` remains paired with Desktop `0.16.3`, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.16.3` moves remote-host management into the Apps center, adds a chat-history minimap
|
||||
with an Appearance switch, and improves navigation, waiting-state feedback, graph zoom, and
|
||||
attachment boundaries. Hosts shows locally available Agent packages before refreshing remote
|
||||
information, and schema remains unchanged.
|
||||
- Desktop `0.16.2` adds an all-time Supervisor review range and waits for messages and tasks to
|
||||
settle before including them in a review. It also improves Magic Notes action labels, contains
|
||||
terminal IME composition layout, and upgrades the database to schema 63.
|
||||
- Desktop `0.16.1` smooths the message-to-composer transition: messages extend to the composer
|
||||
outline before fading, and light/dark theme backgrounds and focus boundaries no longer create
|
||||
an abrupt solid band above the input. Schema remains unchanged.
|
||||
- Desktop `0.16.0` moves expert and team-role selection into the composer and persists the
|
||||
selection per conversation. Historical messages retain the role used for their request,
|
||||
including after a role is renamed or removed; schema remains unchanged.
|
||||
- Desktop `0.15.14` restores continuation for legacy Supervisor reviews that failed because a
|
||||
source changed or was removed. Sources that keep changing are re-frozen only within a bounded
|
||||
number of attempts and then omitted so the review can finish while retaining surviving progress.
|
||||
Settings categories also show matching icons to make the settings page easier to scan. Schema
|
||||
remains 62.
|
||||
- Desktop `0.15.13` / Agent `0.15.6` move desktop business SQLite ownership into
|
||||
one typed storage utility process and keep Remote Runtime changes coordinated.
|
||||
Assistant, Knowledge, attachments, runtime bindings and ledgers retain their
|
||||
@@ -1209,8 +1227,10 @@ records are listed separately and do not introduce another feature status.
|
||||
offline GoodBuddy transfer release acceptance. Until these gates pass, this
|
||||
path must not be described as published or as having passed formal release
|
||||
acceptance.
|
||||
- [ ] **Host and cloud environment management** (planned): Move host management
|
||||
into App Center with a list/detail workspace. A sibling cloud application
|
||||
- [ ] **Host and cloud environment management** (partly done): On 2026-10-13 host
|
||||
management moved into App Center as the Hosts application, with the host list
|
||||
and Agent packages; see [progress](./docs/features/remote-host/progress.md).
|
||||
The list/detail workspace is still planned. A sibling cloud application
|
||||
reuses host connections and remote execution. The first-version adapter plan
|
||||
covers AWS, Azure, Alibaba Cloud, Tencent Cloud, VMware vCenter / vSphere,
|
||||
and ZStack, delivered in batches with real instance discovery, import, and
|
||||
|
||||
+9
-3
@@ -53,7 +53,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数按当前账号权限发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并应用多文件补丁;读取和补丁 API 保留工作区相对路径合同。用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
|
||||
- [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,不要求开机服务。请求使用所选 SSH 账号权限和已接入能力,无产品模式或一般工具审批。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.5`,配套 Desktop 候选为 `0.15.12`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由应用中心“主机管理 > 应用设置”中的“启用远程能力”开关控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。请求使用所选 SSH 账号权限和已接入能力,无产品模式或一般工具审批。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.5`,配套 Desktop 候选为 `0.15.12`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 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。
|
||||
@@ -159,7 +159,13 @@
|
||||
### 开源、构建与发布
|
||||
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.13`,Agent 候选为 `0.15.6`、最低要求 Desktop `0.15.13`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- 当前 Desktop 候选为 `0.16.3`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.3`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.16.3` 将远程主机管理迁移到应用中心,增加可在外观设置中关闭的聊天历史缩略图,并改进导航、等待状态、图谱缩放和附件边界;schema 保持不变。
|
||||
- Hosts 会在刷新远程信息前先显示本地已有的 Agent 包,刷新过程中不会暂时隐藏这些包。
|
||||
- Desktop `0.16.2` 增加全部时间的监督回顾范围,并等待消息和任务完成后再纳入回顾;同时改进 Magic Notes 操作标签、修复终端输入法布局,数据库升级到 schema 63。
|
||||
- Desktop `0.16.1` 平滑消息与输入框的过渡:消息会延伸到输入框轮廓前再淡出,浅色和深色主题的背景与焦点边界也不再在输入框上方形成突兀的实色横带;schema 保持不变。
|
||||
- Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。
|
||||
- Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。
|
||||
- Desktop `0.15.13` / Agent `0.15.6` 将桌面业务 SQLite 的唯一 owner 移到一个类型化 storage utility process,Assistant、Knowledge、附件、运行绑定和账本保留原路径及事务语义。直连模型在工具失败后可继续,分页输出、Magic Notes 标签、监督来源别名、设置直达预加载和文件元数据同步修正;schema 仍为 62。storage/监督 focused 编排已通过,但 C01–C07 并发验收矩阵、跨平台 storage 进程安装和低端机对照仍是明确后续门槛,不作通用性能承诺。见[发布准备](./docs/development/release-preparation-0.15.13.md)。
|
||||
- Desktop `0.15.12` / Agent `0.15.5` 增加 Telegram 私聊配置(真实 Bot 与模型完整验收仍待完成)和本地日期自定义回顾。监督图谱/来源按需读取、索引 Worker 查询、摘要缓存和选择列表窗口化减少重复工作,最终发布仍可阻塞 Main;未发布回顾省略已删除会话并重算受影响批次,历史结果保留。临时模型失败最多多试一次,可能增加费用;远程启动取消、通道交付、长消息展开状态及历史缺失时长得到修正。schema 62 增加通道支持和查询索引,升级前备份完整数据目录,降级需恢复备份。两个独立开发审计阶段共记录 15 次外部文本请求,本次发布准备没有新增;精确源码 CI 与原生打包仍为门槛。见[发布准备](./docs/development/release-preparation-0.15.12.md)。
|
||||
- Desktop `0.15.11` / Agent `0.15.4` 移除 Ask/Execute 与通用工具审批,旧 Ask 会话也不再有产品级只读边界。schema 60 和 Runtime 设置 22 清理退休元数据,升级前备份完整数据目录,降级需恢复备份;远程需配套 runtime/acp 6 工件。工作区导入、文件时间排序、会话图片发现和远程专家保存随成果页移除一起提供,不删除既有成果。执行时长按运行状态计时、不补算旧历史,OpenCode 缓存率采用完整输入量。回顾历史、响应布局及螺旋实际区间着色保留未归属事件与中性空档;汇总读取和回顾初始化减少限定路径 I/O,不承诺普遍提速。开发证据含无模式执行与专家图片保存共 25 次真实文本请求,不是本次发布准备新调用或图片生成;精确候选 CI、签名包、公开验证和跨版本活动操作恢复与这些检查分开记录。见[发布准备](./docs/development/release-preparation-0.15.11.md)。
|
||||
@@ -194,7 +200,7 @@
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- **SSH 主机与远程执行空间历史验证记录**:当时已接线主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验。Linux x64 当时源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包;现有记录未覆盖当前 Linux x64/arm64 正式签名工件的完整公开核对及 GitHub/北京镜像、双架构和离线 GoodBuddy 传输矩阵,发布结果另行记录。
|
||||
- [ ] **主机管理与云环境管理**(规划中):主机管理迁入应用中心,提供列表与详情工作页面;云环境管理作为同级应用,复用主机连接和执行能力。首版适配计划覆盖 AWS、Azure、阿里云、腾讯云、VMware vCenter / vSphere、ZStack,分批完成已有实例发现、接入及真实执行验收;实例创建、启停、销毁和网络编排后续交付。平台 API 尚未实现,现有云主机可按 SSH 条件手工接入。详见[产品范围](./docs/features/remote-host/prd.md)。
|
||||
- [ ] **主机管理与云环境管理**(部分完成):2026-10-13 主机管理已迁入应用中心,提供主机列表与 Agent 离线包,见[实施进度](./docs/features/remote-host/progress.md);列表与详情两栏仍待实现;云环境管理作为同级应用,复用主机连接和执行能力。首版适配计划覆盖 AWS、Azure、阿里云、腾讯云、VMware vCenter / vSphere、ZStack,分批完成已有实例发现、接入及真实执行验收;实例创建、启停、销毁和网络编排后续交付。平台 API 尚未实现,现有云主机可按 SSH 条件手工接入。详见[产品范围](./docs/features/remote-host/prd.md)。
|
||||
|
||||
## 规划原则
|
||||
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@
|
||||
- 页面顶部和底部内边距默认为 `32px`。
|
||||
- `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
|
||||
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
|
||||
- 输入区顶部向消息区延伸 `--space-4`(16px)的透明到 `--surface-raised` 渐变,柔化消息裁切边缘;渐变不占布局空间、不拦截鼠标事件,并随主题切换。
|
||||
- 输入区外层不铺整块背景、不画横向分隔线。输入区以上内边距的高度向上叠入消息区,输入框上方保留 `--space-4`(16px)的透明到页面底色渐变,渐变终点对齐输入框上缘,让滚动的消息一直延伸到输入框轮廓前再淡出;输入框上缘以下才使用实色底;渐变位于输入框之下、不拦截鼠标事件,并随主题切换。输入框上缘只保留自身 1px 描边,不加向上的阴影,避免与渐变终点叠成粗边界。
|
||||
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
|
||||
|
||||
## 5. 信息层级
|
||||
|
||||
@@ -1419,6 +1419,31 @@ function verifyArtifacts(directory, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAppImageArtifact(directory, options) {
|
||||
if (!options.formats.includes('AppImage')) {
|
||||
return
|
||||
}
|
||||
const candidates = readdirSync(directory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.AppImage'))
|
||||
.map((entry) => entry.name)
|
||||
if (candidates.length !== 1) {
|
||||
throw new Error(
|
||||
`AppImage 产物数量错误:${candidates.join(', ') || '未生成'}`
|
||||
)
|
||||
}
|
||||
const artifactName =
|
||||
`${productName}-${packageJson.version}-` +
|
||||
`${options.arch === 'x64' ? 'x86_64' : 'arm64'}.AppImage`
|
||||
const source = join(directory, candidates[0])
|
||||
const destination = join(directory, artifactName)
|
||||
if (source !== destination) {
|
||||
if (existsSync(destination)) {
|
||||
throw new Error(`AppImage 目标文件已存在:${artifactName}`)
|
||||
}
|
||||
renameSync(source, destination)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyArtifactSignature(filePath, format, arch) {
|
||||
if (format === 'nsis') {
|
||||
if (readChunk(filePath, 2).toString('ascii') !== 'MZ') {
|
||||
@@ -1656,6 +1681,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
builderArguments,
|
||||
electronBuilderEnvironment(options)
|
||||
)
|
||||
normalizeAppImageArtifact(stagingDirectory, options)
|
||||
const unpackedDirectory = verifyUnpackedOutput(
|
||||
stagingDirectory,
|
||||
options
|
||||
@@ -1718,6 +1744,7 @@ module.exports = {
|
||||
verifyUnpackedOutput,
|
||||
verifyArtifacts,
|
||||
verifyArtifactSignature,
|
||||
normalizeAppImageArtifact,
|
||||
verifyPortableZip,
|
||||
writeManifest
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Desktop 0.15.14 发布准备
|
||||
|
||||
日期:2026-10-09。基线 `v0.15.13`,候选源码 `8f1b6007986f31fb5e2a80e80021602a13fa20a2`。
|
||||
本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。标准六平台 Desktop,
|
||||
不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 恢复旧版因监督来源变化或移除而失败的历史回顾的继续操作。
|
||||
- 来源在回顾期间变化时,在有限次数内重新冻结处理;持续变化的来源之后省略,
|
||||
已删除、归档或不可用来源沿用既有省略规则,其他来源继续保留进度。
|
||||
- 保留暂停、失败、取消和已保存批次的监督活动状态及项目范围、来源处理规则。
|
||||
- 设置分类显示对应图标,帮助用户更快找到各类设置。
|
||||
- 不新增数据库 schema,继续使用 schema 62;Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本候选包含监督存储、Worker、服务和 Renderer 回归测试,以及历史失败回顾继续操作的
|
||||
界面覆盖。完整并发验收矩阵、跨平台存储进程安装和其他未完成验收不在本候选中宣称通过。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再由用户确认后创建不可变的 `v0.15.14` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Desktop 0.16.0 发布准备
|
||||
|
||||
日期:2026-10-09。基线 `v0.15.14`,候选源码为本次发布准备提交。
|
||||
本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。标准六平台 Desktop,
|
||||
不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 专家和团队角色选择移动到 Composer 输入框顶部,发送前更容易发现。
|
||||
- 角色选择按会话保存,切换会话不会带入其他会话的选择。
|
||||
- 历史消息保留请求发送时使用的角色,即使角色后来重命名或删除,历史上下文仍可识别。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本候选包含 Composer、会话选择状态、历史消息元数据和角色选择的回归测试。
|
||||
完整跨平台安装、远程 Host 和未覆盖的整包验收仍以 CI 与发布工作流结果为准。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再由用户确认后创建不可变的 `v0.16.0` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,18 @@
|
||||
# Desktop 0.16.1 发布准备
|
||||
|
||||
日期:2026-10-10。基线 `v0.16.0`,候选源码包含 `d9dcb65d` 和 `c7f09e1e`
|
||||
两个 Composer 视觉修复提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
|
||||
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 消息内容延伸到 Composer 输入框轮廓前再淡出,不再被输入区上方的实色区域提前截断。
|
||||
- 浅色和深色主题使用匹配的消息到输入框渐变,输入框焦点边界不再形成粗边。
|
||||
- 滚动到底部按钮保持与新的输入区过渡相配的可见位置。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.1` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Desktop 0.16.2 发布准备
|
||||
|
||||
日期:2026-10-10。基线 `v0.16.1`,候选源码包含 `12a02912`、`ba308520`
|
||||
和 `f2f4c127` 三个提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
|
||||
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 监督回顾增加从最早记录到当前时间的全部时间范围。
|
||||
- 监督回顾只读取已完成消息和任务,实时生成中的内容等待完成后再进入回顾。
|
||||
- Magic Notes 操作入口增加文字标签,次要更多操作按需显示。
|
||||
- 终端输入法组合区域保持在终端边界内。
|
||||
- 数据库 schema 从 62 升到 63;Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 升级须知
|
||||
|
||||
首次启动会执行 schema 63 数据库升级。升级前关闭应用并备份完整数据目录、
|
||||
相邻 `notes/` 和设置文件。旧客户端回退需要恢复升级前备份。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.2` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Desktop 0.16.3 发布准备
|
||||
|
||||
日期:2026-10-14。基线 `v0.16.2`,候选源码包含 `ae46cccc` 至 `af86e2f8`
|
||||
之间的已提交变更。本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。
|
||||
标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 将远程主机管理迁移到应用中心,提供 Hosts 和 Agent 包页签;原有远程项目和连接配置继续保留。
|
||||
- Hosts 先显示本地已有的 Agent 包,再刷新远程信息,避免刷新期间隐藏本地库存。
|
||||
- 聊天历史增加可关闭的缩略图导航,保留连续历史加载和窗口化显示。
|
||||
- 改进应用中心、设置和项目菜单导航,等待状态、监督图谱缩放和附件选择边界。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 工作区边界
|
||||
|
||||
开始准备时工作区包含用户未提交的 Hosts 文档修改:
|
||||
`docs/features/application-tool-navigation/README.md` 和未跟踪的
|
||||
`docs/features/external-apps/`。这些内容不属于候选,不得覆盖或提交。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,再确认候选提交并推送到
|
||||
`origin` 与 `github`。本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -272,7 +272,7 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务),验证范围见[实施进度](./progress.md)。
|
||||
原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
|
||||
|
||||
### FR-17 主机管理与云环境管理(待实现)
|
||||
### FR-17 主机管理与云环境管理(主机管理已实现)
|
||||
|
||||
2026-09-28 确认新增两个同级应用:“主机管理”和“云环境管理”。主机管理从设置中心迁入,
|
||||
两个应用均从应用菜单、管理应用和常驻入口打开主内容区独立页面,不嵌入管理 Modal。
|
||||
@@ -281,4 +281,8 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
|
||||
导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用
|
||||
主机能力。两个应用的能力依赖、迁移范围及产品验收由
|
||||
[主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义,
|
||||
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文五应用的当前状态描述。
|
||||
业务页面布局见[界面设计](../remote-host/ui-design.md)。
|
||||
|
||||
2026-10-13 “主机管理”已实现,应用标识 `remote-hosts`,默认排在末尾、不常驻;“可打开”与
|
||||
“已启用”分开判断,远程能力关闭时仍可打开页面。实现事实见
|
||||
[主机管理实施进度](../remote-host/progress.md)。“云环境管理”仍待实现。
|
||||
|
||||
@@ -444,7 +444,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
- 文件预览保留在右侧工作区内;预览期间隐藏列表但保持目录树挂载,返回后保留已展开
|
||||
目录、滚动位置和选中行,并恢复触发行焦点。Diff 返回未提交更改列表时同样恢复
|
||||
滚动位置、选中行和焦点。
|
||||
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。
|
||||
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用系统应用打开”。
|
||||
刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。
|
||||
- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。
|
||||
文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 聊天历史导航
|
||||
|
||||
聊天历史导航负责长会话的连续滚动、窗口化渲染和对话 minimap。当前实现已经取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制,并用轻量 canvas 提供消息分布和快速定位。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [产品需求](./prd.md) | 用户问题、范围、验收和非目标 |
|
||||
| [技术设计与测试计划](./technical-design.md) | 当前实现边界、实验分组、性能指标和执行步骤 |
|
||||
| [实施进度](./progress.md) | 已验证事实、命令、结果和遗留问题 |
|
||||
|
||||
## 术语
|
||||
|
||||
- **历史窗口**:允许滚动访问的消息范围。第一阶段扩大为当前已加载会话的全部消息。
|
||||
- **DOM 窗口**:实际挂载消息组件的范围。继续由消息高度估算和最多约 60 条挂载消息控制。
|
||||
- **minimap**:替代消息区原生滚动条的 canvas 全局概览,高度与滚动区一致,悬停时展开为较宽的缩略长图;不等于完整渲染所有消息。设置界面中称为“滚动条缩略图”(设置 › 外观),对应应用设置 `chatMinimapEnabled`,默认开启;关闭后恢复原生滚动条,历史窗口不受影响。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 聊天历史连续滚动
|
||||
|
||||
## 用户问题
|
||||
|
||||
长会话向上阅读时,当前界面每次只增加 80 条消息,并在顶部显示“加载更早的消息”控件。这个控件把连续滚动切成多个操作,用户无法像 VSCode 一样直接拖动滚动条或连续滚到会话顶部。
|
||||
|
||||
## 目标
|
||||
|
||||
- 已加载的当前会话可以从底部连续滚动到顶部。
|
||||
- 消息仍按可见范围挂载,长会话不会因为取消 80 条限制而一次性创建全部消息组件。
|
||||
- 滚动位置、锚点、消息展开状态和流式输出行为保持稳定。
|
||||
- 为后续 minimap 提供完整的消息索引和可定位滚动范围。
|
||||
|
||||
## 范围
|
||||
|
||||
- 取消真实聊天会话的 80 条历史窗口增量。
|
||||
- 保留现有消息窗口化、占位高度、滚动锚定和按需挂载。
|
||||
- 对 100、500、2,000、10,000 条消息进行功能和性能对比。
|
||||
- 记录 minimap 的下一阶段性能测试入口,但本阶段不实现 minimap。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改变未打开会话的摘要加载策略。
|
||||
- 不要求一次从数据库加载所有未加载的会话历史。
|
||||
- 不取消最多约 60 条消息组件的 DOM 窗口化限制。
|
||||
- 不在本阶段加入 minimap、消息摘要接口或新的 IPC。
|
||||
|
||||
## 产品验收
|
||||
|
||||
- 打开一个已加载历史的会话后,顶部不再出现“加载更早的消息”按钮。
|
||||
- 用户可以连续滚动到第一条消息,滚动位置不会因消息挂载或高度测量而跳动。
|
||||
- 2,000 条消息会话仍保持窗口化渲染,实际挂载消息数量维持现有上限附近。
|
||||
- 流式输出、输入、消息展开、附件和工具输出行为没有回归。
|
||||
- 若会话只有摘要,仍按现有逻辑按需加载完整历史。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 实施进度
|
||||
|
||||
## 2026-10-08
|
||||
|
||||
- 已确认 80 条限制由 `ChatHistoryPane` 的 `messageRenderBatchSize` 和 `revealEarlierMessages` 驱动。
|
||||
- 已确认消息窗口化仍以高度占位和最多约 60 条挂载消息为上限。
|
||||
- 已建立基线、本阶段和 minimap 阶段的对比测试计划。
|
||||
- 已将生产会话接线改为直接使用当前已加载消息总数,保留 DOM 窗口化。
|
||||
- `ChatHistoryPane` 与 `ChatTimeline` 定向测试通过:58/58。
|
||||
- 改动文件定向 web 类型检查和 lint 通过。
|
||||
- `npm run build:bundle` 通过。
|
||||
- `npm run perf:app` 通过。本次 B 组测量中,长会话打开交互延迟为 86.9 ms,长会话滚动帧间隔 p95 为 16.9 ms、无长任务;流式输入场景帧间隔 p95 为 16.9 ms,输入延迟 p95 为 17.1 ms。
|
||||
- B 组第二次运行仍通过:长会话打开交互延迟为 46.9 ms,长会话滚动帧间隔 p95 为 16.8 ms、无长任务;流式输入场景输入延迟 p95 为 19.1 ms。
|
||||
- App 测试通过 341/349;剩余 8 个失败集中在既有图片附件异步场景,未出现聊天历史窗口相关失败。
|
||||
- 全局 `npm run typecheck` 仍被既有 `src/main/desktop-storage-electron-fixture.ts:215-217` 类型错误阻断;全局 lint 仍包含生成的 VitePress cache 错误。
|
||||
- A 组临时恢复后连续两次在 composer 初始化阶段超时,未进入性能场景,因此没有有效 A 组数据,不能据此推导 A/B 数值差异。B 组代码已恢复并重新完成 bundle、定向类型检查、定向 lint 和 58/58 聊天窗口测试。
|
||||
|
||||
## 2026-10-11
|
||||
|
||||
- 已确认工作区干净,生产代码保持 `47917094` 的 A 组:向上滚动仍按 80 条揭示,DOM 窗口化仍保留。
|
||||
- 当前聊天窗口和时间线定向测试:59/59 通过,包含 2,000 条消息连续滚动、实际挂载数量上限、锚点稳定、状态恢复和 80 条历史揭示。
|
||||
- App 单元测试:342/350 通过;8 个失败均为等待状态圆点的既有异步断言,失败位置不涉及历史窗口逻辑。
|
||||
- `npm run build:bundle`:通过。
|
||||
- 当前 A 组首次可重复的 `npm run perf:app`:通过。长会话打开交互延迟 54.4 ms;长会话滚动无长任务,帧间隔 p95 8.5 ms;流式输入延迟 p95 10.0 ms;流式输入场景无长任务。
|
||||
- 性能基准内存回收:24 次切换后 mounted panes 保持 6,DOM 从 24,746 降至约 2,446,堆从 50.5 MB 回收至约 22 MB,后半段增长约 0.1 MB。
|
||||
- 之前记录的“A 组 composer 初始化超时”未在当前回退后的基线复现;现在已经取得有效 A 组数据,后续可在同一 bundle 和环境下重新接线 B 组做严格交替 A/B。
|
||||
- minimap 尚未实现;在 B 组完成交替测量前不进入 minimap 性能对比。
|
||||
- B 组已重新接线:`ConversationHistorySlot` 对完整会话传入 `conversation.messages.length`,App 层不再维护 80 条可见数量状态;摘要会话仍走原有历史加载流程。
|
||||
- B 组 App 历史回归:4/4 通过,覆盖无历史闸门、长会话有界提交、暖切换位置和展开状态、超过 500 条历史保留。
|
||||
- B 组 `npm run build:bundle`:通过;改动文件 web 类型检查和 lint:通过。
|
||||
- B 组 `npm run perf:app`:通过。长会话打开交互延迟 46.8 ms,长会话滚动无长任务、帧间隔 p95 8.5 ms;流式输入延迟 p95 9.5 ms,无长任务。
|
||||
- A/B 基准对比:B 组 DOM 在长会话滚动后约 2,644、24 次切换回收后约 2,446;A 组对应约 2,632 和 2,446。B 组 24 次切换后的堆约 22.9 MB,后半段增长约 0.1 MB;A 组约 22.0 MB,后半段增长约 0.1 MB。当前没有观察到明显滚动、流式或内存回收回归。
|
||||
- 全量 App 测试:B 组 341/350 通过;剩余 9 个中 1 个为已迁移历史测试前的旧断言结果,8 个仍为既有等待状态圆点异步断言。迁移后的历史相关测试已单独通过。
|
||||
- B 组尚未提交;下一步继续做多轮交替 A/B 和低端机模拟,再决定是否进入 minimap。
|
||||
|
||||
## 2026-10-12
|
||||
|
||||
- C 组已实现 minimap:使用单个 canvas 绘制用户、助手、工具/待处理和错误消息色带,不为每条消息增加 DOM 节点。
|
||||
- minimap 显示当前滚动视口,支持点击、指针拖动、Home、End、PageUp 和 PageDown;使用现有滚动容器和消息高度估算定位。
|
||||
- 新增 `ChatMinimap.test.tsx`,覆盖颜色分类、坐标边界、跳转和键盘操作;minimap、历史窗口和时间线定向测试共 62/62 通过。
|
||||
- C 组 App 历史相关回归 4/4 通过;改动文件 lint、web 类型检查和 `npm run build:bundle` 通过。
|
||||
- C 组 `npm run perf:app`:通过。长会话打开交互延迟 46.4 ms;长会话滚动帧间隔 p95 8.5 ms、无长任务;流式输入延迟 p95 9.6 ms、无长任务。
|
||||
- B/C 单次对比:滚动 p95 均为 8.5 ms 且无长任务;B 组流式输入 p95 9.5 ms,C 组 9.6 ms;B 组 24 次切换后 DOM/堆约 2,446/22.9 MB,C 组约 2,449/23.1 MB,后半段堆增长均约 0.1 MB。
|
||||
- C 组尚未做多轮交替和低端机模拟;当前结果支持继续保留实现并进入更广泛验证,不能单凭单次数据宣称最终性能结论。
|
||||
|
||||
### 2026-10-12 交互修订
|
||||
|
||||
评审发现初版 minimap 是悬浮在中部的 220 px 小条,视口指示经 `requestAnimationFrame` + React 状态更新而滞后于滚动,且没有悬停放大。已修订:
|
||||
|
||||
- minimap 替代消息滚动区的原生滚动条:`.chat-history-pane > .chat` 隐藏原生滚动条,minimap 高度与滚动区 `clientHeight` 一致并贴合右侧。
|
||||
- 视口滑块在 `scroll` 事件和 `ResizeObserver` 回调中同步写入 `transform`,不经过 React 提交或下一帧;滚动区尺寸不变时只写 `transform`,不读布局。
|
||||
- canvas 仅在消息变化、行高测量变化、尺寸变化或展开/收起时重绘;普通滚动不重绘。
|
||||
- 悬停时展开为 96 px(窄屏 64 px)的缩略长图:用户消息靠右、助手消息靠左并按内容长度给出线宽;拖动中离开不收起。
|
||||
- 点击轨道将滑块中心移到指针处,拖动滑块保持抓取偏移;滚轮转发给消息区;保留 Home/End/PageUp/PageDown;无障碍角色为 `scrollbar`,文案使用 i18n `chat.minimap`。
|
||||
- 验证:minimap、历史窗口和时间线定向测试 65/65;web 类型检查和改动文件 lint 通过;`App.test.tsx` 342/350,8 个失败与此前相同(等待状态圆点异步断言);`npm run build:bundle` 通过。
|
||||
- `npm run perf:app` 通过:长会话打开帧 p95 8.6 ms、无长任务;长会话滚动帧 p95 8.6 ms、无长任务;`stream-seeded`/`stream-and-type` 无长任务、帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23 MB,后半段增长 0.1 MB。
|
||||
- 未验证:真实鼠标悬停/拖动的视觉效果仍需在桌面端人工确认;多轮交替与低端机模拟仍未做。
|
||||
|
||||
### 2026-10-12 第二次交互修订
|
||||
|
||||
- minimap 只覆盖阅读区:顶部从 `--chat-context-height`(任务条)开始,毛玻璃模式下再让出 58 px 标题栏;滑块轨道高度取 rail 实际高度,rail 自身尺寸变化由独立 `ResizeObserver` 触发重算。
|
||||
- 展开宽度调为 120 px(后续按反馈改为 80 px)。展开视图改为逐行描线:`minimapLines` 按约 72 列折行(CJK 按两列计)、保留空行、代码块缩进并降低不透明度,每条消息最多取 600 行;消息块高度装不下时按行均匀抽样。行剖面按消息对象缓存在 `WeakMap`,流式更新产生新对象时才重算。
|
||||
- 验证:定向测试 67/67(新增折行剖面和逐行绘制用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
|
||||
- `npm run perf:app` 通过,各场景帧 p95 8.5–8.6 ms;长会话打开、长会话滚动、两个流式场景均无长任务;内存回收与上一轮一致。运行中出现的 renderer error 为网络更新检查 `AbortError`,与本功能无关。
|
||||
|
||||
### 2026-10-12 第三次交互修订
|
||||
|
||||
- 展开宽度改为 80 px(窄屏仍为 64 px)。
|
||||
- 已取消的回复在缩略图中改为助手灰色,与正文状态行一致;只有失败的回复显示红色(复用 `isCancelledMessage`)。
|
||||
- 修正滚动时缩略图密度变化:此前每条消息的色块高度取 `MessageRowHeights.size()`,未测量行使用“已测行平均高度”估算;滚动挂载新行后估算值和实测值变化,每次提交都会按新高度重画整张图。现改为双轴:
|
||||
- 绘制轴:`minimapMessageWeight`(折行数 + 3 行头部 + 每个工具 2 行),只依赖消息内容;画布只在消息数组变化(新消息、流式输出)或尺寸、展开状态变化时重画,行测量不再触发重画。
|
||||
- 滚动轴:行高前缀和,仅在 `scrollHeight` 变化时重建。滑块位置和高度按行在两轴之间分段线性映射,覆盖视口内消息在缩略图上的实际区段(最小 16 px);点击和拖动走反向映射。
|
||||
- 验证:minimap、历史窗口、时间线定向测试 69/69(新增“行测量不��发重画”和“滑块对齐可见行的概览区段”用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
|
||||
- `npm run perf:app` 连续两次通过。第二次结果:长会话打开、长会话滚动、`stream-seeded`、`stream-and-type` 均无长任务,帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23.2 MB,后半段增长 0.2 MB。第一次运行中长会话滚动帧 p95 为 16.6 ms,`stream-seeded` 有 1 个 62 ms 长任务,第二次未复现,按单次噪声记录。
|
||||
- 未验证:真实桌面端滚动时的视觉稳定性需人工确认。
|
||||
|
||||
### 2026-10-12 外观设置开关
|
||||
|
||||
- 新增应用设置 `chatMinimapEnabled`(`z.boolean().default(true)`),旧设置文件缺省时读为开启,无需升级设置版本;`application.update` 配置工具复用同一更新 schema,自动接受该字段。
|
||||
- 设置 › 外观新增开关“滚动条缩略图”/“Scrollbar minimap”,位于“透明磨砂特效”之后,保存后立即生效。
|
||||
- 关闭时 `ChatHistoryPane` 不挂载 `ChatMinimap`,也不再设置 `data-minimap`,原生滚动条随之恢复;历史窗口(完整已加载消息)和约 60 条 DOM 窗口不变。
|
||||
- 验证:设置存储(缺省默认、两种取值的持久化与重载、非法值拒绝)、外观开关(中英文)、窗格开关切换用例均通过;相关 8 个测试文件 410 项中修正配置快照夹具后全部通过;`App.test.tsx` 342/350,8 个失败与此前相同;web/node 类型检查(除既有 `desktop-storage-electron-fixture.ts`)与改动文件 lint 通过。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 技术设计与测试计划
|
||||
|
||||
## 当前边界
|
||||
|
||||
`ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。当前工作区处于 C 组实验:生产接线直接使用已加载消息总数,并用基于高度估算的 canvas minimap 替代消息区原生滚动条;DOM 窗口化、消息高度占位和锚点恢复保持不变。
|
||||
|
||||
minimap(`ChatMinimap.tsx`)的更新路径不经过 React:`scroll` 事件和 `ResizeObserver` 回调同步写入滑块 `transform`,只有滚动区尺寸、消息或行高测量变化时才重绘 canvas;悬停展开改写 `data-expanded` 后重绘一次。React 只在 `ChatHistoryPane` 自身提交时刷新消息数据。
|
||||
|
||||
已加载历史的消息数组仍由会话 store 提供。带 `messageSummary` 的会话继续先显示加载状态,由现有 `loadHistory` 路径取得完整历史。这样本实验测量的是滚动历史窗口的变化,不混入存储或 IPC 改造。
|
||||
|
||||
## 实验分组
|
||||
|
||||
| 组别 | 历史窗口 | DOM 窗口 | minimap |
|
||||
| --- | --- | --- | --- |
|
||||
| A 基线 | 每次增加 80 条 | 保留 | 无 |
|
||||
| B 本阶段 | 当前已加载消息全部可访问 | 保留 | 无 |
|
||||
| C 下一阶段 | 当前已加载消息全部可访问 | 保留 | 有 |
|
||||
|
||||
A 组是已记录的生产基线;B 组是无 minimap 的历史导航实验;C 组是当前工作区实验,待多轮对比和低端机模拟完成后再决定发布范围。
|
||||
|
||||
## 功能测试计划
|
||||
|
||||
- 100 条消息:打开会话后直接滚到第一条,不出现加载按钮。
|
||||
- 500 条消息:从底部连续滚到顶部,检查消息顺序、首尾消息和滚动位置。
|
||||
- 2,000 条消息:在中部、顶部和底部来回滚动,确认高度占位和锚点不跳动。
|
||||
- 消息交互:展开 reasoning/tool details、输入答案、选择文本、打开附件后滚出再滚回,确认状态保留。
|
||||
- 流式消息:生成过程中滚动、暂停跟随底部,再回到底部,确认自动跟随行为不变。
|
||||
- 摘要会话:打开只有 `messageSummary` 的会话,确认仍然先加载历史,不把摘要误当作完整消息。
|
||||
|
||||
## 性能测试计划
|
||||
|
||||
每个规模至少运行 5 次,分别记录冷打开和已经打开后的滚动表现。低端机模拟沿用已有性能测试配置,结果记录中注明运行环境。
|
||||
|
||||
记录指标:
|
||||
|
||||
- 会话打开到首屏可交互的耗时;
|
||||
- 首次滚到顶部的耗时;
|
||||
- 连续滚动期间长任务和掉帧;
|
||||
- Renderer 内存峰值和测试结束后的回收情况;
|
||||
- 实际挂载消息数量;
|
||||
- 流式输出时输入延迟。
|
||||
|
||||
通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。C 组还需在同一批数据上确认 minimap 的定位行为和增量性能成本。当前 A/B/C 数据记录在 `progress.md`。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```text
|
||||
npm exec vitest run src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run perf:app
|
||||
```
|
||||
|
||||
`perf:app` 的具体参数和输出以仓库脚本当前实现为准;无法在自动化环境观察的内存和掉帧指标,需要补充 Electron 手工记录。
|
||||
@@ -14,7 +14,7 @@ Main。IPC 先验证可信 sender,再使用共享 `contextImportFilesSchema`
|
||||
没有本地路径的截图沿用 `context.addPastedImage`;无路径的非图片文件和混有无路径项目
|
||||
的本地文件批次显示可处理的错误,不静默丢弃项目。
|
||||
|
||||
`ContextManager.selectFiles` 只负责文件对话框,再调用与粘贴共用的 `importFiles`。
|
||||
`ContextManager.selectFiles` 打开不按文件类型过滤的多选文件对话框,再调用与粘贴共用的 `importFiles`。
|
||||
这个方法完成路径规范化、格式及大小检查、文本读取、图片转换、文档解析与上下文暂存。
|
||||
任一文件失败时删除本批次已经暂存的项目,保留之前的附件。文件系统错误不向 Renderer
|
||||
暴露绝对路径。
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
本功能负责 SSH Host 管理、Host Key、远程环境安装、GoodBuddy Agent、签名 Runtime、
|
||||
远程 Workspace 和无模式工具执行生产路径。
|
||||
|
||||
2026-09-28 新增待实现方向:主机管理迁入应用中心,与“云环境管理”成为同级应用;
|
||||
2026-09-28 确认方向:主机管理迁入应用中心,与“云环境管理”成为同级应用;
|
||||
云平台发现已有实例,接入执行时复用 SSH 主机。产品范围见 [PRD](./prd.md)。
|
||||
2026-10-13 已完成一期:主机管理成为应用中心中的独立应用,包含主机和 Agent 离线包,
|
||||
设置中心不再提供主机入口,见[实施进度](./progress.md)。云环境管理仍待实现。
|
||||
本文中的平台连接指云账号或私有化 API 连接,云实例指平台中的虚拟机,主机指 GoodBuddy
|
||||
保存的 SSH 连接目标;三者不等同。现有实现和历史验收仍以下列技术文档为准。
|
||||
|
||||
@@ -12,15 +14,16 @@
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [产品需求](./prd.md) | 应用迁移、六平台适配范围、分期与产品验收;待实现 |
|
||||
| [功能逻辑](./logic-design.md) | 平台、实例、主机关系,独立状态、依赖和删除规则;待实现 |
|
||||
| [界面设计](./ui-design.md) | 独立工作页面、主机列表与详情、云实例接入和响应式交互;待实现 |
|
||||
| [产品需求](./prd.md) | 应用迁移、六平台适配范围、分期与产品验收;主机管理一期已实现 |
|
||||
| [功能逻辑](./logic-design.md) | 平台、实例、主机关系,独立状态、依赖和删除规则;主机管理部分已实现 |
|
||||
| [界面设计](./ui-design.md) | 主机管理页面、主机列表与详情、云实例接入和响应式交互;一期已实现 |
|
||||
| [实施进度](./progress.md) | 主机管理与云环境管理的已验证实现事实、验证结果和剩余工作 |
|
||||
| [远程主机与 Agent 技术设计](./technical-design.md) | 当前产品语义、Agent、Runtime、Workspace、协议、生命周期和验证事实 |
|
||||
| [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 |
|
||||
| [执行清单验证记录](./runtime-checklist-validation.md) | 远程 OC/CN 原生清单、CN 开发包安装、取消及断线重放的实测结果与剩余验收 |
|
||||
|
||||
既有技术文档保留当前 SSH 行为及历史进度;新增 PRD、逻辑和 UI 文档只定义本次待实现范围,
|
||||
不替代已有验证事实。验收场景随 PRD 维护,本次不单独创建 User Stories 或进度占位文档。
|
||||
既有技术文档保留当前 SSH 行为及历史进度;PRD、逻辑和 UI 文档定义主机管理与云环境范围,
|
||||
实施进度只记录该范围的验证事实。验收场景随 PRD 维护,不单独创建 User Stories。
|
||||
|
||||
跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 主机与云环境功能逻辑
|
||||
|
||||
状态:2026-09-28 设计,待实现。对应[产品需求 FR-RH-1 至 FR-RH-6](./prd.md),验收场景使用其中的 AC-RH 编号。
|
||||
状态:2026-09-28 设计;“导航与能力依赖”中主机管理部分 2026-10-13 已实现(见[实施进度](./progress.md)),云环境部分待实现。对应[产品需求 FR-RH-1 至 FR-RH-6](./prd.md),验收场景使用其中的 AC-RH 编号。
|
||||
|
||||
## 对象与关联
|
||||
|
||||
@@ -29,8 +29,12 @@
|
||||
|
||||
常驻只控制导航捷径,取消常驻不停止任务、禁用能力或删除数据。应用中心的主机管理设置承载共享远程能力启用状态,迁移保留原 `remoteProjectsEnabled` 值;旧设置不再维护第二个独立开关。云环境应用的启用单独控制云管理入口和新平台操作。(FR-RH-1、FR-RH-4、FR-RH-6)
|
||||
|
||||
主机管理的“已启用”即 `remoteProjectsEnabled`,但“可打开”与“已启用”分开判断:设置读取成功后即可打开页面,以便就地启用和管理 Agent 离线包;主机服务仍由 Main 按 `remoteProjectsEnabled` 校验。常驻入口同样按“可打开”显示。
|
||||
|
||||
| 操作 | 目标行为 |
|
||||
| --- | --- |
|
||||
| 打开主机管理但远程能力未启用 | “主机”页签提供就地启用,不调用主机服务;“Agent 离线包”照常可用 |
|
||||
| 在任一入口关闭远程能力 | 当前项目为 SSH 项目时切回第一个普通本地项目;若正在主机管理页面则停留在该页面 |
|
||||
| 打开云环境但远程能力未启用 | 允许配置平台及查询实例;接入主机时提供就地启用,保存成功后继续 |
|
||||
| 取消主机常驻 | 云环境仍可引用主机,已有关联和运行不变 |
|
||||
| 关闭共享远程能力 | 沿用现有远程项目停用语义,保留主机、凭据和项目;云资源浏览仍可用,依赖操作显示原因与启用入口 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 主机管理与云环境管理产品需求
|
||||
|
||||
状态:2026-09-28 确认产品方向,待实现。本次仅更新文档;现有 SSH 实现与验证范围见[技术设计](./technical-design.md)。
|
||||
状态:2026-09-28 确认产品方向。主机管理一期(FR-RH-1 中的主机管理入口,FR-RH-2 中的搜索与精简列表,FR-RH-6 中的配置迁移)2026-10-13 已实现,见[实施进度](./progress.md);列表与详情两栏、云环境管理和平台适配器待实现。现有 SSH 实现与验证范围见[技术设计](./technical-design.md)。
|
||||
|
||||
## 目标与范围
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
|
||||
### FR-RH-1 应用入口
|
||||
|
||||
应用中心分别提供“主机管理”和“云环境管理”,打开独立主内容区页面,支持排序与常驻。应用中心只负责访问和通用应用设置,主机和云资源操作位于业务页面。迁移后移除设置中心内的重复主机管理表单;旧入口可提供跳转。
|
||||
应用中心分别提供“主机管理”和“云环境管理”,打开独立主内容区页面,支持排序与常驻。应用中心只负责访问和通用应用设置,主机和云资源操作位于业务页面。迁移后移除设置中心内的重复主机管理表单。
|
||||
|
||||
主机管理页面包含主机和 Agent 离线包两部分;离线包只服务于远程主机的环境安装,随主机管理迁出设置中心。
|
||||
|
||||
### FR-RH-2 主机工作页面
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 主机管理实施进度
|
||||
|
||||
本文只记录主机管理与云环境管理([PRD](./prd.md) FR-RH-1 至 FR-RH-6)的已验证实现事实。现有 SSH、Agent 和 Runtime 的历史验证见[技术设计](./technical-design.md)和[执行清单验证记录](./runtime-checklist-validation.md)。
|
||||
|
||||
## 2026-10-13 一期:主机管理迁入应用中心
|
||||
|
||||
提交:`05838d2c feat(hosts): move host management into the Hosts application`。
|
||||
|
||||
### 已完成
|
||||
|
||||
| 范围 | 对应需求 | 实现事实 |
|
||||
| --- | --- | --- |
|
||||
| 应用入口 | FR-RH-1 | 应用中心、应用菜单和常驻侧栏新增“主机管理”,打开独立工作页面;应用标识 `remote-hosts`,默认排在末尾、不常驻 |
|
||||
| 远程能力开关 | FR-RH-1、FR-RH-6 | 开关移到“主机管理 > 应用设置”,沿用 `remoteProjectsEnabled`,Main 校验不变;远程能力关闭时页面仍可打开并就地启用 |
|
||||
| Agent 离线包 | FR-RH-1 | 从“平台功能 > 远程项目”迁到主机管理的“Agent 离线包”页签;远程能力关闭时仍可管理 |
|
||||
| 设置中心 | FR-RH-1 | 删除“主机与远程执行”分类和“平台功能 > 远程项目”页签,无重复入口 |
|
||||
| 主机列表 | FR-RH-2(部分) | 名称或地址搜索;多台主机默认收起为一行,显示验证、执行环境和关联项目徽标;编辑、删除在卡片头部;版本信息改为表格 |
|
||||
| 配置迁移 | FR-RH-6 | 已保存的导航顺序和常驻偏好保留,新应用追加到末尾;远程能力开关值、主机、凭据、Host Key 和项目引用不变 |
|
||||
| 应用设置 | — | 各应用设置中的开关统一靠右对齐,包括带说明图标的开关 |
|
||||
|
||||
### 验证
|
||||
|
||||
- `npm run typecheck` 通过;改动文件的 `eslint` 无问题。
|
||||
- 下列测试 9 个文件、265 项全部通过:`SshHostsSettingsSection`、`RemoteHostsPage`、`AgentPackageInventoryPanel`、`ApplicationCenter`、`ApplicationMenu`、`SettingsPanel`、`settings-categories`、`application-settings-store`、`goodbuddy-config-contracts`。
|
||||
- 存储测试覆盖:已发布的五应用完整顺序在远程能力开启和关闭两种情况下升级,新应用追加到末尾且常驻为否,重新读取后不变。
|
||||
- `App.test.tsx` 350 项中有 8 项失败,均为流式消息与持久化终态收敛用例。在改动前的提交 `b22be514` 上单独运行这 8 项同样失败,与本次改动无关。
|
||||
|
||||
### 未验证与剩余工作
|
||||
|
||||
- 尚未在真实运行的应用中检查主机列表、离线包页签和应用设置开关对齐,也未覆盖深色主题和窄窗口;以上界面行为目前只有组件测试。
|
||||
- 尚未连接真实主机执行添加、验证、安装和创建远程项目的完整流程。
|
||||
- 未实现:列表与详情两栏、来源与连接状态筛选、关联项目页签(FR-RH-2 其余部分),云环境管理和平台适配器(FR-RH-3 至 FR-RH-5)。
|
||||
@@ -64,11 +64,12 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
|
||||
## 产品语义
|
||||
|
||||
本节记录现有实现。2026-09-28 确认的应用中心迁移与云环境管理目标见
|
||||
[PRD](./prd.md)、[功能逻辑](./logic-design.md)及[UI 设计](./ui-design.md),尚未接入源码。
|
||||
[PRD](./prd.md)、[功能逻辑](./logic-design.md)及[UI 设计](./ui-design.md);其中主机管理
|
||||
入口迁移已于 2026-10-13 接入源码,云环境管理尚未实现。
|
||||
|
||||
远程 Host 与项目能力默认关闭。用户在“设置 > 平台功能”的独立
|
||||
“远程项目”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存
|
||||
远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
|
||||
远程 Host 与项目能力默认关闭。用户在应用中心“主机管理 > 应用设置”的“启用远程能力”开关,
|
||||
或主机管理页面“主机”页签的就地启用按钮开启后,Renderer 才显示 SSH Host 管理、托管 SSH
|
||||
项目创建和已保存远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
|
||||
项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定
|
||||
选择普通本地项目,不会因为保存了远程项目而自动连接 Host。
|
||||
|
||||
@@ -84,21 +85,35 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
|
||||
|
||||
## 组件
|
||||
|
||||
### 应用迁移与云平台接入计划(待实现)
|
||||
### 主机管理应用(2026-10-13 已实现)
|
||||
|
||||
实现顺序对应 FR-RH-1 至 FR-RH-6:
|
||||
- 应用标识 `remote-hosts` 加入 `builtInApplicationIds` 和 `editableApplicationIds`,
|
||||
常驻键 `pinned['remote-hosts']` 默认 `false`。公开导航 schema 中该键在输入侧默认
|
||||
`false`,旧调用方不传也能通过;顺序数组仍须包含全部六个应用。
|
||||
- 不新增设置字段,也不提升设置文件版本:启用状态直接使用 `remoteProjectsEnabled`。
|
||||
已保存的导航由现有 `storedApplicationSettingsSchema` 归一化,把缺失的 `remote-hosts`
|
||||
追加到末尾并补齐常驻默认值,原有相对顺序不变。Main 的 `requireRemoteProjectsEnabled`、
|
||||
IPC 通道名(`settings:ssh-hosts:*`)和 `ssh-hosts.json` 均未改动;配置工具 schema
|
||||
由 zod 派生,自动包含新应用。
|
||||
- Renderer 新增 `RemoteHostsPage`(懒加载,由 `KeepAliveRoute` 缓存),包含复用的
|
||||
`SshHostsSettingsSection` 和从平台功能抽出的 `AgentPackageInventoryPanel`。两个面板
|
||||
切换时保持挂载;主机草稿、环境安装或离线包传输进行中时,通过
|
||||
`useWorkspaceUnsavedChanges` 固定该路由,避免页面缓存淘汰。
|
||||
- `isApplicationOpenable` 与 `isApplicationEnabled` 分开:主机管理在设置读取成功后即可
|
||||
打开,应用菜单、应用中心和常驻入口都按此判断。
|
||||
- 远程开关的副作用(当前 SSH 项目切回本地项目)由 `applyApplicationSettings` 在开关值
|
||||
变化时统一触发,覆盖应用中心、主机页面和外部设置变更三种来源。
|
||||
- 设置中心删除 `ssh-hosts` 分类、平台功能的“远程项目”页签及相关 props;平台功能只剩
|
||||
通用设置,不再显示页签条。
|
||||
|
||||
1. 从 `SshHostsSettingsSection` 提取独立主机工作页面,接入 `ApplicationCenter` 和 `App`
|
||||
页面导航;复用现有 SSH 服务、验证弹窗、安装操作及通知,完成列表与详情改版。
|
||||
2. 扩展应用标识、排序、常驻和配置读取。读取已发布四应用配置时保留原相对顺序及偏好,
|
||||
新入口追加且默认不常驻;云管理初始关闭,共享远程能力继承 `remoteProjectsEnabled`。
|
||||
配置工具公开契约、默认值、持久化、Preload 和 Main 校验同步修改,不能仅扩展 Renderer。
|
||||
3. 按功能逻辑整理能力依赖,将共享远程开关的设置入口迁入应用中心。保留旧值、Host ID、
|
||||
加密凭据、Host Key 与项目引用;不为页面迁移重建主机或修改 Agent 协议。
|
||||
4. 云平台适配器在 Main 中查询真实 API,凭据复用加密存储;Renderer 仅接收脱敏配置、
|
||||
列表与详情两栏、来源和连接状态筛选、关联项目页签尚未实现。
|
||||
|
||||
### 云平台接入计划(待实现)
|
||||
|
||||
1. 云平台适配器在 Main 中查询真实 API,凭据复用加密存储;Renderer 仅接收脱敏配置、
|
||||
实例快照与操作结果。采用满足首批平台发现和导入的最小接口,不预建资源编排框架。
|
||||
平台连接和实例关联采用独立记录,SSH 主机仍为执行目标,项目继续使用 `local / ssh`。
|
||||
5. 确认首批平台版本及测试环境,补齐需要的私钥和私网连接路径,逐个验收发现、导入、
|
||||
2. 确认首批平台版本及测试环境,补齐需要的私钥和私网连接路径,逐个验收发现、导入、
|
||||
SSH 验证、环境安装和真实任务执行。具体存储字段、迁移及适配 API 在实施时细化;
|
||||
变更已发布存储前遵守[数据库迁移规范](../../development/database-migrations.md)。
|
||||
|
||||
@@ -187,7 +202,8 @@ Detached GoodBuddy Agent
|
||||
GoodBuddy 所需版本比较。进入或切回该设置页只读取本地 Host 列表,不连接任何 Host;
|
||||
用户点击“刷新版本”后才探测对应 Host,探测不安装或切换远端组件。项目切换与此按钮
|
||||
解耦且不建立连接;实际远程操作才建立或复用 SSH/Agent 连接。
|
||||
- “平台功能 > 远程项目”中的本地 Agent 包清单与 Host 状态分离。Main 对用户数据目录中
|
||||
- “主机管理 > Agent 离线包”中的本地 Agent 包清单与 Host 状态分离(2026-10-13 前位于
|
||||
“平台功能 > 远程项目”)。包清单 IPC 不受远程能力开关限制。Main 对用户数据目录中
|
||||
Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime
|
||||
签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的
|
||||
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
# 主机管理与云环境界面设计
|
||||
|
||||
状态:2026-09-28 设计,待实现。范围见[PRD](./prd.md),状态及依赖遵守[功能逻辑](./logic-design.md)。视觉、页面壳层和组件遵守[统一界面设计系统](../../../UI-DESIGN.md)。
|
||||
状态:主机管理一期(入口迁移与列表精简)2026-10-13 已实现,见[实施进度](./progress.md);列表与详情两栏和云环境页面仍待实现。范围见[PRD](./prd.md),状态及依赖遵守[功能逻辑](./logic-design.md)。视觉、页面壳层和组件遵守[统一界面设计系统](../../../UI-DESIGN.md)。
|
||||
|
||||
## 应用入口
|
||||
|
||||
应用中心新增“主机管理”和“云环境管理”两个同级应用。应用菜单、管理应用中的“打开”和常驻侧栏入口进入同一主内容区页面,关闭来源菜单或管理 Modal,不在应用中心 Modal 内管理主机。业务页面使用全局范围标识;打开页面不切换当前项目、不连接主机或执行安装。
|
||||
应用中心新增“主机管理”和“云环境管理”两个同级应用,其中“主机管理”已实现。应用菜单、管理应用中的“打开”和常驻侧栏入口进入同一主内容区页面,关闭来源菜单或管理 Modal,不在应用中心 Modal 内管理主机。业务页面使用全局范围标识;打开页面不切换当前项目、不连接主机或执行安装。
|
||||
|
||||
通用启用、常驻和排序遵守[应用导航设计](../application-tool-navigation/ui-design.md)。设置中心移除主机操作表单,保留必要的迁移跳转;共享远程能力开关归入主机管理的应用设置。Agent 包下载、导入等现有全局包管理暂留原位置,主机执行环境页提供相关入口,不复制包状态存储。
|
||||
通用启用、常驻和排序遵守[应用导航设计](../application-tool-navigation/ui-design.md)。设置中心不再包含“主机与远程执行”分类和“平台功能 > 远程项目”页签。共享远程能力开关只在应用中心的“主机管理 > 应用设置”中,开关文字为“启用远程能力”;业务页面不重复提供设置页签。
|
||||
|
||||
## 主机页面
|
||||
远程能力关闭时,主机管理仍可从应用菜单和应用中心打开:“主机”页签显示“远程能力未启用”及“启用远程能力”按钮,不调用主机服务;“Agent 离线包”页签照常可用,便于提前下载或导入。
|
||||
|
||||
页面使用共享 `dashboard` 壳层。标题区显示“主机管理”、全局范围和主操作“添加主机”。工具栏提供名称或地址搜索、来源筛选、连接状态筛选。列表显示名称、地址、来源、SSH 状态和执行环境摘要,避免每行展开安装表单。
|
||||
## 主机页面(一期,已实现)
|
||||
|
||||
宽窗口使用列表与详情两栏,点击主机只更新详情,不重排列或滚动列表。详情使用共享 `PageTabs`:
|
||||
页面使用共享 `standard` 壳层,标题区显示“主机管理”和全局范围。页签为“主机”和“Agent 离线包”;两个面板切换时保持挂载,安装、下载和导入的进度不中断。离线包页签首次显示时才读取包清单。
|
||||
|
||||
“主机”页签顶部是名称或地址搜索和“添加主机”。主机多于一台时,每台主机默认收起为一行,包含:
|
||||
|
||||
| 区域 | 内容 |
|
||||
| --- | --- |
|
||||
| 左侧 | 展开按钮、名称、`用户@地址:端口` |
|
||||
| 状态徽标 | 验证状态;已验证时显示执行环境摘要(环境就绪、环境待更新、环境未安装、环境未检查、安装中);有关联项目时显示项目数 |
|
||||
| 右侧 | “编辑”或“验证并保存”、“删除”,均为纯文字按钮 |
|
||||
|
||||
只有一台主机时默认展开。安装进行中、安装失败、版本读取失败或正在确认删除时,该主机强制展开且不能收起。保存或重新验证成功后自动展开该主机。展开后显示认证方式、凭据、验证状态、Host Key 指纹、连接结果和执行环境;执行环境的版本以表格呈现,列为“组件 / Host 已安装 / GoodBuddy 所需 / 状态”,悬停版本号显示架构。
|
||||
|
||||
删除确认在主机头部下方占满整行,列出会一并删除的关联项目记录,并说明不会连接主机或删除远端内容。展开状态只保存在当前页面,不持久化。
|
||||
|
||||
## 主机页面(后续,待实现)
|
||||
|
||||
后续改为列表与详情两栏,点击主机只更新详情,不重排列或滚动列表;工具栏补充来源筛选和连接状态筛选。详情使用共享 `PageTabs`:
|
||||
|
||||
| 页签 | 内容与操作 |
|
||||
| --- | --- |
|
||||
@@ -35,10 +51,14 @@
|
||||
|
||||
首版不展示尚未实现的创建、销毁或网络编排按钮。各适配器随真实能力交付,不能用不可操作的平台卡片代替接入功能。
|
||||
|
||||
## 应用设置
|
||||
|
||||
各应用的“应用设置”中,开关统一靠右对齐;带说明图标的开关,说明图标紧跟文字,开关仍在右侧对齐。
|
||||
|
||||
## 响应式与交互
|
||||
|
||||
两栏无法满足共享最小宽度时改为单面板,选择主机进入详情并提供“返回主机列表”,返回恢复筛选、滚动和列表项焦点。页签可横向滚动,搜索与筛选换行,长地址和错误信息折行,页面不整体横向溢出。
|
||||
一期在窄容器下,主机行的状态徽标换到第二行,地址与名称上下排列,事实列改为单列。后续两栏无法满足共享最小宽度时改为单面板,选择主机进入详情并提供“返回主机列表”,返回恢复筛选、滚动和列表项焦点。页签可横向滚动,搜索与筛选换行,长地址和错误信息折行,页面不整体横向溢出。
|
||||
|
||||
列表项及操作使用语义按钮,提供可访问名称和可见焦点;页签沿用 `tablist`、`tab`、`tabpanel`、方向键及 Home/End。弹窗遵守共享焦点恢复及未保存编辑规则。通知使用全局应用通知,进行中的安装进度保留在对应主机,页面切换不重启操作。
|
||||
列表项及操作使用语义按钮,提供可访问名称和可见焦点;一期展开按钮使用 `aria-expanded` 和 `aria-controls`,名称为“展开 / 收起 {主机名} 的详情”,编辑、删除按钮名称带主机名;页签沿用 `tablist`、`tab`、`tabpanel`、方向键及 Home/End。弹窗遵守共享焦点恢复及未保存编辑规则。通知使用全局应用通知,进行中的安装进度保留在对应主机,页面切换不重启操作。
|
||||
|
||||
验收对应 AC-RH-1 至 AC-RH-7,覆盖浅深主题、窄窗口、键盘操作、空列表、长名称、认证失败及安装取消;具体实施记录需附真实 UI 验证结果。
|
||||
|
||||
@@ -1,388 +1,77 @@
|
||||
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
|
||||
# GoodBuddy 更新:故事图谱、Telegram 与 Obsidian 接入
|
||||
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.11,以及其后截至 `e50ca6d1` 的已提交变化,更新于 2026-10-05。所述功能与修复均已开发完成并提交,验证及发布归属另行标注;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.13,以及截至 `3556df88` 的已提交变化,更新于 2026-10-09。本文所述功能均已完成并提交;截图、下载信息和正式发布链接属于发布材料,另行补齐。原故事线概念图仅作示意,发布前替换为生产界面截图。
|
||||
|
||||
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者把分散的事件归入持续发展的故事,从中整理带有来源和适用条件的经验,供人和助手在后续工作中查阅。笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
|
||||
GoodBuddy 这一版完成了三条连接:用故事图谱整理持续推进的工作,用 Telegram 在桌面之外发起对话,用 Obsidian 连接已有的本地笔记。故事图谱帮助人和助手回看决定、依据与后续变化;Telegram 适合在手机或其他设备上继续处理文字请求;Obsidian 则让原有笔记仓库可以直接参与对话。
|
||||
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳、笔记标签、会话图片保存、Telegram 私聊和工作区文件导入。项目与会话集中到同一个菜单,提问和执行任务也无需再切换工作模式。远程统一执行与文件导入建议配套 Desktop 0.15.11 和 Agent 0.15.4,请先升级桌面端,再更新 Host 环境;最新远程启动修复还需包含后续提交的 Agent。
|
||||
当前配套版本为 Desktop `0.15.13` 和 Agent `0.15.6`。远程项目使用这些能力前,请先升级桌面端,再更新 Host 环境。
|
||||
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.11` 与 Agent tag `agent-v0.15.4` 均指向 `78afec77`。本轮检查 `9527402..e50ca6d1`,共 60 个提交;Telegram 私聊、历史耗时缺失提示、自定义回顾日期及最新运行修复位于这些 tag 之后,发布版本待确认。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
> **【发布信息待补】** 正式发布前补入发布日期、公开安装包、下载地址和完整更新日志链接。本文所述功能已经完成,发布信息待补不代表功能处于开发中。
|
||||
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库、设置和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前核查提交的助理数据库格式为 61,0.15.11 标签对应格式为 60;升级后旧客户端不能直接打开新版数据库,回退需要升级前的备份。大库首次转换可能需要等待。高频本地保存调整了磁盘同步策略,断电时可能丢失最近尚未同步的写入。
|
||||
## 故事图谱:从工作记录中看清一件事如何发展
|
||||
|
||||
## 新增功能
|
||||
### 工作回顾留下进展和依据
|
||||
|
||||
### 监督者:从工作记录中整理进展,查看每条结论的依据
|
||||
监督者可以按全局或项目范围回顾最近一天、数天或指定日期内的工作记录,整理出工作总结、变化和未解决事项,并按故事列出推进、新出现和结束的内容。回顾结果会保留范围与生成时间,方便之后回看。
|
||||
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,再选择最近 1 天、7 天、30 天、自定义最近天数或起止日期。指定日期按本地时区包含结束日全天,结束日为今天时截至发起回顾的时刻。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
|
||||
每条结论都可以继续追溯到事件、实体、关系和来源正文。打开来源后,可以检查模型依据的是哪段讨论,再决定是否确认、改名或调整关系。
|
||||
|
||||
监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。
|
||||
### 故事、经验和时间螺旋
|
||||
|
||||
> **【待补图 01|必补|工作回顾结果】** 在隔离演示项目中通过正常聊天和任务操作形成公开、虚构的工作记录,运行一次真实回顾。截图同时展示范围、时间和实际生成的总结、未解决事项;不能用预置答案冒充模型结果。计划文件:`assets/01-work-review.png`。
|
||||
>
|
||||
> 建议图注:按项目回顾近期讨论与任务,查看进展和仍待处理的问题。
|
||||
事件可以归入持续发展的故事,按项目、功能和子线索浏览。用户可以调整故事名称、合并重复故事、移动事件或撤销调整。故事中的经验会保留适用条件、形成依据和后续应用记录,也可以人工编辑、合并或删除。
|
||||
|
||||
回顾中的事件、实体与关系可以在“故事线图谱”中查看。选中一个对象,就能检查它的说明、关联和保存的来源正文,再判断模型的归纳是否准确。实体支持确认、改名或撤销,关系可以确认或移除。
|
||||
|
||||
这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱提供平铺和时间螺旋两种浏览方式,时间螺旋按事件时间排列;平铺视图中的节点间距不表示实际时长。历史回顾中的故事分组使用当前保存的故事结构,尚不提供任意时刻的完整状态还原。
|
||||
|
||||
> **【待补图 02|必补|图谱与来源】** 使用图 01 同一份真实回顾结果,选中事件或实体,展示对应的来源正文。画面应能辨认对象与依据的关联;不使用设计原型代替生产结果。计划文件:`assets/02-story-sources.png`。
|
||||
>
|
||||
> 建议图注:从回顾图谱查看关联对象,并打开保存的讨论依据。
|
||||
|
||||
### 故事与时间螺旋:追踪一件事如何持续发展
|
||||
|
||||
一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。
|
||||
|
||||
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径按所选回顾范围内的消息密度变化,不代表实际工时、效率或成果价值;消息按小时汇总,无法还原小时内的精细分布。
|
||||
|
||||
实际事件区间和事件点使用主故事的颜色,空白区间与未归属事件保持中性色。历史回顾中尚未归入故事的事件也会保留,可以在“未归属”分组查看;故事分组采用当前关系,不把它当作当时分类的完整还原。
|
||||
|
||||
视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。
|
||||
故事图谱提供平铺和时间螺旋两种视图。时间螺旋按事件时间和消息密度展示工作如何交错推进,适合观察停顿、延续和阶段变化;平铺视图适合查看对象之间的关系。设备不适合 3D 浏览时,可以切换回平铺视图。
|
||||
|
||||

|
||||
|
||||
*早期概念示意,使用示例数据;不是当前生产界面的截图,实际布局与操作以应用为准。*
|
||||
*早期概念示意,使用示例数据;发布前替换为当前版本的生产界面截图。*
|
||||
|
||||
> **【待补图 09|必补|故事与经验的生产视图】** 原图保留作概念参考。发布前用目标版本的真实回顾结果替换,展示同一故事的事件、经验形成依据与后续应用,并核对平铺和时间螺旋中的选择联动。计划文件:`assets/09-story-experience.png`。
|
||||
>
|
||||
> 建议图注:沿故事查看工作进展,从经验节点核对形成依据与后续应用记录。
|
||||
### 助手可以接着既有决定继续工作
|
||||
|
||||
### 从故事中整理经验,保留适用条件和依据
|
||||
当用户追问“此前为什么选这个方案”或“这项决定后来怎样修改”时,助手可以查询已经保存的故事、经验、事件、实体、关系和回顾结果,再读取相关来源。查询发生在需要时,不会在每次对话中自动注入整张图谱。
|
||||
|
||||
故事积累到一定规模,并出现新的决定、变化或里程碑后,监督者可以从事件摘要中归纳经验,保存适用条件、边界和形成依据。自动归纳的内容有明确标记,用户可以编辑、合并或删除,再回到原讨论核对。
|
||||
输入区可以单独控制当前会话是否允许访问故事图谱。查询默认使用当前项目,也可以指定全局或项目集合;故事图谱工具只读取已有记录,不会因为一次查询重新运行回顾或修改图谱。
|
||||
|
||||
后续回顾还可以记录某条经验在哪些事件中得到应用。应用记录会核对事件是否晚于最早形成事件的起始时间,并与至少一个形成事件涉及共同实体;这些记录仍是模型对历史的归纳,需要结合来源判断,不能据此断定经验已经产生效果。助手可以查阅这些经验,为后续工作提供参考。
|
||||
### 智能心跳持续整理变化
|
||||
|
||||
### 助手可以查阅故事图谱,接着已有决定继续工作
|
||||
设置每日或每周计划后,智能心跳会处理新增和变化的工作记录,更新故事图谱,并提出未决事项、分歧、修订、候选约定、长期没有进展的故事和可能适用的经验。没有新的工作来源时会跳过事实提取;建议生成失败可以单独重试,已保存的回顾和图谱仍然保留。
|
||||
|
||||
继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的故事、经验、事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。故事上下文带出相关事件,经验上下文带出形成和应用记录。
|
||||
## Telegram:从私聊继续处理任务
|
||||
|
||||
工具说明也会引导助手在继续旧工作、追问决定原因或遇到可能已有约定的任务时主动查阅,优先找故事与经验。是否调用仍由当前模型判断;普通闲聊和独立的通用问题不要求查询,也不会在每次对话中自动塞入整张图谱。
|
||||
配置自己创建的 Telegram Bot、数字用户 ID 白名单和通道项目后,可以通过 Telegram 私聊发送文字问题或任务。GoodBuddy 会沿用该项目的 Runtime、模型和工作目录处理请求,并把最终文字回复返回到 Telegram,桌面端同时保留会话记录。
|
||||
|
||||
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;本地 Continue、OpenCode 原生终端也可读取。
|
||||
首次配置时可以先留空发送者 ID,启用并保存通道;连接 Bot 后发送 `/start` 和 `/whoami` 获取自己的数字用户 ID,再填入白名单并保存。连接支持断线恢复和停用取消,桌面应用需要保持运行并能连接 Telegram。
|
||||
|
||||
输入区的选项中可以为当前会话单独开关故事图谱访问。默认允许查询,但仍受监督者总开关和工具配置约束;开启不会自动运行回顾或注入上下文,关闭也不会删除图谱,或将这个会话排除在后续回顾之外。
|
||||
当前通道面向单个 Bot 的普通私聊和文字请求。群聊、媒体输入和附件发送仍在桌面端处理,图片等附件结果需要回到桌面查看。
|
||||
|
||||
查询默认使用当前项目,也可明确指定全局或项目集合。事件可从共享时间线中按项目查阅,回顾结果仍保留各自的范围。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,尚不支持任意时刻的完整历史状态重建。
|
||||
## Obsidian:直接连接已有笔记仓库
|
||||
|
||||
### 智能心跳:按新增工作回顾,并提出可处理的建议
|
||||
在“设置 → 能力与工具 → MCP”的 Obsidian 卡片中,可以选择本机已注册的仓库或限定文件夹,测试连接并分配给所用 Runtime。无需启动 Obsidian,也无需安装插件或配置 REST 服务。
|
||||
|
||||
为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有新来源时跳过事实提取,仍可补做尚未完成的故事与经验整理。没有待处理工作时,不调用模型。
|
||||
接入后,可以让助手搜索和读取笔记,也可以把讨论结果写回仓库。支持追加、局部修改、目录浏览,以及标签和属性操作。笔记仍保存在原仓库,不会自动复制到 GoodBuddy 知识库。
|
||||
|
||||
建议围绕本次回顾发现的未决事项、分歧、修订和候选约定展开,并保留来源供核对。接受未决事项后,会创建一项暂停任务,方便先检查再开始;候选约定经确认后才进入长期记忆。建议生成失败可以单独重试,已经保存的图谱和回顾进度仍然保留。
|
||||
Obsidian 接入默认关闭,启用后只在配置范围内读写。当前支持直连模型及 GoodBuddy 管理的本机 OpenCode、Continue 聊天;读出的笔记会随当前会话交给所选模型处理。
|
||||
|
||||
心跳完成回顾后,还可以提示达到设定天数仍没有新进展的故事,或指出某条已有经验可能适用于当前故事。建议可以直接打开图谱;接受暂无进展建议会建立暂停任务,接受经验建议只标记已参考,不会据此写入经验应用记录。暂无进展默认按 14 天筛选;仅时间流逝到期、但本次回顾没有变化时,不会单独触发提醒。
|
||||
## 其他改进
|
||||
|
||||
计划需要应用保持运行,并按所选全局或项目范围处理。心跳不会在每条聊天消息后实时介入;有候选建议时会额外调用模型整理措辞,回顾本身也可能分批调用模型。
|
||||
### 输入区和 Runtime 控制
|
||||
|
||||
### 在任务中心跟踪当前会话或任务
|
||||
上下文来源、专家角色、模型和 Runtime 控制收进输入区工具栏。故事图谱与知识库使用同一个上下文入口,OpenCode 和 Continue 的运行配置放入独立弹窗。窄窗口下仍能按可用空间排列,弹窗支持自动聚焦、外部点击关闭和 Escape 返回焦点。
|
||||
|
||||
右侧任务中心的项目概览下方新增监督反馈。选中任务时查看该任务的匹配结果,否则跟随当前会话;也可以固定目标,切换聊天后继续查看同一个事项。没有匹配回顾时会明确显示空状态。
|
||||
### 笔记、文件和图表
|
||||
|
||||
监督反馈中的“继续讨论”可以直接使用,无需先打开来源。侧栏和图谱来源中的入口都会展示完整、可编辑的发送正文,包含相关回顾结论和来源内容;可以删改引用、补充问题,再确认单独显示的目标会话。发送以编辑后的正文为准,已删除的上下文不会被重新附加;发送失败保留草稿。在图谱来源中发送后会打开目标会话,需要先看原讨论时,也可单独点击“打开会话”。
|
||||
魔法笔记支持插入和编辑表格,表格与其他富文本一起保存;笔记支持标签筛选、标签管理和列表与正文双栏浏览。聊天回答可以保存到笔记并返回原会话,工作区支持批量导入本地文件到本地或远程项目,Mermaid 图表可以导出完整 PNG。
|
||||
|
||||
带入的结论按所选来源关联的事件、实体与关系整理,方便围绕当前事项继续追问。监督界面已移除知识实体写回控件。
|
||||
### Runtime、会话和日常操作
|
||||
|
||||
后续讨论的最终内容最多 12,000 字符。固定任务没有关联会话时,不能直接发送继续讨论;后续对话沿用目标会话的 Runtime、模型与能力配置。
|
||||
项目和会话集中到同一个菜单,Runtime 与模型可以分别按会话、项目和全局配置解析,也可以在当前会话临时切换。聊天历史、侧栏和长消息减少重复渲染,阅读位置、展开状态和流式回复更稳定;桌面通知、磨砂外观和任务用量可以分别设置。
|
||||
|
||||
### 聊天正文存入魔法笔记,并保留来源
|
||||
### 请求、存储和故障处理
|
||||
|
||||
遇到值得保留的回答,可以点击助手消息旁的“加入笔记”,预览并编辑文字,再追加到已有笔记,或新建笔记保存。需要保留整段讨论时,会话菜单中的“保存当前对话到笔记”会读取完整历史,收录用户与助手的正文,不局限于屏幕上已经显示的消息。
|
||||
SQLite 存储、后台读取、请求取消和并发处理路径已经合并调整,减少主进程等待。直连模型支持手动压缩上下文,工具执行失败后可以继续对话;长请求结束、取消或失败时会正确回收工具授权。监督回顾支持暂停、继续、取消和失败后重试,远程 Runtime 启动和本地 OpenCode 连接也补充了取消与恢复处理。
|
||||
|
||||
保存后的记录带有“来自会话”入口。单条回答可以返回对应消息;原会话或消息被删除后,已保存的笔记内容仍保留。每次采集新增一条记录,后续对话修改不会自动同步到笔记。
|
||||
## 升级提示
|
||||
|
||||
工作栏“+ → 笔记”可以打开紧凑面板,在聊天旁搜索笔记标题与正文、浏览摘要、追加文字。切换项目或会话时草稿保留,关闭有未保存内容的面板前会提示;草稿仅在本次应用运行期间保留。画布、待办和完整编辑继续在魔法笔记工作区中使用。
|
||||
升级前请关闭应用,并备份完整用户数据、设置、数据库、WAL 文件和相邻的 `notes/` 目录。当前数据库格式为 62,0.15.13 之前的客户端不能直接打开新版数据库,回退需要使用升级前的备份。首次转换较大的数据库时需要等待;高频本地保存调整了磁盘同步策略,断电时可能丢失最近尚未同步的写入。
|
||||
|
||||
快速笔记入口集中在工作栏和聊天采集操作中,移除了侧栏与顶栏的重复快捷按钮。面板精简重复标签,采集范围说明按需展开,标题或正文超限时在对应输入位置提示。保存和来源操作统一靠右排列,窄面板中的保存按钮也保持紧凑。
|
||||
|
||||
采集保存文本与 Markdown,不复制推理过程、工具日志或附件图片。生成中的回答暂不可采集,单条记录上限为 500 KiB,超限会提示缩减。采集本身无需调用模型;保存后自动评论仍按已有设置执行。笔记继续属于全局,来源项目只用于帮助回看。
|
||||
|
||||
> **【待补图 06|必补|聊天采集与来源返回】** 用公开演示对话预览并保存一条回答,展示笔记记录中的来源入口,再验证能返回原消息。计划文件:`assets/06-chat-note-source.png`。
|
||||
>
|
||||
> 建议图注:把有用的回答存入笔记,需要上下文时返回原会话。
|
||||
|
||||
### 笔记支持标签筛选,列表与正文并排浏览
|
||||
|
||||
给笔记加上“项目资料”“待整理”等标签后,可以按标签查找,也可以同时选择多个标签,筛出同时带有这些标签的笔记。每篇最多添加 10 个标签;标签支持改名、同名合并和删除,删除标签会保留笔记正文。
|
||||
|
||||
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口可从正文工具栏打开笔记列表抽屉,选完笔记后继续阅读;记录索引默认收起,“新建笔记”固定在页头。
|
||||
|
||||
正文使用当前栏的可用宽度,宽窗口下阅读画布和长内容有更多空间。新建文字或画布记录都可以取消,有草稿时先确认是否放弃;取消编辑、切换记录类型或返回聊天后,焦点回到对应的按钮或输入位置。
|
||||
|
||||
标签也可由支持魔法笔记工具的助手按你的要求添加或修改。快速采集面板继续用于保存聊天内容,标签管理在完整笔记工作区中进行。
|
||||
|
||||
### 在聊天中查找和整理 Obsidian 笔记
|
||||
|
||||
已有 Obsidian 笔记可以直接接入 GoodBuddy。在“设置 → 能力与工具 → MCP”的 Obsidian 卡片中,选择本机已注册的仓库,或限定一个文件夹,测试连接并保存,然后启用并分配给所用 Runtime。无需启动 Obsidian,也无需额外安装 Obsidian 插件或配置 REST 服务。
|
||||
|
||||
接入后,可以请助手查找笔记、读取内容,或将讨论结果写回仓库;也支持追加、局部修改、目录浏览,以及标签和属性操作。笔记仍保存在原仓库,不会自动复制成 GoodBuddy 知识库。
|
||||
|
||||
目前支持直连模型及 GoodBuddy 管理的本机 OpenCode、Continue 聊天,默认关闭;DeepSeek Harness 和远程执行暂不支持。启用并分配后,可按请求读写已配置范围内的笔记;多仓库调用需要明确目标仓库。接入本机文件不等于离线推理,读出的内容会随当前会话交给所选模型处理。
|
||||
|
||||
> **【待补图 07|建议|Obsidian 接入与读写】** 已有候选 `obsidian.png` 展示仓库范围和 Runtime 分配,尚需确认构建来源;正文主图优先补公开笔记的搜索、写入与回读结果。计划文件:`assets/07-obsidian-notes.png`。
|
||||
>
|
||||
> 建议图注:按仓库范围接入本机 Obsidian,在聊天中查找笔记并保存整理结果。
|
||||
|
||||
### 从输入区打开本地原生客户端
|
||||
|
||||
本地项目选择 Continue 或 OpenCode 后,输入框下方可以打开工作栏内的原生交互终端;选择 DS 后,可以在系统默认浏览器中打开官方 DeepSeek Harness Web 客户端。启动时沿用当前项目目录与所选模型,适合希望使用 Runtime 自身交互界面的用户。
|
||||
|
||||
原生客户端使用独立会话,不会自动接续或同步 GoodBuddy 中的聊天历史。DS 网页关闭后,本地服务继续运行,可以回到 GoodBuddy 重新打开或停止;退出应用会清理所属服务。
|
||||
|
||||
当前入口支持本地项目,远程原生客户端尚未接通。Continue 与 DS Web 需要可用的标准 Node 环境,首次准备环境可能需要下载。Continue、OpenCode 原生终端可使用已启用并分配的内置工具及自定义 MCP;故事图谱工具保持只读。
|
||||
|
||||
> **【待补图 08|建议|原生终端与 DS Web】** 已有 `continue.png`、`opencode.png`、`deepseekweb.png` 候选;发布前确认构建来源,并处理本机路径、浏览器书签及与演示无关的会话。Continue 图含模型能力警告,建议换用合适模型重拍;不能据启动画面宣称完整对话验收通过。计划文件:`assets/08-native-clients.png`。
|
||||
>
|
||||
> 建议图注:在工作栏打开原生终端,或在系统浏览器中使用 DS Web 的独立会话。
|
||||
|
||||
### Runtime 与模型可以分别跟随项目或临时切换
|
||||
|
||||
项目可以保留常用的执行方式与模型,某次会话需要换模型时,在输入区的两栏选择器中临时选择即可。菜单标明当前选择来自会话、项目还是全局,也可以恢复项目默认,减少每次新建会话重复配置。
|
||||
|
||||
执行方式与模型分别按“会话 → 项目 → 全局”解析,并检查模型是否适用于当前 Runtime。连接被删除或不兼容时按规则回退;缺少 API Key 时保留选择并提示配置,不会悄悄换用另一个模型。排队消息在实际发送时重新读取有效选择,定时任务则保留创建时确定的配置。
|
||||
|
||||
托管 SSH 项目支持 OpenCode、Continue;外部 OpenCode Server 使用服务端自身配置。
|
||||
|
||||
### 复制模型连接,保留已有配置
|
||||
|
||||
在“设置 → 模型连接”中打开一条 LLM 连接,点击“复制”,即可在副本中修改模型名称、地址或其他选项,再保存为新连接。协议、请求定制和认证配置随副本带入,已保存的 API Key 无需重新输入。
|
||||
|
||||
复制后仍需保存才会生效;当前最多保留 20 条连接。保存地址有误时,界面会指出对应连接并定位到地址字段,便于处理多条连接中的配置问题。
|
||||
|
||||
### Mermaid 图表可导出完整 PNG
|
||||
|
||||
聊天中的流程图或关系图可以打开大图窗口,再导出完整 PNG。导出内容不受当前缩放比例和拖动位置影响,适合插入报告、文档或演示材料。
|
||||
|
||||
图片沿用当前主题背景,最长边上限为 4096 像素。此功能用于 Mermaid 图表,故事线图谱的图片导出不在本轮范围内。
|
||||
|
||||
> **【待补图 03|建议|图表导出】** 在正常聊天中打开一张公开示例 Mermaid 图,执行 PNG 导出,并核对图片包含整张图。优先展示实际导出结果,图注注明它是导出图片。计划文件:`assets/03-mermaid-export.png`。
|
||||
>
|
||||
> 建议图注:从 Mermaid 大图窗口导出完整 PNG,保留图中的边缘节点。
|
||||
|
||||
### 让助手把会话图片保存到指定文件
|
||||
|
||||
可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
|
||||
|
||||
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址。成果页已移除,历史图片和成果记录仍保留;保存图片无需先进入成果页,既有成果也不会自动复制到项目目录。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
|
||||
|
||||
### 通过 Telegram 私聊使用通道项目
|
||||
|
||||
配置自己创建的 Telegram Bot、数字用户 ID 白名单和通道项目后,可以在 Telegram 私聊中发送文字问题或任务。GoodBuddy 沿用该项目的 Runtime、模型和工作目录处理请求,返回最终文字回复,并在桌面保留会话记录。
|
||||
|
||||
首次配置可以先留空发送者 ID,启用并保存通道。连接后私聊 Bot,发送 `/start` 和 `/whoami` 获取自己的数字用户 ID,填回白名单并再次保存后即可发送 AI 请求;仅测试连接不会开始接收消息。
|
||||
|
||||
当前支持单个 Bot 的普通私聊,桌面应用需要保持运行并能连接 Telegram。连接使用系统代理,支持断线恢复和停用取消;排除连接故障后,也可原样保存配置重新连接。目前不处理群聊、媒体输入或附件发送,图片等附件需回到桌面查看。
|
||||
|
||||
> **【待核对 C02|Telegram 实际联调】** 功能已提交,真实 Bot 与模型的完整收发验收仍待完成。对外发布前用专用演示 Bot 核对文字请求、回复、断线恢复与停用行为,不将本轮静态核查写成实际联调通过。
|
||||
|
||||
### 将本机文件导入本地或远程工作区
|
||||
|
||||
工作区文件面板的“新建文件”旁新增导入入口。选定目标目录后,可以批量选择本机文件,复制到本地项目或上传到远程 Host,随后在文件列表中查看。远程导入需要配套 Agent 0.15.4 或包含该功能的更新版本。
|
||||
|
||||
导入遇到同名文件时保留原文件,单个文件失败后继续处理其余文件。列表可以按名称、修改时间或可用的创建时间排序;排序针对当前目录已读取的条目,每目录最多 500 条。
|
||||
|
||||
### 桌面通知可以单独关闭
|
||||
|
||||
“设置 → 平台功能 → 通用设置”新增“桌面通知”开关,默认开启。关闭后,应用处于后台时的任务完成、失败或取消等系统提醒停止弹出,应用内通知仍然保留。设置保存后立即生效,重启后也会记住选择。
|
||||
|
||||
### 设备共享目录:登记设备,发布能力与知识说明
|
||||
|
||||
在应用中心启用“设备共享”,连接另行运行的 ShareServer 后,可以登记本机、浏览设备和发布目录,并发布或撤销能力、知识的元数据,供使用同一服务的人查看有哪些资源。
|
||||
|
||||
此功能默认关闭,当前提供目录管理。发布“知识”记录不会上传或同步正文,搜索、读取和下载标志仅表示声明;尚不提供远程执行或知识正文访问。设备登记也不表示设备在线,当前服务没有身份认证,应在可信环境中使用。
|
||||
|
||||
## 功能改进
|
||||
|
||||
### 请求直接使用当前 Runtime 与能力配置
|
||||
|
||||
聊天、任务和通道请求不再需要切换 Ask/Execute,也移除了原有的通用工具审批策略。选好 Runtime、模型与已启用能力后,即可提出请求;只需解释时可以直接说明,文件和工具操作使用执行账户的实际权限。
|
||||
|
||||
能力开关、工具分配、资源范围和取消操作继续生效,各 Runtime 的支持范围也仍有区别。监督回顾、摘要等专用文本分析任务保持无工具。已有会话内容保留,旧工作模式设置不再决定后续请求的行为。
|
||||
|
||||
### 项目与会话集中到一个菜单
|
||||
|
||||
左上角的双行按钮统一显示项目与当前会话,点击后浏览本地、远程和通道项目。可以分类搜索,悬停预览项目会话,也可以直接跨项目进入正在处理的工作;切回项目时恢复上次打开的会话。
|
||||
|
||||
菜单同时提供最近 10 个会话,以及待处理和运行中的工作;按已完成状态筛选时可以继续查找历史会话。项目设置、归档恢复和离开未保存草稿前的提示仍可使用。新建本地项目必须选择目录,远程目录选择器支持进入指向目录的符号链接。
|
||||
|
||||
### 会话搜索按需展开,快捷键提示集中在输入框
|
||||
|
||||
侧栏的搜索入口与“新建对话”放在同一行,点击后展开并自动聚焦,可查找会话标题或消息正文。切换会话保留关键词;点击关闭按钮,或在搜索控件行内按 Escape,可清空并收起搜索。展开和收起时,会话列表位置保持不变。
|
||||
|
||||
输入框的空白提示集中显示发送、换行、粘贴、新建对话及可用的快捷唤起按键,并按系统显示 Ctrl 或 Command。修改快捷键设置后提示随之更新;快捷唤起已关闭或注册失败时,不再显示不可用的按键。
|
||||
|
||||
侧栏底部的“应用”和“设置”保留文字并分开显示,应用菜单箭头与向上展开方向一致。会话列表独立滚动,短窗口中的入口仍可到达,滚动条和分隔线样式也作了统一。
|
||||
|
||||
### 知识库文档与来源集中浏览
|
||||
|
||||
知识库将内容来源与文档索引合并为统一列表。单文档来源合成一行,目录继续按组展示,导入失败、尚未生成文档的来源也保留恢复入口。搜索可以同时查找来源与文档,网址来源保留原网址,便于辨认同名资料。
|
||||
|
||||
列表突出查看入口,打开原文、分块管理、重新解析等操作收进更多菜单。解析结果页也整理了返回导航和图片操作,减少长名称、多个按钮挤在一行的情况。
|
||||
|
||||
任务历史改为按批次展示,可以展开处理步骤,查看进行中、失败与历史记录。筛选与计数跟随当前文档或来源,便于检查一次导入的处理情况。
|
||||
|
||||
### 图谱列表与画布各自浏览
|
||||
|
||||
故事线图谱侧栏可以按故事、经验、事件、实体和关系查看,列表与详情各自滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别。平铺与时间螺旋都使用中间画布的可用空间,视角等控件收进紧凑操作区,减少窗口较矮时内容被挤出屏幕的问题。
|
||||
|
||||
工作回顾与图谱共用选中的历史结果和范围说明,从总结切到图谱时继续查看同一份回顾。新回顾的输入区域可以收起,历史选择器和视角工具排列更紧凑;详情布局也适配较窄窗口。
|
||||
|
||||
### 手动回顾与智能心跳共用增量进度
|
||||
|
||||
原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。
|
||||
|
||||
手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化。项目与全局共用来源处理进度:同一版本的来源已成功发布后,换一个范围回顾可以复用,减少重复提取。没有新来源时仍可补做积压的故事与经验整理,没有待处理工作才跳过模型调用。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。
|
||||
|
||||
启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。
|
||||
|
||||
监督者设置中可以单独选择一条文本模型连接,用于回顾、故事和经验整理,以及建议生成;未指定时跟随默认文本模型。选定连接失效或缺少 API Key 时明确提示,不会自动换用其他模型。
|
||||
|
||||
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录,长时间范围可能分成多批;心跳不再额外生成一份独立报告。并发数和超时设置不等于总费用上限。
|
||||
|
||||
### 长回顾可以看进度、保留批次并继续处理
|
||||
|
||||
监督者的“活动记录”集中展示手动和自动执行情况。展开记录,可以查看当前阶段、已保存批次、剩余来源和失败原因,再按项目、会话、批次检查已保留事实与来源片段。
|
||||
|
||||
回顾会分页读取选定范围内的来源,将长内容拆成批次处理。成功批次保存后,后续阶段失败不会把这些内容一并丢弃;暂停或失败后可以继续。0.13.16 还取消了按固定累计时长自动暂停的行为,长回顾可以持续处理至完成,无需隔一段时间手动续跑。
|
||||
|
||||
需要调整处理方式时,可在“监督者 → 设置”中配置模型并发、单次模型超时、每次读取的来源条数、每批消息和文本量,以及单次响应容量。新建回顾会保存分批配置;来源已被修改或删除时,需要重新发起回顾。
|
||||
|
||||
> **【待补图 04|必补|回顾进度与来源批次】** 展开真实运行的活动记录,展示阶段、已保存批次和来源正文。若展示暂停后继续,须通过真实操作完成,不注入运行状态。图 01 的演示可复用同一份记录。计划文件:`assets/04-review-progress.png`。
|
||||
>
|
||||
> 建议图注:从活动记录查看处理阶段与已保存批次,展开核对来源。
|
||||
|
||||
> **【待核对 C01|暂停与取消配图】** `v0.15.0` 已包含 `5116613` 的暂停控件修复与取消入口,见末尾“回顾运行期间的控制”。截图须对应目标版本,展示实际暂停、继续和取消操作。
|
||||
|
||||
分页和批次记录便于核查处理范围,模型给出的总结、实体和关系仍需人工判断;来源处理完成不代表每个事实都被模型准确理解。
|
||||
|
||||
### 待办、任务与用量更便于浏览
|
||||
|
||||
魔法笔记的待办页可以拖动列表与详情之间的分隔条,调整后的宽度会保存。来源笔记标题支持独立折叠,收起一个分组不会清空正在查看的任务详情。分组折叠状态不跨重启保存,窄窗口下不提供横向拖宽。
|
||||
|
||||
笔记详情的记录索引与 AI 区开关集中在正文顶部的固定工具栏,图标带有悬停提示。任务中心展开列表后,可以直接选择“全部、进行中、暂停、已结束”;默认显示进行中的任务,历史任务仍可通过切换状态查看。
|
||||
|
||||
> **【待补图 05|建议|待办布局】** 用虚构笔记创建不同来源的待办,拖动分隔条并折叠一个来源组,保持另一项详情可见。计划文件:`assets/05-note-todos.png`。
|
||||
>
|
||||
> 建议图注:调整待办列表宽度,按来源笔记收起暂时不看的任务。
|
||||
|
||||
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。“系统任务”页签单列监督回顾、笔记、知识库等后台用量,并补齐回顾、图片重新生成、知识图谱提取和远程嵌入等调用的用量记录;过去未记录的用量无法补算。页面打开时加载,可见期间每 15 秒刷新,也可点击页头手动刷新。这里汇总的是应用记录,并非服务商计费账单。
|
||||
|
||||
会话和任务耗时按实际运行状态累计,包含模型与工具执行,等待用户回答时暂停,运行期间持续更新。并行请求的时间会相加,因此它不等于项目经历的日历时长。旧历史不补算,缺少计时数据且累计为零时显示“不可用”,与真实的零时长区分。
|
||||
|
||||
### 长对话输入、切换与流式回复减少重复处理
|
||||
|
||||
任务耗时按运行状态累计,避免反复扫描历史消息和事件;隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
|
||||
|
||||
运行记录按需分页加载,筛选时读取对应范围,保存时只传递变化的记录;页面计数与汇总仍覆盖已保存历史。离开页面后释放多余已加载页,重新查看时可以继续加载,保留原有内容与顺序。实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
|
||||
|
||||
聊天输入和流式回复减少了无关活动面板的刷新;置顶或取消置顶会话时,也不再附带扫描附件引用。笔记启动检查复用已读取的正文信息,减少重复读取,原有内容恢复与清理流程保留。
|
||||
|
||||
长回答逐步生成时,已完成的 Markdown 区块可以复用,减少每次新增文字都重新处理整篇内容。切回近期看过的会话时也会复用缓存结果;会话侧栏与运行记录减少无关刷新,降低回复期间继续输入和切换会话的开销。
|
||||
|
||||
较长会话和大量会话的侧栏主要渲染屏幕附近的内容,历史记录仍然保留,可以继续加载和定位。切回缓存会话时恢复阅读位置;Markdown 解析结果也可复用,包含公式的长回复减少重复解析。
|
||||
|
||||
知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。
|
||||
|
||||
文档解析、分块与摘要哈希计算也转到后台线程;笔记网格、工作区文件树和知识库文档表主要绘制可见区域,终端连续输出合并刷新。启动时按需加载部分组件,并在后台准备工具环境,需要这些工具时再等待准备完成。
|
||||
|
||||
### 设置页减少来回查找
|
||||
|
||||
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
|
||||
|
||||
设置导航按用途分组,开关统一放在右侧。通用设置集中放置会话与通知、工作目录等选项;本地语音和嵌入模型页面提供“模型下载源”快捷入口,可打开统一弹窗,在 ModelScope 与 Hugging Face 之间切换。选择影响后续托管模型下载,已安装模型、ZIP 导入、Ollama 和应用更新不受影响。
|
||||
|
||||
外观设置新增透明磨砂效果,当前默认开启;标题栏和相关卡片使用半透明背景,可以看到其后的应用内容。可以关闭恢复实色显示,已有的外观选择继续保留。
|
||||
|
||||
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;仍需先生成有效计划,再应用对应变更。
|
||||
|
||||
HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。
|
||||
|
||||
成功和提示通知采用更紧凑的卡片,两秒后自动消失;错误提示继续保留,直至手动关闭。删除会话的确认框也去掉了重复警告,永久删除的含义不变。
|
||||
|
||||
## 问题修复
|
||||
|
||||
### 直连文本模型恢复手动压缩上下文
|
||||
|
||||
本地直连文本会话可以从输入区点击“压缩上下文”,即使关闭自动压缩或尚未达到触发阈值,也能手动执行。压缩使用配置的摘要连接,摘要随会话保存并用于后续对话,同时保留最近轮次和完整聊天历史;关闭自动压缩后,已有有效摘要仍可使用。
|
||||
|
||||
会话运行或正在压缩时需等待完成;没有可压缩的较早历史时会提示。这项修复适用于本地直连文本会话,不包含图像生成连接、远程通道会话或 SSH 项目。
|
||||
|
||||
### 长请求持续保留已授权的工具访问
|
||||
|
||||
修复桌面 Agent 请求持续超过十分钟后,工具授权提前失效的问题。请求仍在执行时,已启用并分配的知识库、笔记、浏览器等工具可继续使用;完成、取消或失败清理时仍会撤销该次授权。工具自身的调用超时、读写权限和开关设置保持不变。
|
||||
|
||||
### 取消与失败提示更清楚
|
||||
|
||||
取消和失败信息集中显示在消息状态中,减少与回答正文或工具详情的重复。主动取消使用中性提示,真实失败继续显示错误状态,已经生成的回答保留。
|
||||
|
||||
最后一条失败或取消消息有可恢复的原问题文字时,可以点击“重新编辑”将文字回填输入框,修改后自行发送;不会自动重发,也不承诺恢复附件。
|
||||
|
||||
取消或失败的轮次在后续上下文中明确标为中断,并保留已有回答,避免上一条未完成请求被并入新问题继续执行。OpenCode 复用会话在取消后的下一轮也会收到中断说明。
|
||||
|
||||
### 远程回答完成后,可以继续对话
|
||||
|
||||
远程请求现在等待模型交付确认后再结束。回答已经完成时,随后回收空闲 Runtime 失败不会再把结果改成“未知”。确实遇到结果未知的请求,用户显式发送新消息后会建立新的运行绑定并带入已有历史;自动恢复仍只重新连接原请求,不会擅自重复执行结果未知的任务。
|
||||
|
||||
最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。
|
||||
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮基础配套为 Agent 0.15.4,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
|
||||
最新修复使远程 Runtime 在等待启动时仍可取消,并避免阻塞其他会话;本地 OpenCode 提交失败后也能及时结束等待并显示原因。远程部分需要包含 `bbf24c28` 改动的 Agent,不属于已核查的 Agent 0.15.4 标签。
|
||||
|
||||
### 通道交付与统计结果
|
||||
|
||||
- 修复生成图片成功后,通道因结果格式校验而误报失败的问题。Telegram 仍只发送文字提示,附件需到桌面查看。
|
||||
- 钉钉返回业务错误时不再被当作送达成功,可以进入已有重试流程。
|
||||
- 修正 OpenCode 缓存命中率偏高、完全命中时显示为空的问题;已有用量重新查询后使用完整输入量计算,不表示实际缓存命中率提高。
|
||||
- 活动汇总覆盖已加载的会话集合,不再只统计前 5,000 个会话。
|
||||
- PPTX 解析警告显示实际使用的 OCR 服务及失败的幻灯片、图片位置,便于排查;这项修复不改变外部 OCR 服务本身的识别结果。
|
||||
|
||||
### 启动、搜索与回顾结果
|
||||
|
||||
- 修复正常聊天或保存笔记后,重启反复显示历史迁移的问题。普通数据库结构升级也不再因正常空闲页额外整理整库;确需转换的旧数据仍会处理,启动页区分实际操作。
|
||||
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出;工具使用当前执行账户权限。这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
|
||||
- 修复早期监督回顾只取部分会话、消息和正文的问题,改为对入选来源分页、分批处理。事实与来源独立保存,只有必要阶段完成并成功保存结果后才标记完成。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。
|
||||
- 监督模型遇到可识别的临时网络中断、限流或部分服务端错误时,清理失败请求后自动再试一次,等待期间可以取消。认证、配置、费用额度、超时、取消及容量错误不会触发这次重试;失败尝试已报告的用量仍会记录,重试可能增加模型费用。
|
||||
- 故事或经验整理失败时,已保存的回顾结果继续可用,可以单独重试整理。故事归类减少把各功能的提交与测试统归到“发布”的情况;经验应用记录也增加时间顺序与共同实体核对,归纳内容仍需结合来源判断。
|
||||
- 时间螺旋退出时释放图形资源;3D 初始化失败或图形上下文丢失时,在视图内提示并提供重试,也可切回平铺,避免整个监督页面无法使用。
|
||||
- 修复历史回顾入口跳到其他结果、切换后旧响应覆盖当前选择的问题。侧栏、活动记录和图谱按对应结果定位,人工确认与修订的内容保留。
|
||||
- 修复关闭跨项目关联后候选范围未正确限定、暂停发生在最终查询期间仍继续发布的问题。故事整理期间已移除的故事不会再收到新关联,刷新后的控件也不会因旧请求失效而一直禁用。
|
||||
|
||||
### 日常界面操作
|
||||
|
||||
- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,每五秒检查一次;悬停、滚动后的短暂停留、菜单展开或键盘焦点位于列表时暂缓重排,方便点选。置顶会话继续优先显示。
|
||||
- 输入框的附件、语音与选项按钮统一尺寸、间距和悬停反馈,录音中和选项展开时仍保留对应状态提示。
|
||||
- 草稿和排队消息中的图片可以直接打开预览,发送前更容易核对附件。文本输入框移除重复问候,只保留快捷键提示。
|
||||
- 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。
|
||||
- 笔记保存后退出编辑;从快速笔记跳到指定记录时,等待正文显示后再定位,减少落在错误位置的情况。
|
||||
- 切回已查看的会话时,立即显示缓存的累计回复时长,再后台刷新;缓存限本次应用运行期间。
|
||||
- 长对话滚出屏幕后再返回,推理、工具输入输出、引用和子代理详情保留展开选择;该状态限历史面板存续期间,不跨应用重启保存。跨屏选择、复制与滚动定位也作了修复,跳到底部恢复平滑滚动。
|
||||
- Mermaid 大图放大后,顶部和左侧等边缘仍可通过滚动、拖动到达,窄窗口中的控件可以换行。
|
||||
- Git 分支选择器悬浮在工作区上方,不再把文件列表和提交历史向下挤动;项目选择菜单可以浮出侧栏,长名称与路径有更多显示空间。
|
||||
- 文件差异页将查看文件、刷新和复制路径集中到路径旁,窄面板中可以换行;只有未暂存改动时,省去重复的分区标题。浏览器“前往”按钮改为紧凑图标,保留悬停提示。
|
||||
- 聊天与浏览器、终端工作栏之间的分隔条恢复完整拖动区域;笔记缩略图与内容边缘对齐,键盘焦点轮廓保持可见。
|
||||
- 纯文本排队消息不再显示无效的“查看附件”按钮;浏览器页签关闭失败后重试成功,会清除残留错误。
|
||||
- 监督反馈显示会话或任务名称,固定与刷新按钮在窄侧栏中排列更紧凑。工作回顾和活动页使用可用宽度,运行阶段与已保存批次更醒目。
|
||||
|
||||
### 运行配置与界面修复
|
||||
|
||||
- OpenCode 原生终端减少重复准备步骤,缩短打开时的等待;临时 MCP 工具名缩短并在同一会话中尽量复用,减少工具名称过长导致的调用失败。
|
||||
- 本地 OpenCode 初始化工具连接时,遇到明确的暂时性传输故障,清理成功后最多重试一次,不重放问题或工具调用。迟到的旧连接结果不再替换当前连接;自定义 MCP 中一台服务加载工具失败时,其他正常服务仍可使用。
|
||||
- 修复 Continue 禁用自动更新后仍显示“Checking for updates”的问题,避免空闲终端显示误导性的更新状态。
|
||||
- 补齐 DS Web、远程 Runtime 和模型桥辅助进程启动时的遥测关闭配置;模型、MCP 和用户主动发起的联网请求保持可用。远程遥测配置与 Continue 更新检查修复由 Agent 0.15.0 配套提供,对更新 Host 后新启动的 Runtime 生效,已有进程不会热更新。
|
||||
- 修复知识库不同来源的操作相互阻塞,以及多文档来源中任务范围、筛选计数不一致的问题。
|
||||
- 修复深浅主题中危险操作按钮、禁用状态、控件边框和键盘焦点显示不一致的问题,包括放弃笔记草稿的确认框。
|
||||
- MCP 编辑器和图片预览可以在父弹窗上正常操作;项目弹窗的 Escape 只处理当前弹窗,帮助层覆盖浏览器时不再被原生页面遮挡。
|
||||
- HTML 全屏预览恢复键盘进入与 Escape 返回,继续保持原有沙箱限制。
|
||||
- 窄工作栏的添加按钮不再被滚动箭头遮挡,模型连接协议文字保持可读;DS 入口移除常驻说明,重新打开和停止服务操作继续保留。
|
||||
- 多页签工作栏的滚动箭头不再遮挡关闭按钮。DS 网页和服务操作名称更明确,停止确认与取消按钮同行显示,减少输入区跳动。
|
||||
|
||||
### 回顾运行期间的控制
|
||||
|
||||
回顾运行期间,范围、时间选择和刷新入口继续可用;修改选择不会改变已经启动的回顾。重复点击发起按钮会提示已有运行,避免同时创建重叠回顾。
|
||||
|
||||
活动记录增加取消正在运行回顾的入口,并修复继续回顾后无法再次暂停的问题。暂停保留进度供继续处理;取消会保留已成功保存的批次,但该次回顾不能再继续。取消清理结束前显示“正在取消”,未完成结果不会发布,之后可以新建回顾。
|
||||
|
||||
手动发起、继续与自动回顾共用执行限制;自动回顾在忙碌时等待,手动操作会提示当前已有运行。同一次回顾内部仍可按设置并行处理批次。
|
||||
> **编辑提示:** 发布前补齐生产截图,替换故事线概念图,并核对下载地址、发布日期和更新日志链接。以上提示属于编辑流程,不影响本文所述功能已完成的状态。
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
# 0.13.8 起综合宣传稿核查记录
|
||||
|
||||
## 2026-10-09 宣传版重排
|
||||
|
||||
本轮按宣传重点重写正文,将故事图谱、Telegram 和 Obsidian 接入作为三条主线;底部输入区、Runtime、笔记、性能和稳定性改进合并到“其他改进”。当前版本内所述功能均按已完成能力表述,发布前的待办只保留生产配图、公开下载信息、发布日期和更新日志链接,不再把功能验收材料写成开发状态。
|
||||
|
||||
正文保留必要的使用范围和升级提示,提交依据、实现边界与历史变更继续留在本核查记录中。原正文中的概念图仍只作示意,发布前需要用目标版本的生产截图替换。索引已同步更新,未修改产品代码。
|
||||
|
||||
早期核查中关于 Telegram 真实联调的 C02 标记属于当时的验证记录,不再作为当前正文的功能状态描述;本轮按用户确认,将 Telegram 作为当前版本已完成能力介绍。发布前仍需补齐公开演示素材和发布信息。
|
||||
|
||||
## 2026-10-09 截至 3556df88 的增量核查
|
||||
|
||||
本轮固定范围 `47917094..3556df88`,共 5 个提交;全文范围为 `v0.13.7..3556df88`。`v0.15.13` 与 `agent-v0.15.6` 均指向 `3556df88`。本轮没有新增独立宣传功能,连续提交合并回已有的输入区 UI 与 Runtime 修复说明。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `69f64df2`、`4b4531ab` | 补强“底部输入区工具栏更紧凑” | 恢复 Composer 弹层与工具入口的集成,保留打开后聚焦第一个可用控件、外部点击或焦点离开关闭、Escape 返回触发按钮焦点的最终行为;测试夹具调整不单独宣传。 |
|
||||
| `adb58d92`、`3556df88` | 合入 Runtime 网络请求修复 | 本地模型请求可通过 Electron 的 `net.fetch` 发送,监督模型连接也沿用该请求路径并保持既有配置校验;不扩大为所有网络故障已解决或跨平台安装包验收完成。 |
|
||||
| `d7fb4299` | 仅留内部验证依据 | 使用真实 fetch 的监督模型覆盖测试,不写成当前轮次已执行的产品测试或真实模型验收。 |
|
||||
|
||||
本轮只更新宣传正文、核查记录和索引,未修改产品代码。未运行产品测试、构建、真实模型请求或拍摄新截图;生产配图、Telegram 真实联调和公开下载信息继续待核验。`deai-writing` 扫描结果沿用上一轮为阻断项 0、复核项 19;`git diff --check -- docs/pr-articles` 通过,仅提示既有 LF/CRLF 行尾策略。
|
||||
|
||||
## 2026-10-08 截至 47917094 的增量核查
|
||||
|
||||
本轮固定范围 `384683e8..47917094`,共 23 个提交;全文范围为 `v0.13.7..47917094`。Desktop/Agent 版本依据仍为 `0.15.13`/`0.15.6`,本轮没有新的版本准备提交。正文按用户场景合并了表格编辑、专家与 Runtime 控制入口;输入区、历史导航和存储测试相关的连续提交没有拆成多个宣传条目。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `3d063937` | 新增“在魔法笔记中插入和编辑表格” | Quill 原生 table Delta 经过编辑器、共享契约、IPC、存储和只读渲染链路;支持设置行列、行列增删和整表删除,表格上限 20 行/12 列。空单元格表格可保存并重新打开,空白富文本仍拒绝保存。 |
|
||||
| `13b58627`、`1a59c2d8`、`670b5985` | 新增“在当前会话中使用专家角色和 Runtime 控制”,并合并底部输入区 UI 改进 | 专家和 Runtime 控制收进 Composer 工具栏。选定专家或智能路由得到的角色说明加入当前 Runtime 的受信任提示词,沿用当前会话的模型、工具、流式输出和权限;普通对话不转为额外子任务,团队模式仍走独立团队路径。上下文来源共用一个入口,OpenCode/Continue 的控制项进入独立弹窗;点击外部区域或焦点离开时关闭,Escape 返回触发按钮焦点,运行控制弹窗移除多余分隔线。 |
|
||||
| `e2a5f836`、`47917094`、`4266ff5`、`4a2ea490` | 仅记录历史导航方案的收回和文档整理 | `e2a5f836` 发布了聊天历史导航文档,曾尝试取消向上滚动的 80 条增量限制;`47917094` 恢复批量历史加载。当前仍保留历史窗口和约 60 条 DOM 窗口化,不能宣传为已实现连续滚动或 minimap。用户指南和 README 截图整理不单列为功能。 |
|
||||
| `377b14fc`、`a406f596`、`7f0a73e6`、`2d68d104`、`277da413`、`e1360264`、`59a28209`、`9788b4cd`、`c90ad2af`、`95ece25c`、`c4ab83a7`、`7eb11302`、`5ba127e3`、`2a92f8f8`、`95f44195` | 仅留内部验证依据 | 存储进程取消、排空、崩溃恢复和测试夹具稳定化,不写入宣传正文,不据此扩大存储进程故障恢复或跨平台验收结论。 |
|
||||
|
||||
本轮只更新宣传正文、核查记录和索引,未修改产品代码。未运行产品测试、构建、真实模型请求或拍摄新截图;提交中已有的表格、聊天历史和存储测试记录只用于核对实现边界。生产配图、Telegram 真实联调和公开下载信息继续待核验。deai-writing 扫描结果为阻断项 0、复核项 19、读取错误 0;复核项集中在版本、素材、能力边界和必要否定条件,未机械删除。`git diff --check -- docs/pr-articles` 通过,仅提示既有 LF/CRLF 行尾策略。
|
||||
|
||||
## 2026-10-08 截至 384683e8 的增量核查
|
||||
|
||||
本轮固定范围 `e50ca6d1..384683e8`,共 22 个提交;全文范围为 `v0.13.7..384683e8`。版本资料已推进到 Desktop 0.15.13、Agent 0.15.6;HEAD 位于版本准备提交之后,最后一组提交主要是存储故障验收夹具和测试稳定化,不另列为产品功能。正文仍按新增功能、功能改进、问题修复组织,并将连续的架构与性能改动合并为一节。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `d80d42b0`、`8473189f`、`904bb691`、`1f85d0c0` | 合并为“存储与请求路径合并,减少主进程等待” | Desktop SQLite 的 Assistant、Knowledge、附件、Runtime 绑定和用量记录改由同一 storage utility process 负责;查询、文件操作和回顾来源读取共用类型化异步调用。同步保留事务、提交确认、取消、关闭排空和故障报告边界。移除无依据的产品侧 admission、子任务和解析 worker 队列上限,不宣传为全量并发或跨平台性能验收;存储进程故障后未确认写入仍需按域核对。 |
|
||||
| `13c29bad`、`c1e4bbf6`、`3e3a0216` | 更新魔法笔记标签节 | 标签管理器新增直接创建标签;保留空标签集合,重复标签错误保留原始原因。正文不扩大为自动打标签或取消每篇最多 10 个标签的限制。 |
|
||||
| `e0607a15`、`2be57492` | 合入回顾结果修复 | 支持短引用和已知历史键映射;删除会话后回顾可继续查看或处理,暂停、继续、重试及项目范围规则仍有效。 |
|
||||
| `56b8b10d` | 新增直连模型工具失败后的继续对话说明 | 工具执行失败不会阻塞后续直连模型消息,分页输出边界、工具权限、取消和超时规则保留。 |
|
||||
| `425accf4`、`4bb7573b`、`edf019aa` | 合入日常界面改进 | 直接进入设置页时预加载目标路由;工作区文件列表元数据和聊天输入区过渡更简洁。均不单列为新能力。 |
|
||||
| `b90ff50f`、`36e671a0`、`fbfafa15` | 更新版本与监督稳定性依据 | 监督任务交付和停顿修复并入既有监督修复范围;版本准备资料为 Desktop 0.15.12/0.15.13、Agent 0.15.5/0.15.6。 |
|
||||
| `42ad8a98`、`4c25e85b`、`7eb51bd5`、`9212fd43`、`384683e8` | 仅留内部验证依据 | 存储故障验收、权限夹具、容量断言和测试竞态稳定化,不写入宣传正文,也不据此宣称完整 C01–C07 或跨平台性能验收完成。 |
|
||||
|
||||
本轮未修改产品代码,仅更新宣传正文、核查记录和索引。未运行产品测试、构建、真实模型请求或拍摄新截图;本轮提交中的测试记录只作为静态核查依据。生产配图、Telegram 真实联调和公开下载信息继续待核验。deai-writing 扫描结果为阻断项 0、复核项 18、读取错误 0;复核项均为版本、素材、能力边界或模型结果限定,未机械删除。`git diff --check -- docs/pr-articles` 通过,仅提示既有 LF/CRLF 行尾策略。
|
||||
|
||||
## 2026-10-05 截至 e50ca6d1 的增量核查
|
||||
|
||||
本轮最终范围 `9527402..e50ca6d1`,共 60 个提交;全文范围为 `v0.13.7..e50ca6d1`。初次固定至 `bbf24c28` 的 58 个提交,收尾补查新产生的两项提交。本地桌面 `v0.15.11` 与 Agent `agent-v0.15.4` 同指向 `78afec77`,上一桌面 `v0.15.10` 指向 `ff61da28`。Telegram、自定义回顾日期、历史耗时缺失提示及最后一组运行修复在 0.15.11 标签之后,均已提交,不将其混入该标签,也不使用开发状态标签。以下取代旧节的当前状态描述,旧节仅作历史依据。
|
||||
|
||||
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
|
||||
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-05 更新) | v0.15.11(78afec77)及之后截至 e50ca6d1;汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补 Telegram 私聊、统一执行、项目会话菜单、文件导入、自定义回顾日期、监督模型与会话图谱开关、临时失败重试、3D 和性能改进;Telegram 实际联调、生产配图及公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-09 更新) | Desktop 0.15.13/Agent 0.15.6 及之后截至 3556df88;汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Telegram 与 Obsidian 接入 | 草稿,按故事图谱、Telegram、Obsidian 三条主线重排;其他功能合并为辅助改进,生产配图及公开发布信息待补 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
|
||||
Generated
+1559
-20
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.13",
|
||||
"version": "0.16.3",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
@@ -20,6 +20,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"docs:dev": "vitepress dev sites/docs --config sites/docs/.vitepress/config.mts",
|
||||
"docs:build": "vitepress build sites/docs --outDir sites/docs-dist --config sites/docs/.vitepress/config.mts",
|
||||
"docs:preview": "vitepress preview sites/docs-dist",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.agent.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"lint": "eslint .",
|
||||
@@ -419,6 +422,7 @@
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^7.3.6",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,6 +1,162 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.16.3",
|
||||
"releasedAt": "2026-10-14",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**将远程主机管理迁移到应用中心**:现在可以从“应用”菜单进入 Hosts,集中查看和管理远程主机、Agent 版本与可用安装包。主机管理和 Agent 包管理分成独立页签,原有远程项目和连接配置继续保留。",
|
||||
"**更快查看本地 Agent 包**:打开 Hosts 时会先展示已经存在的本地 Agent 包库存,刷新远程信息不会让已有包暂时消失。",
|
||||
"**为聊天历史增加缩略图导航**:长对话可以通过右侧缩略图快速定位消息,仍保留连续历史加载和窗口化显示;也可以在外观设置中关闭缩略图,恢复原生滚动条。",
|
||||
"**优化应用中心和设置入口**:远程主机不再作为设置中的独立主机管理入口,应用中心、项目菜单和设置入口的布局与文字提示更加清晰。",
|
||||
"**改善聊天和监督体验**:等待状态增加更明确的视觉反馈,监督图谱缩放会以指针位置为中心,文件附件选择范围和界面边界处理更加一致。"
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema。首次使用 Hosts 应用前,请确认远程主机连接信息和 Agent 包来源;Agent 0.15.6 不需要更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Move remote-host management into the Apps center**: Open Hosts from the Apps menu to view and manage remote hosts, Agent versions, and available packages in one place. Host management and Agent package management use separate tabs, while existing remote projects and connection settings remain available.",
|
||||
"**Show local Agent packages sooner**: Hosts displays the Agent packages already available locally before refreshing remote information, so existing packages do not temporarily disappear during refresh.",
|
||||
"**Add a chat-history minimap**: Use the minimap beside long conversations to jump quickly between messages while continuous history loading and windowed rendering remain available. You can disable it in Appearance to restore the native scrollbar.",
|
||||
"**Improve the Apps center and settings entry points**: Remote hosts are no longer managed through a separate settings entry, and the Apps center, project menu, and settings labels now provide clearer navigation.",
|
||||
"**Improve chat and Supervisor interaction**: Waiting states provide clearer visual feedback, graph zoom centers on the pointer, and attachment selection plus related interface boundaries behave more consistently."
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Before using Hosts, confirm your remote-host connection details and Agent package source; Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.16.2",
|
||||
"releasedAt": "2026-10-10",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**支持全部时间的监督回顾**:监督者现在可以选择从最早保存的工作记录到当前时间的全部时间范围,适合查看长期积累的工作进展。",
|
||||
"**只在稳定后纳入监督回顾**:正在生成的消息和运行中的任务会等到完成后再进入回顾,避免回顾内容随着实时输出变化。"
|
||||
],
|
||||
"fixes": [
|
||||
"**让 Magic Notes 操作更容易理解**:笔记入口和操作按钮增加清晰的文字标签,更多操作在需要时显示,减少误解和界面干扰。",
|
||||
"**修复终端输入法布局**:在终端中输入中文或其他组合文字时,输入候选和组合区域会保持在终端边界内,不再撑开或遮挡周围界面。"
|
||||
],
|
||||
"notices": [
|
||||
"本版本首次启动会将数据库升级到 schema 63。升级前请关闭应用并备份完整数据目录、相邻 `notes/` 和设置文件;Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Review all time in Supervisor**: Supervisor now offers an all-time range from the earliest saved work records through the current time, making it easier to review long-running progress.",
|
||||
"**Review sources only after they settle**: Messages that are still streaming and tasks that are still running wait until completion before entering a review, so review content does not change underneath an active run."
|
||||
],
|
||||
"fixes": [
|
||||
"**Make Magic Notes actions easier to understand**: Note entry points and actions now use clearer text labels, while secondary actions appear when needed to reduce confusion and visual noise.",
|
||||
"**Contain terminal IME layout**: Chinese and other composition input in the terminal now stays inside the terminal bounds instead of expanding into or obscuring surrounding UI."
|
||||
],
|
||||
"notices": [
|
||||
"First launch upgrades the database to schema 63. Close the app and back up the complete data directory, adjacent `notes/`, and settings files before upgrading; Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.16.1",
|
||||
"releasedAt": "2026-10-10",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**让消息自然延伸到输入框边缘**:修复聊天消息在输入框上方提前被实色区域截断的问题,现在消息会沿着输入框轮廓自然淡出,输入区和消息区的过渡更平滑。",
|
||||
"**改善输入框与消息区的主题适配**:调整浅色和深色主题下的渐变、背景和焦点边界,减少输入框上方出现粗边或明显横线的问题;滚动到底部按钮也会保持合适的可见位置。"
|
||||
],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Let messages fade naturally to the composer edge**: Chat messages no longer stop at a solid band above the composer. They now fade along the composer outline for a smoother transition between the message area and input area.",
|
||||
"**Improve composer and message-area theme behavior**: Light and dark theme gradients, backgrounds, and focus boundaries are refined to reduce thick edges and visible horizontal bands above the composer; the scroll-to-bottom control also remains positioned for clear access."
|
||||
],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"releasedAt": "2026-10-09",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**在输入框中选择专家或团队角色**:发送消息前可以直接在输入框顶部选择专家或团队角色,选择入口更接近实际发送操作。每个会话独立保存自己的选择,切换会话时不会误用其他会话的角色。",
|
||||
"**保留历史消息使用的角色**:已发送的回复会保留请求当时使用的专家或团队角色,即使之后重命名或删除该角色,历史记录仍能准确显示当时的使用情况。"
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Choose an expert or team role from the composer**: Select an expert or team role directly at the top of the composer before sending a message. Each conversation keeps its own selection, so switching conversations does not accidentally carry over another conversation’s role.",
|
||||
"**Preserve the role used by historical messages**: Sent replies retain the expert or team role selected when the request was made. Later renaming or removing that role does not make existing history lose the context of how it was produced."
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.14",
|
||||
"releasedAt": "2026-10-09",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**恢复历史监督回顾的继续操作**:对于旧版本遗留的“来源已变化或已移除”失败回顾,现在可以直接从监督活动中继续,而不必重新开始整个回顾。",
|
||||
"**让变化中的来源能够有界地重新纳入回顾**:来源在回顾期间发生变化时,系统会重新冻结并处理有限次数;持续变化的来源会被省略,避免回顾无法结束。已删除、归档或不再可用的来源会按现有规则安全省略,同时保留其余来源的进度。",
|
||||
"**改进监督活动状态**:监督活动继续保留暂停、失败、取消和已保存批次的状态,继续操作不会绕过项目范围和来源处理规则。",
|
||||
"**让设置分类更易识别**:设置页的分类现在显示对应图标,帮助用户更快找到外观、模型、Runtime、通道和其他设置。"
|
||||
],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,继续使用 schema 62。监督回顾可能产生模型调用费用;持续变化的来源会被有限次数地重新处理,之后自动省略。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Resume historical Supervisor reviews**: Reviews left in the legacy “source changed or was removed” failure state can now be continued directly from Supervisor Activity instead of restarting the entire review.",
|
||||
"**Re-review changing sources within a bound**: When a source changes during a review, GoodBuddy re-freezes and processes it for a limited number of attempts. A source that keeps changing is then omitted so the review can finish. Deleted, archived, or unavailable sources continue to be omitted under the existing rules while surviving progress is retained.",
|
||||
"**Improve Supervisor activity state**: Paused, failed, cancelled, and partially saved review states remain available, and resuming a review continues to follow the project-scope and source-processing rules.",
|
||||
"**Make settings categories easier to scan**: Settings categories now show matching icons, helping users find Appearance, Models, Runtime, Channels, and other settings more quickly."
|
||||
],
|
||||
"notices": [
|
||||
"This release adds no database schema version and remains on schema 62. Supervisor reviews may use model calls; continuously changing sources are reprocessed a limited number of times and then omitted."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.13",
|
||||
"releasedAt": "2026-10-07",
|
||||
|
||||
+43
-4
@@ -1,6 +1,6 @@
|
||||
# GoodBuddy 静态官网
|
||||
|
||||
`sites` 是无需构建步骤或额外依赖的中英文静态官网源码,可直接托管整个目录。
|
||||
`sites` 包含中英文官网和使用 VitePress 默认主题的用户手册。官网可直接预览;手册正文使用 Markdown 维护,发布前需要构建。
|
||||
|
||||
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
|
||||
|
||||
@@ -16,7 +16,7 @@ x64 / arm64 安装包与独立龙芯 loong64 预览版,并说明兼容性与
|
||||
首屏示意显示所选 Runtime,不展示 Ask / Execute 模式或一般工具审批。工具说明以
|
||||
已启用能力和 MCP 服务分配为准;站点校验会检查中英文页面中的旧模式文案与控件。
|
||||
FAQ 使用原生 details/summary,说明模型费用、数据流向、组件准备和远端使用条件。
|
||||
英文页面位于 `en.html`,用平台与架构覆盖描述同一产品事实,不使用中文信创
|
||||
英文页面位于 `en.html`,用户手册由 `docs/content/` 中按功能拆分的 Markdown 内容构建到 `docs-dist/`。英文页面用平台与架构覆盖描述同一产品事实,不使用中文信创
|
||||
政策语汇;页面会标明独立的 LoongArch 实验预览,但三个平台的下载按钮仍始终
|
||||
前往 GitHub 最新正式 Release。
|
||||
首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、
|
||||
@@ -26,7 +26,7 @@ FAQ 使用原生 details/summary,说明模型费用、数据流向、组件准
|
||||
## 部署
|
||||
|
||||
`.github/workflows/pages.yml` 会在 `main` 分支中的官网文件发生变化后,
|
||||
校验并部署整个 `sites` 目录。工作流也支持在 GitHub Actions 中手动运行。
|
||||
安装依赖、构建手册、校验并部署整个 `sites` 目录。工作流也支持在 GitHub Actions 中手动运行。
|
||||
|
||||
首次部署前,需要在 GitHub 仓库的 **Settings > Pages** 中将 **Source**
|
||||
设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件
|
||||
@@ -42,17 +42,41 @@ FAQ 使用原生 details/summary,说明模型费用、数据流向、组件准
|
||||
|
||||
## 本地预览
|
||||
|
||||
使用 Node.js 24,在仓库根目录安装依赖后运行 `npm run docs:dev`。访问终端显示的地址,在手册 base 下打开 `zh/` 或 `en/`,默认路径为 `/docs-dist/zh/` 和 `/docs-dist/en/`。Markdown 修改会触发热更新。
|
||||
|
||||
预览构建产物时,先按下方命令使用本地 base 构建,再运行 `npm run docs:preview`;同样以终端显示的地址和端口为准,在 base 下打开 `zh/` 或 `en/`。
|
||||
|
||||
### 维护手册
|
||||
|
||||
- 章节顺序、双语标题和稳定 slug 维护在 `docs/manifest.ts`;VitePress 配置从该清单生成侧栏。
|
||||
- 中文正文放在 `docs/content/zh/`,英文正文放在 `docs/content/en/`。每个语言目录都必须包含 manifest 中的十二个 slug:`start`、`concepts`、`connections`、`runtimes`、`workbar`、`knowledge`、`notes`、`tasks`、`supervision`、`remote`、`privacy`、`troubleshooting`。
|
||||
- 每个章节文件以一个 `#` 标题开始,正文使用 `##` 和 `###`。除 `start.md` 的 `##` 章节标题外,其余文件的 `#` 标题应与 manifest 对应语言的标题一致。标题尽量使用普通文字。
|
||||
- VitePress 根目录为 `docs/`,`srcDir` 为 `content`。新增章节时添加两份 Markdown 并更新 manifest,同时更新校验脚本中的必需章节清单。
|
||||
- `zh/start.md` 和 `en/start.md` 分别重写为 `zh/index.md` 和 `en/index.md`,入口为 base 下的 `zh/` 和 `en/`;其余章节路径为 `zh/<slug>.html` 和 `en/<slug>.html`。
|
||||
- 配置维护在 `docs/.vitepress/config.mts`,使用默认主题的本地搜索、双语侧栏和原生语言切换,不维护自定义主题或布局。
|
||||
- 生产 base 为 `/goodbuddy/docs-dist/`,开发与预览默认使用 `/docs-dist/`,可用 `DOCS_BASE` 覆盖。构建和预览的 base 必须一致。开始页下载链接使用正式官网绝对地址:`https://mesalogo.github.io/goodbuddy/#download` 和 `https://mesalogo.github.io/goodbuddy/en.html#download`。
|
||||
- `docs-dist/` 是构建产物,已忽略,不手工编辑或提交。
|
||||
|
||||
整站预览前先构建,以下命令在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
$env:DOCS_BASE='/docs-dist/'
|
||||
npm run docs:build
|
||||
Remove-Item Env:DOCS_BASE
|
||||
```
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python -m http.server 4173 --bind 127.0.0.1 --directory sites
|
||||
```
|
||||
|
||||
然后访问 <http://localhost:4173/>。也可以直接用浏览器打开 `sites/index.html`。
|
||||
然后访问官网 <http://127.0.0.1:4173/> 或 <http://127.0.0.1:4173/en.html>,中文手册 <http://127.0.0.1:4173/docs-dist/zh/>,英文手册 <http://127.0.0.1:4173/docs-dist/en/>。手册需要通过 HTTP 服务预览。
|
||||
|
||||
## 校验
|
||||
|
||||
```powershell
|
||||
npm run docs:build
|
||||
node sites/scripts/validate.mjs
|
||||
node --check sites/app.js
|
||||
node --check sites/language.js
|
||||
@@ -68,6 +92,7 @@ node --test sites/scripts/app.test.mjs sites/scripts/release-index.test.mjs
|
||||
不可变 URL,并按发布生成器的实际命名绑定版本、平台、架构和格式。移动
|
||||
导航行为测试同时覆盖现代 MediaQueryList 监听与旧版 Safari 的 `addListener`
|
||||
回退。英文下载入口固定指向 GitHub Release。
|
||||
手册校验覆盖全部 24 个构建章节的服务端渲染标题与正文、双语侧栏、语言切换和本地资源,并检查 Markdown 标题与 manifest 一致。上述命令检查生产 base;若检查本地构建,运行校验脚本时也设置 `DOCS_BASE='/docs-dist/'`。
|
||||
|
||||
## 下载入口
|
||||
|
||||
@@ -173,10 +198,24 @@ SIL OFL 1.1 许可证位于 `assets/fonts/inter-OFL.txt`。
|
||||
焦点,切换回桌面宽度也会解除隔离。媒体查询监听兼容现代浏览器和使用
|
||||
`MediaQueryList.addListener` 的旧版 Safari。
|
||||
|
||||
## 手册开发
|
||||
|
||||
手册正文维护在 `docs/content/zh/` 与 `docs/content/en/`,章节清单维护在
|
||||
`docs/manifest.ts`,主题、语言、侧栏和本地搜索配置维护在 `docs/.vitepress/config.mts`。本地开发运行:
|
||||
|
||||
```powershell
|
||||
npm run docs:dev
|
||||
```
|
||||
|
||||
发布前清除本地 `DOCS_BASE` 覆盖并运行 `npm run docs:build`。VitePress 将中英文各十二个章节及资源生成到 `docs-dist/`,入口为 `docs-dist/zh/index.html` 和 `docs-dist/en/index.html`。Pages 工作流会在校验前自动构建;不要直接编辑构建产物。
|
||||
|
||||
## 文件
|
||||
|
||||
- `index.html`:页面结构与简体中文内容
|
||||
- `en.html`:不包含信创适配文案的英文页面
|
||||
- `docs/content/zh/`、`docs/content/en/`:按功能拆分的中英文手册 Markdown 正文
|
||||
- `docs/manifest.ts`:手册章节顺序、标题和中英文映射
|
||||
- `docs/.vitepress/config.mts`:VitePress 默认主题、语言、路由、本地搜索和构建配置,输出到 `docs-dist/`
|
||||
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局
|
||||
- `app.js`:主题、移动导航、当前章节和中文下载索引
|
||||
- `language.js`:浏览器语言自动选择与手动语言偏好
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
import { chapters } from '../manifest'
|
||||
|
||||
const base = process.env.DOCS_BASE ?? '/goodbuddy/docs-dist/'
|
||||
|
||||
const sidebar = (language: 'zh' | 'en') => [
|
||||
{
|
||||
text: language === 'zh' ? '用户手册' : 'User guide',
|
||||
items: chapters.map(({ slug, title }) => ({
|
||||
text: title[language],
|
||||
link: `/${language}/${slug === 'start' ? '' : `${slug}.html`}`,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
title: 'GoodBuddy',
|
||||
description: 'GoodBuddy user guide',
|
||||
base,
|
||||
srcDir: 'content',
|
||||
rewrites: {
|
||||
'zh/start.md': 'zh/index.md',
|
||||
'en/start.md': 'en/index.md',
|
||||
},
|
||||
locales: {
|
||||
root: { label: 'English', lang: 'en', link: '/en/' },
|
||||
zh: { label: '简体中文', lang: 'zh-CN', link: '/zh/' },
|
||||
},
|
||||
themeConfig: {
|
||||
siteTitle: 'GoodBuddy',
|
||||
search: { provider: 'local' },
|
||||
outline: 'deep',
|
||||
sidebar: {
|
||||
'/zh/': sidebar('zh'),
|
||||
'/en/': sidebar('en'),
|
||||
},
|
||||
socialLinks: [{ icon: 'github', link: 'https://github.com/mesalogo/goodbuddy' }],
|
||||
locales: {
|
||||
root: {
|
||||
langMenuLabel: '语言',
|
||||
nav: [
|
||||
{ text: 'Home', link: 'https://mesalogo.github.io/goodbuddy/en.html' },
|
||||
{ text: 'GitHub', link: 'https://github.com/mesalogo/goodbuddy' },
|
||||
],
|
||||
},
|
||||
zh: {
|
||||
langMenuLabel: 'Languages',
|
||||
nav: [
|
||||
{ text: '官网首页', link: 'https://mesalogo.github.io/goodbuddy/' },
|
||||
{ text: 'GitHub', link: 'https://github.com/mesalogo/goodbuddy' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
# Core concepts
|
||||
|
||||
GoodBuddy connects conversations, model requests, and file work through a small set of related objects. Knowing these objects makes Runtime selection, knowledge scope, and execution history easier to understand.
|
||||
|
||||
## Projects and conversations
|
||||
|
||||
A Project is a working scope with a project directory, project Runtime choice, conversations, and tasks. A Conversation is a continuing chat record containing messages, tool activity, and citations. A conversation may have no Task or several Tasks; linking a Task does not change the conversation type.
|
||||
|
||||
## Model connections and Runtimes
|
||||
|
||||
A model connection is a reusable service configuration with a URL, protocol, model name, and credentials. A Runtime is the way a request runs: Direct models are called by GoodBuddy, while OpenCode and Continue use their native agent clients and DeepSeek Harness uses GoodBuddy's compatible managed path. See [Choose a Runtime](./runtimes.html).
|
||||
|
||||
A Runtime uses the selected capabilities and current account permissions. A project can define the default choice, and a conversation can inherit it or select its own. If a connection is deleted or incompatible, the picker explains the fallback or failure instead of silently choosing another credentialed model.
|
||||
|
||||
## Capabilities and execution spaces
|
||||
|
||||
A capability is something a request may use, such as knowledge retrieval, workspace files, a terminal, a browser, or an MCP service. Availability depends on the Runtime, project settings, conversation scope, and execution space. An execution space is where files, terminals, managed processes, and the Runtime actually run: a local project directory or a remote SSH project directory.
|
||||
|
||||
## Task, Job, and Run
|
||||
|
||||
A Task is work that the user explicitly creates or confirms, and it has exactly one associated Conversation. A Job is an internal step or scheduled occurrence within a Task. A Run is one execution attempt and record for a Job or subjob. Ordinary chat requests, tool calls, and Runs do not become Tasks automatically. The UI primarily exposes Tasks; Job and Run details appear through activity and execution records when available.
|
||||
|
||||
## No Ask / Execute mode
|
||||
|
||||
GoodBuddy has no Ask / Execute work mode and does not require a mode switch before using an enabled capability. A normal request uses the selected Runtime, enabled capabilities, and current account permissions. If a Runtime lacks a capability, GoodBuddy reports that limitation. Summaries, note analysis, and supervision reviews remain dedicated text requests without tools.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Configure model connections
|
||||
|
||||
A model connection stores a service URL, protocol, model name, authentication method, and optional request customization. Projects and conversations can reuse it, while credentials remain managed by GoodBuddy's main process. A usable connection does not mean every Runtime supports every field.
|
||||
|
||||
## Create and test a connection
|
||||
|
||||
Open Settings > Model connections > LLM. Create a connection with a name, HTTP or HTTPS URL, protocol, model ID, and API key. Save it and run Test connection. A successful test proves that the basic connection works; image generation, tools, and native Runtime clients still need their own real request path.
|
||||
|
||||
Connections can be copied. Copy creates a new connection and leaves the original unchanged. A saved key is copied through the protected credential path and is never returned to the page or error text. Choose the connection in a project or conversation picker, or choose the Runtime default.
|
||||
|
||||
## Custom headers and body
|
||||
|
||||
The Advanced settings area has two JSON editors. Headers must be an object whose values are strings, and the body must be a serializable top-level JSON object. Syntax, type, size, and reserved-field errors block saving while keeping the original text for correction.
|
||||
|
||||
Runtime, protocol, and authentication fields take precedence. Custom values cannot replace model, message, tool, streaming, or authentication fields. Body values merge only at the top level. Keep API keys and other secrets in the dedicated credential field. Direct models and Continue support both customizations; local OpenCode and DeepSeek Harness support headers but not custom body; managed remote OpenCode supports both.
|
||||
|
||||
## Scope and diagnostics
|
||||
|
||||
Connections are Settings resources; projects and conversations store the selection relationship. Vector connections are for knowledge indexing and do not appear in Runtime model pickers. For diagnosis, test the basic connection first, then confirm the Runtime and model used by the real request. If custom values appear to cause failure, clear them temporarily. A connection test is not a generation guarantee.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Build a knowledge base
|
||||
|
||||
A knowledge base brings local sources or a configured external knowledge service into retrieval. For a first setup, create a base, add sources, wait until it is searchable, and use it in the current conversation.
|
||||
|
||||
## Local workflow
|
||||
|
||||
Create a local base, add files, directories, or web sources, and wait for parsing and indexing. In the composer, explicitly select the knowledge scope and choose Model retrieves when needed or Retrieve before every request. Open citations to inspect the matching chunk, surrounding context, and source.
|
||||
|
||||
Knowledge scope is stored per Conversation. New conversations start with no selected base, and creating a base does not re-enable other bases you turned off. Each request uses only its explicit scope. The first mode lets the model decide whether to use read-only search; the second searches before the Runtime starts and shows zero results or degradation. Image generation does not run the pre-search.
|
||||
|
||||
Local retrieval can use full-text, Chinese phrase, vector, and graph channels. A citation is bounded evidence for the request, not proof that every answer sentence came from it. Check parsing, full-text, vector, and graph status separately. Vector failure can leave full-text and Chinese retrieval available, with the degraded channel shown. A failed rebuild keeps the last ready index.
|
||||
|
||||
## External knowledge bases
|
||||
|
||||
GoodBuddy can manage Dify, FastGPT, and RAGFlow instances and bind a remote base into the local list. Retrieval sends only the current query, remote ID, and saved settings. GoodBuddy checks returned chunks before showing citations.
|
||||
|
||||
External bindings are read-only. GoodBuddy does not create, edit, upload, or delete remote bases, documents, chunks, or graphs, and does not bulk-sync them. Removing a local binding does not delete remote data. Authentication, timeout, target, and provider failures remain visible and do not silently switch instances.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Use Magic Notes
|
||||
|
||||
Magic Notes is for meeting records, research drafts, ideas, and long-term plans. Notes are application-level data. The full workspace supports rich text, todos, and paged canvases, while the workbar provides quick browsing and appending.
|
||||
|
||||
## Capture conversation content
|
||||
|
||||
Choose Add to note from an assistant response, or Save current conversation to note from the conversation menu. Review the preview, source, and destination, then explicitly save. A full capture contains completed user and assistant text, preserving Markdown, line breaks, and code. Reasoning, tool logs, system prompts, binary attachments, and images are excluded.
|
||||
|
||||
Saved entries keep structured source information. Open a source to return to the conversation or locate the message. If the source is later removed, the note body remains and the source is marked unavailable. A failed save keeps the preview and destination for retry; cancelling writes nothing.
|
||||
|
||||
## Quick panel and full workspace
|
||||
|
||||
Open + > Notes in the workbar to search notes, view summaries, and append text. The panel and full workspace share storage, so successful writes appear in both. Unsaved drafts stay in app memory and are protected when changing project, conversation, or workbar visibility. Leaving the panel asks whether to keep or discard a changed draft.
|
||||
|
||||
Tags are manual. A note can have up to 10 case-insensitive tags; filter by tags or rename, merge, and delete them in tag management. Deleting a tag does not delete notes.
|
||||
|
||||
## Canvas and AI comments
|
||||
|
||||
Paged canvases support templates, text, handwriting, highlights, images, and PDF backgrounds. Canvas saves are manual. Zoom changes viewing only; there is no infinite canvas or separate PNG download.
|
||||
|
||||
Request AI comments manually, or enable analysis after save. Comments use the default model's image input and the first 1 to 8 configured pages. Visual-only content requires an image-capable model; a text-only model uses a labelled text fallback. Analysis failure never undoes saved content and does not append again automatically.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Data and privacy
|
||||
|
||||
GoodBuddy is a local-first desktop app and does not require a GoodBuddy account. Whether data leaves the computer depends on the model connection, Runtime, knowledge type, remote project, and browser features you use.
|
||||
|
||||
## Data flow
|
||||
|
||||
Local conversations, notes, tasks, settings, and knowledge indexes are stored in the application-data directory. Direct models send requests to the selected service; OpenCode, Continue, and Harness call models according to their Runtime configuration. Installing GoodBuddy or opening an empty conversation does not upload history.
|
||||
|
||||
With a local knowledge base, sources and indexes stay local and the Runtime receives bounded evidence and citations selected for the request. With Dify, FastGPT, or RAGFlow, the query goes to the configured instance. GoodBuddy does not upload the full conversation or local attachments or write to the external base. Returned bounded chunks and citations are stored with the local conversation.
|
||||
|
||||
## Credentials and boundaries
|
||||
|
||||
API keys, SSH credentials, and authentication data are managed by Main and protected storage; the Renderer does not read them directly. Custom headers and bodies are ordinary settings, not a place for secrets. Tools, MCP, terminals, and plugins use current-user permissions and enabled capabilities. Ask / Execute is not a separate privacy boundary.
|
||||
|
||||
Remote projects send bounded model configuration to an authenticated Agent, where requests run with SSH account permissions. The Agent is not bundled with the desktop package. Reconnect restores confirmed Agent output; uncertain model calls are not replayed automatically.
|
||||
|
||||
## Preview and clearing data
|
||||
|
||||
Conversation HTML preview handles completed assistant HTML only. Scripts, network requests, forms, nested pages, and window operations are disabled; turning preview off still shows source. Clear local data is an explicit confirmed action and does not delete provider records, remote hosts, remote bases, exported files, or files saved in a workspace.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Remote projects
|
||||
|
||||
Remote projects run Workspace, terminals, and supported Runtimes on an SSH host. A remote path is never treated as local. Files, processes, network access, and model calls are bounded by the SSH account and prepared environment. The GoodBuddy Agent is not included in the desktop installer.
|
||||
|
||||
## Prepare an SSH host
|
||||
|
||||
Add a Host in remote-project settings with its address, port, user, and authentication method. Before authentication, inspect and explicitly accept the Host Key algorithm and SHA-256 fingerprint. A changed fingerprint requires a separate replacement confirmation. GoodBuddy can use the system SSH Agent or supported certificates; root and SSH Agent Forwarding are not required.
|
||||
|
||||
Saving a Host does not mean the environment is ready. Use the Host card to prepare or update the signed Agent and Runtime, then wait for checks. A version-match badge is only a version fact. Failed, cancelled, or disconnected preparation keeps the Host and offers retry.
|
||||
|
||||
## Create a project and use its space
|
||||
|
||||
Choose a verified Host, enter or select a bounded remote workspace directory, and choose a supported Runtime such as OpenCode or Continue. The project stores the Host ID, remote path, and Runtime choice. Switching projects changes local selection only; the Agent is attached when Workspace or Runtime is first used.
|
||||
|
||||
Remote Workspace provides bounded directories, previews, Git, and import. A terminal uses the remote shell. Operations use the SSH account permissions. Local Skills, stdio MCP servers, and tool environments are not uploaded automatically; remote capabilities are those reported by the Agent.
|
||||
|
||||
## Failures and boundaries
|
||||
|
||||
Check network, port, Host Key, credentials, directory permissions, Agent version, Runtime version, and project permissions separately. If the Agent is unavailable, GoodBuddy does not fall back to an SSH stdio Runtime or secretly run the request locally.
|
||||
|
||||
An SSH relay disconnect does not necessarily stop an accepted request. Reconnect restores only output recorded and confirmed by the Agent. An uncertain model call is not replayed automatically because it could duplicate cost or side effects. Stop, Agent shutdown, and remote process failure are shown as cancellation, failure, or unknown outcome.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Choose a Runtime
|
||||
|
||||
A Runtime selects the execution path for a request. A connection answers which model service to call, while the Runtime organizes the request, tools, and execution space. Each request retains visible status, tool activity, cancellation, and failure information.
|
||||
|
||||
## The four choices
|
||||
|
||||
| Runtime | Best for | Model configuration |
|
||||
| --- | --- | --- |
|
||||
| Direct models | Chat, retrieval, image generation, and GoodBuddy capabilities | GoodBuddy model connections |
|
||||
| OpenCode | Coding agents, files, commands, and Skills | GoodBuddy connection or OpenCode config |
|
||||
| Continue | Rules, prompt presets, and coding work | GoodBuddy connection or Continue config |
|
||||
| DeepSeek Harness | A fixed host and compatible plugin capabilities | GoodBuddy-managed compatible connection |
|
||||
|
||||
Use Direct models for ordinary questions and retrieval. Choose OpenCode or Continue for continuing project-file and command work. Choose DeepSeek Harness when its fixed host capabilities fit. Tools and native questions differ by Runtime.
|
||||
|
||||
## Selection and capabilities
|
||||
|
||||
A project selects a concrete Runtime. A conversation follows the project by default, while the composer can select its own choice. The picker shows defaults, individual connections, and Runtime-owned configuration with their source. Requests use the Runtime, enabled capabilities, knowledge scope, execution space, and current account permissions.
|
||||
|
||||
GoodBuddy has no Ask / Execute mode and no separate general tool-approval switch. Unsupported capabilities are reported clearly. Local Runtimes run locally; remote projects use the Agent and managed Runtime on the SSH host. The Agent is not bundled with the desktop app; see [Remote projects](./remote.html).
|
||||
|
||||
The workbar shows Runtime, connection, tool state, cancellation, errors, and saved results. Closing a panel does not cancel background work, and stopping a request affects that request only.
|
||||
@@ -0,0 +1,30 @@
|
||||
# GoodBuddy User Guide
|
||||
|
||||
This guide is for a first GoodBuddy session. No GoodBuddy account is required. Conversations, projects, and notes are local by default; model providers, remote hosts, and external knowledge services receive data only when you configure and use them.
|
||||
|
||||
## Get started
|
||||
|
||||
### Install GoodBuddy
|
||||
|
||||
Choose a platform, processor architecture, and package from the [home page](https://mesalogo.github.io/goodbuddy/en.html#download). Windows provides an installer and a portable ZIP. Linux provides AppImage, DEB, and RPM. LoongArch uses a separate experimental preview channel. Start the app after installation; the first launch does not require sign-in.
|
||||
|
||||
### Initial setup
|
||||
|
||||
1. Open Settings, confirm the local data location, and prepare a text model connection.
|
||||
2. In Model connections, enter the service URL, protocol, model name, and credentials, then run a connection test. See [Configure model connections](./connections.html) for the field rules.
|
||||
3. Create a project and choose its project directory and Runtime. A project groups conversations, workspace access, and tasks.
|
||||
4. Open or create a conversation and send a simple question first. Once that works, try files, knowledge, or remote operations.
|
||||
|
||||
### Projects and conversations
|
||||
|
||||
A project is a working scope that can contain many conversations. A conversation stores user messages, assistant replies, tool activity, and citations; it can also be associated with multiple tasks. New conversations usually inherit the project Runtime choice, while the composer can select a conversation-specific choice.
|
||||
|
||||
When changing projects or working directories, check the workspace and terminal scope in the [assistant workbar](./workbar.html). Before sending a request, confirm the Runtime, model, and knowledge scope so content goes to the intended service.
|
||||
|
||||
### Local data
|
||||
|
||||
Conversations, notes, task records, knowledge indexes, and settings are stored in the system application-data directory. Clear local data is an explicit Settings action with confirmation. Uninstalling the app does not delete exported files, remote directories, or provider-side records. See [Data and privacy](./privacy.html) for the data boundaries.
|
||||
|
||||
### Continue reading
|
||||
|
||||
Read [Core concepts](./concepts.html) for the relationship between projects, conversations, and Runtimes. Then configure a [knowledge base](./knowledge.html), create [tasks](./tasks.html), or set up a [remote project](./remote.html).
|
||||
@@ -0,0 +1,25 @@
|
||||
# Supervision
|
||||
|
||||
Supervision reviews completed work, highlights changes and issues, and records supported results in the story graph. It does not send messages, approve tools, expand file scope, or change execution permissions. The Supervisor app is off by default.
|
||||
|
||||
## Enablement and scope
|
||||
|
||||
Enable Supervisor in the app center, then choose the default text model or an existing text connection in Settings. The first review requires an explicit project, conversation, and time range. Automatic supervision can target Global or selected projects; changing the active project does not change a saved scope.
|
||||
|
||||
## Review and story graph
|
||||
|
||||
Work review reads conversations, Tasks, notes, and sources within the selected scope and produces sourced summaries, changes, and unresolved items. With no valid new change it records no change and does not call the model. Review is incremental by default; use Reorganize for a historical interval.
|
||||
|
||||
The story graph presents events, entities, relationships, and changes over time. Select an object to inspect its sources. Conversations, notes, and knowledge bases remain the owners of source text. You can continue a discussion after checking evidence or preview a write to a local knowledge base. External knowledge remains read-only.
|
||||
|
||||
## Follow, pin, and intervene
|
||||
|
||||
The Supervision panel can follow the active conversation or pin to a conversation or Task. A pinned target stays selected across page changes and reports an error if it becomes unavailable. Suggestions create editable drafts or explicit next actions; they enter a conversation only after you choose to send them.
|
||||
|
||||
Supervisor does not execute suggestions and cannot send messages, approve tools, change capability switches, or expand workspace access. Its model calls are internal text requests without tools.
|
||||
|
||||
## Heartbeat and failures
|
||||
|
||||
Smart Heartbeat is the scheduled wake-up mechanism. It checks incremental changes within its scope and generates suggestions or updates memory according to the plan. The app and each plan have separate enabled states; disabling the app prevents new heartbeat runs, while an already started run may finish.
|
||||
|
||||
Pause, cancel, and failure keep the last successful result and saved batches. The run shows covered and omitted scope and can be retried when valid. A failed suggestion stage does not undo a published graph or review. Supervisor being off by default does not affect ordinary chat or Tasks.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tasks
|
||||
|
||||
A Task is work that you explicitly create or confirm. It has exactly one associated Conversation, while one Conversation can have several Tasks. Ordinary chat requests and tool calls do not appear in Task Center automatically.
|
||||
|
||||
## Immediate and scheduled work
|
||||
|
||||
Choose New task in Task Center or the conversation task area. Enter the request; the name can be blank and GoodBuddy will derive one. Immediate execution is the default. You can choose a one-time, daily, or weekly schedule and select the current conversation, another conversation in the project, or a new conversation. The request, conversation, and schedule are submitted together.
|
||||
|
||||
A Task uses the associated conversation's Runtime, capabilities, knowledge scope, and workspace when it runs. It has no Ask / Execute mode and does not gain permission because it runs in the background. A new conversation does not copy source history.
|
||||
|
||||
## Task, Job, and Run
|
||||
|
||||
Task is the user-facing object. An immediate run, schedule occurrence, or retry may create internal Jobs and Runs, but they do not become new Task entries. Task Center aggregates status, progress, schedule, and attention items; Activity and Runtime panels provide detail.
|
||||
|
||||
A recurring schedule remains one Task. Pause, resume, run now, and cancel act on the Task. Deleting it normally stops future scheduling while keeping the Conversation and messages. Deleting one queued occurrence does not delete the Task.
|
||||
|
||||
## Queue and Heartbeat
|
||||
|
||||
Normal and Task messages use the same Conversation queue. A due occurrence waits while a reply is running. You can remove an unstarted item or choose Run now and interrupt. Later items can continue after cancellation or failure. Smart Heartbeat is not a Task Center item; it belongs to [Supervision](./supervision.html) and wakes incremental reviews and suggestions.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Troubleshooting
|
||||
|
||||
First confirm the current project, conversation, Runtime, model, and local or remote execution space. Check request status and activity before repeating a send or rebuilding all data.
|
||||
|
||||
## The model connection test fails
|
||||
|
||||
Check protocol, URL, model ID, API key, proxy, certificate, and provider limits. Clear custom headers and body temporarily and test again. If the basic test succeeds but generation fails, confirm the Runtime and model capability; a connection test is not a generation guarantee.
|
||||
|
||||
## The reply does not use tools or files
|
||||
|
||||
Check Runtime support, execution space, workspace directory, enabled capability or MCP service, and account permissions. Open Workspace or Runtime in the workbar. For remote projects check SSH, Agent readiness, remote path, and Agent version. GoodBuddy has no Ask / Execute mode.
|
||||
|
||||
## Knowledge retrieval returns nothing
|
||||
|
||||
Check the conversation scope, parsing and full-text status, vector compatibility, and OCR for image-only PDFs. Use Test retrieval to see actual channels and degradation. Test with source words first, then rebuild a failed document or index. External bases also require valid authentication, remote ID, timeout, and provider status.
|
||||
|
||||
## A note, Task, or remote project failed
|
||||
|
||||
For notes and canvases, confirm that Save was explicitly used; AI comment failure does not undo saved content. For Tasks, check enabled state, pause state, associated conversation, app availability, and the latest error. Heartbeat is off by default. For remote work, check network, Host Key, credentials, permissions, and Agent preparation. Uncertain remote calls are not replayed automatically.
|
||||
|
||||
## HTML preview is empty or shows source
|
||||
|
||||
Check that the reply is complete, recognizable HTML or an `html`/`htm` block, and HTML rendering is enabled. Streaming replies, user messages, and reasoning remain source text. Scripts, network, forms, and window operations are intentionally disabled in the preview.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Assistant workbar
|
||||
|
||||
The assistant workbar is the application-level workspace on the right side of the main window. It is not attached to one chat message. Chat, knowledge, task, and supervision pages can open it to show tasks, workspace, Runtime, terminals, browser tabs, notes, and supervision status.
|
||||
|
||||
## Panels and the add menu
|
||||
|
||||
Task Center and Workspace are always-present base panels. Use the + menu to open Runtime, Supervision, Terminal, Processes, Browser, Resources, Documents, or Notes. Terminals and browsers can have multiple instances; Notes is single-instance. Background events update badges or notifications without taking over the current panel.
|
||||
|
||||
An open panel shows its capability, instance, and target. When a capability is unavailable, the panel explains why and provides a configuration path instead of silently hiding it. Terminals, logs, and large results may offer right, bottom, or separate-window docking.
|
||||
|
||||
## Follow and pin
|
||||
|
||||
Panels that can bind to a target have two scope choices: follow the active context, or pin to a conversation, Task, project, Runtime, terminal, workspace, or browser session. Follow mode changes with the project or conversation. Pinned mode stays on its target. If a pinned target disappears, the panel reports that it is unavailable and offers a repair path rather than switching silently.
|
||||
|
||||
The active context is only a default. Opening Workspace does not expand file access, and choosing a temporary directory does not silently change project settings. GoodBuddy still validates the project, execution space, and capability before an operation.
|
||||
|
||||
## Execution-space boundaries
|
||||
|
||||
Workspace shows the current project directory or a local or remote directory that you explicitly choose. A new terminal binds to the project execution space and directory; with no project it uses the local home directory. Browser manages only GoodBuddy-controlled browser tabs, not a browser already open on your system. Processes shows processes created or managed by GoodBuddy, not every system process.
|
||||
|
||||
Workspace supports file previews, Git status, and bounded directory operations. A remote workspace uses the Agent on the SSH host and does not pretend that a remote path is local. Terminal input, file operations, browser activity, and Runtime execution each keep their own scope; selecting one panel does not grant another panel more access.
|
||||
|
||||
## A practical combination
|
||||
|
||||
For project work, let Task Center follow the current project and pin Supervision to one conversation. Use Workspace to inspect files and create a terminal for commands. When reviewing activity, check the project, conversation, Runtime, and local or remote label at the top of the panel. Open a Task for its complete user-facing relation; Job and Run are not separate workbar panels.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: GoodBuddy User Guide
|
||||
layout: home
|
||||
---
|
||||
|
||||
# GoodBuddy User Guide
|
||||
|
||||
Choose a language to open the user guide:
|
||||
|
||||
- [English](/en/)
|
||||
- [简体中文](/zh/)
|
||||
@@ -0,0 +1,25 @@
|
||||
# 核心概念
|
||||
|
||||
GoodBuddy 把聊天、模型调用和文件操作放在几个相互关联的对象中。理解这些对象后,选择 Runtime、限制知识范围和查看执行记录会更容易。
|
||||
|
||||
## Project 与 Conversation
|
||||
|
||||
Project 是项目范围,包含项目目录、项目级 Runtime 选择、会话和任务。Conversation 是一条持续的聊天记录,保存消息、工具活动和引用。一条会话可以没有 Task,也可以关联多个 Task;关联 Task 不会改变会话类型。
|
||||
|
||||
## 模型连接与 Runtime
|
||||
|
||||
模型连接是可复用的服务配置,通常包含 URL、协议、模型名和凭据。Runtime 是执行请求的方式:直连模型由 GoodBuddy 调用模型服务,OpenCode 和 Continue 使用各自的 Agent 客户端,DeepSeek Harness 使用 GoodBuddy 管理的兼容执行路径。Runtime 的具体选择见[选择 Runtime](./runtimes.html)。
|
||||
|
||||
Runtime 会使用当前选择的能力和当前账号权限。项目可以设置默认 Runtime,会话可以继承项目设置或单独选择。模型连接被删除或不兼容时,选择器会说明回退或失败原因,不会悄悄换成另一个有凭据的模型。
|
||||
|
||||
## Capability 与执行空间
|
||||
|
||||
Capability 是一次请求可以使用的能力,例如知识检索、工作区文件、终端、浏览器或 MCP 服务。能力是否可用取决于 Runtime、项目设置、会话范围和执行空间。执行空间是文件、终端、受管进程和 Runtime 实际运行的位置,可以是本机项目目录,也可以是远程 SSH 项目目录。
|
||||
|
||||
## Task、Job 与 Run
|
||||
|
||||
Task 是用户明确创建或确认的工作单位,并且只关联一条 Conversation。Job 是 Task 内部的一次步骤或一次调度触发,Run 是 Job 或子任务的一次执行尝试和记录。普通聊天请求、工具调用和 Run 不会自动变成 Task;用户界面主要展示 Task,Job 和 Run 的细节在活动和执行记录中按需查看。
|
||||
|
||||
## 没有 Ask / Execute 模式
|
||||
|
||||
GoodBuddy 不提供 Ask / Execute 工作模式,也不会要求先切换模式才能调用已启用的能力。普通请求直接使用所选 Runtime、已启用能力和当前账号权限。某个 Runtime 不支持某项能力时,界面会报告能力缺失;内部摘要、笔记分析和监督回顾仍是专用的无工具文本请求。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 配置模型连接
|
||||
|
||||
模型连接保存服务地址、协议、模型名称、认证方式和可选的请求定制。连接可以被多个项目和会话复用,但凭据仍由 GoodBuddy 的主进程管理。连接本身可用,不代表每个 Runtime 都支持它的全部字段。
|
||||
|
||||
## 新建和测试
|
||||
|
||||
进入“设置 → 模型连接 → LLM”,新建连接并填写名称、HTTP 或 HTTPS 地址、协议、模型 ID 和 API Key。保存后执行“测试连接”。测试成功只说明当前连接的基础请求可达;图片生成、工具调用或某个 Runtime 的原生客户端仍需在实际请求中验证。
|
||||
|
||||
连接可以复制。复制会生成新的连接,不会修改原连接;已保存的密钥通过安全凭据路径复制,不会回显到页面或错误信息。需要在项目或会话中使用时,回到选择器选中连接或选择“跟随默认”。
|
||||
|
||||
## 自定义 Header 和 Body
|
||||
|
||||
连接详情底部的“高级设置”提供两个 JSON 编辑器。请求头必须是字符串值的 JSON 对象,例如 `{ "X-Team": "research" }`;请求体是可序列化的顶层 JSON 对象。语法、类型、大小或保留字段错误会阻止保存,并保留原始输入供修正。
|
||||
|
||||
Runtime、协议和认证生成的字段优先,自定义字段不能替换模型、消息、工具、流式或认证字段。请求体是顶层浅合并,不会递归合并内部对象。API Key、Cookie、签名和其他秘密信息必须放在专用凭据字段,不要写入 Header 或 Body。
|
||||
|
||||
直连模型和 Continue 支持两项定制;本地 OpenCode 和 DeepSeek Harness 支持自定义 Header,不使用自定义 Body;受管远程 OpenCode 在其模型网关支持两项。使用 Runtime 自有配置时不引用 GoodBuddy 连接,也不会应用连接级定制。
|
||||
|
||||
## 范围和诊断
|
||||
|
||||
连接是设置级资源,项目和会话只保存选择关系。向量连接只用于知识库索引,不会出现在 Runtime 模型选择器中。远程项目只能使用支持的远程 Runtime。连接地址变化、证书错误、缺少 API Key、模型不兼容和服务商限流都应在连接详情或请求状态中分别处理。
|
||||
|
||||
诊断时先执行基础连接测试,再检查实际请求使用的 Runtime 和模型。若自定义字段导致失败,暂时清空 Header 和 Body 重测;若基础测试成功而生成失败,检查协议、模型能力、输出限制和服务商的实际接口,而不要把测试结果当作生成保证。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 建立知识库
|
||||
|
||||
知识库把本地资料或已配置的外部知识服务接入对话检索。第一次使用按“创建知识库 → 添加资料 → 等待可检索 → 用于当前对话”的顺序操作;高级检索参数可以在基本流程可用后再调整。
|
||||
|
||||
## 本地知识库流程
|
||||
|
||||
1. 在知识库页面新建本地知识库,填写名称和描述。
|
||||
2. 添加文件、目录或网页来源,等待解析、全文索引和可选向量索引完成。
|
||||
3. 打开会话输入区的知识范围,明确勾选要使用的知识库。
|
||||
4. 选择“模型按需检索”或“每次先检索”,再发送问题。
|
||||
5. 打开回答中的引用,查看命中分块、相邻上下文和原始来源。
|
||||
|
||||
知识范围属于 Conversation。新会话默认不勾选知识库;切换会话会恢复各自的选择。新建一个知识库只把它加入当前会话,不会重新启用你以前取消的其他库。每次请求只能使用当前明确选择的范围。
|
||||
|
||||
## 检索方式和证据
|
||||
|
||||
“模型按需检索”只开放当前范围的只读搜索能力,由模型决定是否查询;没有调用时不会显示虚假的“已检索”。“每次先检索”会在 Runtime 启动前先查一次,零结果、失败和降级都会显示,模型仍可在同一范围内继续查询。图片生成请求不执行知识预检索。
|
||||
|
||||
本地检索可以使用全文、中文词组、向量和知识图谱通道。引用是本次检索得到的有界证据,不等于自动证明回答中的每一句话都来自该片段。点击引用可以核对来源;来源被移动或删除时显示失效,不会打开任意替代路径。
|
||||
|
||||
## 状态、降级和维护
|
||||
|
||||
分别查看解析、全文、向量和图谱状态。没有向量模型、查询向量失败、索引不兼容或部分文档失败时,全文和中文检索仍可用,但页面会标明降级原因,不会悄悄切换到另一个向量服务。重建失败时,上一版已就绪索引继续服务。
|
||||
|
||||
分块设置修改后需要显式重建。重建或大规模检索可以取消;已完成的文档保持可用。原文件移动、解析失败、OCR 不可用和零结果都应按照页面给出的原因处理,而不要只看“索引完成”这一总状态。
|
||||
|
||||
## 外部知识库
|
||||
|
||||
知识库页面可以管理 Dify、FastGPT 和 RAGFlow 实例,并把远端知识库绑定到 GoodBuddy 的本地列表。检索只发送本次查询、远端知识库 ID 和已保存的检索参数;返回片段经过 GoodBuddy 校验后作为引用使用。
|
||||
|
||||
外部绑定是只读的。GoodBuddy 不在外部实例中创建、修改、上传或删除知识库、文档、分块或图谱,也不批量同步远端资料。移除本地绑定不会删除远端内容;外部请求失败时显示实例、认证、超时或目标问题,不自动改用另一个实例。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 使用魔法笔记
|
||||
|
||||
魔法笔记适合保存会议记录、研究草稿、灵感和长期计划。笔记属于应用级数据,不会因为你从哪个项目或会话打开它而改变归属。完整工作区支持富文本、待办和分页画布,助手工作栏提供快速浏览和追加。
|
||||
|
||||
## 从会话保存内容
|
||||
|
||||
在一条助手回答的操作菜单中选择“加入笔记”,或在会话更多菜单中选择“保存当前对话到笔记”。先检查预览、来源和目标,再选择已有笔记或新建笔记并明确保存。整段会话保存的是已完成的用户和助手文字,保留 Markdown、换行和代码文本;推理、工具日志、系统提示、附件二进制和图片不会写入正文。
|
||||
|
||||
保存后的记录保留会话、项目、消息和采集时间等结构化来源。点击来源可以回到会话或定位消息;原会话或原消息后来删除时,笔记正文仍保留,并显示来源不可用。保存失败会保留预览、目标和输入,便于重试;取消不会写入。
|
||||
|
||||
## 快速面板和完整工作区
|
||||
|
||||
从工作栏“+ → 笔记”打开快速面板,可以搜索笔记、查看记录摘要和快速追加文字。面板与完整笔记工作区共享同一份存储,任一处成功写入后另一处会更新。未保存草稿保存在当前应用内存中,切换项目、会话或收起工作栏不会覆盖它;离开面板、关闭笔记或用另一条采集替换草稿时会要求选择继续编辑或放弃。
|
||||
|
||||
完整工作区可编辑记录、管理待办、搜索和调整列表。标签由用户手动维护:每篇笔记最多 10 个标签,标签不区分大小写;可以按一个或多个标签筛选,也可以在标签管理中重命名、合并或删除标签。删除标签不会删除笔记内容。
|
||||
|
||||
## 画布和 AI 评论
|
||||
|
||||
分页画布支持纸张模板、文字、手写、高亮、图片和 PDF 底版。画布保存是手动操作;缩放和适应宽度只影响查看,不改变已保存内容或导出尺寸。画布没有无限画布,也不提供单独的 PNG 下载按钮。
|
||||
|
||||
AI 评论由你手动触发,或在设置为保存后自动分析时于保存完成后触发。评论使用默认模型的图像输入能力,并按设置发送画布开头的 1 至 8 页。纯视觉内容需要图像模型;文字模型只能使用明确标记的文字降级。分析失败不会撤销已经成功保存的笔记或画布内容,也不会自动重复追加。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 数据与隐私
|
||||
|
||||
GoodBuddy 是本地优先的桌面应用,不要求 GoodBuddy 账号。数据是否离开本机,取决于你使用的模型连接、Runtime、知识库类型、远程项目和浏览器等功能。
|
||||
|
||||
## 数据流向
|
||||
|
||||
本地对话、笔记、任务、设置和知识库索引保存在系统应用数据目录。使用直连模型时,请求发送到你选择的模型服务;OpenCode、Continue 和 Harness 也会按其 Runtime 配置调用模型。GoodBuddy 不会因为安装应用或打开空白会话自动上传历史。
|
||||
|
||||
启用本地知识库时,资料和索引留在本机,Runtime 只收到本次检索选出的有界文本证据和引用。使用 Dify、FastGPT 或 RAGFlow 时,本次查询会发送到你配置的外部实例;GoodBuddy 不上传完整会话或本地附件,也不向外部知识库写入内容。实际返回的有界片段和引用会随会话保存在本机。
|
||||
|
||||
## 凭据和 Runtime 边界
|
||||
|
||||
API Key、SSH 凭据和其他认证信息由 Main 进程和系统安全存储管理,Renderer 不直接读取它们。自定义 Header 和 Body 属于普通连接设置,不适合保存秘密。工具、MCP、终端和插件使用当前用户权限以及明确启用的能力;没有 Ask / Execute 模式来替代这些实际配置。
|
||||
|
||||
远程项目会把有界的模型配置交给已认证的远程 Agent,模型请求在远端按 SSH 账号权限执行。远程 Agent 不随桌面包提供,GoodBuddy 不会凭空提高远端账号权限。断线恢复只同步 Agent 已记录且能确认的结果,不会自动重放不确定的模型调用。
|
||||
|
||||
## 预览和清除
|
||||
|
||||
会话中的 HTML 预览只处理已完成的助手 HTML,脚本、网络请求、表单提交、嵌套页面和窗口操作均被禁用;关闭 HTML 预览后仍可查看源码。它不是联网浏览器,也不会因为预览而把页面内容上传。
|
||||
|
||||
清除本地数据是设置中的明确操作,会先要求确认。它不删除外部模型服务、远程主机、远程知识库、导出文件或你手动保存到工作区的文件。备份、系统同步和第三方服务的保留策略由相应系统决定。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 远程项目
|
||||
|
||||
远程项目让 Workspace、终端和支持的 Runtime 在 SSH Host 上执行。远程路径不会被当成本机路径处理,文件、进程、网络和模型调用都受远端登录账号及远程环境限制。GoodBuddy Agent 不随桌面安装包提供,需要单独准备。
|
||||
|
||||
## 准备 SSH Host
|
||||
|
||||
在设置的远程项目入口添加 Host,填写地址、端口、用户名和认证方式。首次连接先查看 Host Key 的算法和 SHA-256 指纹并明确接受;指纹变化时必须单独确认替换。可用系统 SSH Agent 或支持的证书方式,凭据由桌面安全存储管理。GoodBuddy 不要求 root,也不默认启用 SSH Agent Forwarding。
|
||||
|
||||
Host 保存成功不等于远程环境已经就绪。按 Host 卡片的操作准备或更新签名 Agent 和 Runtime,等待环境检查通过。版本匹配只表示版本事实,不代表 Agent 正在运行或能力完整;安装失败、取消或网络中断会保留 Host 并提供重试。
|
||||
|
||||
## 创建项目和使用空间
|
||||
|
||||
创建远程项目时选择已验证的 Host,输入或通过有界目录选择器选择远端工作目录,再选择 OpenCode 或 Continue 等支持的远程 Runtime。项目保存 Host ID、远端路径和 Runtime 选择。普通切换项目只切换本地配置,不自动连接、安装或扫描主机;首次使用 Workspace 或 Runtime 时才建立或复用 Agent 连接。
|
||||
|
||||
远程 Workspace 提供有界目录、文件预览、Git 和导入操作;终端使用远端默认 Shell。所有操作使用 SSH 账号权限,不能获得 root 或账号本身没有的访问权。本机 Skills、stdio MCP 和工具环境不会自动上传到远端;远程能力以 Agent 报告的实际能力为准。
|
||||
|
||||
## 失败和边界
|
||||
|
||||
SSH 可达、认证成功、Agent 就绪、Workspace 可用和 Runtime 可启动是不同状态。分别检查网络、防火墙、端口、Host Key、凭据、远端目录、Agent 版本、Runtime 版本和项目权限。远端 Agent 不可用时不会退化成 SSH stdio Runtime,也不会把同一请求偷偷改在本机执行。
|
||||
|
||||
断开 SSH relay 不一定会停止已接受的远程请求;重新连接后 GoodBuddy 只恢复 Agent 已记录且能确认的输出。无法确认模型请求是否已处理时不会自动重放,以免重复计费或重复副作用。显式停止、Agent 关闭或远端进程失败会按请求状态显示取消、失败或结果未知。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 选择 Runtime
|
||||
|
||||
Runtime 决定请求由哪条执行路径处理。它与模型连接不同:连接回答“调用哪个模型服务”,Runtime 回答“由谁组织请求、工具和执行空间”。每次请求都会保留可查看的状态、工具活动、取消或失败结果。
|
||||
|
||||
## 四种选择
|
||||
|
||||
| Runtime | 适合场景 | 模型配置 |
|
||||
| --- | --- | --- |
|
||||
| 直连模型 | 普通问答、知识检索、图片生成和 GoodBuddy 内置能力 | GoodBuddy 模型连接 |
|
||||
| OpenCode | 编程 Agent、文件和命令工具、Skills | GoodBuddy 连接或 OpenCode 自有配置 |
|
||||
| Continue | 使用 Rules、Prompt 预设和编程工作 | GoodBuddy 连接或 Continue 自有配置 |
|
||||
| DeepSeek Harness | 固定 Host 和兼容插件能力 | GoodBuddy 管理的兼容连接 |
|
||||
|
||||
先用直连模型完成普通问答或知识检索;需要持续操作项目文件、命令和 Skills 时选择 OpenCode 或 Continue;需要其固定 Host 能力时再选择 DeepSeek Harness。不同 Runtime 的工具、问答和原生客户端能力可能不同,选择器会显示当前可用项。
|
||||
|
||||
## 选择范围和能力
|
||||
|
||||
项目设置必须选择具体 Runtime。会话默认跟随项目,也可以在输入框的两栏选择器中为本会话单独选择。模型栏会显示“默认”、具体连接和 Runtime 自有配置,并标明来源。切换 Runtime 后,原来不属于该 Runtime 的模型选择不会被强行沿用。
|
||||
|
||||
请求使用当前 Runtime、已启用 Capability、知识库范围、执行空间和当前账号权限。GoodBuddy 不提供 Ask / Execute 模式,也没有一个额外的通用工具审批开关;不支持的能力会明确报告,不会用隐藏模式代替。
|
||||
|
||||
## 本地、远程和记录
|
||||
|
||||
本地 Runtime 在本机项目执行。远程项目目前使用 SSH Host 上的 GoodBuddy Agent 和受管 Runtime,远程执行的文件、终端和进程使用 SSH 登录账号权限。远程 Agent 不随桌面安装包提供,需要先按[远程项目](./remote.html)准备。
|
||||
|
||||
查看请求消息的活动或助手工作栏,可以看到 Runtime、模型连接、工具状态、取消、错误和已保存结果。关闭面板不会自动取消后台请求;停止请求只影响所属请求。请求失败或取消时,已经保存的正文和工具记录会保留。
|
||||
@@ -0,0 +1,30 @@
|
||||
# GoodBuddy 用户手册
|
||||
|
||||
这是一份面向第一次使用者的操作指南。GoodBuddy 不要求注册 GoodBuddy 账号,聊天、项目和笔记默认保存在本机;模型服务、远程主机和外部知识库是否收到数据,取决于你的配置。
|
||||
|
||||
## 开始使用
|
||||
|
||||
### 安装
|
||||
|
||||
从[官网首页](https://mesalogo.github.io/goodbuddy/#download)选择系统、处理器架构和安装包。Windows 提供安装版和便携版,Linux 提供 AppImage、DEB 和 RPM。LoongArch 使用单独的实验预览通道。安装完成后启动 GoodBuddy,首次启动不需要登录。
|
||||
|
||||
### 初始设置
|
||||
|
||||
1. 打开“设置”,确认本地工作数据位置,并准备一个可用的文本模型连接。
|
||||
2. 在“模型连接”中填写服务地址、协议、模型名称和凭据,先执行连接测试。具体字段见[配置模型连接](./connections.html)。
|
||||
3. 新建一个项目,选择项目目录和 Runtime。项目用于集中管理会话、工作区和任务。
|
||||
4. 打开或新建会话,先发送一个简单问题,确认模型能正常回答,再尝试文件、知识库或远程操作。
|
||||
|
||||
### 项目和会话
|
||||
|
||||
项目是工作范围的容器,可以包含多个会话。会话保存用户消息、助手回复、工具活动和引用;同一个会话也可以关联多个任务。创建会话时,通常会继承项目的 Runtime 选择,之后可以在输入区为当前会话单独选择。
|
||||
|
||||
需要换主题或工作目录时,先切换项目,再确认右侧[助手工作栏](./workbar.html)中的工作区和终端范围。发送请求前检查当前 Runtime、模型和知识库范围,避免把内容发到错误的服务。
|
||||
|
||||
### 本地数据
|
||||
|
||||
对话、笔记、任务记录、知识库索引和设置存放在系统应用数据目录。清除本地数据是设置中的明确操作,执行前会要求确认。卸载应用不等于删除你导出的文件、远端目录或模型服务中的记录;需要了解数据流向时查看[数据与隐私](./privacy.html)。
|
||||
|
||||
### 下一步
|
||||
|
||||
先读[核心概念](./concepts.html)了解 Project、Conversation 和 Runtime 的关系,再按需要配置[知识库](./knowledge.html)、[任务](./tasks.html)或[远程项目](./remote.html)。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 监督工作
|
||||
|
||||
监督者用于回顾已发生的工作、标出变化和问题,并把有依据的结果放进故事线图谱。它不会替你发消息、批准工具、扩大文件范围或改变执行权限。监督者应用默认关闭,启用后才会接受新的监督工作。
|
||||
|
||||
## 启用和设置范围
|
||||
|
||||
在应用中心启用监督者,然后在“设置”中选择跟随默认文本模型,或选择一个已有的文本模型连接。监督执行复用连接和凭据;回顾进行中修改设置只影响后续执行。
|
||||
|
||||
首次使用需要明确选择项目、会话和时间范围。自动监督可以选择 Global 或一个或多个项目,当前项目切换不会改变已保存的范围。启用应用不会自动观察全部历史,也不会自动创建计划。
|
||||
|
||||
## 工作回顾和故事线图谱
|
||||
|
||||
工作回顾按所选范围读取会话、任务、笔记和已有来源,生成带来源的阶段总结、变化和未解决事项。没有新的有效变化时记录“无变化”,不重复调用模型或发送通知。默认“回顾”处理新增和修订内容;需要重新阅读历史区间时,使用明确的“重新整理”操作。
|
||||
|
||||
故事线图谱展示事件、实体、关系和随时间的变化。点击结果或图谱对象可以查看来源和定位。来源正文仍由会话、笔记或知识库维护,图谱不会复制一套可编辑原文。核对后可以继续讨论,也可以预览并确认写入本地知识库;外部知识库保持只读。
|
||||
|
||||
## 跟随、固定和介入
|
||||
|
||||
助手工作栏中的监督面板可以跟随当前会话,也可以固定到会话或 Task。切换页面不会改变固定目标;目标失效时保留错误并提供修复入口。监督建议只生成可编辑草稿或明确的后续操作,用户确认“发到当前会话”后才会写入聊天。
|
||||
|
||||
监督者不会自动执行建议。它不能替用户发送消息、批准工具、修改能力开关或扩大工作区。监督模型调用是无工具的内部文本请求,普通聊天和 Task 不会被监督并发设置改成同一队列。
|
||||
|
||||
## 智能心跳和失败处理
|
||||
|
||||
智能心跳是监督者的定时唤醒机制。它按计划检查指定范围的增量变化,并按设置生成建议或只更新记忆。心跳应用和计划分别有启停状态;关闭应用后不领取新的心跳,但已经开始的运行可以完成。
|
||||
|
||||
暂停、取消或失败会保留上次成功结果和已保存的成功批次,当前运行显示覆盖范围和遗漏。可以重试或继续仍有效的工作。建议生成失败不会撤销已经发布的图谱或回顾结果;活动记录会明确显示失败阶段。监督者默认关闭不会影响普通聊天和普通任务。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 任务
|
||||
|
||||
Task 是你明确创建或确认的一项工作。它只关联一条 Conversation,但一条 Conversation 可以有多个 Task。普通聊天请求和工具调用不会自动出现在任务中心。
|
||||
|
||||
## 创建立即或定时任务
|
||||
|
||||
在任务中心或会话任务区选择“新建任务”。填写任务要求,名称可以留空让 GoodBuddy 根据内容生成。默认立即执行,也可以选择单次、每日或每周计划,并选择当前项目中的当前会话、其他会话或新会话。内容、会话和计划会一起提交,失败时保留表单。
|
||||
|
||||
任务使用关联会话在实际执行时的 Runtime、能力、知识范围和工作区。它没有 Ask / Execute 模式,也不会因为后台运行而扩大权限。新会话不会复制来源会话历史;已有会话会使用自己的最新上下文。
|
||||
|
||||
## Task、Job 和 Run
|
||||
|
||||
Task 是用户可见的工作对象。每次立即运行、计划触发或重试,内部都可能创建 Job 和 Run,但它们不会变成新的任务条目。任务中心聚合显示任务状态、最近进展、计划时间和需要关注的错误;活动和 Runtime 面板显示更详细的工具与执行信息。
|
||||
|
||||
一个周期任务始终是一条 Task。取消、暂停、恢复和重新运行都作用于 Task;删除 Task 默认停止后续计划并解除关联,保留会话和已有消息。删除一次待执行的计划项不会删除 Task。
|
||||
|
||||
## 排队和控制
|
||||
|
||||
同一 Conversation 的普通消息和任务消息按队列顺序执行。当前回复期间到期的任务会等待,不会把另一条回复插入正在生成的时间线。你可以删除尚未执行的队列项,或使用“立即中断并插入”让任务成为下一项;取消或失败后,后续队列项可以继续。
|
||||
|
||||
任务卡提供暂停、继续、立即运行、编辑和取消等操作。暂停阻止新的执行,不撤销已经完成的外部操作;取消会停止所属执行,但已经保存的消息、工具记录和成果不会伪装成回滚。
|
||||
|
||||
## 任务与心跳
|
||||
|
||||
智能心跳不是任务中心里的 Task。它属于[监督工作](./supervision.html),用于按计划唤醒增量回顾和生成建议。需要一项明确的结果、文件或周期性消息时创建 Task;需要观察工作变化时配置监督计划。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 问题排查
|
||||
|
||||
先确认当前项目、会话、Runtime、模型和本机或远程执行空间,再根据症状处理。不要重复点击发送或立即重建全部资料;先查看请求状态、活动记录和错误原因。
|
||||
|
||||
## 模型连接测试失败
|
||||
|
||||
检查协议、URL、模型 ID、API Key、代理、证书和服务商限流。若填写了自定义 Header 或 Body,先暂时清空后重测基础连接。基础测试成功但生成失败时,确认当前会话选择的 Runtime 和模型能力。
|
||||
|
||||
## 回复没有使用工具或文件
|
||||
|
||||
检查 Runtime 是否支持所需能力,项目或会话是否使用正确执行空间,工作区目录是否存在,能力或 MCP 是否启用,以及当前账号是否有权限。打开工作栏的 Workspace 或 Runtime 确认目标;远程项目另外检查 SSH、Agent、远端路径和 Agent 版本。GoodBuddy 没有 Ask / Execute 模式。
|
||||
|
||||
## 知识库没有返回内容
|
||||
|
||||
确认会话勾选了目标知识库,文档已解析,全文索引可用,向量没有失败或不兼容,图片型 PDF 已做 OCR。用“测试检索”查看实际通道和降级原因;先用原文关键词测试,再重建失败文档或索引。外部库还要检查认证、远端 ID、超时和 Provider 状态。
|
||||
|
||||
## 笔记、任务或远程项目失败
|
||||
|
||||
笔记和画布先确认明确保存;AI 评论失败不会撤销已保存内容。任务检查启用、暂停、关联会话、GoodBuddy 是否运行及最近错误;心跳默认关闭。远程检查网络、Host Key、凭据、目录权限和 Agent/Runtime 准备状态。结果未知的远程请求不会自动重放,先查看活动再决定是否重新运行。
|
||||
|
||||
## HTML 预览为空或显示源码
|
||||
|
||||
确认回复已完成,内容是 HTML 文档或 `html`/`htm` 代码块,并且设置已开启会话 HTML 渲染。流式回复、用户消息和推理只显示源码。预览故意禁用脚本、网络、表单和窗口操作;需要交互页面时导出到你信任的环境。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 助手工作栏
|
||||
|
||||
助手工作栏是主窗口右侧的应用级工作区。它不属于某一条聊天消息,聊天、知识库、任务和监督页面都可以打开它,用来查看任务、工作区、Runtime、终端、浏览器、笔记和监督状态。
|
||||
|
||||
## 面板和加号目录
|
||||
|
||||
任务中心和工作区是默认存在、不可关闭的基础面板。点击“+”可以打开 Runtime、监督、终端、进程、浏览器、资源监控、文档或笔记等能力。终端和浏览器可以创建多个实例;笔记是单实例。后台状态会更新徽标或通知,不会无故把当前页面切走。
|
||||
|
||||
工作栏面板显示能力名称、实例和当前目标。能力不可用时应显示原因及配置入口,而不是把入口悄悄隐藏。面板可以收起,终端、日志和较大的成果可以按界面提供的选项停靠到底部或独立窗口。
|
||||
|
||||
## 跟随和固定
|
||||
|
||||
可绑定目标的面板有两种范围:跟随当前上下文,或固定到指定会话、Task、项目、Runtime、终端、工作区或浏览器实例。跟随模式会随项目或会话切换;固定模式保持原目标。固定目标失效时显示“目标不可用”和修复入口,不自动改到另一个目标。
|
||||
|
||||
工作栏的当前上下文只是默认值。打开工作区不会自动扩大文件范围,临时选择目录也不会静默修改项目设置。确认目标后,GoodBuddy 仍会检查项目、执行空间和当前能力。
|
||||
|
||||
## 执行空间边界
|
||||
|
||||
工作区显示当前项目目录或你明确选择的本机、远程目录。终端创建时绑定项目执行空间和目录;没有项目时使用本机 Home。浏览器只管理 GoodBuddy 创建的受控浏览器标签,不控制你已经打开的系统浏览器。进程面板只显示 GoodBuddy 创建或托管的进程,不是系统级进程管理器。
|
||||
|
||||
工作区支持文件预览、Git 状态和有界目录操作。远程工作区使用 SSH Host 上的 Agent,不把远程路径伪装成本机路径。终端输入、文件操作、浏览器和 Runtime 各自有明确范围;一个面板的选择不会给另一个面板额外权限。
|
||||
|
||||
## 实际使用
|
||||
|
||||
处理一个项目时,可以让任务中心跟随当前项目,同时把监督固定到某个会话;打开工作区查看文件,再创建一个终端执行命令。查看活动时确认面板顶部的项目、会话、Runtime 和本机或远程标识。需要完整任务关系时打开 Task;Job 和 Run 不作为工作栏中的独立面板。
|
||||
@@ -0,0 +1,21 @@
|
||||
export type Language = 'zh' | 'en'
|
||||
|
||||
export type Chapter = {
|
||||
slug: string
|
||||
title: { zh: string; en: string }
|
||||
}
|
||||
|
||||
export const chapters: Chapter[] = [
|
||||
{ slug: 'start', title: { zh: '开始使用', en: 'Get started' } },
|
||||
{ slug: 'concepts', title: { zh: '核心概念', en: 'Core concepts' } },
|
||||
{ slug: 'connections', title: { zh: '配置模型连接', en: 'Configure model connections' } },
|
||||
{ slug: 'runtimes', title: { zh: '选择 Runtime', en: 'Choose a Runtime' } },
|
||||
{ slug: 'workbar', title: { zh: '助手工作栏', en: 'Assistant workbar' } },
|
||||
{ slug: 'knowledge', title: { zh: '建立知识库', en: 'Build a knowledge base' } },
|
||||
{ slug: 'notes', title: { zh: '使用魔法笔记', en: 'Use Magic Notes' } },
|
||||
{ slug: 'tasks', title: { zh: '任务', en: 'Tasks' } },
|
||||
{ slug: 'supervision', title: { zh: '监督工作', en: 'Supervision' } },
|
||||
{ slug: 'remote', title: { zh: '远程项目', en: 'Remote projects' } },
|
||||
{ slug: 'privacy', title: { zh: '数据与隐私', en: 'Data and privacy' } },
|
||||
{ slug: 'troubleshooting', title: { zh: '问题排查', en: 'Troubleshooting' } },
|
||||
]
|
||||
@@ -75,6 +75,7 @@
|
||||
<a href="#workflows">Workflows</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#assistant">Assistant</a>
|
||||
<a href="./docs-dist/en/">User guide</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
</nav>
|
||||
|
||||
@@ -495,6 +496,7 @@
|
||||
<a href="#download">Download</a>
|
||||
<a href="#workflows">Workflows</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="./docs-dist/en/">User guide</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="https://github.com/mesalogo/goodbuddy/blob/main/README.md">Getting started</a>
|
||||
<a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<a href="#workflows">场景</a>
|
||||
<a href="#features">功能</a>
|
||||
<a href="#assistant">助手</a>
|
||||
<a href="./docs-dist/zh/">用户手册</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
</nav>
|
||||
|
||||
@@ -514,6 +515,7 @@
|
||||
选择你的桌面版本
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
<a class="text-link" href="./docs-dist/zh/">阅读用户手册 <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
|
||||
<div class="assistant-list">
|
||||
@@ -583,6 +585,7 @@
|
||||
<a href="#download">下载</a>
|
||||
<a href="#workflows">场景</a>
|
||||
<a href="#features">功能</a>
|
||||
<a href="./docs-dist/zh/">用户手册</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="https://github.com/mesalogo/goodbuddy/blob/main/README.zh-CN.md">入门指南</a>
|
||||
<a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
: format === "rpm"
|
||||
? "aarch64"
|
||||
: "arm64";
|
||||
if (format === "AppImage") {
|
||||
return `GoodBuddy-${version}-${artifactArch}.AppImage`;
|
||||
}
|
||||
return `GoodBuddy-${version}-linux-${artifactArch}.${format}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -42,12 +42,12 @@ const canonicalFileName = (version, platform, arch, format) => {
|
||||
zip: `GoodBuddy-${version}-mac-arm64.zip`,
|
||||
},
|
||||
"linux-x64": {
|
||||
AppImage: `GoodBuddy-${version}-linux-x86_64.AppImage`,
|
||||
AppImage: `GoodBuddy-${version}-x86_64.AppImage`,
|
||||
deb: `GoodBuddy-${version}-linux-amd64.deb`,
|
||||
rpm: `GoodBuddy-${version}-linux-x86_64.rpm`,
|
||||
},
|
||||
"linux-arm64": {
|
||||
AppImage: `GoodBuddy-${version}-linux-arm64.AppImage`,
|
||||
AppImage: `GoodBuddy-${version}-arm64.AppImage`,
|
||||
deb: `GoodBuddy-${version}-linux-arm64.deb`,
|
||||
rpm: `GoodBuddy-${version}-linux-aarch64.rpm`,
|
||||
},
|
||||
|
||||
@@ -4,10 +4,28 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const siteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const errors = [];
|
||||
|
||||
const chapterSlugs = [
|
||||
"start",
|
||||
"concepts",
|
||||
"connections",
|
||||
"runtimes",
|
||||
"workbar",
|
||||
"knowledge",
|
||||
"notes",
|
||||
"tasks",
|
||||
"supervision",
|
||||
"remote",
|
||||
"privacy",
|
||||
"troubleshooting",
|
||||
];
|
||||
const requiredFiles = [
|
||||
"index.html",
|
||||
"en.html",
|
||||
"docs/manifest.ts",
|
||||
...chapterSlugs.flatMap((slug) => [`docs/content/zh/${slug}.md`, `docs/content/en/${slug}.md`]),
|
||||
"docs/.vitepress/config.mts",
|
||||
...chapterSlugs.flatMap((slug) => ["zh", "en"].map((language) =>
|
||||
`docs-dist/${language}/${slug === "start" ? "index" : slug}.html`)),
|
||||
"styles.css",
|
||||
"app.js",
|
||||
"language.js",
|
||||
@@ -49,10 +67,12 @@ await Promise.all(
|
||||
}),
|
||||
);
|
||||
|
||||
const [html, englishHtml, css, appJs, languageJs, releaseIndexJs, fontLicense] =
|
||||
const [html, englishHtml, docsManifest, docsConfig, css, appJs, languageJs, releaseIndexJs, fontLicense] =
|
||||
await Promise.all([
|
||||
readSiteFile("index.html"),
|
||||
readSiteFile("en.html"),
|
||||
readSiteFile("docs/manifest.ts"),
|
||||
readSiteFile("docs/.vitepress/config.mts"),
|
||||
readSiteFile("styles.css"),
|
||||
readSiteFile("app.js"),
|
||||
readSiteFile("language.js"),
|
||||
@@ -63,6 +83,8 @@ const [html, englishHtml, css, appJs, languageJs, releaseIndexJs, fontLicense] =
|
||||
for (const [relativePath, content] of [
|
||||
["index.html", html],
|
||||
["en.html", englishHtml],
|
||||
["docs/manifest.ts", docsManifest],
|
||||
["docs/.vitepress/config.mts", docsConfig],
|
||||
["styles.css", css],
|
||||
["app.js", appJs],
|
||||
["language.js", languageJs],
|
||||
@@ -112,6 +134,59 @@ report(
|
||||
(englishHtml.match(/<h1[\s>]/g) ?? []).length === 1,
|
||||
"英文页面必须且只能包含一个 h1",
|
||||
);
|
||||
report(chapterSlugs.every((slug) => docsManifest.includes(`slug: '${slug}'`)), "手册 manifest 缺少章节或顺序不完整");
|
||||
const manifestEntries = [...docsManifest.matchAll(/slug: '([^']+)', title: \{ zh: '([^']+)', en: '([^']+)' \}/g)];
|
||||
report(manifestEntries.length === chapterSlugs.length && manifestEntries.every((entry, index) => entry[1] === chapterSlugs[index]), "手册 manifest 章节顺序不完整");
|
||||
const docsBase = process.env.DOCS_BASE || "/goodbuddy/docs-dist/";
|
||||
const docsOrigin = "https://docs.example";
|
||||
const renderedText = (value) => value
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/&(?:amp|lt|gt|quot|apos|#39|nbsp);/g, (entity) => ({
|
||||
"&": "&", "<": "<", ">": ">", """: '"',
|
||||
"'": "'", "'": "'", " ": " ",
|
||||
})[entity])
|
||||
.replace(/\s+/g, " ").trim();
|
||||
for (const slug of chapterSlugs) {
|
||||
const [zh, en] = await Promise.all([readSiteFile(`docs/content/zh/${slug}.md`), readSiteFile(`docs/content/en/${slug}.md`)]);
|
||||
const manifestEntry = manifestEntries.find((entry) => entry[1] === slug);
|
||||
const titleMatches = slug === "start"
|
||||
? manifestEntry && zh.includes(`## ${manifestEntry[2]}`) && en.includes(`## ${manifestEntry[3]}`)
|
||||
: manifestEntry && zh.startsWith(`# ${manifestEntry[2]}`) && en.startsWith(`# ${manifestEntry[3]}`);
|
||||
report(titleMatches, `${slug} 章节标题必须与 manifest 保持一致`);
|
||||
report(zh.trim().length > 20 && en.trim().length > 20, `${slug} 章节正文过短`);
|
||||
for (const [content, languageCode] of [[zh, "zh"], [en, "en"]]) {
|
||||
const chapterPath = `${languageCode}/${slug === "start" ? "index" : slug}.html`;
|
||||
const relativePath = `docs-dist/${chapterPath}`;
|
||||
const built = await readSiteFile(relativePath);
|
||||
if (!built) continue;
|
||||
const pageUrl = new URL(`${docsBase}${chapterPath}`, docsOrigin);
|
||||
const main = built.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1] ?? "";
|
||||
const heading = content.match(/^# (.+)$/m)?.[1];
|
||||
const firstBodyLine = content.split(/\r?\n/).find((line) => line.trim() && !line.startsWith("#")) ?? "";
|
||||
const paragraphText = firstBodyLine.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*`]/g, "");
|
||||
report((main.match(/<h1\b/g) ?? []).length === 1 && renderedText(main.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/)?.[1] ?? "").includes(heading), `${relativePath} 缺少服务端渲染的 Markdown 主标题`);
|
||||
report(paragraphText.length > 20 && renderedText(main).includes(renderedText(paragraphText)), `${relativePath} 缺少服务端渲染的章节正文`);
|
||||
report(built.includes(`lang="${languageCode === "zh" ? "zh-CN" : "en"}"`), `${relativePath} 页面语言不正确`);
|
||||
for (const entry of manifestEntries) {
|
||||
const route = `${docsBase}${languageCode}/${entry[1] === "start" ? "" : `${entry[1]}.html`}`;
|
||||
report(built.includes(`href="${route}"`) || (entry[1] === "start" && built.includes(`href="${route}index.html"`)), `${relativePath} 侧栏缺少章节:${entry[1]}`);
|
||||
report(renderedText(built).includes(entry[languageCode === "zh" ? 2 : 3]), `${relativePath} 侧栏标题与 manifest 不一致:${entry[1]}`);
|
||||
}
|
||||
const otherLanguage = languageCode === "zh" ? "en" : "zh";
|
||||
report(built.includes("VPNavBarTranslations") && built.includes(`href="${docsBase}${otherLanguage}/`), `${relativePath} 缺少原生语言切换入口`);
|
||||
report(/<script\b[^>]*\bsrc="[^"]+\.js"/.test(built) && /<link\b[^>]*\brel="[^"]*stylesheet/.test(built), `${relativePath} 缺少脚本或样式资源`);
|
||||
for (const [, link] of built.matchAll(/\s(?:href|src)="([^"]+)"/g)) {
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(link)) continue;
|
||||
const target = new URL(link, pageUrl);
|
||||
report(target.pathname.startsWith(docsBase), `${relativePath} 本地链接未使用手册 base:${link}`);
|
||||
if (!target.pathname.startsWith(docsBase)) continue;
|
||||
let localPath = decodeURIComponent(target.pathname.slice(docsBase.length));
|
||||
if (localPath.endsWith("/") || !localPath) localPath += "index.html";
|
||||
report((await stat(path.join(siteRoot, "docs-dist", localPath)).catch(() => null))?.isFile(), `${relativePath} 本地链接或资源不存在:${link}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
report(/provider:\s*['"]local['"]/.test(docsConfig), "手册必须启用 VitePress 本地搜索");
|
||||
report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接");
|
||||
report(
|
||||
/class="skip-link"\s+href="#main-content"/.test(englishHtml),
|
||||
@@ -618,7 +693,8 @@ for (const [relativePath, content] of [
|
||||
);
|
||||
totalLocalAssets += localAssets.length;
|
||||
for (const asset of localAssets) {
|
||||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
let cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
if (cleanAsset.endsWith("/")) cleanAsset += "index.html";
|
||||
try {
|
||||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||||
report(assetStats.isFile(), `${relativePath} 本地资源不是文件:${asset}`);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
|
||||
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
|
||||
export type AgentCapabilityContext = {
|
||||
modelFetcher?: ModelRuntimeOptions['fetcher']
|
||||
outputStore?: ModelRuntimeOptions['outputStore']
|
||||
outputAdopt?: ModelRuntimeOptions['outputAdopt']
|
||||
observeFailure?: DesktopDiagnosticFailureObserver
|
||||
@@ -139,9 +140,11 @@ export function createDefaultModelRuntime(
|
||||
export function createModelProfileRuntime(
|
||||
defaultWorkspace: string,
|
||||
settings: ResolvedRuntimeSettings,
|
||||
profile: ResolvedModelProfile
|
||||
profile: ResolvedModelProfile,
|
||||
fetcher?: ModelRuntimeOptions['fetcher']
|
||||
): ModelAgentRuntime {
|
||||
return new ModelAgentRuntime({
|
||||
fetcher,
|
||||
apiKey: profile.apiKey,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
@@ -359,6 +362,7 @@ export function createAgentRuntime(
|
||||
(modelAuthentication === 'none' || modelApiKey))
|
||||
) {
|
||||
return new ModelAgentRuntime({
|
||||
fetcher: capabilities.modelFetcher,
|
||||
outputStore: capabilities.outputStore,
|
||||
outputAdopt: capabilities.outputAdopt,
|
||||
apiKey: modelApiKey ?? '',
|
||||
|
||||
@@ -84,6 +84,20 @@ describe('ApplicationSettingsStore', () => {
|
||||
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults a missing scrollbar minimap setting to on and persists both choices', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null }
|
||||
delete legacy.chatMinimapEnabled
|
||||
await writeFile(filePath, JSON.stringify(legacy))
|
||||
expect((await store.get()).chatMinimapEnabled).toBe(true)
|
||||
for (const chatMinimapEnabled of [false, true]) {
|
||||
expect((await store.update({ chatMinimapEnabled })).chatMinimapEnabled).toBe(chatMinimapEnabled)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).chatMinimapEnabled).toBe(chatMinimapEnabled)
|
||||
}
|
||||
await expect(store.update({ chatMinimapEnabled: 'false' })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
expect((await store.get()).transparentFrostedEffectEnabled).toBe(true)
|
||||
@@ -268,7 +282,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
const settings = await store.get()
|
||||
expect(settings.warnings).toBeUndefined()
|
||||
expect(settings.magicNotesEnabled).toBe(false)
|
||||
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } })
|
||||
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing', 'remote-hosts'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } })
|
||||
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
|
||||
@@ -278,8 +292,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
const applicationNavigation = {
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false },
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'remote-hosts', 'device-sharing'],
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': true },
|
||||
}
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
@@ -323,11 +337,27 @@ describe('ApplicationSettingsStore', () => {
|
||||
pinned: { 'magic-notes': false, 'local-inference': pinned }
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
|
||||
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] }
|
||||
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true, 'remote-hosts': false }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] }
|
||||
expect((await store.get()).applicationNavigation).toEqual(normalized)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
|
||||
})
|
||||
it.each([true, false])('adds Hosts after a saved full order and keeps remote projects %s', async (remoteProjectsEnabled) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const applicationNavigation = {
|
||||
order: ['device-sharing', 'local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
|
||||
pinned: { 'magic-notes': false, heartbeat: false, 'local-inference': true }
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, remoteProjectsEnabled, applicationNavigation, version: 12, lastSeenReleaseNotesVersion: null }), 'utf8')
|
||||
const expected = { order: [...applicationNavigation.order, 'remote-hosts'], pinned: { ...applicationNavigation.pinned, 'remote-hosts': false } }
|
||||
const migrated = await store.get()
|
||||
expect(migrated.warnings).toBeUndefined()
|
||||
expect(migrated.remoteProjectsEnabled).toBe(remoteProjectsEnabled)
|
||||
expect(migrated.applicationNavigation).toEqual(expected)
|
||||
expect(applicationSettingsSchema.parse(migrated)).toEqual(migrated)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ remoteProjectsEnabled, applicationNavigation: expected })
|
||||
})
|
||||
it('migrates released settings without replacing explicit false values or comment choices', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
|
||||
|
||||
@@ -252,6 +252,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: true,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -295,6 +296,7 @@ export class ApplicationSettingsStore {
|
||||
| 'conversationHtmlRenderingEnabled'
|
||||
| 'desktopNotificationsEnabled'
|
||||
| 'transparentFrostedEffectEnabled'
|
||||
| 'chatMinimapEnabled'
|
||||
| 'localToolEnvironment'
|
||||
| 'version'
|
||||
| 'applicationNavigation'
|
||||
@@ -532,6 +534,7 @@ export class ApplicationSettingsStore {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
|
||||
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
|
||||
chatMinimapEnabled: stored.chatMinimapEnabled,
|
||||
updateSource: stored.updateSource,
|
||||
modelDownloadSource: stored.modelDownloadSource,
|
||||
localToolEnvironment: stored.localToolEnvironment,
|
||||
@@ -581,6 +584,7 @@ export class ApplicationSettingsStore {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
|
||||
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
|
||||
chatMinimapEnabled: next.chatMinimapEnabled,
|
||||
updateSource: next.updateSource,
|
||||
modelDownloadSource: next.modelDownloadSource,
|
||||
localToolEnvironment: next.localToolEnvironment,
|
||||
|
||||
@@ -134,6 +134,27 @@ it('persists the story graph switch per conversation without schema migration or
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists the composer role per conversation and the role snapshot on replies', async () => {
|
||||
const database = await createDatabase()
|
||||
const expertId = randomUUID()
|
||||
const header = { id: randomUUID(), title: 'Role preference', updatedAt: 1000, selectedExpertId: expertId }
|
||||
const team = { id: randomUUID(), title: 'Team preference', updatedAt: 1000, selectedExpertId: 'team' as const }
|
||||
const plain = { id: randomUUID(), title: 'No preference', updatedAt: 1000 }
|
||||
const reply = { id: randomUUID(), role: 'assistant' as const, content: 'Reviewed', state: 'complete' as const,
|
||||
createdAt: 900, expert: { kind: 'expert' as const, id: expertId, name: '代码审查专家' } }
|
||||
try {
|
||||
database.saveLocalConversations([{ header, messages: [reply] }, { header: team, messages: [] }, { header: plain, messages: [] }])
|
||||
database.close()
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.getConversation(header.id)).toMatchObject({ selectedExpertId: expertId, messages: [{ expert: reply.expert }] })
|
||||
expect(database.getConversation(team.id).selectedExpertId).toBe('team')
|
||||
expect(database.getConversation(plain.id).selectedExpertId).toBeUndefined()
|
||||
database.saveLocalConversations([{ header: { ...header, selectedExpertId: undefined, updatedAt: 1001 }, messages: [] }])
|
||||
expect(database.getConversation(header.id).selectedExpertId).toBeUndefined()
|
||||
expect(localConversationHeaderSchema.safeParse({ ...plain, selectedExpertId: 'not-an-id' }).success).toBe(false)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
||||
|
||||
@@ -134,7 +134,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 63
|
||||
|
||||
function withoutHistoricalWorkMode(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
@@ -364,6 +364,7 @@ type MessageMetadata = {
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
subagents?: ConversationSnapshot['messages'][number]['subagents']
|
||||
expert?: ConversationMessage['expert']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
||||
@@ -1238,6 +1239,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
knowledgeLibraryIds: true,
|
||||
knowledgeRetrievalMode: true,
|
||||
storyGraphEnabled: true,
|
||||
selectedExpertId: true,
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
@@ -1246,7 +1248,7 @@ function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -1264,18 +1266,20 @@ function parseConversationContextState(
|
||||
function serializeConversationContextState(
|
||||
conversation: Pick<
|
||||
ConversationSnapshot,
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
|
||||
>
|
||||
): string | null {
|
||||
return conversation.knowledgeLibraryIds !== undefined ||
|
||||
conversation.knowledgeRetrievalMode !== undefined ||
|
||||
conversation.storyGraphEnabled !== undefined ||
|
||||
conversation.selectedExpertId !== undefined ||
|
||||
conversation.contextMetrics ||
|
||||
conversation.contextCompressionState
|
||||
? JSON.stringify({
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState
|
||||
})
|
||||
@@ -1346,6 +1350,7 @@ function toConversationSnapshot(
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -1377,6 +1382,7 @@ function serializeConversationMessageMetadata(
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
subagents: message.subagents,
|
||||
expert: message.expert,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
@@ -1784,6 +1790,7 @@ function storedRemoteAssistantMessage(
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -5638,6 +5645,7 @@ export class AssistantDatabase {
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -9168,8 +9176,11 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) {
|
||||
const owner = target?.type === 'conversation' ? this.getConversation(target.conversationId)
|
||||
: target?.type === 'task' ? this.getTask(target.taskId) : undefined
|
||||
const owner = target?.type === 'conversation'
|
||||
? this.requireDatabase().prepare('SELECT project_id AS projectId FROM conversations WHERE id = ?').get(target.conversationId) as { projectId: string | null } | undefined
|
||||
: target?.type === 'task'
|
||||
? this.requireDatabase().prepare('SELECT project_id AS projectId FROM tasks WHERE id = ?').get(target.taskId) as { projectId: string | null } | undefined
|
||||
: undefined
|
||||
const sourceType = target?.type ?? null
|
||||
const sourceId = target?.type === 'conversation' ? target.conversationId : target?.taskId ?? null
|
||||
return this.requireDatabase().prepare(`SELECT id, run_id AS runId, summary,
|
||||
@@ -11908,6 +11919,7 @@ export class AssistantDatabase {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec(supervisionReviewMigration)
|
||||
database.exec(supervisionReviewCurrentView)
|
||||
database.exec('PRAGMA user_version = 47; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
@@ -12239,6 +12251,15 @@ export class AssistantDatabase {
|
||||
`)
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 63) {
|
||||
// Reviews read only finished messages and tasks, so a frozen manifest never changes.
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec('DROP VIEW IF EXISTS supervision_review_current')
|
||||
database.exec(supervisionReviewCurrentView)
|
||||
database.exec('PRAGMA user_version = 63; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
@@ -12303,7 +12324,7 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store'
|
||||
import { SupervisionReviewStore, supervisionReviewCurrentView, supervisionReviewMigration } from './supervision-review-store'
|
||||
import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories'
|
||||
import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences'
|
||||
import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline'
|
||||
|
||||
@@ -70,9 +70,10 @@ it('scans read-only, transfers at most 200 rows, yields without a transaction, a
|
||||
expect(wrote).toBe(true)
|
||||
expect(f.store.progress(f.runId).sources).toBe(450)
|
||||
expect(f.store.load(f.runId).initializing).toBeUndefined()
|
||||
// Frozen scan revisions must not silently advance to the concurrent write.
|
||||
await expect(f.store.resumeWithReader(f.runId, f.reader)).rejects.toThrow('source changed')
|
||||
expect(f.store.load(f.runId).restartRequired).toBe(true)
|
||||
// A concurrent write is detected on resume and that one source is skipped, not the whole run failed.
|
||||
await f.store.resumeWithReader(f.runId, f.reader)
|
||||
expect(f.store.progress(f.runId)).toMatchObject({ sources: 449, skippedSources: 1 })
|
||||
expect(f.store.load(f.runId).restartRequired).toBeUndefined()
|
||||
await expect(f.worker.call('reviewManifestPage', [f.runId, 0])).rejects.toThrow('lost its snapshot')
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { setImmediate as yieldTurn } from 'node:timers/promises'
|
||||
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
|
||||
|
||||
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; skippedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
|
||||
export class ReviewSourcesOmitted extends Error {}
|
||||
|
||||
@@ -52,6 +52,14 @@ export const supervisionReviewMigration = `
|
||||
id TEXT NOT NULL, children_json TEXT NOT NULL, output_json TEXT NOT NULL,
|
||||
PRIMARY KEY(run_id, id)
|
||||
);
|
||||
`
|
||||
|
||||
/**
|
||||
* Reviewable sources. Only finished messages (complete or error) and finished tasks are
|
||||
* visible: their content no longer changes, so the manifest frozen when a review starts
|
||||
* is a fixed snapshot. Replies still streaming and running tasks wait for the next review.
|
||||
*/
|
||||
export const supervisionReviewCurrentView = `
|
||||
CREATE VIEW supervision_review_current AS
|
||||
SELECT 'message:' || m.id AS source, m.id AS record_id, 0 AS reference_index, 'conversation' AS type, c.id AS owner,
|
||||
c.project_id AS project_id, c.id AS conversation_id, m.sequence AS sequence,
|
||||
@@ -60,14 +68,15 @@ export const supervisionReviewMigration = `
|
||||
json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context,
|
||||
json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'role', m.role, 'sequence', m.sequence) AS locator
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant')
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error')
|
||||
UNION ALL
|
||||
SELECT 'task:' || t.id, t.id, 0, 'task', t.id, COALESCE(t.project_id, ''), '', 0, t.title,
|
||||
t.created_at, COALESCE(t.completed_at, t.created_at),
|
||||
json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at),
|
||||
json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id)
|
||||
FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.visible = 1 AND (t.project_id IS NULL OR p.status = 'active')
|
||||
WHERE t.visible = 1 AND t.status IN ('completed', 'failed', 'cancelled', 'interrupted')
|
||||
AND (t.project_id IS NULL OR p.status = 'active')
|
||||
UNION ALL
|
||||
SELECT 'knowledge:' || m.id || ':' || ref.key, m.id, ref.key, 'knowledge',
|
||||
COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'),
|
||||
@@ -79,7 +88,7 @@ export const supervisionReviewMigration = `
|
||||
json_patch(ref.value, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'referenceIndex', ref.key))
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
|
||||
JOIN json_each(m.metadata_json, '$.sourceReferences') ref
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant');
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error');
|
||||
`
|
||||
|
||||
export class SupervisionReviewStore {
|
||||
@@ -235,26 +244,19 @@ export class SupervisionReviewStore {
|
||||
unfinished(request: SupervisionRunRequest): string | undefined {
|
||||
return this.db.prepare(`SELECT s.id FROM supervision_runs s JOIN supervision_review_runs r ON r.run_id = s.id
|
||||
WHERE s.trigger = 'heartbeat' AND s.scope_json = ? AND s.status IN ('failed', 'running')
|
||||
AND (COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0 OR EXISTS (
|
||||
SELECT 1 FROM supervision_review_sources source WHERE source.run_id = s.id AND source.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = source.conversation_id)
|
||||
AND instr(s.error, '(' || source.source || ');') > 0))
|
||||
ORDER BY s.created_at LIMIT 1`).get(JSON.stringify(request.scope))?.id as string | undefined
|
||||
}
|
||||
|
||||
resume(runId: string, signal?: AbortSignal): void {
|
||||
const state = this.prepareResume(runId)
|
||||
if (state.initializing) { this.initializeSources(runId, state, signal); return }
|
||||
// Existing sources must still match their frozen versions.
|
||||
// A source that no longer matches its frozen version is skipped and left for the next review.
|
||||
let after = ''
|
||||
for (;;) {
|
||||
const rows = this.resumePage(runId, after)
|
||||
if (!rows.length) break
|
||||
const changed = this.changedSource(rows, signal)
|
||||
if (changed) {
|
||||
if (this.omitDeletedConversations(runId)) return this.resume(runId, signal)
|
||||
this.sourceChanged(runId, changed)
|
||||
}
|
||||
if (changed) { this.skipSource(runId, changed); after = changed; continue }
|
||||
after = rows.at(-1)!.source
|
||||
}
|
||||
}
|
||||
@@ -263,7 +265,8 @@ export class SupervisionReviewStore {
|
||||
const status = this.db.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)?.status
|
||||
if (status === 'cancelled') throw new Error('SUPERVISION_REVIEW_CANCELLED: 此回顾已取消,不能继续。请开始新的回顾。')
|
||||
this.omitDeletedConversations(runId)
|
||||
if (this.load(runId).restartRequired) throw new Error('Review source changed or was removed; start a new review')
|
||||
// Runs failed by an earlier release on a changed source are recoverable now.
|
||||
this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.restartRequired') WHERE run_id = ?").run(runId)
|
||||
const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'running', error = NULL, completed_at = NULL
|
||||
WHERE id = ? AND status IN ('paused', 'failed', 'running')`).run(runId)
|
||||
if (!changed.changes) throw new Error('SUPERVISION_REVIEW_NOT_RESUMABLE: 此回顾已完成或不存在,请开始新的回顾。')
|
||||
@@ -286,10 +289,8 @@ export class SupervisionReviewStore {
|
||||
if (!rows.length) break
|
||||
const changed = await reader.changedSource(rows, signal)
|
||||
this.checkRunning(runId, signal)
|
||||
if (changed) {
|
||||
if (this.omitDeletedConversations(runId)) { after = ''; continue }
|
||||
this.sourceChanged(runId, changed)
|
||||
}
|
||||
// The read snapshot may lag the writer; skipSource re-reads the source here.
|
||||
if (changed) { this.skipSource(runId, changed); after = changed; await yieldTurn(); continue }
|
||||
after = rows.at(-1)!.source
|
||||
await yieldTurn()
|
||||
}
|
||||
@@ -349,8 +350,9 @@ export class SupervisionReviewStore {
|
||||
for (const metadata of rows) {
|
||||
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
|
||||
if (!current || current.current_revision !== metadata.revision) {
|
||||
if (this.omitDeletedConversations(runId)) return this.chunk(runId, projectId, conversationId, config)
|
||||
this.sourceChanged(runId, metadata.source)
|
||||
// Skip the changed source, then rebuild the batch from current offsets.
|
||||
this.skipSource(runId, metadata.source)
|
||||
return this.chunk(runId, projectId, conversationId, config)
|
||||
}
|
||||
const row = { ...metadata, ...current }
|
||||
const locator = JSON.parse(row.locator) as Record<string, unknown>
|
||||
@@ -387,17 +389,72 @@ export class SupervisionReviewStore {
|
||||
const reference = kind === 'knowledge' ? Number(key.slice(last + 1)) : 0
|
||||
// The view predicate pushes record_id into each branch's message/task primary-key lookup.
|
||||
return this.db.prepare(`SELECT type, owner, substr(title, 1, 240) AS title, occurred, alternate_time,
|
||||
locator, review_revision(context) AS current_revision, substr(body, ? + 1, ?) AS content
|
||||
locator, review_revision(context) AS current_revision, substr(body, ? + 1, ?) AS content, length(body) AS body_length
|
||||
FROM supervision_review_current WHERE record_id = ? AND reference_index = ? AND type = ?`).get(
|
||||
offset, characters, id, reference, kind === 'message' ? 'conversation' : kind) as {
|
||||
type: SupervisionEvidence['sourceType']; owner: string; title: string; occurred: string;
|
||||
alternate_time: string; locator: string; current_revision: string; content: string
|
||||
alternate_time: string; locator: string; current_revision: string; content: string; body_length: number
|
||||
} | undefined
|
||||
}
|
||||
|
||||
private sourceChanged(runId: string, source: string): never {
|
||||
this.db.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.restartRequired', json('true')) WHERE run_id = ?").run(runId)
|
||||
throw new Error(`Review source changed or was removed (${source}); start a new review`)
|
||||
/**
|
||||
* The manifest is a snapshot of finished sources, so a frozen source should never change.
|
||||
* If one still does (or disappears), it is dropped from this run without failing it: leaves
|
||||
* that used it are discarded with their navigation, and because no checkpoint is written for
|
||||
* it, the next review picks it up at its current version. Returns false when it still matches.
|
||||
*/
|
||||
skipSource(runId: string, source: string): boolean {
|
||||
const frozen = this.db.prepare('SELECT revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, source) as { revision: string } | undefined
|
||||
if (!frozen) return false
|
||||
const live = this.currentSource(source, 0, 0)
|
||||
if (live?.current_revision === frozen.revision) return false
|
||||
if (this.omitDeletedConversations(runId)) return true
|
||||
// No longer visible (archived, emptied): omitted. Still visible but changed: left for the next review.
|
||||
const counter = live && Number(live.body_length) > 0 ? '$.skippedSources' : '$.omittedSources'
|
||||
this.db.exec('SAVEPOINT skip_review_source')
|
||||
try {
|
||||
this.discardLeaves(runId, new Set([source]))
|
||||
this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'),
|
||||
'${counter}', COALESCE(json_extract(state_json, '${counter}'), 0) + 1,
|
||||
'$.phase', 'extracting') WHERE run_id = ?`).run(runId)
|
||||
this.db.exec('RELEASE skip_review_source')
|
||||
return true
|
||||
} catch (error) { this.db.exec('ROLLBACK TO skip_review_source; RELEASE skip_review_source'); throw error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards every leaf containing an affected source, transitively over mixed leaves, and
|
||||
* their dependent navigation. Surviving sources of those leaves restart at their initial offset.
|
||||
*/
|
||||
private discardLeaves(runId: string, affected: Set<string>): void {
|
||||
const invalid = new Set<string>()
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const batches = this.db.prepare(`SELECT b.id, json_group_array(json_extract(item.value, '$.locator.source')) AS sources
|
||||
FROM (SELECT id, evidence_json FROM supervision_review_batches WHERE run_id = ? ORDER BY rowid LIMIT 10 OFFSET ?) b,
|
||||
json_each(b.evidence_json) item GROUP BY b.id`).all(runId, offset)
|
||||
if (!batches.length) break
|
||||
for (const batch of batches) {
|
||||
const sources = JSON.parse(String(batch.sources)) as string[]
|
||||
if (invalid.has(String(batch.id)) || !sources.some(source => affected.has(source))) continue
|
||||
invalid.add(String(batch.id))
|
||||
for (const source of sources) affected.add(source)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const invalidIds = JSON.stringify([...invalid])
|
||||
this.db.prepare(`WITH RECURSIVE invalid(id) AS (
|
||||
SELECT value FROM json_each(?) UNION
|
||||
SELECT n.id FROM supervision_review_navigation n, json_each(n.children_json) child
|
||||
JOIN invalid i ON i.id = child.value WHERE n.run_id = ?
|
||||
) DELETE FROM supervision_review_navigation WHERE run_id = ? AND id IN (SELECT id FROM invalid)`)
|
||||
.run(invalidIds, runId, runId)
|
||||
this.db.prepare('DELETE FROM supervision_review_batches WHERE run_id = ? AND id IN (SELECT value FROM json_each(?))').run(runId, invalidIds)
|
||||
for (const source of affected) this.db.prepare('UPDATE supervision_review_sources SET processed_offset = initial_offset WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
}
|
||||
|
||||
save(runId: string, projectId: string, conversationId: string, evidence: SupervisionEvidence[], output: SupervisionSummaryOutput): void {
|
||||
@@ -405,11 +462,13 @@ export class SupervisionReviewStore {
|
||||
try {
|
||||
this.omitDeletedConversations(runId)
|
||||
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
|
||||
// A source changed while the model was working: drop this answer and that source, keep the run going.
|
||||
const stale = evidence.find(item => this.currentSource(String(item.locator!.source), 0, 0)?.current_revision !== item.locator!.revision)
|
||||
if (stale) { this.skipSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return }
|
||||
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
|
||||
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
|
||||
for (const item of evidence) {
|
||||
const locator = item.locator!
|
||||
if (this.currentSource(String(locator.source), 0, 0)?.current_revision !== locator.revision) this.sourceChanged(runId, String(locator.source))
|
||||
if (advance.run(Number(locator.end), runId, String(locator.source), String(locator.revision), Number(locator.start)).changes !== 1) {
|
||||
throw new Error('Review coverage has a gap or duplicate batch')
|
||||
}
|
||||
@@ -438,13 +497,9 @@ export class SupervisionReviewStore {
|
||||
const batches = this.db.prepare(`SELECT COUNT(*) AS batches, COALESCE(SUM(characters), 0) AS characters
|
||||
FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
|
||||
const state = this.load(runId)
|
||||
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
|
||||
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
|
||||
const deletedFailure = failedSource && this.db.prepare(`SELECT 1 FROM supervision_review_sources s
|
||||
WHERE s.run_id = ? AND s.source = ? AND s.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).get(runId, failedSource)
|
||||
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
|
||||
return { runId, omittedSources: state.omittedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: deletedFailure ? false : state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
// Changed sources are skipped on resume, so no saved run requires a restart.
|
||||
return { runId, omittedSources: state.omittedSources, skippedSources: state.skippedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
|
||||
}
|
||||
|
||||
@@ -467,41 +522,11 @@ export class SupervisionReviewStore {
|
||||
this.db.exec('SAVEPOINT omit_review_sources')
|
||||
try {
|
||||
const removed = new Set(deleted.map(row => String(row.source)))
|
||||
const affected = new Set(removed)
|
||||
const invalid = new Set<string>()
|
||||
// A mixed leaf cannot be filtered safely: its prose may combine every input.
|
||||
// Reset its surviving sources and discard their other leaves to avoid duplicate coverage.
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const batches = this.db.prepare(`SELECT b.id, json_group_array(json_extract(item.value, '$.locator.source')) AS sources
|
||||
FROM (SELECT id, evidence_json FROM supervision_review_batches WHERE run_id = ? ORDER BY rowid LIMIT 10 OFFSET ?) b,
|
||||
json_each(b.evidence_json) item GROUP BY b.id`).all(runId, offset)
|
||||
if (!batches.length) break
|
||||
for (const batch of batches) {
|
||||
const sources = JSON.parse(String(batch.sources)) as string[]
|
||||
if (invalid.has(String(batch.id)) || !sources.some(source => affected.has(source))) continue
|
||||
invalid.add(String(batch.id))
|
||||
for (const source of sources) affected.add(source)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const invalidIds = JSON.stringify([...invalid])
|
||||
this.db.prepare(`WITH RECURSIVE invalid(id) AS (
|
||||
SELECT value FROM json_each(?) UNION
|
||||
SELECT n.id FROM supervision_review_navigation n, json_each(n.children_json) child
|
||||
JOIN invalid i ON i.id = child.value WHERE n.run_id = ?
|
||||
) DELETE FROM supervision_review_navigation WHERE run_id = ? AND id IN (SELECT id FROM invalid)`)
|
||||
.run(invalidIds, runId, runId)
|
||||
this.db.prepare('DELETE FROM supervision_review_batches WHERE run_id = ? AND id IN (SELECT value FROM json_each(?))').run(runId, invalidIds)
|
||||
for (const source of affected) this.db.prepare('UPDATE supervision_review_sources SET processed_offset = initial_offset WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
this.discardLeaves(runId, new Set(removed))
|
||||
for (const source of removed) this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
|
||||
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
|
||||
if (failedSource && removed.has(failedSource)) this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.restartRequired') WHERE run_id = ?").run(runId)
|
||||
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(state_json,
|
||||
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'),
|
||||
'$.omittedSources', COALESCE(json_extract(state_json, '$.omittedSources'), 0) + ?, '$.phase', 'extracting') WHERE run_id = ?`).run(removed.size, runId)
|
||||
this.db.exec('RELEASE omit_review_sources')
|
||||
return true
|
||||
|
||||
@@ -313,17 +313,82 @@ it('still pauses on user request and resumes saved batches without changing batc
|
||||
expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500])
|
||||
})
|
||||
|
||||
it('rejects changed source versions and rolls back offsets when batch persistence fails', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 })
|
||||
it('skips changed source versions and rolls back offsets when batch persistence fails', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)], ['Kept']], { concurrency: 1 })
|
||||
f.sql.exec("CREATE TRIGGER fail_batch BEFORE INSERT ON supervision_review_batches BEGIN SELECT RAISE(ABORT, 'disk failure'); END")
|
||||
await expect(f.service().run(request)).rejects.toThrow('disk failure')
|
||||
const runId = f.db.listSupervisionActivity()[0]!.id
|
||||
expect(f.db.supervisionReviewStore().progress(runId).characters).toBe(0)
|
||||
expect(f.sql.prepare('SELECT processed_offset FROM supervision_review_sources').get()!.processed_offset).toBe(0)
|
||||
expect(f.sql.prepare('SELECT MAX(processed_offset) AS offset FROM supervision_review_sources').get()!.offset).toBe(0)
|
||||
f.sql.exec('DROP TRIGGER fail_batch')
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id)
|
||||
await expect(f.service().resume(runId)).rejects.toThrow('source changed')
|
||||
expect(f.db.listSupervisionActivity()[0]!.status).toBe('failed')
|
||||
const resumed = await f.service().resume(runId)
|
||||
expect(resumed).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, characters: 'Kept'.length, complete: true } })
|
||||
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Kept'])
|
||||
})
|
||||
|
||||
it('reviews only finished messages: a streaming reply waits for the next review', async () => {
|
||||
const f = await fixture([['Finished question', 'Partial reply']], { concurrency: 1 })
|
||||
const reply = f.conversations[0]!.messages[1]!.id
|
||||
f.sql.prepare("UPDATE messages SET state = 'streaming' WHERE id = ?").run(reply)
|
||||
const manual = { ...request, reanalyze: undefined }
|
||||
const first = await f.service().run(manual)
|
||||
expect(first.coverage).toMatchObject({ sources: 1, complete: true })
|
||||
expect(f.db.supervisionReviewStore().batches(first.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Finished question'])
|
||||
f.sql.prepare("UPDATE messages SET content = ?, state = 'complete' WHERE id = ?").run('Partial reply, finished', reply)
|
||||
const next = await f.service().run(manual)
|
||||
expect(next.coverage).toMatchObject({ sources: 1, complete: true })
|
||||
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Partial reply, finished'])
|
||||
})
|
||||
|
||||
it('skips a source rewritten while its batch is with the model and discards the stale answer', async () => {
|
||||
const f = await fixture([['Original reply'], ['Other conversation']], { concurrency: 1 })
|
||||
const target = f.conversations[0]!.messages[0]!.id
|
||||
const base = f.summarize.getMockImplementation()!
|
||||
let rewritten = false
|
||||
f.summarize.mockImplementation(async input => {
|
||||
if (!rewritten && input.evidence.some(item => item.locator?.messageId === target)) {
|
||||
rewritten = true
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Original reply, continued after reconnect', target)
|
||||
}
|
||||
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
|
||||
})
|
||||
const result = await f.service().run(request)
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, remainingSources: 0, complete: true } })
|
||||
const contents = f.db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence).map(item => item.content)
|
||||
expect(contents).toEqual(['Other conversation'])
|
||||
})
|
||||
|
||||
it('discards a mixed leaf when one of its sources changes and skips only that source', async () => {
|
||||
const f = await fixture([['A', 'B']], { concurrency: 1 })
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
store.initialize(runId, { request, config: f.config })
|
||||
const conversation = f.conversations[0]!
|
||||
const evidence = store.chunk(runId, conversation.projectId, conversation.id, f.config)
|
||||
expect(evidence.map(item => item.content)).toEqual(['A', 'B'])
|
||||
store.save(runId, conversation.projectId, conversation.id, evidence, empty)
|
||||
store.saveNavigation(runId, 'dependent', [store.batches(runId)[0]!.id], empty)
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('B2', conversation.messages[1]!.id)
|
||||
f.db.failSupervisionRun(runId, 'Provider failed')
|
||||
await f.service().resume(runId)
|
||||
expect(store.navigation(runId, 'dependent')).toBeUndefined()
|
||||
expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A'])
|
||||
expect(store.progress(runId)).toMatchObject({ skippedSources: 1, sources: 1, complete: true })
|
||||
})
|
||||
|
||||
it('omits a source that is no longer visible and resumes old runs failed by the earlier restart rule', async () => {
|
||||
const f = await fixture([['Hidden'], ['Kept']], { concurrency: 1 })
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
store.initialize(runId, { request, config: f.config })
|
||||
f.sql.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.restartRequired', json('true')) WHERE run_id = ?").run(runId)
|
||||
f.db.failSupervisionRun(runId, `Review source changed or was removed (message:${f.conversations[0]!.messages[0]!.id}); start a new review`)
|
||||
f.sql.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?").run(f.conversations[0]!.id)
|
||||
expect(store.progress(runId).restartRequired).toBeUndefined()
|
||||
const result = await f.service().resume(runId)
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 1, sources: 1, complete: true } })
|
||||
expect(result.output.summary).not.toContain('Hidden')
|
||||
})
|
||||
|
||||
it('round-robins projects and conversations and keeps semantic chunks independent of database page size', async () => {
|
||||
@@ -381,20 +446,23 @@ it('continues an existing automatic timeline checkpoint without rereading its su
|
||||
expect(f.sql.prepare('SELECT processed_offset FROM review_checkpoints').get()!.processed_offset).toBe(3300)
|
||||
})
|
||||
|
||||
it('keeps old-version facts but does not permanently block automatic review after a source changes', async () => {
|
||||
it('lets the next automatic review continue a failed run whose source changed in the meantime', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 })
|
||||
f.summarize.mockImplementationOnce(async () => empty).mockRejectedValueOnce(new Error('Provider failed'))
|
||||
const automatic = { ...request, trigger: 'heartbeat' as const }
|
||||
await expect(f.service().run(automatic)).rejects.toThrow('Provider failed')
|
||||
const runId = f.db.listSupervisionActivity()[0]!.id
|
||||
const saved = f.db.supervisionReviewStore().batches(runId)
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toHaveLength(1)
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('New version', f.conversations[0]!.messages[0]!.id)
|
||||
await expect(f.service().resume(runId)).rejects.toThrow('source changed')
|
||||
expect(f.db.supervisionReviewStore().progress(runId).restartRequired).toBe(true)
|
||||
const result = await f.service().run(automatic)
|
||||
expect(result.runId).not.toBe(runId)
|
||||
expect(result.status).toBe('completed')
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toEqual(saved)
|
||||
expect(result.runId).toBe(runId)
|
||||
expect(result).toMatchObject({ status: 'no_change', coverage: { skippedSources: 1, sources: 0 } })
|
||||
// The leaf of the old version is discarded and no checkpoint is written for the source,
|
||||
// so the next automatic review reads its current version.
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toEqual([])
|
||||
const next = await f.service().run(automatic)
|
||||
expect(next.runId).not.toBe(runId)
|
||||
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version'])
|
||||
})
|
||||
|
||||
it('makes a manual review incremental by default and keeps explicit re-analysis out of shared progress', async () => {
|
||||
@@ -455,7 +523,8 @@ it('omits a deleted conversation, its knowledge and tasks before resume without
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const kept = store.batches(runId).filter(batch => batch.conversationId !== deleted.id)
|
||||
expect(f.db.deleteLocalConversation(deleted.id)).toBe(true)
|
||||
expect(store.progress(runId)).toMatchObject({ omittedSources: 3, sources: 1, batches: kept.length, restartRequired: undefined })
|
||||
expect(store.progress(runId)).toMatchObject({ omittedSources: 3, sources: 1, batches: kept.length })
|
||||
expect(store.progress(runId).restartRequired).toBeUndefined()
|
||||
expect(store.batches(runId)).toEqual(kept)
|
||||
f.sql.exec('DROP TRIGGER fail_publication')
|
||||
const calls = f.summarize.mock.calls.length
|
||||
@@ -542,7 +611,7 @@ it('resets mixed-leaf survivors only and invalidates their dependent navigation
|
||||
expect(store.batches(runId)).toContainEqual(kept)
|
||||
})
|
||||
|
||||
it('recovers the earlier deletion failure but keeps archived and modified existing conversations as explicit failures', async () => {
|
||||
it('recovers earlier deletion failures and omits archived conversations instead of failing', async () => {
|
||||
const f = await fixture([['Source']])
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const id = f.db.startSupervisionRun(request)
|
||||
@@ -550,11 +619,11 @@ it('recovers the earlier deletion failure but keeps archived and modified existi
|
||||
f.sql.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.restartRequired', json('true')) WHERE run_id = ?").run(id)
|
||||
f.db.failSupervisionRun(id, `Review source changed or was removed (message:${f.conversations[0]!.messages[0]!.id}); start a new review`)
|
||||
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
|
||||
expect(store.progress(id).restartRequired).toBe(false)
|
||||
expect(store.progress(id).restartRequired).toBeUndefined()
|
||||
await expect(f.service().resume(id)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1 } })
|
||||
const g = await fixture([['Archived source']])
|
||||
g.summarize.mockRejectedValueOnce(new Error('Pause'))
|
||||
await expect(g.service().run(request)).rejects.toThrow('Pause')
|
||||
g.sql.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?").run(g.conversations[0]!.id)
|
||||
await expect(g.service().resume(g.db.listSupervisionActivity()[0]!.id)).rejects.toThrow('source changed')
|
||||
await expect(g.service().resume(g.db.listSupervisionActivity()[0]!.id)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1 } })
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ async function fixture() {
|
||||
{ documentId: 'document', documentName: 'Reference', snippet: 'Knowledge \u{1f600}', chunkId: 'chunk' }
|
||||
] }), conversations[0]!.messages[0]!.id)
|
||||
db.createTask({ id: randomUUID(), conversationId: conversations[0]!.id, projectId: project.id,
|
||||
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true })
|
||||
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true, status: 'completed' })
|
||||
const state: ReviewState = { request: { trigger: 'manual', scope: { kind: 'global' },
|
||||
timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2027-01-01T00:00:00.000Z' } },
|
||||
config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, timeoutSeconds: 30, concurrency: 1 }, phase: 'collecting' }
|
||||
@@ -96,10 +96,15 @@ it('rebuilds a partially written manifest after restart and validates completed
|
||||
await f.storage.resumeSupervisionReview(id, new AbortController().signal)
|
||||
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(422)
|
||||
expect(f.db.supervisionReviewStore().load(id).initializing).toBeUndefined()
|
||||
// Existing saved runs have no initialization flag and retain their frozen versions.
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id)
|
||||
await expect(f.storage.resumeSupervisionReview(id, new AbortController().signal)).rejects.toThrow('source changed')
|
||||
expect(f.db.supervisionReviewStore().load(id).restartRequired).toBe(true)
|
||||
// A source changed after freezing is skipped for this run instead of failing it.
|
||||
const changedId = f.conversations[0]!.messages[0]!.id
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', changedId)
|
||||
await f.storage.resumeSupervisionReview(id, new AbortController().signal)
|
||||
const store = f.db.supervisionReviewStore()
|
||||
expect(store.progress(id)).toMatchObject({ sources: 421, skippedSources: 1 })
|
||||
expect(store.load(id).restartRequired).toBeUndefined()
|
||||
expect(f.sql.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
|
||||
.get(id, `message:${changedId}`)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['paused', 'cancelled'] as const)('accepts %s during production initialization, holds the slot, and never calls the model', async status => {
|
||||
|
||||
@@ -514,11 +514,7 @@ describe('ContextManager', () => {
|
||||
})
|
||||
expect(showOpenDialog).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({ name: '图片' })
|
||||
])
|
||||
})
|
||||
{ properties: ['openFile', 'multiSelections'] }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -555,19 +551,7 @@ describe('ContextManager', () => {
|
||||
})
|
||||
expect(showOpenDialog).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'PDF 和 Office 文档',
|
||||
extensions: expect.arrayContaining([
|
||||
'docx',
|
||||
'pdf',
|
||||
'pptx',
|
||||
'xlsx'
|
||||
])
|
||||
})
|
||||
])
|
||||
})
|
||||
{ properties: ['openFile', 'multiSelections'] }
|
||||
)
|
||||
expect(onProgress.mock.calls.map(([progress]) => { const { operationId, ...rest } = progress; expect(operationId).toEqual(expect.any(String)); return rest })).toEqual([
|
||||
{
|
||||
|
||||
@@ -329,27 +329,7 @@ export class ContextManager {
|
||||
conversationId?: string
|
||||
): Promise<ContextAttachment[]> {
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{
|
||||
name: '文本、代码和配置文件',
|
||||
extensions: [...supportedExtensions].map((extension) =>
|
||||
extension.slice(1)
|
||||
)
|
||||
},
|
||||
{
|
||||
name: '图片',
|
||||
extensions: [...supportedImageExtensions].map((extension) =>
|
||||
extension.slice(1)
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'PDF 和 Office 文档',
|
||||
extensions: [...supportedDocumentExtensions].map((extension) =>
|
||||
extension.slice(1)
|
||||
)
|
||||
}
|
||||
]
|
||||
properties: ['openFile', 'multiSelections']
|
||||
})
|
||||
if (result.canceled) {
|
||||
return []
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
dialog,
|
||||
globalShortcut,
|
||||
Menu,
|
||||
net,
|
||||
safeStorage,
|
||||
session,
|
||||
shell,
|
||||
@@ -1115,6 +1116,7 @@ if (hasSingleInstanceLock) {
|
||||
: Promise.resolve([])
|
||||
])
|
||||
return createAgentRuntime(defaultWorkspace, settings, {
|
||||
modelFetcher: net.fetch as unknown as typeof fetch,
|
||||
outputStore: { backingStore: storageFiles.backingStore },
|
||||
outputAdopt: storageFiles.attachments.outputAdopt,
|
||||
observeFailure: observeDesktopFailure,
|
||||
|
||||
+54
-122
@@ -1805,6 +1805,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
} as const
|
||||
const agentPackageManager = {
|
||||
getAllSnapshot: vi.fn(async () => agentPackageInventory),
|
||||
getAllInventory: vi.fn(async () => agentPackageInventory),
|
||||
forPlatform: vi.fn(() => agentPackageManager),
|
||||
download: vi.fn(
|
||||
async (
|
||||
@@ -1944,7 +1945,16 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
).resolves.toEqual(agentPackageInventory)
|
||||
expect(
|
||||
agentPackageManager.getAllSnapshot
|
||||
).toHaveBeenCalledWith({ refresh: true })
|
||||
).toHaveBeenCalledWith({ refresh: true, localOnly: false })
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.sshHostsAgentPackageInventory
|
||||
)?.(event, { localOnly: true })
|
||||
).resolves.toEqual(agentPackageInventory)
|
||||
expect(agentPackageManager.getAllInventory).toHaveBeenCalledWith({
|
||||
refresh: false,
|
||||
localOnly: true
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.sshHostsAgentPackageDownload
|
||||
@@ -6684,6 +6694,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
||||
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime)
|
||||
electronMocks.fetch.mockImplementation((input, init) => fetch(input, init))
|
||||
const profile = { id: crypto.randomUUID(), name: 'Review connection',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none',
|
||||
requestHeaders: {}, requestBody: { temperature: 0.25 } }
|
||||
@@ -6712,6 +6723,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockReset()
|
||||
electronMocks.fetch.mockReset()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12083,16 +12095,26 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('routes eligible requests through the persisted smart expert service and publishes child events', async () => {
|
||||
it.each([
|
||||
{ route: 'manual', consumesTrustedInstructions: true },
|
||||
{ route: 'smart', consumesTrustedInstructions: true },
|
||||
{ route: 'manual', consumesTrustedInstructions: false }
|
||||
] as const)('applies a $route expert as a role prompt on the conversation runtime (system channel: $consumesTrustedInstructions)', async ({ route, consumesTrustedInstructions }) => {
|
||||
const received: Array<{ requestId: string; prompt: string; trustedInstructions?: string }> = []
|
||||
const runtime = {
|
||||
runtimeId: 'opencode',
|
||||
capability: 'chat',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
consumesTrustedInstructions,
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
run: vi.fn()
|
||||
async *run(request: { requestId: string; prompt: string; trustedInstructions?: string }) {
|
||||
received.push(request)
|
||||
yield { requestId: request.requestId, type: 'text', delta: '角色回答' }
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const childTaskId = '00000000-0000-4000-8000-000000000099'
|
||||
const expert = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: '研究专家',
|
||||
@@ -12104,69 +12126,49 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
updatedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
const subagentService = {
|
||||
run: vi.fn(async (input: {
|
||||
parentRequest: { requestId: string }
|
||||
onEvent: (event: Record<string, unknown>) => void
|
||||
}) => {
|
||||
for (const state of ['queued', 'running', 'completed']) {
|
||||
input.onEvent({
|
||||
requestId: input.parentRequest.requestId,
|
||||
type: 'subagent',
|
||||
childTaskId,
|
||||
expertId: expert.id,
|
||||
expertName: expert.name,
|
||||
routingMode: 'smart',
|
||||
state
|
||||
})
|
||||
}
|
||||
return { childTaskId, output: '专家结果' }
|
||||
}),
|
||||
run: vi.fn(),
|
||||
cancelAll: vi.fn(),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
const harness = createHarness(
|
||||
runtime,
|
||||
undefined,
|
||||
subagentService,
|
||||
true
|
||||
)
|
||||
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
|
||||
expert
|
||||
])
|
||||
const harness = createHarness(runtime, undefined, subagentService, true)
|
||||
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([expert])
|
||||
vi.mocked(harness.assistantDatabase.getExpert).mockReturnValue(expert)
|
||||
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
||||
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId,
|
||||
conversationId: 'conversation-smart',
|
||||
conversationId: 'conversation-role',
|
||||
prompt: '请做资料分析',
|
||||
smartRouting: true
|
||||
...(route === 'manual' ? { expertId: expert.id } : { smartRouting: true })
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
'completed'
|
||||
)
|
||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')
|
||||
)
|
||||
expect(runtime.run).not.toHaveBeenCalled()
|
||||
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||
expect(subagentService.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expert, routingMode: 'smart' })
|
||||
)
|
||||
expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith(
|
||||
expect(received).toHaveLength(1)
|
||||
const request = received[0]!
|
||||
expect(request.trustedInstructions).toContain('enabled capabilities')
|
||||
expect(request.trustedInstructions).toContain('"研究专家"')
|
||||
expect(request.trustedInstructions).toContain('Analyze evidence.')
|
||||
if (consumesTrustedInstructions) {
|
||||
expect(request.prompt).toBe('请做资料分析')
|
||||
} else {
|
||||
expect(request.prompt).toMatch(/enabled capabilities[\s\S]*Analyze evidence\.\n\n请做资料分析$/u)
|
||||
}
|
||||
expect(subagentService.run).not.toHaveBeenCalled()
|
||||
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
|
||||
requestId,
|
||||
'subagent',
|
||||
expect.objectContaining({ childTaskId, state: 'queued' })
|
||||
expect.anything()
|
||||
)
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.agentEvent,
|
||||
expect.objectContaining({ type: 'subagent', state: 'completed' })
|
||||
expect.objectContaining({ type: 'text', delta: '角色回答' })
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['manual', 'team', 'smart'] as const)('binds %s expert writes to each request project instead of the global workspace', async (route) => {
|
||||
it('binds expert team writes to each request project instead of the global workspace', async () => {
|
||||
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
||||
const directory = await mkdtemp(join(tmpdir(), 'expert-projects-'))
|
||||
const globalRoot = join(directory, 'global')
|
||||
@@ -12215,11 +12217,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
for (const [index, projectId] of projectIds.entries()) {
|
||||
const requestId = crypto.randomUUID()
|
||||
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: `project-${index}`, projectId,
|
||||
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, expertId: route === 'manual' ? expert.id : undefined,
|
||||
teamMode: route === 'team', smartRouting: route === 'smart' })
|
||||
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, teamMode: true })
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
||||
const files = await readdir(roots[index]!)
|
||||
expect(files).toHaveLength(route === 'team' ? 2 : 1)
|
||||
expect(files).toHaveLength(2)
|
||||
for (const file of files) expect(await readFile(join(roots[index]!, file), 'utf8')).toBe(index === 0 ? 'alpha' : 'beta')
|
||||
for (const input of requests.filter(input => input.parentRequest.requestId === requestId)) {
|
||||
expect(input.executionSpace).toBe(getRuntime.mock.calls[index]?.[1])
|
||||
@@ -12236,9 +12237,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each((['manual', 'team', 'smart'] as const).flatMap(route =>
|
||||
(['opencode', 'continue'] as const).map(provider => ({ route, provider }))
|
||||
))('uses managed $provider for $route SSH experts with their profile and parent bindings', async ({ route, provider }) => {
|
||||
it.each(['opencode', 'continue'] as const)('uses managed %s for SSH expert team members with their profile and parent bindings', async (provider) => {
|
||||
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
||||
const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-'))
|
||||
const projectId = crypto.randomUUID()
|
||||
@@ -12297,14 +12296,13 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
try {
|
||||
const requestId = crypto.randomUUID()
|
||||
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId,
|
||||
prompt: 'write a file', expertId: route === 'manual' ? expert.id : undefined,
|
||||
teamMode: route === 'team', smartRouting: route === 'smart' })
|
||||
prompt: 'write a file', teamMode: true })
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
||||
expect(factory).toHaveBeenCalledTimes(route === 'team' ? 2 : 1)
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
expect(createRemote).toHaveBeenCalledWith({ provider, profileId: expert.modelProfileId }, executionSpace)
|
||||
expect(createLocal).not.toHaveBeenCalled()
|
||||
expect(binding.save).not.toHaveBeenCalled()
|
||||
expect(fetcher).toHaveBeenCalledTimes(route === 'team' ? 1 : 0)
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
expect(ordinary.run).not.toHaveBeenCalled()
|
||||
expect(await readdir(globalRoot)).toEqual([])
|
||||
} finally {
|
||||
@@ -12375,72 +12373,6 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => {
|
||||
const runtime = {
|
||||
capability: 'chat',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
run: vi.fn()
|
||||
}
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve
|
||||
})
|
||||
const subagentService = {
|
||||
run: vi.fn((input: { signal: AbortSignal }) => {
|
||||
markStarted()
|
||||
return new Promise((_resolve, reject) => {
|
||||
input.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(input.signal.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}),
|
||||
cancelAll: vi.fn(),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
const harness = createHarness(
|
||||
runtime,
|
||||
undefined,
|
||||
subagentService,
|
||||
true
|
||||
)
|
||||
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: '研究专家',
|
||||
description: '',
|
||||
systemInstructions: 'Analyze.',
|
||||
routingKeywords: ['资料分析'],
|
||||
enabled: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
])
|
||||
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId,
|
||||
conversationId: 'conversation-cancel-smart',
|
||||
prompt: '请做资料分析',
|
||||
smartRouting: true
|
||||
})
|
||||
await started
|
||||
harness.cancelHandler?.(trustedEvent(harness.webContents), requestId)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
'cancelled',
|
||||
'请求已取消'
|
||||
)
|
||||
)
|
||||
expect(runtime.run).not.toHaveBeenCalled()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('allows ordinary text requests on runtimes without tool execution', async () => {
|
||||
const runtime = {
|
||||
capability: 'chat',
|
||||
|
||||
+52
-85
@@ -11,6 +11,7 @@ import {
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
net,
|
||||
shell,
|
||||
type NotificationConstructorOptions,
|
||||
type IpcMainInvokeEvent
|
||||
@@ -282,10 +283,7 @@ import {
|
||||
supervisionKnowledgeCommitRequestSchema
|
||||
} from '../shared/supervision-contracts'
|
||||
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
|
||||
import {
|
||||
SubagentRunError,
|
||||
type SubagentService
|
||||
} from './assistant/subagent-service'
|
||||
import type { SubagentService } from './assistant/subagent-service'
|
||||
import { routeSubagent } from './assistant/subagent-router'
|
||||
import {
|
||||
startEnvironmentChannels
|
||||
@@ -2699,37 +2697,20 @@ export function registerIpcHandlers(
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
|
||||
const runSingleExpert = async function* (
|
||||
request: AgentExecutionRequest,
|
||||
expert: Awaited<ReturnType<AssistantStoragePort['getExpert']>>,
|
||||
routingMode: 'manual' | 'smart',
|
||||
signal: AbortSignal,
|
||||
executionSpace: ExecutionSpaceDescriptor | undefined,
|
||||
reason?: string
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
if (!subagentService) {
|
||||
throw new Error('专家子任务服务不可用')
|
||||
}
|
||||
const result = await subagentService.run({
|
||||
parentRequest: request,
|
||||
executionSpace,
|
||||
expert,
|
||||
routingMode,
|
||||
reason,
|
||||
signal,
|
||||
onEvent: async (event) =>
|
||||
(await publishSubagentEvent(request.requestId, event)),
|
||||
onModelUsage: persistModelUsage
|
||||
})
|
||||
if (result.output) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: result.output
|
||||
}
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
/**
|
||||
* A selected or smart-routed expert is a role prompt for the conversation's
|
||||
* own runtime, not a delegated child task: it joins the trusted
|
||||
* instructions so streaming, tools and the selected runtime stay unchanged.
|
||||
*/
|
||||
const expertRolePrompt = (
|
||||
expert: Awaited<ReturnType<AssistantStoragePort['getExpert']>>
|
||||
): string =>
|
||||
[
|
||||
`Role: act as the specialist "${expert.name}" for this request.`,
|
||||
expert.systemInstructions
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
let scheduleQueueTickRunning = false
|
||||
const queueDueSchedules = async (): Promise<void> => {
|
||||
@@ -4192,6 +4173,15 @@ export function registerIpcHandlers(
|
||||
)
|
||||
}
|
||||
}
|
||||
// A selected or smart-routed expert is a role prompt for the
|
||||
// conversation's own runtime, not a delegated child task.
|
||||
const roleExpert =
|
||||
executionRequest.teamMode || imageGeneration
|
||||
? undefined
|
||||
: executionRequest.expertId
|
||||
? await assistantDatabase.getExpert(executionRequest.expertId)
|
||||
: smartRoute?.expert
|
||||
const rolePrompt = roleExpert ? expertRolePrompt(roleExpert) : ''
|
||||
const ordinaryStream = (): AsyncGenerator<
|
||||
RuntimeEvent,
|
||||
void,
|
||||
@@ -4202,66 +4192,40 @@ export function registerIpcHandlers(
|
||||
managedSshOperationAccepted = true
|
||||
markManagedSshAccepted()
|
||||
}
|
||||
const runtimeRequest: AgentExecutionRequest = rolePrompt
|
||||
? {
|
||||
...executionRequest,
|
||||
trustedInstructions: [
|
||||
executionRequest.trustedInstructions,
|
||||
rolePrompt
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
: executionRequest
|
||||
// Runtimes without a system channel get the capability and role
|
||||
// instructions ahead of the prompt.
|
||||
const promptPrefix =
|
||||
selectedRuntime.consumesTrustedInstructions !== true
|
||||
? [capabilityInstruction, rolePrompt].filter(Boolean).join('\n\n')
|
||||
: ''
|
||||
return selectedRuntime.run(
|
||||
capabilityInstruction && selectedRuntime.consumesTrustedInstructions !== true
|
||||
promptPrefix
|
||||
? {
|
||||
...executionRequest,
|
||||
prompt: `${capabilityInstruction}\n\n${executionRequest.prompt}`
|
||||
...runtimeRequest,
|
||||
prompt: `${promptPrefix}\n\n${runtimeRequest.prompt}`
|
||||
}
|
||||
: executionRequest,
|
||||
: runtimeRequest,
|
||||
controller.signal
|
||||
)
|
||||
}
|
||||
const runSmartRoute = async function* (): AsyncGenerator<
|
||||
RuntimeEvent,
|
||||
void,
|
||||
void
|
||||
> {
|
||||
if (!smartRoute) {
|
||||
yield* ordinaryStream()
|
||||
return
|
||||
}
|
||||
try {
|
||||
yield* runSingleExpert(
|
||||
executionRequest,
|
||||
smartRoute.expert,
|
||||
'smart',
|
||||
controller.signal,
|
||||
requestExecutionSpace,
|
||||
`匹配 ${smartRoute.matches} 个关键词,得分 ${smartRoute.score}`
|
||||
)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw error
|
||||
}
|
||||
if (error instanceof SubagentRunError && error.output) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: error.output
|
||||
}
|
||||
throw error
|
||||
}
|
||||
yield* ordinaryStream()
|
||||
}
|
||||
}
|
||||
const eventStream = executionRequest.teamMode
|
||||
? runExpertTeam(
|
||||
executionRequest,
|
||||
controller.signal,
|
||||
requestExecutionSpace
|
||||
)
|
||||
: executionRequest.expertId && !imageGeneration
|
||||
? runSingleExpert(
|
||||
executionRequest,
|
||||
await assistantDatabase.getExpert(
|
||||
executionRequest.expertId
|
||||
),
|
||||
'manual',
|
||||
controller.signal,
|
||||
requestExecutionSpace
|
||||
)
|
||||
: runSmartRoute()
|
||||
: ordinaryStream()
|
||||
for await (const agentEvent of splitTaggedReasoning(eventStream)) {
|
||||
if (agentEvent.type === 'done' || agentEvent.type === 'error') activeRequestLease.settling = true
|
||||
const provenance = remoteSemanticProvenance(agentEvent)
|
||||
@@ -4891,7 +4855,9 @@ export function registerIpcHandlers(
|
||||
const request =
|
||||
agentPackageInventoryRequestSchema.parse(input ?? {})
|
||||
return agentPackageInventorySchema.parse(
|
||||
await agentPackageManager.getAllSnapshot(request)
|
||||
request.localOnly
|
||||
? await agentPackageManager.getAllInventory(request)
|
||||
: await agentPackageManager.getAllSnapshot(request)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -6355,7 +6321,7 @@ export function registerIpcHandlers(
|
||||
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
||||
throw new Error(`监督者模型连接“${profile.name}”未配置 API Key`)
|
||||
}
|
||||
return createModelProfileRuntime(settings.workspacePath, settings, profile)
|
||||
return createModelProfileRuntime(settings.workspacePath, settings, profile, net.fetch as unknown as typeof fetch)
|
||||
}
|
||||
const supervisorService = createProductionSupervisorService(supervisionDatabase,
|
||||
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
|
||||
@@ -7126,6 +7092,7 @@ export function registerIpcHandlers(
|
||||
assertTrustedSender(event, window)
|
||||
const request = supervisionSuggestionListRequestSchema.parse(input ?? {})
|
||||
if ((await applicationSettingsStore?.get())?.heartbeatEnabled !== true) return []
|
||||
if (!supervisionPorts) return []
|
||||
return await (await supervisionDatabase.supervisionSuggestions()).list(request.status, request.limit, request.offset)
|
||||
})
|
||||
registerHandler(ipcChannels.supervisionSuggestionAction, async (event, input: unknown) => {
|
||||
|
||||
@@ -300,6 +300,17 @@ export class AgentPackageManager {
|
||||
})
|
||||
}
|
||||
|
||||
async getAllInventory(options: { refresh?: boolean } = {}): Promise<AgentPackageInventory> {
|
||||
const linux = await this.forPlatform('linux').getInventory(options)
|
||||
const darwin = this.forPlatform('darwin')
|
||||
darwin.#catalogState = this.forPlatform('linux').#catalogState
|
||||
const mac = await darwin.getInventory(options)
|
||||
return agentPackageInventorySchema.parse({
|
||||
...linux,
|
||||
entries: [...linux.entries, ...mac.entries]
|
||||
})
|
||||
}
|
||||
|
||||
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
|
||||
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
|
||||
}
|
||||
|
||||
@@ -528,10 +528,10 @@ const desktopApi: DesktopApi = {
|
||||
handler
|
||||
)
|
||||
},
|
||||
getAgentPackageInventory: (refresh = false) =>
|
||||
getAgentPackageInventory: (refresh = false, localOnly = false) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.sshHostsAgentPackageInventory,
|
||||
{ refresh }
|
||||
{ refresh, localOnly }
|
||||
) as Promise<AgentPackageInventory>,
|
||||
downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') =>
|
||||
ipcRenderer.invoke(
|
||||
|
||||
@@ -292,7 +292,7 @@ describe('sandboxed preload', () => {
|
||||
'ipcChannels.sshHostsAgentConnectionStatus'
|
||||
)
|
||||
expect(source).toContain(
|
||||
'getAgentPackageInventory: (refresh = false)'
|
||||
'getAgentPackageInventory: (refresh = false, localOnly = false)'
|
||||
)
|
||||
expect(source).toContain('downloadAgentPackage:')
|
||||
expect(source).toContain('importAgentPackage:')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentPackageInventory } from '../../shared/agent-package-contracts'
|
||||
import { AgentPackageInventoryPanel } from './AgentPackageInventoryPanel'
|
||||
import { changeUiLocale } from './i18n'
|
||||
|
||||
const agentPackageInventory: AgentPackageInventory = {
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
catalog: {
|
||||
state: 'available',
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
error: null
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
state: 'verified',
|
||||
version: '0.11.2',
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: true,
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntimeVersion: '1.18.29'
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
architecture: 'arm64',
|
||||
state: 'not-downloaded',
|
||||
version: null,
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: false,
|
||||
agentProtocol: null,
|
||||
remoteRuntimeVersion: null
|
||||
}
|
||||
]
|
||||
}
|
||||
const getAgentPackageInventory = vi.fn<(refresh?: boolean, localOnly?: boolean) => Promise<AgentPackageInventory>>()
|
||||
const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<AgentPackageInventory>>()
|
||||
const importAgentPackage = vi.fn<() => Promise<AgentPackageInventory | undefined>>()
|
||||
const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<void>>()
|
||||
const onAgentPackageProgress = vi.fn(() => () => {})
|
||||
|
||||
describe('AgentPackageInventoryPanel', () => {
|
||||
beforeEach(async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
vi.clearAllMocks()
|
||||
getAgentPackageInventory.mockResolvedValue(agentPackageInventory)
|
||||
downloadAgentPackage.mockResolvedValue(agentPackageInventory)
|
||||
importAgentPackage.mockResolvedValue(agentPackageInventory)
|
||||
exportAgentPackage.mockResolvedValue(undefined)
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
sshHosts: {
|
||||
getAgentPackageInventory,
|
||||
downloadAgentPackage,
|
||||
importAgentPackage,
|
||||
exportAgentPackage,
|
||||
onAgentPackageProgress
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('waits until shown, then lists, refreshes, downloads, exports, and imports packages', async () => {
|
||||
const onNotify = vi.fn()
|
||||
const onBusyChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<AgentPackageInventoryPanel active={false} onBusyChange={onBusyChange} onNotify={onNotify} />
|
||||
)
|
||||
expect(getAgentPackageInventory).not.toHaveBeenCalled()
|
||||
rerender(<AgentPackageInventoryPanel active onBusyChange={onBusyChange} onNotify={onNotify} />)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent 包')).toBeInTheDocument()
|
||||
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
|
||||
expect(screen.getByText('有更新')).toBeInTheDocument()
|
||||
expect(screen.getByText('未下载')).toBeInTheDocument()
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true)
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
|
||||
await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(3))
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(3, true)
|
||||
|
||||
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
|
||||
const arm64Card = screen.getByText('Linux arm64').closest('[role="listitem"]') as HTMLElement
|
||||
downloadAgentPackage.mockResolvedValueOnce({
|
||||
...agentPackageInventory,
|
||||
entries: agentPackageInventory.entries.map((entry) =>
|
||||
entry.architecture === 'x64' ? { ...entry, version: '0.11.4', updateAvailable: false } : entry
|
||||
)
|
||||
})
|
||||
fireEvent.click(within(x64Card).getByRole('button', { name: '更新到 0.11.4' }))
|
||||
await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux'))
|
||||
expect(await within(x64Card).findByText('已是最新')).toBeInTheDocument()
|
||||
expect(onBusyChange).toHaveBeenCalledWith(true)
|
||||
await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(false))
|
||||
|
||||
fireEvent.click(within(x64Card).getByRole('button', { name: '导出离线包' }))
|
||||
await waitFor(() => expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux'))
|
||||
await waitFor(() => expect(within(arm64Card).getByRole('button', { name: '下载 0.11.4' })).toBeEnabled())
|
||||
fireEvent.click(within(arm64Card).getByRole('button', { name: '下载 0.11.4' }))
|
||||
await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux'))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '导入离线包' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入离线包' }))
|
||||
await waitFor(() => expect(importAgentPackage).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'agent-package-imported' })))
|
||||
})
|
||||
|
||||
it('shows the local inventory before the online catalog returns', async () => {
|
||||
const localInventory: AgentPackageInventory = {
|
||||
...agentPackageInventory,
|
||||
catalog: { state: 'not-checked', checkedAt: null, error: null },
|
||||
entries: agentPackageInventory.entries.map((entry) => ({
|
||||
...entry,
|
||||
latestVersion: null,
|
||||
updateAvailable: false
|
||||
}))
|
||||
}
|
||||
let resolveOnline: ((inventory: AgentPackageInventory) => void) | undefined
|
||||
getAgentPackageInventory
|
||||
.mockResolvedValueOnce(localInventory)
|
||||
.mockReturnValueOnce(
|
||||
new Promise<AgentPackageInventory>((resolve) => {
|
||||
resolveOnline = resolve
|
||||
})
|
||||
)
|
||||
|
||||
render(<AgentPackageInventoryPanel />)
|
||||
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true)
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false)
|
||||
|
||||
await act(async () => {
|
||||
resolveOnline?.(agentPackageInventory)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(await screen.findByText('有更新')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading and recovers from a refresh error', async () => {
|
||||
let resolveInventory: ((inventory: AgentPackageInventory) => void) | undefined
|
||||
getAgentPackageInventory.mockReturnValueOnce(
|
||||
new Promise<AgentPackageInventory>((resolve) => { resolveInventory = resolve })
|
||||
)
|
||||
render(<AgentPackageInventoryPanel />)
|
||||
expect(await screen.findByText('正在校验本地 Agent 包并检查在线版本…')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
resolveInventory?.(agentPackageInventory)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
|
||||
|
||||
getAgentPackageInventory.mockRejectedValueOnce(new Error('inventory unavailable'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
|
||||
await screen.findByText('inventory unavailable')
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
|
||||
await waitFor(() => expect(screen.queryByText('inventory unavailable')).not.toBeInTheDocument())
|
||||
expect(getAgentPackageInventory).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
|
||||
const unavailableInventory: AgentPackageInventory = {
|
||||
...agentPackageInventory,
|
||||
catalog: { state: 'unavailable', checkedAt: '2026-08-24T00:00:00.000Z', error: 'network unavailable' },
|
||||
entries: agentPackageInventory.entries.map((entry) => ({ ...entry, latestVersion: null, updateAvailable: false }))
|
||||
}
|
||||
getAgentPackageInventory
|
||||
.mockResolvedValueOnce(unavailableInventory)
|
||||
.mockResolvedValueOnce(unavailableInventory)
|
||||
render(<AgentPackageInventoryPanel />)
|
||||
expect(await screen.findByText(/network unavailable/u)).toBeInTheDocument()
|
||||
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
|
||||
expect(within(x64Card).getByRole('button', { name: '检查并更新' })).toBeInTheDocument()
|
||||
expect(within(x64Card).getByRole('button', { name: '导出离线包' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,447 @@
|
||||
import { Cpu, Download, RefreshCw, Upload } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AgentPackageDownloadProgress,
|
||||
AgentPackageInventory
|
||||
} from '../../shared/agent-package-contracts'
|
||||
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
|
||||
// Local Agent package inventory used to install and update remote hosts.
|
||||
// Package state lives in Main and is independent of host records.
|
||||
export function AgentPackageInventoryPanel({
|
||||
active = true,
|
||||
onBusyChange,
|
||||
onNotify
|
||||
}: {
|
||||
active?: boolean
|
||||
onBusyChange?: (busy: boolean) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [agentInventory, setAgentInventory] =
|
||||
useState<AgentPackageInventory>()
|
||||
const [agentInventoryLoading, setAgentInventoryLoading] =
|
||||
useState(false)
|
||||
const [agentInventoryError, setAgentInventoryError] =
|
||||
useState<string>()
|
||||
const [agentPackageBusy, setAgentPackageBusy] =
|
||||
useState<string>()
|
||||
const [agentPackageProgress, setAgentPackageProgress] =
|
||||
useState<AgentPackageDownloadProgress>()
|
||||
const agentInventoryRequested = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(agentPackageBusy !== undefined)
|
||||
}, [agentPackageBusy, onBusyChange])
|
||||
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
|
||||
|
||||
const loadAgentInventory = useCallback(async (
|
||||
refresh = false
|
||||
): Promise<void> => {
|
||||
const getInventory =
|
||||
window.goodbuddy.sshHosts?.getAgentPackageInventory
|
||||
if (!getInventory) {
|
||||
setAgentInventoryError(
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.unavailable'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setAgentInventoryLoading(true)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
if (!refresh) {
|
||||
setAgentInventory(await getInventory(false, true))
|
||||
}
|
||||
setAgentInventory(await getInventory(refresh))
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.readFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentInventoryLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Reading the inventory checks the online catalog, so it waits until shown.
|
||||
useEffect(() => {
|
||||
if (!active || agentInventoryRequested.current) {
|
||||
return
|
||||
}
|
||||
agentInventoryRequested.current = true
|
||||
void loadAgentInventory()
|
||||
}, [active, loadAgentInventory])
|
||||
|
||||
useEffect(() => {
|
||||
const subscribe =
|
||||
window.goodbuddy.sshHosts?.onAgentPackageProgress
|
||||
return subscribe?.((progress) =>
|
||||
setAgentPackageProgress(progress)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const downloadAgentPackage = async (
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const download =
|
||||
window.goodbuddy.sshHosts?.downloadAgentPackage
|
||||
if (!download) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentPackageProgress(undefined)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
setAgentInventory(await download(architecture, platform))
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-downloaded:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.downloadFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
setAgentPackageProgress(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const importAgentPackage = async (): Promise<void> => {
|
||||
const importPackage =
|
||||
window.goodbuddy.sshHosts?.importAgentPackage
|
||||
if (!importPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy('import')
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
const inventory = await importPackage()
|
||||
if (inventory) {
|
||||
setAgentInventory(inventory)
|
||||
onNotify?.({
|
||||
dedupeKey: 'agent-package-imported',
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.imported'
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
}
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.importFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAgentPackage = async (
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const exportPackage =
|
||||
window.goodbuddy.sshHosts?.exportAgentPackage
|
||||
if (!exportPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
await exportPackage(architecture, platform)
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-exported:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.exportFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.title'
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.description'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="capability-card__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() => void importAgentPackage()}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.import'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.agentInventory.refresh'
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
agentInventoryLoading ||
|
||||
agentPackageBusy !== undefined
|
||||
}
|
||||
onClick={() => void loadAgentInventory(true)}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className={
|
||||
agentInventoryLoading
|
||||
? 'bundled-agent-inventory__spinner'
|
||||
: undefined
|
||||
}
|
||||
size={13}
|
||||
/>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.refresh'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{agentInventory && (
|
||||
<p className="settings-notice">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.summary',
|
||||
{
|
||||
available: agentInventory.entries.filter(
|
||||
(entry) => entry.state === 'verified'
|
||||
).length,
|
||||
total: agentInventory.entries.length
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agentInventoryError && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{agentInventoryError}
|
||||
</p>
|
||||
)}
|
||||
{agentInventoryLoading && !agentInventory ? (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.loading'
|
||||
)}
|
||||
</p>
|
||||
) : agentInventory ? (
|
||||
<div
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.agentInventory.listLabel'
|
||||
)}
|
||||
className="bundled-agent-inventory"
|
||||
role="list"
|
||||
>
|
||||
{agentInventory.entries.map((entry) => (
|
||||
<article
|
||||
className="bundled-agent-inventory__item"
|
||||
key={`${entry.platform}-${entry.architecture}`}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="bundled-agent-inventory__item-header">
|
||||
<Cpu aria-hidden="true" size={16} />
|
||||
<strong>
|
||||
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
|
||||
</strong>
|
||||
<span
|
||||
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
|
||||
entry.updateAvailable
|
||||
? 'update-available'
|
||||
: agentInventory.catalog.state ===
|
||||
'available' &&
|
||||
entry.state === 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'up-to-date'
|
||||
: entry.state
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
entry.updateAvailable
|
||||
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
|
||||
: agentInventory.catalog.state ===
|
||||
'available' &&
|
||||
entry.state === 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
|
||||
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.latestVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.architecture}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
|
||||
)}
|
||||
</dt>
|
||||
<dd>
|
||||
{entry.agentProtocol
|
||||
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{agentPackageProgress?.architecture ===
|
||||
entry.architecture &&
|
||||
agentPackageProgress.platform === entry.platform &&
|
||||
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="capability-card__actions">
|
||||
{(
|
||||
entry.updateAvailable ||
|
||||
(
|
||||
entry.state !== 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
) ||
|
||||
agentInventory.catalog.state !== 'available'
|
||||
) && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void downloadAgentPackage(
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t(
|
||||
entry.updateAvailable
|
||||
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
|
||||
: entry.state !== 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
|
||||
: 'platformFeatures.remoteProjects.agentInventory.update',
|
||||
{ version: entry.latestVersion }
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{entry.state === 'verified' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void exportAgentPackage(
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.export'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{agentInventory?.catalog.state === 'available' && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.catalog.available'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agentInventory?.catalog.state === 'unavailable' && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
|
||||
{ error: agentInventory.catalog.error }
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
+115
-110
@@ -1146,14 +1146,18 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
function composerMenuTrigger(
|
||||
label: "专家角色",
|
||||
): HTMLButtonElement {
|
||||
if (label === "专家角色") openComposerOptions();
|
||||
return screen.getByRole("button", {
|
||||
name: new RegExp(`^${label}:`, "u"),
|
||||
});
|
||||
}
|
||||
|
||||
function openComposerOptions(): void {
|
||||
const trigger = screen.getByRole("button", { name: "选项" });
|
||||
const trigger = screen.getByRole("button", { name: "故事图谱与知识库" });
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||
}
|
||||
|
||||
async function openRuntimeOptions(runtime = "OpenCode"): Promise<void> {
|
||||
const trigger = await screen.findByRole("button", { name: `${runtime} 专属功能` });
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||
}
|
||||
|
||||
@@ -1252,6 +1256,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3482,6 +3487,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3587,6 +3593,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3604,6 +3611,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3707,6 +3715,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3724,6 +3733,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -4604,7 +4614,7 @@ describe("App", () => {
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("windows the latest 80 messages and preserves scroll when revealing earlier messages", async () => {
|
||||
it("exposes the full loaded history while keeping the DOM windowed", async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000421",
|
||||
@@ -4623,62 +4633,17 @@ describe("App", () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
|
||||
// Only the rows near the bottom are mounted (PERF-14); a spacer stands in
|
||||
// for the rest of the 80-message batch.
|
||||
// Only the rows near the bottom are mounted (PERF-14); spacers stand in
|
||||
// for the rest of the loaded history.
|
||||
const mounted = container.querySelectorAll(".message").length;
|
||||
expect(mounted).toBeGreaterThan(0);
|
||||
expect(mounted).toBeLessThan(80);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
|
||||
const chat = container.querySelector<HTMLElement>(".chat");
|
||||
if (!chat) {
|
||||
throw new Error("Missing chat scroll container");
|
||||
}
|
||||
const topSpacerHeight = (): number =>
|
||||
Number.parseFloat(
|
||||
container.querySelector<HTMLElement>('[data-window-spacer="top"]')?.style.height ?? "0",
|
||||
) || 0;
|
||||
Object.defineProperties(chat, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: {
|
||||
configurable: true,
|
||||
get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(),
|
||||
},
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 125,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "加载更早的消息(还剩 81 条)",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".message")).toHaveLength(mounted);
|
||||
// The 80 earlier messages land above the reader, who keeps their place.
|
||||
expect(chat.scrollTop).toBe(125 + 80 * 160);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "加载更早的消息(还剩 1 条)",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("历史消息 000")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /加载更早的消息/u }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus();
|
||||
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(mounted + 1);
|
||||
expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a long conversation in one bounded commit without catch-up steps", async () => {
|
||||
it("opens a long conversation in one bounded commit without a history gate", async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000422",
|
||||
@@ -4716,9 +4681,8 @@ describe("App", () => {
|
||||
|
||||
expect(counts).toHaveLength(1);
|
||||
expect(counts[0]).toBeLessThan(80);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the reader position while a response continues below", async () => {
|
||||
@@ -5197,17 +5161,9 @@ describe("App", () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
expect(await screen.findByText("第一段历史 160")).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "加载更早的消息(还剩 81 条)",
|
||||
}),
|
||||
);
|
||||
const firstPane = container.querySelector<HTMLElement>(
|
||||
`[data-conversation-id="${firstConversationId}"]`,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
|
||||
).toBeInTheDocument();
|
||||
// Windowed (PERF-14): the mounted rows stay bounded and keep their nodes.
|
||||
const mountedRows = firstPane?.querySelectorAll(".message").length ?? 0;
|
||||
expect(mountedRows).toBeGreaterThan(0);
|
||||
@@ -6480,6 +6436,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -6873,6 +6830,7 @@ describe("App", () => {
|
||||
evidence: [],
|
||||
});
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: "故事图谱与知识库" });
|
||||
openComposerOptions();
|
||||
const knowledgeScopeTrigger = await screen.findByRole("button", {
|
||||
name: "选择知识库,本次已启用 0 个",
|
||||
@@ -7101,6 +7059,7 @@ describe("App", () => {
|
||||
evidence: [],
|
||||
});
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: "故事图谱与知识库" });
|
||||
openComposerOptions();
|
||||
const knowledgeScope = await screen.findByRole("button", {
|
||||
name: "选择知识库,本次已启用 0 个",
|
||||
@@ -8097,9 +8056,9 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByLabelText("切换助手工作栏"));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "工作区" }));
|
||||
fireEvent.click(await screen.findByLabelText("docs 的更多操作"));
|
||||
fireEvent.click(within(screen.getByRole("menu", { name: "docs" })).getByRole("menuitem", { name: "使用默认应用打开" }));
|
||||
fireEvent.click(within(screen.getByRole("menu", { name: "docs" })).getByRole("menuitem", { name: "使用系统应用打开" }));
|
||||
fireEvent.click(screen.getByLabelText("README.md 的更多操作"));
|
||||
fireEvent.click(within(screen.getByRole("menu", { name: "README.md" })).getByRole("menuitem", { name: "使用默认应用打开" }));
|
||||
fireEvent.click(within(screen.getByRole("menu", { name: "README.md" })).getByRole("menuitem", { name: "使用系统应用打开" }));
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.openPath).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
@@ -8656,7 +8615,6 @@ describe("App", () => {
|
||||
|
||||
it("matches the expert keyboard menu to the model picker", async () => {
|
||||
render(<App />);
|
||||
openComposerOptions();
|
||||
|
||||
const expertTrigger = await screen.findByRole("button", {
|
||||
name: "专家角色:通用助手",
|
||||
@@ -8683,15 +8641,16 @@ describe("App", () => {
|
||||
screen.queryByRole("menu", { name: "专家角色" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(expertTrigger, { key: "Escape" });
|
||||
const optionsTrigger = screen.getByRole("button", { name: "选项" });
|
||||
expect(optionsTrigger).toHaveFocus();
|
||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
|
||||
fireEvent.click(optionsTrigger);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "专家角色:通用助手" })).toHaveFocus());
|
||||
const contextTrigger = screen.getByRole("button", { name: "故事图谱与知识库" });
|
||||
fireEvent.click(contextTrigger);
|
||||
expect(screen.getByRole("dialog", { name: "故事图谱与知识库" })).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name: "使用故事图谱" })).toHaveFocus());
|
||||
fireEvent.keyDown(screen.getByRole("switch", { name: "使用故事图谱" }), { key: "Escape" });
|
||||
expect(contextTrigger).toHaveFocus();
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(contextTrigger);
|
||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
screen.queryByRole("menu", { name: "工作模式" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -8731,8 +8690,6 @@ describe("App", () => {
|
||||
await screen.findByLabelText("向 GoodBuddy 提问")
|
||||
).closest<HTMLElement>(".composer");
|
||||
expect(composer).not.toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
const contentTools = within(composer!).getByRole("group", {
|
||||
name: "添加内容",
|
||||
@@ -8747,12 +8704,16 @@ describe("App", () => {
|
||||
const conversationSettings = within(composer!).getByRole("group", {
|
||||
name: "对话设置",
|
||||
});
|
||||
openComposerOptions();
|
||||
expect(
|
||||
within(screen.getByRole("dialog", { name: "对话设置" })).getByRole("button", {
|
||||
name: "专家角色:通用助手",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
// The role chip sits in the input's top-left corner, before the textarea.
|
||||
const inputArea = composer!.querySelector<HTMLElement>(".composer__input")!;
|
||||
const expertTrigger = within(inputArea).getByRole("button", { name: "专家角色:通用助手" });
|
||||
expect(expertTrigger.compareDocumentPosition(screen.getByLabelText("向 GoodBuddy 提问")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(conversationSettings).queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
|
||||
// Toolbar order: context sources, Runtime, then Runtime-specific settings.
|
||||
const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
|
||||
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(
|
||||
within(conversationSettings).queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
@@ -9720,15 +9681,14 @@ describe("App", () => {
|
||||
).toHaveTextContent(remoteProject.name),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
|
||||
// The switch now lives in the Hosts application settings.
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "管理应用" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", {
|
||||
name: "远程项目",
|
||||
}),
|
||||
await screen.findByRole("button", { name: "主机管理 应用设置" }),
|
||||
);
|
||||
const remoteProjectsSwitch = await screen.findByRole("switch", {
|
||||
name: "远程项目",
|
||||
name: "启用远程能力",
|
||||
});
|
||||
expect(remoteProjectsSwitch).toBeChecked();
|
||||
fireEvent.click(remoteProjectsSwitch);
|
||||
@@ -10311,14 +10271,14 @@ describe("App", () => {
|
||||
));
|
||||
// Let startup selection and its focus effects finish before opening these options.
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const agentPicker = await screen.findByRole("button", {
|
||||
name: /OpenCode Runtime Agent/u,
|
||||
});
|
||||
const runtimeToolbar = screen.getByRole("group", {
|
||||
name: "OpenCode 专属功能",
|
||||
});
|
||||
const options = screen.getByRole("dialog", { name: "对话设置" });
|
||||
const options = screen.getByRole("dialog", { name: "OpenCode 专属功能" });
|
||||
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
||||
expect(runtimeToolbar).toContainElement(agentPicker);
|
||||
expect(options).toContainElement(agentPicker);
|
||||
@@ -10346,7 +10306,7 @@ describe("App", () => {
|
||||
target: { value: "src/main" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "OpenCode 专属功能" })).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("Planner");
|
||||
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("/review");
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
@@ -10468,7 +10428,7 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("已就绪");
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const runtimeToolbar = await screen.findByRole("group", {
|
||||
name: "OpenCode 专属功能",
|
||||
});
|
||||
@@ -10536,7 +10496,7 @@ describe("App", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
openComposerOptions();
|
||||
expect(screen.queryByRole("button", { name: "OpenCode 专属功能" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /OpenCode Runtime Agent/u })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
retry.resolve({
|
||||
@@ -10548,6 +10508,7 @@ describe("App", () => {
|
||||
});
|
||||
await retry.promise;
|
||||
});
|
||||
await openRuntimeOptions();
|
||||
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
||||
expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible();
|
||||
fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" });
|
||||
@@ -10581,7 +10542,7 @@ describe("App", () => {
|
||||
.mockResolvedValueOnce(healthy)
|
||||
.mockResolvedValueOnce(healthy);
|
||||
render(<App />);
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
await screen.findByRole("button", { name: /OpenCode Runtime Agent/u });
|
||||
selectProjectOption(secondProject.name);
|
||||
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith(
|
||||
@@ -10596,7 +10557,7 @@ describe("App", () => {
|
||||
selectProjectOption(firstProject.name);
|
||||
// Let the project picker restore focus before opening the dismiss-on-blur options.
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||
await act(async () => {
|
||||
pending.resolve(failed);
|
||||
@@ -10615,9 +10576,7 @@ describe("App", () => {
|
||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
|
||||
selectProjectOption(firstProject.name);
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "true");
|
||||
await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4);
|
||||
});
|
||||
|
||||
@@ -10652,7 +10611,7 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: new RegExp(`^${rows[0]!.title}`, "u") });
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /Planner/u }));
|
||||
@@ -10660,7 +10619,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /\/review/u }));
|
||||
const inventoryCalls = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
|
||||
fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!);
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar);
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(inventoryCalls);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })).not.toHaveTextContent("Planner"));
|
||||
@@ -10744,7 +10703,7 @@ describe("App", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions("Continue");
|
||||
const presetPicker = await screen.findByRole("button", {
|
||||
name: /Continue 配置预设.*使用设置默认预设/u,
|
||||
});
|
||||
@@ -10754,7 +10713,7 @@ describe("App", () => {
|
||||
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
||||
expect(runtimeToolbar).toContainElement(presetPicker);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "对话设置" }),
|
||||
screen.getByRole("dialog", { name: "Continue 专属功能" }),
|
||||
).toContainElement(presetPicker);
|
||||
fireEvent.click(presetPicker);
|
||||
fireEvent.click(
|
||||
@@ -12579,7 +12538,8 @@ describe("App", () => {
|
||||
|
||||
it("can dispatch a request to the parallel expert team", async () => {
|
||||
render(<App />);
|
||||
|
||||
// The role belongs to the loaded conversation.
|
||||
await screen.findByLabelText("发送");
|
||||
selectComposerOption("专家角色", "专家团队(并行)");
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "制定发布计划" },
|
||||
@@ -12667,6 +12627,43 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the role per conversation and snapshots it on the reply", async () => {
|
||||
const expertId = "00000000-0000-4000-8000-000000000511";
|
||||
vi.mocked(api.experts.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: expertId,
|
||||
name: "审查专家",
|
||||
description: "代码审查",
|
||||
systemInstructions: "Review code.",
|
||||
routingKeywords: [],
|
||||
enabled: true,
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
updatedAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const { container } = render(<App />);
|
||||
await waitFor(() => expect(api.experts.list).toHaveBeenCalled());
|
||||
const firstTitle = container.querySelector(".conversation-item--active .conversation-item__title")?.textContent;
|
||||
const expertMenu = openComposerMenu("专家角色");
|
||||
fireEvent.click(
|
||||
(await within(expertMenu).findByText("审查专家", { selector: "span" })).closest<HTMLButtonElement>("button")!,
|
||||
);
|
||||
expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "审查这个改动" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ expertId })));
|
||||
expect(await screen.findByText("审查专家", { selector: ".message__role" })).toBeVisible();
|
||||
|
||||
const create = screen.getAllByRole("button", { name: "新建对话" })[0]!;
|
||||
fireEvent.click(create);
|
||||
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:通用助手"));
|
||||
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!)
|
||||
.getByTitle(firstTitle && firstTitle !== "新对话" ? firstTitle : "审查这个改动"));
|
||||
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家"));
|
||||
});
|
||||
|
||||
it("shows Subagent states and records child expert activity", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -14672,6 +14669,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14689,6 +14687,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14743,6 +14742,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14804,6 +14804,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14842,6 +14843,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14859,6 +14861,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14922,6 +14925,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14982,6 +14986,7 @@ describe("App", () => {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -15018,7 +15023,7 @@ describe("App", () => {
|
||||
);
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
await screen.findByRole("tab", { name: "通用设置" });
|
||||
await screen.findByLabelText("默认工作区目录");
|
||||
expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
|
||||
expect(api.updates.updateSettings).not.toHaveBeenCalled();
|
||||
@@ -15067,24 +15072,24 @@ describe("App", () => {
|
||||
await waitFor(() => expect(move).toBeEnabled())
|
||||
fireEvent.click(move)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)',
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理',
|
||||
]))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
|
||||
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
||||
fireEvent.dragStart(cards[3]!)
|
||||
fireEvent.drop(cards[1]!)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)',
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理',
|
||||
]))
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing', 'remote-hosts'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
|
||||
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '主机管理', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)'])
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理'])
|
||||
})
|
||||
|
||||
it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => {
|
||||
|
||||
+107
-47
@@ -14,6 +14,7 @@ import {
|
||||
ApplicationCenter,
|
||||
applicationDefinitions,
|
||||
isApplicationEnabled,
|
||||
isApplicationOpenable,
|
||||
} from "./ApplicationCenter";
|
||||
import {
|
||||
defaultApplicationNavigation,
|
||||
@@ -131,10 +132,7 @@ import type {
|
||||
ImageViewerItem,
|
||||
Message,
|
||||
} from "./ChatTimeline";
|
||||
import {
|
||||
messageRenderBatchSize,
|
||||
type ChatScrollSnapshot,
|
||||
} from "./ChatHistoryPane";
|
||||
import type { ChatScrollSnapshot } from "./ChatHistoryPane";
|
||||
import { usePaneOrder } from "./pane-order";
|
||||
import { getConversationDisplayTitle, isUnusedConversation, type Conversation } from "./chat-conversation";
|
||||
import {
|
||||
@@ -290,6 +288,10 @@ const activityPanelRoute = createPreloadableComponent(
|
||||
() => import("./ActivityPanel"),
|
||||
(module) => module.ActivityPanel,
|
||||
);
|
||||
const remoteHostsPageRoute = createPreloadableComponent(
|
||||
() => import("./RemoteHostsPage"),
|
||||
(module) => module.RemoteHostsPage,
|
||||
);
|
||||
const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const;
|
||||
|
||||
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component;
|
||||
@@ -297,6 +299,7 @@ const HeartbeatCenter = heartbeatCenterRoute.Component;
|
||||
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component;
|
||||
const SettingsPanel = settingsPanelRoute.Component;
|
||||
const ActivityPanel = activityPanelRoute.Component;
|
||||
const RemoteHostsPage = remoteHostsPageRoute.Component;
|
||||
|
||||
const conversationPersistenceIntervalMs = 500;
|
||||
// How often streaming deltas are absorbed into the conversation store; rows render them live.
|
||||
@@ -510,14 +513,14 @@ export function AppNotificationViewport({
|
||||
}
|
||||
|
||||
type WorkspaceView =
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings";
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "remote-hosts" | "activity" | "settings";
|
||||
|
||||
// Tracks which kept-alive routes currently report unsaved edits. Reads happen
|
||||
// only in event handlers and the sweep timer, so this is not React state.
|
||||
function createUnsavedWorkspaceTracker(): {
|
||||
pinnedViews: () => Set<WorkspaceView>;
|
||||
reporters: Record<
|
||||
"knowledge" | "heartbeat" | "magic-notes" | "activity",
|
||||
"knowledge" | "heartbeat" | "magic-notes" | "remote-hosts" | "activity",
|
||||
ReportWorkspaceUnsavedChanges
|
||||
>;
|
||||
} {
|
||||
@@ -538,6 +541,7 @@ function createUnsavedWorkspaceTracker(): {
|
||||
knowledge: reporterFor("knowledge"),
|
||||
heartbeat: reporterFor("heartbeat"),
|
||||
"magic-notes": reporterFor("magic-notes"),
|
||||
"remote-hosts": reporterFor("remote-hosts"),
|
||||
activity: reporterFor("activity"),
|
||||
},
|
||||
};
|
||||
@@ -550,6 +554,7 @@ const intentRoutePreloaders: Partial<
|
||||
knowledge: knowledgeWorkspaceRoute.preload,
|
||||
activity: activityPanelRoute.preload,
|
||||
settings: settingsPanelRoute.preload,
|
||||
"remote-hosts": remoteHostsPageRoute.preload,
|
||||
};
|
||||
|
||||
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
|
||||
@@ -842,6 +847,7 @@ function toConversationSnapshots(
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
@@ -1153,7 +1159,6 @@ function App(): React.JSX.Element {
|
||||
const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedExpertId, setSelectedExpertId] = useState("");
|
||||
const [activeProjectId, setActiveProjectId] = useState("");
|
||||
const activeProjectIdRef = useRef(activeProjectId);
|
||||
const workspaceChangesRequestRef = useRef(0);
|
||||
@@ -1518,6 +1523,9 @@ function App(): React.JSX.Element {
|
||||
setActiveIdState(next);
|
||||
}, [activeConversationViewIds, conversationStore]);
|
||||
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
|
||||
const remoteProjectsEnabledRef = useRef(false);
|
||||
// Assigned after the handler is defined below; reads latest projects at call time.
|
||||
const remoteProjectsEnabledChangeRef = useRef<(enabled: boolean) => void>(setRemoteProjectsEnabled);
|
||||
const [
|
||||
conversationHtmlRenderingEnabled,
|
||||
setConversationHtmlRenderingEnabled,
|
||||
@@ -1530,7 +1538,7 @@ function App(): React.JSX.Element {
|
||||
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
|
||||
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
|
||||
const visibleApplications = useMemo(() => applicationNavigation.order.filter(id =>
|
||||
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
|
||||
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationOpenable(applicationSettings, id)))
|
||||
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
|
||||
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
|
||||
setApplicationSettings(settings);
|
||||
@@ -1552,7 +1560,11 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
|
||||
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
|
||||
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
|
||||
// Whichever surface changed the switch, leave an active SSH project when it turns off.
|
||||
if (remoteProjectsEnabledRef.current !== settings.remoteProjectsEnabled) {
|
||||
remoteProjectsEnabledRef.current = settings.remoteProjectsEnabled;
|
||||
remoteProjectsEnabledChangeRef.current(settings.remoteProjectsEnabled);
|
||||
}
|
||||
}, [guardNoteDraft, setNoteDraft, setView]);
|
||||
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
|
||||
const revision = ++applicationSettingsRevisionRef.current;
|
||||
@@ -1806,9 +1818,6 @@ function App(): React.JSX.Element {
|
||||
),
|
||||
[],
|
||||
);
|
||||
const [visibleMessageCounts, setVisibleMessageCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth);
|
||||
@@ -1863,15 +1872,6 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleVisibleMessageCountChange = useCallback(
|
||||
(conversationId: string, count: number): void => {
|
||||
setVisibleMessageCounts((current) => ({
|
||||
...current,
|
||||
[conversationId]: count,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const closeNarrowSidebar = useCallback((): void => {
|
||||
setSidebarOpen(false);
|
||||
requestAnimationFrame(() => sidebarToggleRef.current?.focus());
|
||||
@@ -2280,6 +2280,34 @@ function App(): React.JSX.Element {
|
||||
// Header fields of the active conversation, value-compared: message updates
|
||||
// (streaming flushes, tool events) do not re-render App.
|
||||
const activeConversation = useActiveConversationView(conversationStore, activeId);
|
||||
// The role follows the conversation; a deleted role reads as the general
|
||||
// assistant.
|
||||
const resolveExpertSelection = useCallback(
|
||||
(value: string | undefined): string =>
|
||||
!value || value === "team" ||
|
||||
assistantExperts.some((expert) => expert.id === value)
|
||||
? value ?? ""
|
||||
: "",
|
||||
[assistantExperts],
|
||||
);
|
||||
const selectedExpertId = resolveExpertSelection(activeConversation?.selectedExpertId);
|
||||
const setSelectedExpertId = useCallback(
|
||||
(value: string): void => {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeId &&
|
||||
(conversation.selectedExpertId ?? "") !== value
|
||||
? {
|
||||
...conversation,
|
||||
selectedExpertId: value ? value : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
},
|
||||
[activeId, setConversations],
|
||||
);
|
||||
const enabledKnowledgeLibraryIds =
|
||||
activeConversation?.knowledgeLibraryIds ?? [];
|
||||
const setEnabledKnowledgeLibraryIds = useCallback(
|
||||
@@ -4297,9 +4325,15 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
const localProject = projects.find(isOrdinaryLocalProject);
|
||||
if (localProject) {
|
||||
// Keep the Hosts page open when the switch is turned off there.
|
||||
const currentView = viewRef.current;
|
||||
commitProjectSelection(localProject);
|
||||
if (currentView === "remote-hosts") commitView("remote-hosts");
|
||||
}
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
remoteProjectsEnabledChangeRef.current = handleRemoteProjectsEnabledChange;
|
||||
});
|
||||
|
||||
const archiveProject = async (projectId: string): Promise<void> => {
|
||||
await window.goodbuddy.projects.setArchived(projectId, true);
|
||||
@@ -4580,11 +4614,6 @@ function App(): React.JSX.Element {
|
||||
delete next[conversationId];
|
||||
return next;
|
||||
});
|
||||
setVisibleMessageCounts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[conversationId];
|
||||
return next;
|
||||
});
|
||||
setConversationActivity(conversationId, false);
|
||||
const remaining = conversationStore.getState().filter(
|
||||
(conversation) => conversation.id !== conversationId,
|
||||
@@ -5113,7 +5142,7 @@ function App(): React.JSX.Element {
|
||||
: (queuedInput.expertId ?? "")
|
||||
: runtime?.capability === "image-generation"
|
||||
? ""
|
||||
: selectedExpertId;
|
||||
: resolveExpertSelection(conversationSnapshot.selectedExpertId);
|
||||
const knowledgeLibraryIdsSnapshot =
|
||||
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
|
||||
const smartRoutingSnapshot =
|
||||
@@ -5210,6 +5239,14 @@ function App(): React.JSX.Element {
|
||||
const executionPrompt = memoryContext
|
||||
? `${prompt}\n\n${memoryContext}`
|
||||
: prompt;
|
||||
const selectedExpert = selectedExpertSnapshot && selectedExpertSnapshot !== "team"
|
||||
? assistantExperts.find((expert) => expert.id === selectedExpertSnapshot)
|
||||
: undefined;
|
||||
const expertSnapshot: Message["expert"] = selectedExpertSnapshot === "team"
|
||||
? { kind: "team" }
|
||||
: selectedExpert
|
||||
? { kind: "expert", id: selectedExpert.id, name: selectedExpert.name.trim().slice(0, 80) }
|
||||
: undefined;
|
||||
const assistantMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
@@ -5218,6 +5255,7 @@ function App(): React.JSX.Element {
|
||||
createdAt: Date.now(),
|
||||
state: "streaming",
|
||||
status: t("chat.status.preparingRequest"),
|
||||
...(expertSnapshot ? { expert: expertSnapshot } : {}),
|
||||
};
|
||||
|
||||
activeRuns.current.set(requestId, {
|
||||
@@ -5941,7 +5979,6 @@ function App(): React.JSX.Element {
|
||||
const index = conversation.messages.findIndex(item => item.id === targetId);
|
||||
if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') });
|
||||
else {
|
||||
setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) }));
|
||||
setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() });
|
||||
}
|
||||
}
|
||||
@@ -6495,6 +6532,8 @@ function App(): React.JSX.Element {
|
||||
const settingsPanelActions = useStableHandlers({
|
||||
onTransparentFrostedEffectEnabledChange: (enabled: boolean) =>
|
||||
updateApplicationSettings({ transparentFrostedEffectEnabled: enabled }),
|
||||
onChatMinimapEnabledChange: (enabled: boolean) =>
|
||||
updateApplicationSettings({ chatMinimapEnabled: enabled }),
|
||||
onRetryApplicationSettings: () => void reloadApplicationSettings(),
|
||||
onBrandingPreferencesChange: (preferences: Parameters<typeof saveBrandingPreferences>[0]) => {
|
||||
if (!saveBrandingPreferences(preferences)) {
|
||||
@@ -6512,24 +6551,19 @@ function App(): React.JSX.Element {
|
||||
onClose: () => {
|
||||
commitView(viewRef.current);
|
||||
},
|
||||
// Each conversation keeps its role; one that no longer resolves reads as
|
||||
// the general assistant (see resolveExpertSelection).
|
||||
onExpertsChanged: (experts: AssistantExpert[]) => {
|
||||
setAssistantExperts(experts);
|
||||
if (
|
||||
(selectedExpertId === "team" && experts.length < 2) ||
|
||||
(selectedExpertId &&
|
||||
selectedExpertId !== "team" &&
|
||||
!experts.some((expert) => expert.id === selectedExpertId))
|
||||
) {
|
||||
setSelectedExpertId("");
|
||||
}
|
||||
},
|
||||
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
|
||||
onProjectsDeleted: removeProjectsFromUi,
|
||||
onSaved: (settings: RuntimeSettings) => {
|
||||
setRuntimeSettings(settings);
|
||||
},
|
||||
onUpdateProject: updateProject,
|
||||
});
|
||||
const remoteHostsActions = useStableHandlers({
|
||||
onProjectsDeleted: removeProjectsFromUi,
|
||||
});
|
||||
const rightSidebarActions = useStableHandlers<RightSidebarHandlers>({
|
||||
onBeforeCloseNotes: async () => {
|
||||
if (!await guardNoteDraft()) return false;
|
||||
@@ -7059,6 +7093,7 @@ function App(): React.JSX.Element {
|
||||
conversationHtmlRenderingEnabled={
|
||||
conversationHtmlRenderingEnabled
|
||||
}
|
||||
chatMinimapEnabled={applicationSettings?.chatMinimapEnabled ?? true}
|
||||
conversationId={conversationId}
|
||||
key={conversationId}
|
||||
loadHistory={ensureConversationHistory}
|
||||
@@ -7081,19 +7116,12 @@ function App(): React.JSX.Element {
|
||||
onSelectTask={setSelectedAssistantTaskId}
|
||||
onSetInput={setQuickActionInput}
|
||||
onSetScheduleEnabled={setAssistantScheduleEnabled}
|
||||
onVisibleMessageCountChange={
|
||||
handleVisibleMessageCountChange
|
||||
}
|
||||
quickActions={quickActions}
|
||||
schedules={assistantSchedules}
|
||||
scrollSnapshot={chatScrollSnapshots[conversationId]}
|
||||
selectedAssistantTaskId={selectedAssistantTaskId}
|
||||
store={conversationStore}
|
||||
tasks={tasksByConversation.get(conversationId) ?? emptyConversationTasks}
|
||||
visibleMessageCount={
|
||||
visibleMessageCounts[conversationId] ??
|
||||
messageRenderBatchSize
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{activeProject?.kind === "channel" &&
|
||||
@@ -7333,6 +7361,38 @@ function App(): React.JSX.Element {
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && <DeviceSharingPage notify={notify} />}
|
||||
{(view === "remote-hosts" ||
|
||||
cachedWorkspaceViewKeys.has("remote-hosts")) && (
|
||||
<KeepAliveRoute
|
||||
active={view === "remote-hosts"}
|
||||
onUnsavedChanges={workspaceUnsavedChangesReporters["remote-hosts"]}
|
||||
route="remote-hosts"
|
||||
>
|
||||
<RouteErrorBoundary
|
||||
key="remote-hosts"
|
||||
fallback={
|
||||
<RouteLoadError
|
||||
message={t("route.loadFailed")}
|
||||
reloadLabel={t("route.reload")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<RouteLoadingStatus label={t("route.loading")} />}
|
||||
>
|
||||
<RemoteHostsPage
|
||||
active={view === "remote-hosts"}
|
||||
settings={applicationSettings}
|
||||
settingsPending={applicationSettingsPending}
|
||||
settingsLocked={applicationSettingsUnconfirmed}
|
||||
onUpdateSettings={updateApplicationSettings}
|
||||
onNotify={notify}
|
||||
onProjectsDeleted={remoteHostsActions.onProjectsDeleted}
|
||||
/>
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{localInferenceOpen && (
|
||||
<LocalInferencePage
|
||||
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
|
||||
@@ -7350,7 +7410,7 @@ function App(): React.JSX.Element {
|
||||
error={applicationSettingsError}
|
||||
onClose={() => setApplicationCenterOpen(false)}
|
||||
onOpen={(id) => {
|
||||
if (!isApplicationEnabled(applicationSettings, id)) return;
|
||||
if (!isApplicationOpenable(applicationSettings, id)) return;
|
||||
setApplicationCenterOpen(false);
|
||||
setView(id);
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
@@ -7391,6 +7451,7 @@ function App(): React.JSX.Element {
|
||||
{...settingsPanelActions}
|
||||
appearanceTheme={appearanceTheme}
|
||||
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true}
|
||||
chatMinimapEnabled={applicationSettings?.chatMinimapEnabled ?? true}
|
||||
applicationSettingsPending={applicationSettingsPending}
|
||||
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
|
||||
applicationSettingsError={applicationSettingsError}
|
||||
@@ -7403,7 +7464,6 @@ function App(): React.JSX.Element {
|
||||
initialCategory={settingsInitialCategory}
|
||||
initialChannel={settingsInitialChannel}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
onConversationHtmlRenderingEnabledChange={
|
||||
setConversationHtmlRenderingEnabled
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ApplicationCenter,
|
||||
ApplicationSettingsView,
|
||||
isApplicationEnabled,
|
||||
isApplicationOpenable,
|
||||
} from './ApplicationCenter'
|
||||
|
||||
const settings = applicationSettingsSchema.parse({
|
||||
@@ -39,7 +40,7 @@ const pinnedSettings = {
|
||||
...settings,
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true },
|
||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true, 'remote-hosts': false },
|
||||
},
|
||||
}
|
||||
afterEach(async () => {
|
||||
@@ -73,6 +74,35 @@ describe('Application Center', () => {
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing')
|
||||
})
|
||||
|
||||
it('opens Hosts while remote capability is off and switches it through the shared remote projects setting', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} />)
|
||||
expect(isApplicationEnabled(settings, 'remote-hosts')).toBe(false)
|
||||
expect(isApplicationOpenable(settings, 'remote-hosts')).toBe(true)
|
||||
expect(isApplicationOpenable(undefined, 'remote-hosts')).toBe(false)
|
||||
const card = screen.getByText('主机管理', { exact: true }).closest('article')!
|
||||
expect(within(card).getByText('可选 · 已关闭')).toBeVisible()
|
||||
fireEvent.click(within(card).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith('remote-hosts')
|
||||
fireEvent.click(within(card).getByRole('button', { name: '主机管理 应用设置' }))
|
||||
const enable = screen.getByRole('switch', { name: '启用远程能力' })
|
||||
expect(enable).not.toBeChecked()
|
||||
expect(screen.getByText(/主机、凭据、远程项目和 Agent 离线包都会保留/)).toBeVisible()
|
||||
fireEvent.click(enable)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ remoteProjectsEnabled: true })
|
||||
rerender(<ApplicationCenter {...handlers} settings={{ ...settings, remoteProjectsEnabled: true }} />)
|
||||
expect(enable).toBeChecked()
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
expect(pin).not.toBeChecked()
|
||||
fireEvent.click(pin)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { ...defaultApplicationNavigation.pinned, 'remote-hosts': true },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
|
||||
await changeUiLocale(locale)
|
||||
const onUpdate = vi.fn(async () => true)
|
||||
@@ -127,7 +157,7 @@ describe('Application Center', () => {
|
||||
fireEvent.drop(card)
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'],
|
||||
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'],
|
||||
} })
|
||||
})
|
||||
|
||||
@@ -239,7 +269,7 @@ describe('Application Center', () => {
|
||||
fireEvent.dragStart(notes)
|
||||
fireEvent.drop(inference)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
} })
|
||||
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).not.toHaveBeenCalled()
|
||||
@@ -263,9 +293,9 @@ describe('Application Center', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
|
||||
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
|
||||
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
||||
expect(moveUp.closest('article')).not.toBeNull()
|
||||
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
||||
@@ -273,18 +303,18 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
|
||||
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
|
||||
rerender(<ApplicationCenter {...handlers} settings={{
|
||||
...pinnedSettings,
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] },
|
||||
}} />)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)'])
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)', '主机管理'])
|
||||
expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
@@ -306,7 +336,7 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -352,7 +382,7 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -367,7 +397,7 @@ describe('Application Center', () => {
|
||||
})
|
||||
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(screen.getAllByRole('article')).toHaveLength(5)
|
||||
expect(screen.getAllByRole('article')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('edits all notes preferences through the shared settings form', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Network,
|
||||
HeartPulse,
|
||||
Library,
|
||||
Server,
|
||||
Settings,
|
||||
Sparkles,
|
||||
X,
|
||||
@@ -52,6 +53,12 @@ export const applicationDefinitions = {
|
||||
title: 'applications.localInference',
|
||||
enabled: 'localInferenceEnabled',
|
||||
},
|
||||
// Shares the remote projects switch: the backend gates hosts on it.
|
||||
'remote-hosts': {
|
||||
icon: Server,
|
||||
title: 'applications.remoteHosts',
|
||||
enabled: 'remoteProjectsEnabled',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function isApplicationEnabled(
|
||||
@@ -60,6 +67,7 @@ export function isApplicationEnabled(
|
||||
): boolean {
|
||||
if (id === 'knowledge') return true
|
||||
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
|
||||
if (id === 'remote-hosts') return settings?.remoteProjectsEnabled === true
|
||||
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
|
||||
return (
|
||||
settings !== undefined &&
|
||||
@@ -67,6 +75,17 @@ export function isApplicationEnabled(
|
||||
)
|
||||
}
|
||||
|
||||
// Hosts stays openable while remote capability is off: Agent packages can be
|
||||
// prepared ahead of time, and the page offers the switch itself.
|
||||
export function isApplicationOpenable(
|
||||
settings: ApplicationSettings | undefined,
|
||||
id: BuiltInApplicationId,
|
||||
): boolean {
|
||||
return id === 'remote-hosts'
|
||||
? settings !== undefined
|
||||
: isApplicationEnabled(settings, id)
|
||||
}
|
||||
|
||||
export function ApplicationAvailability({
|
||||
enabled,
|
||||
children,
|
||||
@@ -120,7 +139,7 @@ export function ApplicationSettingsView({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t('applications.enable')}</span>
|
||||
<span>{t(id === 'remote-hosts' ? 'applications.remoteHostsEnable' : 'applications.enable')}</span>
|
||||
</label>
|
||||
{id !== 'device-sharing' && <div className="inline-help-label">
|
||||
<label className="toggle-row">
|
||||
@@ -143,7 +162,7 @@ export function ApplicationSettingsView({
|
||||
</label>
|
||||
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
|
||||
</div>}
|
||||
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p>
|
||||
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : id === 'remote-hosts' ? 'applications.remoteHostsHelp' : 'applications.disableHelp')}</p>
|
||||
{id === 'magic-notes' && (
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
@@ -464,7 +483,7 @@ export function ApplicationCenter({
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!isApplicationOpenable(settings, id)}
|
||||
onClick={() => onOpen(id)}
|
||||
>
|
||||
{t('applications.open')}
|
||||
|
||||
@@ -35,10 +35,20 @@ it('only shows device sharing when explicitly enabled and removes it on disable'
|
||||
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists Hosts once settings load, even while remote capability is off', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(<Harness value={null} pending onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
expect(screen.queryByRole('menuitem', { name: '主机管理' })).not.toBeInTheDocument()
|
||||
rerender(<Harness onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '主机管理' }))
|
||||
expect(onOpen).toHaveBeenCalledWith('remote-hosts')
|
||||
})
|
||||
|
||||
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
|
||||
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
|
||||
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } } }
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } } }
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, type RefObject } from 'react'
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
|
||||
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
|
||||
import { applicationDefinitions, isApplicationOpenable } from './ApplicationCenter'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
|
||||
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
|
||||
@@ -19,7 +19,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
|
||||
const applications = useMemo(() => {
|
||||
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
|
||||
return order
|
||||
.filter(id => isApplicationEnabled(settings, id))
|
||||
.filter(id => isApplicationOpenable(settings, id))
|
||||
}, [settings])
|
||||
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
|
||||
{pending && <p role="status">{t('route.loading')}</p>}
|
||||
|
||||
@@ -181,6 +181,19 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ChatHistoryPane scrollbar minimap setting', () => {
|
||||
it('replaces the native scrollbar only while the minimap is enabled', () => {
|
||||
const messages = makeMessages(5)
|
||||
const { container, rerender, props } = setup(messages)
|
||||
const pane = container.querySelector<HTMLElement>('.chat-history-pane')!
|
||||
expect(pane).toHaveAttribute('data-minimap', 'true')
|
||||
expect(container.querySelector('.chat-minimap')).not.toBeNull()
|
||||
rerender(<ChatHistoryPane {...props} chatMinimapEnabled={false} />)
|
||||
expect(pane).not.toHaveAttribute('data-minimap')
|
||||
expect(container.querySelector('.chat-minimap')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatHistoryPane windowing', () => {
|
||||
it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => {
|
||||
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
KnowledgeSearchReference,
|
||||
} from "../../shared/contracts";
|
||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||
import { ChatMinimap } from "./ChatMinimap";
|
||||
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
|
||||
import { isUnusedConversation, type Conversation } from "./chat-conversation";
|
||||
import { MessageExpansionProvider } from "./message-expansion";
|
||||
@@ -151,6 +152,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
active,
|
||||
artifactById,
|
||||
conversationHtmlRenderingEnabled,
|
||||
chatMinimapEnabled = true,
|
||||
conversation,
|
||||
locale,
|
||||
onCopyMessage,
|
||||
@@ -176,6 +178,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
active: boolean;
|
||||
artifactById: ReadonlyMap<string, AssistantArtifact>;
|
||||
conversationHtmlRenderingEnabled: boolean;
|
||||
/** Replaces the native scrollbar with the minimap; off restores the scrollbar. */
|
||||
chatMinimapEnabled?: boolean;
|
||||
conversation: Conversation;
|
||||
locale: TimeFormatLocale;
|
||||
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
|
||||
@@ -1070,6 +1074,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
className="chat-history-pane"
|
||||
data-active={active ? "true" : "false"}
|
||||
data-conversation-id={conversation.id}
|
||||
data-minimap={chatMinimapEnabled ? "true" : undefined}
|
||||
hidden={!active}
|
||||
inert={!active}
|
||||
>
|
||||
@@ -1141,6 +1146,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{active && chatMinimapEnabled && (
|
||||
<ChatMinimap
|
||||
messages={messages}
|
||||
rowHeights={heights}
|
||||
scrollContainerRef={scrollRef}
|
||||
/>
|
||||
)}
|
||||
{active && showScrollToBottom && (
|
||||
<button
|
||||
aria-controls="chat-message-list"
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatMinimap, minimapLines, minimapMessageColor, minimapScrollTop } from "./ChatMinimap";
|
||||
import { MessageRowHeights } from "./chat-message-window";
|
||||
import type { Message } from "./ChatTimeline";
|
||||
|
||||
function message(role: Message["role"], id: string, extra: Partial<Message> = {}): Message {
|
||||
return { id, role, content: id, createdAt: 1, state: "complete", ...extra } as Message;
|
||||
}
|
||||
|
||||
function scroller(scrollHeight = 1_000, clientHeight = 200, scrollTop = 0): HTMLElement {
|
||||
const element = document.createElement("section");
|
||||
let top = scrollTop;
|
||||
Object.defineProperties(element, {
|
||||
clientHeight: { configurable: true, value: clientHeight },
|
||||
scrollHeight: { configurable: true, value: scrollHeight },
|
||||
offsetTop: { configurable: true, value: 0 },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => top,
|
||||
set: (value: number) => { top = Math.max(0, Math.min(scrollHeight - clientHeight, value)); },
|
||||
},
|
||||
});
|
||||
element.scrollTo = vi.fn(({ top: next }: ScrollToOptions) => {
|
||||
element.scrollTop = next ?? 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}) as typeof element.scrollTo;
|
||||
return element;
|
||||
}
|
||||
|
||||
const messages = Array.from({ length: 20 }, (_, index) => message(index % 2 ? "assistant" : "user", `m${index}`));
|
||||
|
||||
describe("ChatMinimap", () => {
|
||||
let fillRect: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fillRect = vi.fn();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
fillRect,
|
||||
setTransform: vi.fn(),
|
||||
set fillStyle(_value: string) {},
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps message roles and states to stable overview colors", () => {
|
||||
expect(minimapMessageColor(message("user", "user"))).toBe("#6d9ee8");
|
||||
expect(minimapMessageColor(message("assistant", "assistant"))).toBe("#8f98a8");
|
||||
expect(minimapMessageColor(message("assistant", "tool", { tools: [{ callId: "1" }] as never }))).toBe("#d9a441");
|
||||
expect(minimapMessageColor(message("assistant", "error", { state: "error" }))).toBe("#e06b75");
|
||||
expect(minimapMessageColor(message("assistant", "failed", { state: "error", terminalStatus: "failed" }))).toBe("#e06b75");
|
||||
expect(minimapMessageColor(message("assistant", "cancelled", { state: "error", terminalStatus: "cancelled" }))).toBe("#8f98a8");
|
||||
});
|
||||
|
||||
it("clamps minimap positions to the scrollable range", () => {
|
||||
expect(minimapScrollTop(-1, 1_000, 200)).toBe(0);
|
||||
expect(minimapScrollTop(0.5, 1_000, 200)).toBe(400);
|
||||
expect(minimapScrollTop(2, 1_000, 200)).toBe(800);
|
||||
});
|
||||
|
||||
it("profiles messages as wrapped text lines with blank lines and code", () => {
|
||||
const lines = minimapLines(message("assistant", "a", {
|
||||
content: `${"x".repeat(100)}\n\n\`\`\`ts\n code\n\`\`\`\n中文`,
|
||||
}));
|
||||
expect(lines).toEqual([
|
||||
{ indent: 0, width: 72 / 72, code: false },
|
||||
{ indent: 0, width: 28 / 72, code: false },
|
||||
{ indent: 0, width: 0, code: false },
|
||||
{ indent: 4 / 72, width: 4 / 72, code: true },
|
||||
{ indent: 0, width: 4 / 72, code: false },
|
||||
]);
|
||||
expect(minimapLines(message("assistant", "empty", { content: "" }))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("draws one stroke per text line in the expanded overview", () => {
|
||||
const long = [message("assistant", "long", { content: Array.from({ length: 30 }, () => "line").join("\n") }), message("user", "u")];
|
||||
const heights = new MessageRowHeights();
|
||||
heights.set("long", 900);
|
||||
heights.set("u", 100);
|
||||
const view = render(<ChatMinimap messages={long} rowHeights={heights} scrollContainerRef={{ current: scroller() }} />);
|
||||
fillRect.mockClear();
|
||||
fireEvent.pointerEnter(view.getByRole("scrollbar"));
|
||||
// 180 px block at a 3 px pitch fits all 30 lines; the user turn draws one.
|
||||
expect(fillRect).toHaveBeenCalledTimes(31);
|
||||
});
|
||||
|
||||
it("spans the scroller height and moves the slider synchronously on scroll", () => {
|
||||
const scroll = scroller();
|
||||
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroll }} />);
|
||||
const rail = view.getByRole("scrollbar");
|
||||
expect(rail.style.display).toBe("");
|
||||
expect(fillRect).toHaveBeenCalledTimes(messages.length);
|
||||
const slider = rail.querySelector<HTMLElement>(".chat-minimap__viewport")!;
|
||||
expect(slider.style.height).toBe("40px");
|
||||
scroll.scrollTop = 800;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
// No frame or React commit in between: the slider is already at the end.
|
||||
expect(slider.style.transform).toBe("translateY(160px)");
|
||||
expect(rail).toHaveAttribute("aria-valuenow", "100");
|
||||
});
|
||||
|
||||
it("keeps the overview drawing stable while rows are measured during scrolling", () => {
|
||||
const scroll = scroller();
|
||||
const heights = new MessageRowHeights();
|
||||
const view = render(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
|
||||
fillRect.mockClear();
|
||||
// Scrolling mounts and measures rows: heights change, messages do not.
|
||||
heights.set("m0", 900);
|
||||
heights.set("m1", 30);
|
||||
view.rerender(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
expect(fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("places the slider on the overview span of the rows in view", () => {
|
||||
// Row 0 is tall in pixels but short in text: the overview gives it 1/20.
|
||||
const scroll = scroller(1_000, 200);
|
||||
const heights = new MessageRowHeights();
|
||||
heights.set("m0", 810);
|
||||
for (let index = 1; index < messages.length; index += 1) heights.set(`m${index}`, 10);
|
||||
const view = render(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
|
||||
const slider = view.getByRole("scrollbar").querySelector<HTMLElement>(".chat-minimap__viewport")!;
|
||||
// Viewport [0, 200] lies inside row 0, i.e. inside its 10 px overview block.
|
||||
expect(slider.style.transform).toBe("translateY(0px)");
|
||||
expect(slider.style.height).toBe("16px");
|
||||
});
|
||||
|
||||
it("hides itself when the conversation does not scroll", () => {
|
||||
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroller(200, 200) }} />);
|
||||
expect(view.getByRole("scrollbar", { hidden: true }).style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("expands on hover and redraws the wider overview", () => {
|
||||
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroller() }} />);
|
||||
const rail = view.getByRole("scrollbar");
|
||||
fillRect.mockClear();
|
||||
fireEvent.pointerEnter(rail);
|
||||
expect(rail).toHaveAttribute("data-expanded", "true");
|
||||
expect(fillRect).toHaveBeenCalledTimes(messages.length);
|
||||
fireEvent.pointerLeave(rail);
|
||||
expect(rail).toHaveAttribute("data-expanded", "false");
|
||||
});
|
||||
|
||||
it("jumps on track clicks, drags the slider, and pages by keyboard", () => {
|
||||
const scroll = scroller();
|
||||
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroll }} />);
|
||||
const rail = view.getByRole("scrollbar");
|
||||
const slider = rail.querySelector<HTMLElement>(".chat-minimap__viewport")!;
|
||||
vi.spyOn(rail, "getBoundingClientRect").mockReturnValue({ top: 0, height: 200, left: 0, right: 14 } as DOMRect);
|
||||
vi.spyOn(slider, "getBoundingClientRect").mockImplementation(() => {
|
||||
const top = Number(/translateY\((.+)px\)/.exec(slider.style.transform)?.[1] ?? 0);
|
||||
return { top, bottom: top + 40, height: 40 } as DOMRect;
|
||||
});
|
||||
// Track click centers the slider under the pointer: (100 - 20) / 160.
|
||||
fireEvent.pointerDown(rail, { button: 0, clientY: 100, pointerId: 1 });
|
||||
expect(scroll.scrollTop).toBe(400);
|
||||
fireEvent.pointerMove(rail, { clientY: 180, pointerId: 1 });
|
||||
expect(scroll.scrollTop).toBe(800);
|
||||
fireEvent.pointerUp(rail, { clientY: 180, pointerId: 1 });
|
||||
fireEvent.pointerMove(rail, { clientY: 0, pointerId: 1 });
|
||||
expect(scroll.scrollTop).toBe(800);
|
||||
act(() => fireEvent.keyDown(rail, { key: "Home" }));
|
||||
expect(scroll.scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "auto" });
|
||||
act(() => fireEvent.keyDown(rail, { key: "End" }));
|
||||
expect(scroll.scrollTo).toHaveBeenLastCalledWith({ top: 1_000, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type KeyboardEvent, type PointerEvent, type RefObject, type WheelEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Message } from "./ChatTimeline";
|
||||
import type { MessageRowHeights } from "./chat-message-window";
|
||||
import { isCancelledMessage } from "./message-terminal-status";
|
||||
|
||||
/** Rail width when idle and when hovered (expanded overview), in CSS pixels. */
|
||||
export const minimapCollapsedWidth = 14;
|
||||
export const minimapExpandedWidth = 80;
|
||||
|
||||
export function minimapMessageColor(message: Message): string {
|
||||
// Cancelled replies are muted like their status line; only failures are red.
|
||||
if (message.state === "error") return isCancelledMessage(message) ? "#8f98a8" : "#e06b75";
|
||||
if (message.tools?.length || message.pendingQuestions?.length) return "#d9a441";
|
||||
return message.role === "user" ? "#6d9ee8" : "#8f98a8";
|
||||
}
|
||||
|
||||
export function minimapScrollTop(
|
||||
pointerRatio: number,
|
||||
scrollHeight: number,
|
||||
viewportHeight: number,
|
||||
): number {
|
||||
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight);
|
||||
return Math.min(1, Math.max(0, pointerRatio)) * maxScrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable overview weight of a message, in text-line units: its wrapped lines
|
||||
* plus fixed room for the header/padding and each tool row. It depends only on
|
||||
* the message content, never on measured DOM heights, so the overview keeps
|
||||
* its density while the timeline measures rows during scrolling.
|
||||
*/
|
||||
export function minimapMessageWeight(message: Message): number {
|
||||
return minimapLines(message).length + 3 + 2 * (message.tools?.length ?? 0);
|
||||
}
|
||||
|
||||
/** Largest index i in [0, prefix.length - 2] with prefix[i] <= value. */
|
||||
function locate(prefix: Float64Array, value: number): number {
|
||||
let low = 0;
|
||||
let high = prefix.length - 2;
|
||||
while (low < high) {
|
||||
const middle = (low + high + 1) >> 1;
|
||||
if (prefix[middle]! <= value) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return Math.max(0, low);
|
||||
}
|
||||
|
||||
/** Maps a position on one prefix-sum axis to the other, row by row. */
|
||||
function mapAxis(from: Float64Array, to: Float64Array, value: number): number {
|
||||
const count = from.length - 1;
|
||||
if (count <= 0) return 0;
|
||||
const clamped = Math.min(from[count]!, Math.max(0, value));
|
||||
const index = locate(from, clamped);
|
||||
const span = from[index + 1]! - from[index]!;
|
||||
const fraction = span > 0 ? (clamped - from[index]!) / span : 0;
|
||||
return to[index]! + fraction * (to[index + 1]! - to[index]!);
|
||||
}
|
||||
|
||||
function prefixSums(messages: readonly Message[], size: (message: Message) => number): Float64Array {
|
||||
const prefix = new Float64Array(messages.length + 1);
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
prefix[index + 1] = prefix[index]! + Math.max(0, size(messages[index]!));
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
/** Characters per minimap text line, roughly one reading-column line. */
|
||||
const minimapCharsPerLine = 72;
|
||||
/** Upper bound of text lines kept per message; very long replies are sampled. */
|
||||
const minimapMaxLines = 600;
|
||||
|
||||
/**
|
||||
* Coarse text-line profile of a message: one entry per wrapped visual line,
|
||||
* holding its indent and length as fractions of a full line. Empty entries
|
||||
* (`width === 0`) are blank lines. Code fences become indented strokes.
|
||||
*/
|
||||
export type MinimapLine = { indent: number; width: number; code: boolean };
|
||||
|
||||
const lineCache = new WeakMap<Message, MinimapLine[]>();
|
||||
|
||||
export function minimapLines(message: Message): MinimapLine[] {
|
||||
const cached = lineCache.get(message);
|
||||
if (cached) return cached;
|
||||
const lines: MinimapLine[] = [];
|
||||
let code = false;
|
||||
const source = message.content ?? "";
|
||||
for (const raw of source.split("\n")) {
|
||||
if (lines.length >= minimapMaxLines) break;
|
||||
if (/^\s*(```|~~~)/.test(raw)) {
|
||||
code = !code;
|
||||
continue;
|
||||
}
|
||||
const text = raw.replace(/\t/g, " ");
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed) {
|
||||
lines.push({ indent: 0, width: 0, code });
|
||||
continue;
|
||||
}
|
||||
const indent = Math.min(0.5, (text.length - trimmed.length) / minimapCharsPerLine);
|
||||
// Wide (CJK) characters take about two columns.
|
||||
let columns = 0;
|
||||
for (const char of trimmed) columns += char.charCodeAt(0) > 0x2e7f ? 2 : 1;
|
||||
const span = Math.max(1, Math.round(minimapCharsPerLine * (1 - indent)));
|
||||
while (columns > 0 && lines.length < minimapMaxLines) {
|
||||
const take = Math.min(span, columns);
|
||||
lines.push({ indent, width: take / minimapCharsPerLine, code });
|
||||
columns -= take;
|
||||
}
|
||||
}
|
||||
// Tool calls or attachments without text still show as one stroke.
|
||||
if (lines.length === 0) lines.push({ indent: 0, width: 0.35, code: false });
|
||||
lineCache.set(message, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
type ChatMinimapProps = {
|
||||
messages: readonly Message[];
|
||||
rowHeights: MessageRowHeights;
|
||||
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
type Drag = { pointerId: number; grabOffset: number };
|
||||
|
||||
/**
|
||||
* Replaces the native scrollbar of the chat scroller with a canvas overview.
|
||||
* Geometry follows the scroller on every scroll/resize event by writing styles
|
||||
* directly, so the viewport indicator never waits for a React commit.
|
||||
*/
|
||||
export function ChatMinimap({ messages, rowHeights, scrollContainerRef }: ChatMinimapProps): React.JSX.Element {
|
||||
const { t } = useTranslation("app");
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const sliderRef = useRef<HTMLSpanElement>(null);
|
||||
const dragRef = useRef<Drag | undefined>(undefined);
|
||||
const expandedRef = useRef(false);
|
||||
const drawnRef = useRef<{ width: number; height: number; messages: readonly Message[] | undefined }>({ width: 0, height: 0, messages: undefined });
|
||||
const layoutRef = useRef({ scrollable: false, railHeight: -1, percent: -1, scrollHeight: -1 });
|
||||
const dataRef = useRef({ messages, rowHeights });
|
||||
// Overview axis: stable content weights. Scroll axis: current row heights
|
||||
// (measured or estimated). The slider maps between them row by row, so the
|
||||
// drawing never changes when rows are measured during scrolling.
|
||||
const axesRef = useRef<{ messages: readonly Message[] | undefined; weights: Float64Array; pixels: Float64Array }>({
|
||||
messages: undefined, weights: new Float64Array(1), pixels: new Float64Array(1),
|
||||
});
|
||||
|
||||
const refreshAxes = useCallback((): void => {
|
||||
const { messages: current, rowHeights: heights } = dataRef.current;
|
||||
const axes = axesRef.current;
|
||||
const weights = axes.messages === current ? axes.weights : prefixSums(current, minimapMessageWeight);
|
||||
axesRef.current = { messages: current, weights, pixels: prefixSums(current, (message) => heights.size(message.id)) };
|
||||
}, []);
|
||||
|
||||
const draw = useCallback((force: boolean): void => {
|
||||
const canvas = canvasRef.current;
|
||||
const rail = railRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!canvas || !rail || !container) return;
|
||||
const width = rail.clientWidth ||
|
||||
(expandedRef.current ? minimapExpandedWidth : minimapCollapsedWidth);
|
||||
const height = Math.max(1, rail.clientHeight || container.clientHeight);
|
||||
const { messages: current } = dataRef.current;
|
||||
const drawn = drawnRef.current;
|
||||
if (!force && drawn.width === width && drawn.height === height && drawn.messages === current) return;
|
||||
drawnRef.current = { width, height, messages: current };
|
||||
if (axesRef.current.messages !== current) refreshAxes();
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const { weights } = axesRef.current;
|
||||
const totalWeight = weights[current.length] ?? 0;
|
||||
if (totalWeight <= 0) return;
|
||||
const expanded = expandedRef.current;
|
||||
const inset = expanded ? 6 : 2;
|
||||
const lineSpan = width - inset * 2;
|
||||
// Expanded rows: thin strokes on a device-pixel-friendly pitch.
|
||||
const pitch = ratio >= 2 ? 2 : 3;
|
||||
const stroke = ratio >= 2 ? 1.5 : 2;
|
||||
for (let index = 0; index < current.length; index += 1) {
|
||||
const message = current[index]!;
|
||||
const top = (weights[index]! / totalWeight) * height;
|
||||
const blockHeight = Math.max(1, ((weights[index + 1]! - weights[index]!) / totalWeight) * height);
|
||||
context.fillStyle = minimapMessageColor(message);
|
||||
if (!expanded) {
|
||||
context.fillRect(inset, top, lineSpan, blockHeight);
|
||||
continue;
|
||||
}
|
||||
// Expanded: one stroke per wrapped text line, like an editor minimap.
|
||||
// User turns are right-aligned and narrower, echoing the chat layout.
|
||||
const user = message.role === "user";
|
||||
const span = user ? lineSpan * 0.7 : lineSpan;
|
||||
const base = user ? width - inset - span : inset;
|
||||
const lines = minimapLines(message);
|
||||
const rows = Math.max(1, Math.min(lines.length, Math.floor(blockHeight / pitch)));
|
||||
const rowStep = rows > 1 ? Math.min(pitch * 1.6, blockHeight / rows) : 0;
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
const line = lines[Math.floor((row * lines.length) / rows)];
|
||||
if (!line || line.width <= 0) continue;
|
||||
const lineWidth = Math.max(2, Math.min(1, line.width) * span);
|
||||
const left = user ? base + span - lineWidth : base + line.indent * span;
|
||||
context.globalAlpha = line.code ? 0.6 : 1;
|
||||
context.fillRect(left, top + row * rowStep, Math.min(lineWidth, base + span - left), Math.min(stroke, blockHeight));
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
}, [refreshAxes, scrollContainerRef]);
|
||||
|
||||
/** Overview position (0..railHeight) of a scroll-content offset. */
|
||||
const toRail = (offset: number, scrollHeight: number, railHeight: number): number => {
|
||||
const { weights, pixels } = axesRef.current;
|
||||
const count = pixels.length - 1;
|
||||
const totalPixels = pixels[count] ?? 0;
|
||||
const totalWeight = weights[count] ?? 0;
|
||||
if (totalPixels <= 0 || totalWeight <= 0 || scrollHeight <= 0) return (offset / Math.max(1, scrollHeight)) * railHeight;
|
||||
// Scroll content and the row model differ only by padding; scale linearly.
|
||||
const pixel = (offset / scrollHeight) * totalPixels;
|
||||
return (mapAxis(pixels, weights, pixel) / totalWeight) * railHeight;
|
||||
};
|
||||
|
||||
/** Scroll-content offset of an overview position (inverse of toRail). */
|
||||
const fromRail = (y: number, scrollHeight: number, railHeight: number): number => {
|
||||
const { weights, pixels } = axesRef.current;
|
||||
const count = pixels.length - 1;
|
||||
const totalPixels = pixels[count] ?? 0;
|
||||
const totalWeight = weights[count] ?? 0;
|
||||
if (totalPixels <= 0 || totalWeight <= 0 || railHeight <= 0) return (y / Math.max(1, railHeight)) * scrollHeight;
|
||||
const pixel = mapAxis(weights, pixels, (y / railHeight) * totalWeight);
|
||||
return (pixel / totalPixels) * scrollHeight;
|
||||
};
|
||||
|
||||
/** Syncs the slider with the scroller; runs synchronously in the event. */
|
||||
const sync = useCallback((): void => {
|
||||
const rail = railRef.current;
|
||||
const slider = sliderRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!rail || !slider || !container) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollable = scrollHeight > clientHeight + 1 && dataRef.current.messages.length > 0;
|
||||
const last = layoutRef.current;
|
||||
if (last.scrollable !== scrollable) rail.style.display = scrollable ? "" : "none";
|
||||
if (!scrollable) {
|
||||
layoutRef.current = { ...last, scrollable };
|
||||
return;
|
||||
}
|
||||
// The rail spans the reading area below the title bar and context strip
|
||||
// (CSS). Its height is read only when it may have changed, so a plain
|
||||
// scroll event does no layout reads.
|
||||
let { railHeight } = last;
|
||||
const shown = !last.scrollable;
|
||||
if (shown || railHeight < 0) railHeight = rail.clientHeight || clientHeight;
|
||||
// Row measurements change scrollHeight; only then rebuild the pixel axis.
|
||||
if (shown || last.scrollHeight !== scrollHeight || axesRef.current.messages !== dataRef.current.messages) refreshAxes();
|
||||
// The slider covers the overview span of the rows in view, so it sits
|
||||
// exactly on the strokes of the visible messages.
|
||||
let top = toRail(scrollTop, scrollHeight, railHeight);
|
||||
let bottom = toRail(scrollTop + clientHeight, scrollHeight, railHeight);
|
||||
if (bottom - top < 16) {
|
||||
const center = (top + bottom) / 2;
|
||||
top = Math.max(0, Math.min(railHeight - 16, center - 8));
|
||||
bottom = top + 16;
|
||||
}
|
||||
slider.style.height = `${bottom - top}px`;
|
||||
slider.style.transform = `translateY(${top}px)`;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
const percent = Math.round(maxScrollTop > 0 ? (scrollTop / maxScrollTop) * 100 : 0);
|
||||
if (percent !== last.percent) rail.setAttribute("aria-valuenow", String(percent));
|
||||
layoutRef.current = { scrollable, railHeight, percent, scrollHeight };
|
||||
if (shown) draw(false);
|
||||
}, [draw, refreshAxes, scrollContainerRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
container.addEventListener("scroll", sync, { passive: true });
|
||||
// Content growth (streaming, measurement, resize) changes scrollHeight
|
||||
// without a scroll event.
|
||||
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(sync);
|
||||
observer?.observe(container);
|
||||
if (container.firstElementChild) observer?.observe(container.firstElementChild);
|
||||
// The rail itself shrinks when the context strip above the reading area
|
||||
// grows, independently of the scroller size.
|
||||
const rail = railRef.current;
|
||||
const railObserver = typeof ResizeObserver === "undefined" || !rail
|
||||
? undefined
|
||||
: new ResizeObserver(() => {
|
||||
if (rail.clientHeight === layoutRef.current.railHeight) return;
|
||||
layoutRef.current = { ...layoutRef.current, railHeight: -1 };
|
||||
sync();
|
||||
draw(false);
|
||||
});
|
||||
if (rail) railObserver?.observe(rail);
|
||||
// The per-commit effect below performs the initial sync.
|
||||
return () => {
|
||||
container.removeEventListener("scroll", sync);
|
||||
observer?.disconnect();
|
||||
railObserver?.disconnect();
|
||||
};
|
||||
}, [draw, scrollContainerRef, sync]);
|
||||
|
||||
// Pane commits keep the slider in sync (row measurements move scrollHeight).
|
||||
// The canvas redraws only when the message list itself changes; draw()
|
||||
// compares identities, so measurement-only commits never repaint it.
|
||||
useLayoutEffect(() => {
|
||||
dataRef.current = { messages, rowHeights };
|
||||
sync();
|
||||
if (layoutRef.current.scrollable) draw(false);
|
||||
});
|
||||
|
||||
const scrollToSliderTop = (clientY: number, grabOffset: number): void => {
|
||||
const rail = railRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!rail || !container) return;
|
||||
const bounds = rail.getBoundingClientRect();
|
||||
const target = fromRail(clientY - bounds.top - grabOffset, container.scrollHeight, bounds.height);
|
||||
const maxScrollTop = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTop = minimapScrollTop(maxScrollTop > 0 ? target / maxScrollTop : 0, container.scrollHeight, container.clientHeight);
|
||||
sync();
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>): void => {
|
||||
if (event.button !== 0) return;
|
||||
const slider = sliderRef.current;
|
||||
if (!slider) return;
|
||||
const sliderBounds = slider.getBoundingClientRect();
|
||||
const onSlider = event.clientY >= sliderBounds.top && event.clientY <= sliderBounds.bottom;
|
||||
// Grabbing the slider keeps its offset; clicking the track centers it.
|
||||
const grabOffset = onSlider ? event.clientY - sliderBounds.top : sliderBounds.height / 2;
|
||||
dragRef.current = { pointerId: event.pointerId, grabOffset };
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
if (!onSlider) scrollToSliderTop(event.clientY, grabOffset);
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent<HTMLDivElement>): void => {
|
||||
const drag = dragRef.current;
|
||||
if (drag && drag.pointerId === event.pointerId) scrollToSliderTop(event.clientY, drag.grabOffset);
|
||||
};
|
||||
const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||||
dragRef.current = undefined;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
};
|
||||
const setExpanded = (expanded: boolean): void => {
|
||||
if (expandedRef.current === expanded) return;
|
||||
expandedRef.current = expanded;
|
||||
const rail = railRef.current;
|
||||
if (rail) rail.dataset.expanded = expanded ? "true" : "false";
|
||||
draw(true);
|
||||
};
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const step = Math.max(1, container.clientHeight * 0.8);
|
||||
if (event.key === "Home") container.scrollTo({ top: 0, behavior: "auto" });
|
||||
else if (event.key === "End") container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
else if (event.key === "PageUp") container.scrollTo({ top: container.scrollTop - step, behavior: "auto" });
|
||||
else if (event.key === "PageDown") container.scrollTo({ top: container.scrollTop + step, behavior: "auto" });
|
||||
else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-controls="chat-message-list"
|
||||
aria-label={t("chat.minimap")}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
className="chat-minimap"
|
||||
data-expanded="false"
|
||||
onKeyDown={onKeyDown}
|
||||
onLostPointerCapture={() => { dragRef.current = undefined; }}
|
||||
onPointerCancel={endDrag}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={() => setExpanded(true)}
|
||||
onPointerLeave={() => { if (!dragRef.current) setExpanded(false); }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={(event) => {
|
||||
endDrag(event);
|
||||
const bounds = railRef.current?.getBoundingClientRect();
|
||||
if (bounds && (event.clientX < bounds.left || event.clientX > bounds.right)) setExpanded(false);
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
ref={railRef}
|
||||
// Shown by sync() once the scroller overflows; React never rewrites it.
|
||||
style={{ display: "none" }}
|
||||
role="scrollbar"
|
||||
tabIndex={0}
|
||||
>
|
||||
<canvas aria-hidden="true" className="chat-minimap__canvas" ref={canvasRef} />
|
||||
<span aria-hidden="true" className="chat-minimap__viewport" ref={sliderRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,23 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the GoodBuddy name and shows the saved role as a caption below it', () => {
|
||||
const props = { ...callbacks, artifactById: new Map(), conversationId: 'role', hiddenMessageCount: 0,
|
||||
isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: 3 }
|
||||
const messages: Message[] = [
|
||||
{ id: 'expert', role: 'assistant', content: 'a', createdAt: 1, state: 'complete',
|
||||
expert: { kind: 'expert', id: '00000000-0000-4000-8000-000000000901', name: '代码审查专家' } },
|
||||
{ id: 'team', role: 'assistant', content: 'b', createdAt: 2, state: 'complete', expert: { kind: 'team' } },
|
||||
{ id: 'plain', role: 'assistant', content: 'c', createdAt: 3, state: 'complete' }
|
||||
]
|
||||
const view = render(<ChatTimeline {...props} messages={messages} />)
|
||||
const articles = view.container.querySelectorAll('article.message')
|
||||
expect([...articles].map(article => article.querySelector('.message__meta strong')?.textContent))
|
||||
.toEqual(['GoodBuddy', 'GoodBuddy', 'GoodBuddy'])
|
||||
expect([...articles].map(article => article.querySelector('.message__role')?.textContent ?? null))
|
||||
.toEqual(['代码审查专家', '专家团队(并行)', null])
|
||||
})
|
||||
|
||||
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
|
||||
const onAddToNote = vi.fn()
|
||||
const messages: Message[] = [
|
||||
@@ -543,17 +560,21 @@ describe('ChatTimeline', () => {
|
||||
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
|
||||
}
|
||||
const message: Message = {
|
||||
id: 'status-message', role: 'assistant', content: '已有内容',
|
||||
id: 'status-message', role: 'assistant', content: '',
|
||||
createdAt: Date.now(), state: 'streaming'
|
||||
}
|
||||
const { rerender } = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
|
||||
expect(screen.getByRole('status', { name: '等待后续进展' })).toHaveClass('message__status--active')
|
||||
expect(document.querySelector('.message__status-dot--active')).toBeNull()
|
||||
rerender(<ChatTimeline {...props} messages={[{ ...message, content: '已有内容' }]} />)
|
||||
expect(screen.getByRole('status', { name: '等待后续进展' })).not.toHaveClass('message__status--active')
|
||||
const tool = { callId: 'read-1', name: 'read', summary: 'Read file' }
|
||||
rerender(<ChatTimeline {...props} messages={[{
|
||||
...message, tools: [{ ...tool, state: 'pending' }]
|
||||
}]} />)
|
||||
expect(screen.getByRole('status', { name: '工具待执行:read' })).toBeVisible()
|
||||
expect(screen.getByRole('status', { name: '工具待执行:read' })).toHaveClass('message__status--active')
|
||||
rerender(<ChatTimeline {...props} messages={[{
|
||||
...message, tools: [{ ...tool, state: 'running' }]
|
||||
}]} />)
|
||||
|
||||
@@ -73,6 +73,7 @@ export type Message = {
|
||||
contextCompressions?: ConversationMessage['contextCompressions']
|
||||
tools?: ToolActivity[]
|
||||
subagents?: SubagentActivity[]
|
||||
expert?: ConversationMessage['expert']
|
||||
approval?: {
|
||||
id: string
|
||||
taskId?: string
|
||||
@@ -753,6 +754,18 @@ function ChatMessageRowView({
|
||||
? t('chat.status.waitingForSubagent')
|
||||
: t('chat.status.waitingForProgress'))
|
||||
: message.status
|
||||
const isWaitingForResponse = message.role === 'assistant' &&
|
||||
message.state === 'streaming' &&
|
||||
!message.content.trim() &&
|
||||
!message.blocks?.some(block => block.type === 'text' && block.content.trim())
|
||||
|
||||
const roleCaption = message.role !== 'assistant'
|
||||
? undefined
|
||||
: message.expert?.kind === 'expert'
|
||||
? message.expert.name
|
||||
: message.expert?.kind === 'team'
|
||||
? t('composer.experts.team')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -768,7 +781,7 @@ function ChatMessageRowView({
|
||||
<UserRound size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message__header">
|
||||
<div className={`message__header${roleCaption ? ' message__header--with-role' : ''}`}>
|
||||
<div className="message__meta">
|
||||
<strong>
|
||||
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
|
||||
@@ -786,6 +799,11 @@ function ChatMessageRowView({
|
||||
)}
|
||||
<span>{formatTime(message.createdAt, locale)}</span>
|
||||
</div>
|
||||
{roleCaption && (
|
||||
<small className="message__role" title={t('composer.expertLabel')}>
|
||||
{roleCaption}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="message__body">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
@@ -1218,9 +1236,11 @@ function ChatMessageRowView({
|
||||
: 'polite'
|
||||
}
|
||||
className={
|
||||
failed
|
||||
? 'message__status message__status--error'
|
||||
: 'message__status'
|
||||
[
|
||||
'message__status',
|
||||
failed && 'message__status--error',
|
||||
isWaitingForResponse && 'message__status--active'
|
||||
].filter(Boolean).join(' ')
|
||||
}
|
||||
role={
|
||||
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
|
||||
@@ -1234,7 +1254,7 @@ function ChatMessageRowView({
|
||||
aria-hidden="true"
|
||||
className="message__status-dot"
|
||||
/>
|
||||
{statusText}
|
||||
<span className="message__status-label">{statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
{canRetry && Boolean(retryContent?.trim()) && (
|
||||
|
||||
@@ -98,13 +98,14 @@ afterEach(() => {
|
||||
describe("Composer render boundary", () => {
|
||||
it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => {
|
||||
const { props, rerender, store } = setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Story graph and knowledge" })).toBeNull();
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||
let finish!: () => void;
|
||||
const save = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
||||
const notify = vi.fn();
|
||||
const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } };
|
||||
rerender(<Composer {...next} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Story graph and knowledge" }));
|
||||
const toggle = screen.getByRole("switch", { name: "Use story graph" });
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle.closest("label")).toHaveClass("toggle-row");
|
||||
@@ -121,7 +122,7 @@ describe("Composer render boundary", () => {
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); });
|
||||
rerender(<Composer {...next} conversationId="b" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Story graph and knowledge" }));
|
||||
expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked();
|
||||
rerender(<Composer {...next} supervisorEnabled={false} />);
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||
|
||||
+396
-300
@@ -208,11 +208,16 @@ export const Composer = memo(function Composer({
|
||||
`${conversationId}\u0000${isRunning}\u0000${workspaceView}`,
|
||||
);
|
||||
const composerOptionsOpen = menus.optionsOpen;
|
||||
const runtimeOptionsOpen = menus.runtimeOptionsOpen;
|
||||
const knowledgeScopeOpen = menus.knowledgeScopeOpen;
|
||||
const composerMenuOpen = menus.menu;
|
||||
const runtimeMenuOpen = menus.runtimeMenuOpen;
|
||||
const setComposerOptionsOpen = useCallback(
|
||||
(open: boolean): void => setMenus({ optionsOpen: open }),
|
||||
const closeComposerOptions = useCallback(
|
||||
(): void => setMenus({ optionsOpen: false, knowledgeScopeOpen: false }),
|
||||
[setMenus],
|
||||
);
|
||||
const closeRuntimeOptions = useCallback(
|
||||
(): void => setMenus({ runtimeOptionsOpen: false, menu: undefined }),
|
||||
[setMenus],
|
||||
);
|
||||
const setKnowledgeScopeOpen = useCallback(
|
||||
@@ -233,7 +238,13 @@ export const Composer = memo(function Composer({
|
||||
);
|
||||
const setExpertMenuOpen = useCallback((open: boolean): void => {
|
||||
setMenus(open
|
||||
? { menu: "expert", runtimeMenuOpen: false, knowledgeScopeOpen: false }
|
||||
? {
|
||||
menu: "expert",
|
||||
runtimeMenuOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
}
|
||||
: { menu: undefined });
|
||||
}, [setMenus]);
|
||||
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
|
||||
@@ -253,6 +264,8 @@ export const Composer = memo(function Composer({
|
||||
}, [setMenus]);
|
||||
const composerOptionsRef = useRef<HTMLDivElement>(null);
|
||||
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const runtimeOptionsRef = useRef<HTMLDivElement>(null);
|
||||
const runtimeOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
|
||||
const runtimeMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -308,32 +321,27 @@ export const Composer = memo(function Composer({
|
||||
};
|
||||
}, [knowledgeScopeOpen, setKnowledgeScopeOpen]);
|
||||
|
||||
useDismissibleComposerDialog(
|
||||
composerOptionsOpen,
|
||||
composerOptionsRef,
|
||||
composerOptionsTriggerRef,
|
||||
closeComposerOptions,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!composerOptionsOpen) {
|
||||
return;
|
||||
}
|
||||
const isOptionsTarget = (target: EventTarget | null): boolean =>
|
||||
target instanceof Node &&
|
||||
(composerOptionsRef.current?.contains(target) === true ||
|
||||
composerOptionsTriggerRef.current?.contains(target) === true);
|
||||
const dismissOutside = (event: Event): void => {
|
||||
if (!isOptionsTarget(event.target)) {
|
||||
setMenus({ optionsOpen: false, knowledgeScopeOpen: false });
|
||||
}
|
||||
};
|
||||
if (!composerOptionsOpen) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
// Keyboard users land on the same first control as the model picker
|
||||
// and expert menu; optional switches above stay Tab-reachable.
|
||||
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
|
||||
composerOptionsRef.current
|
||||
?.querySelector<HTMLElement>("button:not(:disabled), input:not(:disabled)")
|
||||
?.focus();
|
||||
});
|
||||
document.addEventListener("pointerdown", dismissOutside);
|
||||
document.addEventListener("focusin", dismissOutside);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener("pointerdown", dismissOutside);
|
||||
document.removeEventListener("focusin", dismissOutside);
|
||||
};
|
||||
}, [composerOptionsOpen, setMenus]);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [composerOptionsOpen, knowledgeLibraries.length]);
|
||||
useDismissibleComposerDialog(
|
||||
runtimeOptionsOpen,
|
||||
runtimeOptionsRef,
|
||||
runtimeOptionsTriggerRef,
|
||||
closeRuntimeOptions,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeMenuOpen) {
|
||||
@@ -406,10 +414,9 @@ export const Composer = memo(function Composer({
|
||||
runtime: runtimeControlsProvider,
|
||||
})
|
||||
: "";
|
||||
const composerContextAvailable =
|
||||
supervisorEnabled === true || knowledgeLibraries.length > 0;
|
||||
const composerOptionSummary = [
|
||||
selectedExpertId && runtime?.capability !== "image-generation"
|
||||
? assistantExpertOptions.find((option) => option.value === selectedExpertId)?.label
|
||||
: undefined,
|
||||
enabledKnowledgeLibraryIds.length > 0
|
||||
? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length })
|
||||
: undefined,
|
||||
@@ -646,6 +653,22 @@ export const Composer = memo(function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__input">
|
||||
<div className="composer__input-header">
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert composer-picker--chip"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === "image-generation"
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={13} />}
|
||||
menuOpen={composerMenuOpen === "expert"}
|
||||
onChange={actions.selectExpert}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
</div>
|
||||
<ComposerDraftText
|
||||
conversationId={conversationId}
|
||||
store={composerDrafts}
|
||||
@@ -809,270 +832,214 @@ export const Composer = memo(function Composer({
|
||||
<Mic aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="composer-options"
|
||||
aria-expanded={composerOptionsOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={t("composer.options")}
|
||||
className="composer__options-trigger"
|
||||
onClick={() => {
|
||||
setComposerOptionsOpen(!composerOptionsOpen);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(false);
|
||||
setKnowledgeScopeOpen(false);
|
||||
}}
|
||||
ref={composerOptionsTriggerRef}
|
||||
title={t("composer.settings")}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<div
|
||||
aria-label={t("composer.settings")}
|
||||
className="composer__options"
|
||||
id="composer-options"
|
||||
hidden={!composerOptionsOpen}
|
||||
ref={composerOptionsRef}
|
||||
role="dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (knowledgeScopeOpen) {
|
||||
setKnowledgeScopeOpen(false);
|
||||
knowledgeScopeTriggerRef.current?.focus();
|
||||
} else {
|
||||
setComposerOptionsOpen(false);
|
||||
setComposerMenuOpen(undefined);
|
||||
composerOptionsTriggerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<strong>{t("composer.settings")}</strong>
|
||||
{supervisorEnabled && (
|
||||
<label className="toggle-row">
|
||||
<span className="composer__story-graph-label">
|
||||
<Network aria-hidden="true" size={16} />
|
||||
{t("composer.storyGraph.label")}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={view?.storyGraphEnabled !== false}
|
||||
disabled={storyGraphSaving}
|
||||
onChange={(event) => {
|
||||
setStoryGraphSaving(true);
|
||||
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
|
||||
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
|
||||
.finally(() => setStoryGraphSaving(false));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{knowledgeLibraries.length > 0 && (
|
||||
<div
|
||||
className="knowledge-scope"
|
||||
onBlurCapture={(event) => {
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(
|
||||
event.relatedTarget,
|
||||
)
|
||||
) {
|
||||
setKnowledgeScopeOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-controls="knowledge-scope-popover"
|
||||
aria-haspopup="dialog"
|
||||
aria-label={t("composer.knowledge.select", {
|
||||
count: enabledKnowledgeLibraryIds.length,
|
||||
})}
|
||||
aria-expanded={knowledgeScopeOpen}
|
||||
onClick={() => {
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(false);
|
||||
setKnowledgeScopeOpen(
|
||||
(current) => !current,
|
||||
);
|
||||
}}
|
||||
ref={knowledgeScopeTriggerRef}
|
||||
title={t("composer.knowledge.title")}
|
||||
type="button"
|
||||
>
|
||||
<Library aria-hidden="true" size={16} />
|
||||
<span>
|
||||
{t("navigation.knowledge")}
|
||||
<strong>
|
||||
{enabledKnowledgeLibraryIds.length}
|
||||
</strong>
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="knowledge-scope__chevron"
|
||||
size={14}
|
||||
/>
|
||||
</button>
|
||||
{knowledgeScopeOpen && (
|
||||
<div
|
||||
aria-label={t("composer.knowledge.scope")}
|
||||
className="knowledge-scope__popover"
|
||||
id="knowledge-scope-popover"
|
||||
ref={knowledgeScopePopoverRef}
|
||||
role="dialog"
|
||||
>
|
||||
<strong>
|
||||
{t("composer.knowledge.scope")}
|
||||
</strong>
|
||||
{knowledgeLibraries.map(
|
||||
(library) => {
|
||||
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
|
||||
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
|
||||
return (
|
||||
<label key={library.id}>
|
||||
<input
|
||||
disabled={!!library.external && externalStatus !== 'ready'}
|
||||
checked={enabledKnowledgeLibraryIds.includes(
|
||||
library.id,
|
||||
)}
|
||||
onChange={(event) =>
|
||||
actions.setEnabledKnowledgeLibraryIds(
|
||||
(current) =>
|
||||
event.target.checked
|
||||
? [
|
||||
...new Set([
|
||||
...current,
|
||||
library.id,
|
||||
]),
|
||||
]
|
||||
: current.filter(
|
||||
(id) =>
|
||||
id !== library.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{library.name}</span>
|
||||
<small>
|
||||
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
|
||||
"composer.knowledge.documents",
|
||||
{
|
||||
count: library.documentCount,
|
||||
},
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
)},
|
||||
)}
|
||||
<div className="knowledge-scope__retrieval-mode">
|
||||
<strong>
|
||||
{t("composer.knowledge.modeLabel")}
|
||||
</strong>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
"composer.knowledge.modeLabel",
|
||||
)}
|
||||
onChange={actions.setKnowledgeRetrievalMode}
|
||||
options={[
|
||||
{
|
||||
value: "auto",
|
||||
label: t(
|
||||
"composer.knowledge.auto",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "always",
|
||||
label: t(
|
||||
"composer.knowledge.always",
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={
|
||||
view?.knowledgeRetrievalMode ??
|
||||
"auto"
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
{view?.knowledgeRetrievalMode ===
|
||||
"always"
|
||||
? t(
|
||||
"composer.knowledge.alwaysDescription",
|
||||
)
|
||||
: t(
|
||||
"composer.knowledge.autoDescription",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__configuration">
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === "image-generation"
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "expert"}
|
||||
onChange={actions.selectExpert}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
</div>
|
||||
{runtimeControlsProvider && (
|
||||
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
|
||||
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
|
||||
<div className="composer__runtime-controls">
|
||||
{runtimeAgentControlAvailable && (
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.runtimeControls.agentLabel")}
|
||||
className="composer-picker--runtime"
|
||||
disabled={isRunning}
|
||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "runtime-agent"}
|
||||
onChange={actions.selectRuntimeAgent}
|
||||
onOpenChange={setRuntimeAgentMenuOpen}
|
||||
options={runtimeAgentOptions}
|
||||
value={selectedRuntimeAgent}
|
||||
/>
|
||||
)}
|
||||
{runtimePresetControlAvailable && (
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.runtimeControls.presetLabel")}
|
||||
className="composer-picker--runtime"
|
||||
disabled={isRunning}
|
||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "runtime-preset"}
|
||||
onChange={actions.selectContinuePreset}
|
||||
onOpenChange={setRuntimePresetMenuOpen}
|
||||
options={runtimePresetOptions}
|
||||
value={selectedContinuePreset}
|
||||
/>
|
||||
)}
|
||||
{runtimeActionControlAvailable && (
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.runtimeControls.actionLabel")}
|
||||
className="composer-picker--runtime-action"
|
||||
disabled={isRunning}
|
||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "runtime-action"}
|
||||
onChange={actions.selectRuntimeAction}
|
||||
onOpenChange={setRuntimeActionMenuOpen}
|
||||
options={runtimeActionOptions}
|
||||
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
|
||||
{composerContextAvailable && (
|
||||
<>
|
||||
<button
|
||||
aria-controls="composer-options"
|
||||
aria-expanded={composerOptionsOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={t("composer.contextSources")}
|
||||
className="composer__options-trigger"
|
||||
onClick={() => {
|
||||
setMenus({
|
||||
optionsOpen: !composerOptionsOpen,
|
||||
runtimeOptionsOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
});
|
||||
}}
|
||||
ref={composerOptionsTriggerRef}
|
||||
title={t("composer.contextSources")}
|
||||
type="button"
|
||||
>
|
||||
<Library aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<div
|
||||
aria-label={t("composer.contextSources")}
|
||||
className="composer__options"
|
||||
id="composer-options"
|
||||
hidden={!composerOptionsOpen}
|
||||
ref={composerOptionsRef}
|
||||
role="dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (knowledgeScopeOpen) {
|
||||
setKnowledgeScopeOpen(false);
|
||||
knowledgeScopeTriggerRef.current?.focus();
|
||||
} else {
|
||||
closeComposerOptions();
|
||||
composerOptionsTriggerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<strong>{t("composer.contextSources")}</strong>
|
||||
{supervisorEnabled && (
|
||||
<label className="toggle-row composer__context-row">
|
||||
<span className="composer__story-graph-label">
|
||||
<Network aria-hidden="true" size={16} />
|
||||
{t("composer.storyGraph.label")}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={view?.storyGraphEnabled !== false}
|
||||
disabled={storyGraphSaving}
|
||||
onChange={(event) => {
|
||||
setStoryGraphSaving(true);
|
||||
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
|
||||
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
|
||||
.finally(() => setStoryGraphSaving(false));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{knowledgeLibraries.length > 0 && (
|
||||
<div
|
||||
className="knowledge-scope"
|
||||
onBlurCapture={(event) => {
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(
|
||||
event.relatedTarget,
|
||||
)
|
||||
) {
|
||||
setKnowledgeScopeOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-controls="knowledge-scope-popover"
|
||||
aria-haspopup="dialog"
|
||||
aria-label={t("composer.knowledge.select", {
|
||||
count: enabledKnowledgeLibraryIds.length,
|
||||
})}
|
||||
aria-expanded={knowledgeScopeOpen}
|
||||
onClick={() => {
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(false);
|
||||
setKnowledgeScopeOpen(
|
||||
(current) => !current,
|
||||
);
|
||||
}}
|
||||
ref={knowledgeScopeTriggerRef}
|
||||
title={t("composer.knowledge.title")}
|
||||
type="button"
|
||||
>
|
||||
<Library aria-hidden="true" size={16} />
|
||||
<span>
|
||||
{t("navigation.knowledge")}
|
||||
<strong>
|
||||
{enabledKnowledgeLibraryIds.length}
|
||||
</strong>
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="knowledge-scope__chevron"
|
||||
size={14}
|
||||
/>
|
||||
</button>
|
||||
{knowledgeScopeOpen && (
|
||||
<div
|
||||
aria-label={t("composer.knowledge.scope")}
|
||||
className="knowledge-scope__popover"
|
||||
id="knowledge-scope-popover"
|
||||
ref={knowledgeScopePopoverRef}
|
||||
role="dialog"
|
||||
>
|
||||
<strong>
|
||||
{t("composer.knowledge.scope")}
|
||||
</strong>
|
||||
{knowledgeLibraries.map(
|
||||
(library) => {
|
||||
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
|
||||
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
|
||||
return (
|
||||
<label key={library.id}>
|
||||
<input
|
||||
disabled={!!library.external && externalStatus !== 'ready'}
|
||||
checked={enabledKnowledgeLibraryIds.includes(
|
||||
library.id,
|
||||
)}
|
||||
onChange={(event) =>
|
||||
actions.setEnabledKnowledgeLibraryIds(
|
||||
(current) =>
|
||||
event.target.checked
|
||||
? [
|
||||
...new Set([
|
||||
...current,
|
||||
library.id,
|
||||
]),
|
||||
]
|
||||
: current.filter(
|
||||
(id) =>
|
||||
id !== library.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{library.name}</span>
|
||||
<small>
|
||||
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
|
||||
"composer.knowledge.documents",
|
||||
{
|
||||
count: library.documentCount,
|
||||
},
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
)},
|
||||
)}
|
||||
<div className="knowledge-scope__retrieval-mode">
|
||||
<strong>
|
||||
{t("composer.knowledge.modeLabel")}
|
||||
</strong>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
"composer.knowledge.modeLabel",
|
||||
)}
|
||||
onChange={actions.setKnowledgeRetrievalMode}
|
||||
options={[
|
||||
{
|
||||
value: "auto",
|
||||
label: t(
|
||||
"composer.knowledge.auto",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "always",
|
||||
label: t(
|
||||
"composer.knowledge.always",
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={
|
||||
view?.knowledgeRetrievalMode ??
|
||||
"auto"
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
{view?.knowledgeRetrievalMode ===
|
||||
"always"
|
||||
? t(
|
||||
"composer.knowledge.alwaysDescription",
|
||||
)
|
||||
: t(
|
||||
"composer.knowledge.autoDescription",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="runtime-picker">
|
||||
<button
|
||||
aria-expanded={runtimeMenuOpen}
|
||||
@@ -1080,10 +1047,13 @@ export const Composer = memo(function Composer({
|
||||
className="model-button"
|
||||
disabled={isRunning || runtimeSwitching}
|
||||
onClick={() => {
|
||||
setComposerOptionsOpen(false);
|
||||
setKnowledgeScopeOpen(false);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(!runtimeMenuOpen);
|
||||
setMenus({
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: !runtimeMenuOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -1093,10 +1063,13 @@ export const Composer = memo(function Composer({
|
||||
event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
setComposerOptionsOpen(false);
|
||||
setKnowledgeScopeOpen(false);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(true);
|
||||
setMenus({
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
ref={runtimeMenuButtonRef}
|
||||
@@ -1143,6 +1116,91 @@ export const Composer = memo(function Composer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{runtimeControlsProvider && (
|
||||
<>
|
||||
<button
|
||||
aria-controls="composer-runtime-options"
|
||||
aria-expanded={runtimeOptionsOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={runtimeControlsLabel}
|
||||
className="composer__options-trigger"
|
||||
onClick={() => {
|
||||
setMenus({
|
||||
runtimeOptionsOpen: !runtimeOptionsOpen,
|
||||
optionsOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
});
|
||||
}}
|
||||
ref={runtimeOptionsTriggerRef}
|
||||
title={runtimeControlsLabel}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<div
|
||||
aria-label={runtimeControlsLabel}
|
||||
className="composer__options composer__options--runtime"
|
||||
id="composer-runtime-options"
|
||||
hidden={!runtimeOptionsOpen}
|
||||
ref={runtimeOptionsRef}
|
||||
role="dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeRuntimeOptions();
|
||||
runtimeOptionsTriggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
|
||||
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
|
||||
<div className="composer__runtime-controls">
|
||||
{runtimeAgentControlAvailable && (
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.runtimeControls.agentLabel")}
|
||||
className="composer-picker--runtime"
|
||||
disabled={isRunning}
|
||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "runtime-agent"}
|
||||
onChange={actions.selectRuntimeAgent}
|
||||
onOpenChange={setRuntimeAgentMenuOpen}
|
||||
options={runtimeAgentOptions}
|
||||
value={selectedRuntimeAgent}
|
||||
/>
|
||||
)}
|
||||
{runtimePresetControlAvailable && (
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.runtimeControls.presetLabel")}
|
||||
className="composer-picker--runtime"
|
||||
disabled={isRunning}
|
||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "runtime-preset"}
|
||||
onChange={actions.selectContinuePreset}
|
||||
onOpenChange={setRuntimePresetMenuOpen}
|
||||
options={runtimePresetOptions}
|
||||
value={selectedContinuePreset}
|
||||
/>
|
||||
)}
|
||||
{runtimeActionControlAvailable && (
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.runtimeControls.actionLabel")}
|
||||
className="composer-picker--runtime-action"
|
||||
disabled={isRunning}
|
||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "runtime-action"}
|
||||
onChange={actions.selectRuntimeAction}
|
||||
onOpenChange={setRuntimeActionMenuOpen}
|
||||
options={runtimeActionOptions}
|
||||
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="composer__submit-actions">
|
||||
@@ -1378,3 +1436,41 @@ export const Composer = memo(function Composer({
|
||||
});
|
||||
|
||||
const emptyLibraryIds: string[] = [];
|
||||
|
||||
/**
|
||||
* Closes a composer toolbar dialog when pointer or focus leaves it and its
|
||||
* trigger, and moves keyboard focus to its first enabled control on open.
|
||||
*/
|
||||
function useDismissibleComposerDialog(
|
||||
open: boolean,
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
triggerRef: RefObject<HTMLElement | null>,
|
||||
close: () => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const isDialogTarget = (target: EventTarget | null): boolean =>
|
||||
target instanceof Node &&
|
||||
(dialogRef.current?.contains(target) === true ||
|
||||
triggerRef.current?.contains(target) === true);
|
||||
const dismissOutside = (event: Event): void => {
|
||||
if (!isDialogTarget(event.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const frame = requestAnimationFrame(() => {
|
||||
dialogRef.current
|
||||
?.querySelector<HTMLElement>("button:not(:disabled), input:not(:disabled)")
|
||||
?.focus();
|
||||
});
|
||||
document.addEventListener("pointerdown", dismissOutside);
|
||||
document.addEventListener("focusin", dismissOutside);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener("pointerdown", dismissOutside);
|
||||
document.removeEventListener("focusin", dismissOutside);
|
||||
};
|
||||
}, [close, dialogRef, open, triggerRef]);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ function ConversationHistoryLoader({ conversationId, active, load }: {
|
||||
</section>;
|
||||
}
|
||||
|
||||
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById">;
|
||||
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById" | "onVisibleMessageCountChange" | "visibleMessageCount">;
|
||||
|
||||
const ignoreVisibleMessageCountChange = (): void => undefined;
|
||||
|
||||
export type ConversationHistorySlotProps = PaneProps & {
|
||||
conversationId: string;
|
||||
@@ -110,5 +112,12 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ChatHistoryPane {...paneProps} artifactById={artifactById} conversation={conversation} taskStrip={taskStrip} />;
|
||||
return <ChatHistoryPane
|
||||
{...paneProps}
|
||||
artifactById={artifactById}
|
||||
conversation={conversation}
|
||||
onVisibleMessageCountChange={ignoreVisibleMessageCountChange}
|
||||
taskStrip={taskStrip}
|
||||
visibleMessageCount={conversation.messages.length}
|
||||
/>;
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-0000000
|
||||
const settings: ApplicationSettings = {
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
chatMinimapEnabled: true,
|
||||
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
|
||||
@@ -1460,7 +1461,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
expect(container.querySelector('.magic-note-detail-header > :first-child')).toBe(screen.getByLabelText('笔记标题'))
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('icon-button', 'magic-note-entry__delete-button')
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('magic-note-entry__action-button', 'magic-note-entry__delete-button')
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
expect(container.querySelector('.magic-note-entry__editor')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toBeDisabled()
|
||||
|
||||
@@ -3091,7 +3091,7 @@ function MagicNotesWorkspaceView({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon-button"
|
||||
className="magic-note-entry__action-button"
|
||||
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||
disabled={Boolean(busy) || editingEntry?.id === entry.id}
|
||||
@@ -3104,12 +3104,13 @@ function MagicNotesWorkspaceView({
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
<span>{t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('actions.deleteEntry')}
|
||||
title={t('actions.deleteEntry')}
|
||||
className="icon-button magic-note-entry__delete-button"
|
||||
className="magic-note-entry__action-button magic-note-entry__delete-button"
|
||||
disabled={Boolean(busy) || Boolean(editingEntry)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -3119,7 +3120,8 @@ function MagicNotesWorkspaceView({
|
||||
setDeletingEntryId(entry.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
<span>{t('actions.deleteEntry')}</span>
|
||||
</button>
|
||||
</div>}
|
||||
</header>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user