Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49377206cc | ||
|
|
1c22e1a06d |
@@ -120,6 +120,11 @@ additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
## Data and Compatibility
|
||||
|
||||
- Before changing released database schemas or large-data migrations, read
|
||||
[`docs/development/database-migrations.md`](./docs/development/database-migrations.md).
|
||||
Keep feature-specific storage rules and validation evidence in the owning
|
||||
feature documents.
|
||||
|
||||
- Preserve released user-owned data, migrations required by released data, and
|
||||
supported external contracts.
|
||||
- Compatibility does not apply to unshipped, branch-local, or safely disposable
|
||||
|
||||
+11
-3
@@ -21,7 +21,7 @@ otherwise.
|
||||
packages target compatible x64 and arm64 environments, including UOS, Kylin,
|
||||
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
|
||||
LoongArch has a separate experimental loong64 preview outside standard
|
||||
releases and automatic updates; no preview is planned for 0.13.0. See the
|
||||
releases and automatic updates; no preview is planned for 0.13.1. See the
|
||||
[preview boundaries](./docs/development/loongarch-preview-build.md).
|
||||
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
|
||||
accelerator under Platform Features / General. The default remains
|
||||
@@ -111,6 +111,14 @@ otherwise.
|
||||
|
||||
### Agent Runtimes and model connections
|
||||
|
||||
- [x] **Compact subagent history without lost details**: Local and managed SSH
|
||||
subagent events store changes instead of repeated full progress snapshots.
|
||||
The first upgrade automatically converts existing history and reclaims disk
|
||||
space, with visible progress and quit/retry support. Chats, execution details,
|
||||
results, and remote event deduplication are preserved; older clients cannot
|
||||
reopen the upgraded database. See
|
||||
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
|
||||
|
||||
- [x] **Direct model Runtime**: Supports question answering, knowledge
|
||||
synthesis, controlled tool execution, image generation, and reference-image
|
||||
editing through providers supporting the OpenAI-compatible image-editing
|
||||
@@ -215,7 +223,7 @@ otherwise.
|
||||
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.11.24`, while the current Desktop release candidate is `0.13.0`; formal
|
||||
`0.11.24`, while the current Desktop release candidate is `0.13.1`; 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
|
||||
@@ -668,7 +676,7 @@ otherwise.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- Current source candidates are Desktop `0.13.0` and Agent `0.11.24`, with
|
||||
- Current source candidates are Desktop `0.13.1` and Agent `0.11.24`, with
|
||||
OpenCode pinned to `1.18.29`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Candidate acceptance remains separate from version preparation. The current
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.0` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.1` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话,可直接打开对应对话;项目选择器也显示活动数量。
|
||||
@@ -32,6 +32,8 @@
|
||||
|
||||
### Agent Runtime 与模型连接
|
||||
|
||||
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。
|
||||
@@ -119,7 +121,7 @@
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前源码候选为 Desktop `0.13.0`、Agent `0.11.24`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- 当前源码候选为 Desktop `0.13.1`、Agent `0.11.24`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- 候选验收与版本准备分别记录。当前远程问答源码已通过真实 Linux x64 SSH 与 Electron 问答测试,但模型为确定性 loopback 服务,不是完整生产 Main/Preload 配合真实服务商;该真实模型链路和候选原生 CI/打包验证仍待完成,详见[远程问答证据](./docs/features/assistant-workbar/progress.md)。
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
|
||||
@@ -210,6 +210,8 @@
|
||||
- 选项数量为 2 至 4 个。
|
||||
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
|
||||
- 不能用于多选筛选、页面导航或执行即时命令。
|
||||
- 与 `PageTabs` 一样,共享控件根节点必须 `flex: 0 0 auto`,不能被同级长表单或滚动区域
|
||||
压缩高度。横向空间不足时保留控件自身滚动,不把标签裁成一条窄缝。
|
||||
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
|
||||
|
||||
### 6.3 筛选工具栏
|
||||
@@ -395,6 +397,10 @@
|
||||
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
|
||||
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
|
||||
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
|
||||
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
|
||||
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
|
||||
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
|
||||
[浏览器多实例技术设计](./docs/features/assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport)。
|
||||
- 遮罩必须 `inset: 0` 铺满整窗,并由 `styles.css` 中统一的遮罩选择器声明
|
||||
`-webkit-app-region: no-drag`,否则 `.brand` 与 `.topbar` 的原生拖动区域会截获指针,
|
||||
使遮罩上的控件无法悬停和点击。新增整窗遮罩时扩展该共享选择器,不要在各自规则里复制
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 数据库迁移与大数据升级指南
|
||||
|
||||
本文约束已发布用户数据的数据库升级。具体字段、存储表示和功能行为由对应功能文档负责;
|
||||
本文不建立通用迁移框架、回滚引擎或独立状态日志。
|
||||
|
||||
## 先确认问题和数据边界
|
||||
|
||||
1. 先只读统计文件大小、WAL、页数、空闲页、表与字段的字节数,不先删除或压缩数据库。
|
||||
2. 区分有效内容、重复存储、索引和已释放但未回收的空间。空闲页少时,单独
|
||||
`VACUUM` 不能消除重复记录。
|
||||
3. 查清实际写入、读取、删除、重放与恢复入口。修改 Runtime 相关持久化时,同时检查
|
||||
本机和 GoodBuddy Agent 的桌面路径,不假设一个入口覆盖另一个入口。
|
||||
4. 明确用户必须保留的信息、稳定 ID、顺序、时间、引用与远程去重字段。
|
||||
分支内未发布、可安全重建的内部记录不需要兼容机制。
|
||||
|
||||
## 实现原则
|
||||
|
||||
- 先修新增数据路径,再修历史数据。历史转换与新写入尽量使用同一套转换函数。
|
||||
- 普通 schema 变更使用既有 `PRAGMA user_version` 和 SQLite 事务;
|
||||
已升级 schema 不允许旧客户端强行打开,不通过回写版本号支持降级。
|
||||
- 大数据转换不得同步阻塞 Electron Main 或 Renderer。放到工作线程,在业务连接
|
||||
打开前处理,并使用应用的共享字体、主题与页面组件显示进度和可执行的失败提示。
|
||||
- 根据实测设置有界读取批次与缓存,批次提交后再报告进度。保持 SQLite 事务与
|
||||
checkpoint,不靠关闭同步落盘、取消约束或一次性读入整库提速。
|
||||
- 从已提交数据直接重试和还原即可完成的迁移,不增加 durable snapshot、journal、
|
||||
receipt 或多阶段恢复状态。退出及失败至少回滚未提交批次,保留已完成部分。
|
||||
- 差量编码必须能精确还原原始事件。更新内容、删除或重排都要覆盖;涉及远程来源时,
|
||||
去重、冲突判断、消息投影和恢复序号不得失效。
|
||||
- 释放数据库页和归还文件系统空间是不同步骤。先减少实际存储内容,再执行需要的
|
||||
空间回收;保留足够临时空间,磁盘不足时明确失败并允许重试。
|
||||
- SQLite 同库写入是串行的。是否增加读取/转换并行,应由实际读取、解析、写入、
|
||||
提交与 checkpoint 的耗时决定,不把增加写线程当作默认优化。
|
||||
|
||||
## 验证与备份
|
||||
|
||||
### 合成回归
|
||||
|
||||
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
|
||||
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
|
||||
|
||||
### 真实旧库
|
||||
|
||||
1. 关闭占用源数据库的应用,确认 WAL 状态。没有 WAL 时可复制已关闭的数据库;
|
||||
有活动 WAL 时使用 SQLite 一致性备份机制,不只复制主文件。
|
||||
2. 备份到空间足够的位置,记录字节数并校验 SHA-256。备份和测试副本分离,
|
||||
不直接在唯一原件或唯一备份上试跑。
|
||||
3. 使用当前源码或用户要求的 portable 正常启动入口执行升级,不用手工 SQL 代替
|
||||
产品升级过程。退出一次后重新启动,验证已提交数据可以继续处理。
|
||||
4. 比较升级前后消息、任务、成果等用户表的内容;对转换事件逐条还原后比较原始内容、
|
||||
ID、顺序、时间和去重来源,不只比较行数。日志只能包含统计和断言结果,不记录正文。
|
||||
5. 运行数据库完整性检查,记录主文件和 WAL 的实际字节数、回收比例、耗时、平台、
|
||||
源 schema 与目标 schema。确认原件和备份未被改写。
|
||||
6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则
|
||||
检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。
|
||||
|
||||
## 发布与维护
|
||||
|
||||
发布说明应告知升级范围、可能耗时、退出重试、额外磁盘空间和降级边界。不得把一个测试库
|
||||
的回收比例当作所有用户都能获得的保证。功能文档记录具体转换规则,进度文档记录实际
|
||||
证据和未完成项;未经验证的阶段不能标为完成。
|
||||
|
||||
### 已有实例
|
||||
|
||||
- Desktop 0.13.1:子任务累计过程重复保存导致数据库膨胀,转换到 schema 34 的差量事件
|
||||
表示,并回收空间。具体规则见
|
||||
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
|
||||
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
|
||||
@@ -10,6 +10,7 @@
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
|
||||
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD
|
||||
|
||||
@@ -77,8 +77,16 @@ Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
|
||||
非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏
|
||||
收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。
|
||||
普通导航、快照与页面操作只同步轻量导航状态,不自动截图、等待画面或传输 JPEG;只有显式
|
||||
`browser_screenshot` 调用生成图片。侧栏内终端关闭确认显示期间,同样隐藏原生浏览器视图,
|
||||
确认或取消后恢复当前实例,避免原生子视图遮挡 HTML 确认按钮。
|
||||
`browser_screenshot` 调用生成图片。
|
||||
|
||||
`BrowserViewport` 统一观察 body Portal 的插入、移除和可见属性变化,以及浮层尺寸、
|
||||
窗口尺寸、滚动和过渡结束。`browser-viewport-occlusion.ts` 按共享语义识别
|
||||
`aria-modal="true"`、dialog、alertdialog、menu 和应用通知:可见应用级 Modal 始终释放
|
||||
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
|
||||
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
|
||||
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
|
||||
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
|
||||
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
|
||||
|
||||
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
|
||||
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 执行记录存储与升级回收
|
||||
|
||||
通用迁移、备份、性能测量和发布要求见
|
||||
[数据库迁移与大数据升级指南](../../development/database-migrations.md)。
|
||||
|
||||
## 用户行为
|
||||
|
||||
子任务仍实时显示完整的文字、思考、工具过程和最终结果。运行时间较长、工具输出较多时,
|
||||
数据库不再为每个流式更新重复保存此前的整份过程;本机和托管 SSH OpenCode 使用相同
|
||||
的桌面存储机制。此修复不关闭子任务、不截短已有过程,也不增加 Execute 权限门槛。
|
||||
|
||||
已有数据库在首次升级时自动处理。启动页铺满窗口,显示处理数量、全宽进度条和近期
|
||||
处理速度;处理时间取决于历史数据量与磁盘速度,不承诺固定几分钟。用户可以退出,
|
||||
下次启动重新检查并继续转换未完成的数据;完成后回收空闲页并进入应用。失败时留在
|
||||
该页提供重试与退出,磁盘空间不足会明确说明,不删除聊天来腾出空间。
|
||||
|
||||
## 存储规则
|
||||
|
||||
- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。
|
||||
- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。
|
||||
`progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。
|
||||
完全相同的过程使用空更新数组。其他事件内容不改变。
|
||||
- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、
|
||||
最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。
|
||||
- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整
|
||||
`progress`;差量表示只存在于桌面数据库内部,不要求更新远端 Agent 协议。
|
||||
- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验,
|
||||
相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID
|
||||
核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。
|
||||
|
||||
## 旧库升级
|
||||
|
||||
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
|
||||
按任务有序读取,每批最多 32 条,提交后报告进度;SQLite 保留事务原子性及正常 WAL
|
||||
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
|
||||
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
|
||||
|
||||
转换可以混合读取旧完整记录与已经转换的记录,因此退出、磁盘写入失败后可以重新扫描
|
||||
并继续,不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
|
||||
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
|
||||
|
||||
业务初始化随后将数据库升级为 schema 34,并建立子任务事件的部分索引。
|
||||
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
|
||||
升级前备份,不要修改 `user_version` 强行绕过版本检查。
|
||||
|
||||
## 验证要求
|
||||
|
||||
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
|
||||
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
|
||||
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
|
||||
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
|
||||
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。具体证据见
|
||||
[实现与验证进度](./progress.md)。
|
||||
@@ -1,5 +1,71 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级
|
||||
|
||||
- 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发
|
||||
要求见[数据库迁移指南](../../development/database-migrations.md)。本机和远程写入共用
|
||||
差量编码,不修改 Runtime 公共事件或 Agent 协议;消息投影与来源去重继续保持事务。
|
||||
- 真实 portable 旧库先备份并校验 SHA-256,再在独立新版 portable 中启动升级:
|
||||
13,094,912 → 12,595,200 字节,schema 33 → 34。659 条消息、52 条会话、133 条成果、
|
||||
笔记/待办及模型用量表逐表内容一致,完整性检查通过。
|
||||
- 另对真实膨胀库的核验备份副本运行同一 portable 启动入口:
|
||||
97,308,667,904 → 594,182,144 字节,即 90.63 GiB → 566.66 MiB,
|
||||
实际回收 90.07 GiB(99.39%),freelist 为 0。原库和原 portable 数据库未改写。
|
||||
- 全量还原比较 411,981 条事件,所有内容、事件 ID、时间及远程来源字段一致;
|
||||
SQLite `integrity_check` 返回 `ok`。1,876 条消息、895 个任务、136 个成果及笔记、
|
||||
待办、用量和活动记录等表内容一致。隔离 profile 没有复制模型配置,既有启动修复更新了
|
||||
一条项目及一条会话的 Runtime 选择,项目 `updated_at` 随之更新;不是事件迁移修改。
|
||||
- 真实升级中通过“退出,稍后继续”停止,再用更新后的 portable 继续并完成。初版 UI
|
||||
只按内容高度展开且进度条过窄,用户指出后修复为全高画布和全宽进度条,并显示处理速度;
|
||||
实包测得页面高度约 761 CSS px、viewport 761 px,进度条宽约 960 px,截图人工复核通过。
|
||||
- 大库采样的读取耗时占约 65%、JSON 解析约 17%。将升级读取缓存由 8 MiB 提至
|
||||
64 MiB,保留每批 32 条事务和 SQLite 自动 checkpoint,只在任务结束时截断 WAL。
|
||||
相邻 2,048 条转换采样从约 12.0 秒降至 8.8 秒;样本和缓存状态不同,不作为固定加速比。
|
||||
未增加同库并行写入,也未关闭事务或同步落盘。
|
||||
- 当前源码通过真实本地 OpenCode Execute 和共享 Linux x64 Host 的托管 Ask/Execute。
|
||||
本机与远程各观察到 65 条子任务事件,最终过程仍可显示,远程重复事件不重复投影;
|
||||
测试资源均位于专用目录。共提交 3 次真实 Prompt,Runtime 上报 4 笔模型用量;
|
||||
子代理内部 HTTP 调用未单独计数,不将该上报数称为服务商请求总数。
|
||||
- 首轮完整 `npm test` 为 4,114 通过、66 跳过,637.97 秒;后续 8 项新增/修正的专项
|
||||
回归通过,`npm run typecheck`、`npm run lint` 通过。生产构建及用户要求的 portable
|
||||
实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求
|
||||
不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。
|
||||
- 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。
|
||||
截至本记录尚未提交或打标签,准确候选提交、main CI 和原生发布结果待发布流程确认。
|
||||
|
||||
## 2026-09-13 原生浏览器与应用浮层修复
|
||||
|
||||
- 用户在“添加实例”中复现原生网页盖住弹窗。原先只有终端关闭确认释放 viewport,
|
||||
现在所有应用 Modal 与相交浮层共用
|
||||
[浏览器遮挡机制](./browser-tabs-technical-design.md#4-renderer-与-viewport)。
|
||||
新增五个组件回归在修复前全部失败,修复后通过;独立检测器另有十项回归。
|
||||
- Windows 当前 Main、Preload、Renderer,通过 `electron-vite dev` 和独立 profile
|
||||
启动完整 App,不是旧安装包或仅有 Renderer 的 fixture。浏览器导航到本机合成页后,
|
||||
实例管理与项目设置均验证原生视图 `visible → hidden → visible`;视图 ID 不变且未销毁。
|
||||
本次不修改 Runtime、Agent、MCP 或远程生产路径。
|
||||
- 实例编辑器在浅深主题、目标 1280×800、960×720、720×640、640×420 下完成八组
|
||||
几何、关闭按钮命中和原生视图隐藏断言。Windows 缩放产生不足 1 CSS px 的取整差异,
|
||||
记录实际尺寸而非把目标尺寸当测量值。PNG 实际审阅包括浅色 1280、720 和深色 640;
|
||||
其余尺寸执行程序化检查,不将其描述为全部 PNG 人工审阅。
|
||||
- 临时驱动曾出现新浏览器 target 抢占自动化目标、隐藏窗口截图阻塞及严格像素比较
|
||||
误判;修正驱动后重新取证,不把这些失败改写为产品修复。最终使用 pinned renderer、
|
||||
独立测试窗口和 CDP PNG;原生视图状态单独从 Main 读取,避免网页截图漏掉原生视图。
|
||||
- 顺带复现已发布更新说明资源为 136,554 字节,超过原 128 KiB 读取上限。上限提高到
|
||||
1 MiB,仍使用有界读取;回归直接读取实际打包资源并确认当前版本可展示、可确认。
|
||||
完整 App 已显示并关闭 0.13.0 更新说明,重启后不再次显示。
|
||||
- 首轮 `npm test -- --maxWorkers=1`:4,108 通过、66 跳过,608.30 秒;
|
||||
`npm run typecheck`、`npm run lint` 通过。补长名称修正后的全量执行为 4,105 通过、
|
||||
66 跳过、3 失败,586.51 秒;失败全部位于并行修改中的子代理进度测试。
|
||||
未修改这些文件,随后定向重跑 ACP 与 OpenCode 子代理共 66 项全部通过。
|
||||
全仓类型与 lint 检查期间先后遇到并行子代理/存储升级改动的缺失字段和未使用导入,
|
||||
这些改动不纳入本次提交,不能把早先的通过结果当作当前并行工作区全绿。
|
||||
- 两组 Harness 单测补 `onTestFinished` 回收临时目录。最后定向四文件 73 项通过,
|
||||
运行前后检查新增残留目录为 0。测试清理不改变 deployed Runtime。
|
||||
- 本次修改文档的 66 个相对链接目标存在,暂存差异无空白错误;最终发布仍需对确定的、
|
||||
不再变动的候选提交执行发布验证,不能将未提交的并行改动混入候选。
|
||||
- 临时脚本、截图和隔离构建按用户要求在提交后清理;这里只保留脱敏结论。知识库布局、
|
||||
合成服务保存验证与调用计数见[知识库进度](../knowledge-base/progress.md#2026-09-13-知识库布局与自动名称修复)。
|
||||
|
||||
## 2026-09-13 OpenCode 事件流关闭顺序修复
|
||||
|
||||
候选检查中的 lifecycle 失败已稳定复现,4 路并行后 `openEventBodies` 为 3 而不是 0。
|
||||
|
||||
@@ -42,6 +42,9 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
|
||||
## 托管 Runtime 的并行与输出
|
||||
|
||||
子任务完整过程的显示与数据库差量存储相互独立,本机和托管 SSH 共用桌面存储层;
|
||||
旧库升级与空间回收规则见[执行记录存储与升级回收](./execution-history-storage.md)。
|
||||
|
||||
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
|
||||
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
|
||||
计算阻塞同一原生进程中的其他会话。
|
||||
|
||||
@@ -68,6 +68,10 @@ Renderer 丢弃已失效的目录、详情和测试响应,不把切换前的
|
||||
|
||||
## 5. 创建与编辑
|
||||
|
||||
新建绑定时,用户尚未编辑的显示名称跟随当前目录选择更新;更换实例或手工输入另一个
|
||||
目标 ID 时清除旧自动名称。用户手动填写过名称后保留该草稿。编辑或重新绑定已有库时,
|
||||
始终保留已保存的本地名称,除非用户主动修改;此修正不自动重命名已有知识库。
|
||||
|
||||
流程为选择 `local | dify | fastgpt | ragflow`,外部类型再选择实例、目录项或手工 ID、
|
||||
显示名称、配置与测试查询。表单测试成功后允许保存;服务保存前再次执行同一查询验证,
|
||||
检查实例、绑定和唯一性是否在等待期间改变,再写入数据库。
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
| 检索结果 | 外部组件显示总耗时、返回数量、片段、定位、实际 ID 和原始评分,包括 FastGPT 评分数组;不显示本地全文、向量、图谱通道或本地上下文操作 |
|
||||
| 聊天与引用 | 统一选择器显示 Provider、实例与状态;外部引用打开保存片段,显示实例 ID 和远端定位,不请求本地上下文或打开原始来源 |
|
||||
|
||||
创建类型分段控件复用全局不收缩规则,不能被目录和参数表单压扁;列表长名称的宽度约束见
|
||||
[知识库 UI 设计](./ui-design.md#2-信息架构)。新建时自动名称跟随目录选择,手动改名和已保存
|
||||
名称不被覆盖,具体规则见[逻辑设计](./external-knowledge-logic-design.md#5-创建与编辑)。
|
||||
实例 Modal 和其他浮层统一参与[原生浏览器遮挡处理](../assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport),
|
||||
不再仅验证没有浏览器的独立 Portal。
|
||||
|
||||
原 `knowledge-field` 的控件样式依赖知识库工作区祖先,实例 Modal 经 `createPortal` 挂到
|
||||
body 后丢失该作用域。当前 `Field` 使用全局 `.field` 和标签 span,密码也复用同一字段
|
||||
结构,确保 Portal 内的字体、输入边框、背景和焦点样式与应用一致。弹窗宽度为
|
||||
|
||||
@@ -16,6 +16,26 @@
|
||||
|
||||
## 验证证据
|
||||
|
||||
### 2026-09-13 知识库布局与自动名称修复
|
||||
|
||||
- 创建类型的共享分段控件被长表单压缩,修复共享不收缩规则,不添加页面专属高度。
|
||||
长名称卡片增加零最小宽度的 Grid 轨道,名称单行省略、图标保持 15px;详情名称允许换行。
|
||||
合成长名称实测列表横向溢出为 0;详情修复前溢出 658px,修复后为 0。
|
||||
- 连续选择两个目录项时,未修改过的自动名称原先停留在第一个目标。回归先失败再修复,
|
||||
现在跟随选择,手动名称和已保存名称不自动覆盖。规则见
|
||||
[创建与编辑](./external-knowledge-logic-design.md#5-创建与编辑)。
|
||||
- 当前完整 App 的真实 UI → Preload → Main → SQLite 路径完成实例保存、两个目录项
|
||||
切换、零结果检索后添加绑定、详情展示和配置草稿保留。上游是 loopback Dify 合成服务,
|
||||
不是真实知识平台;没有调用模型,也不能据此声称真实检索质量或远端验收。
|
||||
- 本轮合成服务共收到 8 次请求:1 次初始目录、2 次初始详情、2 次检索(测试与保存)、
|
||||
2 次后续详情、1 次后续目录;真实模型和真实知识服务请求均为 0。
|
||||
- 创建类型在浅深主题、目标 1280×800、960×720、720×640、640×420 的八组尺寸中
|
||||
检查控件高度至少 34px,并保存 PNG。实际审阅浅色 1280、深色 640 及最终添加后的
|
||||
长名称详情 PNG,确认 Tab 不再成为窄缝,卡片边框和图标完整,详情无横向滚动条。
|
||||
- 同期修复和通用验证结果见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-原生浏览器与应用浮层修复)。
|
||||
临时脚本与合成 profile 在提交后清理,不把它们作为长期依赖;用户在另一测试窗口
|
||||
手工创建的实例与绑定不属于本轮合成数据,不随清理删除。
|
||||
|
||||
### 2026-09-13 检索路径与绑定查询简化
|
||||
|
||||
对 `3b560c9..ca4a58c` 的复查发现三处与本功能相关的实现问题,已按 KISS 与性能优先修正:
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
|
||||
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
|
||||
|
||||
本地与外部库共用的列表以 `minmax(0, 1fr)` 限制条目宽度,再对名称单行省略;选中卡片
|
||||
的边框必须完整留在列表内,Provider/实例等辅助文字允许换行。不得用列表外层裁切
|
||||
掩盖被长名称撑宽的卡片,库图标不能随名称一起收缩。外部详情的长名称和远端名称允许
|
||||
换行,不能撑出横向滚动条。创建类型分段控件与详情页签保持固定内容高度,不随长表单收缩。
|
||||
|
||||
## 3. 首次使用
|
||||
|
||||
没有知识库时,页面空状态显示“新建知识库”主操作。创建时先选择类型;本地类型默认
|
||||
|
||||
@@ -41,6 +41,11 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
|
||||
|
||||
## 组件
|
||||
|
||||
桌面保存托管 SSH 子任务事件时,共用
|
||||
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
|
||||
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
|
||||
继续保留。该存储修复不要求单独升级 Agent。
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> 窄 Preload / Main IPC
|
||||
|
||||
@@ -26,6 +26,37 @@
|
||||
**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖
|
||||
的遮罩;后续复查改为共享规则加自动发现测试。
|
||||
|
||||
## 网页浮层遗漏原生浏览器视图
|
||||
|
||||
**症状**:知识库实例、项目设置等 Modal 的右侧被浏览器网页覆盖,通知或菜单也可能
|
||||
被压在原生视图下面。
|
||||
|
||||
**原因**:`WebContentsView` 不参与 Renderer 的 CSS 层叠。先前只在关闭终端确认时
|
||||
显式隐藏 viewport,没有覆盖其他页面的 Portal 和浮层。
|
||||
|
||||
**规则**:`BrowserViewport` 共用 `browser-viewport-occlusion.ts`,按 Modal 语义和
|
||||
浮层相交状态释放显示租约,不销毁网页会话。不得再增加逐个弹窗的特殊隐藏条件。
|
||||
|
||||
**防复发**:组件回归覆盖 body Modal、嵌套 Modal、已打开 Modal、隐藏或非相交浮层、
|
||||
通知、终端确认和非活动页签;实际 Electron 验证同时检查原生视图可见性,不能只看 DOM
|
||||
的遮罩或截图。
|
||||
|
||||
**历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例
|
||||
Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
|
||||
|
||||
## Flex 滚动内容压缩页签和分段控件
|
||||
|
||||
**症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。
|
||||
|
||||
**规则**:`PageTabs` 与 `SegmentedControl` 在共享样式上固定 `flex: 0 0 auto`;
|
||||
直接包装层也必须保留高度。不要按页面复制高度常量。
|
||||
|
||||
**防复发**:`WorkspacePrimitives.test.tsx` 检查共享分段控件不收缩,已有平台功能、
|
||||
能力和 MCP 页签回归继续保留;Electron 复验必须包含长表单与短窗口。
|
||||
|
||||
**历史**:设置页签曾补过包装层不收缩规则;2026-09-13 知识库创建类型的共享
|
||||
`SegmentedControl` 又出现同类问题。
|
||||
|
||||
## 逐行查询代替主键查询
|
||||
|
||||
**症状**:列表渲染或校验函数随数据量线性变慢,且慢在单次操作内重复发生。
|
||||
@@ -40,6 +71,24 @@
|
||||
**历史**:外部知识库绑定同时出现三处:`requireLibrary`(30+ 调用点,一处在循环内)、
|
||||
`getKnowledgeSnapshot`(每个知识库两次全表扫描)、`listInstances`(每个实例过滤一次)。
|
||||
|
||||
## 将累计过程当作增量事件反复落库
|
||||
|
||||
**症状**:聊天正文很小,执行记录数据库却增长到几十 GiB;只增加几个字节的子任务输出,
|
||||
又写入接近 1 MiB 的旧过程副本。
|
||||
|
||||
**原因**:本机与远程 OpenCode 共用完整进度快照,两个桌面落库入口都按流式频率新增
|
||||
完整 JSON;正文流的缓冲器没有覆盖这种累计快照。
|
||||
|
||||
**规则**:界面快照不直接作为追加式日志的存储表示。子任务事件通过共享存储层记录变化,
|
||||
保留逐条还原和远程来源去重;会话仍保存完整显示结果。旧用户数据通过同一编码器无损
|
||||
转换后回收空间,不靠删聊天或单独修本机数据库解决。
|
||||
|
||||
**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、
|
||||
消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。
|
||||
|
||||
**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB;
|
||||
本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。
|
||||
|
||||
## 生产路径复用“测试”函数
|
||||
|
||||
**症状**:正常用户操作触发了只应在配置校验时出现的错误码,或多发了一次网络请求。
|
||||
|
||||
@@ -112,6 +112,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'),
|
||||
'wechat-sidecar': resolve(
|
||||
'src/main/channels/wechat-sidecar.ts'
|
||||
),
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.13.1",
|
||||
"releasedAt": "2026-09-13",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**修复数据库异常膨胀**:长任务和子任务不再反复保存整份执行过程。首次启动自动整理旧记录并回收磁盘空间,保留聊天、执行详情和结果。",
|
||||
"**浏览器不再遮挡弹窗**:打开项目设置、知识库实例等弹窗时,内置网页会正确让出显示区域。关闭弹窗后恢复原网页,不丢失页面状态。",
|
||||
"**知识库配置更清楚**:长名称和长参数表单不再挤压来源选择控件。切换远端知识库时自动名称随之更新,手工填写的名称保持不变。",
|
||||
"**更新说明恢复显示**:修复历史更新说明增长后,本次更新内容可能无法加载的问题。当前版本说明可正常显示并确认,重启后不再重复弹出。"
|
||||
],
|
||||
"notices": [
|
||||
"整理较大的历史数据库可能需要较长时间和额外磁盘空间,支持退出后下次继续。升级后的数据库不能由旧版客户端打开,需要回退时请保留升级前备份。",
|
||||
"Agent 保持 **0.11.24**,本次不重新发布 Agent。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Fix excessive database growth**: Long-running tasks and subagents no longer repeatedly save the entire execution history. The first launch compacts existing records and reclaims disk space while preserving chats, execution details, and results.",
|
||||
"**Keep dialogs above embedded webpages**: Embedded webpages now yield their display area when project settings, knowledge-instance dialogs, and other overlays open. Closing the overlay restores the original webpage without losing its state.",
|
||||
"**Clearer knowledge configuration**: Long names and parameter forms no longer squeeze source-selection controls. Automatically filled names follow the selected remote knowledge base, while manually entered names are preserved.",
|
||||
"**Restore release-note display**: Fixed an issue where growing historical release notes could prevent the current update notes from loading. Current-version notes can be displayed and acknowledged without appearing again after restart."
|
||||
],
|
||||
"notices": [
|
||||
"Large history databases may require substantial processing time and extra disk space; you can quit and continue on the next launch. Older clients cannot open the upgraded database, so retain a pre-upgrade backup if rollback is needed.",
|
||||
"Agent remains at **0.11.24** and is not republished."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.13.0",
|
||||
"releasedAt": "2026-09-13",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
@@ -33,6 +33,7 @@ async function fixture() {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
const hostPath = join(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { app, ipcMain, type BrowserWindow } from 'electron'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
assistantStorageActionSchema,
|
||||
assistantStorageProgressSchema,
|
||||
type AssistantStorageProgress
|
||||
} from '../shared/assistant-storage-contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||
import { loadMainWindow } from './window'
|
||||
|
||||
export async function prepareAssistantStorage(
|
||||
window: BrowserWindow,
|
||||
databasePath: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const file = await stat(databasePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
})
|
||||
if (!file) return
|
||||
let progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size
|
||||
}
|
||||
let shown = false
|
||||
let retry: (() => void) | undefined
|
||||
ipcMain.handle(ipcChannels.storageUpgradeProgress, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return progress
|
||||
})
|
||||
ipcMain.handle(ipcChannels.storageUpgradeAction, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const action = assistantStorageActionSchema.parse(input)
|
||||
if (action === 'quit') app.quit()
|
||||
else if (progress.stage === 'failed') retry?.()
|
||||
})
|
||||
const publish = (next: AssistantStorageProgress): void => {
|
||||
progress = assistantStorageProgressSchema.parse(next)
|
||||
if (window.isDestroyed()) return
|
||||
if (!shown) {
|
||||
shown = true
|
||||
loadMainWindow(window, true)
|
||||
}
|
||||
window.setProgressBar(next.stage === 'failed' ? -1 :
|
||||
next.total > 0 && next.stage === 'converting'
|
||||
? next.processed / next.total : 2)
|
||||
}
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await runStorageWorker(databasePath, signal, publish)
|
||||
return
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
publish({
|
||||
...progress, stage: 'failed',
|
||||
error: error instanceof Error ? error.message : '历史执行记录优化失败'
|
||||
})
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = (): void => {
|
||||
signal.removeEventListener('abort', finish)
|
||||
retry = undefined
|
||||
resolve()
|
||||
}
|
||||
retry = finish
|
||||
signal.addEventListener('abort', finish, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ipcMain.removeHandler(ipcChannels.storageUpgradeProgress)
|
||||
ipcMain.removeHandler(ipcChannels.storageUpgradeAction)
|
||||
if (!window.isDestroyed()) window.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
|
||||
function runStorageWorker(
|
||||
databasePath: string,
|
||||
signal: AbortSignal,
|
||||
publish: (progress: AssistantStorageProgress) => void
|
||||
): Promise<void> {
|
||||
const cancellation = new SharedArrayBuffer(4)
|
||||
const cancel = (): void => {
|
||||
Atomics.store(new Int32Array(cancellation), 0, 1)
|
||||
}
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const worker = new Worker(join(
|
||||
dirname(fileURLToPath(import.meta.url)), 'assistant-storage-worker.js'
|
||||
), { workerData: { databasePath, cancellation } })
|
||||
let failure: Error | undefined
|
||||
let done = false
|
||||
worker.on('message', (message: {
|
||||
progress?: AssistantStorageProgress
|
||||
error?: string
|
||||
cancelled?: boolean
|
||||
done?: boolean
|
||||
}) => {
|
||||
if (message.progress) publish(message.progress)
|
||||
if (message.error && !message.cancelled) failure = new Error(message.error)
|
||||
if (message.done || message.cancelled) done = true
|
||||
})
|
||||
worker.once('error', () => {
|
||||
failure = new Error('无法启动历史执行记录优化,请重启 GoodBuddy 后重试。')
|
||||
})
|
||||
worker.once('exit', (code) => {
|
||||
signal.removeEventListener('abort', cancel)
|
||||
if (failure) reject(failure)
|
||||
else if (done && code === 0) resolve()
|
||||
else reject(new Error('历史执行记录优化意外结束,请重试。'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { upgradeAssistantStorage } from './assistant/assistant-storage-upgrade'
|
||||
|
||||
const input = workerData as {
|
||||
databasePath: string
|
||||
cancellation: SharedArrayBuffer
|
||||
}
|
||||
const cancellation = new Int32Array(input.cancellation)
|
||||
try {
|
||||
upgradeAssistantStorage(
|
||||
input.databasePath,
|
||||
(progress) => parentPort?.postMessage({ progress }),
|
||||
() => Atomics.load(cancellation, 0) !== 0
|
||||
)
|
||||
parentPort?.postMessage({ done: true })
|
||||
} catch (error) {
|
||||
parentPort?.postMessage({
|
||||
error: error instanceof Error && /full|space/iu.test(error.message)
|
||||
? '磁盘空间不足,无法完成空间回收。请释放一些空间后重试。'
|
||||
: '历史执行记录优化未完成。请重试;若仍失败,请备份数据并联系支持。',
|
||||
cancelled: error instanceof Error && error.name === 'AbortError'
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import {
|
||||
AssistantDatabase,
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
} from './assistant-database'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
@@ -641,7 +644,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 33', async () => {
|
||||
it('migrates existing databases to the current schema version', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -670,7 +673,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(33)
|
||||
).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -840,7 +843,7 @@ describe('AssistantDatabase', () => {
|
||||
enableForeignKeyConstraints: true
|
||||
})
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
|
||||
@@ -938,7 +941,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
@@ -1015,7 +1018,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
@@ -1265,7 +1268,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(33)
|
||||
).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -1403,7 +1406,7 @@ describe('AssistantDatabase', () => {
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 33 })
|
||||
).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
@@ -3102,7 +3105,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected.prepare('PRAGMA table_info(task_events)').all()
|
||||
|
||||
@@ -95,8 +95,9 @@ import {
|
||||
setMagicNoteChecklistCompletion
|
||||
} from '../magic-notes/rich-content'
|
||||
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
||||
import { SubagentProgressStorage } from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1750,6 +1751,7 @@ function deleteProjectRecords(
|
||||
|
||||
export class AssistantDatabase {
|
||||
private database?: DatabaseSync
|
||||
private subagentProgress?: SubagentProgressStorage
|
||||
private channelEventWrites = 0
|
||||
private channelOutboxWrites = 0
|
||||
|
||||
@@ -1780,6 +1782,7 @@ export class AssistantDatabase {
|
||||
`)
|
||||
this.migrate(database)
|
||||
this.database = database
|
||||
this.subagentProgress = new SubagentProgressStorage(database)
|
||||
this.channelEventWrites = (
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM channel_events')
|
||||
@@ -2015,6 +2018,7 @@ export class AssistantDatabase {
|
||||
close(): void {
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
this.subagentProgress = undefined
|
||||
}
|
||||
|
||||
clearAssistantData(): void {
|
||||
@@ -4931,7 +4935,9 @@ export class AssistantDatabase {
|
||||
kind: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
this.requireDatabase()
|
||||
const database = this.requireDatabase()
|
||||
const payloadJson = this.subagentProgress!.serialize(taskId, kind, payload)
|
||||
const result = database
|
||||
.prepare(
|
||||
`INSERT INTO task_events
|
||||
(task_id, run_id, kind, payload_json, created_at)
|
||||
@@ -4940,9 +4946,12 @@ export class AssistantDatabase {
|
||||
.run(
|
||||
taskId,
|
||||
kind.slice(0, 64),
|
||||
JSON.stringify(payload),
|
||||
payloadJson,
|
||||
new Date().toISOString()
|
||||
)
|
||||
this.subagentProgress!.inserted(
|
||||
taskId, kind, Number(result.lastInsertRowid), payloadJson
|
||||
)
|
||||
}
|
||||
|
||||
appendRemoteTaskEventOnce(input: RemoteTaskEventInput): boolean {
|
||||
@@ -5335,6 +5344,9 @@ export class AssistantDatabase {
|
||||
database: DatabaseSync,
|
||||
event: ValidatedRemoteTaskEvent
|
||||
): boolean {
|
||||
const payloadJson = this.subagentProgress!.serialize(
|
||||
event.taskId, event.kind, JSON.parse(event.payloadJson)
|
||||
)
|
||||
const result = database
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO task_events
|
||||
@@ -5346,7 +5358,7 @@ export class AssistantDatabase {
|
||||
.run(
|
||||
event.taskId,
|
||||
event.kind,
|
||||
event.payloadJson,
|
||||
payloadJson,
|
||||
new Date().toISOString(),
|
||||
event.bindingId,
|
||||
event.operationId,
|
||||
@@ -5354,11 +5366,14 @@ export class AssistantDatabase {
|
||||
event.eventIndex
|
||||
)
|
||||
if (result.changes === 1) {
|
||||
this.subagentProgress!.inserted(
|
||||
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
|
||||
)
|
||||
return true
|
||||
}
|
||||
const existing = database
|
||||
.prepare(
|
||||
`SELECT task_id, kind, payload_json
|
||||
`SELECT id, task_id, kind, payload_json
|
||||
FROM task_events
|
||||
WHERE remote_binding_id = ?
|
||||
AND remote_operation_id = ?
|
||||
@@ -5372,6 +5387,7 @@ export class AssistantDatabase {
|
||||
event.eventIndex
|
||||
) as
|
||||
| {
|
||||
id: number
|
||||
task_id: string
|
||||
kind: string
|
||||
payload_json: string
|
||||
@@ -5380,7 +5396,10 @@ export class AssistantDatabase {
|
||||
if (
|
||||
existing?.task_id === event.taskId &&
|
||||
existing.kind === event.kind &&
|
||||
existing.payload_json === event.payloadJson
|
||||
(existing.payload_json === event.payloadJson ||
|
||||
(event.kind === 'subagent' && this.subagentProgress!.matches(
|
||||
event.taskId, existing.id, JSON.parse(event.payloadJson)
|
||||
)))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -9653,6 +9672,15 @@ export class AssistantDatabase {
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
if (version.user_version < 34) {
|
||||
database.exec(`
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
|
||||
ON task_events(task_id, id) WHERE kind = 'subagent';
|
||||
PRAGMA user_version = 34;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
const directories: string[] = []
|
||||
const databases: AssistantDatabase[] = []
|
||||
afterEach(async () => {
|
||||
for (const database of databases.splice(0)) database.close()
|
||||
await Promise.all(directories.splice(0).map(
|
||||
(path) => rm(path, { recursive: true, force: true })
|
||||
))
|
||||
})
|
||||
|
||||
async function fixture(): Promise<{
|
||||
path: string; database: AssistantDatabase; taskId: string
|
||||
}> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-upgrade-'))
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
databases.push(database)
|
||||
database.initialize(directory)
|
||||
const taskId = randomUUID()
|
||||
database.createTask({
|
||||
id: taskId, title: 'Storage regression', instructions: 'Synthetic fixture',
|
||||
workMode: 'execute'
|
||||
})
|
||||
return { path, database, taskId }
|
||||
}
|
||||
|
||||
function subagent(requestId: string): SubagentEvent {
|
||||
return {
|
||||
requestId, type: 'subagent', childTaskId: randomUUID(),
|
||||
expertId: randomUUID(), expertName: 'general', routingMode: 'native',
|
||||
runtimeCallId: 'test-call', state: 'running', progress: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('subagent progress storage', () => {
|
||||
it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => {
|
||||
const event = subagent(randomUUID())
|
||||
const textId = randomUUID()
|
||||
const toolId = randomUUID()
|
||||
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: toolId, type: 'tool', tool: { name: 'read', state: 'completed', summary: 'Read', output: 'seed' }
|
||||
}
|
||||
const encoder = new Map<string, ConversationMessageBlock[]>()
|
||||
const decoder = new Map<string, ConversationMessageBlock[]>()
|
||||
for (const progress of [
|
||||
[], [text], [text, tool],
|
||||
[{ ...text, content: 'seed tail' }, tool],
|
||||
[{ ...text, content: 'corrected' }, tool],
|
||||
[tool, text], [], [text]
|
||||
]) {
|
||||
const full = { ...event, progress }
|
||||
const compact = compactSubagentPayload(full, encoder)
|
||||
restoreSubagentPayload(full, encoder)
|
||||
expect(restoreSubagentPayload(compact, decoder)).toEqual(full)
|
||||
}
|
||||
})
|
||||
|
||||
it('stores small changes without repeating large tool outputs, including across reopen', async () => {
|
||||
const fixtureData = await fixture()
|
||||
let database = fixtureData.database
|
||||
const event = subagent(fixtureData.taskId)
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) }
|
||||
}
|
||||
const textId = randomUUID()
|
||||
try {
|
||||
for (let index = 0; index < 200; index++) {
|
||||
event.progress = [tool, { id: textId, type: 'text', content: `seed${'中'.repeat(index)}` }]
|
||||
database.appendTaskEvent(fixtureData.taskId, 'subagent', event)
|
||||
if (index === 99) {
|
||||
database.close()
|
||||
database = new AssistantDatabase(fixtureData.path)
|
||||
database.initialize(tmpdir())
|
||||
}
|
||||
}
|
||||
database.close()
|
||||
const inspection = new DatabaseSync(fixtureData.path)
|
||||
try {
|
||||
const rows = inspection.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
||||
).all() as Array<{ payload_json: string }>
|
||||
expect(rows).toHaveLength(200)
|
||||
expect(rows.reduce((sum, row) => sum + row.payload_json.length, 0)).toBeLessThan(400_000)
|
||||
const state = new Map<string, ConversationMessageBlock[]>()
|
||||
let restored: unknown
|
||||
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
|
||||
expect(restored).toEqual(event)
|
||||
} finally { inspection.close() }
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
|
||||
const { database, taskId, path } = await fixture()
|
||||
const event = subagent(taskId)
|
||||
const textId = randomUUID()
|
||||
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] }
|
||||
try {
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
|
||||
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
|
||||
} finally { database.close() }
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(tmpdir())
|
||||
try {
|
||||
expect(reopened.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(false)
|
||||
expect(reopened.getHighestCommittedRemoteTaskEventSequence('binding', 'operation')).toBe('2')
|
||||
} finally { reopened.close() }
|
||||
})
|
||||
|
||||
it('rebuilds progress after a remote batch rolls back', async () => {
|
||||
const { database, taskId } = await fixture()
|
||||
const event = subagent(taskId)
|
||||
const textId = randomUUID()
|
||||
const base = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
try {
|
||||
database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '2', payload: first })
|
||||
expect(() => database.appendRemoteTaskEventsBatch([
|
||||
{ ...base, semanticSequence: '1', payload: { ...first, progress: [] } },
|
||||
{ ...base, semanticSequence: '2', payload: { ...first, output: 'conflict' } }
|
||||
])).toThrow('conflicts')
|
||||
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(false)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('keeps full remote conversation progress atomic with compact events', async () => {
|
||||
const { database, path } = await fixture()
|
||||
const hostId = randomUUID()
|
||||
const project = database.createSshProject({
|
||||
project: {
|
||||
name: 'Remote regression', description: '', rootPath: '/tmp/goodbuddy-storage-test',
|
||||
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/tmp/goodbuddy-storage-test' },
|
||||
assertCurrent: () => undefined
|
||||
})
|
||||
const conversationId = randomUUID()
|
||||
const userMessageId = randomUUID()
|
||||
const assistantMessageId = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId: project.id, title: 'Atomic regression', updatedAt: Date.now() },
|
||||
messages: []
|
||||
}])
|
||||
const remoteTaskId = randomUUID()
|
||||
database.createTask({
|
||||
id: remoteTaskId, projectId: project.id, conversationId,
|
||||
title: 'Atomic remote', instructions: 'Synthetic', workMode: 'execute',
|
||||
remoteRecovery: {
|
||||
recoverable: true, currentUserMessageId: userMessageId,
|
||||
currentAssistantMessageId: assistantMessageId
|
||||
}
|
||||
})
|
||||
const event = subagent(remoteTaskId)
|
||||
const textId = randomUUID()
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
const second = { ...event, state: 'completed' as const, output: 'Complete', progress: [
|
||||
{ id: textId, type: 'text' as const, content: 'seed tail' }
|
||||
] }
|
||||
const owner = {
|
||||
taskId: remoteTaskId, conversationId, assistantMessageId,
|
||||
bindingId: 'atomic-binding', operationId: 'atomic-operation', eventIndex: 0
|
||||
}
|
||||
const inspection = new DatabaseSync(path)
|
||||
try {
|
||||
database.appendRemoteConversationTaskEventOnce({ ...owner, semanticSequence: '1', event: first })
|
||||
inspection.exec(`CREATE TRIGGER reject_message BEFORE UPDATE ON messages
|
||||
BEGIN SELECT RAISE(ABORT, 'fixture message write failed'); END;`)
|
||||
expect(() => database.appendRemoteConversationTaskEventOnce({
|
||||
...owner, semanticSequence: '2', event: second
|
||||
})).toThrow('fixture message write failed')
|
||||
expect(database.getHighestCommittedRemoteTaskEventSequence('atomic-binding', 'atomic-operation')).toBe('1')
|
||||
inspection.exec('DROP TRIGGER reject_message')
|
||||
expect(database.appendRemoteConversationTaskEventOnce({
|
||||
...owner, semanticSequence: '2', event: second
|
||||
})).toBe(true)
|
||||
expect(database.appendRemoteConversationTaskEventOnce({
|
||||
...owner, semanticSequence: '2', event: second
|
||||
})).toBe(false)
|
||||
const message = database.getConversation(conversationId).messages.find((row) => row.id === assistantMessageId)
|
||||
expect(message?.subagents?.[0]).toMatchObject({
|
||||
state: 'completed', progress: second.progress, output: 'Complete'
|
||||
})
|
||||
expect(inspection.prepare(
|
||||
`SELECT COUNT(*) AS count FROM task_events WHERE task_id = ? AND kind = 'subagent'`
|
||||
).get(remoteTaskId)).toEqual({ count: 2 })
|
||||
} finally { inspection.close(); database.close() }
|
||||
})
|
||||
|
||||
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => {
|
||||
const { database, taskId, path } = await fixture()
|
||||
database.close()
|
||||
const event = subagent(taskId)
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(300_000) }
|
||||
}
|
||||
const textId = randomUUID()
|
||||
const legacy = new DatabaseSync(path)
|
||||
const expected: SubagentEvent[] = []
|
||||
try {
|
||||
legacy.exec('PRAGMA user_version = 33; BEGIN')
|
||||
const insert = legacy.prepare(
|
||||
`INSERT INTO task_events(task_id, kind, payload_json, created_at)
|
||||
VALUES (?, 'subagent', ?, ?)`
|
||||
)
|
||||
for (let index = 0; index < 150; index++) {
|
||||
const full = {
|
||||
...event,
|
||||
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
|
||||
}
|
||||
expected.push(full)
|
||||
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z')
|
||||
}
|
||||
legacy.exec('COMMIT')
|
||||
} finally { legacy.close() }
|
||||
const before = (await stat(path)).size
|
||||
expect(before).toBeGreaterThan(40_000_000)
|
||||
let cancelled = false
|
||||
expect(() => upgradeAssistantStorage(path, (progress) => {
|
||||
if (progress.processed >= 32) cancelled = true
|
||||
}, () => cancelled)).toThrow('cancelled')
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
const after = (await stat(path)).size
|
||||
expect(after).toBeLessThan(before / 20)
|
||||
const inspected = new DatabaseSync(path)
|
||||
try {
|
||||
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
const rows = inspected.prepare(
|
||||
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
||||
).all() as Array<{ payload_json: string; created_at: string }>
|
||||
expect(rows).toHaveLength(expected.length)
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
rows.forEach((row, index) => {
|
||||
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
|
||||
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
|
||||
})
|
||||
} finally { inspected.close() }
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(tmpdir())
|
||||
reopened.close()
|
||||
const messages: unknown[] = []
|
||||
upgradeAssistantStorage(path, (value) => messages.push(value))
|
||||
expect(messages).toEqual([])
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export function upgradeAssistantStorage(
|
||||
databasePath: string,
|
||||
onProgress: (progress: AssistantStorageProgress) => void,
|
||||
isCancelled: () => boolean = () => false
|
||||
): void {
|
||||
const database = new DatabaseSync(databasePath, { timeout: 5_000 })
|
||||
const checkCancelled = (): void => {
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
}
|
||||
const bytesBefore = statSync(databasePath).size
|
||||
try {
|
||||
const version = database.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
if (version.user_version < 1 || version.user_version >= 34) return
|
||||
checkCancelled()
|
||||
const progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore
|
||||
}
|
||||
onProgress({ ...progress })
|
||||
database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
PRAGMA secure_delete = OFF;
|
||||
PRAGMA cache_size = -65536;
|
||||
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
|
||||
ON task_events(task_id, id) WHERE kind = 'subagent';
|
||||
`)
|
||||
const tasks = database.prepare(
|
||||
`SELECT task_id, COUNT(*) AS count FROM task_events
|
||||
WHERE kind = 'subagent' GROUP BY task_id`
|
||||
).all() as Array<{ task_id: string; count: number }>
|
||||
progress.total = tasks.reduce((sum, task) => sum + task.count, 0)
|
||||
progress.stage = 'converting'
|
||||
const select = database.prepare(
|
||||
`SELECT id, payload_json FROM task_events
|
||||
WHERE task_id = ? AND kind = 'subagent' AND id > ?
|
||||
ORDER BY id LIMIT 32`
|
||||
)
|
||||
const update = database.prepare(
|
||||
'UPDATE task_events SET payload_json = ? WHERE id = ?'
|
||||
)
|
||||
for (const task of tasks) {
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
let afterId = 0
|
||||
for (;;) {
|
||||
checkCancelled()
|
||||
const rows = select.all(task.task_id, afterId) as Array<{
|
||||
id: number; payload_json: string
|
||||
}>
|
||||
if (!rows.length) break
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const payload: unknown = JSON.parse(row.payload_json)
|
||||
const compact = compactSubagentPayload(payload, blocks)
|
||||
// Replay mixed old/new rows on retry. Never delete events or provenance.
|
||||
restoreSubagentPayload(payload, blocks)
|
||||
if (compact !== payload) {
|
||||
update.run(JSON.stringify(compact), row.id)
|
||||
}
|
||||
afterId = row.id
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
progress.processed += rows.length
|
||||
onProgress({ ...progress })
|
||||
}
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
}
|
||||
checkCancelled()
|
||||
const free = database.prepare('PRAGMA freelist_count').get() as {
|
||||
freelist_count: number
|
||||
}
|
||||
if (free.freelist_count > 0) {
|
||||
progress.stage = 'compacting'
|
||||
onProgress({ ...progress })
|
||||
// Rebuild the now-small live database, not a second copy of the bloated input.
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;')
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
}
|
||||
checkCancelled()
|
||||
onProgress({
|
||||
...progress, stage: 'complete',
|
||||
bytesAfter: statSync(databasePath).size
|
||||
})
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { z } from 'zod'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
conversationMessageBlockSchema,
|
||||
type ConversationMessageBlock
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('append'),
|
||||
id: assistantIdSchema,
|
||||
blockType: z.enum(['text', 'reasoning']),
|
||||
delta: z.string()
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('upsert'),
|
||||
block: conversationMessageBlockSchema
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('reset'),
|
||||
blocks: z.array(conversationMessageBlockSchema)
|
||||
}).strict()
|
||||
])
|
||||
|
||||
export type SubagentProgressUpdate = z.infer<
|
||||
typeof subagentProgressUpdateSchema
|
||||
>
|
||||
|
||||
type ProgressEvent = {
|
||||
progress?: ConversationMessageBlock[]
|
||||
progressUpdates?: SubagentProgressUpdate[]
|
||||
}
|
||||
|
||||
export function applySubagentProgress(
|
||||
previous: ConversationMessageBlock[] | undefined,
|
||||
event: ProgressEvent
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
let blocks = event.progress ?? previous
|
||||
if (!event.progressUpdates?.length) return blocks
|
||||
blocks = [...(blocks ?? [])]
|
||||
for (const update of event.progressUpdates) {
|
||||
if (update.type === 'reset') {
|
||||
blocks = [...update.blocks]
|
||||
continue
|
||||
}
|
||||
const id = update.type === 'upsert' ? update.block.id : update.id
|
||||
const index = blocks.findIndex((block) => block.id === id)
|
||||
const prior = blocks[index]
|
||||
const block: ConversationMessageBlock = update.type === 'upsert'
|
||||
? update.block
|
||||
: {
|
||||
id,
|
||||
type: update.blockType,
|
||||
content: (
|
||||
prior?.type === update.blockType ? prior.content : ''
|
||||
) + update.delta
|
||||
}
|
||||
if (index < 0) blocks.push(block)
|
||||
else blocks[index] = block
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function diffSubagentProgress(
|
||||
previous: readonly ConversationMessageBlock[],
|
||||
next: readonly ConversationMessageBlock[]
|
||||
): SubagentProgressUpdate[] {
|
||||
if (
|
||||
previous.length > next.length ||
|
||||
previous.some((block, index) => block.id !== next[index]?.id)
|
||||
) {
|
||||
return [{ type: 'reset', blocks: [...next] }]
|
||||
}
|
||||
const updates: SubagentProgressUpdate[] = []
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
const block = next[index]!
|
||||
const prior = previous[index]
|
||||
if (block === prior) continue
|
||||
if (
|
||||
(block.type === 'text' || block.type === 'reasoning') &&
|
||||
prior?.type === block.type &&
|
||||
block.content.startsWith(prior.content)
|
||||
) {
|
||||
const delta = block.content.slice(prior.content.length)
|
||||
if (delta) {
|
||||
updates.push({
|
||||
type: 'append', id: block.id, blockType: block.type, delta
|
||||
})
|
||||
}
|
||||
} else if (!prior || !isDeepStrictEqual(prior, block)) {
|
||||
updates.push({ type: 'upsert', block })
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
type StoredSubagent = ProgressEvent & {
|
||||
childTaskId: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function asSubagent(payload: unknown): StoredSubagent | undefined {
|
||||
if (
|
||||
typeof payload !== 'object' || payload === null ||
|
||||
!('childTaskId' in payload) || typeof payload.childTaskId !== 'string'
|
||||
) return undefined
|
||||
return payload as StoredSubagent
|
||||
}
|
||||
|
||||
export function compactSubagentPayload(
|
||||
payload: unknown,
|
||||
progress: ReadonlyMap<string, ConversationMessageBlock[]>
|
||||
): unknown {
|
||||
const event = asSubagent(payload)
|
||||
if (!event || !Array.isArray(event.progress)) return payload
|
||||
const compact = { ...event }
|
||||
delete compact.progress
|
||||
compact.progressUpdates = diffSubagentProgress(
|
||||
progress.get(event.childTaskId) ?? [],
|
||||
event.progress
|
||||
)
|
||||
return compact
|
||||
}
|
||||
|
||||
export function restoreSubagentPayload(
|
||||
payload: unknown,
|
||||
progress: Map<string, ConversationMessageBlock[]>
|
||||
): unknown {
|
||||
const event = asSubagent(payload)
|
||||
if (!event) return payload
|
||||
const blocks = applySubagentProgress(
|
||||
progress.get(event.childTaskId), event
|
||||
)
|
||||
if (blocks) progress.set(event.childTaskId, blocks)
|
||||
if (!event.progressUpdates) return payload
|
||||
const restored = { ...event, progress: blocks ?? [] }
|
||||
delete restored.progressUpdates
|
||||
return restored
|
||||
}
|
||||
|
||||
type TaskProgress = {
|
||||
lastId: number
|
||||
blocks: Map<string, ConversationMessageBlock[]>
|
||||
}
|
||||
|
||||
/** The conversation keeps the display snapshot; its event history stores changes. */
|
||||
export class SubagentProgressStorage {
|
||||
private readonly tasks = new Map<string, TaskProgress>()
|
||||
|
||||
constructor(private readonly database: DatabaseSync) {}
|
||||
|
||||
serialize(taskId: string, kind: string, payload: unknown): string {
|
||||
return JSON.stringify(kind === 'subagent'
|
||||
? compactSubagentPayload(payload, this.current(taskId).blocks)
|
||||
: payload)
|
||||
}
|
||||
|
||||
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
|
||||
if (kind !== 'subagent') return
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task) return
|
||||
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
|
||||
task.lastId = id
|
||||
}
|
||||
|
||||
matches(taskId: string, eventId: number, payload: unknown): boolean {
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
const rows = this.database.prepare(
|
||||
`SELECT id, payload_json FROM task_events
|
||||
WHERE task_id = ? AND kind = 'subagent' AND id <= ?
|
||||
ORDER BY id`
|
||||
).iterate(taskId, eventId)
|
||||
for (const row of rows) {
|
||||
const restored = restoreSubagentPayload(
|
||||
JSON.parse(row.payload_json as string), blocks
|
||||
)
|
||||
if (row.id === eventId) return isDeepStrictEqual(restored, payload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private current(taskId: string): TaskProgress {
|
||||
const last = this.database.prepare(
|
||||
`SELECT id FROM task_events WHERE task_id = ? AND kind = 'subagent'
|
||||
ORDER BY id DESC LIMIT 1`
|
||||
).get(taskId) as { id: number } | undefined
|
||||
let task = this.tasks.get(taskId)
|
||||
if (task?.lastId === (last?.id ?? 0)) return task
|
||||
// A rolled-back transaction or reopened task is rebuilt from committed rows.
|
||||
task = { lastId: last?.id ?? 0, blocks: new Map() }
|
||||
const rows = this.database.prepare(
|
||||
`SELECT payload_json FROM task_events
|
||||
WHERE task_id = ? AND kind = 'subagent' ORDER BY id`
|
||||
).iterate(taskId)
|
||||
for (const row of rows) {
|
||||
restoreSubagentPayload(JSON.parse(row.payload_json as string), task.blocks)
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
this.tasks.set(taskId, task)
|
||||
if (this.tasks.size > 16) {
|
||||
this.tasks.delete(this.tasks.keys().next().value!)
|
||||
}
|
||||
return task
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
installHarnessDiagnosticGuard,
|
||||
startControlledDeepSeekHarnessHost
|
||||
} from './deepseek-harness-host'
|
||||
import { vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import type {
|
||||
Agent,
|
||||
CreateAgentOptions
|
||||
@@ -65,6 +65,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-host-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
@@ -125,6 +126,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-alias-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const alias = join(root, '..', basename(root))
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
@@ -157,6 +159,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-extensions-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const brokenEntrypoint = join(root, 'broken.mjs')
|
||||
await writeFile(
|
||||
brokenEntrypoint,
|
||||
@@ -202,6 +205,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const skillDirectory = join(root, 'web-3d-game')
|
||||
await mkdir(skillDirectory)
|
||||
await writeFile(
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from './ipc'
|
||||
import { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { prepareAssistantStorage } from './assistant-storage-startup'
|
||||
import { createModelGraphExtractor } from './knowledge/model-extractor'
|
||||
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
||||
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
|
||||
@@ -512,6 +513,9 @@ function buildTray(): Tray {
|
||||
return nextTray
|
||||
}
|
||||
|
||||
const storageUpgradeController = new AbortController()
|
||||
let storageUpgrade: Promise<void> | undefined
|
||||
|
||||
if (hasSingleInstanceLock) {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
@@ -544,6 +548,13 @@ if (hasSingleInstanceLock) {
|
||||
mainWindow = createMainWindow(() => isQuitting)
|
||||
registerDesktopNotificationActivation(mainWindow)
|
||||
tray = buildTray()
|
||||
storageUpgrade = prepareAssistantStorage(
|
||||
mainWindow,
|
||||
join(app.getPath('userData'), 'assistant.sqlite'),
|
||||
storageUpgradeController.signal
|
||||
)
|
||||
await storageUpgrade
|
||||
if (storageUpgradeController.signal.aborted) return
|
||||
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
|
||||
const secureCipher = {
|
||||
isAvailable: () =>
|
||||
@@ -1478,6 +1489,7 @@ let cleanupComplete = false
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
isQuitting = true
|
||||
storageUpgradeController.abort()
|
||||
if (cleanupComplete) {
|
||||
return
|
||||
}
|
||||
@@ -1490,6 +1502,7 @@ app.on('before-quit', (event) => {
|
||||
try {
|
||||
const cleanup = settleCleanupPhases([
|
||||
[
|
||||
() => storageUpgrade,
|
||||
() => feedbackService?.dispose(),
|
||||
() => removeFeedbackIpcHandler?.(),
|
||||
() => dshExtensionInstaller?.dispose(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -70,6 +70,22 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ReleaseNotesService', () => {
|
||||
it('reads and acknowledges the actual packaged release history', async () => {
|
||||
const { version } = JSON.parse(
|
||||
await readFile(join(process.cwd(), 'package.json'), 'utf8')
|
||||
) as { version: string }
|
||||
const { filePath, service } = await createService(version)
|
||||
await writeFile(
|
||||
filePath,
|
||||
await readFile(join(process.cwd(), 'resources', 'release-notes.json'))
|
||||
)
|
||||
await expect(service.getPending()).resolves.toMatchObject({
|
||||
currentVersion: version,
|
||||
releases: [{ version }]
|
||||
})
|
||||
await service.acknowledge({ version })
|
||||
await expect(service.getPending()).resolves.toMatchObject({ releases: [] })
|
||||
})
|
||||
it('shows only the current release on a fresh installation', async () => {
|
||||
const { service } = await createService('0.8.18')
|
||||
|
||||
@@ -135,7 +151,7 @@ describe('ReleaseNotesService', () => {
|
||||
|
||||
it('rejects an oversized release-notes resource with a bounded read', async () => {
|
||||
const { filePath, service } = await createService('0.8.18')
|
||||
await writeFile(filePath, ' '.repeat(128 * 1024 + 1), 'utf8')
|
||||
await writeFile(filePath, ' '.repeat(1024 * 1024 + 1), 'utf8')
|
||||
|
||||
await expect(service.getPending()).rejects.toThrow(
|
||||
'Release notes exceed the size limit'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { ApplicationSettingsStore } from './application-settings-store'
|
||||
import { compareStrictSemVer } from './version-checker'
|
||||
|
||||
const maximumReleaseNotesBytes = 128 * 1024
|
||||
const maximumReleaseNotesBytes = 1024 * 1024
|
||||
|
||||
export class ReleaseNotesService {
|
||||
private releases?: ReleaseNote[]
|
||||
|
||||
+11
-3
@@ -105,11 +105,19 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
|
||||
return window
|
||||
}
|
||||
|
||||
export function loadMainWindow(window: BrowserWindow): void {
|
||||
export function loadMainWindow(
|
||||
window: BrowserWindow,
|
||||
storageUpgrade = false
|
||||
): void {
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
void window.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
const url = new URL(process.env.ELECTRON_RENDERER_URL)
|
||||
if (storageUpgrade) url.searchParams.set('storageUpgrade', '1')
|
||||
void window.loadURL(url.href)
|
||||
} else {
|
||||
void window.loadFile(join(currentDirectory, '../renderer/index.html'))
|
||||
void window.loadFile(
|
||||
join(currentDirectory, '../renderer/index.html'),
|
||||
storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,12 @@ import type {
|
||||
|
||||
|
||||
const desktopApi: DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: () => ipcRenderer.invoke(ipcChannels.storageUpgradeProgress),
|
||||
act: async (action) => {
|
||||
await ipcRenderer.invoke(ipcChannels.storageUpgradeAction, action)
|
||||
}
|
||||
},
|
||||
app: {
|
||||
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
|
||||
show: async () => {
|
||||
|
||||
@@ -181,6 +181,10 @@ const terminalSnapshot: TerminalSnapshot = {
|
||||
};
|
||||
|
||||
const api: DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: vi.fn(),
|
||||
act: vi.fn(async () => undefined)
|
||||
},
|
||||
app: {
|
||||
getInfo: vi.fn(async () => ({
|
||||
name: "GoodBuddy",
|
||||
|
||||
@@ -32,6 +32,21 @@ function selectInstance(index = 0): void {
|
||||
}
|
||||
|
||||
describe('external knowledge workflows', () => {
|
||||
it('updates an automatic name when selecting another library but preserves an edited name', async () => {
|
||||
bridge.externalCatalogList.mockResolvedValueOnce({
|
||||
items: [{ id: 'remote-1', name: 'Handbook' }, { id: 'remote-2', name: 'Second library' }],
|
||||
hasMore: false
|
||||
})
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Handbook')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Second library/ }))
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Second library')
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'My chosen name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Handbook/ }))
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('My chosen name')
|
||||
})
|
||||
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: 'External instance' })
|
||||
@@ -97,6 +112,7 @@ describe('external knowledge workflows', () => {
|
||||
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
|
||||
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
|
||||
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge > header\s*\{[^}]*overflow-wrap: anywhere/)
|
||||
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
|
||||
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
|
||||
|
||||
@@ -187,6 +187,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
|
||||
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
|
||||
const [name, setName] = useState(library?.name ?? '')
|
||||
const [nameEdited, setNameEdited] = useState(Boolean(library))
|
||||
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
|
||||
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
|
||||
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
|
||||
@@ -265,10 +266,10 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
<div className="external-knowledge__columns">
|
||||
<section className="external-knowledge__fields">
|
||||
<h3>{t('external.source')}</h3>
|
||||
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
|
||||
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); if (!nameEdited) setName(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
|
||||
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
|
||||
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
|
||||
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
|
||||
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value); if (!nameEdited) setName('') }} /></Field> : <>
|
||||
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
|
||||
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
|
||||
@@ -280,7 +281,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
|
||||
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
|
||||
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
|
||||
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
|
||||
invalidate(); setRemoteId(item.id); setDetail(item); if (!nameEdited) setName(item.name)
|
||||
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
|
||||
})}
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
|
||||
@@ -288,7 +289,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
</>}
|
||||
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
|
||||
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
|
||||
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
|
||||
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => { setName(event.currentTarget.value); setNameEdited(true) }} /></Field>
|
||||
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
|
||||
</section>
|
||||
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
|
||||
|
||||
@@ -1472,6 +1472,99 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(browserA).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('releases the native browser for portalled modals until the last modal closes', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
width: 400, x: 900, y: 100, toJSON: () => ({})
|
||||
})
|
||||
const modal = document.createElement('section')
|
||||
modal.setAttribute('role', 'dialog')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
const nested = modal.cloneNode() as HTMLElement
|
||||
try {
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'modal-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
|
||||
document.body.append(modal)
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
|
||||
document.body.append(nested)
|
||||
modal.remove()
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
|
||||
expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease })
|
||||
nested.remove()
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', leaseToken: lease, bounds: expect.any(Object) })
|
||||
))
|
||||
expect(browserApi.closeTab).not.toHaveBeenCalled()
|
||||
expect(browserApi.createTab).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
modal.remove()
|
||||
nested.remove()
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it.each(['menu', 'dialog', 'notification'])('hides the native view only while an overlapping %s is visible', async (kind) => {
|
||||
const popup = document.createElement('section')
|
||||
if (kind === 'notification') popup.className = 'app-notification'
|
||||
else popup.setAttribute('role', kind)
|
||||
popup.hidden = true
|
||||
let overlapping = true
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const left = this === popup && !overlapping ? 100 : 900
|
||||
return {
|
||||
bottom: 500, height: 400, left, right: left + 400, top: 100,
|
||||
width: 400, x: left, y: 100, toJSON: () => ({})
|
||||
}
|
||||
})
|
||||
try {
|
||||
document.body.append(popup)
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'popup-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
|
||||
popup.hidden = false
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
|
||||
overlapping = false
|
||||
fireEvent(window, new Event('resize'))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
overlapping = true
|
||||
fireEvent(window, new Event('resize'))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
|
||||
popup.hidden = true
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
} finally {
|
||||
popup.remove()
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it('does not acquire a native view behind an already open modal or restore an inactive tab', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
width: 400, x: 900, y: 100, toJSON: () => ({})
|
||||
})
|
||||
const modal = document.createElement('section')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
try {
|
||||
document.body.append(modal)
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'covered-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalled())
|
||||
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||
modal.remove()
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
|
||||
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
|
||||
} finally {
|
||||
modal.remove()
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it('releases the native browser viewport while confirming another terminal close', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
|
||||
@@ -43,6 +43,10 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import {
|
||||
browserOverlaySelector,
|
||||
isBrowserViewportOccluded
|
||||
} from './browser-viewport-occlusion'
|
||||
import {
|
||||
findTaskSchedule,
|
||||
TaskScheduleActions
|
||||
@@ -504,10 +508,11 @@ function BrowserViewport({
|
||||
let animationFrame: number | undefined
|
||||
let lastRequest = ''
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
const observedOverlays = new Set<Element>()
|
||||
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
|
||||
const syncViewport = (): void => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
return
|
||||
}
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = undefined
|
||||
@@ -517,7 +522,8 @@ function BrowserViewport({
|
||||
Boolean(conversationId) &&
|
||||
panel?.hidden !== true &&
|
||||
rect.width >= 1 &&
|
||||
rect.height >= 1
|
||||
rect.height >= 1 &&
|
||||
!isBrowserViewportOccluded(host, rect)
|
||||
const bounds = isVisible
|
||||
? {
|
||||
x: Math.max(0, Math.round(rect.left)),
|
||||
@@ -545,6 +551,7 @@ function BrowserViewport({
|
||||
if (panel?.hidden !== false) {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = undefined
|
||||
observedOverlays.clear()
|
||||
syncViewport()
|
||||
return
|
||||
}
|
||||
@@ -552,17 +559,34 @@ function BrowserViewport({
|
||||
resizeObserver = new ResizeObserver(syncViewport)
|
||||
resizeObserver.observe(host)
|
||||
}
|
||||
if (resizeObserver) {
|
||||
const overlays = new Set(document.querySelectorAll(browserOverlaySelector))
|
||||
for (const overlay of observedOverlays) {
|
||||
if (!overlays.has(overlay)) {
|
||||
resizeObserver.unobserve(overlay)
|
||||
observedOverlays.delete(overlay)
|
||||
}
|
||||
}
|
||||
for (const overlay of overlays) {
|
||||
if (!observedOverlays.has(overlay)) {
|
||||
resizeObserver.observe(overlay)
|
||||
observedOverlays.add(overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
syncViewport()
|
||||
}
|
||||
const mutationObserver = new MutationObserver(updateObservation)
|
||||
if (panel) {
|
||||
mutationObserver.observe(panel, {
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden']
|
||||
})
|
||||
}
|
||||
mutationObserver.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden', 'aria-modal', 'role', 'class', 'style', 'open']
|
||||
})
|
||||
window.addEventListener('resize', syncViewport)
|
||||
window.addEventListener('scroll', syncViewport, true)
|
||||
document.addEventListener('transitionend', syncViewport, true)
|
||||
document.addEventListener('animationend', syncViewport, true)
|
||||
updateObservation()
|
||||
return () => {
|
||||
if (animationFrame !== undefined) {
|
||||
@@ -572,6 +596,8 @@ function BrowserViewport({
|
||||
mutationObserver.disconnect()
|
||||
window.removeEventListener('resize', syncViewport)
|
||||
window.removeEventListener('scroll', syncViewport, true)
|
||||
document.removeEventListener('transitionend', syncViewport, true)
|
||||
document.removeEventListener('animationend', syncViewport, true)
|
||||
void browserApi.setViewport({ leaseToken }).catch(() => undefined)
|
||||
}
|
||||
}, [conversationId, tabId, visible])
|
||||
@@ -2397,7 +2423,7 @@ export function RightAssistantSidebar({
|
||||
: undefined
|
||||
}
|
||||
tabId={browserTabIds[instance.id]}
|
||||
visible={open && instance.id === activeWorkbarInstanceId && !terminalCloseConfirmation}
|
||||
visible={open && instance.id === activeWorkbarInstanceId}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.storage-upgrade {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.storage-upgrade > .page-shell {
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.storage-upgrade progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--space-2);
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { StorageUpgrade } from './StorageUpgrade'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('shows real progress and lets the user quit without deleting records', async () => {
|
||||
const act = vi.fn(async () => undefined)
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
getProgress: vi.fn(async () => ({
|
||||
stage: 'converting', processed: 32, total: 100, bytesBefore: 90_000_000_000
|
||||
})),
|
||||
act
|
||||
} } as unknown as DesktopApi)
|
||||
render(<StorageUpgrade />)
|
||||
await waitFor(() => expect(screen.getByRole('progressbar')).toHaveAttribute('value', '32'))
|
||||
expect(screen.getByText(/保留聊天、执行详情和结果/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出,稍后继续' }))
|
||||
await waitFor(() => expect(act).toHaveBeenCalledWith('quit'))
|
||||
})
|
||||
|
||||
it('keeps a failed upgrade actionable through retry', async () => {
|
||||
const act = vi.fn(async () => undefined)
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
getProgress: vi.fn(async () => ({
|
||||
stage: 'failed', processed: 32, total: 100, bytesBefore: 90_000_000_000,
|
||||
error: '磁盘空间不足'
|
||||
})),
|
||||
act
|
||||
} } as unknown as DesktopApi)
|
||||
render(<StorageUpgrade />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('磁盘空间不足')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(act).toHaveBeenCalledWith('retry'))
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
import { PageHeader, PageShell } from './WorkspacePrimitives'
|
||||
import { useUiLocale } from './i18n/UiLocaleProvider'
|
||||
import './StorageUpgrade.css'
|
||||
|
||||
export function StorageUpgrade(): React.JSX.Element {
|
||||
const { resolvedLocale } = useUiLocale()
|
||||
const en = resolvedLocale === 'en-US'
|
||||
const [progress, setProgress] = useState<AssistantStorageProgress>()
|
||||
const [rate, setRate] = useState<number>()
|
||||
const [actionError, setActionError] = useState('')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
let sample: { count: number; time: number } | undefined
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const next = await window.goodbuddy.storageUpgrade.getProgress()
|
||||
if (active) {
|
||||
setProgress(next)
|
||||
if (next.stage === 'converting') {
|
||||
const now = performance.now()
|
||||
if (sample && now - sample.time >= 2_000) {
|
||||
setRate(Math.max(0, (next.processed - sample.count) * 1_000 / (now - sample.time)))
|
||||
sample = { count: next.processed, time: now }
|
||||
} else if (!sample) {
|
||||
sample = { count: next.processed, time: now }
|
||||
}
|
||||
} else {
|
||||
sample = undefined
|
||||
setRate(undefined)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The normal application replaces this page when startup continues.
|
||||
}
|
||||
if (active) timer = setTimeout(() => void poll(), 500)
|
||||
}
|
||||
void poll()
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [])
|
||||
const title = en ? 'Optimizing saved execution history' : '正在优化历史执行记录'
|
||||
const stage = progress?.stage
|
||||
const status = stage === 'failed'
|
||||
? progress?.error
|
||||
: stage === 'compacting'
|
||||
? en ? 'Reclaiming disk space…' : '正在回收磁盘空间…'
|
||||
: stage === 'complete'
|
||||
? en ? 'Optimization complete. Starting GoodBuddy…' : '优化完成,正在启动 GoodBuddy…'
|
||||
: progress?.stage === 'converting'
|
||||
? `${en ? 'Processed' : '已处理'} ${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`
|
||||
: en ? 'Checking saved execution history…' : '正在检查历史执行记录…'
|
||||
const act = async (action: 'retry' | 'quit'): Promise<void> => {
|
||||
try {
|
||||
setActionError('')
|
||||
await window.goodbuddy.storageUpgrade.act(action)
|
||||
} catch {
|
||||
setActionError(en ? 'The action failed. Please try again.' : '操作未完成,请重试。')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="storage-upgrade" aria-labelledby="storage-upgrade-title">
|
||||
<PageShell variant="standard">
|
||||
<PageHeader
|
||||
headingId="storage-upgrade-title"
|
||||
title={title}
|
||||
description={en
|
||||
? 'This update removes repeated copies of subagent progress. Your chats, execution details and results are preserved. Time depends on the history size and disk speed.'
|
||||
: '本次更新清理子任务进度的重复副本,保留聊天、执行详情和结果。处理时间取决于历史数据量和磁盘速度。'}
|
||||
/>
|
||||
<p role={stage === 'failed' ? 'alert' : 'status'}>{status}</p>
|
||||
{progress?.stage === 'converting' && progress.total > 0 && (
|
||||
<progress aria-label={title} max={progress.total} value={progress.processed} />
|
||||
)}
|
||||
{stage === 'converting' && rate !== undefined && (
|
||||
<p>{en ? 'Processing speed' : '处理速度'}:{Math.round(rate).toLocaleString()} {en ? 'events/s' : '条/秒'}</p>
|
||||
)}
|
||||
<p>{en
|
||||
? 'You can quit and retry on the next launch. Completed batches are preserved.'
|
||||
: '可以退出并在下次启动时继续处理,已完成的批次会保留。'}</p>
|
||||
{actionError && <p role="alert">{actionError}</p>}
|
||||
{stage === 'failed' && (
|
||||
<button className="primary-button" onClick={() => void act('retry')}>
|
||||
{en ? 'Retry' : '重试'}
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-button" onClick={() => void act('quit')}>
|
||||
{en ? 'Quit and continue later' : '退出,稍后继续'}
|
||||
</button>
|
||||
</PageShell>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -111,6 +111,27 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(platformFeaturesTabs).toBeDefined()
|
||||
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('keeps shared segmented controls from collapsing beside scrollable content', () => {
|
||||
const segmentedStyles = stylesheet.match(
|
||||
/\.segmented-control,\s*\.page-tabs--segmented\s*\{(?<rules>[^}]*)\}/u
|
||||
)?.groups?.rules
|
||||
expect(segmentedStyles).toBeDefined()
|
||||
expect(segmentedStyles).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('bounds knowledge library cards before truncating long names', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-list\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-button\s*\{[^}]*min-width:\s*0;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-meta\s*\{[^}]*overflow-wrap:\s*anywhere;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-identity > svg\s*\{[^}]*flex:\s*0 0 auto;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Capabilities and tools tabs visible in scrollable Settings', () => {
|
||||
const capabilitiesTabs = stylesheet.match(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setup(attributes: Record<string, string>, overlaps = true) {
|
||||
const host = document.createElement('div')
|
||||
const parent = document.createElement('div')
|
||||
const overlay = document.createElement('section')
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
overlay.setAttribute(name, value)
|
||||
}
|
||||
parent.append(overlay)
|
||||
document.body.append(host, parent)
|
||||
const viewport = new DOMRect(600, 100, 400, 500)
|
||||
vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(overlaps ? 700 : 10, 200, 200, 100)
|
||||
)
|
||||
return { host, parent, overlay, viewport }
|
||||
}
|
||||
|
||||
describe('native browser overlay detection', () => {
|
||||
it('blocks an application modal even when its surface does not overlap the page', () => {
|
||||
const { host, viewport } = setup({ 'aria-modal': 'true' }, false)
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
|
||||
})
|
||||
it.each(['dialog', 'alertdialog', 'menu'])('detects overlapping %s without requiring modal focus setup', role => {
|
||||
const { host, viewport } = setup({ role })
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
|
||||
})
|
||||
it.each(['hidden', 'display', 'visibility', 'ancestor-display'])('ignores a modal hidden by %s', kind => {
|
||||
const { host, parent, overlay, viewport } = setup({ 'aria-modal': 'true' })
|
||||
if (kind === 'hidden') parent.hidden = true
|
||||
if (kind === 'display') overlay.style.display = 'none'
|
||||
if (kind === 'visibility') overlay.style.visibility = 'hidden'
|
||||
if (kind === 'ancestor-display') parent.style.display = 'none'
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
it('does not treat inline statuses and error messages as overlays', () => {
|
||||
const { host, viewport } = setup({ role: 'alert' })
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
it('does not let a surrounding non-modal container hide its own browser', () => {
|
||||
const { host, overlay, viewport } = setup({ role: 'dialog' })
|
||||
overlay.append(host)
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
export const browserOverlaySelector = [
|
||||
'[aria-modal="true"]',
|
||||
'[role="dialog"]',
|
||||
'[role="alertdialog"]',
|
||||
'[role="menu"]',
|
||||
'.app-notification'
|
||||
].join(', ')
|
||||
|
||||
export function isBrowserViewportOccluded(
|
||||
host: HTMLElement,
|
||||
viewport: DOMRect
|
||||
): boolean {
|
||||
// WebContentsView is above the renderer, regardless of CSS z-index.
|
||||
// Release its viewport, not its session, while renderer overlays need it.
|
||||
return Array.from(
|
||||
host.ownerDocument.querySelectorAll<HTMLElement>(browserOverlaySelector)
|
||||
).some((overlay) => {
|
||||
if (overlay.contains(host) || overlay.closest('[hidden]')) return false
|
||||
const rect = overlay.getBoundingClientRect()
|
||||
if (rect.width < 1 || rect.height < 1) return false
|
||||
const style = getComputedStyle(overlay)
|
||||
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
|
||||
return false
|
||||
}
|
||||
for (let element: HTMLElement | null = overlay; element; element = element.parentElement) {
|
||||
if (getComputedStyle(element).display === 'none') return false
|
||||
}
|
||||
return overlay.getAttribute('aria-modal') === 'true' || (
|
||||
rect.left < viewport.right &&
|
||||
rect.right > viewport.left &&
|
||||
rect.top < viewport.bottom &&
|
||||
rect.bottom > viewport.top
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
.external-knowledge dt { color: var(--text-secondary); }
|
||||
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
|
||||
.external-knowledge > header { overflow-wrap: anywhere; }
|
||||
.external-knowledge > .page-tabs { flex: 0 0 auto; }
|
||||
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import '@fontsource-variable/noto-sans-sc/wght.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './markdown-math.css'
|
||||
import App from './App'
|
||||
import { StorageUpgrade } from './StorageUpgrade'
|
||||
import { installBundledUiFonts } from './fonts'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
@@ -53,7 +54,9 @@ createRoot(root).render(
|
||||
<UiLocaleProvider
|
||||
initialPreference={initialUiLocalePreference}
|
||||
>
|
||||
<App />
|
||||
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
|
||||
? <StorageUpgrade />
|
||||
: <App />}
|
||||
</UiLocaleProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -12731,6 +12731,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.segmented-control,
|
||||
.page-tabs--segmented {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border-control);
|
||||
@@ -13125,6 +13126,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
.knowledge-workspace__library-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 7px;
|
||||
@@ -13133,6 +13135,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
.knowledge-workspace__library-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
@@ -13155,13 +13158,18 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-workspace__library-identity strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.knowledge-workspace__library-identity > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.knowledge-workspace__library-meta {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-body);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const assistantStorageProgressSchema = z.object({
|
||||
stage: z.enum(['scanning', 'converting', 'compacting', 'complete', 'failed']),
|
||||
processed: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
bytesBefore: z.number().nonnegative(),
|
||||
bytesAfter: z.number().nonnegative().optional(),
|
||||
error: z.string().optional()
|
||||
}).strict()
|
||||
|
||||
export type AssistantStorageProgress = z.infer<
|
||||
typeof assistantStorageProgressSchema
|
||||
>
|
||||
export const assistantStorageActionSchema = z.enum(['retry', 'quit'])
|
||||
@@ -1747,6 +1747,10 @@ export type KnowledgeSearchReference = {
|
||||
}
|
||||
|
||||
export type DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
|
||||
act: (action: 'retry' | 'quit') => Promise<void>
|
||||
}
|
||||
app: {
|
||||
getInfo: () => Promise<AppInfo>
|
||||
show: () => Promise<void>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const ipcChannels = {
|
||||
storageUpgradeProgress: 'app:storage-upgrade:progress',
|
||||
storageUpgradeAction: 'app:storage-upgrade:action',
|
||||
externalInstancesList: 'knowledge:external-instances:list',
|
||||
externalInstancesSave: 'knowledge:external-instances:save',
|
||||
externalInstancesTest: 'knowledge:external-instances:test',
|
||||
|
||||
Reference in New Issue
Block a user