Compare commits

..
14 Commits
Author SHA1 Message Date
mesalogo 8b10ed583e chore: prepare Desktop 0.16.2 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
2026-10-11 11:46:35 +08:00
mesalogo f2f4c1273e fix(supervisor): review only finished sources and add an all-time range
Reviews now snapshot only messages in a terminal state (complete or error) and finished tasks, so a reply still streaming when the review starts waits for the next review instead of changing under it. A frozen source that still changes is skipped for that run and left for the next review, replacing the re-review at the latest version and its 200-source cap. The review period picker gains an All time option.
2026-10-10 15:44:14 +08:00
mesalogo ba30852058 fix(magic-notes): label entry actions and show note more button on hover 2026-10-10 13:43:14 +08:00
mesalogo 12a0291279 fix(terminal): contain IME composition layout 2026-10-10 12:55:18 +08:00
mesalogo 3ebb4f0b79 chore: prepare Desktop 0.16.1 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
2026-10-10 01:25:01 +08:00
mesalogo c7f09e1e53 fix(composer): let messages fade right up to the composer edge
Overlap the composer wrap with the message region by its top padding so messages scroll under the fade until the input outline, and paint the solid backdrop only below the composer's top edge. Drop the dark theme rule that still painted the whole wrap, keep the fade at 16px with a stronger curve, and lift the scroll-to-bottom button to preserve its gap.

Release note: 修复深色主题下聊天输入框上方残留的实色横条,消息可一直淡出到输入框边缘。
2026-10-10 01:13:44 +08:00
mesalogo d9dcb65d10 fix(composer): blend the message fade into the composer outline
Extend the composer fade down to the input card's top edge and layer it beneath the card, so scrolling messages fade straight into the composer outline. Remove the full-width composer-wrap background that produced a hard horizontal band, and soften the focus ring. Keep the dark theme fade on the canvas color.

Release note: 优化聊天输入框上方的渐变过渡,消息自然淡出到输入框边缘,不再出现横向色带。
2026-10-10 01:00:31 +08:00
mesalogo 1044a32b3b chore: prepare Desktop 0.16.0 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Prepare the Desktop 0.16.0 candidate with per-conversation expert selection and historical role snapshots. Keep Agent 0.15.6 unchanged because this range contains no Agent production changes.
2026-10-09 23:44:13 +08:00
mesalogo c6a8455f88 feat(composer): move role picker into the input and keep it per conversation
Show the selected role as a compact chip in the input's top-left corner, persist the choice per conversation, and snapshot it on replies so the timeline shows the role as a caption under GoodBuddy.
2026-10-09 23:26:23 +08:00
mesalogo e07a17f79f chore: prepare Desktop 0.15.14 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Prepare the Desktop 0.15.14 candidate with Supervisor recovery fixes and clearer settings categories. Keep Agent 0.15.6 unchanged because this range contains no Agent production changes.
2026-10-09 21:09:28 +08:00
mesalogo 4205ad6317 feat(settings): add category icons 2026-10-09 20:54:23 +08:00
mesalogo 8f1b600798 fix(supervisor): resume legacy changed-source reviews 2026-10-09 20:25:48 +08:00
mesalogo dc582b7acf fix(supervisor): re-review changed sources instead of failing the run 2026-10-09 17:05:26 +08:00
mesalogo 46a40e7fbf update pr 2026-10-09 16:22:28 +08:00
45 changed files with 968 additions and 522 deletions
+1
View File
@@ -7,6 +7,7 @@ coverage/
/temp/
.vite/
sites/docs-dist/
sites/docs/.vitepress/
.runtime-resources/
.remote-runtime-resources/
.agent-resources/
+15 -1
View File
@@ -983,9 +983,23 @@ 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.2`; Agent `0.15.6` remains paired with Desktop `0.16.2`, 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.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
+5 -1
View File
@@ -159,7 +159,11 @@
### 开源、构建与发布
- 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.2`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.2`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- 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)。
+1 -1
View File
@@ -163,7 +163,7 @@
- 页面顶部和底部内边距默认为 `32px`。
- `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
- 输入区顶部向消息区延伸 `--space-4`(16px)的透明到 `--surface-raised` 渐变,柔化消息裁切边缘;渐变不占布局空间、不拦截鼠标事件,并随主题切换。
- 输入区外层不铺整块背景、不画横向分隔线。输入区以上内边距的高度向上叠入消息区,输入框上方保留 `--space-4`(16px)的透明到页面底色渐变,渐变终点对齐输入框上缘,让滚动的消息一直延伸到输入框轮廓前再淡出;输入框上缘以下才使用实色底;渐变位于输入框之下、不拦截鼠标事件,并随主题切换。输入框上缘只保留自身 1px 描边,不加向上的阴影,避免与渐变终点叠成粗边界。
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
## 5. 信息层级
@@ -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 标签。
+38 -362
View File
@@ -1,401 +1,77 @@
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
# GoodBuddy 更新:故事图谱、Telegram 与 Obsidian 接入
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.13,以及其后截至 `384683e8` 的已提交变化,更新于 2026-10-08。所述功能与修复均已开发完成并提交,验证及发布归属另行标注;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 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 私聊和工作区文件导入。项目与会话集中到同一个菜单,提问和执行任务也无需再切换工作模式。远程统一执行、文件导入和后续 Runtime 修复建议配套 Desktop 0.15.13 和 Agent 0.15.6,请先升级桌面端,再更新 Host 环境。
当前配套版本为 Desktop `0.15.13` 和 Agent `0.15.6`。远程项目使用这些能力前,请先升级桌面端,再更新 Host 环境。
> **【待替换 R01|版本与体验入口】** 当前版本资料为 Desktop `0.15.13`、Agent `0.15.6`;本轮检查 `e50ca6d1..384683e8`,共 22 个提交。`0.15.12`、`0.15.13` 的发布说明已覆盖其中的主要功能,后续测试和发布维护提交不单独作为新功能。对外发布前仍需核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
> **【发布信息待补】** 正式发布前补入发布日期、公开安装包、下载地址和完整更新日志链接。本文所述功能已经完成,发布信息待补不代表功能处于开发中。
**升级前,请先关闭应用并备份完整用户数据。** 数据库、设置和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前核查提交的助理数据库格式为 62,0.15.13 之前的客户端不能直接打开新版数据库,回退需要升级前的备份。大库首次转换可能需要等待。高频本地保存调整了磁盘同步策略,断电时可能丢失最近尚未同步的写入。
## 故事图谱:从工作记录中看清一件事如何发展
## 新增功能
### 工作回顾留下进展和依据
### 监督者:从工作记录中整理进展,查看每条结论的依据
监督者可以按全局或项目范围回顾最近一天、数天或指定日期内的工作记录,整理出工作总结、变化和未解决事项,并按故事列出推进、新出现和结束的内容。回顾结果会保留范围与生成时间,方便之后回看。
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,再选择最近 1 天、7 天、30 天、自定义最近天数或起止日期。指定日期按本地时区包含结束日全天,结束日为今天时截至发起回顾的时刻。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
每条结论都可以继续追溯到事件、实体、关系和来源正文。打开来源后,可以检查模型依据的是哪段讨论,再决定是否确认、改名或调整关系。
监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。
### 故事、经验和时间螺旋
> **【待补图 01|必补|工作回顾结果】** 在隔离演示项目中通过正常聊天和任务操作形成公开、虚构的工作记录,运行一次真实回顾。截图同时展示范围、时间和实际生成的总结、未解决事项;不能用预置答案冒充模型结果。计划文件:`assets/01-work-review.png`。
>
> 建议图注:按项目回顾近期讨论与任务,查看进展和仍待处理的问题。
事件可以归入持续发展的故事,按项目、功能和子线索浏览。用户可以调整故事名称、合并重复故事、移动事件或撤销调整。故事中的经验会保留适用条件、形成依据和后续应用记录,也可以人工编辑、合并或删除。
回顾中的事件、实体与关系可以在“故事线图谱”中查看。选中一个对象,就能检查它的说明、关联和保存的来源正文,再判断模型的归纳是否准确。实体支持确认、改名或撤销,关系可以确认或移除。
这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱提供平铺和时间螺旋两种浏览方式,时间螺旋按事件时间排列;平铺视图中的节点间距不表示实际时长。历史回顾中的故事分组使用当前保存的故事结构,尚不提供任意时刻的完整状态还原。
> **【待补图 02|必补|图谱与来源】** 使用图 01 同一份真实回顾结果,选中事件或实体,展示对应的来源正文。画面应能辨认对象与依据的关联;不使用设计原型代替生产结果。计划文件:`assets/02-story-sources.png`。
>
> 建议图注:从回顾图谱查看关联对象,并打开保存的讨论依据。
### 故事与时间螺旋:追踪一件事如何持续发展
一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径按所选回顾范围内的消息密度变化,不代表实际工时、效率或成果价值;消息按小时汇总,无法还原小时内的精细分布。
实际事件区间和事件点使用主故事的颜色,空白区间与未归属事件保持中性色。历史回顾中尚未归入故事的事件也会保留,可以在“未归属”分组查看;故事分组采用当前关系,不把它当作当时分类的完整还原。
视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。
故事图谱提供平铺和时间螺旋两种视图。时间螺旋按事件时间和消息密度展示工作如何交错推进,适合观察停顿、延续和阶段变化;平铺视图适合查看对象之间的关系。设备不适合 3D 浏览时,可以切换回平铺视图。
![故事线早期概念示意:时间螺旋、跨阶段故事,以及经验的形成依据与后续应用](assets/09-next-storyline-wisdom.png)
*早期概念示意,使用示例数据;不是当前生产界面的截图,实际布局与操作以应用为准。*
*早期概念示意,使用示例数据;发布前替换为当前版本的生产界面截图。*
> **【待补图 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.6 或包含该功能的更新版本。
导入遇到同名文件时保留原文件,单个文件失败后继续处理其余文件。列表可以按名称、修改时间或可用的创建时间排序;排序针对当前目录已读取的条目,每目录最多 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 解析结果也可复用,包含公式的长回复减少重复解析。
知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。
文档解析、分块与摘要哈希计算也转到后台线程;笔记网格、工作区文件树和知识库文档表主要绘制可见区域,终端连续输出合并刷新。启动时按需加载部分组件,并在后台准备工具环境,需要这些工具时再等待准备完成。
### 存储与请求路径合并,减少主进程等待
桌面端的 Assistant、Knowledge、附件、运行绑定和用量记录现在由同一个存储工具进程负责,主进程通过类型化的异步调用访问。查询、文件操作和回顾来源读取共用这条路径,启动升级、取消、关闭排空和存储进程异常都有明确状态;原有事务顺序、提交确认和数据路径保持不变。
这次同时移除了没有实测依据的产品侧请求队列和部分并发上限,工作负载交给实际的存储和 Runtime 主机处理。文档解析不再额外设置不受支持的 worker 队列限制。存储进程故障时,已确认提交的数据可以继续读取,未确认写入会明确报告,应用不会把不确定结果静默当成成功。
### 设置页减少来回查找
长设置表单的分类标题与相关页签固定在顶部,请求 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.6,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
最新修复使远程 Runtime 在等待启动时仍可取消,并避免阻塞其他会话;本地 OpenCode 提交失败后也能及时结束等待并显示原因。远程部分随 Agent 0.15.6 提供,升级 Host 后新启动的 Runtime 才会使用更新后的代码。
### 通道交付与统计结果
- 修复生成图片成功后,通道因结果格式校验而误报失败的问题。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,38 @@
# 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 位于版本准备提交之后,最后一组提交主要是存储故障验收夹具和测试稳定化,不另列为产品功能。正文仍按新增功能、功能改进、问题修复组织,并将连续的架构与性能改动合并为一节。
+1 -1
View File
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
| --- | --- | --- | --- | --- | --- |
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
| 2026-09-26(10-08 更新) | Desktop 0.15.13/Agent 0.15.6 及之后截至 384683e8;汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,新增存储架构与请求路径合并、标签创建、回顾来源兼容和直连模型失败后继续对话;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) | 未发布 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.15.13",
"version": "0.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.15.13",
"version": "0.16.2",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.15.13",
"version": "0.16.2",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+122
View File
@@ -1,6 +1,128 @@
{
"formatVersion": 1,
"releases": [
{
"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",
@@ -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 }
+22 -4
View File
@@ -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,
@@ -11911,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 }
}
@@ -12242,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 {
@@ -12306,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')
})
+89 -64
View File
@@ -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
+85 -16
View File
@@ -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 } })
})
+10 -5
View File
@@ -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 => {
+46 -5
View File
@@ -8751,13 +8751,16 @@ describe("App", () => {
const conversationSettings = within(composer!).getByRole("group", {
name: "对话设置",
});
// Order: context sources, expert, Runtime, then Runtime-specific settings.
// 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 expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(
within(conversationSettings).queryByRole("button", {
name: /工作模式/u,
@@ -12583,7 +12586,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: "制定发布计划" },
@@ -12671,6 +12675,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 提问"), {
+41 -10
View File
@@ -842,6 +842,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 +1154,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);
@@ -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(
@@ -5113,7 +5141,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 +5238,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 +5254,7 @@ function App(): React.JSX.Element {
createdAt: Date.now(),
state: "streaming",
status: t("chat.status.preparingRequest"),
...(expertSnapshot ? { expert: expertSnapshot } : {}),
};
activeRuns.current.set(requestId, {
@@ -6512,16 +6549,10 @@ 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,
+17
View File
@@ -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[] = [
+15 -1
View File
@@ -73,6 +73,7 @@ export type Message = {
contextCompressions?: ConversationMessage['contextCompressions']
tools?: ToolActivity[]
subagents?: SubagentActivity[]
expert?: ConversationMessage['expert']
approval?: {
id: string
taskId?: string
@@ -754,6 +755,14 @@ function ChatMessageRowView({
: t('chat.status.waitingForProgress'))
: message.status
const roleCaption = message.role !== 'assistant'
? undefined
: message.expert?.kind === 'expert'
? message.expert.name
: message.expert?.kind === 'team'
? t('composer.experts.team')
: undefined
return (
<>
<article
@@ -768,7 +777,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 +795,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 && (
+16 -14
View File
@@ -653,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}
@@ -1024,20 +1040,6 @@ export const Composer = memo(function Composer({
</div>
</>
)}
<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 className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
@@ -1460,7 +1460,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()
+6 -4
View File
@@ -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>
+1
View File
@@ -2181,6 +2181,7 @@ function SettingsPanelView({
tabIndex={activeTab === category.id ? 0 : -1}
type="button"
>
<category.icon aria-hidden="true" size={16} strokeWidth={1.8} />
<strong>
{t(`categories.${category.translationKey}.label`)}
</strong>
@@ -24,6 +24,7 @@ export function SupervisionReviewStages({ row, stopping }: { row: SupervisionAct
</ol>
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{!!progress.omittedSources && <p className="supervisor-activity__note">{t('activity.omittedSources', { count: progress.omittedSources })}</p>}
{!!progress.skippedSources && <p className="supervisor-activity__note">{t('activity.skippedSources', { count: progress.skippedSources })}</p>}
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div>
@@ -50,6 +50,19 @@ it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and re
expect(screen.getByRole('button', { name: copy.reviewSettings.resume })).toBeEnabled()
})
it('allows continuing a legacy source-change failure', async () => {
const copy = i18nResources['zh-CN'].heartbeat
const resume = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
...row, status: 'failed', supervisionStatus: 'failed',
error: 'Review source changed or was removed',
reviewProgress: { runId: 'saved-run', batches: 1, characters: 1000, sources: 2, remainingSources: 1, restartRequired: true, complete: false }
}]), resume } })
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
fireEvent.click(await screen.findByRole('button', { name: copy.reviewSettings.resume }))
await waitFor(() => expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' }))
})
it('shows durable progress, pauses and continues the same run, and expands retained facts', async () => {
const progress = { runId: 'saved-run', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([{ ...row, reviewProgress: progress }])
+2 -1
View File
@@ -186,7 +186,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
<button type="button" className="secondary-button" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'pause')}>{t('reviewSettings.pause')}</button>
<button type="button" className="danger-ghost" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'cancel')}>{t('reviewSettings.cancel')}</button>
</>}
{!stoppingKind && !row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={!!pending || execution.active} onClick={() => void control(row, 'resume')}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
{!stoppingKind && !row.reviewProgress.complete && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={!!pending || execution.active} onClick={() => void control(row, 'resume')}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
<button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button>
</div>
</>}
@@ -229,6 +229,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</dl>
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
{!!row.reviewProgress?.omittedSources && <p>{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}</p>}
{!!row.reviewProgress?.skippedSources && <p>{t('activity.skippedSources', { count: row.reviewProgress.skippedSources })}</p>}
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
+24 -1
View File
@@ -225,7 +225,7 @@ describe('SupervisorWorkspace', () => {
vi.setSystemTime(now)
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
const picker = screen.getByLabelText(copy.period)
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'custom', 'range'])
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'all', 'custom', 'range'])
expect(picker).toHaveValue('7')
fireEvent.change(picker, { target: { value: 'custom' } })
fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } })
@@ -245,6 +245,29 @@ describe('SupervisorWorkspace', () => {
timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } })
})
it.each(['zh-CN', 'en-US'] as const)('reviews all time from the epoch through now and names it in the reanalysis dialog in %s', async locale => {
await changeUiLocale(locale)
const copy = i18nResources[locale].heartbeat.supervisor
const run = vi.fn().mockResolvedValue(undefined)
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace />)
await screen.findByText(copy.empty)
vi.useFakeTimers({ toFake: ['Date'] })
const now = new Date(2026, 9, 5, 15, 30)
vi.setSystemTime(now)
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
fireEvent.change(screen.getByLabelText(copy.period), { target: { value: 'all' } })
const timeRange = { from: new Date(0).toISOString(), to: now.toISOString() }
await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run })))
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange })
fireEvent.click(screen.getByRole('button', { name: copy.more }))
fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze }))
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent(copy.allTimeLabel)
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: copy.reanalyzeConfirm })))
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange })
})
it.each([
['2025-11-02', '2025-11-02'],
['2026-03-08', '2026-03-08'],
+3
View File
@@ -240,6 +240,8 @@ export function SupervisorWorkspace({
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
// All time: from the epoch, which predates any stored source, through now.
if (period === 'all') return { range: { from: new Date(0).toISOString(), to: now.toISOString() }, label: t('supervisor.allTimeLabel') }
if (period === 'range') {
const from = new Date(`${startDate}T00:00:00`)
const end = new Date(`${endDate}T00:00:00`)
@@ -651,6 +653,7 @@ export function SupervisorWorkspace({
{t('supervisor.days', { count: value })}
</option>
))}
<option value="all">{t('supervisor.allTime')}</option>
<option value="custom">{t('supervisor.customDays')}</option>
<option value="range">{t('supervisor.dateRange')}</option>
</select>
+12
View File
@@ -221,6 +221,18 @@ describe('TerminalPanel', () => {
)
})
it('contains IME composition layout inside the terminal', () => {
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator\s*\{[^}]*contain:\s*size layout paint;[^}]*overflow-anchor:\s*none;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator \.xterm\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*overflow:\s*hidden;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator \.xterm-helpers\s*\{[^}]*max-width:\s*0;[^}]*overflow:\s*visible;/u
)
})
it('creates only after the first valid fitted size and forwards input', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
@@ -364,7 +364,7 @@ describe('WorkspacePrimitives', () => {
it('wraps native client and compaction actions without overlapping composer metadata', () => {
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*\}/u
)
expect(stylesheet).toMatch(
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
@@ -41,6 +41,7 @@ export function toConversationMessage(message: Message): ConversationMessage {
contextCompressions: message.contextCompressions,
tools: message.tools,
subagents: message.subagents,
expert: message.expert,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
@@ -64,6 +65,7 @@ export function toLocalConversationHeader(
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}),
+3 -1
View File
@@ -151,7 +151,7 @@ export function useConversationActivitySummary(
export type ActiveConversationView = Pick<
Conversation,
| "id" | "title" | "projectId" | "runtimeSelection" | "knowledgeLibraryIds"
| "knowledgeRetrievalMode" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
| "knowledgeRetrievalMode" | "selectedExpertId" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
> & {
/** Untouched greeting-only conversation: shown with the default title. */
unused: boolean;
@@ -188,6 +188,7 @@ export function selectActiveConversationView(
runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
selectedExpertId: conversation.selectedExpertId,
remote: conversation.remote,
branch: conversation.branch,
contextMetrics: conversation.contextMetrics,
@@ -214,6 +215,7 @@ export function sameActiveConversationView(
return left.id === right.id && left.title === right.title &&
left.projectId === right.projectId &&
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
left.selectedExpertId === right.selectedExpertId &&
left.unused === right.unused && left.historyLoaded === right.historyLoaded &&
left.running === right.running && left.messageCount === right.messageCount &&
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
@@ -64,6 +64,7 @@ export const heartbeat = {
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.',
skippedSources: '{{count}} sources changed during the review. They were skipped this time and will be handled in the next review.',
navigationSaved: '{{count}} navigation merges saved',
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
@@ -122,6 +123,7 @@ export const heartbeat = {
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…',
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
allTime: 'All time', allTimeLabel: 'all time',
customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date',
recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)',
customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.',
@@ -61,6 +61,7 @@ export const heartbeat = {
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。',
skippedSources: '回顾期间有 {{count}} 个来源发生变化,本次已跳过,将在下次回顾中处理。',
navigationSaved: '已保存 {{count}} 次导航合并',
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
@@ -119,6 +120,7 @@ export const heartbeat = {
empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…',
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
allTime: '全部', allTimeLabel: '全部时间',
customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期',
recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)',
customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。',
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest'
import { getSettingsCategoryList } from './settings-categories'
describe('getSettingsCategoryList', () => {
it('defines an icon for every settings category', () => {
expect(
getSettingsCategoryList(true).every(({ icon }) => icon != null)
).toBe(true)
})
it('hides SSH hosts while Remote Projects is disabled', () => {
expect(
getSettingsCategoryList(false).map(({ id }) => id)
+35 -11
View File
@@ -1,60 +1,84 @@
import i18n from './i18n'
import {
Bot,
FileText,
Gauge,
Info,
Palette,
Radio,
Server,
SlidersHorizontal,
Terminal,
Users,
Wrench
} from 'lucide-react'
export const settingsCategoryList = [
{
id: 'appearance',
group: 'general',
translationKey: 'appearance'
translationKey: 'appearance',
icon: Palette
},
{
id: 'platform-features',
group: 'general',
translationKey: 'platformFeatures'
translationKey: 'platformFeatures',
icon: SlidersHorizontal
},
{
id: 'model',
group: 'execution',
translationKey: 'model'
translationKey: 'model',
icon: Bot
},
{
id: 'context-control',
group: 'execution',
translationKey: 'contextControl'
translationKey: 'contextControl',
icon: Gauge
},
{
id: 'runtime',
group: 'execution',
translationKey: 'runtime'
translationKey: 'runtime',
icon: Terminal
},
{
id: 'ssh-hosts',
group: 'execution',
translationKey: 'sshHosts'
translationKey: 'sshHosts',
icon: Server
},
{
id: 'document-parsing',
group: 'extensions',
translationKey: 'documentParsing'
translationKey: 'documentParsing',
icon: FileText
},
{
id: 'channels',
group: 'extensions',
translationKey: 'channels'
translationKey: 'channels',
icon: Radio
},
{
id: 'roles',
group: 'extensions',
translationKey: 'roles'
translationKey: 'roles',
icon: Users
},
{
id: 'capabilities',
group: 'extensions',
translationKey: 'capabilities'
translationKey: 'capabilities',
icon: Wrench
},
{
id: 'about',
group: 'system',
translationKey: 'about'
translationKey: 'about',
icon: Info
}
] as const
+142 -11
View File
@@ -2433,7 +2433,8 @@
position: absolute;
top: var(--space-2);
right: var(--space-2);
opacity: 1;
opacity: 0;
transition: opacity var(--motion-fast) ease-out;
}
.magic-note-row:hover .magic-note-more,
@@ -2721,9 +2722,18 @@
line-height: 1;
}
.magic-note-entry {
position: relative;
}
/* Floats over the top-right corner so the actions never take their own row. */
.magic-note-entry__actions {
display: flex;
gap: 2px;
position: absolute;
z-index: 1;
top: var(--space-1);
right: var(--space-2);
gap: var(--space-1);
}
.magic-note-entry__actions .icon-button {
@@ -2731,7 +2741,36 @@
height: 28px;
}
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled) {
.magic-note-entry__action-button {
height: 28px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--font-caption);
white-space: nowrap;
cursor: pointer;
}
.magic-note-entry__action-button:hover:not(:disabled),
.magic-note-entry__action-button:focus-visible {
background: var(--surface-subtle);
color: var(--text-primary);
}
.magic-note-entry__action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.magic-note-entry__actions .magic-note-entry__delete-button {
color: var(--danger-strong);
}
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled),
.magic-note-entry__actions .magic-note-entry__delete-button:focus-visible {
background: var(--danger-subtle);
color: var(--danger-strong);
}
@@ -6483,7 +6522,7 @@ button > svg {
.chat-scroll-to-bottom {
position: absolute;
z-index: 2;
bottom: var(--space-3);
bottom: var(--space-6);
left: 50%;
display: grid;
width: 40px;
@@ -6695,6 +6734,23 @@ button > svg {
gap: 12px;
}
.message__header--with-role {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0;
}
.message__role {
max-width: 100%;
overflow: hidden;
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.message--user .message__header {
width: min(78%, 720px);
align-self: flex-end;
@@ -7890,24 +7946,54 @@ button > svg {
}
.composer-wrap {
--composer-fade-color: var(--surface-raised);
position: relative;
z-index: 1;
width: 100%;
max-width: 100%;
min-width: 0;
/* Overlap the message region by the top padding so messages run under the
fade right up to the composer outline instead of stopping at a solid band. */
margin-top: calc(-1 * var(--space-3));
padding:
var(--space-3)
var(--chat-inline-padding)
var(--space-2);
background: var(--surface-raised);
pointer-events: none;
}
.composer-wrap > * {
pointer-events: auto;
}
.composer-wrap::before {
position: absolute;
right: 0;
bottom: 100%;
bottom: calc(100% - var(--space-3));
left: 0;
z-index: -1;
height: var(--space-4);
background: linear-gradient(to bottom, transparent, var(--surface-raised));
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--composer-fade-color) 55%, transparent) 35%,
color-mix(in srgb, var(--composer-fade-color) 90%, transparent) 70%,
var(--composer-fade-color)
);
content: "";
pointer-events: none;
}
/* Below the composer the wrap stays opaque so nothing shows through its
bottom padding and metadata row. */
.composer-wrap::after {
position: absolute;
top: var(--space-3);
right: 0;
bottom: 0;
left: 0;
z-index: -1;
background: var(--composer-fade-color);
content: "";
pointer-events: none;
}
@@ -8383,7 +8469,7 @@ button > svg {
.composer:focus-within {
border-color: var(--accent);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent),
0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent),
var(--shadow-card);
}
@@ -8401,7 +8487,7 @@ button > svg {
.composer__input textarea {
display: block;
width: 100%;
min-height: 72px;
min-height: 56px;
max-height: 220px;
padding: 0;
border: 0;
@@ -8697,6 +8783,42 @@ button > svg {
width: 148px;
}
/* Role chip in the top-left corner of the input area. */
.composer__input-header {
display: flex;
min-width: 0;
align-items: center;
margin: calc(var(--space-2) * -1) 0 var(--space-2) calc(var(--space-2) * -1);
}
.composer-picker--chip > .model-button {
width: auto;
max-width: 220px;
height: 24px;
padding: 0 var(--space-2);
border-color: transparent;
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: 500;
}
.composer-picker--chip > .model-button[aria-expanded="true"] {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.composer-picker--chip > .model-button:disabled {
border-color: transparent;
background: transparent;
}
.composer-picker--chip .model-button__label {
flex: 0 1 auto;
}
.composer-picker--runtime > .model-button,
.composer-picker--runtime-action > .model-button {
@@ -9361,6 +9483,10 @@ button > svg {
font-weight: 500;
}
.settings-tabs button > svg {
flex: 0 0 auto;
}
.settings-tabs button:hover {
background: var(--surface-muted);
color: var(--text-primary);
@@ -9680,8 +9806,10 @@ button > svg {
min-height: 40px;
padding: var(--space-2) var(--space-3);
align-items: flex-start;
justify-content: center;
justify-content: flex-start;
text-align: left;
flex-direction: row;
gap: var(--space-2);
}
.settings-page .settings-tabs button strong,
@@ -17707,12 +17835,15 @@ textarea.field-control {
.settings-page,
.workspace,
.chat,
.composer-wrap,
.workspace-panel-scroll
) {
background: var(--surface-canvas);
}
:root[data-theme='dark'] .composer-wrap {
--composer-fade-color: var(--surface-canvas);
}
:root[data-theme='dark'] :where(
.sidebar,
.topbar,
+13
View File
@@ -116,11 +116,24 @@
flex: 1;
padding: 0;
overflow: hidden;
contain: size layout paint;
overflow-anchor: none;
background: var(--surface-canvas);
}
.terminal-panel__emulator .xterm {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%;
overflow: hidden;
}
/* IME composition must stay inside the terminal's own scroll boundary. */
.terminal-panel__emulator .xterm-helpers {
max-width: 0;
overflow: visible;
}
.terminal-panel__status {
+22
View File
@@ -519,6 +519,21 @@ export type ConversationAnsweredQuestion = z.infer<
typeof conversationAnsweredQuestionSchema
>
export const conversationMessageExpertSchema = z.union([
z
.object({
kind: z.literal('expert'),
id: assistantIdSchema,
name: z.string().trim().min(1).max(80)
})
.strict(),
z.object({ kind: z.literal('team') }).strict()
])
export type ConversationMessageExpert = z.infer<
typeof conversationMessageExpertSchema
>
export const conversationMessageSchema = z
.object({
id: assistantIdSchema,
@@ -541,6 +556,11 @@ export const conversationMessageSchema = z
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
/**
* Snapshot of the role selected when the reply was requested, so later
* renames or deletions do not rewrite history.
*/
expert: conversationMessageExpertSchema.optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
@@ -720,6 +740,8 @@ export const conversationSnapshotSchema = z
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
storyGraphEnabled: z.boolean().optional(),
/** Role picked in the composer for this conversation; absent means the general assistant. */
selectedExpertId: z.union([assistantIdSchema, z.literal('team')]).optional(),
contextMetrics: conversationContextMetricsSchema.optional(),
contextCompressionState:
conversationContextCompressionStateSchema.optional(),
@@ -39,6 +39,8 @@ export type SupervisionReviewProgress = {
remainingSources: number
/** Sources excluded because their owning conversation was deleted; not reviewed coverage. */
omittedSources?: number
/** Frozen sources that changed or disappeared during the run; skipped and left for the next review. */
skippedSources?: number
complete: boolean
restartRequired?: boolean
inFlight?: number