diff --git a/docs/features/conversation-supervision/README.md b/docs/features/conversation-supervision/README.md index 1427b256..6d7ed5d3 100644 --- a/docs/features/conversation-supervision/README.md +++ b/docs/features/conversation-supervision/README.md @@ -2,9 +2,11 @@ 监督者统一工作回顾、会话监督与故事线图谱,以智能心跳作为定时唤醒机制。现有智能心跳页面承载监督者工作回顾入口,手动回顾、定时结果投影、来源原文、实体/关系确认、继续讨论、知识库预览提交和侧栏反馈已经接入。历史回顾定位到对应结果图谱;侧栏按会话或任务查询,并支持固定目标。同 scope 的跨 run 实体复用依赖模型显式选择 Main 提供的候选引用,Main 处理 UUID 和旧文本主键的精确映射,具体规则和验证边界见[实施进度](./progress.md)。 +监督者可在“设置 → 模型”选择已有文本模型或跟随应用默认,连接与凭据复用规则及执行期间的模型一致性见[监督者模型选择](./logic-design.md#监督者模型选择)。 + ## 文档导航 -主页面包含工作回顾、故事线图谱、自动监督、活动记录和设置。工作回顾阅读统一监督结果并选择历史,图谱定位同一结果;自动监督管理计划、监督建议和只读的旧报告。活动记录展示统一执行、进度、建议阶段及详情,可按计划 ID 筛选;设置只保留算法参数。当前布局以 [UI 设计](./ui-design.md#应用入口)为准。活动读取实际手动回顾及心跳执行,合并展示同一次心跳触发的监督回顾与建议阶段;状态规则、历史缺失和保留期限见[活动记录](./logic-design.md#活动记录)。 +主页面包含工作回顾、故事线图谱、自动监督、活动记录和设置。工作回顾阅读统一监督结果并选择历史,图谱定位同一结果;自动监督管理计划、监督建议和只读的旧报告。活动记录展示统一执行、进度、建议阶段及详情,可按计划 ID 筛选;设置管理模型选择和算法参数。当前布局以 [UI 设计](./ui-design.md#应用入口)为准。活动读取实际手动回顾及心跳执行,合并展示同一次心跳触发的监督回顾与建议阶段;状态规则、历史缺失和保留期限见[活动记录](./logic-design.md#活动记录)。 心跳的职责为“定时触发增量回顾+按配置介入(建议)”,不再先生成独立报告;手动“回顾”默认增量,历史重看改为显式“重新整理”。规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计),需求 FR-S12,验收 US-S32、US-S33,2026-09-30 已实现(主动介入除外),验证见[实施进度](./progress.md#2026-09-30-心跳职责收敛与监督建议)。 @@ -28,4 +30,4 @@ 实施状态以代码和[实施进度](./progress.md)为准。逻辑待定项见[逻辑设计](./logic-design.md),架构边界见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。 -2026-09-28:Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源;监督者关闭后不暴露工具并拒绝旧会话调用。本地与远程接线及验证边界见[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。 +Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源。会话设置中的“使用故事图谱”默认开启,仅在监督者应用启用时显示;关闭会话开关或应用后不暴露工具并拒绝旧会话调用。规则见 [MCP 接入合同](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入),验证边界见[实施进度](./progress.md)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。 diff --git a/docs/features/conversation-supervision/logic-design.md b/docs/features/conversation-supervision/logic-design.md index e345f719..76c3cb90 100644 --- a/docs/features/conversation-supervision/logic-design.md +++ b/docs/features/conversation-supervision/logic-design.md @@ -4,6 +4,12 @@ 本文承接[监督者产品设计](./supervisor-prd.md)、[故事线图谱设计](../story-graph/prd.md)和[User Stories](./user-stories.md),定义触发、整理、展示与用户后续操作之间的关系。既有会话监督的证据和介入边界继续由[会话监督 PRD](./prd.md)约束。 +## 监督者模型选择 + +对应 FR-S13。监督者可跟随应用默认文本模型,或固定使用一个已保存的文本模型连接;会话和项目的 Runtime 选择不影响它。提取、导航汇总、故事归属、经验整理及建议措辞均使用监督者的模型设置。 + +回顾获得执行位置后,读取一次连接配置;同次执行中的批次、输出重试、汇总、故事与经验阶段保持该模型。修改监督者选择、应用默认模型或连接配置,仅影响后续执行。继续暂停或失败的回顾、单独重试故事、生成或重试建议时重新读取当前选择;跨重启不保存模型或凭据快照。显式选择的连接被删除、改为图片协议或缺少所需 API Key 时,执行报错并提示修改设置,不自动换成默认模型。 + ## 整体逻辑架构 报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。 @@ -215,6 +221,8 @@ flowchart LR ## 完整性与待定项 +FR-S11、US-S30 的会话故事图谱开关规则见 [MCP 接入合同第 6 节](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入)。应用启用、会话允许和 Runtime 能力分配须同时满足;回顾是否自动运行不由会话读取开关控制。 + FR-S11、US-S28 至 US-S31 的规则由[时态 Story Graph MCP 设计](./story-graph-mcp-design.md)统一定义:时间/确认与有效性见第 3 节,读取范围与分页见第 4 节,应用关闭与 Runtime 接入见第 6 节,跨 scope 复用与记忆过渡见第 7、8 节。D1 读取已接入,后两项仍待实现;Global/项目独立 checkpoint 和同 scope 候选身份保留。MCP 不接受 `as_of`,较晚生成的结果也不自动使旧决定失效。 本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。 diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 84c5ae53..ab55f9cc 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -4,6 +4,34 @@ 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-10-03 会话故事图谱开关 + +FR-S11、US-S30 的“使用故事图谱”已接通 Composer、生产 Preload/IPC、SQLite 和工具网关。会话默认开启,仅在监督者应用启用时显示,位置在知识库之前。存储和执行合同见 [MCP 接入](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入),没有数据库版本升级或迁移。另保留本轮开始前已有的监督者独立模型选择及并行性能改动。 + +定向测试共 9 个文件、37 项通过,重复运行不重复计数: + +- `npx vitest run src/main/agent/native-client-coordinator.test.ts src/renderer/src/conversation-persistence.test.ts src/renderer/src/Composer.test.tsx src/agent-daemon/story-graph-integration.test.ts`:25 项。覆盖默认值、按会话隔离、保存中及失败、原生客户端绑定、Main/Agent HTTP MCP 发现、旧调用拒绝与重新开启;图谱集成 5 项在最后修改后单独复跑通过,含在途结果交付前关闭。 +- `npx vitest run src/main/assistant/assistant-database.test.ts src/main/ipc.test.ts src/renderer/src/App.test.tsx src/main/agent/deepseek-harness-acp-e2e.test.ts -t "story graph|Story Graph|pins"`:11 项通过,630 项按名称未运行。覆盖真实 SQLite 重开、旧自动保存不能覆盖选择、IPC 校验、四种本地 Runtime 的签发与关闭,以及受控 Harness Ask/Execute。 +- `npx vitest run tests/overlay-layering.electron.test.ts -t "conversation story graph"`:1 项通过,3 项按名称未运行。真实 App、Preload、IPC 和 SQLite 在 1280px/390px、浅色/深色四种组合下检查共享 Switch、位置和可见矩形;原生鼠标打开菜单、Space 保存、页面重载、应用关闭时移除 DOM、重开后恢复选择及旧工具绑定拒绝均通过。切换没有模型请求,模型尝试计数为 0。 + +`npm run typecheck` 的 Main、Agent、Web 三组通过,`git diff --check` 通过。修改文件定向 ESLint 除 `conversation-selectors.ts:32,47` 外通过;这两处是同工作树并行标题缓存改动的 `react-hooks/immutability` 报错,本功能只改该文件的 Composer 字段投影,未修改缓存逻辑。没有执行全量测试、发布构建、提交或推送。中文 MCP 说明扫描阻断项为 0,复核项保留必要的数据和执行边界。 + +### 本轮 Linux Host 证据 + +[源码探针](../../../scripts/story-graph-host-probe.ts)临时打包后在共享 Linux x64 Host 运行当前 `AgentImageToolMcp`、`AgentOwnedAcpPrompt`、`ContinueHostAdapter`,桌面使用真实 `AssistantDatabase`、`KnowledgeMcpGateway` 和 `MainImageToolSession`。关闭操作改为写会话持久设置,验证其他会话仍开启,以及同一个远程 MCP 会话不再发现工具、旧工具调用被拒绝。 + +OpenCode 和 Continue 的 Ask/Execute 确定性探针通过,共 10 次测试推理请求(含 2 次标题生成)、10 次桌面图谱读取、0 次外部 Provider 请求。真实 DeepSeek 复测中,Linux OpenCode Ask/Execute 均完成图谱调用并返回完成标记,随后关闭检查通过;该轮 6 次 Provider 请求(含 2 次标题生成)、8 次桌面读取。首轮真实模型已完成 Ask 图谱读取,但探针未拼接流式文字便检查完成标记而失败,消耗 3 次 Provider 请求;修正探针后复测,整个任务外部请求合计 **9 次**。 + +工具消息通过 SSH 标准输入输出搬运,调用生产二进制工具分发;没有重装共享 Agent,也未把完整 Desktop attach/bootstrap 或远端 Continue 的真实模型请求计作已验证。远程协议与模型桥源码未变,会话开关由 Desktop 的现有可用性回调执行。测试只使用隔离 SQLite 和固定示例内容;临时 Host 探针及运行目录已清理。Electron 界面与 SSH Runtime 分开验收,没有宣称一条自动化场景覆盖整窗点击到远端模型。 + +## 2026-10-03 监督者独立模型选择 + +FR-S13 已接通监督者“设置 → 模型”、应用设置持久化和 Main 连接解析。字段只保存已有文本连接 UUID,旧配置跟随应用默认;执行中复用同一 Runtime,继续和重试采用当前设置。规则见[模型选择](./logic-design.md#监督者模型选择)。 + +定向验证覆盖中英文选择与保存失败草稿、失效连接提示、旧设置默认值及重载、提取到经验阶段的模型一致性、失败后的释放与继续、建议措辞连接及清理。`ipc.test.ts` 的监督/心跳相关 24 项通过,含真实直连 Runtime 到 loopback HTTP 服务的两次请求:固定连接、应用默认各一次,验证模型名、请求定制、无工具及失效连接不回退。外部真实模型请求为 0 次。 + +验证命令:`npx vitest run src/main/application-settings-store.test.ts src/main/assistant/supervision-production.test.ts src/renderer/src/HeartbeatCenter.test.tsx`;`npx vitest run src/main/ipc.test.ts -t "supervis|heartbeat|unchanged sources"`;`npm run typecheck`;修改的源文件定向 ESLint。未跑全量测试、打包、真实供应商或 Electron 原生界面测试。监督模型请求在桌面 Main 内完成,Agent 和远程模型桥未改动;未进行远程 Host 验证。 + ## 2026-10-02 长会话验收与 Agent 自主调用图谱 ### 长会话验收 diff --git a/docs/features/conversation-supervision/review-scheduling-design.md b/docs/features/conversation-supervision/review-scheduling-design.md index 17965ae7..7ae765f1 100644 --- a/docs/features/conversation-supervision/review-scheduling-design.md +++ b/docs/features/conversation-supervision/review-scheduling-design.md @@ -52,7 +52,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张 - 真实验证覆盖一个 27 条消息的完整会话;较早的 129 条消息实验在第二次输出 JSON 无效后停止。两实验合计 5 次付费请求,没有重复付费或回退。范围及缓存复核说明见[进度](./progress.md#2026-09-24-完整会话生产验证与收尾)。 - 来源被修改或删除时保留旧批次并明确失败,标记需要新建回顾,活动页停止提供无效的继续按钮;下一次自动检查可创建新 run,不会永久重复该失败。尚未实现仅使受影响叶子和父节点失效后重算。清单记录冻结版本,不保存全库正文快照。 -- 分块配置、超时、接纳并发及算法版本已保存。实际默认 Runtime、模型及候选背景仍在执行时解析;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。 +- 分块配置、超时、接纳并发及算法版本已保存。监督者模型连接在每次执行开始时解析,同次回顾复用;继续或重试读取当前选择,规则见[监督者模型选择](./logic-design.md#监督者模型选择)。候选背景仍在执行时读取;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。 - 元数据清单和最终发布仍是同步 SQLite 事务,缺少大库延迟验证及可中断来源读取。批次详情已分页,既有历史图谱查询仍返回整份结果;大图谱的响应大小尚未改造。 - 没有 token/费用预算、提供商 RPM/TPM、Retry-After 冷却、执行硬期限、独立清理期限或逐层可配置超时。默认正文预算不保证任意模型上下文容量;超长输出、非法 JSON 和引用错误会失败,保留已成功批次。 - 片段以码点边界切分,尚未增加句界优先和重叠背景。事实保留指保留模型已产出的结构化记录,不证明语义无遗漏;生产契约校验引用 token,不要求原型的逐字引文和 reviewedTokens 回执。提供过的 locator.source 仅在本节点唯一对应一个片段时可作为引用别名;歧义及未知来源拒绝。 diff --git a/docs/features/conversation-supervision/story-graph-mcp-design.md b/docs/features/conversation-supervision/story-graph-mcp-design.md index 33fa0813..92ae8b6a 100644 --- a/docs/features/conversation-supervision/story-graph-mcp-design.md +++ b/docs/features/conversation-supervision/story-graph-mcp-design.md @@ -112,12 +112,17 @@ D1 来源页默认 4,000、上限 8,000 码点。`current` 通过既有 locator | 配置或目录 | 当前状态 | 目标映射 | | --- | --- | --- | | `heartbeatEnabled` | 监督者应用开关,新设置默认关闭 | 为 Story Graph 暴露和每次调用的必要条件;关闭后不列出三个工具,旧会话直接调用也拒绝 | +| 会话 `storyGraphEnabled` | 每个会话默认开启,缺省字段按开启处理 | 关闭后不向该会话签发图谱工具,并拒绝已有能力会话的发现、调用和在途结果交付;其他会话不受影响 | | 内置能力 `enabled` 与 `assignments` | [CapabilityService](../../../src/main/capabilities/capability-service.ts)管理现有 MCP 开关与 Runtime 分配 | `story-graph` 沿用同一管理方式;应用关闭时不得由内置能力开关绕过,保留用户原分配 | | 内置工具目录 | 已有 Story Graph 条目 | 映射 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,全部 `access: read`、`readOnlyHint: true` | | 监督整理参数 | 超时、并发、来源页与批次设置已存在 | 控制后台整理;不让一次 MCP 查询启动整理,也不以正文批次预算截断检索结果 | | 计划 `enabled` | 决定自动回顾是否到期执行 | 不决定已有图谱能否读取;应用开启且能力可用时,无计划也能查已有结果 | -Main 在工具发现和调用分发时共同检查应用启用、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或数据写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。 +Main 在工具发现和调用分发时共同检查应用启用、会话开关、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或图谱写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。 + +会话开关只决定 Agent 能否按需使用这三个 MCP 工具,不自动查询或注入图谱上下文,也不触发回顾。监督者应用关闭时,输入区完全隐藏开关并保留会话选择;再次启用应用后恢复该选择。后台回顾和计划仍由各自设置控制。 + +开关复用已发布的 `conversations.context_state_json`,字段为可选布尔值,不增加数据库版本或迁移。Renderer 经串行会话保存队列与 `conversations:set-story-graph` IPC 保存,成功后更新显示,失败保留原值并通知。Main 按会话主键只读设置,不加载消息历史;显式设置写入后,旧的自动保存快照不能覆盖它。新会话及新建分支使用默认开启。 本地 OpenCode、Continue 和原生客户端复用 [KnowledgeMcpGateway](../../../src/main/agent/knowledge-mcp-gateway.ts) 的 MCP 注册和分发;直连 Model 与 DeepSeek Harness Main 代理调用同一服务。远程 OpenCode/Continue 复用现有图像工具的受管 HTTP MCP 和二进制通道,`storyGraph` 描述独立于图像生成描述,Ask 可只携带图谱读取。请求仍由桌面 Main 解析范围和读取 SQLite,不把数据库、正文库或凭据复制到 Host。 @@ -162,4 +167,4 @@ D1 可将现有已确认偏好作为明确标记的背景投影,原读取路 | Runtime 一致性 | US-S30 | 本地及 GoodBuddy Agent 远程实际链路具有相同 scope、版本、分页、引用与关闭行为,不能以共享 schema 代替接线验收 | | 记忆及范围复用 | US-S31 | 确认偏好与已有引用保留;同版本成功叶子跨 scope 复用且能检索事实,不只返回摘要;历史缺口不补造 | -验证命令和证据集中记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。本次只运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有全量测试、付费模型请求或发布操作。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。 +2026-09-28 的 D1 验证命令和证据记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具),当时运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有付费模型请求。2026-10-03 会话开关的 Electron、Host 及真实模型调用证据见[本轮记录](./progress.md#2026-10-03-会话故事图谱开关)。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。 diff --git a/docs/features/conversation-supervision/supervisor-prd.md b/docs/features/conversation-supervision/supervisor-prd.md index 407061ab..92c37e73 100644 --- a/docs/features/conversation-supervision/supervisor-prd.md +++ b/docs/features/conversation-supervision/supervisor-prd.md @@ -19,10 +19,12 @@ FR-S1:将现有智能心跳入口整合、升级为监督者,不增加另一 应用默认关闭,用户显式启用后才接受新的回顾工作;关闭时保留计划和历史。默认值、已保存选择、自动运行条件及在途运行边界由[应用启停与执行](./logic-design.md#应用启停与执行)统一定义。 -FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置只管理超时、并发、分页、分批及单次响应容量,不嵌套次级菜单。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。 +FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置管理模型选择、超时、并发、分页、分批及单次响应容量,分类页签见 UI 设计。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。 当前设置还支持分别调整报告与监督整理的模型超时,以及两者共用的监督并发数。并发设置不限制普通聊天,也不保证避免上游限流;排队不消耗模型阶段时限,降低并发不打断在途请求。取值及生效规则由[当前控制清单](./review-scheduling-design.md#当前超时与调度控制清单已实现)定义。 +FR-S13:监督者的“设置 → 模型”可选择跟随应用默认文本模型或已有文本模型连接,复用连接及其凭据。回顾执行中修改设置不切换模型;生效时点、继续和重试规则见[监督者模型选择](./logic-design.md#监督者模型选择)。 + FR-S10:活动同时覆盖手动监督回顾、定时心跳及用户立即运行的心跳。每条记录显示触发方式、范围、开始与结束时间、运行状态、失败原因和已有结果入口。同一次心跳的报告和下游监督回顾合为一条活动,报告保存成功不能掩盖下游仍在运行、暂停或失败。活动读取不调用模型,关闭应用后不查询活动。新批次运行支持暂停及继续,保留成功批次;人工修改的时间序列审计尚未实现,历史缺失不得猜测补齐。 FR-S2:分页只控制数据库读取批量,不能限制最终处理范围。当前已开放来源页大小、每批正文/消息上限;这些配置在新建回顾时保存,继续沿用。单次响应容量按当前设置生效。回顾持续处理至完成,不按累计运行时长自动暂停。当前字段和未来高级参数的边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界),交互见 [UI 设计](./ui-design.md#算法配置设置目标)。完整覆盖、来源校验与事实保留不作为可关闭选项。 @@ -59,6 +61,8 @@ FR-S11(D1 已实现,D2/D3 待实现):监督者以时态 Story Graph 触发、整理与结果共享规则,以及整体架构和图谱对象关系见[逻辑设计](./logic-design.md)。 +FR-S11 的会话控制:输入区对话设置在知识库上方提供“使用故事图谱”,每个会话默认开启并独立保存。仅在监督者应用启用时显示,应用关闭时完全隐藏。关闭会话开关后,本地及远程 Agent 不再获得图谱工具,已有能力会话调用也被拒绝;开启仅允许 Agent 自主决定是否调用 MCP,不注入图谱上下文或触发回顾。验收见 US-S30。 + 首个完整流程为:启用监督者、选择范围、生成回顾、进入图谱、核对来源、携带选定上下文继续讨论。知识库双向定位与内容补充随后接入同一流程。图谱关系辅助检索需单独验证效果,不作为首版默认检索策略。 当前工作回顾和图谱读取生产结果,活动展示实际执行与无变化检查、暂停和继续入口,自动监督设置复用已有计划。完整图谱回放及应用市场尚未实现,验收证据见[实施进度](./progress.md)。 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md index b48887f6..5395a2d8 100644 --- a/docs/features/conversation-supervision/technical-design.md +++ b/docs/features/conversation-supervision/technical-design.md @@ -13,7 +13,7 @@ 本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。 -FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用及 Runtime 分配。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。 +FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。 生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和 ask Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。 @@ -41,6 +41,12 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、 已确认记忆作为当前背景,不参与新增正文覆盖。最多四条、每条 500 字符,时间来自真实更新时间;模型不能把背景引用为本批新增证据。旧结果继续保留原来源类型和 locator。监督正文上限现用于分批,余段继续处理;心跳报告的有界输入语义没有改变。 +## 监督者模型连接 + +`ApplicationSettings.supervisorModelProfileId` 只保存已有连接 UUID,`null` 或旧设置缺少字段时跟随 `RuntimeSettings.defaultModelProfileId`。Main 的 `resolveSupervisorRuntime` 从 `getResolvedSettings()` 读取连接和已有凭据,校验文本协议及认证后,用 `createModelProfileRuntime` 创建无工具直连 Runtime。Renderer 仅接收公开连接信息,不新增凭据存储或 IPC 方法。 + +`SupervisorService.admit` 在执行位置内调用生产工厂的 `withExecution`,为提取、导航、故事及经验共享一个 Runtime;执行结束后释放。每次请求仍释放自身临时会话。建议措辞在自身调用开始时解析同一设置,结束后释放 Runtime。生命周期规则见[监督者模型选择](./logic-design.md#监督者模型选择)。这条路径在桌面 Main 内运行,即使证据来自远程项目,也不经过 GoodBuddy Agent 或远程模型桥。 + ## 模型阶段超时与调度 监督整理超时由 run 创建时冻结;共享模型池仍采用实时设置上限。`supervision:pause` 取消该 run 的排队和在途工作,`supervision:resume` 继续已保存批次;监督整理输出在流中按 `supervisionReview.responseKiB` 检查响应容量,默认 1024 KiB,可在设置页调整为 100 至 16384 KiB。该容量每次请求读取当前设置,继续旧运行也采用新值;超限保留成功批次并提示调高后继续。生成摘要及事实内容不设独立短字符限制,监督旧摘要读取保留全文,具体边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。以下报告领取和租约规则继续适用。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index f66811b7..5904e63e 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -4,6 +4,8 @@ ## 应用入口 +“设置 → 模型”还提供“监督者模型”单选框:默认“跟随应用默认”,其余选项为已保存的文本模型连接,以连接名称和模型名区分,不列图片模型。失效的已选连接保留“所选模型已不可用”,用户可改选;加载或保存期间禁用选择。沿用该分类的保存按钮、未保存草稿保护和失败重试,常显说明覆盖的阶段及[生效时点](./logic-design.md#监督者模型选择),不增加凭据输入。 + 应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。 当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。 @@ -12,6 +14,12 @@ 新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 X 只关闭提示,不暂停或取消执行,同一次运行的轮询不重新打开已关闭提示。范围、时间和活动计划筛选保留可访问名称,不再重复显示字段标题。工作回顾不读取历史心跳报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。 +## 会话故事图谱开关 + +对应 FR-S11、US-S30。输入区“选项 → 对话设置”在知识库之前显示“使用故事图谱”(英文 `Use story graph`),复用共享 `toggle-row` 滑动 Switch,标签在左、开关在右,支持 Tab 和 Space。仅当监督者应用 `heartbeatEnabled` 为 true 时渲染;应用关闭或设置尚未加载时完全隐藏,不保留禁用控件或占位。 + +每个会话默认开启。保存期间保留已确认状态并禁用重复提交,成功后显示新值,失败时保留原值并通过应用通知说明错误。切换会话、重新打开或重启后恢复各自设置。开关的执行规则和持久化见 [MCP 接入合同](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入)。 + ## 活动 文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动页不常显重复说明;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。 diff --git a/docs/features/conversation-supervision/user-stories.md b/docs/features/conversation-supervision/user-stories.md index 16883de2..65d7f237 100644 --- a/docs/features/conversation-supervision/user-stories.md +++ b/docs/features/conversation-supervision/user-stories.md @@ -615,6 +615,11 @@ | 用户进行普通聊天 | 没有继续工作或历史依据需求 | 不强制每轮查询或注入完整图谱;需要核对原话时才进一步读来源 | | 监督者开启且内置能力已分配,没有自动计划 | 查询已有结果 | 可以读取;查询不触发模型、回顾或业务写入 | | 监督者关闭,旧会话仍缓存工具 | 列出工具或直接调用 | 不暴露工具,调用被拒绝;已有数据保留 | +| 监督者应用关闭 | 打开输入区对话设置 | 完全不显示“使用故事图谱”开关 | +| 监督者应用开启,会话未保存过该选项 | 打开输入区对话设置 | 知识库上方显示默认开启的共享 Switch | +| 当前会话关闭“使用故事图谱” | 本地或远程 Agent 列工具或使用旧能力会话调用 | 不暴露三个图谱工具并拒绝执行,其他会话仍按自己的设置运行 | +| 开关已保存 | 切换会话、重启或关闭后重开监督者应用 | 恢复该会话的选择;开关不发起回顾或自动注入上下文 | +| 保存失败 | 用户切换开关 | 保留最后确认值并显示错误,可重试 | | 相同范围与证据分别通过本地和远程 Runtime 查询 | 翻页、读取版本或关闭监督者 | 返回相同业务语义与引用,远程实际桥接须通过验收 | ### US-S31 保留偏好并复用跨范围事实 @@ -629,6 +634,19 @@ | 仅部分片段成功或原来源已改版 | 再次整理 | 识别未覆盖或变更部分,不把旧版本覆盖算作新版本完成 | | Global 结果含多个项目的归纳 | 从单项目查询 | 不把跨项目摘要伪装成项目结论,复用事实仍保留来源归属 | +### US-S34 独立选择监督者模型 + +对应 FR-S13,生效规则见[监督者模型选择](./logic-design.md#监督者模型选择)。 + +| Given | When | Then | +| --- | --- | --- | +| 已保存多个文本模型连接 | 在监督者“设置 → 模型”选择连接并保存 | 回顾和建议使用所选连接,无需重新输入凭据 | +| 尚未配置监督者模型,或选择“跟随应用默认” | 开始执行 | 使用应用默认文本模型 | +| 回顾正在提取或汇总 | 修改模型选择或原连接配置 | 此次回顾后续批次、故事和经验阶段保持原模型 | +| 回顾已暂停或失败 | 修改模型后继续 | 使用当前选择继续已保存进度 | +| 已选连接被删除或变成图片模型 | 打开设置并尝试执行 | 显示连接不可用,执行报错,用户可改选;不自动回退 | +| 模型选择保存失败 | 留在设置页 | 保留选择草稿及重试入口 | + ## 验收优先级与依赖 首个可用版本应先打通以下路径: diff --git a/docs/features/model-connections/ui-design.md b/docs/features/model-connections/ui-design.md index 380e440f..45bff558 100644 --- a/docs/features/model-connections/ui-design.md +++ b/docs/features/model-connections/ui-design.md @@ -52,6 +52,8 @@ OpenCode 与 DeepSeek Harness 不使用该值。 ## 会话与项目 Runtime 选择 +监督者在自身“设置 → 模型”中引用已保存的文本模型连接,不复制连接或凭据;默认跟随与执行生效规则见[监督者模型选择](../conversation-supervision/logic-design.md#监督者模型选择)。 + 项目设置分“执行方式”和“模型”两个字段,输入框使用两栏选择器,全局默认模型仍在系统 设置中按 Runtime 页签配置。分层规则、选择器状态、键盘行为和失效提示由 [执行方式与模型选择设计](./runtime-selection-design.md#3-各入口行为已实现)唯一维护。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 3b889d2f..93de348c 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -27,9 +27,9 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源 ## 跨会话进程复用(未发布) -2026-09-28 Story Graph 补充:远程 OpenCode/Continue 的 Ask 和 Execute -通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目范围并复核监督者 -开关及 Runtime 分配;图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、 +2026-09-28 Story Graph 接线、2026-10-03 会话开关补充:远程 OpenCode/Continue 的 Ask 和 Execute +通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目和会话,并复核监督者 +应用开关、会话“使用故事图谱”开关及 Runtime 分配;关闭会话开关后已有远程绑定也拒绝读取。图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、 分页和版本限制由[专项设计](../conversation-supervision/story-graph-mcp-design.md)定义, 源码 Host 探针及协议测试见[验证记录](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具)。 diff --git a/scripts/story-graph-host-probe.ts b/scripts/story-graph-host-probe.ts index c617c9a8..f9dcbc59 100644 --- a/scripts/story-graph-host-probe.ts +++ b/scripts/story-graph-host-probe.ts @@ -1,9 +1,10 @@ // Deterministic SSH validation. Desktop owns SQLite; only tool messages cross SSH. import assert from 'node:assert/strict' -import { randomBytes } from 'node:crypto' +import { randomBytes, randomUUID } from 'node:crypto' import { spawn } from 'node:child_process' import { createServer } from 'node:http' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { parseEnv } from 'node:util' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { createInterface } from 'node:readline' @@ -26,12 +27,19 @@ const emit = (value: object) => process.stdout.write(`${JSON.stringify(value)}\n async function hostProbe() { const [, , , opencode, continueEntry] = process.argv + const live = process.argv[5] === '--live' const root = await mkdtemp(join(tmpdir(), 'goodbuddy-graph-')) const adapter = new AgentImageToolMcp({ channelId: 'graph-probe', channelEpoch: '1', storyGraph: true }, async payload => { emit({ frame: Buffer.from(payload).toString('base64') }) }) let disabled!: () => void const disabling = new Promise(resolve => { disabled = resolve }) const lines = createInterface({ input: process.stdin }) - lines.on('line', line => { const value = JSON.parse(line); if (value.frame) adapter.onReply(Buffer.from(value.frame, 'base64')); if (value.disabled) disabled() }) + const pendingInference = new Map void>() + lines.on('line', line => { + const value = JSON.parse(line) + if (value.frame) adapter.onReply(Buffer.from(value.frame, 'base64')) + if (value.disabled) disabled() + if (value.inferenceReply) { pendingInference.get(value.id)?.(value.inferenceReply); pendingInference.delete(value.id) } + }) const client = new Client({ name: 'story-graph-host-probe', version: '1' }) let inferenceRequests = 0 let backgroundRequests = 0 @@ -44,6 +52,13 @@ async function hostProbe() { const tool = tools?.find(tool => tool.function.name.endsWith('story_graph_search')) if (tools?.length) assert.ok(tool, 'Runtime must expose Story Graph in Ask and Execute') else backgroundRequests++ + if (live) { + const response = new Promise<{ status: number; body: string }>(resolve => pendingInference.set(inferenceRequests, resolve)) + emit({ inference: { ...body, tools: tools?.filter(tool => tool.function.name.endsWith('story_graph_search')) }, id: inferenceRequests }) + const reply = await response + res.writeHead(reply.status, { 'content-type': 'text/event-stream' }).end(reply.body) + return + } const result = body.messages.findLast((message: { role: string }) => message.role === 'tool') if (result) assert.match(JSON.stringify(result), /Decision B/) const delta = !tool ? { role: 'assistant', content: 'Graph probe title' } : result ? { role: 'assistant', content: 'GRAPH_RUNTIME_OK' } : { role: 'assistant', tool_calls: [{ index: 0, id: 'graph-call', type: 'function', function: { @@ -97,11 +112,17 @@ async function hostProbe() { completePrompt: async (_operation, _status, _response, commit) => { commit(); done() } }) try { transcript.prepare({ bindingId: 'graph', controllerId: 'probe', operationId: workMode, requestId: workMode, preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 }) - await owner.start({ bindingId: 'graph', operationId: workMode, requestId: workMode, prompt: [{ type: 'text', text: 'Search the graph.' }] }, workMode) + await owner.start({ bindingId: 'graph', operationId: workMode, requestId: workMode, prompt: [{ type: 'text', text: 'Search the story graph for Decision B using story_graph_search, then reply GRAPH_RUNTIME_OK. Do not use other tools.' }] }, workMode) await completed const page = transcript.page({ bindingId: 'graph', operationId: workMode, controllerId: 'probe', afterSequence: '0', limit: 128 }) - assert.equal(page.state, 'completed'); assert.match(JSON.stringify(page), /GRAPH_RUNTIME_OK/) + assert.equal(page.state, 'completed') + const answer = page.events.map(event => { + const update = (event.payload as { update?: { sessionUpdate: string; content?: { text?: string } } }).update + return update?.sessionUpdate === 'agent_message_chunk' ? update.content?.text ?? '' : '' + }).join('') + assert.ok(answer.includes('GRAPH_RUNTIME_OK'), 'Runtime must return the completion marker') } finally { owner.close(); child.kill(); await exited; transcript.close() } + if (live) continue const continueAdapter = new ContinueHostAdapter({ binaryPath: continueEntry!, configPath: '', workspace: root, cacheRoot: join(root, 'continue'), mode: 'chat', modelProfile: { id: 'probe', name: 'Probe', modelName: 'graph-probe', baseUrl: `${origin}/v1`, protocol: 'openai-chat-completions', authentication: 'none' }, launchHost: (entry, args, options) => { @@ -120,27 +141,34 @@ async function hostProbe() { assert.deepEqual((await client.listTools()).tools, []) assert.equal((await client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })).isError, true) assert.deepEqual(failures, []) - emit({ passed: true, inferenceRequests, backgroundRequests, paidCalls: 0, runtimes: ['OpenCode Ask', 'OpenCode Execute', 'Continue Ask', 'Continue Execute'] }) + emit({ passed: true, inferenceRequests, backgroundRequests, paidCalls: live ? inferenceRequests : 0, + runtimes: live ? ['OpenCode Ask', 'OpenCode Execute'] : ['OpenCode Ask', 'OpenCode Execute', 'Continue Ask', 'Continue Execute'] }) } finally { lines.close(); await client.close(); adapter.close(); inference.closeAllConnections(); inference.close(); await rm(root, { recursive: true, force: true }) } } async function desktopProbe() { const [, , host, remoteScript, opencode, continueEntry] = process.argv assert.ok(host && remoteScript && opencode && continueEntry, 'Expected host, remote bundle, OpenCode and Continue paths') + const liveConfig = process.argv[6] ? parseEnv(await readFile(process.argv[6], 'utf8')) : undefined + if (liveConfig) assert.ok(liveConfig.DEEPSEEK_API_KEY && liveConfig.DEEPSEEK_BASE_URL && liveConfig.DEEPSEEK_MODEL, 'Live model configuration unavailable') + let providerCalls = 0 const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd()) const projectId = db.listProjects()[0]!.id + const conversationId = randomUUID() + const otherConversationId = randomUUID() + db.saveLocalConversations([conversationId, otherConversationId].map(id => ({ header: { id, projectId, title: 'Graph probe', updatedAt: Date.now() }, messages: [] }))) db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } }, evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'probe', title: 'Decision evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A explicitly revised to Decision B; implementation remains pending.' }], output: { summary: 'Decision B', changeDigest: 'Revision A to B', openItems: ['Implementation pending'], events: [], entityChanges: [], relations: [], entities: [{ id: 'decision', label: 'Decision B', description: 'Explicit revision with evidence', sourceReferenceIds: ['quote'] }] } }) - let enabled = true, reads = 0, passed = false - const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available: async () => enabled, - read: (...args) => { assert.ok(enabled); reads++; return db.readStoryGraph(...args) } } }) + let reads = 0, passed = false + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available: async binding => db.isConversationStoryGraphEnabled(binding.conversationId!), + read: (...args) => { assert.ok(db.isConversationStoryGraphEnabled(conversationId)); reads++; return db.readStoryGraph(...args) } } }) const signal = AbortSignal.timeout(150_000) - const token = gateway.grant('probe', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'opencode' })! + const token = gateway.grant('probe', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, conversationId, runtimeTarget: 'opencode' })! const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'` const child = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', host!, - `TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${quote(remoteScript!)} --host ${quote(opencode!)} ${quote(continueEntry!)}`], { stdio: ['pipe', 'pipe', 'inherit'], signal }) + `TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${quote(remoteScript!)} --host ${quote(opencode!)} ${quote(continueEntry!)} ${liveConfig ? '--live' : ''}`], { stdio: ['pipe', 'pipe', 'inherit'], signal }) const exited = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject) }) const queue: RuntimeProtocolBinaryFrame[] = [] let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined @@ -153,14 +181,29 @@ async function desktopProbe() { const lines = createInterface({ input: child.stdout }) lines.on('line', line => { const value = JSON.parse(line) - if (value.frame) { + if (value.inference) { + void (async () => { + assert.ok(liveConfig && ++providerCalls <= 8, 'Live request budget exhausted') + const base = liveConfig.DEEPSEEK_BASE_URL!.replace(/\/+$/, '') + const response = await fetch(base.endsWith('/chat/completions') ? base : `${base}/chat/completions`, { + method: 'POST', headers: { Authorization: `Bearer ${liveConfig.DEEPSEEK_API_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...value.inference, model: liveConfig.DEEPSEEK_MODEL, max_tokens: 512 }), signal + }) + assert.ok(response.ok, `Provider HTTP ${response.status}`) + child.stdin.write(`${JSON.stringify({ id: value.id, inferenceReply: { status: response.status, body: await response.text() } })}\n`) + })().catch(() => { child.stdin.write(`${JSON.stringify({ id: value.id, inferenceReply: { status: 502, body: 'Live provider request failed' } })}\n`) }) + } else if (value.frame) { const frame = { payload: Buffer.from(value.frame, 'base64'), sequence: '1', consume: async () => {} } if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame) - } else if (value.disable) { enabled = false; child.stdin.write('{"disabled":true}\n') } - else if (value.passed) { passed = true; emit({ ...value, desktopReads: reads, desktopOwnsDatabase: true }) } + } else if (value.disable) { + db.setConversationStoryGraphEnabled(conversationId, false) + assert.ok(db.isConversationStoryGraphEnabled(otherConversationId)) + child.stdin.write('{"disabled":true}\n') + } + else if (value.passed) { passed = true; emit({ ...value, providerCalls, desktopReads: reads, desktopOwnsDatabase: true }) } }) try { assert.equal(await exited, 0); assert.ok(passed) } - finally { lines.close(); main.close(); child.kill(); await gateway.dispose(); db.close() } + finally { emit({ providerCalls }); lines.close(); main.close(); child.kill(); await gateway.dispose(); db.close() } } void (process.argv[2] === '--host' ? hostProbe() : desktopProbe()).catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/src/agent-daemon/story-graph-integration.test.ts b/src/agent-daemon/story-graph-integration.test.ts index d34928f9..b227690d 100644 --- a/src/agent-daemon/story-graph-integration.test.ts +++ b/src/agent-daemon/story-graph-integration.test.ts @@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { afterEach, expect, it, vi } from 'vitest' import { AssistantDatabase } from '../main/assistant/assistant-database' import type { KnowledgeService } from '../main/knowledge/knowledge-service' -import { KnowledgeMcpGateway } from '../main/agent/knowledge-mcp-gateway' +import { KnowledgeMcpGateway, type StoryGraphBinding } from '../main/agent/knowledge-mcp-gateway' import { ModelToolProvider } from '../main/agent/model-tool-provider' import { AgentImageToolMcp } from './image-tool-mcp' import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session' @@ -19,18 +19,20 @@ function fixture() { const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd()) cleanups.push(() => db.close()) const projectId = db.listProjects()[0]!.id + const conversationId = randomUUID() + db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }]) db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } }, evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }], output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }], events: [], relations: [], entityChanges: [] } }) let enabled = true, assigned = true const read = vi.fn(db.readStoryGraph.bind(db)) - const available = vi.fn(async () => enabled && assigned) + const available = vi.fn(async (binding: StoryGraphBinding) => enabled && assigned && db.isConversationStoryGraphEnabled(binding.conversationId!)) const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } }) cleanups.push(() => gateway.dispose()) const wait = new AbortController() - const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'model' })! - return { db, projectId, gateway, token, wait, read, available, + const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, conversationId, runtimeTarget: 'model' })! + return { db, projectId, conversationId, gateway, token, wait, read, available, disable: () => { enabled = false }, unassign: () => { assigned = false } } } @@ -41,7 +43,7 @@ async function client(url: string, token?: string) { return connection } -it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when disabled', async transport => { +it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when conversation is disabled', async transport => { const f = fixture() let connection: Client if (transport === 'local') { @@ -84,11 +86,15 @@ it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and expect(unsupported.isError).toBe(true) expect(JSON.stringify(unsupported)).toContain('unsupported_mode') const before = f.read.mock.calls.length - f.disable() + f.db.setConversationStoryGraphEnabled(f.conversationId, false) expect((await connection.listTools()).tools).toEqual([]) const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } }) expect(blocked.isError).toBe(true) expect(f.read).toHaveBeenCalledTimes(before) + f.db.setConversationStoryGraphEnabled(f.conversationId, true) + expect((await connection.listTools()).tools.map(tool => tool.name)).toEqual(storyGraphToolNames) + f.disable() + expect((await connection.listTools()).tools).toEqual([]) }) it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => { @@ -99,6 +105,10 @@ it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and r expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames) const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context) expect(JSON.stringify(result)).toContain(f.projectId) + f.db.setConversationStoryGraphEnabled(f.conversationId, false) + expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false) + await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable') + f.db.setConversationStoryGraphEnabled(f.conversationId, true) f.unassign() expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false) await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable') @@ -110,6 +120,6 @@ it('rejects disabled in-flight delivery and cancellation before the synchronous await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow() expect(f.read).not.toHaveBeenCalled() const other = fixture() - other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.disable(); return result }) + other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.db.setConversationStoryGraphEnabled(other.conversationId, false); return result }) await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable') }) diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index bc46d127..da6d1346 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -109,12 +109,14 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E const db = new AssistantDatabase(':memory:'); db.initialize(root) let enabled = true const projectId = db.listProjects()[0]!.id + const conversationId = crypto.randomUUID() + db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }]) const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { - available: async () => enabled, read: db.readStoryGraph.bind(db) + available: async binding => db.isConversationStoryGraphEnabled(binding.conversationId!), read: db.readStoryGraph.bind(db) } }) const signal = new AbortController().signal const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, - { projectId, runtimeTarget: 'deepseek-harness' })! + { projectId, conversationId, runtimeTarget: 'deepseek-harness' })! const inProcess = createInProcessLaunch(root, { stream(options) { const prompt = latestUserText(options) const tool = options.tools?.find(tool => tool.name === 'story_graph_search') @@ -131,7 +133,8 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E try { for (const mode of ['ask', 'execute', 'disabled'] as const) { enabled = mode !== 'disabled' - const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode, + db.setConversationStoryGraphEnabled(conversationId, enabled) + const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId, prompt: mode, workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal)) expect(events.at(-1)?.type).toBe('done') expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED') diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 3459d1ea..552d2510 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -3,7 +3,7 @@ import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } f import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools' import type { RuntimeTarget } from '../../shared/capability-contracts' -export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget } +export type StoryGraphBinding = { projectId?: string; conversationId?: string; runtimeTarget: RuntimeTarget } export type StoryGraphRemoteBinding = { available(): Promise call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise> diff --git a/src/main/agent/native-client-coordinator.test.ts b/src/main/agent/native-client-coordinator.test.ts index fa47a6fa..c06e0f57 100644 --- a/src/main/agent/native-client-coordinator.test.ts +++ b/src/main/agent/native-client-coordinator.test.ts @@ -14,9 +14,9 @@ function fixture(whenReady: () => Promise = async () => undefined) { const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions', authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' } const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile } - const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask' } + const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask', storyGraphEnabled: true } const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } } - const application = { magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } } + const application = { heartbeatEnabled: false, magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } } const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'), grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp', getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) } @@ -30,11 +30,11 @@ function fixture(whenReady: () => Promise = async () => undefined) { const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) }) const openExternal = vi.fn(async () => {}) const coordinator = new NativeClientCoordinator({ - database: { getConversation: () => conversation, getProject: () => project }, + database: { getConversation: (id: string) => ({ ...conversation, id }), getProject: () => project }, settingsStore: { getResolvedSettings: async () => settings }, applicationSettingsStore: { get: async () => application }, capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }), - getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'], + getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config', 'story-graph'], getObsidianSettings: async () => ({}) }, executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) }, terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady }, @@ -45,6 +45,17 @@ function fixture(whenReady: () => Promise = async () => undefined) { } describe('native client coordinator', () => { + it('binds story graph to its conversation and does not reuse another conversation or disabled configuration', async () => { + const { coordinator, conversation, application, gateway } = fixture() + application.heartbeatEnabled = true + await coordinator.open(1, 'first') + expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toEqual({ projectId: 'project', conversationId: 'first', runtimeTarget: 'deepseek-harness' }) + expect(await coordinator.get(1, 'second')).toBeNull() + conversation.storyGraphEnabled = false + expect(await coordinator.get(1, 'first')).toBeNull() + await coordinator.open(1, 'first') + expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toBeUndefined() + }) it('waits for the startup tool environment before launching a native client', async () => { let ready!: () => void const { coordinator, start } = fixture(() => new Promise(resolve => { ready = resolve })) diff --git a/src/main/agent/native-client-coordinator.ts b/src/main/agent/native-client-coordinator.ts index 18c43ffb..7172c161 100644 --- a/src/main/agent/native-client-coordinator.ts +++ b/src/main/agent/native-client-coordinator.ts @@ -99,7 +99,7 @@ export class NativeClientCoordinator { this.options.applicationSettingsStore.get(), this.options.capabilities.getObsidianSettings() ]) - const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, space: space.cacheIdentity, + const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, conversationId, storyGraphEnabled: conversation.storyGraphEnabled !== false, space: space.cacheIdentity, target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile : selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile, workMode, skills, mcpServers, builtin, @@ -168,7 +168,8 @@ export class NativeClientCoordinator { builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none', builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined, undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined, - builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined) + builtin.includes('story-graph') && application.heartbeatEnabled && conversation.storyGraphEnabled !== false + ? { projectId: project.id, conversationId: conversation.id, runtimeTarget: selected.target } : undefined) const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` }, readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : []) diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index f0c3122c..9fe9b3bd 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -141,6 +141,23 @@ describe('ApplicationSettingsStore', () => { await expect(store.update({ supervisionReview: { ...supervisionReview, ...patch } })).rejects.toThrow() } }) + it.each([11, 12])('persists the supervisor model reference and defaults old settings to app default in version %s', async (version) => { + const { filePath, store } = await createStore() + const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null } + delete legacy.supervisorModelProfileId + await writeFile(filePath, JSON.stringify(legacy)) + expect((await store.get()).supervisorModelProfileId).toBeNull() + const profileId = '00000000-0000-4000-8000-000000000201' + await store.update({ supervisorModelProfileId: profileId }) + await store.update({ checkUpdatesOnStartup: false }) + expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBe(profileId) + for (const value of ['', 'missing', 12, { apiKey: 'not-a-profile-reference' }]) { + await expect(store.update({ supervisorModelProfileId: value })).rejects.toThrow() + } + await store.update({ supervisorModelProfileId: null }) + expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBeNull() + }) + it.each([11, 12])('persists supervision concurrency and defaults missing values in version %s', async (version) => { const { filePath, store } = await createStore() const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null } diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index 6b1f5e49..4d918379 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -265,6 +265,7 @@ export const defaultApplicationSettings: ApplicationSettings = { heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds, supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds, supervisorModelConcurrency: defaultSupervisorModelConcurrency, + supervisorModelProfileId: null, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', @@ -545,6 +546,7 @@ export class ApplicationSettingsStore { heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency, + supervisorModelProfileId: stored.supervisorModelProfileId ?? null, supervisionReview: stored.supervisionReview, magicNotesShowIncompleteTodoCount: stored.magicNotesShowIncompleteTodoCount, @@ -593,6 +595,7 @@ export class ApplicationSettingsStore { heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency, + supervisorModelProfileId: next.supervisorModelProfileId ?? null, supervisionReview: next.supervisionReview, magicNotesShowIncompleteTodoCount: next.magicNotesShowIncompleteTodoCount, diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index ec009150..84bdab63 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -101,6 +101,27 @@ it('roundtrips canvas assets and keeps todos stable across geometry edits and co } finally { database.close() } }) +it('persists the story graph switch per conversation without schema migration or stale autosave overrides', async () => { + const database = await createDatabase() + const header = { id: randomUUID(), title: 'Graph preference', updatedAt: 1000, knowledgeRetrievalMode: 'always' as const } + const other = { ...header, id: randomUUID() } + try { + database.saveLocalConversations([{ header, messages: [] }, { header: other, messages: [] }]) + expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true) + database.setConversationStoryGraphEnabled(header.id, false) + database.saveLocalConversations([{ header, messages: [] }]) + database.close() + database.initialize('C:\\Workspace') + expect(database.getConversation(header.id)).toMatchObject({ storyGraphEnabled: false, knowledgeRetrievalMode: 'always' }) + expect(database.isConversationStoryGraphEnabled(header.id)).toBe(false) + expect(database.isConversationStoryGraphEnabled(other.id)).toBe(true) + database.setConversationStoryGraphEnabled(header.id, true) + expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true) + expect(database.isConversationStoryGraphEnabled(randomUUID())).toBe(false) + expect(() => database.setConversationStoryGraphEnabled(randomUUID(), false)).toThrow('对话不存在') + } 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 } diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index fe5b054e..0d9a4d4c 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1234,6 +1234,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({ workMode: true, knowledgeLibraryIds: true, knowledgeRetrievalMode: true, + storyGraphEnabled: true, contextMetrics: true, contextCompressionState: true }) @@ -1242,7 +1243,7 @@ function parseConversationContextState( value: string | null ): Pick< ConversationSnapshot, - 'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState' + 'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState' > { if (!value) { return {} @@ -1260,18 +1261,20 @@ function parseConversationContextState( function serializeConversationContextState( conversation: Pick< ConversationSnapshot, - 'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState' + 'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState' > ): string | null { return conversation.workMode !== undefined || conversation.knowledgeLibraryIds !== undefined || conversation.knowledgeRetrievalMode !== undefined || + conversation.storyGraphEnabled !== undefined || conversation.contextMetrics || conversation.contextCompressionState ? JSON.stringify({ workMode: conversation.workMode, knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, + storyGraphEnabled: conversation.storyGraphEnabled, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState }) @@ -3194,6 +3197,22 @@ export class AssistantDatabase { } } + isConversationStoryGraphEnabled(conversationId: string): boolean { + const row = this.requireDatabase().prepare( + "SELECT context_state_json FROM conversations WHERE id = ? AND status = 'active'" + ).get(conversationId) as { context_state_json: string | null } | undefined + return Boolean(row && parseConversationContextState(row.context_state_json).storyGraphEnabled !== false) + } + + setConversationStoryGraphEnabled(conversationId: string, enabled: boolean): void { + const result = this.requireDatabase().prepare( + `UPDATE conversations + SET context_state_json = json_set(COALESCE(context_state_json, '{}'), '$.storyGraphEnabled', json(?)) + WHERE id = ? AND status = 'active'` + ).run(JSON.stringify(enabled), conversationId) + if (result.changes !== 1) throw new Error('对话不存在') + } + /** * Clears references to deleted model connections in every project and * conversation so they fall back to the next layer instead of failing. @@ -3324,7 +3343,7 @@ export class AssistantDatabase { saveLocalConversations(batch: LocalConversationSaveBatch): void { const database = this.requireDatabase() const findConversation = database.prepare( - 'SELECT channel FROM conversations WHERE id = ?' + 'SELECT channel, context_state_json FROM conversations WHERE id = ?' ) const insertConversation = database.prepare( `INSERT INTO conversations @@ -3376,7 +3395,7 @@ export class AssistantDatabase { const { header } = save const existingConversation = findConversation.get( header.id - ) as { channel: ProjectChannel | null } | undefined + ) as { channel: ProjectChannel | null; context_state_json: string | null } | undefined const updatedAt = new Date(header.updatedAt).toISOString() if (existingConversation?.channel) { throw new Error('本地对话 ID 与远程对话冲突') @@ -3386,7 +3405,9 @@ export class AssistantDatabase { header.projectId ?? null, serializeRuntimeSelection(header.runtimeSelection), header.knowledgeRetrievalMode ?? null, - serializeConversationContextState(header), + // The explicit switch owns this preference; a queued older header must not undo it. + serializeConversationContextState({ ...header, + storyGraphEnabled: parseConversationContextState(existingConversation.context_state_json).storyGraphEnabled }), header.title, header.branch?.sourceConversationId ?? null, header.branch?.sourceTitle ?? null, diff --git a/src/main/assistant/supervision-production.test.ts b/src/main/assistant/supervision-production.test.ts index 30d3383d..dfbe0262 100644 --- a/src/main/assistant/supervision-production.test.ts +++ b/src/main/assistant/supervision-production.test.ts @@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import type { AgentRuntime } from '../agent/runtime' import type { SupervisionEvidence, SupervisionRunRequest } from '../../shared/supervision-contracts' import { AssistantDatabase } from './assistant-database' -import { createProductionSupervisorService } from './supervision-production' +import { createProductionSupervisorService, createProductionSuggestionPhraser } from './supervision-production' import { SupervisionModelPool } from './supervision-model-pool' import { supervisionEntitySchema } from '../../shared/supervision-contracts' @@ -12,6 +12,88 @@ const cleanups: Array<() => void> = [] afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() }) const empty = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } +it('keeps one model across extraction, navigation, stories and experiences, and reads the next selection on retry', async () => { + const f = fixture('A'.repeat(1500)) + let profileId: string | null = 'first' + const phases: string[] = [] + const dispose = vi.fn(async () => {}) + const resolve = vi.fn(async (selected?: string | null) => ({ + runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose, + run: async function* (input) { + expect(selected).toBe('first') + profileId = 'next' + let output: unknown + if (input.prompt.includes('You organise the GoodBuddy')) { + phases.push('stories') + output = { stories: [{ key: 'new_1', level: 'feature', name: 'Work' }], + assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_1' }] } + } else if (input.prompt.includes('You distil reusable experience')) { + phases.push('experiences') + output = { experiences: [], applications: [] } + } else if (input.prompt.includes('These inputs are navigation summaries')) { + phases.push('navigation') + output = { summary: 'Combined', changeDigest: '', openItems: [] } + } else { + phases.push('extraction') + const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as SupervisionEvidence[] + output = { ...empty, events: [{ title: 'Decision', description: '', eventType: 'decision', + occurredAt: f.request.timeRange.from, entityIds: [], sourceReferenceIds: [evidence[0]!.id] }] } + } + yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(output) } + yield { type: 'done', requestId: input.requestId } + } + } as AgentRuntime)) + const pool = new SupervisionModelPool() + cleanups.push(() => pool.dispose()) + const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId, + supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, experienceMinEvents: 2 } }), resolve, pool) + const result = await service.run(f.request) + expect(phases).toEqual(['extraction', 'extraction', 'navigation', 'stories', 'experiences']) + expect(resolve).toHaveBeenCalledExactlyOnceWith('first') + expect(dispose).toHaveBeenCalledOnce() + await service.organizeStoriesFor(result.runId!) + expect(resolve).toHaveBeenLastCalledWith('next') + expect(dispose).toHaveBeenCalledTimes(2) +}) + +it('releases a failed review runtime and uses the current model when continuing saved work', async () => { + const f = fixture() + let profileId: string | null = 'first' + const dispose = vi.fn(async () => {}) + const resolve = vi.fn(async (selected?: string | null) => ({ runtimeId: 'model', capability: 'chat', + requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose, + run: async function* (input) { + if (selected === 'first') throw new Error('Provider unavailable') + yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(empty) } + yield { type: 'done', requestId: input.requestId } + } + } as AgentRuntime)) + const pool = new SupervisionModelPool() + cleanups.push(() => pool.dispose()) + const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId }), resolve, pool) + await expect(service.run(f.request)).rejects.toThrow('Provider unavailable') + expect(dispose).toHaveBeenCalledOnce() + expect(service.execution().active).toBe(false) + profileId = null + await expect(service.resume(f.db.listSupervisionActivity()[0]!.id)).resolves.toMatchObject({ status: 'completed' }) + expect(resolve.mock.calls).toEqual([['first'], [null]]) + expect(dispose).toHaveBeenCalledTimes(2) +}) + +it('uses the supervisor selection for suggestions and disposes its runtime on failure', async () => { + const f = fixture() + const dispose = vi.fn(async () => {}) + const resolve = vi.fn(async () => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose, + run: async function* (input) { yield { type: 'error', requestId: input.requestId, message: 'Provider unavailable' } } + } as AgentRuntime)) + const pool = new SupervisionModelPool() + cleanups.push(() => pool.dispose()) + const phrase = createProductionSuggestionPhraser(f.db, async () => ({ supervisorModelProfileId: 'reviewer' }), resolve, pool) + await expect(phrase({ systemInstruction: 'Suggest', outputContract: '{}', candidates: [] })).rejects.toThrow('Provider unavailable') + expect(resolve).toHaveBeenCalledExactlyOnceWith('reviewer') + expect(dispose).toHaveBeenCalledOnce() +}) + it('production six-leaf merge failure resumes navigation-only JSON and publishes all retained facts', async () => { const f = fixture('A'.repeat(765)) const now = Date.parse(f.request.timeRange.from) + 1 diff --git a/src/main/assistant/supervision-production.ts b/src/main/assistant/supervision-production.ts index 0a632609..2b236cc0 100644 --- a/src/main/assistant/supervision-production.ts +++ b/src/main/assistant/supervision-production.ts @@ -85,7 +85,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r function dependencies( database: AssistantDatabase, getSettings: () => Promise | undefined>, - resolveRuntime: () => Promise, + resolveRuntime: (profileId?: string | null) => Promise, pool: SupervisionModelPool, persistUsage?: (event: RuntimeModelUsageEvent) => void ): SupervisionModelDependencies { @@ -98,11 +98,16 @@ function dependencies( export function createProductionSupervisorService( database: AssistantDatabase, getSettings: () => Promise | undefined>, - resolveRuntime: () => Promise, + resolveRuntime: (profileId?: string | null) => Promise, pool: SupervisionModelPool, persistUsage?: (event: RuntimeModelUsageEvent) => void ): SupervisorService { - const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage) + // The service admits only one execution at a time, including resume and story retry. + let runtime: AgentRuntime | undefined + const model = dependencies(database, getSettings, async () => { + if (!runtime) throw new Error('Supervisor model execution is not active') + return runtime + }, pool, persistUsage) return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, { summarize: async request => runSupervisionModel(model, { title: '监督者回顾', instructions: '根据有界证据整理监督者回顾', @@ -122,7 +127,15 @@ export function createProductionSupervisorService( start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId), fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId), candidates: async request => database.listSupervisionCandidates(request), save: async result => database.saveSupervisionResult(result) - }, { database: () => database.supervisionReviewStore(), configuration: async () => { + }, { database: () => database.supervisionReviewStore(), withExecution: async operation => { + runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId) + try { return await operation() } + finally { + const completed = runtime + runtime = undefined + await completed.dispose?.() + } + }, configuration: async () => { const settings = await getSettings() return { ...supervisionReviewSettingsSchema.parse(settings?.supervisionReview ?? {}), version: 1, timeoutSeconds: settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, @@ -163,16 +176,21 @@ async function assignStoriesStep(model: SupervisionModelDependencies, request: S export function createProductionSuggestionPhraser( database: AssistantDatabase, getSettings: () => Promise | undefined>, - resolveRuntime: () => Promise, + resolveRuntime: (profileId?: string | null) => Promise, pool: SupervisionModelPool, persistUsage?: (event: RuntimeModelUsageEvent) => void ): SuggestionPhraser { - const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage) - return async request => runSupervisionModel(model, { - title: '监督者建议', instructions: '根据已发布的图谱变化生成建议', - timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`, - authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) }, - prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract, - 'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n') - }) + return async request => { + const runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId) + const model = dependencies(database, getSettings, async () => runtime, pool, persistUsage) + try { + return await runSupervisionModel(model, { + title: '监督者建议', instructions: '根据已发布的图谱变化生成建议', + timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`, + authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) }, + prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract, + 'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n') + }) + } finally { await runtime.dispose?.() } + } } diff --git a/src/main/assistant/supervisor-service.ts b/src/main/assistant/supervisor-service.ts index 8acaeb73..9b8af4d2 100644 --- a/src/main/assistant/supervisor-service.ts +++ b/src/main/assistant/supervisor-service.ts @@ -186,6 +186,7 @@ export class SupervisorService { private readonly review?: { database: () => SupervisionReviewStore configuration: () => Promise + withExecution?: (operation: () => Promise) => Promise /** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */ stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise> } @@ -234,7 +235,7 @@ export class SupervisorService { await this.active.settled.catch(() => undefined) } // Reserve before any asynchronous configuration, collection or resume work. - const active = { settled: Promise.resolve().then(operation) } + const active = { settled: Promise.resolve().then(() => this.review?.withExecution ? this.review.withExecution(operation) : operation()) } this.active = active try { return await active.settled } finally { if (this.active === active) this.active = undefined } diff --git a/src/main/index.ts b/src/main/index.ts index d4840b0f..52695698 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1057,7 +1057,8 @@ if (hasSingleInstanceLock) { { observeFailure: observeDesktopFailure, storyGraphService: { - available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true && + available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true && + (!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) && (await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'), read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal) }, @@ -1540,7 +1541,8 @@ if (hasSingleInstanceLock) { createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, { observeFailure: observeDesktopFailure, storyGraphService: { - available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true && + available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true && + (!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) && (await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'), read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal) }, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index af54a1de..7a7ec948 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type {} from '@testing-library/jest-dom/vitest' import { EventEmitter } from 'node:events' +import { createServer } from 'node:http' import { mkdtemp, mkdir, @@ -21,6 +22,7 @@ import type { } from '../shared/assistant-contracts' import { browserTabIdSchema, + defaultModelProfileId, defaultRuntimeSettings, runtimeSettingsInputSchema, type AgentEvent, @@ -4684,6 +4686,16 @@ describe('registerIpcHandlers local conversation persistence', () => { expect(webContents.send).not.toHaveBeenCalled() expect(database.getConversation(conversationId)).toEqual(before) + const setStoryGraph = electronMocks.handlers.get(ipcChannels.conversationsSetStoryGraph)! + expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(true) + for (const enabled of [false, true]) { + setStoryGraph(event, { conversationId, enabled }) + expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(enabled) + expect(database.getConversation(conversationId)).toEqual({ ...before, storyGraphEnabled: enabled }) + } + expect(() => setStoryGraph(event, { conversationId, enabled: 'false' })).toThrow() + expect(() => setStoryGraph({ ...event, sender: {} }, { conversationId, enabled: false })).toThrow() + expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true) expect(collect).toHaveBeenCalledOnce() expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce() @@ -5509,6 +5521,8 @@ describe('registerIpcHandlers agent terminal state', () => { runtimeSelection: { provider: 'opencode' }, messages: [] })), + isConversationStoryGraphEnabled: vi.fn(() => true), + setConversationStoryGraphEnabled: vi.fn(), getProject: vi.fn< (projectId: string) => Record >((projectId) => ({ @@ -5559,6 +5573,8 @@ describe('registerIpcHandlers agent terminal state', () => { } const getResolvedSettings = vi.fn( async (): Promise> => ({ + modelProfiles: [{ id: defaultModelProfileId, name: 'Default', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none' }], + defaultModelProfileId, toolApproval, subagentSmartRoutingEnabled: smartRoutingEnabled }) @@ -5634,6 +5650,7 @@ describe('registerIpcHandlers agent terminal state', () => { args[45] = nativeClientCoordinator args[41] = nativeTerminalManager if (applicationSettingsStore) args[15] = applicationSettingsStore + runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime) const dispose = registerIpcHandlers(...args) return { approvalBroker, @@ -6396,6 +6413,57 @@ describe('registerIpcHandlers agent terminal state', () => { } finally { await harness.dispose(); database.close() } }) + it('routes supervisor model selection through the real direct runtime to a saved connection', async () => { + const calls: Array<{ model: string; tools?: unknown; temperature?: number }> = [] + const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) body += String(chunk) + calls.push(JSON.parse(body)) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: JSON.stringify({ summary: 'Connection verified', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as { port: number } + const database = new AssistantDatabase(':memory:') + database.initialize(process.cwd()) + const now = Date.now() + database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Review', updatedAt: now }, + messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'A saved decision', createdAt: now }] }]) + const harness = createHarness({ runtimeId: 'opencode', capability: 'chat', run: vi.fn() }, undefined, 'always', undefined, false, + undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database) + const actual = await vi.importActual('./agent/create-runtime') + runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime) + const profile = { id: crypto.randomUUID(), name: 'Review connection', + baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none', + requestHeaders: {}, requestBody: { temperature: 0.25 } } + const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'app-default' } + harness.getResolvedSettings.mockResolvedValue({ ...defaultRuntimeSettings, provider: 'opencode', modelProfiles: [profile, fallback], defaultModelProfileId: fallback.id }) + const handler = electronMocks.handlers.get(ipcChannels.supervisionRun)! + const input = { trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: { + from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString() + } } + try { + for (const supervisorModelProfileId of [profile.id, null]) { + harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId }) + await expect(handler(trustedEvent(harness.webContents), input)).resolves.toMatchObject({ status: 'completed' }) + } + expect(calls.map(call => call.model)).toEqual(['review-selected', 'app-default']) + expect(calls.every(call => !call.tools && call.temperature === 0.25)).toBe(true) + for (const invalid of [undefined, { ...profile, protocol: 'openai-images-generations' }, { ...profile, authentication: 'api-key', apiKey: undefined }]) { + harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId: profile.id }) + harness.getResolvedSettings.mockResolvedValue({ modelProfiles: invalid ? [invalid, fallback] : [fallback], defaultModelProfileId: fallback.id }) + await expect(handler(trustedEvent(harness.webContents), input)).rejects.toThrow(/不可用|API Key/) + } + expect(calls).toHaveLength(2) + } finally { + await harness.dispose() + database.close() + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + runtimeFactoryMocks.createModelProfileRuntime.mockReset() + } + }) + it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z')) @@ -9549,16 +9617,18 @@ describe('registerIpcHandlers agent terminal state', () => { }) try { const projectId = '00000000-0000-4000-8000-000000000088' - for (const enabled of [true, false]) { + for (const [enabled, conversationEnabled] of [[true, true], [false, true], [true, false]]) { harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled }) + harness.assistantDatabase.isConversationStoryGraphEnabled.mockReturnValue(conversationEnabled!) const requestId = crypto.randomUUID() await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')) } expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none', - undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: runtimeId }) + undefined, undefined, undefined, undefined, undefined, { projectId, conversationId: 'graph-grant', runtimeTarget: runtimeId }) expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding }) expect(received[1]).not.toHaveProperty('storyGraphBinding') + expect(received[2]).not.toHaveProperty('storyGraphBinding') expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce() } finally { await harness.dispose() } }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f33530cc..7e7d2f1c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -137,6 +137,7 @@ import { conversationListRequestSchema, conversationSearchRequestSchema, conversationSetPinnedSchema, + conversationSetStoryGraphSchema, type ConversationSnapshot, localConversationSaveBatchSchema, memoryCreateSchema, @@ -2228,8 +2229,9 @@ export function registerIpcHandlers( ) : [] const notesCapability = await grantScopedDataCapability({ - storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled - ? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined } : undefined, + storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled && + assistantDatabase.isConversationStoryGraphEnabled(runtimeConversationId) + ? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined, conversationId: runtimeConversationId } : undefined, obsidian: enabledBuiltinMcpServers.includes('obsidian') ? { settings: await capabilityService.getObsidianSettings(), @@ -3761,8 +3763,9 @@ export function registerIpcHandlers( const imageSaveAvailable = Boolean(imageToolBinding?.save && normalizedWorkMode === 'execute' && await imageToolBinding.describeSave?.()) const scopedCapability = await grantScopedDataCapability({ - storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled - ? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId } : undefined, + storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled && + assistantDatabase.isConversationStoryGraphEnabled(enrichedRequest.conversationId) + ? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId, conversationId: enrichedRequest.conversationId } : undefined, obsidian: enabledBuiltinMcpServers.includes('obsidian') ? { settings: await capabilityService.getObsidianSettings(), @@ -6383,11 +6386,31 @@ export function registerIpcHandlers( publishConversationChange() } ) + registerHandler( + ipcChannels.conversationsSetStoryGraph, + (event, input: unknown) => { + assertTrustedSender(event, window) + const { conversationId, enabled } = conversationSetStoryGraphSchema.parse(input) + assistantDatabase.setConversationStoryGraphEnabled(conversationId, enabled) + publishConversationChange() + } + ) + const resolveSupervisorRuntime = async (profileId?: string | null): Promise => { + const settings = await settingsStore.getResolvedSettings() + const profile = settings.modelProfiles.find(candidate => candidate.id === (profileId ?? settings.defaultModelProfileId)) + if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) { + throw new Error('监督者所选文本模型不存在或已不可用,请在监督者设置中重新选择模型。') + } + if (profile.authentication === 'api-key' && !profile.apiKey) { + throw new Error(`监督者模型连接“${profile.name}”未配置 API Key`) + } + return createModelProfileRuntime(settings.workspacePath, settings, profile) + } const supervisorService = createProductionSupervisorService(assistantDatabase, - async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool, + async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool, persistModelUsage) const suggestionPhraser = createProductionSuggestionPhraser(assistantDatabase, - async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool, + async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool, persistModelUsage) registerHandler( diff --git a/src/preload/index.ts b/src/preload/index.ts index 3346ba44..4202a3da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1135,6 +1135,9 @@ const desktopApi: DesktopApi = { setPinned: async (input) => { await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input) }, + setStoryGraph: async (input) => { + await ipcRenderer.invoke(ipcChannels.conversationsSetStoryGraph, input) + }, branchLocal: (input: ConversationBranchInput) => ipcRenderer.invoke( ipcChannels.conversationsBranchLocal, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index b069be69..8477e363 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -586,6 +586,7 @@ const api: DesktopApi & RuntimeNativeClientApi = { replace: vi.fn(async () => {}), saveLocal: vi.fn(async () => {}), setPinned: vi.fn(async () => {}), + setStoryGraph: vi.fn(async () => {}), branchLocal: vi.fn(async (input) => ({ id: crypto.randomUUID(), branch: { @@ -8544,6 +8545,33 @@ describe("App", () => { ).not.toBeInTheDocument(); }); + it("saves the story graph switch through the conversation API and restores it on reload", async () => { + installRemoteProjectsSetting(false); + const id = "00000000-0000-4000-8000-000000000311"; + const saved = { id, projectId: project.id, title: "Story preference", updatedAt: 1, messages: [] }; + vi.mocked(api.conversations.list).mockResolvedValue([saved]); + const pending = deferred(); + vi.mocked(api.conversations.setStoryGraph).mockImplementationOnce(() => pending.promise); + const view = render(); + await screen.findByLabelText("向 GoodBuddy 提问"); + openComposerOptions(); + const toggle = await screen.findByRole("switch", { name: "使用故事图谱" }); + expect(toggle).toBeChecked(); + fireEvent.click(toggle); + await waitFor(() => expect(api.conversations.setStoryGraph).toHaveBeenCalledWith({ conversationId: id, enabled: false })); + expect(toggle).toBeChecked(); + expect(toggle).toBeDisabled(); + await act(async () => pending.resolve()); + await waitFor(() => { expect(toggle).not.toBeChecked(); expect(toggle).toBeEnabled(); }); + view.unmount(); + vi.mocked(api.conversations.list).mockResolvedValue([{ ...saved, storyGraphEnabled: false }]); + render(); + await screen.findByLabelText("向 GoodBuddy 提问"); + openComposerOptions(); + expect(await screen.findByRole("switch", { name: "使用故事图谱" })).not.toBeChecked(); + expect(api.agent.run).not.toHaveBeenCalled(); + }); + it("groups composer tools and exposes clear control descriptions", async () => { render(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0f826588..e2fdc4d9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -853,6 +853,7 @@ function toConversationSnapshots( workMode: conversation.workMode, knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, + storyGraphEnabled: conversation.storyGraphEnabled, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, ...(conversation.branch ? { branch: conversation.branch } : {}), @@ -6666,6 +6667,16 @@ function App(): React.JSX.Element { openImageViewer, openModelSettings: openImageModelSettings, setEnabledKnowledgeLibraryIds, + setStoryGraphEnabled: async (conversationId, enabled) => { + persistLocalConversationChanges(); + await conversationPersistence.enqueue(async () => { + await window.goodbuddy.conversations.setStoryGraph({ conversationId, enabled }); + setConversations((current) => current.map((conversation) => + conversation.id === conversationId + ? { ...conversation, storyGraphEnabled: enabled, updatedAt: Date.now() } + : conversation)); + }); + }, setKnowledgeRetrievalMode: (mode) => setConversations((current) => current.map((conversation) => @@ -7463,6 +7474,7 @@ function App(): React.JSX.Element { selectedRuntimeAgent={selectedRuntimeAgent} selectedRuntimeCommand={selectedRuntimeCommand} selectingContextFiles={selectingContextFiles} + supervisorEnabled={applicationSettings?.heartbeatEnabled === true} updateAttachmentBusy={updateAttachmentBusy} voiceListening={voiceListening} voiceRecording={voiceRecording} @@ -7569,6 +7581,7 @@ function App(): React.JSX.Element { } > {isApplicationEnabled(applicationSettings, 'heartbeat') ? { }); describe("Composer render boundary", () => { + it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => { + const { props, rerender, store } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Options" })); + expect(screen.queryByRole("switch", { name: "Use story graph" })).toBeNull(); + let finish!: () => void; + const save = vi.fn(() => new Promise(resolve => { finish = resolve; })); + const notify = vi.fn(); + const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } }; + rerender(); + const toggle = screen.getByRole("switch", { name: "Use story graph" }); + expect(toggle).toBeChecked(); + expect(toggle.closest("label")).toHaveClass("toggle-row"); + fireEvent.click(toggle); + expect(save).toHaveBeenCalledWith("a", false); + expect(toggle).toBeChecked(); + expect(toggle).toBeDisabled(); + await act(async () => { + store.set(current => current.map(item => item.id === "a" ? { ...item, storyGraphEnabled: false } : item)); + finish(); + }); + await waitFor(() => { expect(toggle).not.toBeChecked(); expect(toggle).toBeEnabled(); }); + save.mockRejectedValueOnce(new Error("Save failed")); + fireEvent.click(toggle); + await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); }); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Options" })); + expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked(); + rerender(); + expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull(); + }); + it("does not re-render when another conversation changes", () => { const { renders, store } = setup(); act(() => store.set((current) => current.map((item) => diff --git a/src/renderer/src/Composer.tsx b/src/renderer/src/Composer.tsx index 3d06f6d5..5c040e71 100644 --- a/src/renderer/src/Composer.tsx +++ b/src/renderer/src/Composer.tsx @@ -22,6 +22,7 @@ import { useEffect, useMemo, useRef, + useState, type RefObject, } from "react"; import { useTranslation } from "react-i18next"; @@ -127,6 +128,7 @@ export type ComposerProps = { selectedRuntimeAgent: string; selectedRuntimeCommand: string; selectingContextFiles: boolean; + supervisorEnabled?: boolean; updateAttachmentBusy: (busy: boolean) => void; voiceListening: boolean; voiceRecording: boolean; @@ -194,6 +196,7 @@ export const Composer = memo(function Composer({ selectedRuntimeAgent, selectedRuntimeCommand, selectingContextFiles, + supervisorEnabled, updateAttachmentBusy, voiceListening, voiceRecording, @@ -202,6 +205,7 @@ export const Composer = memo(function Composer({ }: ComposerProps): React.JSX.Element { const { t } = useTranslation("app"); const view = useComposerConversationView(conversationStore, conversationId); + const [storyGraphSaving, setStoryGraphSaving] = useState(false); const isRunning = view?.running ?? false; // Popups belong to one conversation, run state and view, like the former // reset in App: changing any of them shows every popup closed. @@ -329,7 +333,7 @@ export const Composer = memo(function Composer({ } }; const frame = requestAnimationFrame(() => { - composerOptionsRef.current?.querySelector("button:not(:disabled)")?.focus(); + composerOptionsRef.current?.querySelector("input:not(:disabled), button:not(:disabled)")?.focus(); }); document.addEventListener("pointerdown", dismissOutside); document.addEventListener("focusin", dismissOutside); @@ -858,6 +862,23 @@ export const Composer = memo(function Composer({ }} > {t("composer.settings")} + {supervisorEnabled && ( + + )} {knowledgeLibraries.length > 0 && (
{ view.rerender() expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled() }) + it.each(['zh-CN', 'en-US'])('saves an existing text model reference, retains failed drafts, and restores app default (%s)', async language => { + await i18n.changeLanguage(language) + const t = (key: string) => i18n.t(key, { ns: 'heartbeat' }) + const profileId = '00000000-0000-4000-8000-000000000201' + const applicationSettings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false }) + const runtimeSettings = { modelProfiles: [ + { id: profileId, name: 'Reviewer', modelName: 'review-model', protocol: 'openai-chat-completions' }, + { id: 'image', name: 'Image only', modelName: 'image-model', protocol: 'openai-images-generations' } + ] } as RuntimeSettings + const onUpdateApplicationSettings = vi.fn(async () => false) + const props = createProps({ applicationSettings, runtimeSettings, onUpdateApplicationSettings }) + const view = render(, false) + fireEvent.click(screen.getByRole('tab', { name: t('supervisor.settings') })) + const selector = screen.getByRole('combobox', { name: t('timeouts.model') }) + expect(selector).toHaveValue('') + expect(within(selector).queryByText(/Image only/)).not.toBeInTheDocument() + fireEvent.change(selector, { target: { value: profileId } }) + fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') })) + await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenCalledExactlyOnceWith({ + supervisorModelProfileId: profileId, supervisorOrganizeTimeoutSeconds: 240, supervisorModelConcurrency: 1 + })) + view.rerender() + expect(selector).toHaveValue(profileId) + view.rerender() + expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled() + view.rerender() + expect(within(selector).getByRole('option', { name: t('timeouts.unavailableModel') })).toBeDisabled() + fireEvent.change(selector, { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') })) + await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenLastCalledWith({ + supervisorModelProfileId: null, supervisorOrganizeTimeoutSeconds: 240, supervisorModelConcurrency: 1 + })) + }) + it.each(['zh-CN', 'en-US'])('opens complete automatic supervision settings without creating defaults (%s)', async (language) => { await i18n.changeLanguage(language) const props = createProps({ configs: [], runs: [], entries: [] }) diff --git a/src/renderer/src/HeartbeatCenter.tsx b/src/renderer/src/HeartbeatCenter.tsx index d84f449c..462fccaf 100644 --- a/src/renderer/src/HeartbeatCenter.tsx +++ b/src/renderer/src/HeartbeatCenter.tsx @@ -18,6 +18,7 @@ import type { HeartbeatUpdateInput } from '../../shared/assistant-contracts' import { HeartbeatSettings } from './HeartbeatSettings' +import { isAgentRuntimeModelProtocol, type RuntimeSettings } from '../../shared/contracts' import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts' import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings' import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace' @@ -34,6 +35,7 @@ import { } from './WorkspacePrimitives' export type HeartbeatCenterProps = { + runtimeSettings?: RuntimeSettings applicationSettings?: ApplicationSettings applicationSettingsPending?: boolean applicationSettingsLocked?: boolean @@ -200,24 +202,37 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen const { t } = useTranslation('heartbeat') const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds const concurrency = props.applicationSettings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency + const profileId = props.applicationSettings?.supervisorModelProfileId ?? '' + const profiles = useMemo(() => props.runtimeSettings?.modelProfiles.filter(profile => isAgentRuntimeModelProtocol(profile.protocol)) ?? [], [props.runtimeSettings]) + const [model, setModel] = useState(profileId) const [parallel, setParallel] = useState(String(concurrency)) const [organize, setOrganize] = useState(String(supervisor)) - const [saved, setSaved] = useState({ supervisor, concurrency }) - if (saved.supervisor !== supervisor || saved.concurrency !== concurrency) { - setSaved({ supervisor, concurrency }) + const [saved, setSaved] = useState({ supervisor, concurrency, profileId }) + if (saved.supervisor !== supervisor || saved.concurrency !== concurrency || saved.profileId !== profileId) { + setSaved({ supervisor, concurrency, profileId }) + setModel(profileId) setParallel(String(concurrency)) setOrganize(String(supervisor)) } - useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency)) + useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency) || model !== profileId) const valid = supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success && supervisorModelConcurrencySchema.safeParse(Number(parallel)).success const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings return
{ event.preventDefault() if (!valid || disabled) return - void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel) }) + void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel), + ...(model !== profileId ? { supervisorModelProfileId: model || null } : {}) }) }}>

{t('timeouts.title')}

+ +

{t('timeouts.modelHelp')}

{t('timeouts.help')}

} - + } function HeartbeatSections({ diff --git a/src/renderer/src/conversation-persistence.ts b/src/renderer/src/conversation-persistence.ts index fdb136b2..1943cd5b 100644 --- a/src/renderer/src/conversation-persistence.ts +++ b/src/renderer/src/conversation-persistence.ts @@ -61,6 +61,7 @@ export function toLocalConversationHeader( workMode: conversation.workMode, knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, + storyGraphEnabled: conversation.storyGraphEnabled, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, ...(conversation.branch ? { branch: conversation.branch } : {}), diff --git a/src/renderer/src/conversation-selectors.ts b/src/renderer/src/conversation-selectors.ts index c44327f3..40d86bea 100644 --- a/src/renderer/src/conversation-selectors.ts +++ b/src/renderer/src/conversation-selectors.ts @@ -250,7 +250,7 @@ export function useActiveConversationView( */ export type ComposerConversationView = Pick< Conversation, - | "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode" + | "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode" | "storyGraphEnabled" | "remote" | "contextMetrics" | "contextCompressionState" > & { running: boolean }; @@ -263,6 +263,7 @@ export function selectComposerConversationView( runtimeSelection: conversation.runtimeSelection, knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, + storyGraphEnabled: conversation.storyGraphEnabled, remote: conversation.remote, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, @@ -277,6 +278,7 @@ export function sameComposerConversationView( if (left === right) return true; if (!left || !right) return false; return left.id === right.id && left.running === right.running && + left.storyGraphEnabled === right.storyGraphEnabled && left.knowledgeRetrievalMode === right.knowledgeRetrievalMode && sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) && (left.knowledgeLibraryIds === undefined) === (right.knowledgeLibraryIds === undefined) && diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 0e2c1bbf..0d42872e 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -563,6 +563,7 @@ export const app = { }, removeAttachment: 'Remove {{name}}', settings: 'Conversation settings', + storyGraph: { label: 'Use story graph' }, options: 'Options', expertLabel: 'Expert role', modeLabel: 'Work mode', diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts index 0fd9c7f6..9cc2d8e4 100644 --- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts +++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts @@ -43,6 +43,10 @@ export const heartbeat = { dismiss: 'Dismiss' }, timeouts: { + model: 'Model', + followDefault: 'Follow app default', + unavailableModel: 'Selected model is unavailable', + modelHelp: 'Uses an existing text model for reviews, summaries, stories, experiences, and suggestions. Saving affects the next execution, including continue or retry; a review already running keeps its model.', title: 'Model timeouts and concurrency', report: 'Heartbeat report timeout (seconds)', organize: 'Supervisor organize timeout (seconds)', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 56348d3d..5e51c04f 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -543,6 +543,7 @@ export const app = { }, removeAttachment: '移除 {{name}}', settings: '对话设置', + storyGraph: { label: '使用故事图谱' }, options: '选项', expertLabel: '专家角色', modeLabel: '工作模式', diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts index c05f4c11..f70465bd 100644 --- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts +++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts @@ -40,6 +40,10 @@ export const heartbeat = { dismiss: '忽略' }, timeouts: { + model: '模型', + followDefault: '跟随应用默认', + unavailableModel: '所选模型已不可用', + modelHelp: '复用已有文本模型,用于回顾、汇总、故事、经验和建议。保存后从下次执行生效,包括继续或重试;正在运行的回顾保持原模型。', title: '模型超时与并发', report: '心跳报告超时(秒)', organize: '监督者整理超时(秒)', diff --git a/src/renderer/src/use-composer-actions.ts b/src/renderer/src/use-composer-actions.ts index c47daa1e..3c0e2c07 100644 --- a/src/renderer/src/use-composer-actions.ts +++ b/src/renderer/src/use-composer-actions.ts @@ -36,6 +36,7 @@ export type ComposerActions = { openModelSettings: () => void; setEnabledKnowledgeLibraryIds: (update: SetStateAction) => void; setKnowledgeRetrievalMode: (mode: "auto" | "always") => void; + setStoryGraphEnabled: (conversationId: string, enabled: boolean) => Promise; selectExpert: (expertId: string) => void; selectRuntimeAgent: (agentId: string) => void; selectContinuePreset: (presetId: string) => void; diff --git a/src/shared/application-settings-contracts.ts b/src/shared/application-settings-contracts.ts index 25be956e..0dd91638 100644 --- a/src/shared/application-settings-contracts.ts +++ b/src/shared/application-settings-contracts.ts @@ -73,6 +73,7 @@ const applicationPreferencesSchema = z heartbeatReportTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(), supervisorOrganizeTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(), supervisorModelConcurrency: supervisorModelConcurrencySchema.optional(), + supervisorModelProfileId: z.string().uuid().nullable().optional(), supervisionReview: supervisionReviewSettingsSchema.optional(), magicNotesShowIncompleteTodoCount: z.boolean().default(true), magicNoteCommentMode: magicNoteCommentModeSchema.default('immediate'), diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index fff5e0a7..829b9147 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -620,6 +620,7 @@ export const conversationSnapshotSchema = z knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(), workMode: workModeSchema.optional(), knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(), + storyGraphEnabled: z.boolean().optional(), contextMetrics: conversationContextMetricsSchema.optional(), contextCompressionState: conversationContextCompressionStateSchema.optional(), @@ -671,6 +672,11 @@ export const conversationSetPinnedSchema = z.object({ pinned: z.boolean() }).strict() +export const conversationSetStoryGraphSchema = z.object({ + conversationId: assistantIdSchema, + enabled: z.boolean() +}).strict() + export type ConversationSetPinnedInput = z.infer< typeof conversationSetPinnedSchema > diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index e06201ee..6efff2a9 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -2100,6 +2100,7 @@ export type DesktopApi = { replace: (conversations: ConversationSnapshot[]) => Promise saveLocal: (batch: LocalConversationSaveBatch) => Promise setPinned: (input: ConversationSetPinnedInput) => Promise + setStoryGraph: (input: { conversationId: string; enabled: boolean }) => Promise branchLocal: ( input: ConversationBranchInput ) => Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index ffdd5201..a51b64b5 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -228,6 +228,7 @@ export const ipcChannels = { conversationsReplace: 'conversations:replace', conversationsSaveLocal: 'conversations:save-local', conversationsSetPinned: 'conversations:set-pinned', + conversationsSetStoryGraph: 'conversations:set-story-graph', conversationsBranchLocal: 'conversations:branch-local', conversationsDeleteLocal: 'conversations:delete-local', conversationsChanged: 'conversations:changed', diff --git a/tests/overlay-layering.electron.test.ts b/tests/overlay-layering.electron.test.ts index 2315bba7..647dd29e 100644 --- a/tests/overlay-layering.electron.test.ts +++ b/tests/overlay-layering.electron.test.ts @@ -40,6 +40,7 @@ it('keeps menus and notifications interactive above real Electron modals', async }, 120000) it.each([ + ['saves the conversation story graph switch from App through production IPC and revokes existing tools', 'story-graph-switch-main.ts'], ['opens the MCP editor from App settings with native browser isolation and real persistence', 'mcp-overlay-main.ts'], ['keeps nested previews, sandbox keyboard, project modals and native overlays usable', 'overlay-paths-main.ts'] ])('%s', async (_name, entry) => { @@ -93,6 +94,9 @@ it.each([ if (entry === 'mcp-overlay-main.ts') { expect(result.persistedAfterReload).toBe(true) expect(result.scenarios).toHaveLength(4) + } else if (entry === 'story-graph-switch-main.ts') { + expect(result).toMatchObject({ persistedAfterReload: true, revokedExistingBinding: true, hiddenWhenDisabled: true }) + expect(result.scenarios).toHaveLength(4) } else { expect(result).toMatchObject({ image: true, iframe: true, project: true, tooltip: true, citation: true }) } diff --git a/tests/support/story-graph-switch-main.ts b/tests/support/story-graph-switch-main.ts new file mode 100644 index 00000000..ac91806d --- /dev/null +++ b/tests/support/story-graph-switch-main.ts @@ -0,0 +1,117 @@ +import { app, BrowserWindow, safeStorage } from 'electron' +import assert from 'node:assert/strict' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { registerIpcHandlers } from '../../src/main/ipc' +import { AssistantDatabase } from '../../src/main/assistant/assistant-database' +import { ApplicationSettingsStore } from '../../src/main/application-settings-store' +import { CapabilityService } from '../../src/main/capabilities/capability-service' +import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store' +import { KnowledgeMcpGateway } from '../../src/main/agent/knowledge-mcp-gateway' +import type { KnowledgeService } from '../../src/main/knowledge/knowledge-service' + +const directory = process.env.GB_MCP_DIRECTORY! +app.setPath('userData', join(directory, 'profile')) +app.commandLine.appendSwitch('force-device-scale-factor', '1') +app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion') + +app.whenReady().then(async () => { + const win = new BrowserWindow({ show: true, width: 1280, height: 900, useContentSize: true, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, + backgroundThrottling: false, preload: join(directory, 'preload.cjs') } }) + const database = new AssistantDatabase(join(directory, 'assistant.sqlite')) + database.initialize(directory) + const projectId = database.listProjects()[0]!.id + const conversationId = '00000000-0000-4000-8000-000000000321' + database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph switch', updatedAt: 1 }, messages: [] }]) + const application = new ApplicationSettingsStore(join(directory, 'application.json')) + await application.update({ heartbeatEnabled: true, checkUpdatesOnStartup: false }) + const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(), + encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) } + const capabilities = new CapabilityService(join(directory, 'capabilities.json'), join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher) + const settings = new RuntimeSettingsStore(join(directory, 'runtime.json'), cipher, {}) + const knowledge = { snapshot: () => ({ libraries: [{ id: '00000000-0000-4000-8000-000000000322', name: 'Knowledge', documentCount: 0 }], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }), + database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as unknown as KnowledgeService + const gateway = new KnowledgeMcpGateway(knowledge, { storyGraphService: { + available: async binding => (await application.get()).heartbeatEnabled === true && database.isConversationStoryGraphEnabled(binding.conversationId!), + read: database.readStoryGraph.bind(database) + } }) + const token = gateway.grant('ui-probe', [], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, + { projectId, conversationId, runtimeTarget: 'model' })! + const binding = gateway.bindRemoteStoryGraph(token)! + const dispose = registerIpcHandlers(win, + { capability: 'text', getStatus: async () => ({ available: true, name: 'Switch test', capability: 'text' }) } as never, + 'CommandOrControl+Shift+Space', settings, capabilities, + { clear() {}, cancelImport() {}, getDraft: () => [] } as never, knowledge, + database, { clear() {} } as never, {} as never, async () => {}, undefined, undefined, + undefined, undefined, application, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never) + const js = (code: string): Promise => win.webContents.executeJavaScript(code) + win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) }) + const wait = async (code: string): Promise => { + for (let i = 0; i < 400; i++) { + if (await js(code)) return + await new Promise(resolve => setTimeout(resolve, 25)) + } + throw new Error(`Timeout: ${code}; ${await js('JSON.stringify({focus:document.activeElement?.tagName + " " + document.activeElement?.className,hidden:document.querySelector(".composer__options")?.hidden,text:document.body.innerText.slice(0,1000)})')}`) + } + const toggle = '.composer__options input[role="switch"]' + const open = async (): Promise => { + await wait('!!document.querySelector(".composer__options-trigger")') + await wait(`!!document.querySelector('${toggle}')`) + await wait('document.querySelector(".topbar")?.textContent.includes("Graph switch")') + win.focus(); win.webContents.focus() + await wait('document.hasFocus()') + await js('document.querySelector(".composer__options-trigger").focus()') + const point = await js<{ x: number; y: number }>(`(() => { const r = document.querySelector('.composer__options-trigger').getBoundingClientRect(); return { x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2) }; })()`) + win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 }) + win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 }) + await wait(`document.activeElement === document.querySelector('${toggle}')`) + } + const scenarios: object[] = [] + try { + await win.loadURL(process.env.GB_MCP_URL!) + await open() + assert.equal(await binding.available(), true) + for (const width of [1280, 390]) for (const theme of ['light', 'dark']) { + win.setContentSize(width, 900) + await js(`document.documentElement.dataset.theme = '${theme}'`) + await wait(`innerWidth === ${width}`) + if (width < 900) await wait('!document.querySelector(".assistant-sidebar--open")') + await js('Promise.all(document.getAnimations().filter(a => a.effect.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))).then(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))') + if (await js('document.querySelector(".composer__options").hidden')) await open() + const layout = await js<{ fits: boolean; shared: boolean; beforeKnowledge: boolean }>(`(() => { + const e = document.querySelector('${toggle}'), r = e.getBoundingClientRect(), s = getComputedStyle(e); + const kb = document.querySelector('.knowledge-scope'); + return { fits: r.width > 0 && r.left >= 0 && r.right <= innerWidth, + shared: s.appearance === 'none' && !!e.closest('.toggle-row'), + beforeKnowledge: !!(e.compareDocumentPosition(kb) & Node.DOCUMENT_POSITION_FOLLOWING) }; + })()`) + assert.deepEqual(layout, { fits: true, shared: true, beforeKnowledge: true }, JSON.stringify({ width, theme, + rect: await js(`document.querySelector('${toggle}').getBoundingClientRect().toJSON()`) })) + scenarios.push({ width, theme, ...layout }) + } + win.focus(); win.webContents.focus() + await js(`document.querySelector('${toggle}').focus()`) + await wait('document.hasFocus()') + win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' }) + win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' }) + await wait(`!document.querySelector('${toggle}').checked && !document.querySelector('${toggle}').disabled`) + assert.equal(database.isConversationStoryGraphEnabled(conversationId), false) + assert.equal(await binding.available(), false) + await assert.rejects(binding.call('story_graph_search', { query: 'Decision' }, new AbortController().signal), /story_graph_unavailable/) + await win.loadURL(process.env.GB_MCP_URL!) + await open() + assert.equal(await js(`document.querySelector('${toggle}').checked`), false) + await js('window.goodbuddy.updates.updateSettings({heartbeatEnabled:false})') + await wait(`!document.querySelector('${toggle}')`) + await js('window.goodbuddy.updates.updateSettings({heartbeatEnabled:true})') + await wait(`!!document.querySelector('${toggle}')`) + assert.equal(await js(`document.querySelector('${toggle}').checked`), false) + const modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0 + assert.equal(modelAttempts, 0) + await writeFile(join(directory, 'result.json'), JSON.stringify({ scenarios, modelAttempts, + persistedAfterReload: true, revokedExistingBinding: true, hiddenWhenDisabled: true })) + } finally { win.destroy(); await dispose(); await gateway.dispose(); database.close() } + app.exit(0) +}).catch(error => { console.error(error); app.exit(1) })