Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff61da28a0 | ||
|
|
362fe50402 | ||
|
|
273a7fa7a5 | ||
|
|
74efbcdc17 | ||
|
|
e89db96b85 | ||
|
|
ae4c6d2423 | ||
|
|
91edcf5e8b | ||
|
|
0dd4d1d642 | ||
|
|
75e50fc194 | ||
|
|
29a134b804 | ||
|
|
59d36c2258 | ||
|
|
39fc15efe0 | ||
|
|
5d0ca44b22 | ||
|
|
78beade289 | ||
|
|
bf9850a6a9 | ||
|
|
0e9c9d4927 | ||
|
|
352da66dbd | ||
|
|
6aab78b9fa | ||
|
|
2adee13638 | ||
|
|
942fe3206c | ||
|
|
6b634c5d6b | ||
|
|
739f26702b | ||
|
|
48d7526b7e | ||
|
|
492f1d208a | ||
|
|
651475d17a | ||
|
|
2e3d2e77a9 | ||
|
|
41be9f8076 | ||
|
|
010aafb6f4 | ||
|
|
68cc0792e1 | ||
|
|
b28ddd4ba8 | ||
|
|
805acc3fce | ||
|
|
a620c22676 | ||
|
|
10065da8ac | ||
|
|
b2eb86a0b4 | ||
|
|
f4458afe4b | ||
|
|
10c5162811 | ||
|
|
9d03217f99 | ||
|
|
55f554d13c |
+15
-2
@@ -345,7 +345,7 @@ records are listed separately and do not introduce another feature status.
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.15.3`, paired with the current Desktop release candidate `0.15.9`; formal
|
||||
`0.15.3`, paired with the current Desktop release candidate `0.15.10`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Previous macOS validation covered native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
@@ -978,9 +978,22 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.9`; Agent candidate `0.15.3` requires Desktop `0.15.9`, with
|
||||
- The current Desktop candidate is `0.15.10`; published Agent `0.15.3` requires Desktop `0.15.9`, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.15.10` unifies project/conversation navigation with ten recent
|
||||
conversations, adds independent Supervisor model selection and a default-on
|
||||
per-conversation Story Graph switch. Indexed activity paging and complete
|
||||
summaries, document Workers, lazy startup, narrower render subscriptions,
|
||||
windowed lists, terminal batching and old Runtime cache cleanup reduce repeated
|
||||
work without promising universal speedups. Schema 58 requires a pre-upgrade
|
||||
database and notes backup. Frequent local writes use NORMAL and can lose a
|
||||
recent suffix on power/system failure; remote acknowledgments, channel delivery
|
||||
and task lifecycle writes retain FULL. Agent stays at 0.15.3. Four real Host
|
||||
model requests and a 1,000-line Unicode SSH terminal check passed; exact-source
|
||||
CI and six native packages remain release gates, and reported remote resume
|
||||
failures are not claimed fixed. See the
|
||||
[preparation record](./docs/development/release-preparation-0.15.10.md).
|
||||
- Desktop `0.15.9` adds shared review progress, stories, experiences, and spiral
|
||||
graph navigation, with schema 56 backup/rollback requirements and possible
|
||||
model costs. Conversation/message windowing, Markdown caching, read-only
|
||||
|
||||
+3
-2
@@ -53,7 +53,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
|
||||
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.3`,配套 Desktop 候选为 `0.15.9`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.3`,配套 Desktop 候选为 `0.15.10`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
|
||||
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
|
||||
@@ -158,7 +158,8 @@
|
||||
### 开源、构建与发布
|
||||
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.9`,Agent 候选为 `0.15.3`、最低要求 Desktop `0.15.9`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- 当前 Desktop 候选为 `0.15.10`,已发布 Agent 为 `0.15.3`、最低要求 Desktop `0.15.9`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.15.10` 统一项目/会话菜单并显示十个最近会话,增加独立监督模型选择及默认开启的会话 Story Graph 开关。活动索引分页与完整汇总、文档 Worker、延迟启动、窄渲染订阅、列表窗口化、终端批次和旧 Runtime 缓存清理减少重复工作,不承诺通用提速比例。schema 58 要求升级前备份数据库及笔记;高频本地写入改用 NORMAL,断电/系统崩溃可能丢失最近部分写入,远程确认、通道投递和任务生命周期仍用 FULL。Agent 保持 0.15.3;四次真实 Host 模型请求及 1000 行 Unicode SSH 终端检查通过,精确候选 CI 与六平台打包仍为发布门槛,不宣称已修复尚未定位的远程恢复错误。见[发布准备](./docs/development/release-preparation-0.15.10.md)。
|
||||
- Desktop `0.15.9` 增加共享回顾进度、故事经验与时间螺旋,schema 56 要求升级前备份,相关模型整理可能增加费用。会话与消息窗口化、Markdown 缓存、只读 Worker、活动差量保存及远程 checkpoint 批次落库减少重复工作,不承诺所有机器相同比例提速;页内查找不覆盖未挂载行,快速滚动可能显示占位,Worker 不可用时同步回退。Execute 配置修改取消重复原生确认,Electron 为 44.5.1。当前源码四次真实 Host 模型请求通过,仍需精确候选 CI 与打包验证,见[发布准备](./docs/development/release-preparation-0.15.9.md)。
|
||||
- Desktop `0.15.8` 与 Agent `0.15.2` 支持 Execute 模式保存会话图片到本机或 Host 绝对路径,覆盖需明确指定,不支持转换为 WebP。MCP 设置列出图像工具,不代表所有模式均可用。笔记增加新记录取消、编辑焦点返回及可用内容全宽;缺少浏览器和磨砂设置时默认开启,已保存选择不变。schema 仍为 52。真实 Linux x64 Host 工具层证据与待完成的精确候选 CI、打包验证分别记录在[发布准备](./docs/development/release-preparation-0.15.8.md)。
|
||||
- Desktop `0.15.7` 复用已完成的流式 Markdown 块与会话派生值,不改默认流式节奏。运行记录进入时读取、可见时每 15 秒刷新,也可手动刷新;本地自定义 MCP 发现故障按服务隔离。受管模型下载源移至模型连接中的确认弹窗,设备共享改为显式启用的测试功能。schema 仍为 52,仍需精确候选 CI 与打包验证,组件测量不等同完整 App 结果。详见[发布准备](./docs/development/release-preparation-0.15.7.md)。
|
||||
|
||||
+2
-2
@@ -307,7 +307,7 @@
|
||||
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
|
||||
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
|
||||
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
|
||||
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
|
||||
- 项目选择器使用固定双行单按钮,第一行显示项目名,第二行显示跨项目活动汇总或“暂无活动”;点击任一行打开统一菜单并默认预览当前项目,全部项目范围在菜单内切换。尺寸、焦点、项目计数与菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
@@ -371,7 +371,7 @@
|
||||
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
|
||||
关闭按钮,也不响应关闭快捷命令。
|
||||
“+”位于滚动容器外并占用固定宽度;滚动箭头限定在剩余的页签区域内,不能覆盖添加入口。
|
||||
“+”位于滚动容器外并占用固定宽度;溢出时左右滚动箭头在滚动视口两侧各占独立宽度,不能覆盖页签、关闭按钮或添加入口。箭头隐藏或禁用时保留占位,避免悬停或滚动引起布局跳动。
|
||||
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
||||
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
||||
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Document parse benchmark (PERF-15/16, P7). Bundles the production parser
|
||||
// client and worker with esbuild, then imports synthetic large documents
|
||||
// (hash -> parse -> chunk, as Knowledge import does) twice per round: inline on
|
||||
// the main thread, then through the parse worker. Records the main event-loop
|
||||
// max block with perf_hooks.monitorEventLoopDelay.
|
||||
//
|
||||
// node build/run-document-parse-perf.cjs
|
||||
// GB_DOCPERF_ROUNDS=3 GB_DOCPERF_NODE=1 (skip Electron) GB_DOCPERF_OUTPUT=file.json
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdirSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
// Measure on the project's Electron (the production runtime), like perf:knowledge.
|
||||
if (process.env.GB_DOCPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const rounds = Number(process.env.GB_DOCPERF_ROUNDS || 3)
|
||||
// Under node_modules so the external pdfjs-dist resolves from the repository.
|
||||
const directory = join(root, 'node_modules', '.cache', `goodbuddy-document-parse-perf-${process.pid}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const harness = join(directory, 'harness.cjs')
|
||||
const workerBundle = join(directory, 'document-parse-worker.cjs')
|
||||
const harnessSource = join(directory, 'harness.ts')
|
||||
writeFileSync(harnessSource, [
|
||||
`export * from ${JSON.stringify(join(root, 'src/main/document-parse-client.ts'))}`,
|
||||
`export * from ${JSON.stringify(join(root, 'tests/support/document-parse-worker-fixture.ts'))}`
|
||||
].join('\n'))
|
||||
for (const [entry, outfile] of [[harnessSource, harness], [join(root, 'src/main/document-parse-worker.ts'), workerBundle]]) {
|
||||
buildSync({
|
||||
entryPoints: [entry], bundle: true, platform: 'node', format: 'cjs', target: 'node22',
|
||||
outfile, logLevel: 'error', external: ['pdfjs-dist', 'electron', 'esbuild']
|
||||
})
|
||||
}
|
||||
const {
|
||||
DocumentParseWorkerClient, chunkDocumentOffMain, parseDocumentOffMain, sha256OffMain,
|
||||
createDocx, createLargePdf, sentenceGenerator
|
||||
} = require(harness)
|
||||
|
||||
const settings = {
|
||||
version: 1, mode: 'structure', targetCharacters: 1600, overlapCharacters: 100,
|
||||
parentCharacters: 1600, childCharacters: 400, contextualIndexingEnabled: false
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
|
||||
async function importOnce(name, buffer, client) {
|
||||
await sha256OffMain(buffer, client)
|
||||
const parsed = await parseDocumentOffMain(name, buffer, undefined, client)
|
||||
return (await chunkDocumentOffMain(parsed, settings, client)).length
|
||||
}
|
||||
|
||||
async function measure(name, buffer, client) {
|
||||
await importOnce(name, buffer, client) // warm modules / worker
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
const histogram = monitorEventLoopDelay({ resolution: 5 })
|
||||
histogram.enable()
|
||||
// The histogram only measures from its first timer tick on; a block that
|
||||
// starts before that tick (the inline path is all microtasks) is not seen.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
const started = performance.now()
|
||||
const chunks = await importOnce(name, buffer, client)
|
||||
const wallMs = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
histogram.disable()
|
||||
return { maxMs: round(histogram.max / 1e6), p99Ms: round(histogram.percentile(99) / 1e6), wallMs: round(wallMs), chunks }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[docperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const sentence = sentenceGenerator(11)
|
||||
const inputs = [
|
||||
{ name: 'large.pdf', buffer: createLargePdf(500, 60) },
|
||||
{ name: 'large.docx', buffer: createDocx(Array.from({ length: 4000 }, () => sentence(25))) },
|
||||
{ name: 'large.md', buffer: Buffer.from(Array.from({ length: 4000 }, (_, index) => `${index % 50 === 0 ? '## Section\n' : ''}${sentence(60)}.`).join('\n')) }
|
||||
]
|
||||
const client = new DocumentParseWorkerClient(workerBundle)
|
||||
const results = []
|
||||
try {
|
||||
for (const input of inputs) {
|
||||
for (let index = 0; index < rounds; index += 1) {
|
||||
// Alternate inline / worker within each round (P7).
|
||||
const inline = await measure(input.name, input.buffer, undefined)
|
||||
const worker = await measure(input.name, input.buffer, client)
|
||||
if (worker.chunks !== inline.chunks) throw new Error(`chunk count mismatch for ${input.name}`)
|
||||
results.push({ input: input.name, bytes: input.buffer.byteLength, round: index + 1, inline, worker })
|
||||
console.log(`[docperf] ${input.name} round ${index + 1}: Main loop max ${inline.maxMs} -> ${worker.maxMs} ms; wall ${inline.wallMs} / ${worker.wallMs} ms; ${inline.chunks} chunks`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
const output = process.env.GB_DOCPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-document-parse-benchmark', node: process.version, results }, null, 2))
|
||||
console.table(results.map(r => ({ input: r.input, round: r.round, 'loop max inline/worker ms': `${r.inline.maxMs} / ${r.worker.maxMs}`, 'wall inline/worker ms': `${r.inline.wallMs} / ${r.worker.wallMs}` })))
|
||||
}
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
+26
-5
@@ -1,6 +1,6 @@
|
||||
// Main-process database benchmark (PERF-15). Bundles the production
|
||||
// AssistantDatabase and readonly worker with esbuild, seeds a temporary
|
||||
// assistant database (default 1,000 conversations / 20,000 messages and 5,000
|
||||
// assistant database (default 1,000 conversations / 20,000 messages and 24,000
|
||||
// activity records) and measures, for the IPC paths the renderer hits often:
|
||||
// - per-call time
|
||||
// - Main event-loop delay while the calls run back to back
|
||||
@@ -10,6 +10,9 @@
|
||||
// activity-history:replace legacy replace (zod parse + BEGIN IMMEDIATE +
|
||||
// JSON.stringify of every record) vs current
|
||||
// activity-history:update one changed record via the incremental API
|
||||
// activity startup load whole list (pre-paging get) vs first page (200)
|
||||
// + summary on the readonly worker
|
||||
// activity page / summary sync paths, for reference
|
||||
// Env: GB_MPERF_CONVERSATIONS, GB_MPERF_MESSAGES, GB_MPERF_ACTIVITY,
|
||||
// GB_MPERF_CALLS, GB_MPERF_OUTPUT (JSON), GB_MPERF_NODE=1 (skip Electron).
|
||||
const { buildSync } = require('esbuild')
|
||||
@@ -35,7 +38,7 @@ if (process.env.GB_MPERF_NODE !== '1') {
|
||||
|
||||
const conversationCount = Number(process.env.GB_MPERF_CONVERSATIONS || 1000)
|
||||
const messageCount = Number(process.env.GB_MPERF_MESSAGES || 20000)
|
||||
const activityCount = Number(process.env.GB_MPERF_ACTIVITY || 5000)
|
||||
const activityCount = Number(process.env.GB_MPERF_ACTIVITY || 24000)
|
||||
const calls = Number(process.env.GB_MPERF_CALLS || 15)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-main-perf-'))
|
||||
@@ -118,7 +121,8 @@ function legacyReplace(raw, cache, input) {
|
||||
const records = new Map()
|
||||
const occurrences = new Map()
|
||||
const order = []
|
||||
const upsert = raw.prepare(`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
||||
// order_seq only satisfies the current schema; the old code kept order in the JSON list below.
|
||||
const upsert = raw.prepare(`INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`)
|
||||
for (const record of snapshot.records) {
|
||||
const occurrence = occurrences.get(record.id) ?? 0
|
||||
@@ -126,7 +130,7 @@ function legacyReplace(raw, cache, input) {
|
||||
const key = JSON.stringify([record.id, occurrence])
|
||||
const json = JSON.stringify(record)
|
||||
order.push(key)
|
||||
if (previous.get(key) !== json) upsert.run(key, json)
|
||||
if (previous.get(key) !== json) upsert.run(key, json, snapshot.records.length - order.length + 1)
|
||||
records.set(key, json)
|
||||
}
|
||||
const remove = raw.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
@@ -224,6 +228,19 @@ async function main() {
|
||||
const record = { ...records[(index + 2) % 50], detail: `incremental ${index}`, status: 'running' }
|
||||
return { changes: [{ type: 'upsert', position: 'front', record }] }
|
||||
}, input => database.updateActivityHistory(input))
|
||||
|
||||
// Startup: the renderer used to load the whole list; now the first page and the summary.
|
||||
const firstPageRequest = { limit: 200 }
|
||||
const summaryRequest = () => ({ conversationIds: [...new Set(database.getActivityHistoryPage(firstPageRequest).records.map(record => record.conversationId))] })
|
||||
results.activityFullLoad = await measure(async () => database.getActivityHistory())
|
||||
results.activityFirstPageSync = await measure(async () => database.getActivityHistoryPage(firstPageRequest))
|
||||
results.activitySummarySync = await measure(async () => database.getActivityHistorySummary(summaryRequest()))
|
||||
results.activityStartupWorker = await measure(async () => {
|
||||
const page = await database.getActivityHistoryPageAsync(firstPageRequest)
|
||||
await database.getActivityHistorySummaryAsync({ conversationIds: [...new Set(page.records.map(record => record.conversationId))] })
|
||||
})
|
||||
results.activityFullLoadBytes = JSON.stringify(database.getActivityHistory()).length
|
||||
results.activityFirstPageBytes = JSON.stringify(database.getActivityHistoryPage(firstPageRequest)).length
|
||||
database.close()
|
||||
|
||||
console.log(`[mperf] seeded ${results.conversations} conversations / ${results.messages} messages (largest ${results.largestConversationMessages}), ${results.activityRecords} activity records in ${seedSeconds} s`)
|
||||
@@ -233,7 +250,10 @@ async function main() {
|
||||
[`conversations:get (${counts[Math.floor(conversationCount / 2)]} msgs)`, results.getTypicalSync, results.getTypicalWorker],
|
||||
['activity replace, unchanged', results.replaceUnchangedLegacy, results.replaceUnchangedAfter],
|
||||
['activity replace, 1 changed', results.replaceOneChangedLegacy, results.replaceOneChangedAfter],
|
||||
['activity update, 1 record', results.replaceOneChangedLegacy, results.updateOneRecord]
|
||||
['activity update, 1 record', results.replaceOneChangedLegacy, results.updateOneRecord],
|
||||
['activity startup: full list vs page+summary (worker)', results.activityFullLoad, results.activityStartupWorker],
|
||||
['activity first page (200), sync', results.activityFullLoad, results.activityFirstPageSync],
|
||||
['activity summary, sync', results.activityFullLoad, results.activitySummarySync]
|
||||
]
|
||||
console.table(rows.map(([name, before, after]) => ({
|
||||
path: name,
|
||||
@@ -242,6 +262,7 @@ async function main() {
|
||||
'after call p50/p95 ms': `${after.callP50} / ${after.callP95}`,
|
||||
'after loop max ms': after.loopMax
|
||||
})))
|
||||
console.log(`[mperf] activity payload: full list ${results.activityFullLoadBytes} bytes, first page ${results.activityFirstPageBytes} bytes`)
|
||||
const output = process.env.GB_MPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-database-benchmark', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// Cold-start benchmark supervisor (PERF-01).
|
||||
// Launches the current production build (out/, from `npm run build:bundle`)
|
||||
// GB_PERF_STARTUP_RUNS times (default 5) per variant through
|
||||
// tests/support/startup-perf-driver.mjs, each launch in a fresh temporary
|
||||
// user-data directory, and reports median / p95 per startup phase.
|
||||
//
|
||||
// Variants (interleaved each round, per docs P7):
|
||||
// first-run empty user-data dir (creates databases, shows release notes)
|
||||
// empty copy of a prepared profile: release notes seen, startup update
|
||||
// check off, 0 conversations
|
||||
// seeded-300 same as empty plus GB_PERF_STARTUP_SEED (default 300)
|
||||
// synthetic conversations, reusing the PERF-11 seed shape
|
||||
//
|
||||
// Isolation: temporary profile/session/logs, a loopback fake model on an
|
||||
// ephemeral port (GB_PERF_MODEL_PORT to pin one; 11434 is left alone), all
|
||||
// non-loopback requests blocked in the driver. Output has phase names, times
|
||||
// and counts only.
|
||||
//
|
||||
// Env: GB_PERF_STARTUP_RUNS, GB_PERF_STARTUP_SEED, GB_PERF_STARTUP_VARIANTS
|
||||
// (comma list), GB_PERF_STARTUP_SKIP_BUILD=1, GB_PERF_STARTUP_PROFILE=1 (one
|
||||
// extra CPU-profiled launch per variant, excluded from the table),
|
||||
// GB_PERF_OUTPUT (report directory), GB_PERF_KEEP_PROFILE=1,
|
||||
// GB_PERF_STARTUP_APP_ROOT (checkout/worktree to build and launch; default .).
|
||||
// Variant "real" (opt-in) launches on a fresh copy of GB_PERF_STARTUP_REAL_DATA.
|
||||
const { spawn, spawnSync, execFileSync } = require('node:child_process')
|
||||
const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { cpus, tmpdir, totalmem } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { npmInvocation } = require('./npm-invocation.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
// The app under test. Defaults to this checkout; point it at a temporary
|
||||
// worktree to measure a baseline or a clean HEAD while this tree is mid-edit.
|
||||
const appRoot = resolve(process.env.GB_PERF_STARTUP_APP_ROOT || root)
|
||||
const runs = Math.max(1, Number(process.env.GB_PERF_STARTUP_RUNS || 5))
|
||||
const seedCount = Number(process.env.GB_PERF_STARTUP_SEED || 300)
|
||||
const seededName = `seeded-${seedCount}`
|
||||
const variants = (process.env.GB_PERF_STARTUP_VARIANTS || `first-run,empty,${seededName}`).split(',').map(v => v.trim()).filter(Boolean)
|
||||
const requestedPort = Number(process.env.GB_PERF_MODEL_PORT || 0)
|
||||
const launchTimeoutMs = Number(process.env.GB_PERF_STARTUP_LAUNCH_TIMEOUT_MS || 150_000)
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const cpuProfile = process.env.GB_PERF_STARTUP_PROFILE === '1'
|
||||
|
||||
// ------------------------------------------------------------------ build
|
||||
|
||||
function newestMtime(path) {
|
||||
let newest = 0
|
||||
const stack = [path]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()
|
||||
let info
|
||||
try { info = statSync(current) } catch { continue }
|
||||
if (info.isDirectory()) {
|
||||
for (const entry of readdirSync(current)) if (entry !== 'node_modules') stack.push(join(current, entry))
|
||||
} else if (!/\.test\.tsx?$/.test(current)) {
|
||||
newest = Math.max(newest, info.mtimeMs)
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
function ensureBuild() {
|
||||
const outputs = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html'].map(file => join(appRoot, file))
|
||||
const missing = outputs.some(file => !existsSync(file))
|
||||
const builtAt = missing ? 0 : Math.min(...outputs.map(file => statSync(file).mtimeMs))
|
||||
const sourceAt = Math.max(...['src/main', 'src/preload', 'src/renderer', 'src/shared', 'electron.vite.config.ts', 'package.json'].map(p => newestMtime(join(appRoot, p))))
|
||||
const stale = missing || sourceAt > builtAt
|
||||
if (!stale) return { rebuilt: false }
|
||||
if (process.env.GB_PERF_STARTUP_SKIP_BUILD === '1') {
|
||||
if (missing) throw new Error('Missing production build and GB_PERF_STARTUP_SKIP_BUILD=1')
|
||||
console.log('[startup] build is older than src/, measuring it anyway (GB_PERF_STARTUP_SKIP_BUILD=1)')
|
||||
return { rebuilt: false, stale: true }
|
||||
}
|
||||
console.log(`[startup] ${missing ? 'no production build' : 'production build is older than src/'}; running npm run build:bundle`)
|
||||
const npm = npmInvocation(appRoot)
|
||||
const result = spawnSync(npm.command, [...npm.prefixArgs, 'run', 'build:bundle'], { cwd: appRoot, stdio: 'inherit', shell: npm.prefixArgs.length === 0 && process.platform === 'win32' })
|
||||
if (result.status !== 0) throw new Error(`npm run build:bundle failed (${result.status})`)
|
||||
return { rebuilt: true }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- fake model
|
||||
|
||||
function startModelServer(ledger) {
|
||||
const server = createServer((request, response) => {
|
||||
ledger.push({ method: request.method, path: request.url })
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && request.url?.endsWith('/chat/completions')) {
|
||||
request.resume()
|
||||
request.on('end', () => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ id: 'perf', object: 'chat.completion', model: 'qwen3', choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'ok' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))
|
||||
})
|
||||
return
|
||||
}
|
||||
response.writeHead(404).end()
|
||||
})
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(requestedPort, '127.0.0.1', () => resolvePromise(server))
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ launch
|
||||
|
||||
function baseEnvironment(modelPort, workspace) {
|
||||
const environment = { ...process.env, GB_PERF_ROOT: appRoot, GB_PERF_STARTUP_MARKS: '1' }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback'
|
||||
environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1`
|
||||
environment.GOODBUDDY_MODEL_NAME = 'qwen3'
|
||||
environment.GOODBUDDY_WORKSPACE = workspace
|
||||
return environment
|
||||
}
|
||||
|
||||
async function launch({ directory, environment, mode = 'measure', seed = 0, expectConversations = 0, profilePath }) {
|
||||
const resultPath = join(directory, 'startup-result.json')
|
||||
const env = { ...environment, GB_STARTUP_DIR: directory, GB_STARTUP_RESULT: resultPath, GB_STARTUP_MODE: mode, GB_STARTUP_SEED: String(seed), GB_STARTUP_EXPECT_CONVERSATIONS: String(expectConversations) }
|
||||
if (profilePath) env.GB_STARTUP_CPU_PROFILE = profilePath
|
||||
const stderr = []
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'startup-perf-driver.mjs')], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: false })
|
||||
child.stderr.on('data', chunk => { if (stderr.length < 200) stderr.push(String(chunk)) })
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, launchTimeoutMs)
|
||||
const exit = await new Promise(resolvePromise => child.once('exit', code => resolvePromise(code)))
|
||||
const exitAt = Date.now()
|
||||
clearTimeout(timer)
|
||||
const report = existsSync(resultPath) ? JSON.parse(readFileSync(resultPath, 'utf8')) : { status: 'failed', error: timedOut ? 'launch timeout' : 'driver exited without a report' }
|
||||
if (exit !== 0 || timedOut) report.status = 'failed'
|
||||
report.spawnAt = spawnAt
|
||||
report.exitMs = exitAt - spawnAt
|
||||
// stderr is kept only for failures, and only its first line (no user data exists in these profiles).
|
||||
if (report.status !== 'passed') report.stderrHead = stderr.join('').split(/\r?\n/).filter(Boolean).slice(0, 8)
|
||||
return report
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- analysis
|
||||
|
||||
function phases(report) {
|
||||
const t = report.times
|
||||
const r = report.renderer
|
||||
const s = report.spawnAt
|
||||
const marks = Object.fromEntries((report.startupMarks ?? []).map(m => [m.name, m.at]))
|
||||
const d = (from, to) => (from === undefined || from === null || to === undefined || to === null) ? null : to - from
|
||||
const result = {
|
||||
'spawn -> driver start (Electron boot)': d(s, t.driverStart),
|
||||
'driver start -> Main import done': d(t.driverStart, t.mainImportEnd),
|
||||
'spawn -> app ready': d(s, t.appReady),
|
||||
'app ready -> BrowserWindow created': d(t.appReady, t.windowCreated),
|
||||
'window created -> loadMainWindow': d(t.windowCreated, marks['main:load-main-window']),
|
||||
'loadMainWindow -> ready-to-show': d(marks['main:load-main-window'], t.readyToShow),
|
||||
'window created -> ready-to-show': d(t.windowCreated, t.readyToShow),
|
||||
'ready-to-show -> first visible frame': d(t.readyToShow, r.firstVisibleFrame),
|
||||
'ready-to-show -> composer interactive': d(t.readyToShow, r.composerInteractive),
|
||||
'spawn -> ready-to-show': d(s, t.readyToShow),
|
||||
'spawn -> first visible frame': d(s, r.firstVisibleFrame),
|
||||
// Only reported by Chromium when the first paint happens while visible.
|
||||
'spawn -> first contentful paint': d(s, r.firstContentfulPaint),
|
||||
'spawn -> composer present': d(s, r.composerPresent),
|
||||
'spawn -> composer interactive': d(s, r.composerInteractive),
|
||||
'spawn -> sidebar populated (seeded)': d(s, r.sidebarPopulated),
|
||||
'composer interactive -> sidebar populated': d(r.composerInteractive, r.sidebarPopulated)
|
||||
}
|
||||
// Main phases (GB_PERF_STARTUP_MARKS spans; parallel spans overlap).
|
||||
const main = {
|
||||
'main: module import (index.js eval)': d(t.mainImportStart, marks['main:module-evaluated']),
|
||||
'main: whenReady -> window created': d(marks['main:when-ready'], marks['main:create-window:end']),
|
||||
'main: createMainWindow()': d(marks['main:create-window:start'], marks['main:create-window:end']),
|
||||
'main: tray': d(marks['main:create-window:end'], marks['main:tray-built'])
|
||||
}
|
||||
for (const m of report.startupMarks ?? []) {
|
||||
if (!m.name.endsWith(':start')) continue
|
||||
const base = m.name.slice(0, -':start'.length)
|
||||
if (base === 'main:create-window') continue
|
||||
main[`${base.replace(/^main:/, 'main: ')}`] = d(m.at, marks[`${base}:end`])
|
||||
// Synchronous part of the span: from the call until the first await yields.
|
||||
if (marks[`${base}:sync-end`] !== undefined) main[`${base.replace(/^main:/, 'main: ')} (sync part)`] = d(m.at, marks[`${base}:sync-end`])
|
||||
}
|
||||
main['main: whenReady -> loadMainWindow (total)'] = d(marks['main:when-ready'], marks['main:load-main-window'])
|
||||
// Critical path in Main: consecutive top-level marks (spans that run in
|
||||
// parallel inside the prerequisites are excluded), so each gap is the time
|
||||
// Main spent between two points of the sequential startup function.
|
||||
const nested = /^main:(knowledge-init|knowledge-gateway-start|runtime-hydrate|assistant-db-init)/
|
||||
const sequence = (report.startupMarks ?? []).filter(m => !nested.test(m.name) && !m.name.endsWith(':sync-end')).sort((a, b) => a.at - b.at)
|
||||
for (let index = 1; index < sequence.length; index += 1) {
|
||||
main[`gap: ${sequence[index - 1].name} -> ${sequence[index].name}`] = sequence[index].at - sequence[index - 1].at
|
||||
}
|
||||
const renderer = {
|
||||
'renderer: navigation start -> DOMContentLoaded': d(r.navigationStart, r.domContentLoaded),
|
||||
'renderer: DOMContentLoaded -> composer present': d(r.domContentLoaded, r.composerPresent),
|
||||
'renderer: navigation start -> composer present': d(r.navigationStart, r.composerPresent)
|
||||
}
|
||||
return { ...result, ...main, ...renderer }
|
||||
}
|
||||
|
||||
function percentile(values, p) {
|
||||
if (!values.length) return null
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
// Nearest rank; with 5 samples p95 is the maximum.
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
function summarize(reportsByVariant) {
|
||||
const rows = new Map()
|
||||
for (const [variant, reports] of Object.entries(reportsByVariant)) {
|
||||
const passed = reports.filter(r => r.status === 'passed')
|
||||
const perRun = passed.map(phases)
|
||||
for (const name of new Set(perRun.flatMap(Object.keys))) {
|
||||
const values = perRun.map(p => p[name]).filter(v => typeof v === 'number' && Number.isFinite(v))
|
||||
const row = rows.get(name) ?? {}
|
||||
row[variant] = values.length ? { median: percentile(values, 50), p95: percentile(values, 95), n: values.length } : null
|
||||
rows.set(name, row)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function sourceInfo() {
|
||||
try {
|
||||
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: appRoot, encoding: 'utf8' }).trim()
|
||||
const dirtyFiles = execFileSync('git', ['status', '--porcelain'], { cwd: appRoot, encoding: 'utf8' }).split('\n').filter(Boolean).length
|
||||
return { commit, dirty: dirtyFiles > 0, dirtyFiles, appRoot: appRoot === root ? '.' : 'GB_PERF_STARTUP_APP_ROOT' }
|
||||
} catch {
|
||||
return { commit: null }
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = v => (v === null || v === undefined ? '-' : Math.round(v).toString())
|
||||
|
||||
// ------------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
const build = ensureBuild()
|
||||
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-startup-perf-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const workspace = join(runDirectory, 'workspace')
|
||||
mkdirSync(workspace, { recursive: true })
|
||||
const ledger = []
|
||||
let server
|
||||
try {
|
||||
server = await startModelServer(ledger)
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${requestedPort} for the fake model (${error.code ?? error.message}); set GB_PERF_MODEL_PORT to a free port or leave it unset for an ephemeral port`, { cause: error })
|
||||
}
|
||||
const modelPort = server.address().port
|
||||
const environment = baseEnvironment(modelPort, workspace)
|
||||
const templates = {}
|
||||
const prepareReports = {}
|
||||
try {
|
||||
// Templates are prepared once and copied per launch, so every measured
|
||||
// launch starts from an identical, never-used-before directory.
|
||||
for (const variant of variants) {
|
||||
if (variant === 'first-run') continue
|
||||
if (variant === 'real') {
|
||||
// A copy of a real data directory (GB_PERF_STARTUP_REAL_DATA, for
|
||||
// example the portable build's data folder). Only the stores startup
|
||||
// reads are copied; the source is never opened by the app. The driver
|
||||
// blocks every non-loopback request, so configured remote models are
|
||||
// not called.
|
||||
const source = process.env.GB_PERF_STARTUP_REAL_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) throw new Error('variant "real" needs GB_PERF_STARTUP_REAL_DATA with assistant.sqlite')
|
||||
const directory = join(runDirectory, 'template-real')
|
||||
const profile = join(directory, 'profile')
|
||||
mkdirSync(profile, { recursive: true })
|
||||
for (const entry of readdirSync(source)) {
|
||||
const from = join(source, entry)
|
||||
const keep = /\.(sqlite|sqlite-wal|sqlite-shm|json)$/i.test(entry) || entry === 'notes' || entry === 'Local Storage'
|
||||
if (keep && entry !== 'DevToolsActivePort') cpSync(from, join(profile, entry), { recursive: true })
|
||||
}
|
||||
templates[variant] = directory
|
||||
continue
|
||||
}
|
||||
const seed = variant === 'empty' ? 0 : variant === seededName ? seedCount : null
|
||||
if (seed === null) throw new Error(`Unknown variant ${variant}`)
|
||||
const directory = join(runDirectory, `template-${variant}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
console.log(`[startup] preparing ${variant} profile${seed ? ` (${seed} conversations)` : ''}`)
|
||||
const report = await launch({ directory, environment, mode: 'prepare', seed })
|
||||
prepareReports[variant] = { status: report.status, error: report.error, seeded: report.seeded, exitMs: report.exitMs, stderrHead: report.stderrHead }
|
||||
if (report.status !== 'passed') throw new Error(`Preparing ${variant} failed: ${report.error ?? 'unknown'}\n${(report.stderrHead ?? []).join('\n')}`)
|
||||
rmSync(join(directory, 'startup-result.json'), { force: true })
|
||||
templates[variant] = directory
|
||||
}
|
||||
|
||||
const fresh = (variant, label) => {
|
||||
const directory = join(runDirectory, `${label}-${variant}`)
|
||||
if (templates[variant]) {
|
||||
cpSync(templates[variant], directory, { recursive: true })
|
||||
} else {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
// The real profile's sidebar is ready once its first rows show.
|
||||
const expectedFor = variant => (variant === seededName ? seedCount : variant === 'real' ? 10 : 0)
|
||||
const results = Object.fromEntries(variants.map(v => [v, []]))
|
||||
// A discarded warm-up launch per invocation so the first measured launch
|
||||
// does not also pay for loading Electron binaries from a cold disk cache.
|
||||
console.log('[startup] warm-up launch (discarded)')
|
||||
await launch({ directory: fresh(variants.includes('empty') ? 'empty' : variants[0], 'warmup'), environment })
|
||||
for (let round = 0; round < runs; round += 1) {
|
||||
for (const variant of variants) {
|
||||
const directory = fresh(variant, `run${round + 1}`)
|
||||
const report = await launch({ directory, environment, expectConversations: expectedFor(variant) })
|
||||
report.round = round + 1
|
||||
results[variant].push(report)
|
||||
const p = report.status === 'passed' ? phases(report) : {}
|
||||
console.log(`[startup] round ${round + 1} ${variant}: ${report.status}, ready ${fmt(p['spawn -> app ready'])} ms, ready-to-show ${fmt(p['spawn -> ready-to-show'])} ms, first frame ${fmt(p['spawn -> first visible frame'])} ms, composer interactive ${fmt(p['spawn -> composer interactive'])} ms${report.error ? `, error ${report.error}` : ''}`)
|
||||
if (!keepProfile) rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = {}
|
||||
if (cpuProfile) {
|
||||
for (const variant of variants) {
|
||||
const directory = fresh(variant, 'profiled')
|
||||
const profilePath = join(outputDirectory, `main-startup-${variant}.cpuprofile`)
|
||||
const report = await launch({ directory, environment, profilePath, expectConversations: expectedFor(variant) })
|
||||
profiles[variant] = { status: report.status, cpuProfile: report.cpuProfile, phases: report.status === 'passed' ? phases(report) : null, file: profilePath }
|
||||
console.log(`[startup] CPU-profiled ${variant}: ${report.status} -> ${profilePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const table = summarize(results)
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
kind: 'goodbuddy-startup-benchmark',
|
||||
source: { ...sourceInfo(), build },
|
||||
environment: { platform: process.platform, arch: process.arch, electron: require('electron/package.json').version, cpu: `${cpus()[0]?.model?.trim()} x${cpus().length}`, memoryGB: Math.round(totalmem() / 1073741824) },
|
||||
config: { runs, variants, seedConversations: seedCount, percentile: 'nearest-rank' },
|
||||
prepare: prepareReports,
|
||||
phases: Object.fromEntries(table),
|
||||
runs: Object.fromEntries(Object.entries(results).map(([variant, reports]) => [variant, reports.map(r => ({
|
||||
round: r.round, status: r.status, error: r.error, stderrHead: r.stderrHead, exitMs: r.exitMs,
|
||||
phases: r.status === 'passed' ? phases(r) : null,
|
||||
renderer: r.renderer ? { domNodes: r.renderer.domNodes, conversationRows: r.renderer.conversationRows, scriptResources: r.renderer.scriptResources, scriptBytes: r.renderer.scriptBytes, jsHeapMB: r.renderer.jsHeapMB, startupDialog: r.renderer.startupDialog !== undefined, composerPresentBeforeProbe: r.renderer.alreadyAtInstall === true, longAnimationFrames: r.renderer.longAnimationFrames && { count: r.renderer.longAnimationFrames.count, totalMs: r.renderer.longAnimationFrames.totalMs, maxMs: r.renderer.longAnimationFrames.maxMs, scriptMs: r.renderer.longAnimationFrames.scriptMs } } : null,
|
||||
mainEventLoopDelay: r.mainEventLoopDelay,
|
||||
blockedRequests: r.blockedRequests && { fetch: r.blockedRequests.fetch.length, renderer: r.blockedRequests.renderer.length, hosts: [...new Set([...r.blockedRequests.fetch, ...r.blockedRequests.renderer])] }
|
||||
}))])),
|
||||
cpuProfiles: profiles,
|
||||
modelRequests: { boundary: 'loopback fake model only; non-loopback requests blocked', total: ledger.length, paths: [...new Set(ledger.map(e => `${e.method} ${e.path}`))] }
|
||||
}
|
||||
const resultPath = join(outputDirectory, 'startup-result.json')
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
|
||||
console.log(`\nGoodBuddy cold-start benchmark: ${runs} runs per variant, ms (median / p95, nearest rank)`)
|
||||
console.log(`source ${report.source.commit ?? '?'}${report.source.dirty ? ` (dirty, ${report.source.dirtyFiles} files)` : ''}, Electron ${report.environment.electron}, ${report.environment.cpu}, ${report.environment.memoryGB} GB`)
|
||||
console.table(Object.fromEntries([...table].map(([name, row]) => [name, Object.fromEntries(variants.map(v => [v, row[v] ? `${fmt(row[v].median)} / ${fmt(row[v].p95)}${row[v].n < runs ? ` (n=${row[v].n})` : ''}` : '-']))])))
|
||||
const failed = Object.values(results).flat().filter(r => r.status !== 'passed').length
|
||||
if (failed) console.log(`${failed} launch(es) failed; see report`)
|
||||
const blocked = Object.values(results).flat().flatMap(r => [...(r.blockedRequests?.fetch ?? []), ...(r.blockedRequests?.renderer ?? [])])
|
||||
console.log(`blocked non-loopback requests: ${blocked.length}${blocked.length ? ` (${[...new Set(blocked)].join(', ')})` : ''}; fake model requests: ${ledger.length}`)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
process.exitCode = failed ? 1 : 0
|
||||
} finally {
|
||||
await new Promise(resolvePromise => server.close(() => resolvePromise()))
|
||||
if (!keepProfile) {
|
||||
for (const entry of readdirSync(runDirectory)) {
|
||||
if (entry !== 'artifacts') rmSync(join(runDirectory, entry), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
// Main-process database WRITE benchmark on a real data directory (PERF-15).
|
||||
// Copies assistant.sqlite (+ -wal/-shm) from GB_WPERF_DATA (for example the
|
||||
// portable build's data folder) into a temporary directory - the source is
|
||||
// never opened - then measures, with the production AssistantDatabase, the
|
||||
// writes Main performs synchronously while a run streams:
|
||||
// task-events:subagent replay of the largest real subagent progress stream
|
||||
// (restored events, re-compacted on write, as live)
|
||||
// task-events:tool replay of real tool events
|
||||
// task-events:text 250 ms text batches (persisted buffer)
|
||||
// conversations:save the renderer's 500 ms local save (header + the
|
||||
// streaming assistant message) for a real conversation
|
||||
// activity:update one incremental activity change
|
||||
// For each: per-call time and the longest Main event-loop block.
|
||||
// Event replays are paced at GB_WPERF_RATE events/s (default 270, the busiest
|
||||
// second in real data; 0 = back to back).
|
||||
// Env: GB_WPERF_DATA (required), GB_WPERF_EVENTS (default 2000),
|
||||
// GB_WPERF_OUTPUT (JSON), GB_WPERF_KEEP=1 (keep the copy).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
if (process.env.GB_WPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const source = process.env.GB_WPERF_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) {
|
||||
console.error('[wperf] set GB_WPERF_DATA to a GoodBuddy data directory containing assistant.sqlite')
|
||||
process.exit(2)
|
||||
}
|
||||
const eventLimit = Number(process.env.GB_WPERF_EVENTS || 2000)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-write-perf-'))
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const progressBundle = join(directory, 'subagent-progress.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/assistant/assistant-database.ts', bundle],
|
||||
['src/main/assistant/subagent-progress-storage.ts', progressBundle],
|
||||
['src/main/readonly-query-worker.ts', join(directory, 'readonly-query-worker.cjs')]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)], bundle: true, platform: 'node', format: 'cjs',
|
||||
target: 'node22', outfile, logLevel: 'error', external: ['electron']
|
||||
})
|
||||
}
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
const { restoreSubagentPayload } = require(progressBundle)
|
||||
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
const percentile = (values, p) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
/** Per-call times plus the longest gap between event-loop turns. */
|
||||
async function measure(count, body, { yieldEvery = 1, rate = 0 } = {}) {
|
||||
let probing = true
|
||||
let last = performance.now()
|
||||
let maxGap = 0
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
maxGap = Math.max(maxGap, now - last)
|
||||
last = now
|
||||
if (probing) setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const samples = []
|
||||
const slow = []
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = performance.now()
|
||||
body(index)
|
||||
const elapsed = performance.now() - at
|
||||
samples.push(elapsed)
|
||||
if (elapsed > 50) slow.push([index, round(elapsed)])
|
||||
if (rate > 0) {
|
||||
// Real streams arrive at a rate; wait until this call's scheduled time.
|
||||
const due = started + ((index + 1) * 1000) / rate
|
||||
const wait = due - performance.now()
|
||||
await new Promise(resolve => (wait > 1 ? setTimeout(resolve, wait) : setImmediate(resolve)))
|
||||
} else if ((index + 1) % yieldEvery === 0) await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
const total = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
probing = false
|
||||
return {
|
||||
calls: count, totalMs: round(total), callP50: round(percentile(samples, 50)),
|
||||
callP95: round(percentile(samples, 95)), callP99: round(percentile(samples, 99)),
|
||||
callMax: round(Math.max(...samples)), loopMax: round(maxGap), slowCalls: slow.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[wperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const copyStarted = performance.now()
|
||||
for (const name of ['assistant.sqlite', 'assistant.sqlite-wal', 'assistant.sqlite-shm']) {
|
||||
if (existsSync(join(source, name))) copyFileSync(join(source, name), join(directory, name))
|
||||
}
|
||||
// Initialization checks note files against the database.
|
||||
if (existsSync(join(source, 'notes'))) cpSync(join(source, 'notes'), join(directory, 'notes'), { recursive: true })
|
||||
console.log(`[wperf] copied data in ${round((performance.now() - copyStarted) / 1000)} s`)
|
||||
const workspace = join(directory, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
const openStarted = performance.now()
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
const results = { openMs: round(performance.now() - openStarted) }
|
||||
// Experiment switch: override the durability level after initialization.
|
||||
if (process.env.GB_WPERF_SYNCHRONOUS) raw.exec(`PRAGMA synchronous = ${process.env.GB_WPERF_SYNCHRONOUS}`)
|
||||
results.synchronous = raw.prepare('PRAGMA synchronous').get().synchronous
|
||||
if (process.env.GB_WPERF_AUTOCHECKPOINT) raw.exec(`PRAGMA wal_autocheckpoint = ${process.env.GB_WPERF_AUTOCHECKPOINT}`)
|
||||
// As in production: checkpoints run on a worker thread (GB_WPERF_CHECKPOINT_WORKER=0 disables).
|
||||
if (process.env.GB_WPERF_CHECKPOINT_WORKER !== '0' && typeof database.enableWalCheckpointWorker === 'function') {
|
||||
database.enableWalCheckpointWorker(join(directory, 'readonly-query-worker.cjs'))
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
results.walAutocheckpoint = raw.prepare('PRAGMA wal_autocheckpoint').get().wal_autocheckpoint
|
||||
const count = (sql, ...args) => Number(raw.prepare(sql).get(...args).n)
|
||||
results.taskEvents = count('SELECT COUNT(*) AS n FROM task_events')
|
||||
results.messages = count('SELECT COUNT(*) AS n FROM messages')
|
||||
results.activityRecords = count('SELECT COUNT(*) AS n FROM activity_history_records')
|
||||
|
||||
// The real task with the most subagent events, restored in order.
|
||||
const sourceTask = raw.prepare(
|
||||
`SELECT task_id FROM task_events WHERE kind = 'subagent' GROUP BY task_id ORDER BY COUNT(*) DESC LIMIT 1`
|
||||
).get()?.task_id
|
||||
const blocks = new Map()
|
||||
const subagentEvents = sourceTask ? raw.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE task_id = ? AND kind = 'subagent' ORDER BY id LIMIT ?`
|
||||
).all(sourceTask, eventLimit).map(row => restoreSubagentPayload(JSON.parse(row.payload_json), blocks)) : []
|
||||
const toolEvents = raw.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE kind = 'tool' ORDER BY id DESC LIMIT ?`
|
||||
).all(eventLimit).map(row => JSON.parse(row.payload_json))
|
||||
|
||||
const conversation = raw.prepare(
|
||||
`SELECT conversation_id AS id FROM messages GROUP BY conversation_id ORDER BY COUNT(*) DESC LIMIT 1`
|
||||
).get()?.id
|
||||
const taskId = randomUUID()
|
||||
database.createTask({ id: taskId, title: 'write perf', instructions: 'replay', workMode: 'ask', conversationId: conversation })
|
||||
|
||||
const replay = (events, kind) => index => {
|
||||
const event = { ...events[index % events.length], requestId: taskId }
|
||||
database.appendTaskEvent(taskId, kind, event)
|
||||
}
|
||||
if (process.env.GB_WPERF_INSPECT) {
|
||||
const at = Number(process.env.GB_WPERF_INSPECT)
|
||||
const { compactSubagentPayload } = require(progressBundle)
|
||||
const event = subagentEvents[at]
|
||||
const t0 = performance.now()
|
||||
const json = JSON.stringify(event)
|
||||
const t1 = performance.now()
|
||||
const restoredBlocks = new Map()
|
||||
for (let i = 0; i < at; i += 1) restoreSubagentPayload(JSON.parse(JSON.stringify(compactSubagentPayload(subagentEvents[i], restoredBlocks))), restoredBlocks)
|
||||
const t2 = performance.now()
|
||||
const compact = JSON.stringify(compactSubagentPayload(event, restoredBlocks))
|
||||
const t3 = performance.now()
|
||||
console.log(`[wperf] event ${at}: full ${json.length} B (stringify ${round(t1 - t0)} ms), compact ${compact.length} B (compact ${round(t3 - t2)} ms)`)
|
||||
console.log(`[wperf] event ${at} progress blocks: ${(event.progress ?? []).length}, updates: ${(event.progressUpdates ?? []).length}`)
|
||||
}
|
||||
// Paced at the busiest second seen in real data (269 subagent events/s); 0 = back to back.
|
||||
const rate = Number(process.env.GB_WPERF_RATE ?? 270)
|
||||
if (subagentEvents.length) {
|
||||
results.subagentEvents = await measure(subagentEvents.length, replay(subagentEvents, 'subagent'), { rate })
|
||||
}
|
||||
if (toolEvents.length) results.toolEvents = await measure(toolEvents.length, replay(toolEvents, 'tool'), { rate })
|
||||
const delta = 'streamed text '.repeat(40)
|
||||
results.textBatches = await measure(400, index => database.appendTaskEvent(taskId, 'text',
|
||||
{ type: 'text', requestId: taskId, delta: `${delta}${index}` }))
|
||||
|
||||
// Renderer local save: the conversation header and the streaming assistant message growing.
|
||||
if (conversation) {
|
||||
const snapshot = database.getConversation(conversation)
|
||||
const header = {
|
||||
id: snapshot.id, projectId: snapshot.projectId, title: snapshot.title,
|
||||
runtimeSelection: snapshot.runtimeSelection, updatedAt: Date.now()
|
||||
}
|
||||
const messageId = randomUUID()
|
||||
let content = ''
|
||||
results.conversationSave = await measure(200, index => {
|
||||
content += 'Lorem ipsum dolor sit amet, streaming reply chunk. '.repeat(8)
|
||||
database.saveLocalConversations([{
|
||||
header: { ...header, updatedAt: Date.now() + index },
|
||||
messages: [{ id: messageId, role: 'assistant', state: 'streaming', content, createdAt: Date.now() }]
|
||||
}])
|
||||
})
|
||||
results.conversationGet = await measure(20, () => database.getConversation(conversation))
|
||||
}
|
||||
|
||||
const sample = database.getActivityHistoryPage({ limit: 50 }).records
|
||||
if (sample.length) {
|
||||
results.activityUpdate = await measure(200, index => database.updateActivityHistory({
|
||||
changes: [{ type: 'upsert', position: 'in-place', record: { ...sample[index % sample.length], detail: `perf ${index}` } }]
|
||||
}))
|
||||
}
|
||||
results.walBytes = existsSync(join(directory, 'assistant.sqlite-wal'))
|
||||
? require('node:fs').statSync(join(directory, 'assistant.sqlite-wal')).size : 0
|
||||
database.close()
|
||||
|
||||
console.log(`[wperf] ${results.taskEvents} task events, ${results.messages} messages, ${results.activityRecords} activity records; open ${results.openMs} ms`)
|
||||
const rows = [
|
||||
['task event: subagent (real stream)', results.subagentEvents],
|
||||
['task event: tool (real)', results.toolEvents],
|
||||
['task event: text batch', results.textBatches],
|
||||
['conversation save (500 ms tick)', results.conversationSave],
|
||||
['conversation get (largest)', results.conversationGet],
|
||||
['activity update, 1 record', results.activityUpdate]
|
||||
].filter(([, value]) => value)
|
||||
console.table(rows.map(([name, r]) => ({
|
||||
path: name, calls: r.calls, 'p50 ms': r.callP50, 'p95 ms': r.callP95, 'p99 ms': r.callP99,
|
||||
'max ms': r.callMax, 'loop max ms': r.loopMax, 'calls/s': Math.round(r.calls / (r.totalMs / 1000))
|
||||
})))
|
||||
for (const [name, r] of rows) if (r.slowCalls.length) console.log(`[wperf] slow calls (index, ms) ${name}: ${JSON.stringify(r.slowCalls)}`)
|
||||
console.log(`[wperf] synchronous=${results.synchronous} wal_autocheckpoint=${results.walAutocheckpoint} wal=${results.walBytes} B`)
|
||||
const output = process.env.GB_WPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-write-benchmark', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
if (process.env.GB_WPERF_KEEP === '1') console.log(`[wperf] kept ${directory}`)
|
||||
else rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
# GoodBuddy 性能原则
|
||||
|
||||
## 文档信息
|
||||
|
||||
- **适用范围:** Desktop(Electron Main、Preload、Renderer)及其后台进程和线程。
|
||||
- **用途:** 新功能设计和代码评审的约束。多个 agent 并行修改代码时,以本文为统一标准。
|
||||
- **关联文档:** `docs/roadmap/product-performance-experience-improvement-plan.md`(测量记录与实施进度)。
|
||||
- **参照:** VS Code 的进程分工和渲染做法。我们没有在同一台机器上实测过 VS Code,下面的预算按常见的流畅标准设定。
|
||||
|
||||
## 1. 性能预算
|
||||
|
||||
“流畅”用下面的数字判断,不靠感觉。“标准机”指不降速运行 `npm run perf:app`,“低端机”指加上 `GB_PERF_CPU_THROTTLE=4`。
|
||||
|
||||
| 指标 | 标准机 | 低端机 |
|
||||
| --- | --- | --- |
|
||||
| 按键到下一帧,p95 | ≤ 16 ms | ≤ 50 ms |
|
||||
| 点击到界面响应(切换会话、打开面板),p95 | ≤ 100 ms | ≤ 300 ms |
|
||||
| 稳定交互期间(打字、滚动、流式输出)超过 50 ms 的长任务 | 0 | 尽量为 0,总时长不超过场景时长的 5% |
|
||||
| 流式输出、滚动时的帧间隔,p95 | ≤ 17 ms | ≤ 33 ms |
|
||||
| Main 事件循环单次阻塞 | ≤ 16 ms | — |
|
||||
| Main 事件循环延迟,p99 | ≤ 30 ms | — |
|
||||
| DOM 节点数 | 不随会话数、消息数增长 | 同左 |
|
||||
|
||||
标准机的阈值由 `npm run perf:check` 检查。低端机的数值目前只做记录,等结果稳定后再加进阈值检查。
|
||||
|
||||
## 2. 原则
|
||||
|
||||
### P1 Main 只做调度
|
||||
|
||||
Main 负责窗口、生命周期、权限和消息转发。数据库查询、文档解析、哈希、向量计算、批量文件读写,都放到 worker 线程或 `utilityProcess` 里执行。
|
||||
|
||||
- 新代码不能在 Main 里使用 `DatabaseSync` 和同步文件 API。lint 已有白名单,名单只能减少。
|
||||
- 暂时必须留在 Main 的同步操作,单次耗时要能证明在 16 ms 以内,并在代码里注明理由。
|
||||
- 耗时可能超过 16 ms 的操作,必须支持取消和进度上报,不能一直占着 Main。
|
||||
|
||||
### P2 界面状态局部订阅
|
||||
|
||||
状态按领域放进外部 store,组件通过 selector 只订阅自己用到的那一部分。
|
||||
|
||||
- 不再往 `App.tsx` 里加 `useState`、`useEffect` 或领域逻辑。`App.tsx` 有行数上限,只能往下调。
|
||||
- IPC 订阅放在 store 模块里,组件不直接调用 `window.goodbuddy.*.on*`。
|
||||
- 一个按键、一个流式片段,只能让显示它的那一小块界面重新渲染。
|
||||
- 传给 memo 组件的回调和 context 值,引用必须稳定。
|
||||
|
||||
**模块约定(便于维护,所有拆分都按这个结构做):**
|
||||
|
||||
| 层 | 文件命名 | 内容 | 不允许 |
|
||||
| --- | --- | --- | --- |
|
||||
| Store | `<domain>-store.ts` | `create<Domain>Store()` 返回 `getState / subscribe / 领域动作`;纯 TS,不依赖 React;可单测 | 访问 DOM、调用 React hook |
|
||||
| 选择器 / Hook | `<domain>-selectors.ts` 或 `use-<domain>.ts` | 基于 `useSyncExternalStore` 的 selector,返回值引用稳定 | 在 selector 里新建对象却不做相等比较 |
|
||||
| 同步层 | `<domain>-sync.ts` | 与 Main 的 IPC:首次加载、订阅推送、增量保存队列、失败重试、退出前刷盘 | 放在组件里;整份数据回写 |
|
||||
| 视图 | `<Domain>*.tsx` | `memo` 组件,只从 selector 和稳定的动作读数据 | 接收 App 的大对象或内联回调 |
|
||||
|
||||
- `App.tsx` 只负责布局外壳、路由和把 store 实例通过 context 提供给子树。新增领域状态一律走上表四层。
|
||||
- 每个 store 的单元测试和 store 文件放在一起(`<domain>-store.test.ts`),覆盖顺序、失败重试和边界情况。
|
||||
- Main 侧对应:`<domain>` 的数据库访问集中在一个 repository 类里,IPC handler 只做校验和转发。
|
||||
|
||||
### P3 高频数据走专门通道,按帧合并
|
||||
|
||||
流式文本、终端输出、进度、滚动这类高频数据,不进全局状态。
|
||||
|
||||
- 多条更新合并后,每帧最多提交一次;同步到全局状态的频率要更低,目前是 250 ms。
|
||||
- 高频路径上不能读取布局(`getBoundingClientRect`、`scrollHeight` 等),布局读取要集中到每帧一次。
|
||||
- 合并更新不能丢失、重复或打乱顺序。每条数据带序号,并有测试覆盖。
|
||||
|
||||
### P4 数据只有一份来源,只传变化部分
|
||||
|
||||
- 每类数据只有一个权威来源,由数据所在进程负责持久化。界面不把整份数据写回。
|
||||
- 跨进程的通知直接带上变化的内容,接收方不再“收到通知后重新查全量”。
|
||||
- 写入合并成批次和事务,不按每个事件单独开一个事务。
|
||||
|
||||
### P5 工作量和可见内容成正比
|
||||
|
||||
- 长列表只渲染可见部分,包括消息、会话、笔记、文件树和知识库列表。
|
||||
- 解析和渲染的结果要缓存,比如 Markdown 语法树。内容没变,就不重新计算。
|
||||
- 增量处理:只解析变化的部分。没有把握时,退回到慢但正确的完整处理,并记录在测试里。
|
||||
|
||||
### P6 启动只做首屏需要的工作
|
||||
|
||||
- 首屏之前只做显示应用壳和恢复上次界面所需的工作。
|
||||
- 其他初始化(Provider、Runtime、知识库、通知渠道等)按需执行,或等首屏显示后再空闲执行。
|
||||
- 后台模块在第一次使用时才加载。
|
||||
|
||||
### P7 先测量,再修改
|
||||
|
||||
- 先跑测量,拿到数据再改代码;每项改动都要有前后对比,基线用临时 worktree 构建。
|
||||
- 每个版本至少跑 3 轮,交替运行;结果差异小于同一版本多次运行之间的波动时,不能说成“有提升”。
|
||||
- 测量用隔离的用户目录和本地假模型,不发出外部模型请求,结果里不含用户内容。
|
||||
|
||||
### P8 正确性优先于速度
|
||||
|
||||
- 事件顺序、持久化、取消、崩溃恢复和显示结果都不能因为性能而改变。
|
||||
- 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。
|
||||
- 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。
|
||||
|
||||
### P10 体验不倒退
|
||||
|
||||
性能优化只能让体验变好,不能用体验换速度。“行为不变”不仅指数据和结果,也包括用户能感知到的交互:
|
||||
|
||||
- **动画和过渡保持原样。** 平滑滚动(如“到底部”按钮)、展开收起、悬停、淡入淡出都不能因为优化而消失。任何瞬间写入 `scrollTop`/`scrollTo` 的代码都会打断正在进行的平滑滚动,必须在平滑滚动期间暂停。
|
||||
- **只渲染可见部分(P5)时,用户状态不能丢。** 行被卸载后,它的局部状态也会消失。以下行必须保持挂载,或者把状态存到行外:有焦点的行、用户展开过 `<details>` 或输入过内容的行、选中文本的两端、待回答的问题和待审批项、打开菜单或正在重命名的行。
|
||||
- **只渲染可见部分时,基本操作要和完整列表一致:** 跨行选择和复制、滚动位置不因上方插入或删除而跳动(关闭原生 `overflow-anchor` 时要自己做锚定)、快速拖动滚动条时没有空白帧、渲染结果缓存让重新挂载的行不出现“加载中”。
|
||||
- **合并和批处理不能增加交互延迟。** 第一条数据立即处理(leading edge),只合并之后同一帧内的数据。打字回显、终端回显不能多等一帧。
|
||||
- **低频同步不能让用户看到旧数据。** 复制、导出、保存这类读取全局状态的操作,要先把待同步的数据刷进去。
|
||||
- **体验要有测试。** 涉及动画、滚动、焦点的改动,需要真实 Electron 测试(逐帧采样)或单元测试,并确认测试在旧代码上会失败。
|
||||
|
||||
### P9 用工具守住规则
|
||||
|
||||
- lint 规则和性能阈值是合并前的硬性条件,不靠人工记得。
|
||||
- 规则只能收紧,白名单和上限只能往下调。
|
||||
- 每个 agent 只提交自己改的文件和片段,不把别人未提交的改动带进自己的提交。
|
||||
|
||||
## 3. 评审清单
|
||||
|
||||
提交性能相关改动,或者会影响热点路径的功能改动时,逐项确认:
|
||||
|
||||
1. Main 里有没有新增同步数据库调用、同步文件 IO,或者耗时可能超过 16 ms 的计算?
|
||||
2. 有没有往 `App.tsx` 里加状态?一次按键或一个流式片段,会让多大范围的界面重新渲染?
|
||||
3. 高频路径上有没有读取布局,有没有每次事件都提交状态?
|
||||
4. 有没有整份数据回写,或者“收到通知后重新查全量”?
|
||||
5. 列表的 DOM 数量会不会随数据量增长?
|
||||
6. 有没有在启动阶段新增同步初始化?
|
||||
7. 是否附上了 `perf:app`、`perf:main` 或 `perf:knowledge` 的前后对比?
|
||||
8. 顺序、取消、持久化和恢复,有没有测试覆盖?
|
||||
9. 动画、平滑滚动、过渡还在吗?有没有新增会打断平滑滚动的瞬间滚动写入?
|
||||
10. 只渲染可见部分的列表,滚走再滚回来,展开状态、输入内容、选区、焦点还在吗?
|
||||
11. 合并或延迟处理后,第一次响应(打字回显、点击反馈)有没有变慢?复制、导出读到的是不是屏幕上的最新内容?
|
||||
|
||||
## 4. 当前差距
|
||||
|
||||
截至 2026-10-04(第一期结项,见 `docs/review/performance-phase-1-report-2026-10-04.md`),按原则对照的主要差距:
|
||||
|
||||
| 原则 | 差距 |
|
||||
| --- | --- |
|
||||
| P1 | 同步数据库调用仍在 Main,没有独立数据进程;但热点读已在 worker,高频写单次 0.1–0.3 ms,WAL checkpoint 在后台线程,启动恢复按索引读取。文档解析、分块和大文件哈希已移到 worker。消息附加数据冗余(子任务工具输出、`tools` 与 `blocks` 重复),大会话读取约 170 ms |
|
||||
| P2 | `App.tsx` 约 7,900 行。输入区、隐藏路由、任务和结果事件、切换会话已不再整体重渲染 App;当前会话的流式吸收(每 250 ms)、队列和活动记录仍经过 App |
|
||||
| P3 | 已基本满足:流式输出和终端输出都已按帧合并 |
|
||||
| P4 | 活动记录已改为增量保存、按页读取(界面只保留已加载的页,计数和分组来自 Main 的汇总);部分 `*Changed` 通知之后还会重新查全量 |
|
||||
| P5 | 已基本满足:消息、会话、笔记、文件树、知识库文档表格都只渲染可见部分 |
|
||||
| P6 | 空配置启动到输入框可用约 0.96 秒;真实数据(2.4 GB)约 1.36 秒。本地工具环境已后台初始化,低频模块按需加载 |
|
||||
| P9 | 性能阈值还没有接入 CI,也没有真实低端机的基线 |
|
||||
@@ -0,0 +1,83 @@
|
||||
# Desktop 0.15.10 发布准备
|
||||
|
||||
日期:2026-10-04。基线 `v0.15.9`,冻结已提交源码
|
||||
`74efbcdc1703e6893764dbb2c48e3d1c47b21fce`,35 个提交。
|
||||
仅纳入已提交实现和发布准备,保留并排除工作区中的导航、文档处理等并行修改。
|
||||
标准六平台 Desktop,不含 LoongArch;Agent 保持已发布的 0.15.3。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 统一项目与会话菜单、当前/全部范围、活动及十个最近会话、悬停预览。
|
||||
- 监督者独立文本模型选择及默认开启的会话 Story Graph 开关。
|
||||
- 活动差量保存、索引分页与完整汇总,schema 57 保留历史并迁移排序;
|
||||
schema 58 增加启动恢复候选消息索引。升级前关闭应用并备份完整数据库
|
||||
和相邻 notes 目录,旧客户端回退须恢复升级前备份。
|
||||
- 文档解析 Worker、依赖延迟加载、本地工具环境后台准备、Composer 和
|
||||
路由等渲染边界、笔记/文件树/知识文档窗口化、滚动和焦点保留。
|
||||
- 终端 Main 合并与 xterm 帧批次,远程事件 16ms 定时 flush;
|
||||
checkpoint 前落库、事务顺序和回放去重保留。
|
||||
- 旧 Runtime 缓存按明确版本和布局命名清理,跳过符号链接;
|
||||
保留当前布局、运行记录、会话和配置。工作栏间距及操作修复。
|
||||
- 高频本地写入临时切换 NORMAL 并恢复原 PRAGMA;嵌套事务由外层决定。
|
||||
保留应用崩溃原子性,但断电/系统崩溃可能丢失最近部分本地写入。
|
||||
远程确认、通道投递和任务生命周期继续 FULL,后台 PASSIVE checkpoint
|
||||
不代表已经验证破坏性断电恢复。
|
||||
- Agent 守护进程只有测试变更,Continue host-v7 仅提取常量,协议和部署
|
||||
行为未变,因此不发布新 Agent。Electron/Node/OpenCode/Continue 锁不变。
|
||||
|
||||
不承诺通用提速比例。窗口化未挂载行不支持页内查找,快速滚动可显示占位;
|
||||
只读 Worker 失效可同步回退。开发报告中的 stable controller generation resume
|
||||
及 Runtime 未到终态错误仍是调查项,本轮不声称修复。
|
||||
|
||||
## 本轮真实 Host 验证
|
||||
|
||||
中断前已针对 `e89db96` 源码使用固定 Host 身份与既有凭据存储,
|
||||
在隔离测试目录经生产 Agent、模型桥、远程 ACP、RemoteEventBatcher
|
||||
和 SQLite 持久化入口执行。后续 `74efbcd` 仅修改工作栏 CSS 间距,
|
||||
不改变已验证的远程实现。OpenCode 探针显式使用已有 Continue 模型配置,
|
||||
不是产品静默回退;未构建生产安装包。
|
||||
|
||||
**4 次真实模型请求,4 次 HTTP 200**:
|
||||
|
||||
| Runtime / 模式 | 批次 | 事件 | checkpoint |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode Ask | 3 | 4 | 2 |
|
||||
| OpenCode Execute | 22 | 42 | 21 |
|
||||
| Continue Ask | 3 | 4 | 2 |
|
||||
| Continue Execute | 21 | 40 | 20 |
|
||||
|
||||
checkpoint 前落库、完整已保存流再次写入返回已存在、Runtime 重建后历史恢复,
|
||||
本轮拥有进程清零。生产 SSH 连接池与终端会话另输出 1000 行 Unicode,
|
||||
13 个输出事件;CRLF 归一化后内容完全一致、序号递增、输出先于退出。
|
||||
最初终端探针选错 Host 路由,发生在模型请求前,真实调用数为零;
|
||||
随后复用成功固定的路由完成。临时 Electron 缓存警告非致命,不算产品修复。
|
||||
|
||||
这些证据不是完整 Renderer、已安装 Agent 升级或全部断线恢复矩阵,
|
||||
也没有验证所有消息投影恢复分支。开发阶段其他模型调用不计入本轮四次。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
按用户持续授权,直接提交、推送并跟进 CI 和发布,不逐步询问。
|
||||
不运行本地测试套件、类型检查、lint 或生产构建;完整验证和构建由精确候选
|
||||
main CI 执行,通过后才创建新的不可变 Desktop 标签。双语说明与 FEATURES
|
||||
同时更新;Agent 不打新标签。开发文档记载的未通过测试不能视为已解决,
|
||||
由此次精确候选 CI 决定,失败须定位修复。
|
||||
|
||||
最终需核验六平台打包、macOS 签名与公证、GitHub Latest 和 20 项资产、
|
||||
北京 OSS 索引一致性与网站 12 种下载选择。不下载或启动安装包,
|
||||
不将候选或推送成功称为发布完成。
|
||||
|
||||
### 首轮候选 CI
|
||||
|
||||
`273a7fa` 的 Desktop CI `37174433765`:5953 通过、59 跳过、3 失败,
|
||||
生产构建未执行;Agent CI `37174433777` 通过。
|
||||
监督设置测试需要先进入“回顾整理”页签再操作其可见保存按钮,
|
||||
Electron 布局测试更新为真实标题“模型与运行限制”,截图锚点限定可见设置面板。
|
||||
保留保存值、再次打开恢复、布局及溢出断言。
|
||||
1200 条活动历史测试会实际显示至 1000 行,专用超时从 30 秒改为 60 秒;
|
||||
不减少数据、不移除分页、清空总数或离页裁剪断言,等待下一次 CI 验证。
|
||||
|
||||
`362fe50` 的 CI `37175597089`:上述三个测试均通过,5955 通过、59 跳过;
|
||||
另一个工作区 KeepAlive 测试在默认一秒内未找到魔法笔记标题。
|
||||
仅将该轮换测试的异步标题等待设为五秒,保留聊天 DOM 身份、旧工作区淘汰
|
||||
及缓存数量断言。CI 观察命令曾出现 GitHub API EOF,最终失败以工作流结果为准。
|
||||
@@ -20,7 +20,8 @@
|
||||
相关验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
生产项目入口是一个固定双行按钮:第一行显示项目名,第二行显示全局活动或“暂无活动”,点击任一行都打开统一工作空间菜单并默认预览当前项目。项目分类依次为“全部项目、本地、远程、消息通道”,点击“全部项目”切换全局范围,悬停预览会话,点击项目直接进入。生产最近会话上限为 10 条,验证状态见实施进度。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,统一菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”包括当前运行、待处理及本次客户端
|
||||
会话内已完成但未查看的会话;完成提示在实际进入聊天后清除,不累计历史完成总数。
|
||||
@@ -40,8 +41,24 @@
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及项目活动级联的实现与验证记录 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及统一工作空间菜单的实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
| [工作空间交互 Demo](./workspace-demo.html) | 左上角开始菜单式项目与并发会话候选方案;配套 `workspace-demo.css` 与 `workspace-demo.js` 仅服务原型 |
|
||||
|
||||
以下保留工作空间 Demo 的历史原型说明,包括最近 5 条的旧上限。生产实现已接入独立的 `WorkspaceMenu`,以功能设计和实施进度为准;Demo 的分类布局、排序、快捷键及模拟设置不作为生产契约。
|
||||
|
||||
工作空间 Demo 可直接打开,展示项目工作空间、会话窗口、全局活动、状态筛选、待办处理与完成待查看。
|
||||
左上角项目和活动摘要打开同一个锚定下拉菜单,没有独立总览页面或全屏遮罩。左栏筛选项目,右栏按状态分组显示紧凑会话列表,列表在菜单内滚动。
|
||||
菜单桌面宽度扩至 920px、高度至 640px,并受视口约束;左栏按本地项目、远程项目、消息通道分组。远程项目按主机归属展示,主机连接状态与会话活动计数分开;分组、主机和通道均使用演示数据。
|
||||
左侧通过本地、远程、消息通道 Tab 点击切换分类,项目悬停预览;演示包含 20 个本地项目、20 个远程项目和 3 个消息通道。搜索跨分类匹配项目名、主机及类型,远程主机分组支持折叠。“全部项目”、搜索和分类固定,项目列表独立滚动。
|
||||
项目行最小高度 52px,无活动时显示名称、工作目录两行;有活动时显示名称、活动状态、工作目录三行,高度随内容增长。当前项目以勾选区分于悬停预览。设置按钮独立,悬停或聚焦显示,触屏常显。设置弹窗可修改演示名称、说明、目录、默认模式及 Runtime;通道名称只读,执行空间不可修改。所有目录均为示例路径,不访问文件系统;原型未模拟真实目录选择、归档删除或远程恢复流程。
|
||||
点击项目入口默认筛选当前项目,点击活动摘要查看全部项目;悬停左侧项目只预览右侧会话。点击项目直接进入并恢复上次打开的会话,尚无会话则创建新会话;点击右侧会话直接进入指定会话。设置按钮独立,不触发项目切换,会话草稿保留。
|
||||
支持浅深主题、窄屏、点击外部关闭、Escape 关闭及 `Ctrl/Command+Shift+K` 切换菜单。
|
||||
菜单底部“新建项目”展开名称表单,创建后进入新项目的首个会话;“新建会话”与状态筛选同排,在所选项目创建会话,全部项目视图下使用当前项目。窄屏状态筛选横向滚动,创建按钮保持可见。均只修改演示数据,重置后清除。
|
||||
页面顶部可模拟后台完成,进入会话后清除该会话的完成提醒;已查看会话仍保留在最近会话中,不额外标记状态。
|
||||
状态沿用正式活动入口:待处理、运行中、已完成;待处理会话区分“等待你的回答”和“等待审批”。不提供“可继续”状态或筛选。“已完成”仍表示完成未查看提醒,不是全部历史完成会话。
|
||||
默认列表将待处理、运行中置顶,其余会话归入“最近会话”,按最近进入、发送或状态更新排序,最多显示 5 条;完成未查看在会话行标注。待处理和运行中不受此上限限制,也不重复列入最近会话。“已完成”筛选展示全部未查看结果,不受最近 5 条限制。没有活动时仍显示最近会话。
|
||||
所有执行、审批和消息均为内存中的模拟,不访问真实项目、不调用 AI;该候选方案尚未接入正式产品。
|
||||
|
||||
Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无需依赖或构建。
|
||||
六个方案共用本次页面会话内的配置,刷新后全部恢复默认,不持久化或修改真实设置。
|
||||
@@ -58,7 +75,7 @@ Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理监控
|
||||
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
|
||||
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
|
||||
空闲”四种场景。项目与会话均为演示数据;该旧级联交互已由生产统一工作空间菜单替代,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。旧原型仍直接打开整窗 Modal,其中使用可搜索应用列表。
|
||||
|
||||
@@ -202,7 +202,7 @@ User Stories 已同步上述入口规则,工具实例行为沿用右侧工作
|
||||
首期覆盖应用中心访问、启用与常驻组合、顺序恢复、默认值与旧值迁移、共同设置、保存失败、
|
||||
知识库固定入口、心跳应用启停、可选应用已打开页面恢复,以及右侧实例选择、本机 Home、托管 SSH、无会话、
|
||||
目录失效、资源部分可用与采样停止。工具内部取消、超时、连接恢复及业务后台生命周期由
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。FR-14 覆盖统一菜单的入口与范围、预览与进入、最近条数、状态筛选、通道创建限制及会话期恢复,规则见第 9 节。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
@@ -257,14 +257,18 @@ ASR 按请求加载与释放,支持独立取消;TTS 尚无执行服务。具
|
||||
- 通知只保存在 Renderer 内存,不跨重载或重启恢复,也不从普通未读状态推导。
|
||||
- 仅当目标会话实际成为当前聊天、设置未打开且文档未隐藏时清除通知;普通侧栏导航和活动菜单
|
||||
导航采用同一查看条件。已在查看的会话完成后不保留通知,恢复可见聊天时也清除通知。
|
||||
- 展开活动项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
- 预览项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
|
||||
没有实时状态且没有已完成未查看通知时才移除活动行。
|
||||
|
||||
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
|
||||
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
|
||||
项目计数。每个项目内依次列待处理、运行中、已完成会话,同类别保留来源顺序。
|
||||
统一菜单的分类、预览范围、状态筛选和项目搜索相互独立。项目名与活动摘要属于同一个按钮,点击任一行打开时分类为“全部项目”,预览范围为当前项目;点击菜单内“全部项目”分类将范围重置为全部。悬停或聚焦项目只更新预览范围,不导航、不读取会话详情。分类按全部项目、本地、远程、消息通道排列;搜索跨分类匹配名称、目录、主机名/地址及通道,搜索期间展开命中的主机组。远程能力关闭时隐藏 SSH 用户项目及其活动,消息通道仍可见。
|
||||
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动行全部移除后返回项目列表;全局无活动行后
|
||||
关闭菜单并隐藏摘要入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
|
||||
移至当前层可用项。关闭菜单后,新的活动只恢复摘要,不会自动重新打开菜单。
|
||||
左栏沿用本地、SSH Host、消息通道的项目顺序,主机支持折叠,不按活动数量排序。右栏合并内存会话摘要与活动行,按 Conversation ID 去重;摘要尚未到达的活动仍可显示。无项目归属的会话只在全部项目范围显示,不增加项目计数;已不存在或不可见项目的会话排除,通道项目只显示通道会话。
|
||||
|
||||
默认筛选依次列待处理、运行中、最近会话。前两组不限条数且不重复列入最近;组内排序时间取最新消息创建时间与本次应用会话内访问/状态变化时间的最大值,无消息时用会话更新时间参与比较,同时间按 ID 排序,不按置顶优先排序。最近组最多 10 条,已完成未查看会话归入最近组并标注状态;“已完成”筛选列出全部完成未查看会话,不受 10 条限制。
|
||||
|
||||
首次活动状态快照只建立基线,不更新最近时间;后续状态变化更新时间,唯独从已完成变为无活动的提醒清除不更新时间。实际打开会话仍记录访问时间;悬停预览不记录。访问与状态时间仅保留在当前 Store 内存中,不跨重载或重启恢复。
|
||||
|
||||
点击项目经离开检查进入聊天,优先恢复本次应用会话内为该项目记住的有效 Conversation ID,否则按最新消息时间从候选会话中选最近一条,无消息时取会话更新时间。普通项目无会话时创建;通道项目只选通道会话,无会话时显示空态。记忆不跨重载或重启保存。点击会话仍按确切 ID 导航;设置按钮独立,不触发项目切换。“新建会话”使用预览项目,全部项目范围使用当前项目;目标缺失或为消息通道时禁用。
|
||||
|
||||
全局无活动时第二行显示“暂无活动”,按钮仍可打开菜单并预览当前项目的最近会话或空态,也可在菜单内切换全部项目范围。活动归零不改变控件高度,也不关闭已打开的菜单。点击外部、焦点离开、Escape 或隐藏侧栏关闭菜单,后台活动不自动打开。查看预览不清除完成通知,实际进入可见聊天才按上述规则清除。
|
||||
|
||||
@@ -146,10 +146,11 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的待处理、运行中与已完成未查看会话,并从活动列表直接进入所选会话。
|
||||
汇总入口仅在有上述活动行时显示在项目选择器下方,无活动行时整行隐藏。点击后先显示有活动会话的项目,
|
||||
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
|
||||
各项目的活动数量;级联菜单内的项目顺序不随各类活动数量变化。
|
||||
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
|
||||
左上角使用一个固定两行的按钮:第一行显示项目名,第二行显示全局活动摘要,无活动时显示“暂无活动”,按钮高度不随活动变化。点击任一行打开同一个锚定工作空间菜单,默认预览当前项目,全部项目范围从菜单内切换;无活动时仍可查看最近会话。
|
||||
左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。
|
||||
悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。
|
||||
右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。
|
||||
活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-10-04:双行入口合并为单个按钮
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。项目名和活动摘要现在属于同一个 `60px` 按钮,共用键盘焦点、展开状态和菜单关联。点击任一行默认预览当前项目,全部项目范围在菜单内切换;活动描述和按钮外的恢复反馈通过 `aria-describedby` 关联。移除了独立摘要触发引用与入口范围状态,保留最近 10 条及现有活动计数,没有新增轮询。
|
||||
|
||||
本次验证:`ProjectSwitcher`、`WorkspaceMenu`、`WorkspacePrimitives` 共 80 项通过;App 活动、审批、问题队列和流式侧栏相关测试 37 项通过;`npm run typecheck` 与 `npm run lint` 通过。真实 Electron overlay harness 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下通过,验证单按钮、空闲与活动状态的 `60px` 高度、第二行点击、菜单布局和 Escape 焦点恢复,模型调用 0 次。
|
||||
|
||||
## 2026-10-04:双行入口与菜单布局调整
|
||||
|
||||
本节保留此次单按钮更正前的实现与验证记录;现行入口规则见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对 `ProjectSwitcher.tsx`、`WorkspaceMenu.tsx` 和 `workspace-menu.css`:左上角控件固定高 `60px`,项目名和全局活动为同级按钮,第二行无活动时显示“暂无活动”;恢复反馈位于控件下方并关联可访问描述。左栏搜索框使用 `border-box` 限制边界,两栏之间使用 `--border-control` 分隔线,全部项目及搜索结果补充分组标题,右栏状态工具栏收紧间距。菜单项目行继续按名称、活动、目录分行显示,高度随内容增长。具体规则见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
以下结果由实现方提供,本次文档更新未重跑产品测试:
|
||||
|
||||
- 79 项定向组件测试、68 项项目相关 App 测试通过。
|
||||
- 完整类型检查与修改范围 lint 通过。
|
||||
- 真实 Electron 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下验证搜索框边界、分隔线、紧凑工具栏、项目三行信息,以及左上角控件在空闲和活动状态下的固定高度与焦点。
|
||||
- 模型调用 0 次。
|
||||
|
||||
最近会话上限已由 5 条调整为 10 条,生产设计与验收条目已同步。最终运行 `npx vitest run --project unit src/renderer/src/workspace-menu-selectors.test.ts src/renderer/src/WorkspaceMenu.test.tsx src/renderer/src/ProjectSwitcher.test.tsx`,3 个文件共 44 项通过,覆盖最近 10 条、活动不占最近名额、已完成筛选超过 10 条,以及完成后保留历史。Demo 文件未修改,仍保留原型的最近 5 条行为。
|
||||
|
||||
## 2026-10-04:生产统一工作空间菜单
|
||||
|
||||
本节保留首次接入时的历史实现与验证范围,其中最近 5 条为当时上限,现行调整见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对生产 `WorkspaceMenu.tsx`、`workspace-menu-selectors.ts`、`ProjectSwitcher.tsx` 与 `App.tsx` 差异:项目入口与活动摘要共用菜单,替代独立 `ProjectActivity` 级联。已接入全部项目优先的分类 PageTabs、悬停预览与点击进入、最近 5 条、同排状态筛选与新建会话、真实项目设置和创建、项目三行信息,以及会话期内上次打开记录。预览只用内存数据,项目和会话列表复用共享窗口化;规则见[活动逻辑](./logic-design.md#9-项目活动汇总)与[技术设计](./technical-design.md#12-项目活动汇总)。
|
||||
|
||||
最终源码补充:菜单最近时间取最新消息时间与 Store 会话期访问/状态时间的最大值;`recordActivityStatuses` 首次只建立基线,清除完成提醒不更新时间。项目上次打开 ID 仍只保留在内存,获准后的项目进入及新建回调使用 `commitView`,避免重复离开检查。
|
||||
|
||||
以下为实现代理提供的验证结果,本次仅更新文档,未重跑产品测试:
|
||||
|
||||
- 82 项定向测试通过。
|
||||
- 31 项选定 App 测试通过。
|
||||
- 1 项真实 Electron overlay 测试通过,最终场景也在全量测试运行中通过。
|
||||
- 最终完整 `npm run typecheck`、`npm run lint` 通过。
|
||||
- 全量 `npm test` 在 600 秒超时前未完成。已结束的 App 文件共 347 项,346 项通过、1 项监督者设置测试失败;`tests/supervisor-layout.electron.test.ts` 也失败。尚无证据将这些失败归为既有问题,全量测试未通过。
|
||||
- 首次 200 秒尝试报告 `activity-history-repository` worker 失败;第二次截至超时未报告该处失败。同期有并行性能修改,未确定失败原因,也不据此认定已修复。
|
||||
|
||||
本次没有性能基准结论。README 保留未提交的工作空间 Demo 描述,并区分生产菜单与旧演示;下方级联菜单记录保留当时实现和验证范围,不再定义当前生产交互。
|
||||
|
||||
## 2026-10-01:设备共享入口
|
||||
|
||||
新增 `device-sharing`,启用后应用菜单及管理 Modal 均进入主内容区页面;管理行可排序。
|
||||
@@ -428,7 +468,7 @@ Windows Electron 43.2.0 / Chromium 150 的当前组件 fixture 验证浅深主
|
||||
|
||||
## 2026-09-13:生产活动入口
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e统一工作空间菜单)。
|
||||
本页只记录项目活动入口,不表示应用中心计划已交付。
|
||||
|
||||
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
|
||||
|
||||
@@ -468,7 +468,7 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks`、`completedConversationIds` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
|
||||
`ProjectSwitcher` 使用单个固定双行按钮显示项目名与全局活动,活动为空时第二行显示空闲文案,统一打开 `WorkspaceMenu` 并默认预览当前项目;生产 `App` 不再挂载独立 `ProjectActivity` 级联菜单。按钮通过 `aria-describedby` 关联活动描述及控件外的恢复反馈,尺寸与样式见活动界面。
|
||||
- `use-unviewed-completions.ts` 在 Renderer 内存维护完成集合和上一轮 Task 状态;已观察到的
|
||||
顶层 Task 转为 `completed` 时加入集合,初始历史不生成通知。`App` 的本地 `done` 事件和
|
||||
活动运行持久化快照的成功终态调用 `markConversationCompleted`,失败、取消不调用。
|
||||
@@ -477,16 +477,15 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
`visibilitychange`,文档不为 `hidden` 时才按当前会话清除通知。这使普通侧栏、活动菜单
|
||||
导航和可见性恢复共用查看规则,KeepAlive 挂载本身不代表查看。集合不写入 SQLite 或设置。
|
||||
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空才隐藏摘要。
|
||||
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
|
||||
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
|
||||
- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。
|
||||
项目层锚定摘要,会话层锚定选中项目行并独立限制窗口边界。布局在打开、层级变化、实时数据更新、窗口缩放和
|
||||
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
|
||||
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
|
||||
菜单关闭与焦点恢复在导航回调之前同步完成。
|
||||
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
|
||||
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空时以“暂无活动”替换计数,保留全局入口。
|
||||
- `App` 向 `ProjectSwitcher` 传入 `conversationStore`、活动行、侧栏可见状态和确切会话跳转、新建回调。`WorkspaceMenu` 持有分类、预览范围、搜索及状态筛选;分类复用 `PageTabs`,状态筛选复用 `SegmentedControl`,项目行与真实设置/创建表单继续由 `ProjectSwitcher` 提供。
|
||||
- `workspace-menu-selectors.ts` 订阅会话 Store,投影 ID、项目、标题、排序时间及通道标记,以字段相等比较保留未变化结果;排序时间为 `Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0)`。与活动行按 ID 合并后派生分组和最近 10 条;仅有活动、尚无摘要的行使用 Store 时间,缺失时取 0。悬停只改变 Renderer 预览范围,不发 IPC、不加载详情或请求模型。打开菜单所需的既有主机快照读取与悬停预览分开。
|
||||
- Store 的 `activityTimes` 保存本次应用会话内的访问/状态变化时间,更新时间为 `Math.max(Date.now(), lastActivityTime + 1)`。`ProjectSwitcher` 调用 `recordActivityStatuses`:首次快照只建立基线,后续状态变化触发更新,但 `completed` 变为无状态的完成提醒清除不触发更新。`WorkspaceMenu` 经 `useSyncExternalStore` 订阅该时间映射;时间不持久化,悬停不更新,普通侧栏原有消息排序规则不变。
|
||||
- 项目、会话列表均使用共享 `useListWindow`,分别估算 `64px`、`56px` 行高并测量实际高度,保留键盘目标行;范围改变时重置滚动。窗口化仅限制挂载行数,不截断活动集合,不代表已有性能基准结果。
|
||||
- 菜单通过 `FloatingPortal` 显示同一双栏表面,定位和焦点恢复共用项目按钮的引用;尺寸、窗口和滚动变化时重算位置。非模态 `dialog` 不调用焦点隔离,Escape 关闭整个菜单,Tab 可访问内部控件,焦点离开后关闭。窄窗口仍为双栏,侧栏隐藏时卸载菜单。
|
||||
- 浮层沿用共享 `browser-viewport-occlusion.ts` 相交检测,不增加浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
- `conversationStore.rememberOpened` 在 `App.setActiveId` 时为存在的会话记录访问时间,并记住其项目最后打开的 ID。`projectConversation` 优先返回仍在候选集合中的记忆 ID,否则按 `conversationActivityTime` 选择最近会话,不使用菜单的访问/状态时间;通道项目只选带 `remote` 的会话。记忆仅存在当前 Store 的 Map 中,不持久化、不跨重载恢复。普通项目无会话时创建,通道无会话时清空活动 ID。
|
||||
- 项目进入、新建会话及创建项目后的导航均经过 `requestWorkspaceLeave`。获准后的 `commitProjectSelection` 与新建会话 `ready` 回调直接调用 `commitView('chat')`,避免经 `setView` 再次触发离开检查;新建会话就绪后才提交项目、会话及输入框焦点。
|
||||
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
|
||||
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
|
||||
终态不能被旧响应覆盖。
|
||||
@@ -503,5 +502,5 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
完成通知的定向验证应覆盖本地成功、持久化活动运行成功、后台 Task 状态转换、初始历史、
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动行隐藏。
|
||||
验证记录见[项目活动级联实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动时的空闲占位。
|
||||
统一菜单还应覆盖固定双行单按钮及焦点、当前项目默认范围和菜单内全部项目切换、分类与跨分类搜索、搜索框边界与两栏分隔、紧凑工具栏、预览无 IPC、最近 10 条与不限量活动筛选、真实设置/创建入口、会话期恢复及窗口化键盘导航。验证记录见[实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
```text
|
||||
品牌
|
||||
项目选择
|
||||
项目活动汇总(仅有活动时显示)
|
||||
项目名称(固定双行控件的第一行)
|
||||
全局活动汇总/暂无活动(第二行)
|
||||
[新对话] [搜索图标]
|
||||
|
||||
当前对话
|
||||
@@ -307,27 +307,20 @@ OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- 全局汇总按钮仅在有活动行时显示在项目选择器下方,紧凑显示待处理、运行中与已完成数量,无活动行时整行隐藏,
|
||||
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
|
||||
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
|
||||
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
|
||||
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
|
||||
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
|
||||
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
|
||||
相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层,
|
||||
不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
|
||||
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
|
||||
运行中会话之前,已完成未查看会话排在最后并显示“已完成”。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
|
||||
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面和完成通知。完成通知在目标聊天
|
||||
实际可见后清除,从普通侧栏进入也相同;设置覆盖下的聊天或隐藏文档不触发清除。
|
||||
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
|
||||
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
|
||||
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
|
||||
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
|
||||
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
|
||||
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
|
||||
- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”,
|
||||
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
|
||||
高度并独立滚动。成功完成但尚未查看的会话继续保留;所有活动行移除后,关闭已打开菜单并隐藏摘要。
|
||||
左上角是固定高 `60px` 的单个按钮,第一行显示项目名称,第二行显示全局待处理、运行中、已完成数量,无活动时显示“暂无活动”。点击任一行都打开锚定整个按钮的工作空间菜单,默认预览当前项目;菜单内点击“全部项目”切换至全局范围。按钮只有一个键盘焦点、焦点环及展开状态,`aria-controls` 关联菜单,`aria-describedby` 关联活动描述和控件下方的远程恢复反馈。活动变化不改变按钮高度,也不关闭已打开的菜单;长内容单行截断。
|
||||
|
||||
左栏固定搜索、分类及底部“新建项目”,项目列表独立滚动。分类使用共享 `PageTabs` 的 `segmented` 变体,依次为“全部项目、本地、远程、消息通道”,保留 `tablist`/`tab`/`tabpanel`、游标焦点及方向键语义;远程能力关闭时隐藏远程分类。搜索跨分类匹配,SSH Host 分组可折叠,连接状态独立于会话数量。
|
||||
|
||||
左栏使用 `--surface-subtle`,与右栏之间以 `1px` 的 `--border-control` 竖线分隔。搜索框使用 `border-box`,宽度包含边框和内边距,并扣除左右外边距,避免越过分隔线。全部项目及搜索结果按实际存在的本地、远程、消息通道显示分组标题,组间用横线和留白区分;远程组内保留 Host 标题。
|
||||
|
||||
项目行显示名称、活动数量、目录三行,无活动时省略数量行,高度随内容增长;恢复状态继续显示在目录下方。当前项目以勾选和 `aria-checked` 标识,预览使用单独背景。设置按钮独立,桌面悬停或聚焦显示,窄窗口及粗指针设备可直接访问;打开既有真实项目设置,保留目录选择、Runtime、模式、删除及远程恢复等适用操作,不触发项目切换。悬停或聚焦项目只预览右栏,点击项目直接进入;通道无会话时保留空态。
|
||||
|
||||
右栏顶部用共享 `SegmentedControl` 显示“全部、待处理、运行中、已完成”,采用辅助字号和紧凑内边距,与靠右的“新建会话”同排。新建使用预览项目,全部项目范围使用当前项目;消息通道禁用并显示自动创建说明。默认会话列表按待处理、运行中、最近会话分组,最近最多 10 条,完成未查看在行内标注;“已完成”筛选不受此上限限制。会话标题单行截断,全部项目范围补充项目名,不展开 Task、Job 或 Run。无活动仍显示最近会话,无匹配会话时显示空态。
|
||||
|
||||
点击会话关闭菜单并请求确切 ID 导航;通过既有未保存离开检查后,选择所属项目、清除侧栏搜索、未读标记、任务选择和会话菜单,窄窗口关闭侧栏。取消离开保留原页面和完成通知。完成通知仅在目标聊天实际可见后清除,预览、设置覆盖或隐藏文档不触发清除。
|
||||
|
||||
菜单使用共享 `FloatingPortal`,外层为非模态 `dialog`,入口提供 `aria-haspopup="dialog"`、展开状态及控制关系,不增加遮罩或背景隔离。项目列表使用 `menu`/`menuitemradio`,独立操作使用 `menuitem`;会话列表使用 `list`/`listitem` 和按钮。原生浏览器相交遮挡沿用共享机制。
|
||||
|
||||
打开后聚焦选中分类。列表内上下方向键循环移动,Home、End 到首尾;右方向键进入会话列表,左方向键返回预览项目,项目不可见时回到选中分类。Enter、Space 激活按钮;Tab 在控件间正常移动,焦点离开后关闭。Escape 关闭整个菜单并恢复仍可用的触发入口焦点,触发入口已卸载时回到项目入口,不继续关闭窄侧栏。点击外部、再次点击入口或隐藏侧栏也关闭。
|
||||
|
||||
菜单最大宽度 `920px`,高度 `640px` 且受视口限制,距窗口边缘至少 `16px`,锚点下方间隔 `8px`。桌面左栏为 `240px` 至 `360px`,窄窗口仍保留双栏并各自滚动,不使用旧级联返回页。状态筛选可横向滚动,新建按钮保持可见。项目和会话列表均采用共享窗口化渲染,键盘可到达完整列表首尾。
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
- Given 右侧工作栏有多个工具实例
|
||||
- Then Tab 单行横向滚动且“+”持续可见,工作栏停靠回流,不遮罩主页面;布局和 Tab 交互仍由工作栏负责
|
||||
|
||||
## 6. Epic E:项目活动级联
|
||||
## 6. Epic E:统一工作空间菜单
|
||||
|
||||
### US-E1 从摘要进入确切会话
|
||||
|
||||
@@ -364,8 +364,11 @@
|
||||
查看跨项目活动,先定位项目,再进入需要处理的会话。
|
||||
|
||||
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
|
||||
- When 打开全项目活动并悬停或点击另一个项目
|
||||
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
|
||||
- When 打开全项目活动并悬停或键盘聚焦另一个项目
|
||||
- Then 同一菜单右栏预览该项目会话,待处理、运行中优先,最近会话最多 10 条,当前页面和会话保持原选择
|
||||
- And 分类依次为全部项目、本地、远程、消息通道;远程能力关闭时隐藏远程分类及项目,搜索跨分类,远程主机可折叠
|
||||
- When 点击项目
|
||||
- Then 进入该项目,恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目没有会话时新建,消息通道保持空态
|
||||
- When 点击会话
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
@@ -373,20 +376,30 @@
|
||||
- Given 用户选择的确切 Conversation ID 未在内存中或已被删除
|
||||
- When 请求进入该会话
|
||||
- Then 刷新持久化会话列表后仍只查找同一 ID;刷新失败或会话不存在时显示通知并保留原页面和选择,不回退到项目最新会话或其他会话
|
||||
- Given 从项目入口打开菜单
|
||||
- Then 默认预览当前项目;点击“全部项目”恢复全局范围,“已完成”筛选列出全部完成未查看会话,不受最近 10 条限制
|
||||
- When 实际打开会话、产生新消息或活动状态变化
|
||||
- Then 菜单按最新消息与本次应用会话内访问/状态变化时间中的较晚者排序;首次状态载入和仅清除完成提醒不更新时间,悬停不改变排序
|
||||
- When 点击与状态筛选同排的“新建会话”
|
||||
- Then 在预览项目创建,全部项目范围使用当前项目;消息通道禁用手动新建并说明自动创建规则
|
||||
- When 点击项目行独立设置按钮或左栏底部“新建项目”
|
||||
- Then 打开真实设置或创建表单;设置不触发项目切换,创建成功后经离开检查进入新项目
|
||||
- And 项目进入或新建会话已获准离开后,提交聊天视图不再次触发同一离开检查
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
- Given 菜单已打开
|
||||
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
|
||||
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
|
||||
- Given 窗口小于 `620px`
|
||||
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
|
||||
- Given 鼠标从项目行移向相接的会话面板
|
||||
- Then 子菜单保持打开,点击会话正常导航
|
||||
- Then 初始焦点进入选中分类,上下方向键与 Home、End 移动列表焦点,右方向键进入会话列表,左方向键返回预览项目或选中分类;Enter、Space 激活按钮
|
||||
- When 按 Escape
|
||||
- Then 关闭整个工作空间菜单并恢复可用触发入口焦点,窄侧栏保持打开
|
||||
- Given 窄窗口或长列表
|
||||
- Then 两栏有清晰分隔并独立滚动,搜索框不越过左栏边界,全部项目及搜索结果按本地、远程、消息通道显示分组标题;紧凑状态筛选可横向滚动,新建按钮保持可见,触控点击项目直接进入
|
||||
- Given 当前项目已无待处理、运行中或已完成未查看会话
|
||||
- Then 返回项目层并恢复可用焦点;全局无活动时整行隐藏摘要并关闭菜单,新活动到来时只恢复摘要
|
||||
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
|
||||
- Then 显示最近会话或空态;全局无活动时第二行显示“暂无活动”,仍可打开菜单并在其中切换全部项目范围,已打开的菜单仍可使用,新活动不自动打开菜单
|
||||
- And 左上角是一个固定两行高度的按钮,项目名和全局活动共用一个键盘焦点;点击任一行都默认预览当前项目,恢复提示显示在按钮下方并关联可访问描述
|
||||
- When 点击外部、焦点离开或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
- And Tab 可在菜单内访问搜索、分类、项目设置、创建及会话按钮,焦点离开后关闭
|
||||
|
||||
### US-E3 完成后保留至查看
|
||||
|
||||
@@ -396,7 +409,7 @@
|
||||
- When 本次应用会话内的运行成功完成,或已观察到的后台任务转为完成
|
||||
- Then 活动会话保留为“已完成”,全局与项目的已完成计数同步更新
|
||||
- When 从活动菜单或普通侧栏实际进入该会话的可见聊天
|
||||
- Then 清除其完成通知;没有其他活动行时隐藏摘要并关闭菜单
|
||||
- Then 清除其完成通知;没有其他活动行时第二行恢复“暂无活动”,已查看会话仍可出现在最近会话中
|
||||
- Given 聊天被设置覆盖或文档隐藏
|
||||
- When 对应运行完成
|
||||
- Then 完成通知保留,直到设置关闭且目标聊天在可见文档中显示;取消设置离开不清除通知
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
|
||||
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
|
||||
--success: #147566; --success-subtle: #e5f4ee; --warning: #865200;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
--shadow: 0 24px 80px #17263a26;
|
||||
font-family: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||
}
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; --success: #72d8be; --success-subtle: #123d37; --warning: #f2bd66; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 32px; background: var(--surface-subtle); color: var(--text-primary); font-size: 13px; line-height: 1.6; }
|
||||
button, textarea, input { font: inherit; color: inherit; }
|
||||
button { border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; padding: 8px 12px; cursor: pointer; }
|
||||
button:hover { background: var(--accent-subtle); }
|
||||
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
h1, h2, h3, p { margin: 0; } .muted, .eyebrow, .section-label { color: var(--text-secondary); }
|
||||
.eyebrow, .section-label { font-size: 11px; letter-spacing: 1px; }
|
||||
.lab, .lab-foot { max-width: 1440px; margin: auto; display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.lab { margin-bottom: 24px; }.lab strong { font-size: 15px; }.lab span { margin-left: 24px; color: var(--text-secondary); }.tools { display: flex; gap: 4px; }.tools button { border-color: var(--border-default); }
|
||||
.shell { max-width: 1440px; margin: auto; height: calc(100dvh - 160px); min-height: 580px; display: grid; grid-template-columns: 256px minmax(0, 1fr); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { background: var(--surface-canvas); padding: 24px 12px 12px; display: flex; flex-direction: column; border-right: 1px solid var(--border-default); }.brand { display: flex; align-items: center; gap: 8px; margin: 0 8px 32px; }.brand .muted { font-size: 11px; margin-left: auto; }.logo { background: var(--text-primary); color: var(--surface-raised); border-radius: var(--radius-control); padding: 4px 10px; }
|
||||
.workspace-control { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; }#workspace-trigger { position: relative; display: grid; text-align: left; width: 100%; padding: 12px; }#project-name { font-size: 16px; }.expand { position: absolute; right: 12px; top: 20px; font-size: 22px; color: var(--accent); }#activity-trigger { border-top: 1px solid var(--border-default); border-radius: 0; width: 100%; font-size: 10px; padding: 8px; display: flex; gap: 4px; justify-content: space-between; }
|
||||
.new { margin: 16px 0 24px; text-align: left; border-color: var(--border-default); }.section-label { margin: 0 12px 8px; }#sessions { overflow: auto; }#sessions button { width: 100%; text-align: left; display: flex; gap: 8px; align-items: center; }#sessions button span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }[aria-current="true"], button[aria-pressed="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); color: var(--accent); }.sidebar-bottom { margin-top: auto; padding-top: 24px; }.sidebar-bottom > span { display: block; margin: 8px 12px; font-size: 11px; }.sidebar-bottom button { width: 100%; text-align: left; }kbd { font: 10px/1.5 inherit; border: 1px solid var(--border-default); border-radius: 4px; padding: 2px 4px; color: var(--text-secondary); margin-left: 8px; }
|
||||
.workspace { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }.titlebar { padding: 16px 24px; border-bottom: 1px solid var(--border-default); background: var(--surface-subtle); display: flex; justify-content: space-between; gap: 12px; }#conversation { flex: 1; overflow: auto; padding: 48px max(32px, calc((100% - 740px) / 2)); }#conversation h1 { font-size: 28px; font-weight: 550; letter-spacing: -1px; margin: 12px 0 32px; }.user-message { margin: 0 0 32px 48px; padding: 16px 24px; background: var(--surface-subtle); border-radius: var(--radius-card); }.assistant-label { color: var(--accent); font-size: 12px; margin-bottom: 12px; }.answer { font-size: 15px; line-height: 1.9; white-space: pre-wrap; }.execution { margin-top: 24px; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }.execution p { margin: 8px 0 16px; color: var(--text-secondary); }.primary { background: var(--accent); color: var(--surface-raised); }.primary:hover { background: var(--text-primary); color: var(--surface-raised); }#composer { margin: 16px 32px 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 12px 16px; }textarea { display: block; width: 100%; resize: vertical; border: 0; background: transparent; padding: 8px 0; }#composer > div { display: flex; align-items: center; justify-content: space-between; }.lab-foot { margin-top: 16px; color: var(--text-secondary); font-size: 11px; }
|
||||
.waiting { color: var(--warning); }.running { color: var(--accent); }.done { color: var(--success); }.idle { color: var(--text-secondary); }.empty { text-align: center; padding: 48px 16px; color: var(--text-secondary); }#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border: 1px solid var(--accent-selected); background: var(--surface-raised); color: var(--text-primary); border-radius: var(--radius-card); box-shadow: var(--shadow); z-index: 5; }
|
||||
@media (max-width: 800px) { body { padding: 12px; }.lab { flex-wrap: wrap; margin-bottom: 12px; }.lab span { display: none; }.shell { grid-template-columns: 208px minmax(0, 1fr); }.brand .muted { display: none; }#conversation { padding: 24px; }.lab-foot span:last-child { display: none; } }
|
||||
@media (max-width: 560px) { .shell { display: flex; flex-direction: column; height: auto; min-height: calc(100dvh - 150px); }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 12px; }.brand, .new, .section-label, #sessions, .sidebar-bottom { display: none; }.workspace-control { display: flex; }#workspace-trigger { width: 48%; }#activity-trigger { width: 52%; flex-wrap: wrap; align-content: center; border-top: 0; border-left: 1px solid var(--border-default); }.workspace { min-height: 540px; }.titlebar { padding: 12px; }.titlebar > .muted { display: none; }#conversation { padding: 24px 16px; }#composer { margin: 12px; }.user-message { margin-left: 16px; } }
|
||||
#overview { position: fixed; inset: auto; margin: 0; padding: 0; width: min(920px, calc(100vw - 16px)); height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow); background: var(--surface-raised); color: var(--text-primary); overflow: hidden; }
|
||||
#overview:popover-open { display: flex; flex-direction: column; }
|
||||
#activity-trigger[hidden] { display: none; }
|
||||
.menu-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }.menu-header button { font-size: 11px; }
|
||||
.menu-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.project-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--surface-subtle); border-right: 1px solid var(--border-default); }
|
||||
#project-search { margin: 12px 8px 8px; width: calc(100% - 16px); min-width: 0; border: 1px solid var(--border-default); background: var(--surface-raised); border-radius: 6px; padding: 8px; font-size: 12px; }
|
||||
#all-projects { text-align: left; margin: 0 8px 8px; padding: 8px; }
|
||||
#project-tabs { display: flex; gap: 4px; padding: 0 8px 8px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }#project-tabs[hidden] { display: none; }#project-tabs button { flex: 1; white-space: nowrap; font-size: 11px; padding: 6px 4px; }#project-tabs [aria-selected="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
#desktops { display: block; padding: 8px; overflow-y: auto; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.search-result > small { display: block; padding: 0 8px 8px 32px; font-size: 10px; color: var(--text-secondary); }.search-result .desktop { margin-bottom: 0; }
|
||||
.host-heading { cursor: pointer; }.host-heading > span:first-child::before { content: '▸'; margin-right: 4px; }details[open] > .host-heading > span:first-child::before { content: '▾'; }
|
||||
.project-row { position: relative; margin-bottom: 4px; }
|
||||
.project-row .desktop { display: grid; grid-template-columns: 16px minmax(0, 1fr) 14px; min-height: 52px; padding: 8px 44px 8px 8px; gap: 8px; margin: 0; }
|
||||
.project-copy { min-width: 0; display: grid; gap: 2px; }.project-heading { display: flex; align-items: center; gap: 8px; min-width: 0; }.project-heading strong { font-size: 13px; font-weight: 650; }.project-path { display: block; font-size: 11px; line-height: 16px; color: var(--text-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.project-row .project-counts { width: auto; padding: 0; flex-wrap: nowrap; }.current-project { color: var(--accent); font-size: 13px; }
|
||||
.project-settings-button { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); width: 32px; height: 32px; padding: 8px; opacity: 0; pointer-events: none; color: var(--text-secondary); }.project-row:hover .project-settings-button, .project-row:focus-within .project-settings-button { opacity: 1; pointer-events: auto; }
|
||||
.project-copy > .project-counts { font-size: 11px; line-height: 16px; flex-wrap: wrap; gap: 2px 8px; }.project-copy > .project-counts > span { white-space: nowrap; }
|
||||
@media (hover: none) { .project-settings-button { opacity: 1; pointer-events: auto; } }
|
||||
#project-settings { width: min(480px, calc(100vw - 24px)); max-height: calc(100dvh - 32px); overflow-y: auto; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); padding: 24px; box-shadow: var(--shadow); }#project-settings::backdrop { background: color-mix(in srgb, var(--text-primary) 25%, transparent); }
|
||||
#settings-form { display: grid; gap: 16px; }#settings-form header, #settings-form footer { display: flex; align-items: center; gap: 8px; }#settings-form header strong, #settings-form footer small { flex: 1; }#settings-form label { display: grid; gap: 4px; font-size: 12px; }#settings-form input, #settings-form select, #settings-form textarea { width: 100%; min-width: 0; border: 1px solid var(--border-default); border-radius: 6px; background: var(--surface-subtle); color: var(--text-primary); padding: 8px; font: inherit; }
|
||||
.desktop { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px; margin-bottom: 4px; text-align: left; border-color: transparent; background: transparent; }.desktop strong { display: block; margin: 0; min-width: 0; flex: 1; font-size: 12px; font-weight: 500; }.project-counts { display: flex; gap: 8px; font-size: 10px; color: var(--text-secondary); flex-shrink: 0; }
|
||||
#desktops svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
|
||||
.project-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-default); }.project-section h2 { display: flex; align-items: center; gap: 8px; margin: 0 8px 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }.host-group + .host-group { margin-top: 12px; }.host-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 4px 8px; font-size: 11px; }.host-status { border: 1px solid var(--border-default); border-radius: 4px; padding: 0 4px; font-size: 10px; }.host-group .desktop { padding-left: 16px; }
|
||||
@media (min-width: 561px) and (max-width: 800px) { .menu-layout { grid-template-columns: 300px minmax(0, 1fr); } }
|
||||
.activity-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
#menu-new-chat { color: var(--accent); font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#project-form:not([hidden]) { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px; border-top: 1px solid var(--border-default); flex-shrink: 0; }#project-form input { min-width: 80px; width: 120px; flex: 1; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: 6px; padding: 8px; }.desktop strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-top: 0; border-bottom: 1px solid var(--border-default); }
|
||||
#filters { display: flex; gap: 4px; overflow-x: auto; min-width: 0; }#filters button { font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#windows { overflow-y: auto; padding: 8px; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.window-group { margin: 0 0 16px; }.group-title { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500; margin: 8px; }
|
||||
.session-row { display: flex; align-items: center; gap: 12px; text-align: left; width: 100%; padding: 12px 8px; border-radius: 6px; }.session-copy { flex: 1; min-width: 0; }.session-copy strong { display: block; font-weight: 500; }.session-copy small { display: block; font-size: 11px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.row-action { font-size: 10px; flex-shrink: 0; }
|
||||
.overview-footer { padding: 8px 16px; background: var(--surface-subtle); border-top: 1px solid var(--border-default); min-height: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text-secondary); font-size: 11px; }
|
||||
@media (max-width: 560px) {
|
||||
.project-row .desktop { grid-template-columns: minmax(0, 1fr) 12px; padding: 8px 36px 8px 4px; gap: 4px; }.project-row .desktop > svg { display: none; }.project-heading { flex-wrap: wrap; gap: 0; }.project-heading strong { flex-basis: 100%; }.project-settings-button { right: 0; }.project-row .project-counts { font-size: 9px; }.project-row .project-path { font-size: 10px; }
|
||||
#project-tabs { overflow-x: auto; gap: 0; padding-inline: 4px; }#project-tabs button { flex: 0 0 auto; }#project-search { font-size: 11px; padding: 6px; }.search-result > small { padding-left: 8px; }
|
||||
.desktop { flex-wrap: wrap; gap: 4px; }.desktop strong { flex-basis: calc(100% - 24px); }.project-counts { width: 100%; padding-left: 19px; }.host-heading { flex-wrap: wrap; gap: 4px; }.host-group .desktop { padding-left: 8px; }
|
||||
.menu-layout { grid-template-columns: 176px minmax(0, 1fr); }#desktops { padding: 4px; }.desktop { padding: 8px; }.desktop strong { font-size: 12px; }.project-counts { gap: 4px; flex-wrap: wrap; }.overview-toolbar { padding: 8px; }#filters { gap: 0; }#filters button { padding: 4px; }.session-row { align-items: flex-start; gap: 4px; flex-direction: column; }.session-copy { width: 100%; }.overview-footer { padding: 8px; gap: 4px; }.overview-footer span { max-width: 55%; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoodBuddy · 工作空间实验</title>
|
||||
<link rel="stylesheet" href="workspace-demo.css">
|
||||
<script src="workspace-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lab"><strong>GoodBuddy / 工作空间实验</strong><div class="tools"><button id="simulate">模拟后台完成</button><button id="theme">切换深色</button><button id="reset">重置</button></div></header>
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><b class="logo">G</b><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
|
||||
<div class="workspace-control"><button id="workspace-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false"><span class="eyebrow">工作空间</span><strong id="project-name"></strong><span class="expand">▾</span></button><button id="activity-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false" aria-label="查看全部项目活动"></button></div>
|
||||
<button class="new" id="new-chat">+ 新会话</button>
|
||||
<div class="section-label">会话</div><nav id="sessions" aria-label="会话窗口"></nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="titlebar"><span id="breadcrumb"></span><span class="muted">会话窗口</span></header>
|
||||
<article id="conversation" aria-live="polite"></article>
|
||||
<form id="composer"><label for="message" class="eyebrow">继续这项工作</label><textarea id="message" placeholder="输入消息,继续与 GoodBuddy 协作…" rows="2" required></textarea><div><span class="muted">OpenCode · Execute</span><button class="primary" type="submit">发送 ↑</button></div></form>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="lab-foot"><span>Demo · 模拟数据</span></footer>
|
||||
<section id="overview" popover="auto" role="dialog" aria-labelledby="overview-title">
|
||||
<header class="menu-header"><strong id="overview-title">项目与活动</strong><button id="close-overview" aria-label="关闭项目与活动菜单">×</button></header>
|
||||
<div class="menu-layout">
|
||||
<aside class="project-pane">
|
||||
<input id="project-search" type="search" aria-label="搜索项目" placeholder="搜索项目…" autocomplete="off">
|
||||
<button id="all-projects" data-scope="all" aria-pressed="false">全部项目</button>
|
||||
<div id="project-tabs" role="tablist" aria-label="项目类型"></div>
|
||||
<nav id="desktops" role="tabpanel" aria-label="项目列表"></nav>
|
||||
</aside>
|
||||
<section class="activity-pane">
|
||||
<div class="overview-toolbar"><div id="filters" role="group" aria-label="筛选会话状态"></div><button id="menu-new-chat">+ 新建会话</button></div>
|
||||
<div id="windows"></div>
|
||||
</section>
|
||||
</div>
|
||||
<form id="project-form" hidden><label for="project-input">项目名称</label><input id="project-input" maxlength="40" required autocomplete="off"><button type="submit" class="primary">创建</button><button type="button" id="cancel-project">取消</button></form>
|
||||
<footer class="overview-footer"><button id="new-project" aria-expanded="false" aria-controls="project-form">+ 新建项目</button></footer>
|
||||
</section>
|
||||
<dialog id="project-settings" aria-labelledby="settings-title">
|
||||
<form id="settings-form">
|
||||
<header><strong id="settings-title">项目设置</strong><button type="button" id="close-settings" aria-label="关闭项目设置">×</button></header>
|
||||
<label>名称<input id="settings-name" maxlength="120" required></label>
|
||||
<label>说明<textarea id="settings-description" rows="2"></textarea></label>
|
||||
<label>执行空间<input id="settings-space" readonly></label>
|
||||
<label>工作目录<input id="settings-path" required></label>
|
||||
<label>默认模式<select id="settings-mode"><option value="ask">Ask · 只读问答</option><option value="execute">Execute · 完全权限</option></select></label>
|
||||
<label>新对话默认 Runtime<select id="settings-runtime"><option>OpenCode</option><option>Continue</option><option>直连模型</option></select></label>
|
||||
<footer><small class="muted">仅修改 Demo 数据</small><button type="button" id="cancel-settings">取消</button><button type="submit" class="primary">保存</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,355 @@
|
||||
const projects = [
|
||||
{ id: 'buddy', name: 'GoodBuddy', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'web', name: '品牌官网', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'data', name: '数据分析', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'api', name: '后端服务', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'lab', name: '模型实验', kind: 'remote', host: 'gpu-lab', location: '远程 · gpu-lab' },
|
||||
{ id: 'channel', name: '产品讨论群', kind: 'channel', location: '消息通道' }
|
||||
];
|
||||
const hosts = [{ name: 'dev-server', status: '就绪' }, { name: 'gpu-lab', status: '未连接' }];
|
||||
for (let index = 3; index <= 20; index++) {
|
||||
projects.push({ id: `local-${index}`, name: `本地项目 ${String(index).padStart(2, '0')}`, kind: 'local', location: '本地工作空间' });
|
||||
}
|
||||
for (let index = 4; index <= 20; index++) {
|
||||
const host = index % 2 ? 'gpu-lab' : 'dev-server';
|
||||
projects.push({ id: `remote-${index}`, name: `远程项目 ${String(index).padStart(2, '0')}`, kind: 'remote', host, location: `远程 · ${host}` });
|
||||
}
|
||||
projects.push(
|
||||
{ id: 'channel-2', name: '研发协作群', kind: 'channel', location: '消息通道' },
|
||||
{ id: 'channel-3', name: '运维通知群', kind: 'channel', location: '消息通道' }
|
||||
);
|
||||
const icons = {
|
||||
all: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
local: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
|
||||
remote: '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6h.01M7 17h.01"/>',
|
||||
channel: '<path d="M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8Z"/><path d="M7 9h9M7 13h6"/>',
|
||||
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>'
|
||||
};
|
||||
const icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`;
|
||||
for (const project of projects) {
|
||||
project.rootPath = project.kind === 'remote' ? `/srv/projects/${project.id}` : `D:/workspace/${project.id}`;
|
||||
project.description = '';
|
||||
project.defaultMode = 'ask';
|
||||
project.runtime = 'OpenCode';
|
||||
}
|
||||
const initialProjects = structuredClone(projects);
|
||||
const initial = [
|
||||
{ id: 1, project: 'buddy', title: '工作空间交互设计', state: 'idle', detail: '设计讨论已保存,随时可以继续。', prompt: '让项目与活动会话的关系更直观,做出操作系统的感觉。', answer: '左上角就是工作入口。\n\n展开项目与活动菜单,可以查看多个项目正在进行的会话,也可以直接处理等待你的工作。选择项目只筛选列表,进入会话才切换工作现场。\n\n所有状态都在这个下拉菜单中,不需要再打开独立页面。' },
|
||||
{ id: 2, project: 'buddy', title: '登录流程调整', state: 'waiting', detail: '需要你选择:使用单点登录还是邮箱登录?', prompt: '帮我完善内部用户的登录流程。', answer: '已梳理现有认证入口。需要确定首选登录方式,然后继续调整界面。', question: true },
|
||||
{ id: 3, project: 'buddy', title: '导航组件测试', state: 'running', detail: '正在检查键盘导航与焦点恢复。', prompt: '检查导航组件的交互和测试覆盖。', answer: '已经完成组件检查,正在验证键盘导航与焦点恢复。' },
|
||||
{ id: 4, project: 'web', title: '发布新版首页', state: 'waiting', detail: '预览构建已就绪,等待你批准部署。', prompt: '构建新版首页,发布前让我确认。', answer: '预览构建已完成,页面检查通过。请确认是否继续部署。' },
|
||||
{ id: 5, project: 'web', title: '首页文案精修', state: 'done', detail: '完成 3 处文案调整,结果等待查看。', prompt: '把首页介绍改得更简洁、自然。', answer: '首页文案调整已完成。\n\n首屏标题:把想法,变成已完成的工作。\n产品介绍:查资料、写方案、改代码。在一个工作空间里,与 AI 一起推进。\n行动按钮:开始你的第一项工作。' },
|
||||
{ id: 6, project: 'data', title: '本周使用情况分析', state: 'running', detail: '正在汇总各项目的使用趋势。', prompt: '整理本周各项目的使用情况。', answer: '已完成示例数据整理,正在汇总各项目的使用趋势。' },
|
||||
{ id: 7, project: 'api', title: '接口变更记录', state: 'idle', detail: '昨天的会话', prompt: '整理本次接口变更。', answer: '接口变更记录已整理。' },
|
||||
{ id: 8, project: 'lab', title: '推理参数对比', state: 'idle', detail: '上次会话', prompt: '对比两组推理参数。', answer: '上次对比结果已保存。' },
|
||||
{ id: 9, project: 'channel', title: '版本发布讨论', state: 'idle', detail: '今天的会话', prompt: '整理群里的版本讨论。', answer: '本轮发布讨论已整理。' }
|
||||
];
|
||||
for (const project of projects) {
|
||||
if (!initial.some((session) => session.project === project.id)) {
|
||||
initial.push({ id: initial.length + 1, project: project.id, title: '最近工作记录', state: 'idle', detail: '上次会话', prompt: '整理当前工作。', answer: '工作记录已保存。' });
|
||||
}
|
||||
}
|
||||
const labels = { waiting: '待处理', running: '运行中', done: '已完成' };
|
||||
const symbols = { waiting: '!', running: '◌', done: '✓' };
|
||||
const statusLabel = (session) => session.state === 'waiting'
|
||||
? session.question ? '等待你的回答' : '等待审批'
|
||||
: labels[session.state] || '';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
let sessions, current, scope = 'all', filter = 'all', projectTab = 'local', toastTimer, menuTrigger, activityOrder, settingsProjectId;
|
||||
const remembered = {};
|
||||
const drafts = {};
|
||||
const overview = $('overview');
|
||||
const projectOf = (session) => projects.find((p) => p.id === session.project);
|
||||
const badge = (state) => `<span class="${state}">${symbols[state]} ${labels[state]}</span>`;
|
||||
function notify(text) {
|
||||
$('toast').textContent = text;
|
||||
$('toast').hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { $('toast').hidden = true; }, 3500);
|
||||
}
|
||||
function render() {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const project = projectOf(session);
|
||||
$('project-name').textContent = project.name;
|
||||
$('breadcrumb').textContent = `${project.name} / ${session.title}`;
|
||||
$('activity-trigger').innerHTML = '<span class="muted">全部</span>' + ['waiting', 'running', 'done'].filter((state) => sessions.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${sessions.filter((s) => s.state === state).length} ${labels[state]}</span>`).join('');
|
||||
$('activity-trigger').hidden = !sessions.some((s) => labels[s.state]);
|
||||
$('sessions').innerHTML = sessions.filter((s) => s.project === session.project).map((s) => `<button data-open="${s.id}" aria-current="${s.id === current}">${statusLabel(s) ? `<span class="${s.state}" aria-label="${statusLabel(s)}">${symbols[s.state]}</span>` : ''}<span>${escapeHtml(s.title)}</span></button>`).join('');
|
||||
$('conversation').innerHTML = `<span class="eyebrow">${escapeHtml(project.location)}${statusLabel(session) ? ` / ${statusLabel(session)}` : ''}</span><h1>${escapeHtml(session.title)}</h1><div class="user-message">${escapeHtml(session.prompt)}</div><div class="assistant-label">G / GoodBuddy</div><div class="answer">${escapeHtml(session.answer)}</div>${session.state === 'waiting' ? `<section class="execution"><span class="waiting">! ${statusLabel(session)}</span><p>${escapeHtml(session.detail)}</p>${session.question ? '<button class="primary" data-resolve="单点登录">使用单点登录</button> <button data-resolve="邮箱登录">使用邮箱登录</button>' : '<button class="primary" data-resolve="批准部署">批准并继续</button> <button data-resolve="暂不部署">暂不部署</button>'}</section>` : session.state === 'running' ? `<section class="execution">${badge('running')}<p>${escapeHtml(session.detail)} 切换到其他工作空间后仍会继续。</p></section>` : ''}`;
|
||||
$('simulate').disabled = !sessions.some((s) => s.state === 'running');
|
||||
renderOverview();
|
||||
}
|
||||
function renderOverview() {
|
||||
const focused = document.activeElement;
|
||||
const focusScope = focused?.dataset.scope;
|
||||
const focusFilter = focused?.dataset.filter;
|
||||
const projectButton = (p) => {
|
||||
const list = sessions.filter((s) => p.id === 'all' || s.project === p.id);
|
||||
const counts = ['waiting', 'running', 'done'].filter((state) => list.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${list.filter((s) => s.state === state).length} 个${labels[state]}</span>`).join(' ');
|
||||
const active = sessions.find((s) => s.id === current).project === p.id;
|
||||
const detail = p.kind === 'local' ? `本地目录 · ${p.rootPath}` : p.kind === 'channel' ? `消息通道 · ${p.rootPath}` : p.rootPath;
|
||||
return `<div class="project-row"><button class="desktop" data-scope="${p.id}" title="${escapeHtml(p.name)}\n${escapeHtml(detail)}">${icon(p.kind === 'channel' ? 'channel' : 'folder')}<span class="project-copy"><span class="project-heading"><strong>${escapeHtml(p.name)}</strong></span>${counts ? `<span class="project-counts">${counts}</span>` : ''}<small class="project-path">${escapeHtml(detail)}</small></span><span class="current-project" ${active ? 'aria-label="当前项目"' : 'aria-hidden="true"'}>${active ? '✓' : ''}</span></button><button class="project-settings-button" data-settings="${p.id}" aria-label="管理项目 ${escapeHtml(p.name)}" title="项目设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-.7 3-2 .9-2.7-.8-2 3.4 2 2.2v2.6l-2 2.2 2 3.4 2.7-.8 2 .9L9 23h4l.7-3 2-.9 2.7.8 2-3.4-2-2.2v-2.6l2-2.2-2-3.4-2.7.8-2-.9L13 3Z" transform="translate(1 -1) scale(.95)"/><circle cx="12" cy="12" r="3"/></svg></button></div>`;
|
||||
};
|
||||
const query = $('project-search').value.trim().toLocaleLowerCase();
|
||||
const kinds = { local: '本地', remote: '远程', channel: '消息通道' };
|
||||
if (!$('project-tabs').children.length) {
|
||||
$('project-tabs').innerHTML = Object.entries(kinds).map(([kind, label]) => `<button id="tab-${kind}" role="tab" data-tab="${kind}" aria-controls="desktops">${label} <small></small></button>`).join('');
|
||||
}
|
||||
$('project-tabs').hidden = Boolean(query);
|
||||
$('project-tabs').querySelectorAll('[data-tab]').forEach((button) => {
|
||||
const selected = button.dataset.tab === projectTab;
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.tabIndex = selected ? 0 : -1;
|
||||
button.querySelector('small').textContent = projects.filter((p) => p.kind === button.dataset.tab).length;
|
||||
});
|
||||
$('desktops').setAttribute('aria-label', query ? '搜索结果' : kinds[projectTab]);
|
||||
$('all-projects').setAttribute('aria-pressed', String(scope === 'all'));
|
||||
const shown = projects.filter((p) => query
|
||||
? `${p.name} ${p.rootPath} ${p.host || ''} ${kinds[p.kind]}`.toLocaleLowerCase().includes(query)
|
||||
: p.kind === projectTab);
|
||||
const projectMarkup = query
|
||||
? shown.map((p) => `<div class="search-result">${projectButton(p)}<small>${kinds[p.kind]}${p.host ? ` · ${escapeHtml(p.host)}` : ''}</small></div>`).join('')
|
||||
: projectTab === 'remote'
|
||||
? hosts.map((host) => `<section class="host-group" aria-label="${host.name}"><details open><summary class="host-heading"><span>${host.name}</span><span class="host-status ${host.status === '就绪' ? 'done' : 'muted'}">${host.status}</span></summary>${shown.filter((p) => p.host === host.name).map(projectButton).join('')}</details></section>`).join('')
|
||||
: shown.map(projectButton).join('');
|
||||
// Keep hovered project nodes stable while the right-hand list changes.
|
||||
if ($('desktops').dataset.markup !== projectMarkup) {
|
||||
$('desktops').innerHTML = projectMarkup || '<div class="empty">无匹配项目</div>';
|
||||
$('desktops').dataset.markup = projectMarkup;
|
||||
}
|
||||
$('desktops').querySelectorAll('[data-scope]').forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.scope === scope));
|
||||
});
|
||||
const list = sessions.filter((s) => scope === 'all' || s.project === scope);
|
||||
$('filters').innerHTML = ['all', 'waiting', 'running', 'done'].map((state) => `<button data-filter="${state}" aria-pressed="${filter === state}">${state === 'all' ? '全部' : labels[state]} <small>${list.filter((s) => state === 'all' || s.state === state).length}</small></button>`).join('');
|
||||
const targetProject = projects.find((p) => p.id === (scope === 'all' ? sessions.find((s) => s.id === current).project : scope));
|
||||
$('menu-new-chat').title = `在 ${targetProject.name} 中新建会话`;
|
||||
$('menu-new-chat').setAttribute('aria-label', $('menu-new-chat').title);
|
||||
const groups = filter === 'all' ? ['waiting', 'running', 'recent'] : [filter];
|
||||
$('windows').innerHTML = groups.map((state) => {
|
||||
const sorted = list.filter((s) => state === 'recent' ? s.state === 'done' || s.state === 'idle' : s.state === state)
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
const group = state === 'recent' ? sorted.slice(0, 5) : sorted;
|
||||
if (!group.length) return '';
|
||||
return `<section class="window-group"><h2 class="group-title">${state === 'recent' ? '最近会话' : badge(state)} <span class="muted">${group.length}</span></h2>${group.map((s) => `<button class="session-row" data-open="${s.id}" title="${escapeHtml(s.detail)}"><span class="session-copy"><strong>${escapeHtml(s.title)}</strong>${scope === 'all' ? `<small>${escapeHtml(projectOf(s).name)}</small>` : ''}</span><span class="row-action ${s.state}">${s.state === 'done' ? '✓ 已完成' : s.state === 'waiting' ? statusLabel(s) : s.id === current ? '当前' : '↗'}</span></button>`).join('')}</section>`;
|
||||
}).join('') || '<div class="empty">暂无会话</div>';
|
||||
if (focusScope) overview.querySelector(`[data-scope="${focusScope}"]`)?.focus();
|
||||
if (focusFilter) overview.querySelector(`[data-filter="${focusFilter}"]`)?.focus();
|
||||
}
|
||||
function openSession(id) {
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
drafts[current] = $('message').value;
|
||||
current = id;
|
||||
session.lastActivity = ++activityOrder;
|
||||
remembered[session.project] = id;
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; }
|
||||
closeMenu();
|
||||
$('message').value = drafts[id] || '';
|
||||
render();
|
||||
$('conversation').scrollTop = 0;
|
||||
}
|
||||
function positionMenu() {
|
||||
const anchor = document.querySelector('.workspace-control').getBoundingClientRect();
|
||||
const top = Math.max(8, Math.min(anchor.bottom + 8, window.innerHeight - 240));
|
||||
overview.style.left = `${Math.max(8, Math.min(anchor.left, window.innerWidth - overview.offsetWidth - 8))}px`;
|
||||
overview.style.top = `${top}px`;
|
||||
overview.style.maxHeight = `${window.innerHeight - top - 8}px`;
|
||||
}
|
||||
function closeMenu() {
|
||||
if (overview.matches(':popover-open')) overview.hidePopover();
|
||||
}
|
||||
function showOverview(project = 'all', trigger = $('workspace-trigger')) {
|
||||
if (overview.matches(':popover-open')) { closeMenu(); return; }
|
||||
menuTrigger = trigger;
|
||||
scope = project;
|
||||
projectTab = projects.find((p) => p.id === (project === 'all' ? sessions.find((s) => s.id === current).project : project)).kind;
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
renderOverview();
|
||||
overview.showPopover();
|
||||
positionMenu();
|
||||
overview.querySelector('[data-scope][aria-pressed="true"]').focus();
|
||||
}
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button');
|
||||
if (!button) return;
|
||||
if (button.dataset.settings) {
|
||||
settingsProjectId = button.dataset.settings;
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
$('settings-name').value = project.name;
|
||||
$('settings-name').readOnly = project.kind === 'channel';
|
||||
$('settings-description').value = project.description || '';
|
||||
$('settings-space').value = project.location;
|
||||
$('settings-path').value = project.rootPath;
|
||||
$('settings-mode').value = project.defaultMode || 'ask';
|
||||
$('settings-runtime').value = project.runtime || 'OpenCode';
|
||||
$('settings-runtime').querySelector('option:last-child').disabled = project.kind === 'remote';
|
||||
closeMenu();
|
||||
$('project-settings').showModal();
|
||||
}
|
||||
if (button.dataset.tab) {
|
||||
projectTab = button.dataset.tab;
|
||||
renderOverview();
|
||||
$('desktops').scrollTop = 0;
|
||||
}
|
||||
if (button.dataset.open) openSession(Number(button.dataset.open));
|
||||
if (button.dataset.scope === 'all') { scope = 'all'; renderOverview(); }
|
||||
else if (button.dataset.scope) {
|
||||
const project = button.dataset.scope;
|
||||
const session = sessions.find((s) => s.id === remembered[project]) ||
|
||||
sessions.filter((s) => s.project === project).sort((a, b) => b.lastActivity - a.lastActivity)[0];
|
||||
if (session) openSession(session.id); else createSession(project);
|
||||
}
|
||||
if (button.dataset.filter) { filter = button.dataset.filter; renderOverview(); }
|
||||
if (button.dataset.resolve) {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const declined = button.dataset.resolve === '暂不部署';
|
||||
session.state = declined ? 'idle' : 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = declined ? '已暂缓部署,等待后续安排。' : `已确认${button.dataset.resolve},正在继续执行。`;
|
||||
session.answer = session.detail;
|
||||
render();
|
||||
notify(declined ? '已暂缓本次部署' : '已处理,工作继续进行');
|
||||
}
|
||||
});
|
||||
$('desktops').addEventListener('pointerover', (event) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
const button = event.target.closest('[data-scope]');
|
||||
if (!button || button.dataset.scope === scope) return;
|
||||
scope = button.dataset.scope;
|
||||
renderOverview();
|
||||
});
|
||||
$('project-search').addEventListener('input', () => { renderOverview(); $('desktops').scrollTop = 0; });
|
||||
$('project-tabs').addEventListener('keydown', (event) => {
|
||||
const tabs = [...$('project-tabs').querySelectorAll('[data-tab]')];
|
||||
const index = tabs.indexOf(document.activeElement);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabs[next].focus();
|
||||
tabs[next].click();
|
||||
});
|
||||
$('settings-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
const name = $('settings-name').value.trim();
|
||||
const path = $('settings-path').value.trim();
|
||||
if (!name || !path) return;
|
||||
if (project.kind !== 'channel') project.name = name;
|
||||
project.rootPath = path;
|
||||
project.description = $('settings-description').value;
|
||||
project.defaultMode = $('settings-mode').value;
|
||||
project.runtime = $('settings-runtime').value;
|
||||
render();
|
||||
$('project-settings').close();
|
||||
};
|
||||
$('close-settings').onclick = $('cancel-settings').onclick = () => $('project-settings').close();
|
||||
$('project-settings').addEventListener('close', () => {
|
||||
showOverview(settingsProjectId);
|
||||
overview.querySelector(`[data-settings="${settingsProjectId}"]`)?.focus();
|
||||
});
|
||||
$('workspace-trigger').onclick = () => showOverview(sessions.find((s) => s.id === current).project);
|
||||
$('activity-trigger').onclick = () => showOverview('all', $('activity-trigger'));
|
||||
$('close-overview').onclick = () => { closeMenu(); menuTrigger?.focus(); };
|
||||
$('simulate').onclick = () => {
|
||||
const session = sessions.find((s) => s.state === 'running' && s.id !== current) || sessions.find((s) => s.state === 'running');
|
||||
if (!session) return;
|
||||
const visible = session.id === current && !overview.matches(':popover-open') && !document.hidden;
|
||||
session.state = visible ? 'idle' : 'done';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = visible ? '结果已查看,可以继续这项工作。' : '工作已完成,结果等待查看。';
|
||||
session.answer = '本轮工作已完成。\n\n示例结果:检查项已通过,相关说明已整理。你可以继续追问,或者切换到下一项工作。';
|
||||
render();
|
||||
notify(`${projectOf(session).name} / ${session.title} 已完成`);
|
||||
};
|
||||
overview.addEventListener('toggle', (event) => {
|
||||
const open = event.newState === 'open';
|
||||
$('workspace-trigger').setAttribute('aria-expanded', String(open));
|
||||
$('activity-trigger').setAttribute('aria-expanded', String(open));
|
||||
if (open) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; render(); }
|
||||
});
|
||||
$('theme').onclick = () => {
|
||||
const dark = document.documentElement.dataset.theme !== 'dark';
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
$('theme').textContent = dark ? '切换浅色' : '切换深色';
|
||||
};
|
||||
function createSession(project) {
|
||||
const id = Math.max(...sessions.map((s) => s.id)) + 1;
|
||||
sessions.push({ id, project, title: '新的工作', state: 'idle', detail: '等待你的第一条消息。', prompt: '开始一项新的工作。', answer: '在下方输入你的想法。' });
|
||||
openSession(id);
|
||||
$('message').focus();
|
||||
}
|
||||
$('new-chat').onclick = () => createSession(sessions.find((s) => s.id === current).project);
|
||||
$('menu-new-chat').onclick = () => createSession(scope === 'all' ? sessions.find((s) => s.id === current).project : scope);
|
||||
$('new-project').onclick = () => {
|
||||
$('project-form').hidden = !$('project-form').hidden;
|
||||
$('new-project').setAttribute('aria-expanded', String(!$('project-form').hidden));
|
||||
if (!$('project-form').hidden) $('project-input').focus();
|
||||
};
|
||||
$('cancel-project').onclick = () => {
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
$('new-project').focus();
|
||||
};
|
||||
$('project-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const name = $('project-input').value.trim();
|
||||
if (!name) { $('project-input').focus(); return; }
|
||||
const id = `project-${projects.length + 1}`;
|
||||
projects.push({ id, name, kind: 'local', location: '本地工作空间', rootPath: `D:/workspace/${id}`, description: '', defaultMode: 'ask', runtime: 'OpenCode' });
|
||||
scope = id;
|
||||
$('project-form').reset();
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
createSession(id);
|
||||
};
|
||||
$('composer').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const text = $('message').value.trim();
|
||||
if (!text) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'running' || session.state === 'waiting') { notify('请先完成当前模拟执行或处理待办,再发送消息。'); return; }
|
||||
session.prompt = text;
|
||||
session.state = 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = '正在处理你的消息。';
|
||||
session.answer = '已开始模拟执行。你可以切换工作空间,然后点击页面上方“模拟后台完成”查看通知效果。';
|
||||
$('message').value = '';
|
||||
render();
|
||||
};
|
||||
function reset() {
|
||||
projects.splice(0, projects.length, ...structuredClone(initialProjects));
|
||||
scope = 'all';
|
||||
projectTab = 'local';
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').reset();
|
||||
sessions = structuredClone(initial);
|
||||
sessions.forEach((session, index) => { session.lastActivity = initial.length - index; });
|
||||
activityOrder = initial.length;
|
||||
current = 1;
|
||||
Object.keys(remembered).forEach((key) => delete remembered[key]);
|
||||
Object.keys(drafts).forEach((key) => delete drafts[key]);
|
||||
remembered.buddy = 1;
|
||||
closeMenu();
|
||||
$('message').value = '';
|
||||
render();
|
||||
}
|
||||
$('reset').onclick = reset;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ($('project-settings').open) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.code === 'KeyK') {
|
||||
event.preventDefault();
|
||||
if (overview.matches(':popover-open')) { closeMenu(); menuTrigger?.focus(); } else showOverview();
|
||||
}
|
||||
if (event.key === 'Escape' && overview.matches(':popover-open')) {
|
||||
event.preventDefault(); closeMenu(); menuTrigger?.focus();
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => { if (overview.matches(':popover-open')) positionMenu(); });
|
||||
reset();
|
||||
@@ -18,7 +18,7 @@ Status: Local shortcut/coordinator paths and configured-model responses verified
|
||||
| --- | --- | --- | --- |
|
||||
| Continue | 在终端中打开 | 工作栏新终端页签,启动原生 CLI | 本地和托管 SSH 项目 |
|
||||
| OpenCode | 在终端中打开 | 工作栏新终端页签,启动原生 TUI | 本地和托管 SSH 项目 |
|
||||
| DeepSeek Harness | 在浏览器中打开 | 系统默认浏览器,访问官方 Web 应用 | 本地项目 |
|
||||
| DeepSeek Harness | 打开DSH Web | 系统默认浏览器,访问官方 Web 应用 | 本地项目 |
|
||||
| 内置模型 | 不显示此快捷入口 | 无 | 保持现状 |
|
||||
|
||||
DS 采用官方 `@deepseek-ai/dsh` Web 应用,不开发新的 DS 终端前端,也不在首轮嵌入
|
||||
@@ -164,7 +164,7 @@ DS 后台的 `read` 和原生 `skill` 可在 Ask 执行;`write`、`edit`、`pw
|
||||
|
||||
## 5. 界面设计
|
||||
|
||||
输入区下方从左到右排列“在终端中打开/在浏览器中打开”“压缩上下文”和现有用量信息。
|
||||
输入区下方从左到右排列“在终端中打开/打开DSH Web”“压缩上下文”和现有用量信息。
|
||||
两个按钮独立判断可用性。采用同一轻量操作组,避免两组绝对定位相互覆盖;窄输入区允许
|
||||
操作组换行,保留模式说明、用量信息和对称边距。稳定预留元信息高度的实现应与
|
||||
[统一 UI 规范](../../../UI-DESIGN.md)一起更新并验证。
|
||||
@@ -173,8 +173,9 @@ DS 后台的 `read` 和原生 `skill` 可在 Ask 执行;`write`、`edit`、`pw
|
||||
进入新终端;外部浏览器由系统处理焦点。启动期间保留按钮宽度并显示“正在打开…”,完成后
|
||||
恢复原文案。缺少项目目录时在相关位置提供选择目录的操作;其他异步失败进入全局通知视口。
|
||||
|
||||
DS 服务运行后,在入口旁提供轻量菜单“重新打开”“停止此服务”;操作指向当前匹配服务。
|
||||
若正在生成,停止前说明会中断该服务中的请求。输入区不常驻显示独立会话和服务生命周期说明。不在每次启动时增加确认弹窗。
|
||||
DS 服务运行后,打开入口仍显示“打开DSH Web”,旁边显示“停止DSH服务”;操作指向当前匹配服务。
|
||||
点击停止后,在同一操作行追加紧凑的“确认”“取消”按钮,不再展开整行警告。窄窗口下确认和取消作为一组换行,两个按钮不拆行。
|
||||
确认按钮的悬停提示和无障碍描述说明网页会断开、进行中的请求会中断;执行时由确认按钮显示“正在停止…”。输入区不常驻显示独立会话和服务生命周期说明。不在每次启动时增加确认弹窗。
|
||||
|
||||
按钮快捷入口根据所选 Runtime 显示,不改变工作栏“+”中的普通终端和浏览器入口。已有服务
|
||||
的停止入口应在切换回来后仍可找到。
|
||||
|
||||
@@ -182,7 +182,7 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
||||
- 每个实例使用独立 `tab` 和 `tabpanel`。
|
||||
- “+”固定在 Tab 行末尾,位于 `tablist` 和页签滚动容器之外,占用独立宽度。
|
||||
- Tab 过多时实例区域单行横向滚动,不换行、不自动隐藏实例。“+”在 `300px` 工作栏内也保持可见和可点击。
|
||||
- 标签全部可见时隐藏左右滚动按钮;溢出时左右箭头透明浮动于页签区域两端,不占标签布局宽度,也不进入“+”的点击区域。鼠标移入标签栏时显示可滚动方向的箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可滚动方向的箭头可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用并隐藏,且不拦截下方标签的指针操作。切换或新增激活标签时将其滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
|
||||
- 标签全部可见时移除左右滚动按钮及其占位;溢出时左右箭头在滚动视口两侧各占独立的 28px 宽度,不覆盖标签、关闭按钮或“+”。鼠标移入标签栏时显示可滚动方向的箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可滚动方向的箭头可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用并隐藏;溢出期间隐藏和禁用均保留占位,避免布局跳动。切换或新增激活标签时将整个标签(含关闭按钮)滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
|
||||
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
|
||||
工作目录和 Shell。用户可以重命名终端。
|
||||
- 任务中心、工作区、成果及新建终端的默认标题随界面语言切换,恢复布局时也使用当前语言。
|
||||
|
||||
@@ -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` 仍属后续能力。
|
||||
|
||||
@@ -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`,较晚生成的结果也不自动使旧决定失效。
|
||||
|
||||
本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。
|
||||
|
||||
@@ -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 自主调用图谱
|
||||
|
||||
### 长会话验收
|
||||
|
||||
@@ -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 仅在本节点唯一对应一个片段时可作为引用别名;歧义及未知来源拒绝。
|
||||
|
||||
@@ -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-会话故事图谱开关)。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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-生产接线与剩余边界)。以下报告领取和租约规则继续适用。
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
|
||||
## 应用入口
|
||||
|
||||
“设置 → 模型”还提供“监督者模型”单选框:默认“跟随应用默认”,其余选项为已保存的文本模型连接,以连接名称和模型名区分,不列图片模型。失效的已选连接保留“所选模型已不可用”,用户可改选;加载或保存期间禁用选择。沿用该分类的保存按钮、未保存草稿保护和失败重试,常显说明覆盖的阶段及[生效时点](./logic-design.md#监督者模型选择),不增加凭据输入。
|
||||
|
||||
应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。
|
||||
|
||||
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置只显示模型超时与并发、回顾算法,不含计划、报告、审计或内部页签,也不显示整页全局徽标或独立刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
|
||||
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
|
||||
|
||||
工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。
|
||||
|
||||
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 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-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动页不常显重复说明;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。
|
||||
@@ -36,9 +44,11 @@
|
||||
|
||||
没有自动运行、报告或关联建议时,只展示计划配置和已有计划,不显示零值统计、空趋势、建议、报告或审计区。手动回顾不计入心跳统计;产生自动运行或报告后展示对应结果区。
|
||||
|
||||
2026-09-23 当前已实现“模型超时与并发”表单:心跳报告与监督者整理分别设置,均为 30 至 600 的整数秒,默认 240;监督模型并发数为 1 至 4 的整数,默认 1。保存真实字段 `heartbeatReportTimeoutSeconds`、`supervisorOrganizeTimeoutSeconds`、`supervisorModelConcurrency`;无效输入不能提交,设置加载/保存中或锁定时禁用,失败保留草稿并提供重试。超时以阶段读取设置的时点为准,不改变已冻结的值;并发数影响后续派发,降低上限不打断在途请求。
|
||||
设置的四个分类共用一个设置面板,使用共享表面、边框和卡片圆角。面板内边距为 `--space-4`(16px),分段页签按内容宽度靠左,与下方表单间隔 `--space-6`(24px);页签和表单标题左对齐。表单不再另设边框、底色或内边距,说明段落不叠加浏览器默认外边距。页签及设置面板不参与 Flex 高度压缩,窄窗口页签在自身内部横向滚动,长表单随页面纵向滚动。
|
||||
|
||||
并发说明明确报告和监督整理共用上限,排队不计入模型超时;普通聊天不受此设置限制,但仍与监督竞争上游额度。表单不宣称提供商全局并发、RPM、TPM 或 Retry-After 限流已实现。
|
||||
模型分类的标题为“模型与运行限制 / Model and execution limits”,包含监督者模型选择、整理超时与并发。整理超时为 30 至 600 的整数秒,默认 240;监督模型并发数为 1 至 4 的整数,默认 1。保存字段为 `supervisorModelProfileId`、`supervisorOrganizeTimeoutSeconds`、`supervisorModelConcurrency`;心跳不再单独调用报告模型,此处不显示报告超时。无效输入不能提交,设置加载/保存中或锁定时禁用,失败保留草稿并提供重试。超时以阶段读取设置的时点为准,不改变已冻结的值;并发数影响后续派发,降低上限不打断在途请求。
|
||||
|
||||
并发说明明确监督模型调用共用上限,排队不计入模型超时;普通聊天不受此设置限制,但仍与监督竞争上游额度。表单不宣称提供商全局并发、RPM、TPM 或 Retry-After 限流已实现。
|
||||
|
||||
模型超时不包含数据收集与保存,直连模型另有独立 600 秒传输超时。“回顾算法”表单提供“每次读取来源条数”“每批文本量”“每批消息数”和“单次响应容量”,显示默认值及合法范围,使用“保存回顾算法”独立提交。文本量内部仍按 UTF-16 单元计算,界面不要求用户理解编码单位。保存经 App 的现有设置回调进入共享设置存储,加载/保存/锁定时禁用,失败保留草稿。取值以[当前设置合同](./review-scheduling-design.md#当前设置合同)为准。帮助说明回顾持续处理至完成,每批保存进度,可从活动记录主动暂停并继续;已保存配置用“同时处理任务数”解释并发,说明实际并发还受当前监督设置限制。
|
||||
|
||||
@@ -115,6 +125,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT=1` 验证工作回顾、空态、活动、待处理及失败场景,在 2000、1440、1024、390px 和浅深主题下共 40 个组合,检查全宽、空态居中及位于工具栏下方、阶段连接线和长内容溢出。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_SETTINGS=1` 只验证设置布局:四个分类分别覆盖浅深主题及 1440×1100、1024×768、390×480,共 24 个组合。检查五个主页签、四个设置页签、单一可见面板、16px 内边距、24px 内容间距、无嵌套表单边框及横向溢出,并保存截图与几何测量;使用生产组件和模拟设置,不调用模型。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_PLAN_MODAL=1` 可只验证五页签 fixture 中的计划 Modal:创建和编辑各覆盖浅深主题、1440×1100、390×1100、390×480,检查关闭按钮、底部按钮对齐、正文滚动、鼠标命中和原生键盘操作。截图及测量写入同一产物目录。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_RECAP=1` 可验证本次工作回顾分区:1440×1100 和 390×1100 下覆盖监督结果与自动报告均有内容、仅有监督结果、两者均空,另检查历史结果运行中仍可阅读以及活动入口。该分支检查每页一处刷新、自动报告和建议的归属、正文段落与宽度,并截取自动页建议、展开报告和审计区块。
|
||||
|
||||
@@ -615,6 +615,11 @@
|
||||
| 用户进行普通聊天 | 没有继续工作或历史依据需求 | 不强制每轮查询或注入完整图谱;需要核对原话时才进一步读来源 |
|
||||
| 监督者开启且内置能力已分配,没有自动计划 | 查询已有结果 | 可以读取;查询不触发模型、回顾或业务写入 |
|
||||
| 监督者关闭,旧会话仍缓存工具 | 列出工具或直接调用 | 不暴露工具,调用被拒绝;已有数据保留 |
|
||||
| 监督者应用关闭 | 打开输入区对话设置 | 完全不显示“使用故事图谱”开关 |
|
||||
| 监督者应用开启,会话未保存过该选项 | 打开输入区对话设置 | 知识库上方显示默认开启的共享 Switch |
|
||||
| 当前会话关闭“使用故事图谱” | 本地或远程 Agent 列工具或使用旧能力会话调用 | 不暴露三个图谱工具并拒绝执行,其他会话仍按自己的设置运行 |
|
||||
| 开关已保存 | 切换会话、重启或关闭后重开监督者应用 | 恢复该会话的选择;开关不发起回顾或自动注入上下文 |
|
||||
| 保存失败 | 用户切换开关 | 保留最后确认值并显示错误,可重试 |
|
||||
| 相同范围与证据分别通过本地和远程 Runtime 查询 | 翻页、读取版本或关闭监督者 | 返回相同业务语义与引用,远程实际桥接须通过验收 |
|
||||
|
||||
### US-S31 保留偏好并复用跨范围事实
|
||||
@@ -629,6 +634,19 @@
|
||||
| 仅部分片段成功或原来源已改版 | 再次整理 | 识别未覆盖或变更部分,不把旧版本覆盖算作新版本完成 |
|
||||
| Global 结果含多个项目的归纳 | 从单项目查询 | 不把跨项目摘要伪装成项目结论,复用事实仍保留来源归属 |
|
||||
|
||||
### US-S34 独立选择监督者模型
|
||||
|
||||
对应 FR-S13,生效规则见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||
|
||||
| Given | When | Then |
|
||||
| --- | --- | --- |
|
||||
| 已保存多个文本模型连接 | 在监督者“设置 → 模型”选择连接并保存 | 回顾和建议使用所选连接,无需重新输入凭据 |
|
||||
| 尚未配置监督者模型,或选择“跟随应用默认” | 开始执行 | 使用应用默认文本模型 |
|
||||
| 回顾正在提取或汇总 | 修改模型选择或原连接配置 | 此次回顾后续批次、故事和经验阶段保持原模型 |
|
||||
| 回顾已暂停或失败 | 修改模型后继续 | 使用当前选择继续已保存进度 |
|
||||
| 已选连接被删除或变成图片模型 | 打开设置并尝试执行 | 显示连接不可用,执行报错,用户可改选;不自动回退 |
|
||||
| 模型选择保存失败 | 留在设置页 | 保留选择草稿及重试入口 |
|
||||
|
||||
## 验收优先级与依赖
|
||||
|
||||
首个可用版本应先打通以下路径:
|
||||
|
||||
@@ -82,7 +82,7 @@ Agent 配置、Shell 执行和系统消息接口。
|
||||
和 Electron UI 测试失败;未取得全量通过结果,未修改这些并行工作的文件。
|
||||
|
||||
本次依赖升级回归的真实供应商模型调用为 0 次;后续真实模型及 Web 实测见上节。上述升级验证不包含官方 Web 应用,
|
||||
“在浏览器中打开”入口仍按[原生客户端方案](../assistant-workbar/runtime-native-client-proposal.md)实施。
|
||||
“打开DSH Web”入口仍按[原生客户端方案](../assistant-workbar/runtime-native-client-proposal.md)实施。
|
||||
远程启动契约和资源打包仅支持 Continue、OpenCode,`create-runtime.ts` 拒绝 SSH
|
||||
执行空间使用 DS,因此本次升级未改变远程 Runtime 路径,也未进行 Linux Host 验证。
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Magic Notes Progress
|
||||
|
||||
## 2026-10-03: Compact Notes List And Narrow Drawer
|
||||
|
||||
The notes list now defaults to 200px with a 160px minimum. At workspace widths
|
||||
of 600px or more it can remain beside detail; below 600px the toolbar toggle
|
||||
opens the mounted list as an overlay drawer. Escape, outside click, the close
|
||||
button and note selection dismiss it. Existing desktop width preferences remain
|
||||
intact. Scroll restoration now runs after read-only content effects populate the
|
||||
record, fixing a failure exposed by the Electron layout regression.
|
||||
|
||||
Validation: 143 workspace and list-window tests passed, along with the real
|
||||
Electron layout test covering native resizing, drawer geometry, Escape focus
|
||||
return and note switching. Typecheck and lint on changed code passed. Repository
|
||||
lint was blocked by concurrent App.tsx changes (max-lines error and a missing
|
||||
conversationStore hook dependency warning). No provider calls were made.
|
||||
|
||||
## 2026-10-01: Header New Note Action
|
||||
|
||||
New note is now one primary page-header button that always follows the view
|
||||
|
||||
@@ -21,13 +21,17 @@ stream events, error causes and nested release/dispose cleanup.
|
||||
## Continuous Record Workspace
|
||||
|
||||
The outer workspace separates the persistent notes list from the detail container.
|
||||
The list defaults to 280px, resizes within 240-420px and stores `notesPaneWidth`
|
||||
and `notesPaneOpen` in `goodbuddy.magic-notes-layout.v1`. At workspace widths up to
|
||||
900px, selection replaces the visible list with detail while retaining the mounted
|
||||
list. The right-hand `magic-notes-detail` query container and its ResizeObserver
|
||||
The list defaults to 200px, resizes within 160-420px and stores `notesPaneWidth`
|
||||
and `notesPaneOpen` in `goodbuddy.magic-notes-layout.v1`. Existing saved widths are
|
||||
preserved. Below 600px, selection replaces the visible list with detail while
|
||||
retaining the mounted list. The detail toolbar keeps its list toggle, opening an
|
||||
overlay drawer without shrinking detail. Selecting a note, clicking outside,
|
||||
using the drawer close button or pressing Escape closes the drawer; dismissal
|
||||
returns focus to the toggle. Drawer state is transient and separate from the
|
||||
desktop open preference. The right-hand `magic-notes-detail` query container and its ResizeObserver
|
||||
measure the detail width for two breakpoints: at 800px or less the record index
|
||||
becomes a drawer while AI stays beside the editor; at 600px or less AI stacks
|
||||
below the stream. The 900px list/detail switch is driven by the same measured
|
||||
below the stream. The 600px list/detail switch is driven by the same measured
|
||||
workspace width in both CSS class and React state, so the two cannot disagree.
|
||||
Per-note stream scroll positions live in a workspace-local map; explicit entry
|
||||
navigation takes precedence over reading-position restoration.
|
||||
@@ -405,7 +409,9 @@ The overview's `libraryView` and the optional note-only `detailView` are indepen
|
||||
opening a todo's source note does not lose the originating task view.
|
||||
The notes list remains visible beside detail on desktop unless collapsed. On
|
||||
narrow screens, or when opening a source note from To-dos, the originating list
|
||||
remains mounted but hidden, retaining scroll position and filters. Returning restores the initiating control's focus and
|
||||
remains mounted but hidden, retaining scroll position and filters. The narrow
|
||||
notes view can reopen that list through the detail toolbar drawer toggle.
|
||||
Returning restores the initiating control's focus and
|
||||
invalidates outstanding detail requests. Initial loading fetches summaries and
|
||||
todos without selecting the first note or task. Navigation away from unsaved content or
|
||||
an unsaved title uses the existing draft confirmation; active writes finish
|
||||
|
||||
@@ -52,6 +52,8 @@ OpenCode 与 DeepSeek Harness 不使用该值。
|
||||
|
||||
## 会话与项目 Runtime 选择
|
||||
|
||||
监督者在自身“设置 → 模型”中引用已保存的文本模型连接,不复制连接或凭据;默认跟随与执行生效规则见[监督者模型选择](../conversation-supervision/logic-design.md#监督者模型选择)。
|
||||
|
||||
项目设置分“执行方式”和“模型”两个字段,输入框使用两栏选择器,全局默认模型仍在系统
|
||||
设置中按 Runtime 页签配置。分层规则、选择器状态、键盘行为和失效提示由
|
||||
[执行方式与模型选择设计](./runtime-selection-design.md#3-各入口行为已实现)唯一维护。
|
||||
|
||||
@@ -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-只读工具)。
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
|
||||
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.7,截至 `b02ce31` 的已提交变化,更新于 2026-10-02;保留 2026-10-01 补入的“下一版故事线”预览及配图。除该未来方向预览外,所述功能与修复均已开发完成并提交。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.9,截至 `9527402` 的已提交变化,更新于 2026-10-03。所述功能与修复均已开发完成并提交;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
|
||||
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者按项目回顾进展,并通过图谱保留结论依据;新加入的笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
|
||||
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者把分散的事件归入持续发展的故事,从中整理带有来源和适用条件的经验,供人和助手在后续工作中查阅。笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
|
||||
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签,以及按会话、项目和全局分别选择 Runtime 与模型的方式。远程使用建议配套 Desktop 0.15.7 和 Agent 0.15.1,请先升级桌面端,再更新 Host 环境。
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签、会话图片保存,以及按会话、项目和全局分别选择 Runtime 与模型的方式,并继续改善长会话的浏览与输入。远程使用建议配套 Desktop 0.15.9 和 Agent 0.15.3,请先升级桌面端,再更新 Host 环境。
|
||||
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.7`,指向 `b02ce31`;Agent tag `agent-v0.15.1` 指向 `a662b16`。本轮检查 `86347af..b02ce31`,共 36 个提交。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.9` 与 Agent tag `agent-v0.15.3` 均指向 `9527402`。本轮检查 `b02ce31..9527402`,共 45 个提交;图片保存、笔记取消及默认开关调整已包含在桌面 0.15.8 中。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留。当前数据库格式为 52,包含笔记来源、Runtime 分层选择、心跳建议、笔记标签和统计索引等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前助理数据库格式为 56,包含共享时间线、故事归属、经验及建议等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
|
||||
|
||||
## 新增功能
|
||||
|
||||
### 监督者:从工作记录中整理进展,查看每条结论的依据
|
||||
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项;历史结果保留各自的范围和生成时间,方便回看。
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
|
||||
|
||||
监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。
|
||||
|
||||
@@ -24,38 +24,52 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
回顾中的事件、实体与关系可以在“故事线图谱”中查看。选中一个对象,就能检查它的说明、关联和保存的来源正文,再判断模型的归纳是否准确。实体支持确认、改名或撤销,关系可以确认或移除。
|
||||
|
||||
这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱按事件顺序提供浏览,但还不能逐时刻还原当时的全部实体状态;图上的间距也不表示实际时间间隔。
|
||||
这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱提供平铺和时间螺旋两种浏览方式,时间螺旋按事件时间排列;平铺视图中的节点间距不表示实际时长。历史回顾中的故事分组使用当前保存的故事结构,尚不提供任意时刻的完整状态还原。
|
||||
|
||||
> **【待补图 02|必补|图谱与来源】** 使用图 01 同一份真实回顾结果,选中事件或实体,展示对应的来源正文。画面应能辨认对象与依据的关联;不使用设计原型代替生产结果。计划文件:`assets/02-story-sources.png`。
|
||||
>
|
||||
> 建议图注:从回顾图谱查看关联对象,并打开保存的讨论依据。
|
||||
|
||||
### 下一版故事线:把经历串成故事,让经验有来路
|
||||
### 故事与时间螺旋:追踪一件事如何持续发展
|
||||
|
||||
下一版故事线将用一条连续的时间螺旋呈现交错发生的经历。同一圈里可以发生不同的事,同一件事也可以隔一段时间再继续。时间均匀推进,螺旋半径的起伏表达注意力密度,不代表效率或成果。
|
||||
一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。
|
||||
|
||||
外侧长短不一的“木片”把属于同一件事的事件串起来:一次旅行可能很短,一个产品可能陆续做了几年。沿时间线看,可以了解某个阶段经历了什么;沿一片木片看,可以追踪一件事如何发展,中间的停顿也保留下来。
|
||||
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径随消息活动量变化,不代表实际工时、效率或成果价值。
|
||||
|
||||

|
||||
视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。
|
||||
|
||||
*下一版故事线预览:选中 W 经验,查看它从哪次经历中形成、适用于什么条件,以及后来在哪件事中使用。图中为示例数据。*
|
||||

|
||||
|
||||
W 表达的是从经历中归纳出的经验。例如,产品试验形成了“先验证关键假设,再扩大投入”的认识,后来用于研究项目。它会作为可查看的内容呈现,保留形成依据、适用条件和后续应用;新的实践结果还可以支持、修订或否定这条经验。这个方向希望让故事线既能回答“发生了什么”,也能帮助理解“学到了什么,下一次能怎么用”。
|
||||
*早期概念示意,使用示例数据;不是当前生产界面的截图,实际布局与操作以应用为准。*
|
||||
|
||||
> **【待补图 09|必补|故事与经验的生产视图】** 原图保留作概念参考。发布前用目标版本的真实回顾结果替换,展示同一故事的事件、经验形成依据与后续应用,并核对平铺和时间螺旋中的选择联动。计划文件:`assets/09-story-experience.png`。
|
||||
>
|
||||
> 建议图注:沿故事查看工作进展,从经验节点核对形成依据与后续应用记录。
|
||||
|
||||
### 从故事中整理经验,保留适用条件和依据
|
||||
|
||||
故事积累到一定规模,并出现新的决定、变化或里程碑后,监督者可以从事件摘要中归纳经验,保存适用条件、边界和形成依据。自动归纳的内容有明确标记,用户可以编辑、合并或删除,再回到原讨论核对。
|
||||
|
||||
后续回顾还可以记录某条经验在哪些事件中得到应用。应用记录会核对事件是否晚于最早形成事件的起始时间,并与至少一个形成事件涉及共同实体;这些记录仍是模型对历史的归纳,需要结合来源判断,不能据此断定经验已经产生效果。助手可以查阅这些经验,为后续工作提供参考。
|
||||
|
||||
### 助手可以查阅故事图谱,接着已有决定继续工作
|
||||
|
||||
继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。
|
||||
继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的故事、经验、事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。故事上下文带出相关事件,经验上下文带出形成和应用记录。
|
||||
|
||||
工具说明也会引导助手在继续旧工作、追问决定原因或遇到可能已有约定的任务时主动查阅,优先找故事与经验。是否调用仍由当前模型判断;普通闲聊和独立的通用问题不要求查询,也不会在每次对话中自动塞入整张图谱。
|
||||
|
||||
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;Ask 与 Execute 均可只读查询。本地 Continue、OpenCode 原生终端也可读取。
|
||||
|
||||
查询默认使用当前项目,也可明确指定全局或项目集合;各范围读取各自保存的结果。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,但完整历史状态重建和跨范围事实自动复用不在本次功能范围内。
|
||||
查询默认使用当前项目,也可明确指定全局或项目集合。事件可从共享时间线中按项目查阅,回顾结果仍保留各自的范围。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,尚不支持任意时刻的完整历史状态重建。
|
||||
|
||||
### 智能心跳:按新增工作回顾,并提出可处理的建议
|
||||
|
||||
为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有待处理变化时,不调用模型。
|
||||
为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有新来源时跳过事实提取,仍可补做尚未完成的故事与经验整理。没有待处理工作时,不调用模型。
|
||||
|
||||
建议围绕本次回顾发现的未决事项、分歧、修订和候选约定展开,并保留来源供核对。接受未决事项后,会创建一项暂停任务,方便先检查再开始;候选约定经确认后才进入长期记忆。建议生成失败可以单独重试,已经保存的图谱和回顾进度仍然保留。
|
||||
|
||||
心跳完成回顾后,还可以提示达到设定天数仍没有新进展的故事,或指出某条已有经验可能适用于当前故事。建议可以直接打开图谱;接受暂无进展建议会建立暂停任务,接受经验建议只标记已参考,不会据此写入经验应用记录。暂无进展默认按 14 天筛选;仅时间流逝到期、但本次回顾没有变化时,不会单独触发提醒。
|
||||
|
||||
计划需要应用保持运行,并按所选全局或项目范围处理。心跳不会在每条聊天消息后实时介入;有候选建议时会额外调用模型整理措辞,回顾本身也可能分批调用模型。
|
||||
|
||||
### 在任务中心跟踪当前会话或任务
|
||||
@@ -90,6 +104,8 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“
|
||||
|
||||
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口改为列表、详情切换;记录索引默认收起,“新建笔记”固定在页头。
|
||||
|
||||
正文使用当前栏的可用宽度,宽窗口下阅读画布和长内容有更多空间。新建文字或画布记录都可以取消,有草稿时先确认是否放弃;取消编辑、切换记录类型或返回聊天后,焦点回到对应的按钮或输入位置。
|
||||
|
||||
标签也可由支持魔法笔记工具的助手按你的要求添加或修改。快速采集面板继续用于保存聊天内容,标签管理在完整笔记工作区中进行。
|
||||
|
||||
### 在聊天中查找和整理 Obsidian 笔记
|
||||
@@ -140,6 +156,12 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“
|
||||
>
|
||||
> 建议图注:从 Mermaid 大图窗口导出完整 PNG,保留图中的边缘节点。
|
||||
|
||||
### 让助手把会话图片保存到指定文件
|
||||
|
||||
在 Execute 模式下,可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
|
||||
|
||||
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址;Ask 模式不提供写入操作。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
|
||||
|
||||
### 桌面通知可以单独关闭
|
||||
|
||||
“设置 → 平台功能 → 通用设置”新增“桌面通知”开关,默认开启。关闭后,应用处于后台时的任务完成、失败或取消等系统提醒停止弹出,应用内通知仍然保留。设置保存后立即生效,重启后也会记住选择。
|
||||
@@ -168,15 +190,15 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“
|
||||
|
||||
任务历史改为按批次展示,可以展开处理步骤,查看进行中、失败与历史记录。筛选与计数跟随当前文档或来源,便于检查一次导入的处理情况。
|
||||
|
||||
### 图谱按事件、实体和关系切换
|
||||
### 图谱列表与画布各自浏览
|
||||
|
||||
故事线图谱侧栏将原来上下堆叠的列表改为“事件、实体、关系”页签,各自显示数量并独立滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别,查看关联信息时少一些来回滚动。
|
||||
故事线图谱侧栏可以按故事、经验、事件、实体和关系查看,列表与详情各自滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别。平铺与时间螺旋都使用中间画布的可用空间,视角等控件收进紧凑操作区,减少窗口较矮时内容被挤出屏幕的问题。
|
||||
|
||||
### 手动回顾与智能心跳共用增量进度
|
||||
|
||||
原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。
|
||||
|
||||
手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化,无变化时跳过模型调用。全局与各项目分别记录进度。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。
|
||||
手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化。项目与全局共用来源处理进度:同一版本的来源已成功发布后,换一个范围回顾可以复用,减少重复提取。没有新来源时仍可补做积压的故事与经验整理,没有待处理工作才跳过模型调用。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。
|
||||
|
||||
启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。
|
||||
|
||||
@@ -220,13 +242,19 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“
|
||||
|
||||
长回答逐步生成时,已完成的 Markdown 区块可以复用,减少每次新增文字都重新处理整篇内容。切回近期看过的会话时也会复用缓存结果;会话侧栏与运行记录减少无关刷新,降低回复期间继续输入和切换会话的开销。
|
||||
|
||||
较长会话和大量会话的侧栏主要渲染屏幕附近的内容,历史记录仍然保留,可以继续加载和定位。切回缓存会话时恢复阅读位置;Markdown 解析结果也可复用,包含公式的长回复减少重复解析。
|
||||
|
||||
知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。
|
||||
|
||||
### 设置页减少来回查找
|
||||
|
||||
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
|
||||
|
||||
设置导航按用途分组,开关统一放在右侧。通用设置集中放置会话与通知、工作目录等选项;本地语音和嵌入模型页面提供“模型下载源”快捷入口,可打开统一弹窗,在 ModelScope 与 Hugging Face 之间切换。选择影响后续托管模型下载,已安装模型、ZIP 导入、Ollama 和应用更新不受影响。
|
||||
|
||||
外观设置新增默认关闭的透明磨砂效果。开启后,标题栏和 Runtime 检查清单使用半透明背景,可以看到其后的应用内容;设置会保存,下次启动继续沿用。
|
||||
外观设置新增透明磨砂效果,当前默认开启;标题栏和相关卡片使用半透明背景,可以看到其后的应用内容。可以关闭恢复实色显示,已有的外观选择继续保留。
|
||||
|
||||
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。在 Execute 模式中,请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;Ask 仍保持只读。
|
||||
|
||||
HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。
|
||||
|
||||
@@ -258,19 +286,21 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等
|
||||
|
||||
最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。
|
||||
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.1,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.3,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
|
||||
### 启动、搜索与回顾结果
|
||||
|
||||
- 修复正常聊天或保存笔记后,重启反复显示历史迁移的问题。普通数据库结构升级也不再因正常空闲页额外整理整库;确需转换的旧数据仍会处理,启动页区分实际操作。
|
||||
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出。Ask 保持工作区只读,Execute 使用当前账号权限;这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
|
||||
- 修复早期监督回顾只取部分会话、消息和正文的问题,改为对入选来源分页、分批处理。事实与来源独立保存,只有必要阶段完成并成功保存结果后才标记完成。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。非法格式、未知引用和超出响应容量的结果仍会报错,成功保存的批次可以保留供后续处理。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。网络错误、超时、取消和超出响应容量不走这套重试。
|
||||
- 故事或经验整理失败时,已保存的回顾结果继续可用,可以单独重试整理。故事归类减少把各功能的提交与测试统归到“发布”的情况;经验应用记录也增加时间顺序与共同实体核对,归纳内容仍需结合来源判断。
|
||||
- 时间螺旋退出时释放图形资源;3D 初始化失败或图形上下文丢失时,在视图内提示并提供重试,也可切回平铺,避免整个监督页面无法使用。
|
||||
- 修复历史回顾入口跳到其他结果、切换后旧响应覆盖当前选择的问题。侧栏、活动记录和图谱按对应结果定位,人工确认与修订的内容保留。
|
||||
|
||||
### 日常界面操作
|
||||
|
||||
- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,新消息仍会把会话向前移动,置顶会话继续优先显示。
|
||||
- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,每五秒检查一次;悬停、滚动后的短暂停留、菜单展开或键盘焦点位于列表时暂缓重排,方便点选。置顶会话继续优先显示。
|
||||
- 输入框的附件、语音与选项按钮统一尺寸、间距和悬停反馈,录音中和选项展开时仍保留对应状态提示。
|
||||
- 草稿和排队消息中的图片可以直接打开预览,发送前更容易核对附件。文本输入框移除重复问候,只保留快捷键提示。
|
||||
- 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# 0.13.8 起综合宣传稿核查记录
|
||||
|
||||
## 2026-10-03 截至 9527402 的增量核查
|
||||
|
||||
本轮固定范围 `b02ce31..9527402`,共 45 个提交;全文范围为 `v0.13.7..9527402`。桌面 `v0.15.9` 与 Agent `agent-v0.15.3` 同指向 `9527402`。沿用用户确认的标题和综合稿结构,直接更新已授权草稿;未在线核验公开安装包和下载信息。以下记录取代旧节中的当前状态描述,旧节保留为历史依据。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `5dc6545`、`c3fa262` | 故事归类、共享时间线与时间螺旋,替换原未来方向节 | `supervision-stories.ts`、`supervision-timeline.ts`、`supervision-review-store.ts`、`story-graph-3d-model.ts`、`StoryGraph3D.tsx`;项目/功能/子线索,命名、合并、移动事件、移除和撤销。共享已成功发布来源版本的增量进度,时间与项目取自来源;螺旋半径为消息量,不是工时或成果。未归类事件不进入当前 3D 树;不宣传手动拆分、任意层级调整或完整时点回放 |
|
||||
| `983cdd4`、`4d1ce38`、`ee4eb73` | 经验归纳、形成与应用依据、螺旋外侧经验节点 | `supervision-experiences.ts`、`SupervisionStories.tsx`、3D 数据模型;自动归纳可读且有标记,可编辑、合并、删除。应用事件必须晚于最早形成事件的起始时间,并与至少一个形成事件共享实体,仍是回顾归纳,不是实时执行遥测。3D 每层最多展示 6 条满足跨故事应用条件的经验,完整内容在经验页签 |
|
||||
| `1f420cd` | 工作回顾按故事展示进展与经验,可点击查看 | `supervision-story-digest.ts`、`SupervisionStoryDigest.tsx`、`SupervisorWorkspace.tsx`;按所选结果的范围和时间筛选当前故事结构,不保存当时分类快照,不额外调用模型 |
|
||||
| `43be465`、`e97263c` | 心跳新增暂无进展与可用经验建议,助手按需搜索故事和经验 | `supervision-suggestions.ts`、`heartbeat-service.ts`、`story-graph-tools.ts`、`story-graph-reader.ts`;停滞默认 14 天,但 no_change 不发建议。接受停滞建议建暂停任务,经验建议仅标记参考。主动检索由模型判断,搜索为分词匹配,不能写成语义检索或每轮自动注入;无答案时模型仍可能多次检索 |
|
||||
| `0fd2f7e` | 无效模型输出缩小批次后重试,保留成功结果 | `supervisor-service.ts`;无效 JSON/结构/引用的叶子批次最多额外重试两次,减小文本预算,余文后续继续;导航合并同输入再问一次。网络/提供方错误、超时、取消、响应超容量不纳入。故事/经验整理在回顾发布后执行,失败不撤回回顾 |
|
||||
| `9d8477b`、`a1a1d24`、`50481de` | 图谱占满画布列、独立滚动、紧凑控件、3D 局部恢复 | `SupervisorWorkspace.tsx`、`StoryGraph3D.tsx`、监督样式;退出释放 WebGL,失败不扩大到整个页面,保留平铺选择 |
|
||||
| `dd29f30` | 保存当前会话上传或生成的图片到本机/Host 文件 | `image-generation-service.ts`、图片契约、`image-tool-mcp.ts`、MCP 设置;Execute、绝对路径、默认不覆盖;PNG/JPEG 转换,WebP 仅原格式保存。远程从 Agent 0.15.2 提供;不是任意 URL 下载,不宣传所有格式无损转换 |
|
||||
| `f4d8fe7` | 笔记新记录取消、焦点恢复、正文铺满栏宽 | 提交 diff 及已提交 `MagicNotesWorkspace.tsx`、App 与样式;不包含工作区未提交的笔记列表窗口化 |
|
||||
| `2e5c348`、`b1129ce`、`02a6f5c` | 浏览器与磨砂默认开启,Execute 配置变更减少重复确认 | capability/application settings、`goodbuddy-config-service.ts` 与网关;保留已有关闭选择,不改系统默认浏览器,不透出系统桌面。仅配置计划应用移除重复原生确认,Ask、请求绑定、有效期、单次应用与失败报告仍保留 |
|
||||
| `3d4e957`、`90ea33e`、`bf6c4a9`、`fdf7d83`、`663aa85`、`b6c7de5`、`775763e`,以及 `43be465`、`ee4eb73` 的聊天改动 | 长会话与侧栏可见区域渲染、Markdown 缓存、草稿/流式/会话更新分离、阅读位置恢复、交互时暂缓排序 | 最终 `ChatHistoryPane.tsx`、`ConversationSidebar.tsx`、会话/草稿/live store 与窗口化组件;单条超长消息仍整行处理,历史未删除。不把缓存与窗口化宣传成全程无卡顿。排序每五秒检查并在交互时暂停 |
|
||||
| `f571301`、`3c31f0b`、`d859cbf` | 常用数据库读取后台化、活动记录差异处理、远程事件批量保存 | 只读 worker、活动记录差异计划、remote event batcher 与 IPC;向量查询仍可能全表扫描,worker 失败/繁忙可同步回退。Renderer 活动记录仍发送完整快照,不宣称全链路增量 IPC |
|
||||
| `09c415b`、`855b941` | 过渡性能实现,不叠加宣传 | 新打开会话先显示少量消息再补齐的方案已被最终窗口化替代 |
|
||||
| `80579ee`、`1e10935`、`dc635d1`、`7aad787`、`169941c`、`82107ad`、`baa6def`、`53b9ecb`、`6ced760`、`0ce9606` | 性能基准、归因更正、架构/测试维护,仅内部依据 | 受控设备与假模型测量不能推为普遍收益;部分测量含同期改动,收尾波动也不作为显著提速。不把本地阈值检查写成性能 CI 已全面接通 |
|
||||
| `5f1ff24`、`d773f5e`、`5368e0e`、`8280158`、`20317c2`、`9527402` | 小样式、运行环境、测试与发布维护 | 滚动按钮边框不单列卖点;Electron 44.5.1 与剪贴板适配不写成新增功能。Desktop 0.15.8/Agent 0.15.2 与本轮版本分开,助理 schema 52→56,知识库 schema 仍为 13 |
|
||||
|
||||
新增主线按使用过程展开:事件归为故事,故事形成经验,人通过回顾和图谱查看,助手按需查阅,心跳提出待处理建议。原稿“下一版”改为已实现能力,但原 `assets/09-next-storyline-wisdom.png` 仍是早期模拟概念图,已改图注并增加生产替换标注 09,计划文件 `assets/09-story-experience.png`。不将概念稿中的切面、展开、动画等全部写成生产功能。旧图 01、02、04 也需采用最新故事/经验布局;本轮没有新增截图或空图片链接。
|
||||
|
||||
同步修正旧稿中的按范围独立进度、无新来源必然零模型调用、磨砂默认关闭、即时会话前移及 Agent 0.15.1 配套等描述。升级提示更新为助理数据库 56,备份数据库、WAL 与相邻 `notes/`;旧客户端不能直接打开升级数据库。
|
||||
|
||||
只读核查发现 `supervision-production.ts` 的 `existingGraph` 回调未转发 `batch`,因此不能承诺关闭跨项目关联后,全局回顾的实体候选已按批次项目隔离;正文未作该承诺,本轮不修改产品代码。故事归属阶段自身的跨项目开关与此不同。
|
||||
|
||||
工作区另有文档解析 worker、终端输出合批、笔记/文件/知识列表窗口化、启动测量及架构文档改动,均未纳入已提交增量,也未编辑。故事图谱历史检索未找到相关宣传记录,本轮依据当前文档与固定 Git 区间核查。
|
||||
|
||||
本轮只执行静态核查和文档检查,产品测试、构建、真实模型请求与新截图均为 0。历史性能和 Host 验证仅作参考,不宣称本轮完成跨平台实机或正式包验收;公开下载信息继续待核验。打包、构建及测试维护未写入宣传正文。
|
||||
|
||||
独立事实复核修正两处:移除旧稿笼统的“跨范围事实自动复用不支持”,区分共享事件时间线与按范围保存的回顾结果;经验应用的时间条件明确为晚于最早形成事件的起始时间,不扩大为晚于全部形成依据。文字扫描无阻断项,人工复核保留版本、素材、升级和能力范围的必要限定;唯一现有图片相对链接目标存在,待补图 09 只登记计划路径。文档差异检查通过,未提交、推送或对外发布。
|
||||
|
||||
## 2026-10-02 截至 b02ce31 的增量核查
|
||||
|
||||
当前依据为 `86347af..b02ce31`,共 36 个提交;全文范围为 `v0.13.7..b02ce31`。桌面 `v0.15.7` 指向 `b02ce31`,Agent `agent-v0.15.1` 指向 `a662b16`。本轮未在线核验公开安装包。沿用用户确认的标题和综合稿结构,继续更新已授权的草稿。
|
||||
|
||||
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
|
||||
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-02 更新) | v0.15.7(b02ce31);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补智能心跳、Runtime 分层选择、笔记标签与双栏、设备共享目录及性能改进;保留下一版故事线预览,生产配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-03 更新) | v0.15.9(9527402);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事归类、时间螺旋、经验与心跳建议、助手按需检索、图片保存和长会话性能;原概念图待换为生产截图,公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# 桌面端性能改造(第一期,参照 VS Code 架构)结项报告
|
||||
|
||||
- **时间:** 2026-10-02 至 2026-10-04
|
||||
- **范围:** Desktop 的 Electron Main、Preload、Renderer 及其后台线程。远程主机(Agent、SSH、远程 Runtime)的稳定性和性能不在本期范围,下一期单独审查。
|
||||
- **结论:** 本期计划内的工作已完成。用户日常能感受到的卡顿(打字、流式输出、切换会话、滚动、启动、写库)都已测量并修复。剩下的工作是架构上的长期项(存储接口异步化、独立数据进程),风险高,已经写明理由,留到后续单独立项。
|
||||
- **相关文档:**
|
||||
- 性能原则和评审清单:`docs/architecture/performance-principles.md`
|
||||
- 逐项测量记录:`docs/roadmap/product-performance-experience-improvement-plan.md` 第 7 节(PERF-11 至 PERF-17)
|
||||
|
||||
## 1. 结果
|
||||
|
||||
### 1.1 模拟低端机(Renderer CPU 降速 4 倍)
|
||||
|
||||
| 场景 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 有 300 个会话时打字,按键延迟 p95 | 最高 4 秒 | 约 10–35 ms |
|
||||
| 流式输出时打字,按键延迟 p95 | 最高 9.6 秒 | 约 30–60 ms |
|
||||
| 流式输出期间卡住界面的总时长 | 16–21 秒 | 约 0.3–3 秒 |
|
||||
| 切回刚看过的会话,点击延迟 p95 | 约 530 ms | 约 110–140 ms |
|
||||
| 打开 2,000 条消息的会话 | 约 1.1–1.8 秒 | 约 0.27 秒 |
|
||||
|
||||
### 1.2 标准机
|
||||
- `npm run perf:check` 的 60 项阈值全部通过。
|
||||
- 流式输出、流式输出时打字,超过 50 ms 的长任务均为 0。
|
||||
- 改造前,流式输出 40 KB 回答时约 47% 掉帧,帧间隔 p95 83 ms。
|
||||
|
||||
### 1.3 Main 进程
|
||||
Main 卡顿的数据来自真实数据,以及 5 万个文本块的知识库。
|
||||
|
||||
| 场景 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 5 万个文本块的知识库混合检索 | 490 ms | 19 ms(移到只读 worker) |
|
||||
| 读取会话列表 | 25 ms | 0.8 ms |
|
||||
| 活动记录:启动读取 | 14.2 MB,198 ms | 114 KB,0.6 ms |
|
||||
| 活动记录:保存一条变化 | 114 ms | 6.9 ms |
|
||||
| 导入大 PDF | 0.5–0.7 s | 约 16 ms(移到解析 worker) |
|
||||
| 单次写库(真实数据) | 2.2–2.6 ms | 0.1–0.3 ms |
|
||||
| 写入期间偶发卡顿(真实数据) | 最长 346 ms,超过 50 ms 共 37 次 | 最长 34 ms,0 次 |
|
||||
|
||||
### 1.4 启动
|
||||
真实数据:2.4 GB 数据库,175 万条任务事件。
|
||||
|
||||
| 指标 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 启动到输入框可用 | 3.70 s | 1.36 s |
|
||||
| 侧栏出现会话 | 4.08 s | 1.75 s |
|
||||
| 启动期间 Main 最长阻塞 | 2.5–2.8 s | 约 0.25 s |
|
||||
| 空配置启动 | 1,044 ms | 962 ms |
|
||||
|
||||
## 2. 做了什么
|
||||
|
||||
按性能原则 P1–P10 归类,提交号见计划文档。
|
||||
|
||||
| 原则 | 改动 |
|
||||
| --- | --- |
|
||||
| P1 Main 只做调度 | 知识检索、Story Graph、会话列表、会话搜索、活动记录分页和汇总放到只读 worker;文档解析、分块、哈希放到解析 worker;WAL checkpoint 放到后台线程;远程事件按检查点批量写库;高频本地写入使用 `synchronous = NORMAL`;启动恢复改为按索引读取(schema 58) |
|
||||
| P2 状态局部订阅 | 流式缓冲、输入草稿、会话、任务和产物、活动记录、弹出层都改为独立 store;拆出 Composer、侧栏和隐藏路由并加 memo;切换会话只渲染一遍;`App.tsx` 从 11,246 行降到约 7,900 行,lint 设了上限 |
|
||||
| P3 高频数据按帧合并 | 流式文本按帧合并;终端输出在 Main 端按 8 ms 合并,界面每帧最多写一次;自动滚动每帧最多一次 |
|
||||
| P4 只传变化部分 | 活动记录改为增量保存和分页,统计由 Main 汇总,覆盖全部记录;Markdown 只重新解析变化的段落,并缓存解析结果 |
|
||||
| P5 工作量和可见内容成正比 | 消息时间线、会话侧栏、笔记、文件树、知识库文档表格都只渲染可见部分 |
|
||||
| P6 启动只做首屏需要的工作 | 本地工具环境改为后台初始化;Canvas、终端、SSH、OpenCode SDK 等模块改为用到时再加载;启动恢复改为按索引读取 |
|
||||
| P7 先测量,再修改 | 新增 `perf:app`(支持模拟低端机、内存、CPU 剖析)、`perf:main`、`perf:knowledge`、`perf:document-parse`、`perf:startup`(含 `real` 真实数据变体)、`perf:write`(真实数据回放) |
|
||||
| P8 正确性优先于速度 | 分段渲染与整篇渲染对照测试,含随机语料;活动记录与改造前的整份列表逻辑逐步对照;数据库写入顺序和持久性边界有专门测试 |
|
||||
| P9 用工具守住规则 | lint 规则:主进程禁止同步文件 API、限制 `DatabaseSync` 的使用范围、组件不得直接订阅 IPC、`App.tsx` 行数上限;`perf:check` 阈值 |
|
||||
| P10 体验不倒退 | 修复了 8 处体验倒退:回到底部的平滑动画、滚出可见区后丢失状态、跨消息选择文本、侧栏插入时跳动、终端回显、远程流式输出一顿一顿、复制时缺内容、锚定冲突 |
|
||||
|
||||
## 3. 有意不做的部分
|
||||
|
||||
| 项目 | 理由 |
|
||||
| --- | --- |
|
||||
| 存储接口全部异步化,新增独立数据进程(PERF-15、16 的剩余部分) | 约 500 处同步调用,并且要保证“先写后读”的顺序,风险高。最重的读已经移出 Main,写入已降到每次 0.1–0.3 ms,收益已经不大 |
|
||||
| Markdown 解析放到后台线程 | 需要重写渲染流程。标准机上流式输出已经不卡,只有低端机上的长回答还有余量 |
|
||||
| 窗口提前加载,接口请求排队等待 | 可省 200–300 ms,但要改动所有接口的注册顺序和首次运行流程 |
|
||||
| 会话队列移出 App | 和发送、恢���、中断联系太紧,拆出来的风险大于收益 |
|
||||
| 性能阈值接入 CI | 仓库目前没有 CI 配置 |
|
||||
|
||||
## 4. 已知问题和下一步
|
||||
|
||||
### 4.1 本期遗留
|
||||
- **打开最大的会话要等约 170 ms。** 这个会话有 407 条消息、42 MB 附加数据。读取在 worker 上,不卡界面,但切换时要等。根因是附加数据冗余:
|
||||
- 子任务过程记录约 207 MB,主要是 `read`、`bash` 等工具的完整输出;
|
||||
- 旧的 `tools` 和新的 `blocks` 重复保存,约 65 MB;
|
||||
- 旧数据里有约 4 MB 内嵌图片。
|
||||
|
||||
建议去重,并把工具输出改为按需加载,单独立项。
|
||||
- **持久性的变化。** 高频本地写入改为 `synchronous = NORMAL`:断电时最多丢失最近一段运行事件和会话保存,应用崩溃时不丢。远程事件、通道投递、任务状态仍然每次落盘。
|
||||
- **没有在真实低配 Windows 机器上测过**,也没有用同一标准实测 VS Code 作为对照。
|
||||
- **全量测试中 2 个监督者相关测试失败**,在本期之前就已失败,属于另一项正在进行的工作。
|
||||
|
||||
### 4.2 远程主机(下一期)
|
||||
本期涉及远程路径的改动只有 3 处:
|
||||
- `d859cbf`:远程事件按检查点批量写库;
|
||||
- `805acc3`:远程流式合并间隔从 50 ms 改为 16 ms;
|
||||
- `0dd4d1d`:远程事件仍然每次落盘,未降级。
|
||||
|
||||
用户报告的 `Agent refused the stable controller generation resume` 和 `Runtime stopped before the prompt reached a terminal state` 两个错误,还需要在下一期确认和这些改动有没有关系。确认方式:在改动前后的版本上复现,对照远程事件写入和确认(ACK)的顺序。
|
||||
@@ -737,6 +737,44 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
- **仍在 App:** 未读、展开、菜单、重命名、删除等界面 ID;持久化、历史加载与合并;
|
||||
活动会话的更新(含 250 ms 流式吸收)仍重渲染 App。
|
||||
|
||||
#### 2026-10-03 PERF-13 进展:Composer、路由边界、任务 store、切换会话
|
||||
|
||||
提交 `41be9f8`、`2e3d2e7`、`651475d`、`492f1d2`、`6b634c5`。
|
||||
|
||||
- **Composer(`Composer.tsx`,`memo`):** 输入区页脚(约 930 行 JSX、23 个内联回调)移出 App。
|
||||
草稿读 `composer-draft-store`,会话字段读 `useComposerConversationView`(不含消息数和结果 ID),
|
||||
弹出菜单状态放进 `composer-menu-store.ts`;动作经 `use-composer-actions.ts` 保持同一引用。
|
||||
其他会话更新、当前会话新增消息、别的会话输入都不重渲染 Composer(有测试)。
|
||||
- **路由边界:** `KeepAliveRoute` 改为 `memo`,隐藏路由在 App 重渲染时保持上次结果,再次显示时
|
||||
补一次渲染。知识库、设置、监督、笔记、右侧栏、项目切换器改为 `memo`,知识库 26 个回调移到
|
||||
`use-knowledge-workspace-actions.ts`,其余用 `useStableHandlers`;笔记面板元素 `useMemo`。
|
||||
- **任务与结果(`task-store.ts` / `task-selectors.ts` / `task-sync.ts`):** `assistantTasks`、
|
||||
`assistantArtifacts` 移出 App state,`assistantTasksRef` 删除。App 只选取顶层任务状态、活动摘要
|
||||
所需字段和当前会话是否忙碌,子代理进度不再重渲染 App;结果刷新时内容相同的对象保留引用,
|
||||
会话面板自己读结果表。`agent-event-handler.ts` 只改了任务/结果调用。队列状态未迁移:它和发送、
|
||||
恢复、中断流程交织,单独拆风险高于收益。
|
||||
- **切换会话:** 面板顺序改为渲染期派生(`usePaneOrder`),Composer 弹窗按上下文键关闭,
|
||||
不再在渲染中 setState,切换一次只渲染 App 一次(测试在旧代码上为 2 次)。标题表在标题不变时
|
||||
保持同一 Map。
|
||||
- **规模:** `App.tsx` 9,576 → 7,904 行(含期间合入的监督者改动);App 内 useState 120→114、
|
||||
useEffect 62→56、useRef 79→73、useCallback 89→83。lint 上限 9,600 → 8,000。
|
||||
- **`npm run perf:app` A/B**(改动前为 `b28ddd4` 临时 worktree 构建,与 `6b634c5` 交替各 3 轮,
|
||||
4× 降速,取中位数,括号为 3 轮范围):
|
||||
|
||||
| 场景 | 改动前 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 首次切换会话 延迟 p95 | 525 ms(520–674) | 489 ms(470–607) |
|
||||
| 首次切换 Long Tasks 数 / 总时长 | 37 / 3.9 s | 24 / 2.0 s |
|
||||
| 缓存切换(warm)延迟 p95 | 254 ms(208–256) | 139 ms(135–149) |
|
||||
| 缓存切换 Long Tasks 数 / 总时长 | 21 / 1.7 s | 12 / 0.7 s |
|
||||
| 热切换(hot)延迟 p95 | 139 ms | 94 ms(77–154) |
|
||||
| 流式 40 KB:Long Tasks 数 / 总时长 / 帧 p95 | 90 / 7.0 s / 208 ms | 49 / 3.1 s / 108 ms |
|
||||
| 流式期间输入:延迟 p95 / Long Tasks 总时长 / 帧 p95 | 110 ms / 8.3 s / 233 ms | 53 ms / 3.5 s / 100 ms |
|
||||
| 输入(有 300 个会话)延迟 p95 | 11.7 ms | 14.1 ms(12.6–15.9,噪声范围内) |
|
||||
|
||||
流式两个场景的掉帧率仍接近 100%(4× 降速下每帧仍有 Markdown 解析和 250 ms 吸收),但 Long
|
||||
Tasks 减半。不降速一轮 `GB_PERF_CHECK=1`:60 项阈值全部通过。
|
||||
|
||||
### PERF-14 长列表虚拟化
|
||||
|
||||
#### 2026-10-03 消息时间线窗口化
|
||||
@@ -866,6 +904,105 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
| 页内查找覆盖未挂载的消息 | 当前 App 没有页内查找功能(未调用 `findInPage`),属于新功能,不是回归 |
|
||||
| 性能阈值接入 CI | 仓库目前没有 CI 工作流;`npm run perf:check` 可以在本地运行 |
|
||||
|
||||
### 7.2 按性能原则补齐差距(2026-10-03,第三轮并行)
|
||||
|
||||
统一标准见 `docs/architecture/performance-principles.md`(P1–P10、性能预算和评审清单)。本轮按其中“当前差距”并行推进:
|
||||
|
||||
| 原则 | 改动 | 测量结果 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 文档解析、PPTX 提取、分块、大文件(≥ 1 MiB)哈希移到 `document-parse-worker` 线程,FIFO 队列;worker 不可用或崩溃时退回原来的 Main 内联执行,30 秒后重试;中止正在执行的任务会终止 worker。数据库写入仍由调用方按原顺序执行 | `npm run perf:document-parse`,各 3 轮:Main 最长阻塞 large.pdf 498–679 → 16–17 ms,large.docx 313–335 → 17–20 ms;总耗时基本不变 |
|
||||
| P3 | 终端输出:Main 端按 8 ms 窗口合并(空闲时第一块立即发出,不增加回显延迟),单批不超过 64 KiB;控制事件(退出、错误、状态)发出前先清空缓冲,顺序不变;背压按“已发出 + 已缓冲”字节计算,暂停阈值留 1/4 余量。Renderer 端每帧最多写一次 xterm,写完后按最大序号累计 ACK;窗口不可见时 100 ms 定时兜底,保证 ACK 不中断 | 单元测试覆盖顺序、UTF-8 边界、背压、退出前清空;`perf:app` 未覆盖终端场景 |
|
||||
| P5 | 笔记列表(按卡片行窗口化,CSS subgrid)、工作区文件树(展开后拍平,按缩进显示层级)、知识库文档表格只渲染可见部分;抽出通用 `use-list-window` / `use-grid-columns`,侧栏会话列表改用同一实现。选中项、打开菜单的行、焦点行保持挂载 | 每个列表新增窗口化单元测试;真实 Electron 文件树布局测试改为验证窗口化后的停靠、滚动和预览返回 |
|
||||
| P6 | Main 启动打点(`startup-marks.ts`),新增 `npm run perf:startup`:首次运行 / 空配置 / 300 个会话三种配置,各 5 轮 | 见下表 |
|
||||
| P9 | `package.json` 新增 `perf:startup`、`perf:document-parse` | — |
|
||||
|
||||
冷启动基线(标准机,中位数 / p95,ms):
|
||||
|
||||
| 阶段 | 首次运行 | 空配置 | 300 个会话 |
|
||||
| --- | --- | --- | --- |
|
||||
| 启动到输入框可用 | 1284 / 1322 | 1024 / 1040 | 1028 / 1040 |
|
||||
| Main 模块加载(index.js 求值) | 414 / 420 | 411 / 420 | 413 / 423 |
|
||||
| 本地工具环境初始化 | 154 / 169 | 160 / 163 | 153 / 160 |
|
||||
| 知识库初始化 | 240 / 249 | 19 / 22 | 32 / 42 |
|
||||
| 助手数据库初始化(同步) | 205 / 206 | 4 / 4 | 14 / 15 |
|
||||
| Renderer 导航到输入框出现 | 228 / 233 | 226 / 244 | 224 / 228 |
|
||||
|
||||
启动的主要开销是 Main 模块加载(约 415 ms)和本地工具环境初始化(约 155 ms);首次运行另有约 450 ms 的建库开销。这些只做了测量,没有改动,后续按 P6 拆分延迟加载。
|
||||
|
||||
本轮回归:`perf:app` 60 项阈值全部通过(不降速,流式和流式打字长任务均为 0);typecheck、全量 lint 通过;全量测试中 2 个失败分别为 OpenCode 免费额度 403 的集成用例和 `magic-notes-*` 真实 Electron 测试在整套并行负载下的偶发失败,后者单独运行和 6 个 Electron 布局测试并行连跑 3 次均通过。
|
||||
|
||||
### 7.2.1 活动记录增量与分页(2026-10-04,P2/P4/P5)
|
||||
|
||||
之前启动时整份读取活动记录(本机约 2.4 万条、10 MB),任何变化 250 ms 后把整份列表 `replace` 回 Main;Main 每次在列表前面插入记录都要重写顺序 JSON。分三次提交改完:
|
||||
|
||||
1. **增量保存(`6aab78b`)。** 活动记录移出 App 状态,放进 `activity-store.ts`。领域动作(新增、更新请求、审批、按请求删除、按调用删除、结束请求、对账、清空)修改状态,同时排入对应的契约变更;`activity-sync.ts` 每 250 ms 用 `activityHistory.update` 发送(每次最多 2 万条),失败的变更按原顺序留在队列里,退避重试;退出和卸载前也会刷盘。清空改为新增的 `activityHistory.clear()`,Main 在一个事务里完成。
|
||||
2. **Main 分页、汇总、对账(`352da66`)。** 第 57 版 schema 重建 `activity_history_records`:`order_seq`(越新越大,移到最前就分配新的最大值)加上从记录 JSON 生成的存储列(状态、类型、请求、会话、项目、创建时间)和索引。顺序从 `record_order_json` 原样迁移;旧数据里的重复 ID 保留,仍然只显示第一条。`ActivityHistoryRepository` 负责全部 SQL:按索引倒序分页 `page`,`summary` 返回面板原来从整份列表算出的计数、会话标题和会话状态,`reconcile` 用 SQL 关联任务表(追加的说明行由界面传入本地化文本),以及 `clear`。分页和汇总走只读 worker。
|
||||
3. **界面只保留已加载的页(`bf9850a`)。** 启动顺序:发送排队的变更,Main 对账,再加载首页(200 条)。打开运行记录页时补足一批(500 条,和以前首屏一样多),“继续显示”先显示下一批,再在后台加载下一页;切换筛选会重新取首页;离开页面只留一批。计数、分组、标题、会话状态和清空数量都来自 Main 的汇总。没加载的记录也照样增量更新:`update` 新增按内容匹配的变更(`upsert-call`、`update-request`、`resolve-approval`、`settle-request`、`remove-request`、`remove-call`),由 Main 在 SQL 里执行,界面对已加载的页用同一份参考实现处理。任务刷新后的对账也改在 Main 里用 SQL 完成。旧 localStorage 记录改成增量变更迁移,界面不再调用 `replace`。
|
||||
|
||||
`npm run perf:main`,2.4 万条活动记录,两轮结果一致(括号内为 Main 事件循环最大延迟):
|
||||
|
||||
| 路径 | 之前 | 之后 |
|
||||
| --- | --- | --- |
|
||||
| 启动读取 | 整份 `get`:14.2 MB,178 ms(198 ms),在 Main 上执行 | 首页 + 汇总:114 KB,16 ms,在 worker 上执行(Main 0.6 ms) |
|
||||
| 首页 200 条(同步路径,参考) | — | 1.0 ms(1.3 ms) |
|
||||
| 汇总(同步路径,参考) | — | 14 ms(15.6 ms) |
|
||||
| 保存 1 条变化 | 整份 `replace`:98 ms(114 ms) | `update`:3.9 ms(6.9 ms) |
|
||||
| 每次保存的载荷 | 整份列表(约 14 MB) | 只含变化的记录,与总量无关(单元测试:3 条变化 < 1.5 KB) |
|
||||
|
||||
测试:随机动作序列(含翻页、截断、刷盘和注入的保存失败),在真实数据库上与改动前的整份列表逻辑逐步对比,最终数据库与整份 `replace` 的结果一致(含重复 ID 和移到最前);启动后界面不超过一页;保存载荷只取决于变化的记录(含未加载的);随机数据集(含重复 ID)下,分页加汇总渲染出的计数、分组、标题、状态和清空数量与整份列表渲染一致;重启后遗留的进行中记录被标为中断,说明文本相同;清空后数据库、界面和汇总都为空;SQL 变更语义与参考实现一致。`perf:app` 60 项阈值全部通过。
|
||||
|
||||
后续(2026-10-04):汇总覆盖全部已加载会话,超过 5,000 个时分多次请求再合并,不再截断(单元测试:5,003 个会话分 5,000 + 3 两次请求,全部有标题和状态)。分组里“会话活动数”统计的仍是已加载的记录,和以前统计已显示的记录一致。
|
||||
|
||||
### 7.2.2 Main 写库与启动:真实数据测量(2026-10-04,PERF-15)
|
||||
|
||||
用便携版 `dist/GoodBuddy-windows-x64/data` 的副本测量(源目录从不被打开):`assistant.sqlite` 2.4 GB,175.5 万条任务事件(其中 148 万条子任务进度、1.1 GB),4,812 条消息(metadata 约 400 MB,单条最大 4 MB),2.4 万条活动记录。新增 `npm run perf:write`(`build/run-write-perf.cjs`,按真实数据中最忙一秒的速率 270 条/秒回放真实事件),`perf:startup` 新增 `real` 变体(`GB_PERF_STARTUP_REAL_DATA`,每次启动用新副本,外网请求全部拦截)。
|
||||
|
||||
**测到的问题:**
|
||||
1. 每次写库都按 `synchronous = FULL` 同步落盘,单次约 2.4 ms,与写入内容无关:子任务进度、工具、文本批、会话保存、活动记录都一样。
|
||||
2. SQLite 自动 checkpoint 发生在越过 1,000 页的那次提交里:大库上每隔几百次写入就有一次卡住 Main 100–350 ms(3 轮里 18 次超过 50 ms)。
|
||||
3. 启动时两条恢复语句、一条图片操作恢复语句各扫描全部消息的 metadata:在 Main 上同步约 2.3 秒,启动到输入框可用 3.7 秒。
|
||||
|
||||
**改动:**
|
||||
- **频繁的本地写入改用 `synchronous = NORMAL`**:本地运行事件(生命周期事件 `status`/`done`/`error` 除外)、渲染进程 500 ms 一次的会话保存、活动记录增量。WAL 模式下仍是原子提交,应用崩溃不丢;断电最多丢最近一段这类写入,任何 FULL 提交及其之前的数据不受影响。远程事件(Agent ACK 依赖其持久性)、通道投递、任务状态等其余写入仍为 FULL。
|
||||
- **WAL checkpoint 移到 worker 线程**:复用只读查询 worker 的入口,每 250 ms 做一次 PASSIVE checkpoint;运行期间 Main 的自动 checkpoint 阈值提到 4,000 页作为兜底(持续写入时被动 checkpoint 追不上 WAL 重置点),worker 退出即恢复默认 1,000。
|
||||
- **启动恢复按索引读候选消息**:第 58 版 schema 新增部分索引 `messages_recovery_idx`(流式中,或 metadata 含未结束的工具、子任务、图片操作状态);没有用户队列项时跳过按 queueItemId 的全表删除。metadata 只由 `JSON.stringify` 写入,状态总以紧凑形式出现,精确判断仍在候选行上用原逻辑执行。一次性迁移在升级 worker 中执行,真实数据约 2 秒。
|
||||
|
||||
**结果(真实数据):**
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
| --- | --- | --- |
|
||||
| 单次写库 p50(子任务 / 工具 / 文本 / 会话保存 / 活动记录) | 2.2–2.6 ms | 0.1–0.3 ms |
|
||||
| 写入期间 Main 最长阻塞(270 条/秒,3 轮) | 8–346 ms,超过 50 ms 共 37 次 | 2–34 ms,超过 50 ms 0 次 |
|
||||
| 启动:Main 上的初始化(`assistant-db-init`) | 2,564 ms | 232 ms |
|
||||
| 启动:到输入框可用(中位数,4 次) | 3,702 ms | 1,359 ms |
|
||||
| 启动:侧栏出现会话 | 4,081 ms | 1,749 ms |
|
||||
| 启动期间 Main 最长阻塞 | 2.5–2.8 s | 236–257 ms |
|
||||
|
||||
部分索引的代价:metadata 更新时如果新旧值都命中候选条件,要多维护一次索引(4 MB metadata 的更新 36 → 59 ms,233 KB 的 0.7 → 1.6 ms);不命中时没有额外开销。启动时剩下的约 0.6 s 在本地工具环境探测(已在后台初始化,不阻塞 Main)。
|
||||
|
||||
未做:读取最大的会话(407 条消息、42 MB metadata)在 worker 上约 170 ms,不阻塞 Main,但切换到这个会话时要等这么久;需要按需加载 metadata,单独立项。
|
||||
|
||||
### 7.3 体验倒退复查(2026-10-03)
|
||||
|
||||
逐一复查了所有性能提交对交互体验的影响,并新增原则 P10“体验不倒退”。发现并修复:
|
||||
|
||||
| 问题 | 来源 | 修复 | 验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| “到底部”按钮的平滑滚动动画消失,直接跳到底部 | `fdf7d83` 消息窗口化:贴底跟随、ResizeObserver、锚定修正都会瞬间写入 `scrollTop`,打断平滑滚动;点击时还立即切换窗口导致可见行被卸载 | 滑动期间暂停所有滚动写入,只随视口重新窗口化;到达底部、`scrollend` 或 1.5 秒兜底时再贴底;用户滚轮、按键或点击会接管滚动 | 真实 Electron 逐帧采样,要求有中间帧(旧代码中间帧为 0),连跑 3 次通过;新增单元测试,旧代码失败 |
|
||||
| 消息滚出可见区后丢失状态:待回答问题里已选的选项和输入的内容、展开的工具详情和推理、Mermaid 重新绘制并显示“加载中” | `fdf7d83` | 有待回答问题或待审批的行、用户交互过的最近 8 行、选区两端所在的行保持挂载;Mermaid 按“主题 + 源码”缓存渲染结果(最多 64 个) | 单元测试,旧代码失败 |
|
||||
| 跨多条消息选择文本时,选区起点被卸载;复制时只拿到已挂载的文字 | `fdf7d83` | 选区两端所在的行保持挂载;复制时,未挂载的消息用原文补全 | 单元测试 |
|
||||
| 侧栏在上方插入、删除、置顶或 5 秒重排时,可见的会话行跳动 | `663aa85`,窗口化关闭了原生滚动锚定 | 会话 id 变化时,在绘制前按第一条可见行恢复位置;列表在顶部时不锚定 | 单元测试,旧代码失败 |
|
||||
| 笔记、文件树、知识库文档表格的锚定修正可能和浏览器原生锚定冲突 | `b2eb86a` | 窗口化状态下关闭 `overflow-anchor` | 单元测试 |
|
||||
| 终端打字回显多等一帧;点击“清屏”前到达的输出在清屏之后才显示 | `f4458af` | 空闲时立即写入,只合并同一帧内后续到达的输出;清屏前先写完待写入的输出 | 单元测试 |
|
||||
| 远程 Runtime 流式输出最多 50 ms 一批,显示一顿一顿 | `d859cbf` | 合并间隔改为 16 ms;写库成功后立即转发,仍然是先写库再转发 | 单元测试 |
|
||||
| 流式输出时从侧栏复制或导出会话,可能缺最后不超过 250 ms 的文字 | PERF-13 流式缓冲 | 复制、导出前先把待同步的流式文字刷进状态 | 单元测试,旧代码失败 |
|
||||
|
||||
检查后没有问题:CSS 动画和过渡没有被删改;其余 `scrollIntoView` 的行为和原来一致;标题跑马灯、侧栏 5 秒重排的悬停冻结、重命名和菜单行保持挂载;输入框自动增高;Markdown 和 KaTeX 渲染结果。快速大跨度跳转是否出现空白帧也做了实测:在 `perf:app` 滚动场景中加了检查,CPU 降速 4 倍时 10 次跳转中空白帧为 0,所以没有改动。
|
||||
|
||||
已知限制(不是倒退,原来也没有):读屏软件的浏览模式访问不到未挂载的消息;应用本身没有页内查找功能。
|
||||
|
||||
修复记录:文件树窗口化最初以 `.assistant-sidebar__body` 为滚动容器,但真实布局中滚动的是停靠的 `.workspace-files__files-view`,真实 Electron 测试发现后改为优先使用后者。
|
||||
|
||||
### PERF-14 状态
|
||||
|
||||
- **优先级 / 状态:** P2 / 进行中(消息时间线和侧栏已窗口化;页内查找等限制未解决)
|
||||
@@ -876,7 +1013,7 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
|
||||
### PERF-15 存储接口异步化
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **优先级 / 状态:** P1 / 进行中(热点读走 worker;高频写降为 NORMAL、checkpoint 移出 Main、启动恢复按索引,见 7.2.2;接口异步化未开始)
|
||||
- **范围:** 在原进程内把 `AssistantDatabase`、`KnowledgeDatabase` 等对外接口改为异步,
|
||||
调用方全部 `await`;会话列表只返回摘要,搜索下推到 SQL,活动记录改为增量追加。
|
||||
远程 Runtime 实时事件改用已有的 `appendRemoteTaskEventsBatch` 批量写入,不再逐条同步事务。
|
||||
|
||||
@@ -134,6 +134,7 @@ export default defineConfig({
|
||||
'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'),
|
||||
'execution-stats-worker': resolve('src/main/execution-stats-worker.ts'),
|
||||
'readonly-query-worker': resolve('src/main/readonly-query-worker.ts'),
|
||||
'document-parse-worker': resolve('src/main/document-parse-worker.ts'),
|
||||
'wechat-sidecar': resolve(
|
||||
'src/main/channels/wechat-sidecar.ts'
|
||||
),
|
||||
|
||||
+3
-2
@@ -81,8 +81,9 @@ const rendererIpcSubscriptionMessage = 'Components must not subscribe to window.
|
||||
|
||||
// PERF-13 goal: App.tsx shrinks to a layout shell. 11,635 lines on
|
||||
// 2026-10-02; 9,567 on 2026-10-03 after moving persistence, refresh and agent
|
||||
// events out. Ceiling rounded up to the next 100. Only ever lower this number.
|
||||
const appTsxMaxLines = 9600
|
||||
// events out; 7,904 after moving the composer, route callbacks and the task
|
||||
// store out. Ceiling rounded up to the next 100. Only ever lower this number.
|
||||
const appTsxMaxLines = 8000
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.9",
|
||||
"version": "0.15.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.9",
|
||||
"version": "0.15.10",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.9",
|
||||
"version": "0.15.10",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
@@ -27,8 +27,11 @@
|
||||
"test:watch": "vitest",
|
||||
"perf:app": "node build/run-app-perf.cjs",
|
||||
"perf:check": "node build/check-perf-thresholds.cjs",
|
||||
"perf:document-parse": "node build/run-document-parse-perf.cjs",
|
||||
"perf:knowledge": "node build/run-knowledge-perf.cjs",
|
||||
"perf:main": "node build/run-main-perf.cjs",
|
||||
"perf:startup": "node build/run-startup-perf.cjs",
|
||||
"perf:write": "node build/run-write-perf.cjs",
|
||||
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
|
||||
"build": "npm run typecheck && npm run build:bundle",
|
||||
"build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs",
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.15.10",
|
||||
"releasedAt": "2026-10-04",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**在一个菜单中切换项目与会话**:左上角统一入口按本地、远程和通道组织项目,可切换当前项目或全部项目范围,并查看活动会话及最近十个会话。悬停可预览,重新进入项目会恢复本次应用会话中最后打开的会话;离开确认和恢复入口保留。",
|
||||
"**单独选择监督模型和会话图谱使用方式**:监督者可从已有文本模型连接中独立选择模型,不必与聊天模型相同。启用监督者后,可在会话中关闭默认开启的 Story Graph 使用开关,限制该会话的图谱工具发现与调用;模型调用仍按所选提供方计费。"
|
||||
],
|
||||
"fixes": [
|
||||
"**减少大活动历史的加载与保存负担**:活动历史按变化保存、按页读取,界面只保留已加载页面,汇总不再局限于前 5000 个会话。记录仍完整保存在数据库,可继续翻页查看,不以删除历史换取响应速度。",
|
||||
"**减少启动、文档处理与界面更新中的等待**:文档解析、分块和哈希移到 Worker,部分依赖及本地工具环境按需或后台准备,聊天输入区与工作区减少无关刷新。笔记、文件树和知识文档列表只挂载附近内容并保留选择、焦点与滚动位置;未挂载行不支持页内查找,效果因设备和内容而异。",
|
||||
"**改善终端输出和远程事件的呈现节奏**:终端合并短时间内的输出再交给界面,远程事件缩短定时批次等待,并保留 checkpoint 前落库、顺序和回放去重。SSH Unicode 输出与真实模型流已做限定场景验证,不代表所有断线恢复问题均已解决。",
|
||||
"**清理旧 Runtime 缓存并调整工作栏操作**:启动后在后台清理匹配既定命名的旧版本缓存,保留当前布局、会话、日志和配置,跳过符号链接。工作栏分隔间距收窄,滚动箭头避开关闭按钮,输入区明确 DSH 操作并使用就地停止确认,恢复跳到底部的平滑滚动。"
|
||||
],
|
||||
"notices": [
|
||||
"首次启动升级到 schema 58,迁移活动历史排序并增加分页和启动恢复索引。请先关闭应用,备份完整数据库及相邻 `notes/` 目录;旧客户端无法打开升级后的库,降级需恢复升级前备份。",
|
||||
"为减少磁盘同步等待,高频本地写入使用 SQLite NORMAL,并在后台执行 WAL checkpoint。应用进程崩溃仍保留事务原子性,但断电或系统崩溃可能丢失最近部分本地写入;远程确认、通道投递和任务生命周期写入继续使用 FULL。",
|
||||
"Agent 保持 0.15.3,本轮不发布新的 Agent 包;Electron、Node、OpenCode 和 Continue 的固定版本不变。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Switch projects and conversations from one menu**: The top-left entry groups local, remote, and channel projects, with current-project or all-project scopes, active conversations, and the ten most recent conversations. Hover to preview, and return to a project's last-opened conversation within the current app session; leave confirmations and recovery entries remain available.",
|
||||
"**Choose a separate Supervisor model and conversation graph access**: Supervisor can select an existing text-model connection independently of the chat model. When Supervisor is enabled, each conversation offers a default-on Story Graph switch that can disable graph tool discovery and calls for that conversation; model requests remain billable through the selected provider."
|
||||
],
|
||||
"fixes": [
|
||||
"**Reduce loading and saving work for large activity histories**: Activity records save incrementally and load in pages, the UI retains only loaded pages, and summaries are no longer limited to the first 5,000 conversations. Complete records remain in the database and can be paged through rather than deleting history to improve responsiveness.",
|
||||
"**Reduce waiting during startup, document processing, and UI updates**: Document parsing, chunking, and hashing run in a Worker, some dependencies and the local tool environment initialize on demand or in the background, and the composer and workspaces avoid unrelated updates. Notes, file trees, and knowledge document lists mount nearby content while preserving selection, focus, and scroll positions; in-page find excludes unmounted rows, and results vary by device and content.",
|
||||
"**Improve terminal output and remote event pacing**: Terminal output is coalesced over short intervals before reaching the UI, and remote event batches wait less before timed flushes while retaining persistence before checkpoints, ordering, and replay deduplication. Bounded real-model stream and SSH Unicode output checks passed; this does not establish that every reconnection issue is resolved.",
|
||||
"**Clean old Runtime caches and refine workbar controls**: Background cleanup after startup removes older caches matching defined naming patterns while retaining current layouts, sessions, logs, and configuration and skipping symbolic links. Narrower dividers leave more room, scroll arrows avoid close buttons, composer controls clarify DSH actions with inline stop confirmation, and jump-to-bottom scrolling is smooth again."
|
||||
],
|
||||
"notices": [
|
||||
"First launch upgrades to schema 58, migrating activity history ordering and adding paging and startup recovery indexes. Close the app and back up the complete database and adjacent `notes/` directory first; older clients cannot open the upgraded database, so downgrading requires a pre-upgrade backup.",
|
||||
"To reduce disk synchronization waits, frequent local writes use SQLite NORMAL with background WAL checkpoints. Application-process crashes retain transaction atomicity, but power loss or an operating-system crash can lose a recent suffix of local writes; remote acknowledgments, channel delivery, and task lifecycle writes continue to use FULL.",
|
||||
"Agent remains at 0.15.3 with no new Agent package in this release; pinned Electron, Node, OpenCode, and Continue versions are unchanged."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.9",
|
||||
"releasedAt": "2026-10-03",
|
||||
|
||||
@@ -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<void>(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<number, (response: { status: number; body: string }) => 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<number | null>((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 })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -55,6 +55,7 @@ import { readBoundedResponseText } from './bounded-response'
|
||||
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
|
||||
import { readBoundedFile } from '../workspace-file-access'
|
||||
import { terminateProcessTreeAndWait } from './child-process-termination'
|
||||
import { CONTINUE_HOST_LAYOUT_VERSION } from './continue-host-layout'
|
||||
|
||||
const supportedVersion = '1.5.47'
|
||||
const supportedBundleHashes = new Set([
|
||||
@@ -1052,7 +1053,7 @@ export class ContinueHostAdapter {
|
||||
const digest = sourceHash.slice(0, 16)
|
||||
const targetRoot = join(
|
||||
this.options.cacheRoot,
|
||||
`host-v7-${supportedVersion}-${digest}`
|
||||
`host-v${CONTINUE_HOST_LAYOUT_VERSION}-${supportedVersion}-${digest}`
|
||||
)
|
||||
const targetDist = join(targetRoot, 'dist')
|
||||
const targetBundle = join(targetDist, 'index.js')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Prepared Continue host directories are `host-v<layout>-…`; older layouts are never read again. */
|
||||
export const CONTINUE_HOST_LAYOUT_VERSION = 7
|
||||
@@ -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')
|
||||
|
||||
@@ -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<boolean>
|
||||
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
|
||||
@@ -81,7 +81,10 @@ import {
|
||||
listAllMcpTools,
|
||||
normalizeMcpToolSchema
|
||||
} from './mcp-tool-utils'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
|
||||
import type {
|
||||
LaunchEnvironmentProvider,
|
||||
LaunchEnvironmentReady
|
||||
} from '../local-tool-environment'
|
||||
import {
|
||||
BrowserModelTools,
|
||||
browserNavigateInputSchema,
|
||||
@@ -270,6 +273,8 @@ export type KnowledgeMcpGatewayOptions = {
|
||||
): BrowserTabUsageLease
|
||||
}
|
||||
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||
/** Awaited before reading the provider; startup builds the environment in the background. */
|
||||
launchEnvironmentReady?: LaunchEnvironmentReady
|
||||
}
|
||||
|
||||
function toMagicNoteToolSummary(
|
||||
@@ -400,6 +405,8 @@ export class KnowledgeMcpGateway {
|
||||
): BrowserTabUsageLease
|
||||
}
|
||||
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||
private readonly launchEnvironmentReady?: LaunchEnvironmentReady
|
||||
|
||||
private readonly observeFailure?: DesktopDiagnosticFailureObserver
|
||||
private server?: Server
|
||||
private endpoint?: string
|
||||
@@ -416,6 +423,8 @@ export class KnowledgeMcpGateway {
|
||||
this.obsidianService = options.obsidianService
|
||||
this.browserService = options.browserService
|
||||
this.launchEnvironmentProvider = options.launchEnvironmentProvider
|
||||
this.launchEnvironmentReady = options.launchEnvironmentReady
|
||||
|
||||
this.observeFailure = options.observeFailure
|
||||
}
|
||||
|
||||
@@ -1048,6 +1057,7 @@ export class KnowledgeMcpGateway {
|
||||
capability.brokerController.signal
|
||||
])
|
||||
try {
|
||||
await this.launchEnvironmentReady?.()
|
||||
await client.connect(
|
||||
createMcpTransport(server, this.launchEnvironmentProvider),
|
||||
{
|
||||
|
||||
@@ -10,13 +10,13 @@ afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
function fixture(whenReady: () => Promise<void> = 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,14 +30,14 @@ function fixture() {
|
||||
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 },
|
||||
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
|
||||
rootDirectory: '/clients', createGateway: () => gateway, openExternal
|
||||
} as unknown as ConstructorParameters<typeof NativeClientCoordinator>[0])
|
||||
coordinators.push(coordinator)
|
||||
@@ -45,6 +45,27 @@ function fixture() {
|
||||
}
|
||||
|
||||
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<void>(resolve => { ready = resolve }))
|
||||
const opened = coordinator.open(1, 'conversation')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
ready()
|
||||
await expect(opened).resolves.toEqual({ kind: 'browser', serviceId: 'service-0' })
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('retains a ready service and its gateway after browser failure, then reopens and stops it', async () => {
|
||||
const { coordinator, gateway, start, stop, openExternal } = fixture()
|
||||
openExternal.mockRejectedValueOnce(new Error('Browser unavailable'))
|
||||
|
||||
@@ -25,7 +25,7 @@ type Options = {
|
||||
capabilities: CapabilityService
|
||||
executionSpaceResolver: ExecutionSpaceResolver
|
||||
terminalManager: TerminalSessionManager
|
||||
localEnvironment: LocalToolEnvironmentService
|
||||
localEnvironment: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
|
||||
bundledRuntimePaths: BundledRuntimePaths
|
||||
rootDirectory: string
|
||||
managedNodeDirectory: string
|
||||
@@ -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,
|
||||
@@ -122,6 +122,9 @@ export class NativeClientCoordinator {
|
||||
}
|
||||
|
||||
async open(ownerId: number, conversationId: string): Promise<RuntimeNativeClientResult> {
|
||||
this.assertOwner(ownerId)
|
||||
// Native clients launch with the prepared tool PATH; startup builds it in the background.
|
||||
await this.options.localEnvironment.whenReady()
|
||||
this.assertOwner(ownerId)
|
||||
const context = await this.resolve(conversationId)
|
||||
this.assertOwner(ownerId)
|
||||
@@ -165,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) : [] }] : [])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist';
|
||||
import {
|
||||
import type {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage,
|
||||
type OpencodeClient,
|
||||
type PermissionRequest,
|
||||
type PermissionRuleset,
|
||||
type QuestionRequest,
|
||||
AssistantMessage,
|
||||
OpencodeClient,
|
||||
PermissionRequest,
|
||||
PermissionRuleset,
|
||||
QuestionRequest,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
import spawn from "cross-spawn";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
@@ -740,11 +740,27 @@ async function defaultValidateBinary(
|
||||
});
|
||||
}
|
||||
|
||||
type OpencodeSdk = typeof import("@opencode-ai/sdk/v2");
|
||||
let opencodeSdk: OpencodeSdk | undefined;
|
||||
|
||||
// The SDK is loaded when an OpenCode client is first created, not at Main
|
||||
// startup (P6). initializeClient awaits this before any default factory runs.
|
||||
async function loadOpencodeSdk(): Promise<OpencodeSdk> {
|
||||
opencodeSdk ??= await import("@opencode-ai/sdk/v2");
|
||||
return opencodeSdk;
|
||||
}
|
||||
|
||||
const defaultCreateClient: typeof createOpencodeClient = (config) => {
|
||||
if (!opencodeSdk) throw new Error("OpenCode SDK is not loaded");
|
||||
return opencodeSdk.createOpencodeClient(config);
|
||||
};
|
||||
|
||||
async function defaultCheckServerHealth(
|
||||
url: string,
|
||||
authorization: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const { createOpencodeClient } = await loadOpencodeSdk();
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
headers: { Authorization: authorization },
|
||||
@@ -798,7 +814,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
spawn,
|
||||
detectBinary: defaultDetectBinary,
|
||||
validateBinary: defaultValidateBinary,
|
||||
createClient: createOpencodeClient,
|
||||
createClient: defaultCreateClient,
|
||||
checkServerHealth: defaultCheckServerHealth,
|
||||
platform: process.platform,
|
||||
startupTimeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -1483,6 +1499,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (baseUrl && this.options.modelProfile) {
|
||||
throw new Error("OpenCode 独立模型连接仅支持由 GoodBuddy 启动的本机服务");
|
||||
}
|
||||
await loadOpencodeSdk();
|
||||
if (!baseUrl && this.options.embedded) {
|
||||
this.server = await this.launchEmbedded(signal);
|
||||
baseUrl = this.server.url;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,8 +6,9 @@ import type { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { activityHistorySnapshotSchema, type ActivityHistoryChange, type ActivityRecord } from '../../shared/assistant-contracts'
|
||||
import { applyActivityChanges } from '../../shared/activity-history-reference'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { parseActivityHistorySnapshot, planActivityHistoryReplace, planActivityHistoryUpdate } from './activity-history-plan'
|
||||
import { parseActivityHistorySnapshot, planActivityHistoryReplace } from './activity-history-plan'
|
||||
|
||||
const directories: string[] = []
|
||||
const opened: AssistantDatabase[] = []
|
||||
@@ -28,8 +29,9 @@ function open(): { database: AssistantDatabase; raw: DatabaseSync; directory: st
|
||||
/** The complete persisted state, independent of the read API. */
|
||||
function storedState(raw: DatabaseSync): unknown {
|
||||
return {
|
||||
header: raw.prepare('SELECT record_order_json, legacy_history_may_be_incomplete FROM activity_history').all(),
|
||||
rows: raw.prepare('SELECT record_key, record_json FROM activity_history_records ORDER BY record_key').all()
|
||||
header: raw.prepare('SELECT legacy_history_may_be_incomplete FROM activity_history').all(),
|
||||
// Sequence values may differ; their order may not.
|
||||
rows: raw.prepare('SELECT record_key, record_json FROM activity_history_records ORDER BY order_seq DESC, record_key').all()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +48,8 @@ function record(id: string, overrides: Partial<ActivityRecord> = {}): ActivityRe
|
||||
}
|
||||
|
||||
/** Reference semantics of a change, applied to a plain newest-first list. */
|
||||
function applyReference(list: ActivityRecord[], change: ActivityHistoryChange): ActivityRecord[] {
|
||||
if (change.type === 'remove') return list.filter(item => item.id !== change.id)
|
||||
const index = list.findIndex(item => item.id === change.record.id)
|
||||
if (index < 0) return [change.record, ...list]
|
||||
if (change.position === 'front') return [change.record, ...list.filter((_, at) => at !== index)]
|
||||
return list.map((item, at) => (at === index ? change.record : item))
|
||||
}
|
||||
const applyReference = (list: ActivityRecord[], change: ActivityHistoryChange): ActivityRecord[] =>
|
||||
applyActivityChanges(list, [change])
|
||||
|
||||
describe('activity history incremental updates', () => {
|
||||
it('stores exactly the state a full replace stores, for random change sequences', () => {
|
||||
@@ -116,8 +113,8 @@ describe('activity history incremental updates', () => {
|
||||
database.updateActivityHistory({ changes: [{ type: 'upsert', position: 'front', record: { ...records[1_500]!, status: 'completed' } }] })
|
||||
const calls = stringify.mock.calls.filter(([value]) => !Array.isArray(value)).length
|
||||
stringify.mockRestore()
|
||||
// One record row plus the order row.
|
||||
expect(changes() - start).toBe(2)
|
||||
// One record row; no order list is rewritten.
|
||||
expect(changes() - start).toBe(1)
|
||||
expect(calls).toBeLessThan(10)
|
||||
})
|
||||
})
|
||||
@@ -187,11 +184,11 @@ describe('activity history replace', () => {
|
||||
const empty = { entries: new Map() }
|
||||
const first = planActivityHistoryReplace(parseActivityHistorySnapshot({ records, legacyHistoryMayBeIncomplete: false }).items, false, empty)
|
||||
const reparsed = parseActivityHistorySnapshot(structuredClone({ records, legacyHistoryMayBeIncomplete: false }), first.next)
|
||||
expect(reparsed.items.every((item, index) => item.record === first.next.entries.get([...first.next.order][index]!)!.record)).toBe(true)
|
||||
const keys = ['["a",0]', '["b",0]', '["a",1]']
|
||||
expect(reparsed.items.every((item, index) => item.record === first.next.entries.get(keys[index]!)!.record)).toBe(true)
|
||||
const second = planActivityHistoryReplace(reparsed.items, false, first.next)
|
||||
expect(second.upserts).toEqual([])
|
||||
expect(second.removes).toEqual([])
|
||||
expect(second.headerChanged).toBe(false)
|
||||
expect(() => planActivityHistoryUpdate({ changes: [] }, { entries: new Map() })).toThrow('unreadable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import {
|
||||
activityHistorySnapshotSchema,
|
||||
activityHistoryUpdateSchema,
|
||||
activityRecordSchema,
|
||||
type ActivityRecord
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
// PERF-15: activity history persistence planning. The renderer sends the whole
|
||||
// newest-first list every change; most records are unchanged. These pure
|
||||
// helpers validate and diff against the last committed state without
|
||||
// re-validating or re-serializing unchanged records, and turn incremental
|
||||
// changes into the same stored rows that `replace` would produce.
|
||||
// PERF-15: planning of a full activity history `replace` (legacy migration).
|
||||
// These pure helpers validate and diff against the last committed rows
|
||||
// without re-validating or re-serializing unchanged records. Incremental
|
||||
// changes are applied by ActivityHistoryRepository directly in SQL.
|
||||
|
||||
/** A stored row; `record` is a validated, privately owned copy when known. */
|
||||
export type ActivityHistoryEntry = { json: string; record?: ActivityRecord }
|
||||
export type ActivityHistoryEntry = { json: string; seq: number; record?: ActivityRecord }
|
||||
|
||||
export type ActivityHistoryState = {
|
||||
/** Every row in activity_history_records, keyed by record_key. */
|
||||
entries: ReadonlyMap<string, ActivityHistoryEntry>
|
||||
/** Stored order; undefined when unknown (always rewritten). */
|
||||
order?: readonly string[]
|
||||
orderJson?: string
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
export type ActivityHistoryPlan = {
|
||||
next: ActivityHistoryState & { order: readonly string[]; orderJson: string; incomplete: boolean }
|
||||
upserts: Array<[key: string, json: string]>
|
||||
next: { entries: Map<string, ActivityHistoryEntry>; incomplete: boolean }
|
||||
upserts: Array<[key: string, json: string, seq: number]>
|
||||
removes: string[]
|
||||
/** Order or legacy flag changed: the singleton row must be updated. */
|
||||
/** The legacy flag changed: the singleton row must be updated. */
|
||||
headerChanged: boolean
|
||||
}
|
||||
|
||||
@@ -57,12 +52,6 @@ function sameValue(raw: unknown, validated: unknown): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function sameOrder(left: readonly string[], right: readonly string[] | undefined): boolean {
|
||||
if (!right || left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index++) if (left[index] !== right[index]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a replace snapshot with the same accept/reject behavior and errors
|
||||
* as `activityHistorySnapshotSchema.parse`, reusing previously validated
|
||||
@@ -106,98 +95,47 @@ export function parseActivityHistorySnapshot(
|
||||
return { items, incomplete: raw.legacyHistoryMayBeIncomplete }
|
||||
}
|
||||
|
||||
/** Diffs a full newest-first list against the committed state. */
|
||||
/**
|
||||
* Diffs a full newest-first list against the committed rows. Order is kept
|
||||
* as `order_seq` (newer = larger): walking from the oldest record, a row
|
||||
* keeps its sequence when it is still above the previous one, otherwise it
|
||||
* gets the next one. Prepending or moving one record to the front therefore
|
||||
* writes only that record.
|
||||
*/
|
||||
export function planActivityHistoryReplace(
|
||||
items: readonly ParsedItem[],
|
||||
incomplete: boolean,
|
||||
previous: ActivityHistoryState,
|
||||
onProgress?: (processed: number) => void
|
||||
): ActivityHistoryPlan {
|
||||
const entries = new Map<string, ActivityHistoryEntry>()
|
||||
const occurrences = new Map<string, number>()
|
||||
const order: string[] = new Array(items.length)
|
||||
const upserts: Array<[string, string]> = []
|
||||
const keys: string[] = new Array(items.length)
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
if (index % 128 === 0) onProgress?.(index)
|
||||
const { record } = items[index]!
|
||||
const occurrence = occurrences.get(record.id) ?? 0
|
||||
occurrences.set(record.id, occurrence + 1)
|
||||
const key = items[index]!.key ?? activityRecordKey(record.id, occurrence)
|
||||
keys[index] = items[index]!.key ?? activityRecordKey(record.id, occurrence)
|
||||
}
|
||||
const entries = new Map<string, ActivityHistoryEntry>()
|
||||
const upserts: Array<[string, string, number]> = []
|
||||
let floor = 0
|
||||
for (let index = items.length - 1, processed = 0; index >= 0; index--, processed++) {
|
||||
if (processed % 128 === 0) onProgress?.(processed)
|
||||
const { record } = items[index]!
|
||||
const key = keys[index]!
|
||||
const json = items[index]!.json ?? JSON.stringify(record)
|
||||
order[index] = key
|
||||
if (previous.entries.get(key)?.json !== json) upserts.push([key, json])
|
||||
entries.set(key, { json, record })
|
||||
const before = previous.entries.get(key)
|
||||
const seq = before && before.seq > floor ? before.seq : floor + 1
|
||||
floor = seq
|
||||
if (before?.json !== json || before.seq !== seq) upserts.push([key, json, seq])
|
||||
entries.set(key, { json, seq, record })
|
||||
}
|
||||
const removes: string[] = []
|
||||
for (const key of previous.entries.keys()) if (!entries.has(key)) removes.push(key)
|
||||
return finish(entries, order, incomplete, previous, upserts, removes)
|
||||
}
|
||||
|
||||
function finish(
|
||||
entries: Map<string, ActivityHistoryEntry>,
|
||||
order: string[],
|
||||
incomplete: boolean,
|
||||
previous: ActivityHistoryState,
|
||||
upserts: Array<[string, string]>,
|
||||
removes: string[]
|
||||
): ActivityHistoryPlan {
|
||||
const orderUnchanged = sameOrder(order, previous.order) && previous.orderJson !== undefined
|
||||
const orderJson = orderUnchanged ? previous.orderJson! : JSON.stringify(order)
|
||||
return {
|
||||
next: { entries, order, orderJson, incomplete },
|
||||
next: { entries, incomplete },
|
||||
upserts,
|
||||
removes,
|
||||
headerChanged: !orderUnchanged || previous.incomplete !== incomplete
|
||||
headerChanged: previous.incomplete !== incomplete
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies incremental changes (see `activityHistoryChangeSchema`). Only the
|
||||
* changed records are validated and serialized; the result is identical to
|
||||
* `planActivityHistoryReplace` with the resulting list.
|
||||
*/
|
||||
export function planActivityHistoryUpdate(input: unknown, previous: ActivityHistoryState): ActivityHistoryPlan {
|
||||
const update = activityHistoryUpdateSchema.parse(input)
|
||||
if (!previous.order) throw new Error('Activity history order is unreadable; save the full history instead')
|
||||
const entries = new Map(previous.entries)
|
||||
let order = [...previous.order]
|
||||
const touched = new Set<string>()
|
||||
for (const change of update.changes) {
|
||||
if (change.type === 'remove') {
|
||||
// Keys of one ID share the JSON prefix `["id",`.
|
||||
const prefix = `${JSON.stringify([change.id]).slice(0, -1)},`
|
||||
const removed = order.filter(key => key.startsWith(prefix))
|
||||
if (removed.length > 0) order = order.filter(key => !key.startsWith(prefix))
|
||||
for (const key of removed) { entries.delete(key); touched.add(key) }
|
||||
for (let occurrence = 0; ; occurrence++) {
|
||||
const key = activityRecordKey(change.id, occurrence)
|
||||
if (!entries.delete(key)) break
|
||||
touched.add(key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const { record } = change
|
||||
// The first occurrence stays first when moved to the front, so its key is stable.
|
||||
const key = activityRecordKey(record.id, 0)
|
||||
const index = order.indexOf(key)
|
||||
if (index < 0) order.unshift(key)
|
||||
else if (change.position === 'front' && index > 0) {
|
||||
order.splice(index, 1)
|
||||
order.unshift(key)
|
||||
}
|
||||
entries.set(key, { json: JSON.stringify(record), record })
|
||||
touched.add(key)
|
||||
}
|
||||
const upserts: Array<[string, string]> = []
|
||||
const removes: string[] = []
|
||||
for (const key of touched) {
|
||||
const entry = entries.get(key)
|
||||
if (!entry) {
|
||||
if (previous.entries.has(key)) removes.push(key)
|
||||
} else if (previous.entries.get(key)?.json !== entry.json) {
|
||||
upserts.push([key, entry.json])
|
||||
}
|
||||
}
|
||||
return finish(entries, order, update.legacyHistoryMayBeIncomplete ?? previous.incomplete ?? false,
|
||||
previous, upserts, removes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { build } from 'esbuild'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ActivityHistoryChange, ActivityRecord, AssistantTask } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
applyActivityChanges,
|
||||
firstOccurrences,
|
||||
referenceActivityPage,
|
||||
referenceActivityReconcile,
|
||||
referenceActivitySummary
|
||||
} from '../../shared/activity-history-reference'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
const directories: string[] = []
|
||||
const opened: AssistantDatabase[] = []
|
||||
afterEach(() => {
|
||||
for (const database of opened.splice(0)) database.close()
|
||||
// A readonly worker ends asynchronously after close and may still hold the file briefly.
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
})
|
||||
|
||||
function open(): { database: AssistantDatabase; raw: DatabaseSync; path: string; directory: string } {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-activity-repository-'))
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
opened.push(database)
|
||||
return { database, raw: (database as unknown as { database: DatabaseSync }).database, path, directory }
|
||||
}
|
||||
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed
|
||||
return () => (state = (state * 1664525 + 1013904223) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
const statuses = ['pending', 'running', 'completed', 'failed', 'denied', 'cancelled', 'interrupted'] as const
|
||||
const kinds = ['request', 'tool', 'approval', 'subagent', 'result'] as const
|
||||
|
||||
/** A random newest-first list with duplicate IDs and createdAt ties. */
|
||||
function randomList(random: () => number, length: number): ActivityRecord[] {
|
||||
const pick = <T,>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
|
||||
const list: ActivityRecord[] = []
|
||||
for (let index = 0; index < length; index++) {
|
||||
const duplicate = list.length > 0 && random() < 0.08
|
||||
const id = duplicate ? pick(list).id : `record-${index}`
|
||||
const project = random() < 0.5
|
||||
list.push({
|
||||
id,
|
||||
conversationId: `conversation-${Math.floor(random() * 6)}`,
|
||||
requestId: `request-${Math.floor(random() * 10)}`,
|
||||
scope: project ? { kind: 'project', projectId: `project-${Math.floor(random() * 2)}`, projectName: 'P' } : { kind: 'global' },
|
||||
kind: pick(kinds),
|
||||
title: `Title ${index}`,
|
||||
detail: `detail ${index}`,
|
||||
status: pick(statuses),
|
||||
createdAt: Math.floor(random() * 40)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
describe('ActivityHistoryRepository', () => {
|
||||
it('pages, counts and summarizes exactly like the full-list computation', () => {
|
||||
for (const seed of [1, 7, 99]) {
|
||||
const random = makeRandom(seed)
|
||||
const { database } = open()
|
||||
const list = randomList(random, 300)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: seed === 7 })
|
||||
const conversationIds = Array.from({ length: 7 }, (_, index) => `conversation-${index}`)
|
||||
expect(database.getActivityHistorySummary({ conversationIds }))
|
||||
.toEqual(referenceActivitySummary(list, conversationIds, seed === 7))
|
||||
for (const filter of ['all', 'active', 'failed'] as const) {
|
||||
for (const scope of [{}, { projectId: 'project-1' }, { conversationId: 'conversation-2' }]) {
|
||||
const pages: ActivityRecord[] = []
|
||||
let before: number | undefined
|
||||
do {
|
||||
const page = database.getActivityHistoryPage({ limit: 37, filter, ...scope, ...(before === undefined ? {} : { before }) })
|
||||
pages.push(...page.records)
|
||||
before = page.nextBefore
|
||||
} while (before !== undefined)
|
||||
const expected = referenceActivityPage(list, { limit: 500, filter, ...scope })
|
||||
expect(pages).toEqual(expected.records)
|
||||
}
|
||||
}
|
||||
expect(database.getActivityHistoryPage({ limit: 500 }).records).toEqual(firstOccurrences(list))
|
||||
}
|
||||
})
|
||||
|
||||
it('applies content-matched changes in SQL exactly like the reference, including legacy duplicates', () => {
|
||||
for (const seed of [4, 8, 15]) {
|
||||
const random = makeRandom(seed)
|
||||
const pick = <T,>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
|
||||
const { database } = open()
|
||||
let list = randomList(random, 150).map((item, index) =>
|
||||
item.kind === 'tool' || item.kind === 'subagent' ? { ...item, callId: `call-${index % 4}` } : item)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: false })
|
||||
list = firstOccurrences(list)
|
||||
for (let round = 0; round < 80; round++) {
|
||||
const requestId = `request-${Math.floor(random() * 10)}`
|
||||
const status = pick(statuses)
|
||||
const changes: ActivityHistoryChange[] = [pick([
|
||||
(): ActivityHistoryChange => ({ type: 'upsert-call', record: { ...randomList(random, 1)[0]!, id: `new-${round}`,
|
||||
kind: pick(['tool', 'subagent'] as const), requestId, callId: `call-${Math.floor(random() * 4)}` } }),
|
||||
(): ActivityHistoryChange => ({ type: 'update-request', requestId, status, ...(random() < 0.5 ? { detail: 'd' } : {}) }),
|
||||
(): ActivityHistoryChange => ({ type: 'resolve-approval', conversationId: `conversation-${Math.floor(random() * 6)}`, status, detailLine: 'x' }),
|
||||
(): ActivityHistoryChange => ({ type: 'settle-request', requestId, status, detailLine: 'y' }),
|
||||
(): ActivityHistoryChange => ({ type: 'remove-request', requestId }),
|
||||
(): ActivityHistoryChange => ({ type: 'remove-call', requestId, callId: `call-${Math.floor(random() * 4)}` })
|
||||
])()]
|
||||
database.updateActivityHistory({ changes })
|
||||
list = applyActivityChanges(list, changes)
|
||||
expect(firstOccurrences(database.getActivityHistory().records)).toEqual(list)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles with the stored tasks like the former renderer code', () => {
|
||||
const { database } = open()
|
||||
const random = makeRandom(5)
|
||||
const list = randomList(random, 200)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: false })
|
||||
const statusesByRequest: Array<AssistantTask['status'] | undefined> =
|
||||
['completed', 'failed', 'cancelled', 'interrupted', 'running', 'waiting_approval', 'queued', undefined, 'completed', undefined]
|
||||
const tasks: Array<Pick<AssistantTask, 'id' | 'status'>> = []
|
||||
statusesByRequest.forEach((status, index) => {
|
||||
if (!status) return
|
||||
const task = database.createTask({ id: `request-${index}`, title: 't', instructions: 'i', workMode: 'ask' })
|
||||
database.updateTaskStatus(task.id, status)
|
||||
tasks.push({ id: task.id, status })
|
||||
})
|
||||
const active = new Set(['request-8', 'request-9'])
|
||||
const changed = database.reconcileActivityHistory({ activeRequestIds: [...active], interruptedDetail: '已在重启时中断' })
|
||||
const expected = referenceActivityReconcile(list, tasks, active, '已在重启时中断')
|
||||
expect(database.getActivityHistory().records).toEqual(expected)
|
||||
expect(changed).toBe(expected.filter((record, index) => record !== list[index]).length)
|
||||
expect(database.reconcileActivityHistory({ activeRequestIds: [...active], interruptedDetail: 'x' })).toBe(0)
|
||||
})
|
||||
|
||||
it('moves a record to the front without rewriting other rows', () => {
|
||||
const { database, raw } = open()
|
||||
const list = randomList(makeRandom(3), 1_000)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: false })
|
||||
const total = (): number => (raw.prepare('SELECT total_changes() AS n').get() as { n: number }).n
|
||||
const start = total()
|
||||
const target = firstOccurrences(list)[600]!
|
||||
database.updateActivityHistory({ changes: [{ type: 'upsert', position: 'front', record: { ...target, status: 'completed' } }] })
|
||||
expect(total() - start).toBe(1)
|
||||
expect(database.getActivityHistoryPage({ limit: 1 }).records[0]).toEqual({ ...target, status: 'completed' })
|
||||
// A replace that prepends one record writes one row.
|
||||
const current = database.getActivityHistory().records
|
||||
const before = total()
|
||||
database.replaceActivityHistory({ records: [{ ...list[0]!, id: 'fresh' }, ...current], legacyHistoryMayBeIncomplete: false })
|
||||
expect(total() - before).toBe(1)
|
||||
})
|
||||
|
||||
it('reads pages by index, without scanning arrays', () => {
|
||||
const { raw } = open()
|
||||
for (const sql of [
|
||||
'SELECT record_json FROM activity_history_records WHERE occurrence = 0 AND order_seq < 5 ORDER BY order_seq DESC LIMIT 10',
|
||||
"SELECT record_json FROM activity_history_records WHERE occurrence = 0 AND status_group = 'failed' ORDER BY order_seq DESC LIMIT 10",
|
||||
"SELECT record_json FROM activity_history_records WHERE occurrence = 0 AND conversation_id = 'c' ORDER BY order_seq DESC LIMIT 10"
|
||||
]) {
|
||||
const plan = (raw.prepare(`EXPLAIN QUERY PLAN ${sql}`).all() as Array<{ detail: string }>).map((row) => row.detail).join('; ')
|
||||
expect(plan).toMatch(/USING INDEX/)
|
||||
expect(plan).not.toMatch(/TEMP B-TREE|json_each/)
|
||||
}
|
||||
})
|
||||
|
||||
it('migrates the stored order exactly, keeping the first occurrence of duplicates shown', () => {
|
||||
const { database, raw, path, directory } = open()
|
||||
const list = randomList(makeRandom(11), 120)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: true })
|
||||
database.close()
|
||||
// Rebuild the schema-56 layout: order list in record_order_json, no order columns.
|
||||
const legacy = new DatabaseSync(path)
|
||||
const rows = legacy.prepare(
|
||||
'SELECT record_key, record_json FROM activity_history_records ORDER BY order_seq DESC, record_key'
|
||||
).all() as Array<{ record_key: string; record_json: string }>
|
||||
legacy.exec(`
|
||||
DROP TABLE activity_history_records;
|
||||
CREATE TABLE activity_history_records (record_key TEXT PRIMARY KEY NOT NULL, record_json TEXT NOT NULL);
|
||||
`)
|
||||
const insert = legacy.prepare('INSERT INTO activity_history_records VALUES (?, ?)')
|
||||
for (const row of rows) insert.run(row.record_key, row.record_json)
|
||||
insert.run(JSON.stringify(['orphan', 0]), JSON.stringify({ ...list[0], id: 'orphan' }))
|
||||
legacy.prepare("UPDATE activity_history SET record_order_json = ? WHERE singleton = 1")
|
||||
.run(JSON.stringify(rows.map((row) => row.record_key)))
|
||||
legacy.exec('PRAGMA user_version = 56')
|
||||
legacy.close()
|
||||
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(directory)
|
||||
opened.push(reopened)
|
||||
expect(reopened.getActivityHistory()).toEqual({ records: list, legacyHistoryMayBeIncomplete: true })
|
||||
expect(reopened.getActivityHistoryPage({ limit: 500 }).records).toEqual(firstOccurrences(list))
|
||||
const check = (reopened as unknown as { database: DatabaseSync }).database
|
||||
expect(check.prepare("SELECT record_order_json FROM activity_history").get()).toEqual({ record_order_json: '[]' })
|
||||
void raw
|
||||
})
|
||||
|
||||
it('serves pages and the summary from the readonly worker with the same results', async () => {
|
||||
const { database, directory } = open()
|
||||
const workerPath = join(directory, 'readonly-query-worker.cjs')
|
||||
await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: workerPath,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||
const list = randomList(makeRandom(21), 150)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: false })
|
||||
const pageRequest = { limit: 40, filter: 'failed' as const }
|
||||
const summaryRequest = { conversationIds: ['conversation-1', 'conversation-4'] }
|
||||
const expectedPage = database.getActivityHistoryPage(pageRequest)
|
||||
const expectedSummary = database.getActivityHistorySummary(summaryRequest)
|
||||
database.enableReadonlyWorker(workerPath)
|
||||
expect(await database.getActivityHistoryPageAsync(pageRequest)).toEqual(expectedPage)
|
||||
expect(await database.getActivityHistorySummaryAsync(summaryRequest)).toEqual(expectedSummary)
|
||||
// A committed write is visible to the next worker read.
|
||||
database.updateActivityHistory({ changes: [{ type: 'upsert', position: 'front', record: { ...list[0]!, id: 'fresh', status: 'failed' } }] })
|
||||
expect((await database.getActivityHistoryPageAsync(pageRequest)).records[0]!.id).toBe('fresh')
|
||||
await expect(database.getActivityHistoryPageAsync({ limit: 501 })).rejects.toThrow()
|
||||
}, 60_000)
|
||||
|
||||
it('clears everything in one call', () => {
|
||||
const { database } = open()
|
||||
database.replaceActivityHistory({ records: randomList(makeRandom(2), 50), legacyHistoryMayBeIncomplete: true })
|
||||
database.clearActivityHistory()
|
||||
expect(database.getActivityHistory()).toEqual({ records: [], legacyHistoryMayBeIncomplete: false })
|
||||
expect(database.getActivityHistorySummary({ conversationIds: [] }).counts).toEqual({ all: 0, active: 0, failed: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
activityHistoryPageRequestSchema,
|
||||
activityHistoryReconcileRequestSchema,
|
||||
activityHistorySnapshotSchema,
|
||||
activityHistorySummaryRequestSchema,
|
||||
activityHistoryUpdateSchema,
|
||||
activityRecordSchema,
|
||||
type ActivityHistoryPage,
|
||||
type ActivityHistorySnapshot,
|
||||
type ActivityHistorySummary,
|
||||
type ActivityHistoryUpdateResult,
|
||||
type ActivityRecord
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
activityRecordKey,
|
||||
isActivityHistoryPlanEmpty,
|
||||
parseActivityHistorySnapshot,
|
||||
planActivityHistoryReplace,
|
||||
type ActivityHistoryPlan,
|
||||
type ActivityHistoryState
|
||||
} from './activity-history-plan'
|
||||
|
||||
/**
|
||||
* The connection the repository runs on; AssistantDatabase owns and passes
|
||||
* it, so this module does not import node:sqlite itself (PERF-16 allowlist).
|
||||
*/
|
||||
type SqlValue = string | number | bigint | null | Uint8Array
|
||||
type Connection = {
|
||||
readonly isTransaction: boolean
|
||||
exec: (sql: string) => void
|
||||
prepare: (sql: string) => {
|
||||
all: (...args: SqlValue[]) => unknown[]
|
||||
get: (...args: SqlValue[]) => unknown
|
||||
run: (...args: SqlValue[]) => { changes: number | bigint }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity history storage (PERF-15). Every record is one row of
|
||||
* activity_history_records:
|
||||
* - `record_key` = JSON `[id, occurrence]`: legacy history may hold an ID more
|
||||
* than once; only occurrence 0 (the first in list order) is shown.
|
||||
* - `order_seq`: list position, newer = larger. A move to the front assigns a
|
||||
* new maximum, so no order list is rewritten.
|
||||
* - `record_id`, `occurrence`, `status`, `status_group`, `kind`,
|
||||
* `request_id`, `conversation_id`, `project_id`, `created_at`: stored
|
||||
* columns generated from the key and JSON, indexed for pages, counts and
|
||||
* updates; they cannot disagree with the record.
|
||||
* The singleton activity_history row keeps only the legacy-incomplete flag.
|
||||
*/
|
||||
const recordsTableSql = (name: string): string => `
|
||||
CREATE TABLE ${name} (
|
||||
record_key TEXT PRIMARY KEY NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
order_seq INTEGER NOT NULL,
|
||||
record_id TEXT GENERATED ALWAYS AS (json_extract(record_key, '$[0]')) STORED,
|
||||
occurrence INTEGER GENERATED ALWAYS AS (json_extract(record_key, '$[1]')) STORED,
|
||||
status TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.status')) STORED,
|
||||
status_group TEXT GENERATED ALWAYS AS (CASE json_extract(record_json, '$.status')
|
||||
WHEN 'pending' THEN 'active' WHEN 'running' THEN 'active'
|
||||
WHEN 'completed' THEN 'other' ELSE 'failed' END) STORED,
|
||||
kind TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.kind')) STORED,
|
||||
request_id TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.requestId')) STORED,
|
||||
conversation_id TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.conversationId')) STORED,
|
||||
project_id TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.scope.projectId')) STORED,
|
||||
created_at REAL GENERATED ALWAYS AS (json_extract(record_json, '$.createdAt')) STORED
|
||||
)`
|
||||
|
||||
/** Schema 57: order and indexed columns; replaces the record_order_json list. */
|
||||
export function migrateActivityHistoryOrder(database: Connection): void {
|
||||
const columns = database.prepare(
|
||||
"SELECT name FROM pragma_table_xinfo('activity_history_records')"
|
||||
).all() as Array<{ name: string }>
|
||||
// Re-running the migration (a lowered user_version) changes nothing.
|
||||
if (columns.some((column) => column.name === 'status_group')) return
|
||||
// Tables created by the schema-44 migration of this version already carry the order.
|
||||
const hasOrder = columns.some((column) => column.name === 'order_seq')
|
||||
const header = database.prepare(
|
||||
'SELECT record_order_json FROM activity_history WHERE singleton = 1'
|
||||
).get() as { record_order_json: string } | undefined
|
||||
database.exec(recordsTableSql('activity_history_records_v57'))
|
||||
if (hasOrder) {
|
||||
database.exec(`INSERT INTO activity_history_records_v57 (record_key, record_json, order_seq)
|
||||
SELECT record_key, record_json, order_seq FROM activity_history_records`)
|
||||
} else {
|
||||
// The stored order lists every row once; rows outside it were never shown.
|
||||
database.prepare(`
|
||||
INSERT INTO activity_history_records_v57 (record_key, record_json, order_seq)
|
||||
SELECT records.record_key, records.record_json, json_array_length(?1) - CAST(ordering.key AS INTEGER)
|
||||
FROM json_each(?1) AS ordering
|
||||
JOIN activity_history_records AS records ON records.record_key = ordering.value
|
||||
`).run(header?.record_order_json ?? '[]')
|
||||
}
|
||||
database.exec(`
|
||||
DROP TABLE activity_history_records;
|
||||
ALTER TABLE activity_history_records_v57 RENAME TO activity_history_records;
|
||||
UPDATE activity_history SET record_order_json = '[]' WHERE singleton = 1;
|
||||
CREATE INDEX activity_history_records_order ON activity_history_records(occurrence, order_seq);
|
||||
CREATE INDEX activity_history_records_group ON activity_history_records(occurrence, status_group, order_seq);
|
||||
CREATE INDEX activity_history_records_conversation ON activity_history_records(conversation_id, occurrence,
|
||||
order_seq, kind, status, request_id, created_at, record_key);
|
||||
CREATE INDEX activity_history_records_project ON activity_history_records(project_id, occurrence, order_seq);
|
||||
CREATE INDEX activity_history_records_request ON activity_history_records(request_id, kind);
|
||||
CREATE INDEX activity_history_records_id ON activity_history_records(record_id);
|
||||
`)
|
||||
}
|
||||
type SummaryRow = {
|
||||
conversation_id: string
|
||||
kind: ActivityRecord['kind']
|
||||
status: ActivityRecord['status']
|
||||
request_id: string
|
||||
created_at: number
|
||||
record_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Title (first request in list order) and status (the latest request's
|
||||
* latest result, else the latest result, else the latest record) of one
|
||||
* conversation; `rows` are newest first, so createdAt ties resolve to the
|
||||
* record nearer the front, as the former full-list computation did.
|
||||
*/
|
||||
function summarizeConversation(
|
||||
rows: readonly SummaryRow[],
|
||||
titles: ReadonlyMap<string, string>
|
||||
): { title?: string; status: ActivityRecord['status'] } {
|
||||
const latest = (match: (row: SummaryRow) => boolean): SummaryRow | undefined => {
|
||||
let best: SummaryRow | undefined
|
||||
for (const row of rows) if (match(row) && (!best || row.created_at > best.created_at)) best = row
|
||||
return best
|
||||
}
|
||||
const firstRequest = rows.find((row) => row.kind === 'request')
|
||||
const title = firstRequest ? titles.get(firstRequest.record_key) : undefined
|
||||
const request = latest((row) => row.kind === 'request')
|
||||
const status = request
|
||||
? (latest((row) => row.kind === 'result' && row.request_id === request.request_id)?.status ?? request.status)
|
||||
: (latest((row) => row.kind === 'result')?.status ?? latest(() => true)?.status ?? 'completed')
|
||||
return { ...(title === undefined ? {} : { title }), status }
|
||||
}
|
||||
|
||||
function parseRow(json: string): ActivityRecord {
|
||||
return activityRecordSchema.parse(JSON.parse(json))
|
||||
}
|
||||
|
||||
export class ActivityHistoryRepository {
|
||||
/** Validated rows of the last committed replace, reused by the next one. */
|
||||
private replaceCache?: { state: ActivityHistoryState; dataVersion: number }
|
||||
|
||||
constructor(private readonly database: Connection) {}
|
||||
|
||||
/** Drops cached rows after writes that bypass the repository. */
|
||||
invalidate(): void {
|
||||
this.replaceCache = undefined
|
||||
}
|
||||
|
||||
/** The whole stored list, every occurrence, newest first. */
|
||||
snapshot(): ActivityHistorySnapshot {
|
||||
const rows = this.database.prepare(
|
||||
'SELECT record_json FROM activity_history_records ORDER BY order_seq DESC, record_key'
|
||||
).all() as Array<{ record_json: string }>
|
||||
return activityHistorySnapshotSchema.parse({
|
||||
records: rows.map((row) => JSON.parse(row.record_json) as unknown),
|
||||
legacyHistoryMayBeIncomplete: this.readIncomplete()
|
||||
})
|
||||
}
|
||||
|
||||
/** One page of shown records, newest first, by index. */
|
||||
page(input: unknown): ActivityHistoryPage {
|
||||
const request = activityHistoryPageRequestSchema.parse(input)
|
||||
const where = ['occurrence = 0']
|
||||
const args: Array<string | number> = []
|
||||
if (request.before !== undefined) {
|
||||
where.push('order_seq < ?')
|
||||
args.push(request.before)
|
||||
}
|
||||
if (request.filter && request.filter !== 'all') {
|
||||
where.push('status_group = ?')
|
||||
args.push(request.filter)
|
||||
}
|
||||
if (request.projectId) {
|
||||
where.push('project_id = ?')
|
||||
args.push(request.projectId)
|
||||
}
|
||||
if (request.conversationId) {
|
||||
where.push('conversation_id = ?')
|
||||
args.push(request.conversationId)
|
||||
}
|
||||
const rows = this.database.prepare(
|
||||
`SELECT order_seq, record_json FROM activity_history_records
|
||||
WHERE ${where.join(' AND ')} ORDER BY order_seq DESC LIMIT ?`
|
||||
).all(...args, request.limit + 1) as Array<{ order_seq: number; record_json: string }>
|
||||
const more = rows.length > request.limit
|
||||
const shown = more ? rows.slice(0, request.limit) : rows
|
||||
return {
|
||||
records: shown.map((row) => parseRow(row.record_json)),
|
||||
...(more ? { nextBefore: shown.at(-1)!.order_seq } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** What the Activity page derives from the whole history. */
|
||||
summary(input: unknown): ActivityHistorySummary {
|
||||
const request = activityHistorySummaryRequestSchema.parse(input)
|
||||
const groupRows = this.database.prepare(
|
||||
`SELECT status_group AS status_group, COUNT(*) AS count FROM activity_history_records
|
||||
WHERE occurrence = 0 GROUP BY status_group`
|
||||
).all() as Array<{ status_group: 'active' | 'failed' | 'other'; count: number }>
|
||||
const counts = { all: 0, active: 0, failed: 0 }
|
||||
for (const { status_group: group, count } of groupRows) {
|
||||
counts.all += count
|
||||
if (group !== 'other') counts[group] += count
|
||||
}
|
||||
// One indexed read of the requested conversations' rows, newest first.
|
||||
const rows = this.database.prepare(
|
||||
`SELECT conversation_id, kind, status, request_id, created_at, record_key
|
||||
FROM activity_history_records
|
||||
WHERE conversation_id IN (SELECT value FROM json_each(?)) AND occurrence = 0
|
||||
ORDER BY conversation_id, order_seq DESC`
|
||||
).all(JSON.stringify([...new Set(request.conversationIds)])) as SummaryRow[]
|
||||
const byConversation = new Map<string, SummaryRow[]>()
|
||||
for (const row of rows) {
|
||||
const own = byConversation.get(row.conversation_id)
|
||||
if (own) own.push(row)
|
||||
else byConversation.set(row.conversation_id, [row])
|
||||
}
|
||||
// Titles come from the first request row of each conversation only.
|
||||
const titleKeys = [...byConversation.values()].flatMap((own) => {
|
||||
const first = own.find((row) => row.kind === 'request')
|
||||
return first ? [first.record_key] : []
|
||||
})
|
||||
const titles = new Map((this.database.prepare(
|
||||
`SELECT record_key, json_extract(record_json, '$.title') AS title FROM activity_history_records
|
||||
WHERE record_key IN (SELECT value FROM json_each(?))`
|
||||
).all(JSON.stringify(titleKeys)) as Array<{ record_key: string; title: string }>)
|
||||
.map((row) => [row.record_key, row.title]))
|
||||
const conversations: ActivityHistorySummary['conversations'] = {}
|
||||
for (const conversationId of new Set(request.conversationIds)) {
|
||||
conversations[conversationId] = summarizeConversation(byConversation.get(conversationId) ?? [], titles)
|
||||
}
|
||||
return { counts, conversations, legacyHistoryMayBeIncomplete: this.readIncomplete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends pending/running records whose request is not running in this
|
||||
* window: without a task they become interrupted (with the given localized
|
||||
* line appended); with a finished task they take its status, except that
|
||||
* tool, approval and subagent records of a completed task become
|
||||
* interrupted. Returns the number of changed records.
|
||||
*/
|
||||
reconcile(input: unknown): number {
|
||||
const request = activityHistoryReconcileRequestSchema.parse(input)
|
||||
return this.write(() => {
|
||||
const result = this.database.prepare(`
|
||||
WITH candidates AS (
|
||||
SELECT records.record_key,
|
||||
CASE
|
||||
WHEN tasks.id IS NULL THEN 'interrupted'
|
||||
WHEN tasks.status IN ('completed', 'failed', 'cancelled', 'interrupted') THEN tasks.status
|
||||
END AS terminal
|
||||
FROM activity_history_records AS records
|
||||
LEFT JOIN tasks ON tasks.id = records.request_id AND tasks.visible = 1
|
||||
WHERE records.occurrence = 0 AND records.status_group = 'active'
|
||||
AND records.request_id NOT IN (SELECT value FROM json_each(?1))
|
||||
)
|
||||
UPDATE activity_history_records SET record_json = json_set(record_json,
|
||||
'$.status', CASE
|
||||
WHEN candidates.terminal = 'completed' AND activity_history_records.kind IN ('tool', 'approval', 'subagent')
|
||||
THEN 'interrupted'
|
||||
ELSE candidates.terminal END,
|
||||
'$.detail', CASE
|
||||
WHEN candidates.terminal = 'interrupted'
|
||||
THEN json_extract(record_json, '$.detail') || char(10) || ?2
|
||||
ELSE json_extract(record_json, '$.detail') END)
|
||||
FROM candidates
|
||||
WHERE activity_history_records.record_key = candidates.record_key AND candidates.terminal IS NOT NULL
|
||||
`).run(JSON.stringify(request.activeRequestIds), request.interruptedDetail)
|
||||
return Number(result.changes)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies incremental changes in one transaction, without reading the
|
||||
* history. Returns the stored record of every upsert-call, which keeps
|
||||
* the id and createdAt of an earlier record of the call the renderer may
|
||||
* not have loaded.
|
||||
*/
|
||||
update(input: unknown): ActivityHistoryUpdateResult {
|
||||
const update = activityHistoryUpdateSchema.parse(input)
|
||||
const calls: ActivityRecord[] = []
|
||||
this.write(() => {
|
||||
const database = this.database
|
||||
const nextSeq = database.prepare(
|
||||
'SELECT COALESCE(MAX(order_seq), 0) + 1 AS seq FROM activity_history_records'
|
||||
)
|
||||
const exists = database.prepare('SELECT 1 FROM activity_history_records WHERE record_key = ?')
|
||||
const insert = database.prepare(
|
||||
'INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)'
|
||||
)
|
||||
const moveToFront = database.prepare(
|
||||
'UPDATE activity_history_records SET record_json = ?, order_seq = ? WHERE record_key = ?'
|
||||
)
|
||||
const replaceInPlace = database.prepare(
|
||||
'UPDATE activity_history_records SET record_json = ? WHERE record_key = ? AND record_json != ?'
|
||||
)
|
||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_id = ?')
|
||||
const seq = (): number => (nextSeq.get() as { seq: number }).seq
|
||||
// Shown rows (first occurrences) a content-matched change applies to, newest first.
|
||||
type Row = { record_key: string; order_seq: number; record: ActivityRecord }
|
||||
const select = (where: string, limit: number, ...args: SqlValue[]): Row[] =>
|
||||
(database.prepare(
|
||||
`SELECT record_key, order_seq, record_json FROM activity_history_records
|
||||
WHERE occurrence = 0 AND ${where} ORDER BY order_seq DESC LIMIT ${limit}`
|
||||
).all(...args) as Array<{ record_key: string; order_seq: number; record_json: string }>)
|
||||
.map((row) => ({ record_key: row.record_key, order_seq: row.order_seq, record: parseRow(row.record_json) }))
|
||||
const frontSeq = database.prepare(
|
||||
'SELECT MAX(order_seq) AS seq FROM activity_history_records WHERE occurrence = 0'
|
||||
)
|
||||
const rewrite = (key: string, record: ActivityRecord): void => {
|
||||
const json = JSON.stringify(activityRecordSchema.parse(record))
|
||||
replaceInPlace.run(json, key, json)
|
||||
}
|
||||
for (const change of update.changes) {
|
||||
switch (change.type) {
|
||||
case 'remove-duplicates':
|
||||
database.exec('DELETE FROM activity_history_records WHERE occurrence != 0')
|
||||
break
|
||||
case 'remove':
|
||||
remove.run(change.id)
|
||||
break
|
||||
case 'upsert': {
|
||||
// The first occurrence stays first when moved to the front, so its key is stable.
|
||||
const key = activityRecordKey(change.record.id, 0)
|
||||
const json = JSON.stringify(change.record)
|
||||
if (!exists.get(key)) insert.run(key, json, seq())
|
||||
else if (change.position === 'front') moveToFront.run(json, seq(), key)
|
||||
else replaceInPlace.run(json, key, json)
|
||||
break
|
||||
}
|
||||
case 'upsert-call': {
|
||||
const incoming = change.record
|
||||
const [existing] = select(
|
||||
"request_id = ? AND kind = ? AND json_extract(record_json, '$.callId') = ?", 1,
|
||||
incoming.requestId, incoming.kind, incoming.callId!
|
||||
)
|
||||
if (!existing) {
|
||||
insert.run(activityRecordKey(incoming.id, 0), JSON.stringify(incoming), seq())
|
||||
calls.push(incoming)
|
||||
break
|
||||
}
|
||||
const before = existing.record
|
||||
const unchanged = before.conversationId === incoming.conversationId && before.title === incoming.title &&
|
||||
before.detail === incoming.detail && before.status === incoming.status
|
||||
const front = existing.order_seq === (frontSeq.get() as { seq: number }).seq
|
||||
const next = unchanged ? before : { ...incoming, id: before.id, createdAt: before.createdAt, scope: before.scope }
|
||||
calls.push(next)
|
||||
if (unchanged && front) break
|
||||
moveToFront.run(JSON.stringify(activityRecordSchema.parse(next)), seq(), existing.record_key)
|
||||
break
|
||||
}
|
||||
case 'update-request':
|
||||
for (const row of select("request_id = ? AND kind = 'request'", -1, change.requestId)) {
|
||||
rewrite(row.record_key, { ...row.record, status: change.status, detail: change.detail ?? row.record.detail })
|
||||
}
|
||||
break
|
||||
case 'resolve-approval': {
|
||||
const [row] = select("conversation_id = ? AND kind = 'approval' AND status = 'pending'", 1,
|
||||
change.conversationId)
|
||||
if (row) {
|
||||
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'settle-request':
|
||||
for (const row of select("request_id = ? AND kind != 'request' AND status_group = 'active'", -1,
|
||||
change.requestId)) {
|
||||
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
|
||||
}
|
||||
break
|
||||
case 'remove-request':
|
||||
for (const row of select('request_id = ?', -1, change.requestId)) remove.run(row.record.id)
|
||||
break
|
||||
case 'remove-call':
|
||||
for (const row of select(
|
||||
"request_id = ? AND kind = 'tool' AND json_extract(record_json, '$.callId') = ?", -1,
|
||||
change.requestId, change.callId
|
||||
)) remove.run(row.record.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (update.legacyHistoryMayBeIncomplete !== undefined) {
|
||||
this.writeIncomplete(update.legacyHistoryMayBeIncomplete)
|
||||
}
|
||||
})
|
||||
return { calls }
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a whole newest-first list (legacy migration). Unchanged records
|
||||
* are neither re-validated, re-serialized nor written; a fully unchanged
|
||||
* snapshot does not open a write transaction.
|
||||
*/
|
||||
replace(input: unknown): void {
|
||||
// Reuse is safe even from a stale cache: cached records are validated copies.
|
||||
const { items, incomplete } = parseActivityHistorySnapshot(input, this.replaceCache?.state)
|
||||
const committed = this.committedState()
|
||||
const planned = committed && planActivityHistoryReplace(items, incomplete, committed)
|
||||
if (planned && isActivityHistoryPlanEmpty(planned)) return
|
||||
let result: ActivityHistoryPlan | undefined
|
||||
this.write(() => {
|
||||
const previous = committed && planned ? committed : this.readState()
|
||||
result = previous === committed && planned ? planned : planActivityHistoryReplace(items, incomplete, previous)
|
||||
this.applyPlan(result)
|
||||
})
|
||||
// Publish only committed records; a failed save must remain retryable.
|
||||
this.replaceCache = { state: result!.next, dataVersion: this.dataVersion() }
|
||||
}
|
||||
|
||||
/** Writes a parsed legacy snapshot into empty tables (schema migrations). */
|
||||
writeSnapshot(snapshot: ActivityHistorySnapshot, onProgress?: (processed: number) => void): void {
|
||||
this.applyPlan(planActivityHistoryReplace(
|
||||
snapshot.records.map((record) => ({ record })), snapshot.legacyHistoryMayBeIncomplete,
|
||||
{ entries: new Map(), incomplete: !snapshot.legacyHistoryMayBeIncomplete }, onProgress))
|
||||
}
|
||||
|
||||
/** Deletes the whole history in one transaction. */
|
||||
clear(): void {
|
||||
this.write(() => {
|
||||
this.database.exec('DELETE FROM activity_history_records')
|
||||
this.writeIncomplete(false)
|
||||
})
|
||||
}
|
||||
|
||||
private write<T>(body: () => T): T {
|
||||
const database = this.database
|
||||
this.replaceCache = undefined
|
||||
if (database.isTransaction) return body()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const result = body()
|
||||
database.exec('COMMIT')
|
||||
return result
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private dataVersion(): number {
|
||||
return (this.database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version
|
||||
}
|
||||
|
||||
/** The cached committed state, when no other connection committed since. */
|
||||
private committedState(): ActivityHistoryState | undefined {
|
||||
const cache = this.replaceCache
|
||||
if (!cache || this.database.isTransaction) return undefined
|
||||
return cache.dataVersion === this.dataVersion() ? cache.state : undefined
|
||||
}
|
||||
|
||||
private readState(): ActivityHistoryState {
|
||||
const rows = this.database.prepare(
|
||||
'SELECT record_key, record_json, order_seq FROM activity_history_records'
|
||||
).all() as Array<{ record_key: string; record_json: string; order_seq: number }>
|
||||
return {
|
||||
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json, seq: row.order_seq }])),
|
||||
incomplete: this.readIncomplete()
|
||||
}
|
||||
}
|
||||
|
||||
private applyPlan(plan: ActivityHistoryPlan): void {
|
||||
const database = this.database
|
||||
if (plan.removes.length > 0) {
|
||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
for (const key of plan.removes) remove.run(key)
|
||||
}
|
||||
if (plan.upserts.length > 0) {
|
||||
const upsert = database.prepare(
|
||||
`INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json, order_seq = excluded.order_seq`
|
||||
)
|
||||
for (const [key, json, seq] of plan.upserts) upsert.run(key, json, seq)
|
||||
}
|
||||
if (plan.headerChanged) this.writeIncomplete(plan.next.incomplete)
|
||||
}
|
||||
|
||||
private readIncomplete(): boolean {
|
||||
const row = this.database.prepare(
|
||||
'SELECT legacy_history_may_be_incomplete AS value FROM activity_history WHERE singleton = 1'
|
||||
).get() as { value: number } | undefined
|
||||
return row?.value === 1
|
||||
}
|
||||
|
||||
private writeIncomplete(value: boolean): void {
|
||||
this.database.prepare(
|
||||
'UPDATE activity_history SET legacy_history_may_be_incomplete = ? WHERE singleton = 1'
|
||||
).run(Number(value))
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
AssistantDatabase,
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION,
|
||||
MESSAGE_RECOVERY_CANDIDATE_SQL
|
||||
} from './assistant-database'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
@@ -101,6 +102,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 }
|
||||
@@ -509,6 +531,86 @@ function claimManualScheduleQueueItem(
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('writes frequent local saves with synchronous NORMAL and keeps FULL for everything else', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-relaxed-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const raw = (database as unknown as { database: DatabaseSync }).database
|
||||
const levels: number[] = []
|
||||
const exec = raw.exec.bind(raw)
|
||||
raw.exec = (sql: string) => {
|
||||
exec(sql)
|
||||
if (/^\s*COMMIT/i.test(sql)) levels.push((raw.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous)
|
||||
}
|
||||
const level = (): number => (raw.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous
|
||||
expect(level()).toBe(2)
|
||||
const task = database.createTask({ id: randomUUID(), title: 't', instructions: 'i', workMode: 'ask' })
|
||||
database.appendTaskEvent(task.id, 'text', { type: 'text', requestId: task.id, delta: 'a' })
|
||||
expect(level()).toBe(2)
|
||||
const id = randomUUID()
|
||||
database.saveLocalConversations([{ header: { id, title: 'Relaxed', updatedAt: 1 },
|
||||
messages: [{ id: randomUUID(), role: 'user', state: 'complete', content: 'hi', createdAt: 1 }] }])
|
||||
expect(levels.at(-1)).toBe(1)
|
||||
expect(level()).toBe(2)
|
||||
// A failed relaxed write still restores FULL.
|
||||
expect(() => database.saveLocalConversations([{ header: { id, title: 'Relaxed', updatedAt: 2 },
|
||||
messages: [{ id: randomUUID(), role: 'user', state: 'complete', content: 'x', createdAt: 2 }, { id: 'dup', role: 'user', state: 'complete', content: 'y', createdAt: 2 }, { id: 'dup', role: 'assistant', state: 'complete', content: 'z', createdAt: 2 }] }]))
|
||||
.toThrow()
|
||||
expect(level()).toBe(2)
|
||||
// Lifecycle events and task status changes commit with FULL.
|
||||
levels.length = 0
|
||||
database.updateTaskStatus(task.id, 'completed')
|
||||
expect(level()).toBe(2)
|
||||
expect(levels.every((value) => value === 2)).toBe(true)
|
||||
expect(database.getConversation(id).messages.map((message) => message.content)).toEqual(['hi'])
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('startup recovery reads only candidate messages and ends the same ones as a full scan', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovery-index-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(process.cwd())
|
||||
const id = randomUUID()
|
||||
const message = (content: string, extra: Record<string, unknown> = {}) => ({
|
||||
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content, createdAt: 1, ...extra })
|
||||
const messages = [
|
||||
message('streaming', { state: 'streaming' as const }),
|
||||
message('running tool', { tools: [{ name: 'a', state: 'running', summary: 's' }] }),
|
||||
message('pending block', { blocks: [{ type: 'tool', tool: { name: 'b', state: 'pending', summary: 's' } }] }),
|
||||
message('queued subagent', { subagents: [{ childTaskId: randomUUID(), expertId: randomUUID(), expertName: 'e', routingMode: 'native', state: 'queued' }] }),
|
||||
// Mentions "running" only inside text: a candidate, but nothing to end.
|
||||
message('"state":"running" in text'),
|
||||
message('done', { tools: [{ name: 'c', state: 'completed', summary: 's' }] })
|
||||
]
|
||||
database.saveLocalConversations([{ header: { id, title: 'Recovery', updatedAt: 1 }, messages }])
|
||||
const raw = (database as unknown as { database: DatabaseSync }).database
|
||||
const plan = (raw.prepare(`EXPLAIN QUERY PLAN SELECT id FROM messages WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL}`)
|
||||
.all() as Array<{ detail: string }>).map((row) => row.detail).join(' ')
|
||||
expect(plan).toContain('messages_recovery_idx')
|
||||
const imagePlan = (raw.prepare(`EXPLAIN QUERY PLAN SELECT id FROM messages
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL} AND json_type(metadata_json, '$.imageOperations') = 'array'`)
|
||||
.all() as Array<{ detail: string }>).map((row) => row.detail).join(' ')
|
||||
expect(imagePlan).toContain('messages_recovery_idx')
|
||||
database.close()
|
||||
|
||||
const recovered = new AssistantDatabase(path)
|
||||
recovered.initialize(process.cwd())
|
||||
try {
|
||||
const after = recovered.getConversation(id).messages
|
||||
expect(after.map((item) => item.state)).toEqual(['error', 'complete', 'complete', 'complete', 'complete', 'complete'])
|
||||
expect(after[1]?.tools?.[0]?.state).toBe('interrupted')
|
||||
expect((after[2]?.blocks?.[0] as { tool: { state: string } }).tool.state).toBe('interrupted')
|
||||
expect(after[3]?.subagents?.[0]?.state).toBe('cancelled')
|
||||
expect(after[4]?.content).toBe('"state":"running" in text')
|
||||
expect(after[5]?.tools?.[0]?.state).toBe('completed')
|
||||
} finally { recovered.close() }
|
||||
})
|
||||
|
||||
it('round trips long checklist metadata and explicit clears across reopen', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-checklist-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -4,16 +4,10 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text
|
||||
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||
import {
|
||||
isActivityHistoryPlanEmpty,
|
||||
parseActivityHistorySnapshot,
|
||||
planActivityHistoryReplace,
|
||||
planActivityHistoryUpdate,
|
||||
type ActivityHistoryPlan,
|
||||
type ActivityHistoryState
|
||||
} from './activity-history-plan'
|
||||
import { ActivityHistoryRepository, migrateActivityHistoryOrder } from './activity-history-repository'
|
||||
import { statSync } from 'node:fs'
|
||||
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
@@ -45,7 +39,10 @@ import type {
|
||||
AssistantTask,
|
||||
ExecutionStats,
|
||||
ExecutionStatsInput,
|
||||
ActivityHistoryPage,
|
||||
ActivityHistorySnapshot,
|
||||
ActivityHistorySummary,
|
||||
ActivityHistoryUpdateResult,
|
||||
ConversationQueueItem,
|
||||
ConversationAnsweredQuestion,
|
||||
ConversationBranchInput,
|
||||
@@ -142,7 +139,23 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 56
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 58
|
||||
/**
|
||||
* Messages startup recovery may have to end (schema 58): still streaming, or
|
||||
* metadata naming an unfinished tool/subagent (pending, running, queued) or
|
||||
* image operation (running, saving, cancelling). Metadata is always minified
|
||||
* JSON.stringify output, so every such state appears literally; the exact
|
||||
* checks run on these rows only, through the partial index
|
||||
* messages_recovery_idx.
|
||||
*/
|
||||
export const MESSAGE_RECOVERY_CANDIDATE_SQL = `(state = 'streaming'
|
||||
OR instr(metadata_json, '"state":"running"') > 0
|
||||
OR instr(metadata_json, '"state":"pending"') > 0
|
||||
OR instr(metadata_json, '"state":"queued"') > 0
|
||||
OR instr(metadata_json, '"state":"saving"') > 0
|
||||
OR instr(metadata_json, '"state":"cancelling"') > 0)`
|
||||
/** Main's automatic checkpoint threshold while the checkpoint worker runs (16 MB of 4 KB pages). */
|
||||
const WAL_SAFETY_CHECKPOINT_PAGES = 4_000
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1234,6 +1247,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
workMode: true,
|
||||
knowledgeLibraryIds: true,
|
||||
knowledgeRetrievalMode: true,
|
||||
storyGraphEnabled: true,
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
@@ -1242,7 +1256,7 @@ function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -1260,18 +1274,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
|
||||
})
|
||||
@@ -1971,9 +1987,10 @@ export class AssistantDatabase {
|
||||
private static readonly executionStatsCacheTtlMs = 30_000
|
||||
private static readonly executionStatsCacheLimit = 8
|
||||
private database?: DatabaseSync
|
||||
private activityHistoryCache?: { state: ActivityHistoryState; dataVersion: number }
|
||||
private activityHistoryRepository?: { database: DatabaseSync; repository: ActivityHistoryRepository }
|
||||
private executionStatsReader?: ExecutionStatsReader
|
||||
private readonlyReader?: ReadonlyQueryReader
|
||||
private checkpointWorker?: Worker
|
||||
private readonlyWorkerPath?: string
|
||||
private foldedSearchConnection?: DatabaseSync
|
||||
private readonly noteStorage: MagicNoteStorage
|
||||
@@ -2272,12 +2289,15 @@ export class AssistantDatabase {
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
database.exec(`DELETE FROM messages WHERE json_extract(metadata_json, '$.queueItemId') IN (
|
||||
SELECT id FROM conversation_queue_items WHERE source = 'user'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
|
||||
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
|
||||
)`)
|
||||
// This scans every message; with no user queue item it cannot match anything.
|
||||
if (database.prepare("SELECT 1 FROM conversation_queue_items WHERE source = 'user' LIMIT 1").get()) {
|
||||
database.exec(`DELETE FROM messages WHERE json_extract(metadata_json, '$.queueItemId') IN (
|
||||
SELECT id FROM conversation_queue_items WHERE source = 'user'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
|
||||
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
|
||||
)`)
|
||||
}
|
||||
database.exec(`
|
||||
INSERT OR IGNORE INTO conversation_queue_items
|
||||
(id, conversation_id, source, label, payload_json,
|
||||
@@ -2319,9 +2339,13 @@ export class AssistantDatabase {
|
||||
|
||||
const recoverableMessages = database
|
||||
.prepare(
|
||||
// The partial index messages_recovery_idx holds exactly the rows
|
||||
// matching the first term (a superset of those updated below), so
|
||||
// startup no longer reads every message's metadata.
|
||||
`SELECT id, state, metadata_json
|
||||
FROM messages
|
||||
WHERE NOT EXISTS (
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks
|
||||
WHERE tasks.id = messages.request_id
|
||||
AND tasks.remote_recoverable = 1
|
||||
@@ -2403,13 +2427,14 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopWalCheckpointWorker()
|
||||
this.executionStatsReader?.close()
|
||||
this.executionStatsReader = undefined
|
||||
this.readonlyReader?.close()
|
||||
this.readonlyReader = undefined
|
||||
this.foldedSearchConnection = undefined
|
||||
this.executionStatsCache.clear()
|
||||
this.activityHistoryCache = undefined
|
||||
this.activityHistoryRepository = undefined
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
this.subagentProgress = undefined
|
||||
@@ -2469,7 +2494,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
this.dirtyMagicNotes.clear()
|
||||
this.activityHistoryCache = undefined
|
||||
this.activityHistoryRepository?.repository.invalidate()
|
||||
this.pendingMagicNoteCleanup.clear()
|
||||
this.reconcileMagicNoteFiles()
|
||||
this.options.onMagicNotesChanged?.()
|
||||
@@ -3194,6 +3219,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.
|
||||
@@ -3322,9 +3363,14 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
||||
// The renderer saves every 500 ms and retries until acknowledged.
|
||||
this.relaxedWrite(() => this.saveLocalConversationsNow(batch))
|
||||
}
|
||||
|
||||
private saveLocalConversationsNow(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 +3422,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 +3432,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,
|
||||
@@ -4996,6 +5044,69 @@ export class AssistantDatabase {
|
||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection used only to checkpoint the WAL from a worker thread
|
||||
* (PERF-15). It never writes rows; see enableWalCheckpointWorker.
|
||||
*/
|
||||
openCheckpointer(): void {
|
||||
if (this.database) throw new Error('Database already open')
|
||||
this.database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
||||
this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA wal_autocheckpoint = 0')
|
||||
}
|
||||
|
||||
/** One PASSIVE checkpoint: copies committed WAL frames without blocking writers. */
|
||||
checkpointWal(): { busy: number; log: number; checkpointed: number } {
|
||||
if (!this.database) throw new Error('Database is not open')
|
||||
return this.database.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as
|
||||
{ busy: number; log: number; checkpointed: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves WAL checkpoints off Main (PERF-15). SQLite's automatic checkpoint
|
||||
* runs inside the commit that crosses 1,000 WAL pages, so on a large
|
||||
* database one ordinary write now and then blocked Main for 100-250 ms
|
||||
* while pages were copied and synced. A worker thread checkpoints on its
|
||||
* own connection instead. While it runs, Main's automatic checkpoint stays
|
||||
* as a safety net at a higher threshold: under sustained writes a passive
|
||||
* checkpoint never catches up exactly, so the WAL could not reset; Main's
|
||||
* checkpoint then finds almost every frame already copied and only
|
||||
* finishes the rest. The default threshold comes back if the worker stops.
|
||||
*/
|
||||
enableWalCheckpointWorker(workerPath: string, intervalMs = 250): void {
|
||||
const database = this.database
|
||||
if (!database || this.checkpointWorker || this.databasePath === ':memory:') return
|
||||
let worker: Worker
|
||||
try {
|
||||
worker = new Worker(workerPath, {
|
||||
workerData: { kind: 'checkpoint', databasePath: this.databasePath, intervalMs }
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
this.checkpointWorker = worker
|
||||
worker.unref()
|
||||
const restore = (): void => {
|
||||
if (this.checkpointWorker !== worker) return
|
||||
this.checkpointWorker = undefined
|
||||
if (this.database === database) database.exec('PRAGMA wal_autocheckpoint = 1000')
|
||||
}
|
||||
worker.on('message', (message: { ready?: boolean }) => {
|
||||
if (message.ready && this.checkpointWorker === worker && this.database === database) {
|
||||
database.exec(`PRAGMA wal_autocheckpoint = ${WAL_SAFETY_CHECKPOINT_PAGES}`)
|
||||
}
|
||||
})
|
||||
worker.once('error', restore)
|
||||
worker.once('exit', restore)
|
||||
}
|
||||
|
||||
private stopWalCheckpointWorker(): void {
|
||||
const worker = this.checkpointWorker
|
||||
if (!worker) return
|
||||
this.checkpointWorker = undefined
|
||||
this.database?.exec('PRAGMA wal_autocheckpoint = 1000')
|
||||
void worker.terminate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs multi-statement reads against one WAL snapshot. Used by the readonly
|
||||
* worker, where Main may commit between statements; on Main itself the
|
||||
@@ -5274,135 +5385,82 @@ export class AssistantDatabase {
|
||||
return result
|
||||
}
|
||||
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
/** Activity history storage and queries (PERF-15); the SQL lives in the repository. */
|
||||
activityHistory(): ActivityHistoryRepository {
|
||||
const database = this.requireDatabase()
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT record_order_json, legacy_history_may_be_incomplete
|
||||
FROM activity_history
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
record_order_json: string
|
||||
legacy_history_may_be_incomplete: number
|
||||
}
|
||||
| undefined
|
||||
const records = database.prepare(
|
||||
`SELECT records.record_json
|
||||
FROM json_each(?) AS ordering
|
||||
JOIN activity_history_records AS records ON records.record_key = ordering.value
|
||||
ORDER BY CAST(ordering.key AS INTEGER)`
|
||||
).all(row?.record_order_json ?? '[]') as Array<{ record_json: string }>
|
||||
return activityHistorySnapshotSchema.parse({
|
||||
records: records.map((record) => JSON.parse(record.record_json)),
|
||||
legacyHistoryMayBeIncomplete:
|
||||
row?.legacy_history_may_be_incomplete === 1
|
||||
})
|
||||
if (this.activityHistoryRepository?.database !== database) {
|
||||
this.activityHistoryRepository = { database, repository: new ActivityHistoryRepository(database) }
|
||||
}
|
||||
return this.activityHistoryRepository.repository
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the whole newest-first list. Unchanged records are neither
|
||||
* re-validated, re-serialized nor written; a fully unchanged snapshot does
|
||||
* not open a write transaction (PERF-15).
|
||||
*/
|
||||
/** The whole stored list (tests, diagnostics); the renderer reads pages. */
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
return this.activityHistory().snapshot()
|
||||
}
|
||||
|
||||
/** Persists a whole newest-first list; used for the legacy localStorage migration. */
|
||||
replaceActivityHistory(input: unknown): void {
|
||||
const database = this.requireDatabase()
|
||||
// Reuse is safe even from a stale cache: cached records are validated copies.
|
||||
const { items, incomplete } = parseActivityHistorySnapshot(input, this.activityHistoryCache?.state)
|
||||
const committed = this.committedActivityHistoryState(database)
|
||||
const planned = committed && planActivityHistoryReplace(items, incomplete, committed)
|
||||
if (planned && isActivityHistoryPlanEmpty(planned)) return
|
||||
this.writeActivityHistoryPlan(database, (previous) => previous === committed && planned
|
||||
? planned : planActivityHistoryReplace(items, incomplete, previous))
|
||||
this.activityHistory().replace(input)
|
||||
}
|
||||
|
||||
/** Applies incremental changes in one transaction (PERF-15). */
|
||||
updateActivityHistory(input: unknown): void {
|
||||
updateActivityHistory(input: unknown): ActivityHistoryUpdateResult {
|
||||
return this.relaxedWrite(() => this.activityHistory().update(input))
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a high-frequency local write with `synchronous = NORMAL` instead of
|
||||
* FULL (PERF-15). In WAL mode NORMAL skips the fsync per commit; the commit
|
||||
* is still atomic and survives an application crash, and the next FULL
|
||||
* commit or checkpoint makes it durable. A power loss can only drop a
|
||||
* suffix of such local writes, never a FULL commit or anything before it,
|
||||
* because the WAL is synced as a whole. Writes another party may rely on
|
||||
* (remote acknowledgements, channel delivery) keep using FULL.
|
||||
*/
|
||||
private relaxedWrite<T>(body: () => T): T {
|
||||
const database = this.requireDatabase()
|
||||
this.writeActivityHistoryPlan(database, (previous) => planActivityHistoryUpdate(input, previous))
|
||||
}
|
||||
|
||||
/** The cached committed state, when no other connection committed since. */
|
||||
private committedActivityHistoryState(database: DatabaseSync): ActivityHistoryState | undefined {
|
||||
const cache = this.activityHistoryCache
|
||||
if (!cache || database.isTransaction) return undefined
|
||||
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
||||
return cache.dataVersion === dataVersion ? cache.state : undefined
|
||||
}
|
||||
|
||||
private writeActivityHistoryPlan(
|
||||
database: DatabaseSync,
|
||||
plan: (previous: ActivityHistoryState) => ActivityHistoryPlan
|
||||
): void {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
// Inside a caller's transaction the caller's commit decides durability.
|
||||
if (database.isTransaction) return body()
|
||||
const previous = (database.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous
|
||||
if (previous <= 1) return body()
|
||||
database.exec('PRAGMA synchronous = NORMAL')
|
||||
try {
|
||||
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
||||
const previous = this.activityHistoryCache?.dataVersion === dataVersion
|
||||
? this.activityHistoryCache.state : this.readActivityHistoryState(database)
|
||||
const result = plan(previous)
|
||||
this.applyActivityHistoryPlan(database, result)
|
||||
database.exec('COMMIT')
|
||||
// Publish only committed records; a failed save must remain retryable.
|
||||
this.activityHistoryCache = { state: result.next, dataVersion }
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
return body()
|
||||
} finally {
|
||||
database.exec(`PRAGMA synchronous = ${previous}`)
|
||||
}
|
||||
}
|
||||
|
||||
private readActivityHistoryState(database: DatabaseSync): ActivityHistoryState {
|
||||
const rows = database.prepare(
|
||||
'SELECT record_key, record_json FROM activity_history_records'
|
||||
).all() as Array<{ record_key: string; record_json: string }>
|
||||
const header = database.prepare(
|
||||
'SELECT record_order_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1'
|
||||
).get() as { record_order_json: string; legacy_history_may_be_incomplete: number } | undefined
|
||||
const order = header ? JSON.parse(header.record_order_json) as unknown : undefined
|
||||
return {
|
||||
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json }])),
|
||||
...(Array.isArray(order) && order.every((key) => typeof key === 'string')
|
||||
? { order, orderJson: header!.record_order_json } : {}),
|
||||
...(header ? { incomplete: header.legacy_history_may_be_incomplete === 1 } : {})
|
||||
}
|
||||
/** Deletes every activity record in one transaction. */
|
||||
clearActivityHistory(): void {
|
||||
this.activityHistory().clear()
|
||||
}
|
||||
|
||||
private applyActivityHistoryPlan(database: DatabaseSync, plan: ActivityHistoryPlan): void {
|
||||
if (plan.upserts.length > 0) {
|
||||
const upsert = database.prepare(
|
||||
`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`
|
||||
)
|
||||
for (const [key, json] of plan.upserts) upsert.run(key, json)
|
||||
}
|
||||
if (plan.removes.length > 0) {
|
||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
for (const key of plan.removes) remove.run(key)
|
||||
}
|
||||
if (plan.headerChanged) {
|
||||
database.prepare(
|
||||
`UPDATE activity_history
|
||||
SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1`
|
||||
).run(plan.next.orderJson, Number(plan.next.incomplete))
|
||||
}
|
||||
getActivityHistoryPage(input: unknown): ActivityHistoryPage {
|
||||
return this.activityHistory().page(input)
|
||||
}
|
||||
|
||||
/** Schema-44 migration: writes a parsed legacy snapshot into the new tables. */
|
||||
private writeActivityHistory(
|
||||
database: DatabaseSync,
|
||||
snapshot: ActivityHistorySnapshot,
|
||||
onProgress?: (processed: number) => void
|
||||
): void {
|
||||
const rows = database.prepare(
|
||||
'SELECT record_key, record_json FROM activity_history_records'
|
||||
).all() as Array<{ record_key: string; record_json: string }>
|
||||
// The order column still holds the legacy records; always rewrite it.
|
||||
const previous: ActivityHistoryState = {
|
||||
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json }]))
|
||||
}
|
||||
this.applyActivityHistoryPlan(database, planActivityHistoryReplace(
|
||||
snapshot.records.map((record) => ({ record })), snapshot.legacyHistoryMayBeIncomplete, previous, onProgress))
|
||||
getActivityHistorySummary(input: unknown): ActivityHistorySummary {
|
||||
return this.readSnapshot(() => this.activityHistory().summary(input))
|
||||
}
|
||||
|
||||
/** Pages and the summary on the readonly worker; same rules as listConversationSummariesAsync. */
|
||||
getActivityHistoryPageAsync(input: unknown): Promise<ActivityHistoryPage> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'activityHistoryPage', [input], undefined, () => this.getActivityHistoryPage(input))
|
||||
}
|
||||
|
||||
getActivityHistorySummaryAsync(input: unknown): Promise<ActivityHistorySummary> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'activityHistorySummary', [input], undefined, () => this.getActivityHistorySummary(input))
|
||||
}
|
||||
|
||||
/** Ends records left running by requests that are no longer active (see the repository). */
|
||||
reconcileActivityHistory(input: unknown): number {
|
||||
return this.activityHistory().reconcile(input)
|
||||
}
|
||||
|
||||
createTask(input: {
|
||||
@@ -6243,6 +6301,20 @@ export class AssistantDatabase {
|
||||
taskId: string,
|
||||
kind: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
// Local run events stream many times per second. Lifecycle events stay
|
||||
// FULL; their commit also makes every earlier relaxed event durable.
|
||||
if (kind === 'status' || kind === 'done' || kind === 'error') {
|
||||
this.appendTaskEventNow(taskId, kind, payload)
|
||||
} else {
|
||||
this.relaxedWrite(() => this.appendTaskEventNow(taskId, kind, payload))
|
||||
}
|
||||
}
|
||||
|
||||
private appendTaskEventNow(
|
||||
taskId: string,
|
||||
kind: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
const database = this.requireDatabase()
|
||||
const payloadJson = this.subagentProgress!.serialize(taskId, kind, payload)
|
||||
@@ -7168,7 +7240,9 @@ export class AssistantDatabase {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const rows = database.prepare("SELECT id, metadata_json FROM messages WHERE json_type(metadata_json, '$.imageOperations') = 'array'")
|
||||
// Candidates come from messages_recovery_idx instead of a scan of every message.
|
||||
const rows = database.prepare(`SELECT id, metadata_json FROM messages
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL} AND json_type(metadata_json, '$.imageOperations') = 'array'`)
|
||||
.all() as { id: string; metadata_json: string }[]
|
||||
for (const row of rows) {
|
||||
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
||||
@@ -12048,11 +12122,13 @@ export class AssistantDatabase {
|
||||
database.exec(`
|
||||
CREATE TABLE activity_history_records (
|
||||
record_key TEXT PRIMARY KEY NOT NULL,
|
||||
record_json TEXT NOT NULL
|
||||
record_json TEXT NOT NULL,
|
||||
order_seq INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
ALTER TABLE activity_history RENAME COLUMN records_json TO record_order_json;
|
||||
`)
|
||||
this.writeActivityHistory(database, snapshot, onProgress || isCancelled ? (processed) => {
|
||||
// Writes order_seq directly; schema 57 then only adds the indexed columns.
|
||||
new ActivityHistoryRepository(database).writeSnapshot(snapshot, onProgress || isCancelled ? (processed) => {
|
||||
onProgress?.({ stage: 'converting', processed, total: snapshot.records.length, bytesBefore })
|
||||
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
} : undefined)
|
||||
@@ -12305,6 +12381,25 @@ export class AssistantDatabase {
|
||||
database.exec('PRAGMA user_version = 56; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 57) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
// Activity history order and indexed columns (PERF-15): pages read by index,
|
||||
// and a move to the front no longer rewrites the order list.
|
||||
migrateActivityHistoryOrder(database)
|
||||
database.exec('PRAGMA user_version = 57; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 58) {
|
||||
// Startup recovery reads candidate messages by index instead of scanning all (PERF-15).
|
||||
database.exec(`
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE INDEX IF NOT EXISTS messages_recovery_idx ON messages(id)
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL};
|
||||
PRAGMA user_version = 58;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,7 +85,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
function dependencies(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SupervisionModelDependencies {
|
||||
@@ -98,11 +98,16 @@ function dependencies(
|
||||
export function createProductionSupervisorService(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
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<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
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?.() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { ASSISTANT_DATABASE_SCHEMA_VERSION, AssistantDatabase } from './assistant-database'
|
||||
import { extractExperiences } from './supervision-experiences'
|
||||
import { assignStories } from './supervision-stories'
|
||||
import { deriveSuggestions } from './supervision-suggester'
|
||||
@@ -168,6 +168,6 @@ it('upgrades schema 55 suggestions to 56 without losing rows', async () => {
|
||||
cleanups.push(async () => { db.close(); await rm(directory, { recursive: true, force: true }) })
|
||||
expect(db.supervisionSuggestions().get('keep')).toMatchObject({ kind: 'open_item', title: 'Keep', storyId: null })
|
||||
const check = new DatabaseSync(path)
|
||||
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(56)
|
||||
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
check.close()
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { ASSISTANT_DATABASE_SCHEMA_VERSION, AssistantDatabase } from './assistant-database'
|
||||
import { SupervisorService, type SupervisorSummarizerRequest } from './supervisor-service'
|
||||
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import type { ReviewConfiguration } from './supervision-review-store'
|
||||
@@ -115,7 +115,7 @@ it('upgrades released per-scope progress and duplicated events into the shared t
|
||||
PRAGMA user_version = 52;`)
|
||||
f.db.close()
|
||||
f.db.initialize(process.cwd())
|
||||
expect(f.sql.prepare('PRAGMA user_version').get()!.user_version).toBe(56)
|
||||
expect(f.sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(f.sql.prepare("SELECT DISTINCT scope FROM review_checkpoints").all().map(row => row.scope)).toEqual(['timeline'])
|
||||
expect(current(f.sql)).toHaveLength(2)
|
||||
await expect(f.run({ kind: 'projects', projectIds: [f.a.id] })).resolves.toMatchObject({ status: 'no_change' })
|
||||
|
||||
@@ -186,6 +186,7 @@ export class SupervisorService {
|
||||
private readonly review?: {
|
||||
database: () => SupervisionReviewStore
|
||||
configuration: () => Promise<ReviewConfiguration>
|
||||
withExecution?: (operation: () => Promise<StoredSupervisionResult>) => Promise<StoredSupervisionResult>
|
||||
/** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */
|
||||
stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise<NonNullable<SupervisionReviewProgress['stories']>>
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -64,7 +64,7 @@ export function registerCapabilityIpcHandlers(
|
||||
runtimeExtensionStore?: RuntimeExtensionStore
|
||||
knowledgeGateway?: Pick<KnowledgeMcpGateway, 'revokeObsidianCapabilities'>
|
||||
obsidianService?: Pick<ObsidianService, 'testConnection'>
|
||||
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider'>
|
||||
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
|
||||
}
|
||||
): void {
|
||||
registerHandler(
|
||||
@@ -266,6 +266,8 @@ export function registerCapabilityIpcHandlers(
|
||||
ipcChannels.capabilitiesTestMcp,
|
||||
async (event, input: unknown): Promise<McpServerTestResult> => {
|
||||
assertTrustedSender(event, window)
|
||||
// A stdio MCP test must launch with the prepared tool PATH.
|
||||
await localToolEnvironmentService?.whenReady()
|
||||
return testMcpServer(
|
||||
await capabilityService.getResolvedMcpServer(
|
||||
mcpServerIdSchema.parse(input)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { convert } from 'html-to-text'
|
||||
import { createLazyRequire } from './lazy-require'
|
||||
|
||||
/**
|
||||
* Loads html-to-text on first use instead of at Main startup (P6, ~45 ms).
|
||||
* Synchronous so the text checks keep their signatures.
|
||||
*/
|
||||
export const loadHtmlToText =
|
||||
createLazyRequire<typeof import('html-to-text')>('html-to-text')
|
||||
|
||||
export function hasExtractedDocumentText(content: string): boolean {
|
||||
const text = content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/gu, '')
|
||||
.replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, '')
|
||||
return Boolean(convert(text, { wordwrap: false }).trim())
|
||||
return Boolean(loadHtmlToText().convert(text, { wordwrap: false }).trim())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts'
|
||||
import {
|
||||
assertDocumentBuffer,
|
||||
chunkDocumentAdvanced,
|
||||
DocumentTextUnavailableError,
|
||||
extractPdfTextPages,
|
||||
parseDocument,
|
||||
type DocumentChunk,
|
||||
type ParsedDocument,
|
||||
type PdfTextExtraction,
|
||||
type PdfTextExtractionOptions
|
||||
} from './knowledge/document-parser'
|
||||
import { extractPptxPages, type PptxPage } from './knowledge/pptx-parser'
|
||||
import type {
|
||||
DocumentParseOperation,
|
||||
DocumentParseRequest,
|
||||
DocumentParseResponse,
|
||||
SerializedDocumentParseError
|
||||
} from './document-parse-errors'
|
||||
|
||||
export type { DocumentParseOperation } from './document-parse-errors'
|
||||
|
||||
// PERF-15/16: pure document work (PDF / Office / text parsing, chunking,
|
||||
// hashing) runs in a single worker thread with a FIFO queue, so Main only
|
||||
// orchestrates. Database writes stay on the caller's side, in the same order.
|
||||
// Same fallback rules as readonly-query-reader: if the worker cannot start or
|
||||
// crashes, the call runs inline (the previous behaviour) and the worker is
|
||||
// retried after a backoff. Aborting a running task terminates the worker.
|
||||
|
||||
/** Worker infrastructure failure; callers fall back to the inline path. */
|
||||
export class DocumentParseWorkerUnavailableError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'DocumentParseWorkerUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
type Task = {
|
||||
id: number
|
||||
op: DocumentParseOperation
|
||||
args: unknown[]
|
||||
transfer: ArrayBuffer[]
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: unknown) => void
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
const MAX_QUEUED = 32
|
||||
const RESTART_BACKOFF_MS = 30_000
|
||||
|
||||
type PdfExceptionConstructor = new (message: string) => Error
|
||||
type PdfExceptionClasses = Partial<Record<string, PdfExceptionConstructor>>
|
||||
const pdfExceptionNames = [
|
||||
'AbortException',
|
||||
'InvalidPDFException',
|
||||
'PasswordException',
|
||||
'ResponseException',
|
||||
'UnknownErrorException'
|
||||
] as const
|
||||
|
||||
let pdfExceptions: Promise<PdfExceptionClasses> | undefined
|
||||
|
||||
/** PDF.js exception classes, loaded only once a PDF error crosses the boundary. */
|
||||
function loadPdfExceptions(): Promise<PdfExceptionClasses> {
|
||||
pdfExceptions ??= import('pdfjs-dist/legacy/build/pdf.mjs').then(
|
||||
(pdfjs) => {
|
||||
const module = pdfjs as unknown as Record<string, unknown>
|
||||
return Object.fromEntries(pdfExceptionNames.flatMap((name) =>
|
||||
typeof module[name] === 'function' ? [[name, module[name] as PdfExceptionConstructor]] : []))
|
||||
},
|
||||
() => ({})
|
||||
)
|
||||
return pdfExceptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds an error with the same class, name, message, cause and primitive
|
||||
* fields as the one thrown inline (stack traces differ by nature).
|
||||
*/
|
||||
export async function deserializeDocumentParseError(error: SerializedDocumentParseError): Promise<Error> {
|
||||
let result: Error
|
||||
if (error.name === 'DocumentTextUnavailableError') {
|
||||
result = new DocumentTextUnavailableError(error.message)
|
||||
} else if ((pdfExceptionNames as readonly string[]).includes(error.name)) {
|
||||
const Constructor = (await loadPdfExceptions())[error.name]
|
||||
result = Constructor ? new Constructor(error.message) : new Error(error.message)
|
||||
} else {
|
||||
const Constructor = error.name === 'RangeError' ? RangeError
|
||||
: error.name === 'TypeError' ? TypeError : Error
|
||||
result = new Constructor(error.message)
|
||||
}
|
||||
if (result.name !== error.name) result.name = error.name
|
||||
if (error.cause) {
|
||||
Object.defineProperty(result, 'cause', {
|
||||
value: await deserializeDocumentParseError(error.cause), writable: true, configurable: true
|
||||
})
|
||||
}
|
||||
for (const [key, value] of Object.entries(error.extra ?? {})) {
|
||||
Object.defineProperty(result, key, { value, writable: true, configurable: true, enumerable: true })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export class DocumentParseWorkerClient {
|
||||
private worker?: Worker
|
||||
private nextId = 0
|
||||
private disabledUntil = 0
|
||||
private closed = false
|
||||
private active?: Task
|
||||
private readonly queue: Task[] = []
|
||||
|
||||
constructor(
|
||||
private readonly workerPath: string,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
/** False while closed, backing off after a crash, or when the queue is full. */
|
||||
get available(): boolean {
|
||||
return !this.closed && this.now() >= this.disabledUntil &&
|
||||
this.queue.length < MAX_QUEUED
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.queue.length + (this.active ? 1 : 0)
|
||||
}
|
||||
|
||||
call<T>(
|
||||
op: DocumentParseOperation,
|
||||
args: unknown[],
|
||||
transfer: ArrayBuffer[] = [],
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (this.closed) return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker closed'))
|
||||
if (signal?.aborted) return Promise.reject(signal.reason)
|
||||
if (this.now() < this.disabledUntil) {
|
||||
return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker is backing off'))
|
||||
}
|
||||
if (this.queue.length >= MAX_QUEUED) {
|
||||
return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker is busy'))
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const task: Task = {
|
||||
id: ++this.nextId,
|
||||
op,
|
||||
args,
|
||||
transfer,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
cleanup: () => signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
if (this.active === task) {
|
||||
// The task is CPU-bound in the worker; stop it by terminating the worker.
|
||||
this.active = undefined
|
||||
const worker = this.worker
|
||||
this.worker = undefined
|
||||
if (worker) void worker.terminate()
|
||||
} else {
|
||||
const index = this.queue.indexOf(task)
|
||||
if (index < 0) return
|
||||
this.queue.splice(index, 1)
|
||||
}
|
||||
task.cleanup()
|
||||
reject(signal!.reason)
|
||||
this.pump()
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
this.queue.push(task)
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.active || this.closed) return
|
||||
const task = this.queue.shift()
|
||||
if (!task) {
|
||||
this.worker?.unref()
|
||||
return
|
||||
}
|
||||
let worker: Worker
|
||||
try {
|
||||
worker = this.ensureWorker()
|
||||
} catch (error) {
|
||||
this.disabledUntil = this.now() + RESTART_BACKOFF_MS
|
||||
this.failAll(task, new DocumentParseWorkerUnavailableError('Document parse worker failed to start', { cause: error }))
|
||||
return
|
||||
}
|
||||
this.active = task
|
||||
// Keep the process alive only while a task is outstanding.
|
||||
worker.ref()
|
||||
try {
|
||||
worker.postMessage(
|
||||
{ id: task.id, op: task.op, args: task.args } satisfies DocumentParseRequest,
|
||||
task.transfer
|
||||
)
|
||||
} catch (error) {
|
||||
this.active = undefined
|
||||
task.cleanup()
|
||||
task.reject(new DocumentParseWorkerUnavailableError('Document parse task could not be posted', { cause: error }))
|
||||
this.pump()
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(first: Task | undefined, error: Error): void {
|
||||
const tasks = [...(first ? [first] : []), ...this.queue.splice(0)]
|
||||
for (const task of tasks) {
|
||||
task.cleanup()
|
||||
task.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureWorker(): Worker {
|
||||
if (this.worker) return this.worker
|
||||
const worker = new Worker(this.workerPath)
|
||||
this.worker = worker
|
||||
worker.on('message', (message: DocumentParseResponse) => {
|
||||
const task = this.active
|
||||
if (this.worker !== worker || !task || task.id !== message.id) return
|
||||
this.active = undefined
|
||||
task.cleanup()
|
||||
if ('error' in message) {
|
||||
void deserializeDocumentParseError(message.error).then(task.reject)
|
||||
} else {
|
||||
task.resolve(message.result)
|
||||
}
|
||||
this.pump()
|
||||
})
|
||||
const failed = (error: Error): void => {
|
||||
// Ignore exits of workers that were terminated on purpose (abort, close).
|
||||
if (this.worker !== worker) return
|
||||
this.worker = undefined
|
||||
this.disabledUntil = this.now() + RESTART_BACKOFF_MS
|
||||
const active = this.active
|
||||
this.active = undefined
|
||||
this.failAll(active, new DocumentParseWorkerUnavailableError(error.message, { cause: error }))
|
||||
void worker.terminate()
|
||||
}
|
||||
worker.once('error', failed)
|
||||
worker.once('exit', (code) => failed(new Error(`Document parse worker exited (${code})`)))
|
||||
worker.unref()
|
||||
return worker
|
||||
}
|
||||
|
||||
/** Test hook: simulate a crash by terminating the worker. */
|
||||
async terminateWorkerForTest(): Promise<void> {
|
||||
await this.worker?.terminate()
|
||||
}
|
||||
|
||||
/** Test hook: end the post-crash backoff. */
|
||||
resetBackoffForTest(): void {
|
||||
this.disabledUntil = 0
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true
|
||||
const worker = this.worker
|
||||
this.worker = undefined
|
||||
const active = this.active
|
||||
this.active = undefined
|
||||
this.failAll(active, new DocumentParseWorkerUnavailableError('Document parse worker closed'))
|
||||
if (worker) void worker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
let sharedClient: DocumentParseWorkerClient | undefined
|
||||
|
||||
/** Enables the parse worker for this process (Main calls it once at startup). */
|
||||
export function configureDocumentParseWorker(workerPath: string | undefined): void {
|
||||
sharedClient?.close()
|
||||
sharedClient = workerPath ? new DocumentParseWorkerClient(workerPath) : undefined
|
||||
}
|
||||
|
||||
export function closeDocumentParseWorker(): void {
|
||||
sharedClient?.close()
|
||||
sharedClient = undefined
|
||||
}
|
||||
|
||||
/** A private copy of the bytes that can be transferred without detaching the caller's buffer. */
|
||||
function transferableCopy(buffer: Uint8Array): ArrayBuffer {
|
||||
return Uint8Array.prototype.slice.call(buffer).buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `op` in the worker when available, otherwise inline. Worker
|
||||
* infrastructure failures fall back to the inline path; operation errors and
|
||||
* aborts propagate unchanged. An already-aborted signal also runs inline, so
|
||||
* validation and abort errors surface in exactly the inline order.
|
||||
*/
|
||||
export async function runOffMain<T>(
|
||||
client: DocumentParseWorkerClient | undefined,
|
||||
op: DocumentParseOperation,
|
||||
request: () => { args: unknown[]; transfer?: ArrayBuffer[] },
|
||||
signal: AbortSignal | undefined,
|
||||
inline: () => T | Promise<T>
|
||||
): Promise<T> {
|
||||
if (!client?.available || signal?.aborted) return inline()
|
||||
const { args, transfer } = request()
|
||||
try {
|
||||
return await client.call<T>(op, args, transfer, signal)
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentParseWorkerUnavailableError && !signal?.aborted) return inline()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDocumentOffMain(
|
||||
name: string,
|
||||
buffer: Buffer,
|
||||
signal?: AbortSignal,
|
||||
client: DocumentParseWorkerClient | undefined = sharedClient
|
||||
): Promise<ParsedDocument> {
|
||||
// Cheap size checks stay inline so oversized input is never copied.
|
||||
try {
|
||||
assertDocumentBuffer(buffer)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return runOffMain(client, 'parseDocument', () => {
|
||||
const bytes = transferableCopy(buffer)
|
||||
return { args: [name, bytes], transfer: [bytes] }
|
||||
}, signal, () => parseDocument(name, buffer, signal))
|
||||
}
|
||||
|
||||
export function extractPdfTextPagesOffMain(
|
||||
buffer: Buffer,
|
||||
options: PdfTextExtractionOptions = {},
|
||||
client: DocumentParseWorkerClient | undefined = sharedClient
|
||||
): Promise<PdfTextExtraction> {
|
||||
const { signal, ...limits } = options
|
||||
return runOffMain(client, 'extractPdfTextPages', () => {
|
||||
const bytes = transferableCopy(buffer)
|
||||
return { args: [bytes, limits], transfer: [bytes] }
|
||||
}, signal, () => extractPdfTextPages(buffer, options))
|
||||
}
|
||||
|
||||
export function extractPptxPagesOffMain(
|
||||
buffer: Buffer,
|
||||
client: DocumentParseWorkerClient | undefined = sharedClient
|
||||
): Promise<PptxPage[]> {
|
||||
return runOffMain(client, 'extractPptxPages', () => {
|
||||
const bytes = transferableCopy(buffer)
|
||||
return { args: [bytes], transfer: [bytes] }
|
||||
}, undefined, () => extractPptxPages(buffer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Not cancellable on purpose: the inline call it replaces was synchronous, so
|
||||
* an abort during chunking keeps surfacing at the caller's next checkpoint.
|
||||
*/
|
||||
export function chunkDocumentOffMain(
|
||||
document: ParsedDocument,
|
||||
settings: KnowledgeChunkingSettings,
|
||||
client: DocumentParseWorkerClient | undefined = sharedClient
|
||||
): Promise<DocumentChunk[]> {
|
||||
return runOffMain(client, 'chunkDocument', () => ({
|
||||
// Only the fields chunking reads; images (Buffers) would otherwise be cloned.
|
||||
args: [{ title: document.title, sourceFormat: document.sourceFormat, content: '', sections: document.sections, warnings: [] }, settings]
|
||||
}), undefined, () => chunkDocumentAdvanced(document, settings))
|
||||
}
|
||||
|
||||
/** Hex SHA-256 of `buffer`, computed in the worker for large inputs. */
|
||||
export function sha256OffMain(
|
||||
buffer: Uint8Array,
|
||||
client: DocumentParseWorkerClient | undefined = sharedClient
|
||||
): Promise<string> {
|
||||
const inline = (): string => createHash('sha256').update(buffer).digest('hex')
|
||||
// Small inputs hash faster than a round trip.
|
||||
if (buffer.byteLength < 1024 * 1024) return Promise.resolve().then(inline)
|
||||
return runOffMain(client, 'sha256', () => {
|
||||
const bytes = transferableCopy(buffer)
|
||||
return { args: [bytes], transfer: [bytes] }
|
||||
}, undefined, inline)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Wire format shared by document-parse-client (Main) and document-parse-worker.
|
||||
|
||||
export type DocumentParseOperation =
|
||||
| 'parseDocument'
|
||||
| 'extractPdfTextPages'
|
||||
| 'extractPptxPages'
|
||||
| 'chunkDocument'
|
||||
| 'sha256'
|
||||
|
||||
export type DocumentParseRequest = {
|
||||
id: number
|
||||
op: DocumentParseOperation
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
export type SerializedDocumentParseError = {
|
||||
name: string
|
||||
message: string
|
||||
/** Primitive own properties some error types carry (PDF.js, Node). */
|
||||
extra?: Record<string, unknown>
|
||||
cause?: SerializedDocumentParseError
|
||||
}
|
||||
|
||||
export type DocumentParseResponse =
|
||||
| { id: number; result: unknown }
|
||||
| { id: number; error: SerializedDocumentParseError }
|
||||
|
||||
export const documentParseErrorExtraKeys = ['code', 'status', 'missing', 'details', 'extraDelay'] as const
|
||||
@@ -0,0 +1,284 @@
|
||||
// @vitest-environment node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts'
|
||||
import {
|
||||
buildDocumentParseWorker,
|
||||
createDocx,
|
||||
createLargePdf,
|
||||
createPptx,
|
||||
createTextPdf,
|
||||
createXlsx,
|
||||
sentenceGenerator
|
||||
} from '../../tests/support/document-parse-worker-fixture'
|
||||
import { createImagePptx } from '../../tests/support/pptx-fixture'
|
||||
import {
|
||||
chunkDocumentOffMain,
|
||||
DocumentParseWorkerClient,
|
||||
DocumentParseWorkerUnavailableError,
|
||||
extractPdfTextPagesOffMain,
|
||||
extractPptxPagesOffMain,
|
||||
parseDocumentOffMain,
|
||||
sha256OffMain
|
||||
} from './document-parse-client'
|
||||
import {
|
||||
chunkDocumentAdvanced,
|
||||
DocumentTextUnavailableError,
|
||||
extractPdfTextPages,
|
||||
parseDocument
|
||||
} from './knowledge/document-parser'
|
||||
import { extractPptxPages } from './knowledge/pptx-parser'
|
||||
|
||||
let workerPath: string
|
||||
let dispose: () => Promise<void>
|
||||
let client: DocumentParseWorkerClient
|
||||
|
||||
beforeAll(async () => {
|
||||
({ workerPath, dispose } = await buildDocumentParseWorker())
|
||||
client = new DocumentParseWorkerClient(workerPath)
|
||||
}, 60_000)
|
||||
afterAll(async () => {
|
||||
client?.close()
|
||||
await dispose?.()
|
||||
})
|
||||
|
||||
const sentence = sentenceGenerator(3)
|
||||
const markdown = [
|
||||
'# GoodBuddy',
|
||||
'知识内容。'.repeat(400),
|
||||
'## 安装',
|
||||
...Array.from({ length: 40 }, (_, index) => `Step ${index}: ${sentence(30)}.`),
|
||||
'### 配置\r\n' + sentence(200),
|
||||
'# 附录',
|
||||
sentence(600)
|
||||
].join('\n')
|
||||
|
||||
/** Fixture corpus: every format the parser accepts, plus edge cases. */
|
||||
const fixtures: Array<{ name: string; buffer: Buffer }> = [
|
||||
{ name: 'notes.md', buffer: Buffer.from(markdown) },
|
||||
{ name: 'plain.txt', buffer: Buffer.from(` ${sentence(5_000)} `) },
|
||||
{ name: 'data.json', buffer: Buffer.from(JSON.stringify({ items: Array.from({ length: 200 }, (_, id) => ({ id, text: sentence(8) })) }, null, 2)) },
|
||||
{ name: 'page.html', buffer: Buffer.from('<main><h1>安全标题</h1><p>网页正文</p><table><tr><td>a</td><td>b</td></tr></table></main><script>恶意脚本</script><style>x{}</style>') },
|
||||
{ name: 'large.txt', buffer: Buffer.from('x'.repeat(5_000_100)) },
|
||||
{ name: 'sample.pdf', buffer: createTextPdf([['PDF body text']]) },
|
||||
{ name: 'multi.pdf', buffer: createTextPdf([['first line', 'second (line)'], [], ['third page', 'ends.']]) },
|
||||
{ name: 'big.pdf', buffer: createLargePdf(40, 40) },
|
||||
{ name: 'sample.docx', buffer: createDocx(['文档正文', '第二段', ''], [[['h1', 'h2'], ['v1', 'v2']]]) },
|
||||
{ name: 'sample.xlsx', buffer: createXlsx([[['name', 'qty', 'note'], ['apple', '3', 'fresh']], [['b', '7', 'inline']]]) },
|
||||
{ name: 'shared.xlsx', buffer: Buffer.from(zipSync({ 'xl/sharedStrings.xml': strToU8('<sst><si><t>表格内容</t></si></sst>') })) },
|
||||
{ name: 'sample.pptx', buffer: createPptx(['幻灯片内容', 'second slide', 'third']) },
|
||||
{ name: 'image.pptx', buffer: createPptx(['with image', 'two'], true) }
|
||||
]
|
||||
|
||||
const failureFixtures: Array<{ name: string; buffer: Buffer }> = [
|
||||
{ name: 'archive.zip', buffer: Buffer.from('not supported') },
|
||||
{ name: 'empty.txt', buffer: Buffer.alloc(0) },
|
||||
{ name: 'large.txt', buffer: Buffer.alloc(20 * 1024 * 1024 + 1) },
|
||||
{ name: 'expanded.docx', buffer: Buffer.from(zipSync({ 'word/document.xml': new Uint8Array(11 * 1024 * 1024) })) },
|
||||
{ name: 'invalid.txt', buffer: Buffer.from([0xc3, 0x28]) },
|
||||
{ name: 'nulls.txt', buffer: Buffer.from('a\0b\0c\0d') },
|
||||
{ name: 'blank.md', buffer: Buffer.from(' \n\n ') },
|
||||
{ name: 'broken.pdf', buffer: Buffer.from('%PDF-1.4 not really') },
|
||||
{ name: 'empty.pdf', buffer: createTextPdf([[]]) },
|
||||
{ name: 'corrupt.xlsx', buffer: Buffer.from('PK not a zip') }
|
||||
]
|
||||
|
||||
const chunkingModes: KnowledgeChunkingSettings[] = [
|
||||
{ version: 1, mode: 'fixed', targetCharacters: 500, overlapCharacters: 50, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: false },
|
||||
{ version: 1, mode: 'structure', targetCharacters: 800, overlapCharacters: 80, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: true },
|
||||
{ version: 1, mode: 'parent-child', targetCharacters: 1_600, overlapCharacters: 100, parentCharacters: 1_600, childCharacters: 400, contextualIndexingEnabled: false }
|
||||
]
|
||||
|
||||
/** Byte-level comparison: JSON (key order) plus deep equality (undefined fields, typed arrays). */
|
||||
function expectIdentical(actual: unknown, expected: unknown): void {
|
||||
expect(JSON.stringify(actual)).toBe(JSON.stringify(expected))
|
||||
expect(isDeepStrictEqual(normalizeBytes(actual), normalizeBytes(expected))).toBe(true)
|
||||
}
|
||||
|
||||
function normalizeBytes(value: unknown): unknown {
|
||||
if (value instanceof Uint8Array) return { bytes: Buffer.from(value).toString('hex') }
|
||||
if (Array.isArray(value)) return value.map(normalizeBytes)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBytes(item)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function settle<T>(run: () => Promise<T>): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
|
||||
try {
|
||||
return { ok: true, value: await run() }
|
||||
} catch (error) {
|
||||
return { ok: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(error: unknown): unknown {
|
||||
return error instanceof Error
|
||||
? { constructor: error.constructor, name: error.name, message: error.message,
|
||||
own: Object.entries(error).filter(([key]) => key !== 'stack'),
|
||||
unavailable: error instanceof DocumentTextUnavailableError,
|
||||
cause: describeError(error.cause) }
|
||||
: error
|
||||
}
|
||||
|
||||
describe('document parse worker equivalence', () => {
|
||||
it('parses every fixture to output identical to the inline parser', async () => {
|
||||
for (const fixture of fixtures) {
|
||||
const inline = await parseDocument(fixture.name, fixture.buffer)
|
||||
const before = Buffer.from(fixture.buffer)
|
||||
const offMain = await parseDocumentOffMain(fixture.name, fixture.buffer, undefined, client)
|
||||
expectIdentical(offMain, inline)
|
||||
// The caller's buffer is not detached or modified by the transfer.
|
||||
expect(fixture.buffer.equals(before)).toBe(true)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('chunks every fixture identically in all chunking modes', async () => {
|
||||
// large.txt (5M chars, no break points) is excluded: splitNatural's backwards
|
||||
// boundary scan makes it take minutes on either path.
|
||||
for (const fixture of fixtures.filter((item) => item.name !== 'large.txt')) {
|
||||
const parsed = await parseDocument(fixture.name, fixture.buffer)
|
||||
for (const settings of chunkingModes) {
|
||||
const inline = await settle(async () => chunkDocumentAdvanced(parsed, settings))
|
||||
const offMain = await settle(() => chunkDocumentOffMain(parsed, settings, client))
|
||||
expect(offMain.ok).toBe(inline.ok)
|
||||
if (inline.ok && offMain.ok) expectIdentical(offMain.value, inline.value)
|
||||
else expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error))
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('extracts PDF pages and PPTX pages identically', async () => {
|
||||
// Pre-existing PDF.js behaviour on the inline path: cancelling a text stream
|
||||
// at the character limit can leave a stray "Controller is already closed".
|
||||
const strayPdfRejection = (reason: unknown): void => {
|
||||
if ((reason as { code?: string })?.code !== 'ERR_INVALID_STATE') throw reason
|
||||
}
|
||||
process.on('unhandledRejection', strayPdfRejection)
|
||||
onTestFinished(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
process.removeListener('unhandledRejection', strayPdfRejection)
|
||||
})
|
||||
for (const fixture of fixtures.filter((item) => item.name.endsWith('.pdf'))) {
|
||||
for (const options of [{}, { maximumCharacters: 50 }, { maximumPages: 1 }]) {
|
||||
const inline = await settle(() => extractPdfTextPages(fixture.buffer, options))
|
||||
const offMain = await settle(() => extractPdfTextPagesOffMain(fixture.buffer, options, client))
|
||||
if (inline.ok && offMain.ok) expectIdentical(offMain.value, inline.value)
|
||||
else expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error))
|
||||
}
|
||||
}
|
||||
for (const buffer of [createPptx(['a', 'b'], true), createImagePptx('native', 3), createPptx(['plain'])]) {
|
||||
const inline = extractPptxPages(buffer)
|
||||
const offMain = await extractPptxPagesOffMain(buffer, client)
|
||||
expectIdentical(offMain, inline)
|
||||
for (const page of offMain) {
|
||||
for (const image of page.images) expect(image.data).toBeInstanceOf(Uint8Array)
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('hashes identically', async () => {
|
||||
for (const buffer of [Buffer.alloc(0), Buffer.from('abc'), createLargePdf(40, 40), Buffer.alloc(3 * 1024 * 1024, 7)]) {
|
||||
expect(await sha256OffMain(buffer, client)).toBe(createHash('sha256').update(buffer).digest('hex'))
|
||||
}
|
||||
})
|
||||
|
||||
it('maps errors to the same types and messages as the inline parser', async () => {
|
||||
for (const fixture of failureFixtures) {
|
||||
const inline = await settle(() => parseDocument(fixture.name, fixture.buffer))
|
||||
const offMain = await settle(() => parseDocumentOffMain(fixture.name, fixture.buffer, undefined, client))
|
||||
expect(inline.ok, fixture.name).toBe(false)
|
||||
expect(offMain.ok, fixture.name).toBe(false)
|
||||
expect(describeError(offMain.ok ? undefined : offMain.error), fixture.name)
|
||||
.toEqual(describeError(inline.ok ? undefined : inline.error))
|
||||
}
|
||||
const tooManySections = {
|
||||
title: 'Too many', sourceFormat: '.txt', content: '', warnings: [],
|
||||
sections: Array.from({ length: 10_001 }, (_, index) => ({ locator: `s-${index}`, content: 'content' }))
|
||||
}
|
||||
const inline = await settle(async () => chunkDocumentAdvanced(tooManySections, chunkingModes[0]!))
|
||||
const offMain = await settle(() => chunkDocumentOffMain(tooManySections, chunkingModes[0]!, client))
|
||||
expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error))
|
||||
const range = await settle(() => extractPdfTextPagesOffMain(createTextPdf([['x']]), { maximumPages: 0 }, client))
|
||||
expect(range.ok ? undefined : range.error).toBeInstanceOf(RangeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document parse worker lifecycle', () => {
|
||||
it('cancels a running parse by terminating the worker and keeps serving later tasks', async () => {
|
||||
const local = new DocumentParseWorkerClient(workerPath)
|
||||
try {
|
||||
const big = createLargePdf(400, 50)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('文档解析已取消')
|
||||
const running = parseDocumentOffMain('big.pdf', big, controller.signal, local)
|
||||
const queued = parseDocumentOffMain('notes.md', Buffer.from(markdown), undefined, local)
|
||||
const queuedCancelled = new AbortController()
|
||||
const queuedAbort = expect(parseDocumentOffMain('plain.txt', Buffer.from('x'), queuedCancelled.signal, local)).rejects.toBe(reason)
|
||||
queuedCancelled.abort(reason)
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
controller.abort(reason)
|
||||
await expect(running).rejects.toBe(reason)
|
||||
await queuedAbort
|
||||
expectIdentical(await queued, await parseDocument('notes.md', Buffer.from(markdown)))
|
||||
expect(local.pendingCount).toBe(0)
|
||||
} finally {
|
||||
local.close()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('rejects with the abort reason when already aborted, without starting the worker', async () => {
|
||||
const local = new DocumentParseWorkerClient(workerPath)
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled')
|
||||
controller.abort(reason)
|
||||
await expect(parseDocumentOffMain('a.txt', Buffer.from('a'), controller.signal, local)).rejects.toBe(reason)
|
||||
} finally {
|
||||
local.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the inline path when the worker crashes or cannot start', async () => {
|
||||
const local = new DocumentParseWorkerClient(workerPath)
|
||||
try {
|
||||
const running = local.call('parseDocument', ['big.pdf', Uint8Array.from(createLargePdf(200, 50)).buffer])
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await local.terminateWorkerForTest()
|
||||
await expect(running).rejects.toBeInstanceOf(DocumentParseWorkerUnavailableError)
|
||||
expect(local.available).toBe(false)
|
||||
// While backing off, calls run inline with identical output.
|
||||
const buffer = Buffer.from(markdown)
|
||||
expectIdentical(await parseDocumentOffMain('notes.md', buffer, undefined, local), await parseDocument('notes.md', buffer))
|
||||
local.resetBackoffForTest()
|
||||
expect(local.available).toBe(true)
|
||||
expectIdentical(await parseDocumentOffMain('notes.md', buffer, undefined, local), await parseDocument('notes.md', buffer))
|
||||
} finally {
|
||||
local.close()
|
||||
}
|
||||
|
||||
const missing = new DocumentParseWorkerClient(`${workerPath}.missing.cjs`)
|
||||
try {
|
||||
const buffer = createDocx(['fallback'])
|
||||
expectIdentical(await parseDocumentOffMain('a.docx', buffer, undefined, missing), await parseDocument('a.docx', buffer))
|
||||
expect(missing.available).toBe(false)
|
||||
} finally {
|
||||
missing.close()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('runs tasks one at a time in FIFO order', async () => {
|
||||
const local = new DocumentParseWorkerClient(workerPath)
|
||||
try {
|
||||
const names = fixtures.slice(0, 6)
|
||||
const results = await Promise.all(names.map((fixture) => parseDocumentOffMain(fixture.name, fixture.buffer, undefined, local)))
|
||||
for (const [index, fixture] of names.entries()) {
|
||||
expectIdentical(results[index], await parseDocument(fixture.name, fixture.buffer))
|
||||
}
|
||||
} finally {
|
||||
local.close()
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { parentPort } from 'node:worker_threads'
|
||||
import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts'
|
||||
import {
|
||||
chunkDocumentAdvanced,
|
||||
extractPdfTextPages,
|
||||
parseDocument,
|
||||
type ParsedDocument
|
||||
} from './knowledge/document-parser'
|
||||
import { extractPptxPages } from './knowledge/pptx-parser'
|
||||
import {
|
||||
documentParseErrorExtraKeys,
|
||||
type DocumentParseRequest,
|
||||
type DocumentParseResponse,
|
||||
type SerializedDocumentParseError
|
||||
} from './document-parse-errors'
|
||||
|
||||
// PERF-15/16: CPU-heavy, pure document work (PDF / Office / text parsing,
|
||||
// chunking, hashing) runs here so Main only orchestrates. Every handler calls
|
||||
// the same production function as the inline path, so results are identical.
|
||||
// No database access and no file system access happen in this worker.
|
||||
|
||||
type Handler = (args: unknown[]) => Promise<unknown> | unknown
|
||||
|
||||
const asBuffer = (value: unknown): Buffer => {
|
||||
const bytes = value as ArrayBuffer
|
||||
return Buffer.from(bytes, 0, bytes.byteLength)
|
||||
}
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
parseDocument: ([name, bytes]) => parseDocument(name as string, asBuffer(bytes)),
|
||||
extractPdfTextPages: ([bytes, options]) => extractPdfTextPages(
|
||||
asBuffer(bytes),
|
||||
options as { maximumPages?: number; maximumCharacters?: number }
|
||||
),
|
||||
extractPptxPages: ([bytes]) => extractPptxPages(asBuffer(bytes)),
|
||||
chunkDocument: ([document, settings]) => chunkDocumentAdvanced(
|
||||
document as ParsedDocument,
|
||||
settings as KnowledgeChunkingSettings
|
||||
),
|
||||
sha256: ([bytes]) => createHash('sha256').update(asBuffer(bytes)).digest('hex')
|
||||
}
|
||||
|
||||
/** ArrayBuffers backing typed arrays in the result (PPTX images); transferred, not copied. */
|
||||
function transferables(value: unknown): ArrayBuffer[] {
|
||||
const buffers = new Set<ArrayBuffer>()
|
||||
if (Array.isArray(value)) {
|
||||
for (const page of value as Array<{ images?: Array<{ data?: unknown }> }>) {
|
||||
for (const image of page?.images ?? []) {
|
||||
const data = image?.data
|
||||
if (
|
||||
data instanceof Uint8Array &&
|
||||
data.buffer instanceof ArrayBuffer &&
|
||||
data.byteOffset === 0 &&
|
||||
data.byteLength === data.buffer.byteLength
|
||||
) {
|
||||
buffers.add(data.buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...buffers]
|
||||
}
|
||||
|
||||
function serializeError(error: unknown, depth = 0): SerializedDocumentParseError {
|
||||
if (!(error instanceof Error)) return { name: 'Error', message: String(error) }
|
||||
const extra: Record<string, unknown> = {}
|
||||
for (const key of documentParseErrorExtraKeys) {
|
||||
const value = (error as unknown as Record<string, unknown>)[key]
|
||||
if (value !== undefined && (typeof value !== 'object' || value === null)) extra[key] = value
|
||||
}
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(Object.keys(extra).length > 0 ? { extra } : {}),
|
||||
...(error.cause !== undefined && depth < 3 ? { cause: serializeError(error.cause, depth + 1) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// PDF.js can leave a stray rejection ("Controller is already closed") after a
|
||||
// text stream is cancelled at the character limit. Unhandled, it would kill
|
||||
// this thread and push the next tasks onto the inline fallback. Task errors are
|
||||
// awaited by the handler and still reported through the response.
|
||||
process.on('unhandledRejection', () => undefined)
|
||||
|
||||
parentPort!.on('message', (message: DocumentParseRequest) => {
|
||||
void (async () => {
|
||||
let response: DocumentParseResponse
|
||||
let transfer: ArrayBuffer[] = []
|
||||
try {
|
||||
const handler = Object.hasOwn(handlers, message.op) ? handlers[message.op] : undefined
|
||||
if (!handler) throw new Error(`Unknown document parse operation: ${message.op}`)
|
||||
const result = await handler(message.args)
|
||||
transfer = message.op === 'extractPptxPages' ? transferables(result) : []
|
||||
response = { id: message.id, result }
|
||||
} catch (error) {
|
||||
response = { id: message.id, error: serializeError(error) }
|
||||
}
|
||||
parentPort!.postMessage(response, transfer)
|
||||
})()
|
||||
})
|
||||
@@ -18,12 +18,14 @@ import { HttpDocumentOcr } from './http-document-ocr'
|
||||
import { hasExtractedDocumentText } from './document-extracted-text'
|
||||
import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf'
|
||||
import type { DocumentResultStorage } from './document-result-storage'
|
||||
import { extractPptxPages } from './knowledge/pptx-parser'
|
||||
import {
|
||||
extractPdfTextPagesOffMain,
|
||||
extractPptxPagesOffMain,
|
||||
parseDocumentOffMain
|
||||
} from './document-parse-client'
|
||||
import {
|
||||
assertDocumentBuffer,
|
||||
DocumentTextUnavailableError,
|
||||
extractPdfTextPages,
|
||||
parseDocument,
|
||||
type ParsedDocument,
|
||||
type ParsedSection,
|
||||
type PdfTextPage
|
||||
@@ -279,10 +281,10 @@ export class DocumentParsingService {
|
||||
return this.parsePptx(name, buffer, purpose, operation, signal)
|
||||
}
|
||||
if (extname(name).toLowerCase() !== '.pdf') {
|
||||
return parseDocument(name, buffer, signal)
|
||||
return parseDocumentOffMain(name, buffer, signal)
|
||||
}
|
||||
|
||||
const extracted = await extractPdfTextPages(buffer, { signal })
|
||||
const extracted = await extractPdfTextPagesOffMain(buffer, { signal })
|
||||
const { pages } = extracted
|
||||
ensureNotAborted(signal)
|
||||
const mode = effectiveOcrMode(settings, purpose)
|
||||
@@ -517,7 +519,7 @@ export class DocumentParsingService {
|
||||
const mode = effectiveOcrMode(settings, purpose)
|
||||
if (mode === 'disabled') {
|
||||
try {
|
||||
return await parseDocument(name, buffer, signal)
|
||||
return await parseDocumentOffMain(name, buffer, signal)
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentTextUnavailableError) {
|
||||
throw new DocumentTextUnavailableError(
|
||||
@@ -527,7 +529,7 @@ export class DocumentParsingService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const pages = extractPptxPages(buffer)
|
||||
const pages = await extractPptxPagesOffMain(buffer)
|
||||
ensureNotAborted(signal)
|
||||
const native: ParsedSection[] = pages
|
||||
.filter((page) => page.content)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas'
|
||||
import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts'
|
||||
import type { DocumentParsingSettings } from '../shared/document-parsing-contracts'
|
||||
import type { ParsedDocument } from './knowledge/document-parser'
|
||||
@@ -96,6 +95,8 @@ export class DocumentResultStorage {
|
||||
if (!image) throw new Error('解析图片不存在')
|
||||
const data = await readFile(join(this.require(id).directory, 'images', imageId))
|
||||
if (thumbnail) {
|
||||
// Native canvas loads on first use (P6).
|
||||
const { createCanvas, loadImage } = await import('@napi-rs/canvas')
|
||||
const source = await loadImage(data)
|
||||
const scale = Math.min(1, 640 / Math.max(source.width, source.height))
|
||||
const canvas = createCanvas(Math.max(1, Math.round(source.width * scale)), Math.max(1, Math.round(source.height * scale)))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { loadImage } from '@napi-rs/canvas'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
maximumDocumentExtractedCharacters,
|
||||
@@ -131,6 +130,8 @@ export class HttpDocumentOcr {
|
||||
: bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? 'image/png'
|
||||
: bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : undefined
|
||||
if (!mimeType) throw new Error('图片格式不支持')
|
||||
// Native canvas loads on first use (P6).
|
||||
const { loadImage } = await import('@napi-rs/canvas')
|
||||
const image = await loadImage(bytes)
|
||||
if (!image.width || !image.height || image.width * image.height > 40_000_000) throw new Error('图片尺寸无效或超过 4000 万像素')
|
||||
imageBytes += bytes.length
|
||||
|
||||
+71
-19
@@ -1,3 +1,4 @@
|
||||
import { startupMark, startupSpan } from './startup-marks'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { NativeClientCoordinator } from './agent/native-client-coordinator'
|
||||
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
|
||||
@@ -64,6 +65,8 @@ import {
|
||||
import { createTrayIcon } from './tray-icon'
|
||||
import { resolveBundledRuntimePaths } from './agent/bundled-runtimes'
|
||||
import type { ContinueHostLauncher } from './agent/continue-host-adapter'
|
||||
import { CONTINUE_HOST_LAYOUT_VERSION } from './agent/continue-host-layout'
|
||||
import { removeStaleRuntimeCaches } from './stale-runtime-cache-cleanup'
|
||||
import { resolvePortableUserDataPath } from './portable-user-data'
|
||||
import { BrowserService } from './browser/browser-service'
|
||||
import { SubagentService } from './assistant/subagent-service'
|
||||
@@ -98,6 +101,7 @@ import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
|
||||
import { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||
import { DocumentOcrBroker } from './document-ocr-broker'
|
||||
import { DocumentParsingService } from './document-parsing-service'
|
||||
import { configureDocumentParseWorker } from './document-parse-client'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { ReleaseNotesService } from './release-notes-service'
|
||||
@@ -553,6 +557,7 @@ function buildTray(): Tray {
|
||||
const storageUpgradeController = new AbortController()
|
||||
let storageUpgrade: Promise<void> | undefined
|
||||
|
||||
startupMark('main:module-evaluated')
|
||||
if (hasSingleInstanceLock) {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
@@ -561,6 +566,7 @@ if (hasSingleInstanceLock) {
|
||||
})
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
startupMark('main:when-ready')
|
||||
session.defaultSession.setPermissionRequestHandler(
|
||||
(webContents, permission, callback, details) => {
|
||||
const mediaTypes =
|
||||
@@ -582,15 +588,18 @@ if (hasSingleInstanceLock) {
|
||||
details.mediaType === 'audio'
|
||||
)
|
||||
|
||||
startupMark('main:create-window:start')
|
||||
mainWindow = createMainWindow(() => isQuitting, observeDesktopFailure)
|
||||
startupMark('main:create-window:end')
|
||||
registerDesktopNotificationActivation(mainWindow)
|
||||
tray = buildTray()
|
||||
startupMark('main:tray-built')
|
||||
storageUpgrade = prepareAssistantStorage(
|
||||
mainWindow,
|
||||
join(app.getPath('userData'), 'assistant.sqlite'),
|
||||
storageUpgradeController.signal
|
||||
)
|
||||
await storageUpgrade
|
||||
await startupSpan('main:storage-upgrade-check', () => storageUpgrade!)
|
||||
if (storageUpgradeController.signal.aborted) return
|
||||
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
|
||||
const secureCipher = {
|
||||
@@ -670,7 +679,18 @@ if (hasSingleInstanceLock) {
|
||||
},
|
||||
baseEnvironment: buildCredentialFilteredUserEnvironment()
|
||||
})
|
||||
await startupLocalToolEnvironmentService.initialize()
|
||||
// Built in the background (P6): only runtime launches read the launch
|
||||
// environment, and each of them awaits this first. A failure still fails
|
||||
// startup with the same diagnostic once the prerequisites have settled.
|
||||
let localToolEnvironmentFailed = false
|
||||
let localToolEnvironmentError: unknown
|
||||
const localToolEnvironmentReady = startupSpan(
|
||||
'main:local-tool-environment',
|
||||
() => startupLocalToolEnvironmentService.initialize()
|
||||
).catch((error: unknown) => {
|
||||
localToolEnvironmentFailed = true
|
||||
localToolEnvironmentError = error
|
||||
})
|
||||
localToolEnvironmentService =
|
||||
startupLocalToolEnvironmentService
|
||||
const sshHostStore = new SshHostStore(
|
||||
@@ -682,11 +702,11 @@ if (hasSingleInstanceLock) {
|
||||
resourcesPath: process.resourcesPath,
|
||||
packaged: app.isPackaged
|
||||
})
|
||||
const controlPlanePackageInstaller = await readFile(
|
||||
const controlPlanePackageInstaller = await startupSpan('main:read-control-plane-installer', () => readFile(
|
||||
resolveControlPlanePackageInstallerPath({
|
||||
appPath: app.getAppPath()
|
||||
})
|
||||
)
|
||||
))
|
||||
const agentPackageManager = new AgentPackageManager({
|
||||
userDataPath: app.getPath('userData'),
|
||||
desktopVersion: app.getVersion(),
|
||||
@@ -728,7 +748,7 @@ if (hasSingleInstanceLock) {
|
||||
return selection.provider === 'continue' ? resolved.continueModelProfile : resolved.opencodeModelProfile
|
||||
}
|
||||
})
|
||||
await startupManagedRemoteExecutionServices.initialize()
|
||||
await startupSpan('main:managed-remote-init', () => startupManagedRemoteExecutionServices.initialize())
|
||||
managedRemoteExecutionServices =
|
||||
startupManagedRemoteExecutionServices
|
||||
const beginRemoteHostInvalidation = (hostId: string): void => {
|
||||
@@ -777,10 +797,10 @@ if (hasSingleInstanceLock) {
|
||||
)
|
||||
})
|
||||
const [initialRuntimeSettings, initialResolvedSettings] =
|
||||
await Promise.all([
|
||||
await startupSpan('main:runtime-settings-load', () => Promise.all([
|
||||
settingsStore.getPublicSettings(),
|
||||
settingsStore.getResolvedSettings()
|
||||
])
|
||||
]))
|
||||
globalTlsPolicy = new GlobalTlsPolicy(app)
|
||||
globalTlsPolicy.install()
|
||||
const capabilityService = new CapabilityService(
|
||||
@@ -836,6 +856,7 @@ if (hasSingleInstanceLock) {
|
||||
(await applicationSettingsStore.get()).modelDownloadSource
|
||||
})
|
||||
documentOcrBroker = new DocumentOcrBroker(mainWindow)
|
||||
configureDocumentParseWorker(join(app.getAppPath(), 'out/main/document-parse-worker.js'))
|
||||
documentParsingService = new DocumentParsingService(
|
||||
documentParsingSettingsStore,
|
||||
documentOcrModelManager,
|
||||
@@ -1030,14 +1051,16 @@ if (hasSingleInstanceLock) {
|
||||
const obsidianService = new ObsidianService({
|
||||
appPath: app.getAppPath(),
|
||||
launchEnvironmentProvider:
|
||||
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
||||
startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
||||
launchEnvironmentReady: () => localToolEnvironmentReady
|
||||
})
|
||||
const startupKnowledgeGateway = 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)
|
||||
},
|
||||
@@ -1046,7 +1069,8 @@ if (hasSingleInstanceLock) {
|
||||
obsidianService,
|
||||
browserService,
|
||||
launchEnvironmentProvider:
|
||||
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
||||
startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
||||
launchEnvironmentReady: () => localToolEnvironmentReady
|
||||
}
|
||||
)
|
||||
knowledgeGateway = startupKnowledgeGateway
|
||||
@@ -1055,6 +1079,8 @@ if (hasSingleInstanceLock) {
|
||||
target: SelectedRuntimeTarget,
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<AgentRuntime> => {
|
||||
await localToolEnvironmentReady
|
||||
if (localToolEnvironmentFailed) throw localToolEnvironmentError
|
||||
const [
|
||||
skillContext,
|
||||
mcpServers,
|
||||
@@ -1208,7 +1234,8 @@ if (hasSingleInstanceLock) {
|
||||
resolved.target
|
||||
)
|
||||
}
|
||||
const configuredRuntime = await runStartupPrerequisites({
|
||||
startupMark('main:services-constructed')
|
||||
const configuredRuntime = await startupSpan('main:prerequisites', () => runStartupPrerequisites({
|
||||
prepareDeepSeekHome: async () => {
|
||||
await mkdir(deepSeekHarnessHome, {
|
||||
recursive: true,
|
||||
@@ -1216,7 +1243,7 @@ if (hasSingleInstanceLock) {
|
||||
})
|
||||
},
|
||||
initializeKnowledgeAndGateway: async () => {
|
||||
await startupKnowledgeService.initialize()
|
||||
await startupSpan('main:knowledge-init', () => startupKnowledgeService.initialize())
|
||||
const embeddingProvider = await createEmbeddingProvider(
|
||||
initialResolvedSettings,
|
||||
startupEmbeddingModelManager
|
||||
@@ -1242,13 +1269,15 @@ if (hasSingleInstanceLock) {
|
||||
() => undefined
|
||||
)
|
||||
])
|
||||
await startupKnowledgeGateway.start()
|
||||
await startupSpan('main:knowledge-gateway-start', () => startupKnowledgeGateway.start())
|
||||
},
|
||||
hydrateConfiguredRuntime: () =>
|
||||
createConfiguredRuntime(initialResolvedSettings),
|
||||
startupSpan('main:runtime-hydrate', () => createConfiguredRuntime(initialResolvedSettings)),
|
||||
initializeAssistant: () => {
|
||||
startupMark('main:assistant-db-init:start')
|
||||
startupAssistantDatabase.initialize(defaultWorkspace)
|
||||
startupAssistantDatabase.enableReadonlyWorker(readonlyQueryWorkerPath)
|
||||
startupAssistantDatabase.enableWalCheckpointWorker(readonlyQueryWorkerPath)
|
||||
imageGenerationService!.initialize()
|
||||
startupAssistantDatabase.ensureChannelProjects(
|
||||
defaultWorkspace,
|
||||
@@ -1259,13 +1288,21 @@ if (hasSingleInstanceLock) {
|
||||
initialRuntimeSettings
|
||||
)
|
||||
)
|
||||
startupMark('main:assistant-db-init:end')
|
||||
}
|
||||
})).catch(async (error: unknown) => {
|
||||
await localToolEnvironmentReady
|
||||
throw localToolEnvironmentFailed ? localToolEnvironmentError : error
|
||||
})
|
||||
// Runtime hydration awaited the environment, so it has settled here. Keep
|
||||
// the previous failure behaviour: an environment error fails startup.
|
||||
await localToolEnvironmentReady
|
||||
if (localToolEnvironmentFailed) throw localToolEnvironmentError
|
||||
const [initialSubagentRuntime, initialSubagentProfileRuntimes] =
|
||||
await Promise.all([
|
||||
await startupSpan('main:subagent-runtimes', () => Promise.all([
|
||||
createSubagentRuntime(initialResolvedSettings),
|
||||
createSubagentProfileRuntimes(initialResolvedSettings)
|
||||
])
|
||||
]))
|
||||
const subagentService = new SubagentService(
|
||||
initialSubagentRuntime,
|
||||
startupAssistantDatabase,
|
||||
@@ -1285,8 +1322,10 @@ if (hasSingleInstanceLock) {
|
||||
createSelectedStatusRuntime,
|
||||
(conversationId) => localRuntimeRegistry.releaseConversation(conversationId)
|
||||
)
|
||||
startupMark('main:attachments-reconcile:start')
|
||||
conversationAttachmentStorage = new ConversationAttachmentStorage(app.getPath('userData'), documentParsingService.results!)
|
||||
conversationAttachmentStorage.reconcile((conversationId, kind, ownerId) => startupAssistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), true)
|
||||
startupMark('main:attachments-reconcile:end')
|
||||
const contextManager = new ContextManager({
|
||||
parseDocument: documentParsingService.parse,
|
||||
assets: conversationAttachmentStorage,
|
||||
@@ -1308,7 +1347,7 @@ if (hasSingleInstanceLock) {
|
||||
},
|
||||
process.platform
|
||||
)
|
||||
await shortcutSettingsService.initialize()
|
||||
await startupSpan('main:shortcuts-init', () => shortcutSettingsService.initialize())
|
||||
|
||||
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
|
||||
let runtimeReconfigurationClosing = false
|
||||
@@ -1505,14 +1544,17 @@ 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)
|
||||
},
|
||||
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
|
||||
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
|
||||
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
||||
launchEnvironmentReady: () => localToolEnvironmentReady
|
||||
})
|
||||
})
|
||||
startupMark('main:ipc-register:start')
|
||||
removeIpcHandlers = registerIpcHandlers(
|
||||
mainWindow,
|
||||
runtime,
|
||||
@@ -1581,9 +1623,19 @@ if (hasSingleInstanceLock) {
|
||||
mainWindow,
|
||||
startupFeedbackService
|
||||
)
|
||||
startupMark('main:ipc-register:end')
|
||||
loadMainWindow(mainWindow)
|
||||
startupMark('main:load-main-window')
|
||||
removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow,
|
||||
new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion()))
|
||||
// Disk only: old versions' runtime caches, removed well after the first frame.
|
||||
setTimeout(() => {
|
||||
void removeStaleRuntimeCaches({
|
||||
userDataPath: app.getPath('userData'),
|
||||
appVersion: app.getVersion(),
|
||||
continueHostLayoutVersion: CONTINUE_HOST_LAYOUT_VERSION
|
||||
})
|
||||
}, 30_000).unref()
|
||||
setImmediate(() => {
|
||||
void repairStaleWindowsNotificationShortcuts({
|
||||
platform: process.platform,
|
||||
|
||||
+73
-3
@@ -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<string, unknown>
|
||||
>((projectId) => ({
|
||||
@@ -5559,6 +5573,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
const getResolvedSettings = vi.fn(
|
||||
async (): Promise<Record<string, unknown>> => ({
|
||||
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<void>(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<typeof import('./agent/create-runtime')>('./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<void>(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'))
|
||||
@@ -9270,7 +9338,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
coordinator = new NativeClientCoordinator({ database, settingsStore,
|
||||
applicationSettingsStore,
|
||||
capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager,
|
||||
localEnvironment: { launchEnvironmentProvider: () => process.env } as never,
|
||||
localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady: async () => undefined } as never,
|
||||
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
|
||||
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
||||
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
|
||||
@@ -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() }
|
||||
})
|
||||
|
||||
+52
-7
@@ -130,6 +130,9 @@ import {
|
||||
import { registerMagicNotesAnalysisIpcHandlers, registerMagicTodosAnalysisIpcHandlers } from './magic-notes/magic-notes-analysis-ipc'
|
||||
import { registerMagicNotesIpcHandlers, registerMagicTodosIpcHandlers } from './magic-notes/magic-notes-ipc'
|
||||
import {
|
||||
activityHistoryPageRequestSchema,
|
||||
activityHistoryReconcileRequestSchema,
|
||||
activityHistorySummaryRequestSchema,
|
||||
assistantIdSchema,
|
||||
executionStatsInputSchema,
|
||||
conversationBranchInputSchema,
|
||||
@@ -137,6 +140,7 @@ import {
|
||||
conversationListRequestSchema,
|
||||
conversationSearchRequestSchema,
|
||||
conversationSetPinnedSchema,
|
||||
conversationSetStoryGraphSchema,
|
||||
type ConversationSnapshot,
|
||||
localConversationSaveBatchSchema,
|
||||
memoryCreateSchema,
|
||||
@@ -2228,8 +2232,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 +3766,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(),
|
||||
@@ -3988,6 +3994,9 @@ export function registerIpcHandlers(
|
||||
let remoteEventBatchClosed = false
|
||||
const remoteEventBatcher = new RemoteEventBatcher<RemoteBatchEvent>({
|
||||
onError: (error) => controller.abort(error),
|
||||
// The batch already coalesced a frame of deltas: send them now rather
|
||||
// than holding them for another public-buffer interval.
|
||||
onTimerFlushed: () => publicEventBuffer.flush(),
|
||||
persist: (entries) =>
|
||||
remoteConversationRecovery
|
||||
? assistantDatabase.appendRemoteConversationTaskEventsBatch({
|
||||
@@ -6380,11 +6389,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<AgentRuntime> => {
|
||||
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(
|
||||
@@ -6840,9 +6869,25 @@ export function registerIpcHandlers(
|
||||
ipcChannels.activityHistoryUpdate,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
assistantDatabase.updateActivityHistory(input)
|
||||
return assistantDatabase.updateActivityHistory(input)
|
||||
}
|
||||
)
|
||||
registerHandler(ipcChannels.activityHistoryClear, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
assistantDatabase.clearActivityHistory()
|
||||
})
|
||||
registerHandler(ipcChannels.activityHistoryPage, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getActivityHistoryPageAsync(activityHistoryPageRequestSchema.parse(input))
|
||||
})
|
||||
registerHandler(ipcChannels.activityHistorySummary, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getActivityHistorySummaryAsync(activityHistorySummaryRequestSchema.parse(input))
|
||||
})
|
||||
registerHandler(ipcChannels.activityHistoryReconcile, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.reconcileActivityHistory(activityHistoryReconcileRequestSchema.parse(input))
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.tokenUsageSummary, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { convert } from 'html-to-text'
|
||||
import { loadHtmlToText } from '../document-extracted-text'
|
||||
import { unzipSync } from 'fflate'
|
||||
import { extname } from 'node:path'
|
||||
import type { ParsedDocumentImage } from '../http-document-ocr'
|
||||
@@ -701,7 +701,7 @@ export async function parseDocument(
|
||||
} else if (['.docx', '.xlsx', '.pptx'].includes(extension)) {
|
||||
sections = parseOfficeArchive(buffer, extension)
|
||||
} else if (['.html', '.htm'].includes(extension)) {
|
||||
const content = convert(decodeText(buffer), {
|
||||
const content = loadHtmlToText().convert(decodeText(buffer), {
|
||||
wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: 'script', format: 'skip' },
|
||||
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
} from 'node:path'
|
||||
import {
|
||||
buildChunkContextPrefix,
|
||||
chunkDocumentAdvanced,
|
||||
parseDocument,
|
||||
supportedDocumentExtensions,
|
||||
type ParsedDocument
|
||||
} from './document-parser'
|
||||
import {
|
||||
chunkDocumentOffMain,
|
||||
parseDocumentOffMain,
|
||||
sha256OffMain
|
||||
} from '../document-parse-client'
|
||||
import type { DocumentResultStorage } from '../document-result-storage'
|
||||
import { parsedCompleteness } from '../document-result-storage'
|
||||
import {
|
||||
@@ -282,7 +285,7 @@ export class KnowledgeService {
|
||||
this.urlImporter = options.urlImporter ?? new UrlImporter()
|
||||
this.documentParser =
|
||||
options.parseDocument ??
|
||||
((name, buffer) => parseDocument(name, buffer))
|
||||
((name, buffer) => parseDocumentOffMain(name, buffer))
|
||||
this.embeddingProvider = options.embeddingProvider
|
||||
this.rerankProvider = options.rerankProvider
|
||||
const embeddingBatchSize = options.embeddingBatchSize ?? 16
|
||||
@@ -769,7 +772,7 @@ export class KnowledgeService {
|
||||
const documentId = input.id ?? randomUUID()
|
||||
const imagesOnly = parsedCompleteness(parsed) === 'images-only'
|
||||
if (imagesOnly && this.database.getDocument(documentId)?.metadata.status === 'ready') throw new Error('未提取到可索引文字,保留上次正文与索引')
|
||||
const chunks = imagesOnly ? [] : this.createDocumentChunks(parsed, library)
|
||||
const chunks = imagesOnly ? [] : await this.createDocumentChunks(parsed, library)
|
||||
let embeddingReplacement: PreparedEmbeddingReplacement | undefined
|
||||
let embeddingFailure: PreparedDocumentPublication['embeddingFailure']
|
||||
const embeddingProvider = this.embeddingProvider
|
||||
@@ -2484,7 +2487,7 @@ export class KnowledgeService {
|
||||
const prepared = await this.prepareDocumentPublication(
|
||||
{
|
||||
...document,
|
||||
checksum: createHash('sha256').update(buffer).digest('hex'),
|
||||
checksum: await sha256OffMain(buffer),
|
||||
metadata: {
|
||||
...document.metadata,
|
||||
status: 'ready',
|
||||
@@ -3137,7 +3140,7 @@ export class KnowledgeService {
|
||||
progress: 35,
|
||||
message: '正在解析文档内容'
|
||||
})
|
||||
const checksum = createHash('sha256').update(buffer).digest('hex')
|
||||
const checksum = await sha256OffMain(buffer)
|
||||
const previous = existing.find(
|
||||
(document) => document.externalId === file.relativePath
|
||||
)
|
||||
@@ -3835,11 +3838,12 @@ export class KnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private createDocumentChunks(
|
||||
private async createDocumentChunks(
|
||||
parsed: ParsedDocument,
|
||||
library: KnowledgeBase
|
||||
): ReplaceChunkInput[] {
|
||||
const chunks = chunkDocumentAdvanced(parsed, library.chunkingSettings)
|
||||
): Promise<ReplaceChunkInput[]> {
|
||||
// Pure CPU work runs off Main; chunk ids are still assigned here.
|
||||
const chunks = await chunkDocumentOffMain(parsed, library.chunkingSettings)
|
||||
const ids = chunks.map(() => randomUUID())
|
||||
return chunks.map((chunk) => ({
|
||||
id: ids[chunk.position],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { lookup as dnsLookup } from 'node:dns/promises'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { request as httpsRequest } from 'node:https'
|
||||
import { parseDocument, type ParsedDocument } from './document-parser'
|
||||
import type { ParsedDocument } from './document-parser'
|
||||
import { parseDocumentOffMain } from '../document-parse-client'
|
||||
|
||||
type ResolvedAddress = {
|
||||
address: string
|
||||
@@ -224,7 +225,7 @@ export class UrlImporter {
|
||||
.trim() || url.hostname
|
||||
).slice(0, 240)
|
||||
: url.pathname.split('/').filter(Boolean).at(-1) ?? url.hostname
|
||||
const document = await parseDocument(
|
||||
const document = await parseDocumentOffMain(
|
||||
isHtml ? `${title}.html` : `${title}.txt`,
|
||||
response.body
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLazyRequire } from './lazy-require'
|
||||
import { loadHtmlToText } from './document-extracted-text'
|
||||
import { loadSsh2 } from './ssh/ssh2-module'
|
||||
|
||||
describe('createLazyRequire', () => {
|
||||
it('loads a module on first call and returns the cached instance afterwards', () => {
|
||||
const load = createLazyRequire<typeof import('node:path')>('node:path')
|
||||
const first = load()
|
||||
expect(typeof first.join).toBe('function')
|
||||
expect(load()).toBe(first)
|
||||
})
|
||||
|
||||
it('surfaces a missing module at first use, not at import time', () => {
|
||||
const load = createLazyRequire('goodbuddy-module-that-does-not-exist')
|
||||
expect(load).toThrow()
|
||||
})
|
||||
|
||||
it('resolves the deferred Main dependencies', () => {
|
||||
expect(typeof loadHtmlToText().convert).toBe('function')
|
||||
expect(typeof loadSsh2().Client).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
/**
|
||||
* Synchronously loads a dependency on first use instead of at module
|
||||
* evaluation (P6). Main is ESM, but some test fixtures bundle the same code as
|
||||
* CommonJS, where `import.meta.url` is empty and `__filename` exists instead.
|
||||
*/
|
||||
export function createLazyRequire<T>(specifier: string): () => T {
|
||||
let loaded: T | undefined
|
||||
return () => {
|
||||
loaded ??= createRequire(import.meta.url || __filename)(specifier) as T
|
||||
return loaded
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,7 @@ export type {
|
||||
LocalToolEnvironmentServiceDependencies,
|
||||
LocalToolEnvironmentServiceOptions
|
||||
} from './local-tool-environment-service'
|
||||
export type { LaunchEnvironmentProvider } from './launch-environment-provider'
|
||||
export type {
|
||||
LaunchEnvironmentProvider,
|
||||
LaunchEnvironmentReady
|
||||
} from './launch-environment-provider'
|
||||
|
||||
@@ -3,3 +3,9 @@
|
||||
* tool launches. Consumers must only copy the values they explicitly support.
|
||||
*/
|
||||
export type LaunchEnvironmentProvider = () => Readonly<NodeJS.ProcessEnv>
|
||||
|
||||
/**
|
||||
* Settles once the launch environment has been built at startup. Consumers that
|
||||
* can run before startup finishes await it before reading the provider.
|
||||
*/
|
||||
export type LaunchEnvironmentReady = () => Promise<void>
|
||||
@@ -217,6 +217,51 @@ describe('LocalToolEnvironmentService', () => {
|
||||
expect(Object.isFrozen(updated)).toBe(true)
|
||||
})
|
||||
|
||||
it('starts initialization once and lets consumers wait for the built environment', async () => {
|
||||
const { service } = await fixture()
|
||||
await expect(service.whenReady()).resolves.toBeUndefined()
|
||||
|
||||
const started = service.initialize()
|
||||
expect(service.initialize()).toBe(started)
|
||||
expect(service.launchEnvironmentProvider()).toEqual({})
|
||||
|
||||
await service.whenReady()
|
||||
expect(service.launchEnvironmentProvider().PATH).toContain('bin')
|
||||
await started
|
||||
})
|
||||
|
||||
it('applies a settings update after an in-flight initialization, never before it', async () => {
|
||||
const { service, node } = await fixture()
|
||||
const order: string[] = []
|
||||
const initialization = service.initialize().then(() => {
|
||||
order.push('initialized')
|
||||
})
|
||||
const update = service
|
||||
.updateSettings({
|
||||
...defaultLocalToolEnvironmentSettings,
|
||||
node: { source: 'custom', executablePath: node }
|
||||
})
|
||||
.then(() => {
|
||||
order.push('updated')
|
||||
})
|
||||
await Promise.all([initialization, update])
|
||||
|
||||
expect(order).toEqual(['initialized', 'updated'])
|
||||
expect(service.launchEnvironmentProvider().PATH).toContain('bin')
|
||||
})
|
||||
|
||||
it('reports a failed initialization through whenReady and still disposes', async () => {
|
||||
const { service, options } = await fixture()
|
||||
const failing = new LocalToolEnvironmentService(
|
||||
{ ...options, pythonArtifactCatalogPath: join(options.managedPythonRoot, 'missing.json') },
|
||||
{ toolEnvironment: { platform: 'win32', spawnProcess: fakeInspectionSpawn() } }
|
||||
)
|
||||
await expect(failing.initialize()).rejects.toThrow()
|
||||
await expect(failing.whenReady()).rejects.toThrow()
|
||||
await expect(failing.dispose()).resolves.toBeUndefined()
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('keeps executable paths inside the Main-owned picker callback', async () => {
|
||||
const { service, settingsStore, python } = await fixture()
|
||||
const snapshot = await service.selectExecutable('python')
|
||||
|
||||
@@ -453,6 +453,7 @@ export class LocalToolEnvironmentService {
|
||||
private installOperation?: Promise<LocalToolEnvironmentSnapshot>
|
||||
private launchEnvironment: Readonly<NodeJS.ProcessEnv> = Object.freeze({})
|
||||
private listeners = new Set<ProgressListener>()
|
||||
private initialization?: Promise<void>
|
||||
readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () =>
|
||||
this.launchEnvironment
|
||||
|
||||
@@ -530,15 +531,35 @@ export class LocalToolEnvironmentService {
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await rm(this.options.binDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await this.rebuildLaunchEnvironment()
|
||||
/**
|
||||
* Starts building the launch environment once. Startup no longer awaits this
|
||||
* before showing the window (P6); every consumer that reads
|
||||
* `launchEnvironmentProvider` or mutates the environment awaits
|
||||
* `whenReady()` first, so no launch observes the empty pre-start snapshot.
|
||||
*/
|
||||
initialize(): Promise<void> {
|
||||
this.initialization ??= (async () => {
|
||||
await rm(this.options.binDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await this.rebuildLaunchEnvironment()
|
||||
})()
|
||||
return this.initialization
|
||||
}
|
||||
|
||||
/** Settles with the result of `initialize()`; resolves immediately if it never started. */
|
||||
whenReady(): Promise<void> {
|
||||
return this.initialization ?? Promise.resolve()
|
||||
}
|
||||
|
||||
/** Waits for initialization without failing; the startup path reports its error. */
|
||||
private async initialized(): Promise<void> {
|
||||
await this.whenReady().catch(() => undefined)
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<LocalToolEnvironmentSnapshot> {
|
||||
await this.initialized()
|
||||
const catalog = await loadPythonArtifactCatalog(
|
||||
this.options.pythonArtifactCatalogPath
|
||||
)
|
||||
@@ -562,6 +583,7 @@ export class LocalToolEnvironmentService {
|
||||
}
|
||||
|
||||
async updateSettings(input: unknown): Promise<LocalToolEnvironmentSnapshot> {
|
||||
await this.initialized()
|
||||
let settings = localToolEnvironmentSettingsSchema.parse(input)
|
||||
const current = await this.settings()
|
||||
for (const kind of ['node', 'python'] as const) {
|
||||
@@ -629,6 +651,7 @@ export class LocalToolEnvironmentService {
|
||||
}
|
||||
|
||||
async diagnose(target: LocalToolDiagnoseTarget): Promise<LocalToolEnvironmentSnapshot> {
|
||||
await this.initialized()
|
||||
const environment = await this.rebuildLaunchEnvironment(
|
||||
this.operationController?.signal
|
||||
)
|
||||
@@ -767,6 +790,7 @@ export class LocalToolEnvironmentService {
|
||||
|
||||
async removePython(): Promise<LocalToolEnvironmentSnapshot> {
|
||||
if (this.operationController) throw new Error('A Managed Python operation is running')
|
||||
await this.initialized()
|
||||
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
|
||||
await removeManagedPython({
|
||||
rootDirectory: this.options.managedPythonRoot,
|
||||
@@ -781,6 +805,7 @@ export class LocalToolEnvironmentService {
|
||||
async dispose(): Promise<void> {
|
||||
this.cancelPython()
|
||||
await this.installOperation?.catch(() => undefined)
|
||||
await this.initialized()
|
||||
await rm(this.options.binDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { CallToolResultSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment } from '../agent/process-environment'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
|
||||
import type { LaunchEnvironmentProvider, LaunchEnvironmentReady } from '../local-tool-environment/launch-environment-provider'
|
||||
|
||||
export type ObsidianSettings = { vaultPath?: string }
|
||||
export type ObsidianVault = { id: string; name: string; path: string }
|
||||
@@ -14,6 +14,8 @@ export type ObsidianServiceOptions = {
|
||||
/** Electron app.getAppPath(), or the project root in development. */
|
||||
appPath: string
|
||||
launchEnvironmentProvider: LaunchEnvironmentProvider
|
||||
/** Awaited before reading the provider; startup builds the environment in the background. */
|
||||
launchEnvironmentReady?: LaunchEnvironmentReady
|
||||
homePath?: string
|
||||
platform?: NodeJS.Platform
|
||||
environment?: NodeJS.ProcessEnv
|
||||
@@ -122,6 +124,7 @@ export class ObsidianService {
|
||||
await stat(serverPath).catch((cause) => {
|
||||
throw new Error(`Bundled Obsidian MCPVault server is missing: ${serverPath}`, { cause })
|
||||
})
|
||||
await this.options.launchEnvironmentReady?.()
|
||||
const env = applyLaunchEnvironmentPath(
|
||||
buildCredentialFilteredUserEnvironment(this.options.environment ?? process.env),
|
||||
this.options.launchEnvironmentProvider
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { statSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -292,3 +293,37 @@ describe('assistant readonly worker', () => {
|
||||
} finally { database.close() }
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
describe('WAL checkpoint worker', () => {
|
||||
it('checkpoints off Main, keeps a raised safety threshold and restores the default when stopped', async () => {
|
||||
const path = join(directory, `checkpoint-${randomUUID()}.sqlite`)
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
const raw = (database as unknown as { database: import('node:sqlite').DatabaseSync }).database
|
||||
const autocheckpoint = (): number =>
|
||||
(raw.prepare('PRAGMA wal_autocheckpoint').get() as { wal_autocheckpoint: number }).wal_autocheckpoint
|
||||
try {
|
||||
expect(autocheckpoint()).toBe(1000)
|
||||
database.enableWalCheckpointWorker(workerPath, 20)
|
||||
await expect.poll(autocheckpoint, { timeout: 10_000 }).toBe(4000)
|
||||
const task = database.createTask({ id: randomUUID(), title: 't', instructions: 'i', workMode: 'ask' })
|
||||
const sizeBefore = statSync(path).size
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
database.appendTaskEvent(task.id, 'text', { type: 'text', requestId: task.id, delta: 'x'.repeat(4_000) })
|
||||
}
|
||||
// ~300 WAL pages stay below Main's 4,000-page threshold, so only the
|
||||
// worker's checkpoint can copy them into the database file.
|
||||
await expect.poll(() => statSync(path).size, { timeout: 10_000 }).toBeGreaterThan(sizeBefore + 1_000_000)
|
||||
expect((raw.prepare("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ? AND kind = 'text'").get(task.id) as { n: number }).n).toBe(300)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
// A reopened database starts with SQLite's default threshold again.
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(directory)
|
||||
try {
|
||||
const again = (reopened as unknown as { database: import('node:sqlite').DatabaseSync }).database
|
||||
expect((again.prepare('PRAGMA wal_autocheckpoint').get() as { wal_autocheckpoint: number }).wal_autocheckpoint).toBe(1000)
|
||||
} finally { reopened.close() }
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -8,13 +8,35 @@ import type { ReadonlyQueryKind, ReadonlyQueryRequest } from './readonly-query-r
|
||||
// It reuses the production classes on a read-only connection, so results are
|
||||
// produced by exactly the same code as the synchronous path.
|
||||
|
||||
const { kind, databasePath } = workerData as { kind: ReadonlyQueryKind; databasePath: string }
|
||||
const { kind, databasePath, intervalMs } = workerData as {
|
||||
kind: ReadonlyQueryKind | 'checkpoint'
|
||||
databasePath: string
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
type Handler = (args: unknown[], signal: AbortSignal) => unknown
|
||||
let handlers: Record<string, Handler>
|
||||
let close: () => void
|
||||
|
||||
if (kind === 'knowledge') {
|
||||
if (kind === 'checkpoint') {
|
||||
// WAL checkpointer for AssistantDatabase.enableWalCheckpointWorker (PERF-15):
|
||||
// PASSIVE checkpoints never wait for, or block, Main's readers and writers.
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.openCheckpointer()
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
database.checkpointWal()
|
||||
} catch {
|
||||
// SQLITE_BUSY and similar: try again on the next tick.
|
||||
}
|
||||
}, intervalMs ?? 1_000)
|
||||
close = () => {
|
||||
clearInterval(timer)
|
||||
database.close()
|
||||
}
|
||||
handlers = {}
|
||||
parentPort!.postMessage({ ready: true })
|
||||
} else if (kind === 'knowledge') {
|
||||
const database = new KnowledgeDatabase(databasePath)
|
||||
database.openReadOnly()
|
||||
close = () => database.close()
|
||||
@@ -36,7 +58,9 @@ if (kind === 'knowledge') {
|
||||
listConversationSummaries: ([detailIds]) => database.readSnapshot(() =>
|
||||
database.listConversationSummaries(detailIds as string[])),
|
||||
getConversation: ([conversationId]) => database.readSnapshot(() =>
|
||||
database.getConversation(conversationId as string))
|
||||
database.getConversation(conversationId as string)),
|
||||
activityHistoryPage: ([input]) => database.getActivityHistoryPage(input),
|
||||
activityHistorySummary: ([input]) => database.getActivityHistorySummary(input)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteEventBatcher } from './remote-event-batcher'
|
||||
import { REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS, RemoteEventBatcher } from './remote-event-batcher'
|
||||
|
||||
const provenance = (eventIndex: number) => ({
|
||||
source: 'remote-semantic-transcript' as const,
|
||||
@@ -46,6 +46,49 @@ describe('RemoteEventBatcher', () => {
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('commits (and so forwards) streamed events within one frame by default', () => {
|
||||
vi.useFakeTimers()
|
||||
expect(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS).toBe(16)
|
||||
const persist = vi.fn((entries: readonly unknown[]) => entries.map(() => true))
|
||||
const committed: string[] = []
|
||||
const batcher = new RemoteEventBatcher<string>({ persist })
|
||||
batcher.add(provenance(0), 'a', (event) => committed.push(event))
|
||||
batcher.add(provenance(1), 'b', (event) => committed.push(event))
|
||||
vi.advanceTimersByTime(15)
|
||||
// Nothing is forwarded before it is persisted.
|
||||
expect(committed).toEqual([])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(persist).toHaveBeenCalledOnce()
|
||||
expect(committed).toEqual(['a', 'b'])
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('reports a timer flush after its events committed, and not after a failed one', () => {
|
||||
vi.useFakeTimers()
|
||||
const order: string[] = []
|
||||
let fail = false
|
||||
const batcher = new RemoteEventBatcher<string>({
|
||||
persist: (entries) => {
|
||||
if (fail) throw new Error('disk full')
|
||||
return entries.map(() => true)
|
||||
},
|
||||
onTimerFlushed: () => order.push('flushed'),
|
||||
onError: () => order.push('error')
|
||||
})
|
||||
batcher.add(provenance(0), 'a', (event) => order.push(event))
|
||||
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
|
||||
expect(order).toEqual(['a', 'flushed'])
|
||||
// Explicit flushes leave forwarding to the caller.
|
||||
batcher.add(provenance(1), 'b', (event) => order.push(event))
|
||||
batcher.flush()
|
||||
expect(order).toEqual(['a', 'flushed', 'b'])
|
||||
fail = true
|
||||
batcher.add(provenance(2), 'c', (event) => order.push(event))
|
||||
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
|
||||
expect(order).toEqual(['a', 'flushed', 'b', 'error'])
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('rethrows a failed commit and stays failed so later checkpoints are never committed', () => {
|
||||
vi.useFakeTimers()
|
||||
const onError = vi.fn()
|
||||
@@ -55,7 +98,7 @@ describe('RemoteEventBatcher', () => {
|
||||
const onCommitted = vi.fn()
|
||||
const batcher = new RemoteEventBatcher<string>({ persist, onError })
|
||||
batcher.add(provenance(0), 'a', onCommitted)
|
||||
vi.advanceTimersByTime(50)
|
||||
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
|
||||
expect(onError).toHaveBeenCalledWith(new Error('disk full'))
|
||||
expect(() => batcher.add(provenance(1), 'checkpoint')).toThrow('disk full')
|
||||
expect(() => batcher.flush()).toThrow('disk full')
|
||||
|
||||
@@ -18,6 +18,12 @@ export type RemoteEventBatcherOptions<TEvent> = {
|
||||
persist(entries: readonly RemoteEventBatchEntry<TEvent>[]): readonly boolean[]
|
||||
/** Called when a timer-driven flush fails (for example to abort the run). */
|
||||
onError?(error: unknown): void
|
||||
/**
|
||||
* Called after a timer-driven flush committed (and its `onCommitted`
|
||||
* callbacks ran), e.g. to forward the committed events without waiting for
|
||||
* a second coalescing timer downstream.
|
||||
*/
|
||||
onTimerFlushed?(): void
|
||||
/** Safety flush for buffered events that are not followed by a boundary. */
|
||||
flushIntervalMs?: number
|
||||
maximumEvents?: number
|
||||
@@ -27,7 +33,11 @@ type PendingEntry<TEvent> = RemoteEventBatchEntry<TEvent> & {
|
||||
onCommitted?: RemoteEventCommitted<TEvent>
|
||||
}
|
||||
|
||||
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 50
|
||||
/**
|
||||
* One frame: committed events are forwarded to the renderer only after their
|
||||
* batch commits, so this bounds how long streamed remote text is held back.
|
||||
*/
|
||||
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 16
|
||||
export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,7 @@ export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
|
||||
export class RemoteEventBatcher<TEvent> {
|
||||
private readonly persist: RemoteEventBatcherOptions<TEvent>['persist']
|
||||
private readonly onError: RemoteEventBatcherOptions<TEvent>['onError']
|
||||
private readonly onTimerFlushed: RemoteEventBatcherOptions<TEvent>['onTimerFlushed']
|
||||
private readonly flushIntervalMs: number
|
||||
private readonly maximumEvents: number
|
||||
private pending: PendingEntry<TEvent>[] = []
|
||||
@@ -56,6 +67,7 @@ export class RemoteEventBatcher<TEvent> {
|
||||
constructor(options: RemoteEventBatcherOptions<TEvent>) {
|
||||
this.persist = options.persist
|
||||
this.onError = options.onError
|
||||
this.onTimerFlushed = options.onTimerFlushed
|
||||
this.flushIntervalMs =
|
||||
options.flushIntervalMs ?? REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS
|
||||
this.maximumEvents =
|
||||
@@ -132,6 +144,7 @@ export class RemoteEventBatcher<TEvent> {
|
||||
this.timer = undefined
|
||||
try {
|
||||
this.flush()
|
||||
this.onTimerFlushed?.()
|
||||
} catch (error) {
|
||||
this.onError?.(error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCanvas } from '@napi-rs/canvas'
|
||||
// @napi-rs/canvas is a native module (~85 ms to load); load it on first OCR render (P6).
|
||||
|
||||
export async function* renderOcrPdf(
|
||||
buffer: Buffer,
|
||||
@@ -6,7 +6,10 @@ export async function* renderOcrPdf(
|
||||
signal?: AbortSignal
|
||||
): AsyncGenerator<{ pageNumber: number; data: Buffer; width: number; height: number }> {
|
||||
signal?.throwIfAborted()
|
||||
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
const [{ getDocument }, { createCanvas }] = await Promise.all([
|
||||
import('pdfjs-dist/legacy/build/pdf.mjs'),
|
||||
import('@napi-rs/canvas')
|
||||
])
|
||||
const task = getDocument({
|
||||
data: Uint8Array.from(buffer), disableFontFace: true,
|
||||
isOffscreenCanvasSupported: false, useSystemFonts: false, useWorkerFetch: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHmac, getCiphers, randomBytes } from "node:crypto";
|
||||
import { connect as connectLoopback, type Socket } from "node:net";
|
||||
import { Client } from "ssh2";
|
||||
import type { ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
import { loadSsh2 } from "./ssh2-module";
|
||||
import {
|
||||
BoundedStagedSftp,
|
||||
type BoundedSftpLimits,
|
||||
@@ -344,7 +344,7 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
|
||||
validatePoolTarget(target);
|
||||
validateConnectionIdentity(target, identity);
|
||||
const resolvedDependencies: SshConnectionDependencies = {
|
||||
createClient: () => new Client(),
|
||||
createClient: () => new (loadSsh2().Client)(),
|
||||
supportedOpenSslCiphers: getCiphers,
|
||||
systemAgent: defaultSystemAgent,
|
||||
controlPlanePackageInstaller: Buffer.alloc(0),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createHash, getCiphers } from 'node:crypto'
|
||||
import {
|
||||
import type {
|
||||
Client,
|
||||
type Algorithms,
|
||||
type CipherAlgorithm,
|
||||
type ConnectConfig,
|
||||
type ServerHostKeyAlgorithm
|
||||
Algorithms,
|
||||
CipherAlgorithm,
|
||||
ConnectConfig,
|
||||
ServerHostKeyAlgorithm
|
||||
} from 'ssh2'
|
||||
import { loadSsh2 } from './ssh2-module'
|
||||
import type {
|
||||
SshHostConnectionTestResult
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
@@ -336,7 +337,7 @@ export class Ssh2Transport implements SshTransport {
|
||||
dependencies: Partial<SshTransportDependencies> = {}
|
||||
) {
|
||||
this.dependencies = {
|
||||
createClient: () => new Client(),
|
||||
createClient: () => new (loadSsh2().Client)(),
|
||||
now: Date.now,
|
||||
supportedOpenSslCiphers: getCiphers,
|
||||
systemAgent: defaultSystemAgent,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createLazyRequire } from '../lazy-require'
|
||||
|
||||
/**
|
||||
* Loads ssh2 on the first SSH connection instead of at Main startup (P6):
|
||||
* evaluating it costs ~65 ms and most sessions never open a remote host.
|
||||
* Synchronous so existing client factories keep their signatures.
|
||||
*/
|
||||
export const loadSsh2 = createLazyRequire<typeof import('ssh2')>('ssh2')
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { removeStaleRuntimeCaches, staleRuntimeCacheDirectories } from './stale-runtime-cache-cleanup'
|
||||
|
||||
const directories: string[] = []
|
||||
afterEach(async () => {
|
||||
for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('stale runtime cache cleanup', () => {
|
||||
it('selects only older app-version and Continue layout directories', () => {
|
||||
expect(staleRuntimeCacheDirectories({
|
||||
opencode: ['0.12.8', '0.15.8', '0.15.9', '0.16.0-beta.1', 'cache', 'custom', '1.0'],
|
||||
continueHost: ['host-v1-1.5.47-a', 'host-v6-1.5.47-a', 'host-v7-1.5.47-a', 'host-v7-1.6.0-b',
|
||||
'host-v8-1.5.47-a', 'isolated-global', 'isolated-global-123', 'host-v7-1.5.47-a.staging-x', 'sessions']
|
||||
}, { appVersion: '0.15.9', continueHostLayoutVersion: 7 })).toEqual({
|
||||
opencode: ['0.12.8', '0.15.8', '0.16.0-beta.1'],
|
||||
continueHost: ['host-v1-1.5.47-a', 'host-v6-1.5.47-a']
|
||||
})
|
||||
})
|
||||
|
||||
it('removes stale directories and leaves everything else in the user data directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cache-cleanup-'))
|
||||
directories.push(root)
|
||||
const make = async (path: string): Promise<void> => {
|
||||
await mkdir(join(root, path), { recursive: true })
|
||||
await writeFile(join(root, path, 'file.txt'), 'x')
|
||||
}
|
||||
await make('opencode-runtime/0.15.8/config')
|
||||
await make('opencode-runtime/0.15.9/config')
|
||||
await make('continue-host/host-v6-1.5.47-a/dist')
|
||||
await make('continue-host/host-v7-1.5.47-a/dist')
|
||||
await make('continue-host/isolated-global')
|
||||
await make('continue-host/sessions')
|
||||
await writeFile(join(root, 'continue-host', 'config.yaml'), 'x')
|
||||
await make('models/embedding')
|
||||
|
||||
const { removed } = await removeStaleRuntimeCaches({
|
||||
userDataPath: root, appVersion: '0.15.9', continueHostLayoutVersion: 7
|
||||
})
|
||||
|
||||
expect(removed.map((path) => path.slice(root.length + 1).replaceAll('\\', '/')).sort())
|
||||
.toEqual(['continue-host/host-v6-1.5.47-a', 'opencode-runtime/0.15.8'])
|
||||
for (const kept of ['opencode-runtime/0.15.9/config/file.txt', 'continue-host/host-v7-1.5.47-a/dist/file.txt',
|
||||
'continue-host/isolated-global/file.txt', 'continue-host/sessions/file.txt', 'continue-host/config.yaml',
|
||||
'models/embedding/file.txt']) {
|
||||
expect(existsSync(join(root, kept))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('does nothing when the cache roots do not exist', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cache-cleanup-'))
|
||||
directories.push(root)
|
||||
await expect(removeStaleRuntimeCaches({ userDataPath: root, appVersion: '0.15.9', continueHostLayoutVersion: 7 }))
|
||||
.resolves.toEqual({ removed: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Removes runtime caches left by earlier GoodBuddy versions (PERF, disk).
|
||||
*
|
||||
* - `opencode-runtime/<app version>/`: OpenCode's shared config and skills
|
||||
* are prepared per app version and only the current version's directory is
|
||||
* ever used, so every other version-named directory is stale (~50 MB each).
|
||||
* - `continue-host/host-v<layout>-…/`: patched Continue host bundles; only
|
||||
* the current layout is read. Older layouts are stale. Directories of the
|
||||
* current layout (other Continue versions or digests) are left alone, as
|
||||
* are run directories, logs, sessions and config files.
|
||||
*
|
||||
* Only names that match these exact patterns are touched, never anything
|
||||
* else in the user data directory. Failures (a file in use, permissions) are
|
||||
* ignored; the next start tries again.
|
||||
*/
|
||||
export type StaleRuntimeCacheCleanupOptions = {
|
||||
userDataPath: string
|
||||
appVersion: string
|
||||
continueHostLayoutVersion: number
|
||||
}
|
||||
|
||||
const appVersionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u
|
||||
const continueHostPattern = /^host-v(\d+)-/u
|
||||
|
||||
async function directoryNames(path: string): Promise<string[]> {
|
||||
try {
|
||||
return (await readdir(path, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
||||
.map((entry) => entry.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function staleRuntimeCacheDirectories(
|
||||
names: { opencode: readonly string[]; continueHost: readonly string[] },
|
||||
options: Pick<StaleRuntimeCacheCleanupOptions, 'appVersion' | 'continueHostLayoutVersion'>
|
||||
): { opencode: string[]; continueHost: string[] } {
|
||||
return {
|
||||
opencode: names.opencode.filter((name) =>
|
||||
appVersionPattern.test(name) && name !== options.appVersion),
|
||||
continueHost: names.continueHost.filter((name) => {
|
||||
const match = continueHostPattern.exec(name)
|
||||
return match !== null && Number(match[1]) < options.continueHostLayoutVersion
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeStaleRuntimeCaches(
|
||||
options: StaleRuntimeCacheCleanupOptions
|
||||
): Promise<{ removed: string[] }> {
|
||||
const opencodeRoot = join(options.userDataPath, 'opencode-runtime')
|
||||
const continueRoot = join(options.userDataPath, 'continue-host')
|
||||
const stale = staleRuntimeCacheDirectories({
|
||||
opencode: await directoryNames(opencodeRoot),
|
||||
continueHost: await directoryNames(continueRoot)
|
||||
}, options)
|
||||
const targets = [
|
||||
...stale.opencode.map((name) => join(opencodeRoot, name)),
|
||||
...stale.continueHost.map((name) => join(continueRoot, name))
|
||||
]
|
||||
const removed: string[] = []
|
||||
// One at a time: this runs in the background and should not compete for disk.
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await rm(target, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 })
|
||||
removed.push(target)
|
||||
} catch {
|
||||
// Left for the next start.
|
||||
}
|
||||
}
|
||||
return { removed }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
// PERF-01 startup phase marks. Inactive unless GB_PERF_STARTUP_MARKS=1 (set by
|
||||
// build/run-startup-perf.cjs); when inactive each call is one boolean check.
|
||||
// Marks hold only fixed phase names and process-relative times, never user
|
||||
// content. The startup harness reads them from the global below.
|
||||
const enabled = process.env.GB_PERF_STARTUP_MARKS === '1'
|
||||
type StartupMark = { name: string; at: number }
|
||||
const marks: StartupMark[] | undefined = enabled ? [] : undefined
|
||||
if (marks) {
|
||||
;(globalThis as { __goodbuddyStartupMarks?: StartupMark[] }).__goodbuddyStartupMarks = marks
|
||||
}
|
||||
|
||||
export function startupMark(name: string): void {
|
||||
if (marks) marks.push({ name, at: performance.now() })
|
||||
}
|
||||
|
||||
// Takes a thunk so the span also covers the synchronous part of the operation
|
||||
// (everything before its first await).
|
||||
export function startupSpan<T>(name: string, operation: () => Promise<T>): Promise<T> {
|
||||
if (!marks) return operation()
|
||||
startupMark(`${name}:start`)
|
||||
let started: Promise<T>
|
||||
try {
|
||||
started = operation()
|
||||
} catch (error) {
|
||||
startupMark(`${name}:end`)
|
||||
throw error
|
||||
}
|
||||
startupMark(`${name}:sync-end`)
|
||||
return started.finally(() => startupMark(`${name}:end`))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user