Compare commits
1
Commits
agent-v0.13.0
...
v0.13.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a383b2d11f |
+19
-6
@@ -24,7 +24,7 @@ records are listed separately and do not introduce another feature status.
|
||||
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.5. See the
|
||||
releases and automatic updates; no preview is planned for 0.13.6. 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
|
||||
@@ -49,6 +49,13 @@ records are listed separately and do not introduce another feature status.
|
||||
settings panel while Runtime, mode, sending, and queue controls remain
|
||||
available. Conversation search has an inline clear action that restores the
|
||||
scoped list and returns focus to the input.
|
||||
- [x] **On-demand conversation history**: Lists load lightweight summaries
|
||||
rather than every conversation's process metadata. Opened and active
|
||||
conversations retain full details; search, copy, export, continued requests,
|
||||
and remote pending questions remain available. Unchanged image reconciliation
|
||||
does not resend saved messages, and idle acknowledged details follow the
|
||||
existing view-cache lifetime. See the
|
||||
[history loading and retention rules](./docs/features/assistant-workbar/execution-history-storage.md#会话列表读取与前端保留).
|
||||
- [x] **File, screenshot, window, and clipboard context**: Added to model
|
||||
context only after explicit user selection. Paste one or more supported local
|
||||
files into the chat composer with `Ctrl+V` to add attachments, using the same
|
||||
@@ -719,7 +726,7 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- Current source candidates are Desktop `0.13.5` and Agent `0.13.0`, with
|
||||
- Current source candidates are Desktop `0.13.6` and Agent `0.13.0`, with
|
||||
OpenCode pinned to `1.18.29`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Agent `0.13.0` adds remote image-tool integration and shared Runtime processes
|
||||
@@ -735,10 +742,16 @@ records are listed separately and do not introduce another feature status.
|
||||
covered by those records. The candidate still requires main CI and
|
||||
native release packaging. See the [Runtime evidence](./docs/features/assistant-workbar/progress.md)
|
||||
and [image-tool evidence](./docs/features/conversation-media-generation/progress.md).
|
||||
- Candidate checks on 2026-09-15 passed: bilingual release notes, `npm test`
|
||||
(4,293 passed, 67 skipped), `npm run typecheck`, and `npm run lint`.
|
||||
This metadata/documentation preparation made no new real model calls and did
|
||||
not rerun Host scenarios or build local production packages.
|
||||
- Desktop 0.13.6 history fixes passed 4,301 tests with 67 skipped, type checking,
|
||||
scoped lint, isolated production Electron validation, and three paired
|
||||
synthetic-history comparisons. Development validation used three real text
|
||||
calls, including two on the current-source Linux Agent with Session
|
||||
continuation; it did not alter Agent protocols. Release preparation does not
|
||||
repeat local production builds. The exact candidate still requires its
|
||||
main-branch CI and native tag packaging. At the user's request, the local
|
||||
release-candidate rerun was cancelled after release-note verification; CI is
|
||||
the candidate validation authority. Agent 0.13.0 is not republished for this
|
||||
Desktop update.
|
||||
- [x] **0BSD open-source license**: Original code can be freely used, copied,
|
||||
modified, distributed, and commercialized. Third-party components and
|
||||
resources retain their own licenses.
|
||||
|
||||
+4
-3
@@ -16,11 +16,12 @@
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.5` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.6` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时隐藏摘要,长会话子菜单独立滚动,项目选择器继续显示活动数量。
|
||||
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
|
||||
- [x] **会话历史按需加载**:列表只加载轻量摘要,不再一次读入全部会话的过程元数据;已打开和活动会话保留完整详情,搜索、复制、导出、续聊和远程待答问题保持可用。图片合并不会重新发送未变化消息,已确认保存的空闲详情随现有视图缓存释放。详见[历史读取与保留规则](./docs/features/assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。聊天输入框支持 `Ctrl+V` 粘贴一个或多个本地支持文件并自动添加为附件,沿用附件按钮的解析进度与限制;普通文本和截图仍可直接粘贴。详见[聊天附件规则](./docs/features/document-processing/prd.md#321-聊天附件入口)。
|
||||
- [x] **图片型 PPTX 文字识别**:配置本地 OCR 并选择启用 OCR 的解析方式后,可识别内嵌 PNG、JPEG 和 WebP 图片,保留原生文字与幻灯片定位。OCR 在没有活动或排队任务时空闲 60 秒自动释放模型内存,下次按需重载;快速文本/索引模式仍跳过 OCR。详见[解析行为与真实文件验证](./docs/features/document-processing/chat-attachments-technical-design.md)。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。
|
||||
@@ -127,10 +128,10 @@
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前源码候选为 Desktop `0.13.5`、Agent `0.13.0`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- 当前源码候选为 Desktop `0.13.6`、Agent `0.13.0`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- Agent `0.13.0` 接入远程图片工具、共享 Runtime 进程并修复会话恢复,复合包要求 Desktop `0.13.5`;请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。
|
||||
- 验证记录与功能状态分开。存储已有本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;进程复用有 Windows 完整 App 与 Linux x64 Host 证据,PPTX OCR 与空闲内存释放有 Windows 真实文件证据。图片工具已有真实服务商生成/编辑及 Host 传输证据,这些记录未覆盖完整界面自然语言调用和本地/远程切换。候选仍需主分支 CI 及原生发布打包,详见[Runtime 验证记录](./docs/features/assistant-workbar/progress.md)与[图片工具验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
- 2026-09-15 候选检查通过:双语更新说明、`npm test`(4,293 项通过、67 项跳过)、`npm run typecheck`、`npm run lint`。本次版本元数据与文档准备新增真实模型调用为 0,未重跑 Host 场景或进行本地生产打包。
|
||||
- Desktop 0.13.6 历史修复已通过 4,301 项测试(67 项跳过)、类型检查、改动文件 lint、隔离生产 Electron 验证及三组合成历史对照。开发阶段使用 3 次真实文本调用,其中 2 次在当前源码 Linux Agent 上验证 Session 续接,未改变 Agent 协议。发布准备不重跑本地生产构建;按用户要求,本地候选重跑在更新说明校验后取消,交由主分支 CI 验证及原生标签打包。本次 Desktop 更新不重新发布 Agent 0.13.0。
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
|
||||
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务差量存储、远程去重、旧库转换、会话摘要与完整历史按需读取、内存引用释放 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 |
|
||||
| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 |
|
||||
| [Runtime 执行清单技术设计](./runtime-checklist-technical-design.md) | OC/CN 原生清单提取、远程 Agent 传递复用、消息恢复与真实部署验收;尚未实现 |
|
||||
|
||||
@@ -33,6 +33,38 @@
|
||||
的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。
|
||||
释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。
|
||||
|
||||
## 会话列表读取与前端保留
|
||||
|
||||
列表读取与数据库存储是两个不同层次。`messages.metadata_json` 继续保留完整过程,
|
||||
本次读取优化不修改 schema、迁移或用户数据:
|
||||
|
||||
- Renderer 启动及焦点、会话、队列刷新使用 `conversations.listSummaries(detailIds)`。
|
||||
普通未打开会话只返回标题、项目、上下文设置和消息数量/首条角色,不解析或跨 IPC
|
||||
传输正文、工具、子任务及图片操作元数据。
|
||||
- 当前会话、现有 KeepAlive 视图,以及 Main 仍持有请求、数据库中仍有活动 Task 或
|
||||
streaming 消息的会话返回完整消息。保留既有最近 100 条加较早活动会话的列表规则,
|
||||
请求中的已缓存会话即使更早也仍能刷新。
|
||||
- 打开历史通过 `conversations.get(id)` 读取全部消息,不使用会截断历史的分页上限。
|
||||
加载时明确显示进度;失败保留会话与输入,提供原地重试。发送、队列派发和压缩上下文
|
||||
必须先确认完整历史;复制与导出临时读取完整内容,不把未打开会话留入视图缓存。
|
||||
- 搜索由 Main 在 SQLite 中读取标题与正文,不读取过程元数据,保持原来的 Unicode
|
||||
大小写匹配和字面子串规则。范围包含 Renderer 已保留的较早会话;前端同时匹配尚未
|
||||
落库的消息。搜索状态只保留命中的 ID,不额外持有整份历史对象。
|
||||
- KeepAlive 淘汰后,在现有保存周期中释放已经确认落库且空闲的详情及持久化比较引用。
|
||||
未保存消息、正在执行的会话、待答/待审批内容不被淘汰。读取中的请求按会话合并;
|
||||
晚到摘要不能覆盖刚打开的历史或新到的后台审批,摘要附带的实时新增消息仍增量保存。
|
||||
- `messageSummary` 仅为只读列表标记,不进入持久化 header。保存继续使用已有逐消息
|
||||
增量接口,空消息数组不代表删除历史。原有完整 `list()` 与数据库 `getConversation()`
|
||||
接口保留供其他调用方使用。
|
||||
- 图片状态通过同一合并函数处理。本地更新与 Main 图片操作按原有时间优先级合并,
|
||||
未变化消息返回原对象,避免每次列表刷新后把无变化的整个历史当作脏消息再次保存。
|
||||
- 本机和托管 SSH 的 Renderer/Main 读取、持久化与恢复投影共用以上实现。Agent 的
|
||||
transcript、原生 Session、权限及模型桥协议未改变;完整列表、摘要和详情都附带
|
||||
Main 当前恢复请求的问题投影。
|
||||
|
||||
此优化不等于工具折叠内容的延迟渲染,也不保证操作系统立即归还全部 Private Commit。
|
||||
实际测量与适用边界见[实现与验证进度](./progress.md)。
|
||||
|
||||
## 旧库升级
|
||||
|
||||
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
|
||||
|
||||
@@ -1,5 +1,100 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-15 Desktop 0.13.6 发布准备
|
||||
|
||||
用户批准两项性能修复的中英文更新说明,指定 Desktop 0.13.6,不发布 Agent 或
|
||||
LoongArch 预览,并明确排除工作区另外四个应用导航设计/演示文件。
|
||||
`package.json` 与 lockfile 的两处根版本一致,更新说明校验通过。
|
||||
双语功能矩阵已核对并补充按需历史行为;Agent 保持 0.13.0。
|
||||
|
||||
用户随后要求直接交由 CI 发布,本地独立候选的全量验收命令被取消,未记为通过;
|
||||
开发阶段的完整测试及实测结果保留在下一节。候选发布不再进行本地生产构建或
|
||||
打包,主分支 CI 与六平台标签工作流仍为发布验收入口。最终提交、标签与公开
|
||||
产物状态须以远端实际结果核对,不以版本号变更视为已发布。
|
||||
|
||||
## 2026-09-15 会话列表与图片合并性能修复
|
||||
|
||||
本轮针对用户报告的 0.13.5 卡顿与高内存,不修改正在运行的正式应用或数据库。
|
||||
只读检查发现最近会话正文约 1.14 MiB,消息 metadata 约 119.3 MiB;`f8d0741`
|
||||
的图片合并还会重建无变化消息,使焦点刷新后再次保存历史。实现规则见
|
||||
[会话列表读取与前端保留](./execution-history-storage.md#会话列表读取与前端保留)。
|
||||
|
||||
已实现并验证:
|
||||
|
||||
- 图片合并的三个分支共用保留引用的函数。1,600 条无图片历史引用不变;App 使用
|
||||
JSON 往返形状刷新后,保存的消息数为 0,较新的图片操作、成果关联和流式正文仍保留。
|
||||
- 启动、焦点/队列刷新、活动跳转与自定义任务入口均使用摘要或按 ID 读取。
|
||||
搜索保留中文、Unicode 大小写和字面 `%_` 匹配,包含仍显示但已落到最近 100 条以外的
|
||||
会话;真实 SQLite 中 501 条历史仍可完整取回,header-only 保存不删除消息。
|
||||
- 打开、复制、导出、发送及压缩上下文保留完整历史语义。详情读取失败可原地重试;
|
||||
旧摘要不能覆盖晚到的后台审批或刚加载的历史。搜索只保存命中 ID,复制/导出不把
|
||||
未打开详情留入缓存,空闲详情在已确认保存后随现有 KeepAlive 生命周期释放。
|
||||
- 三路 simplify 审查覆盖全部本轮源码,修正了两处残留全量读取、搜索保留大对象、
|
||||
较早会话搜索遗漏及后台审批竞态。保留已发布完整列表接口,无 schema/Agent 协议迁移。
|
||||
- 定向 App、SQLite、IPC/恢复及图片合并测试:**443 通过**。最终 `npm test`:
|
||||
**4,301 通过、67 跳过、0 失败**,约 736.46 秒。`npm run typecheck` 和所有本轮
|
||||
源码文件的 ESLint 通过。全仓 `npm run lint` 仍被本轮范围外的
|
||||
`application-tool-navigation/navigation-demo.js:360` 的 `no-unused-expressions`
|
||||
阻塞,未修改该用户编辑;不能记为全仓 lint 通过。
|
||||
- 以 `f47049c40d757a38296d06d281b87fb6a5fe1c8c` 和本轮 14 个源码/测试文件冻结
|
||||
before/after,两个生产 Electron 构建均成功;验证后哈希核对无源码漂移。
|
||||
- 隔离生产 Electron 的 100 会话/1,600 消息试验:首次摘要读取 0 条消息,仅当前会话
|
||||
详情读取 16 条;搜索第 100 个会话的正文不预载其 metadata,点击后再读取完整 16 条。
|
||||
已检查实际 UI 截图,不是只调用数据库或 Mock。
|
||||
- 另一个隔离 App 正常 UI 打开未加载的旧会话并发送,Main 请求携带原有 2 条历史;
|
||||
**1 次真实直连文本调用**,HTTP 200,最终完整保存准确标记 `HISTORY_RENDERER_7391`。
|
||||
初始等待使用错误 DOM class 超时,随后以保存结果核对成功,未重复发送。
|
||||
- Linux x64 共享 Host 在本轮独立目录构建、安装并激活当前源码 Agent/OpenCode,
|
||||
真实 SQLite 摘要测试通过;**2 次真实无工具文本调用**均完成且 delivered=1,
|
||||
第二轮复用同一原生 Session 并准确记住首轮标记。测试 Agent 停止后独立 `/proc`
|
||||
检查无该目录所属进程。此检查验证当前源码部署与 Session 续接,不冒充远端 UI
|
||||
断线恢复全流程;桌面恢复问题投影由本轮真实 SQLite IPC 回归覆盖。
|
||||
|
||||
本轮真实模型调用共 **3 次文本、0 次图片**,无工具请求,不新增图片生成费用。
|
||||
准备阶段的错误 fixture provider/图片默认字段、独立 profile 缺少原安全存储的
|
||||
加密上下文、Host 源码包漏带 LICENSE 均只修验证脚本;失败记录保留,不计为产品修复。
|
||||
所有脚本与原始证据位于忽略目录
|
||||
`.agent-resources/conversation-performance-20260915/run-KX7bIz/`。
|
||||
|
||||
### 完整 App 配对内存测量
|
||||
|
||||
最终采用三组顺序执行的 before/after 冷启动,共六个独立 profile。每组使用相同
|
||||
100 会话/1,600 消息,正文 1.20 MiB、完整 reasoning metadata 119.69 MiB;
|
||||
执行三次焦点刷新、搜索未打开正文并导航 11 次。使用直连 Runtime 的无网络配置,
|
||||
不启动 OC/DSH、不调用模型;测试与构建已完成后再开始这组测量。
|
||||
|
||||
下表是各三次结果的中位数,内存为 Windows Private Commit,单位 MiB:
|
||||
|
||||
| 指标 | before | after |
|
||||
| --- | ---: | ---: |
|
||||
| 初次传入 Renderer 的完整消息数 | 1,600 | 16 |
|
||||
| 三次无变化刷新保存的消息数 | 4,800 | 0 |
|
||||
| Main 焦点列表读取耗时(ms,不是端到端帧延迟) | 207.28 | 8.05 |
|
||||
| 启动稳定点 App / Renderer | 803.42 / 284.77 | 682.36 / 155.73 |
|
||||
| 三次刷新后 App / Renderer | 1,640.43 / 703.91 | 661.34 / 154.16 |
|
||||
| 导航及缓存释放后 App / Renderer | 895.27 / 557.09 | 574.65 / 248.95 |
|
||||
| App 采样峰值 | 1,788.71 | 694.35 |
|
||||
|
||||
每次 10 个进程样本,定时器间隔 5 秒,另有阶段采样,不代表连续峰值。
|
||||
本数据集采样峰值中位数下降 **61.18%**;before 三次峰值分别为
|
||||
2,506.29 / 1,788.71 / 1,766.39,after 为 694.35 / 693.61 / 719.27。
|
||||
它测的是这两项历史读取/重复保存修复,不含重型 Runtime 的原生内存,不能推广为
|
||||
正式用户整个 App 必然下降相同比例,也不是长时间空闲泄漏证明。
|
||||
|
||||
六个最终 profile 都保留 1,600 条消息,按 ID/角色/状态/正文/完整 reasoning
|
||||
计算的 SHA-256 一致,`quick_check` 均通过;六个 App 均正常退出(code 0)。
|
||||
汇总见 `summary.json`,单轮为 `before-2` 至 `after-4` 的 report/acceptance。
|
||||
|
||||
保留但不纳入三组对照的早期 `before-1`/`after-1`:after 的驱动在清除搜索后未等待
|
||||
延迟筛选收敛,导航失败;退出时还记录 `PostQueuedCompletionStatus` 无效句柄。
|
||||
后续为两个版本都补上可见列表等待,并等本轮采样子进程完成后再正常退出;
|
||||
未唯一定位早期退出错误,也未据此修改产品代码。最终六轮完成后的监督脚本仍因
|
||||
向已退出 CDP 调用 browser close 返回错误;随后不附带 CDP 独立关闭命名浏览器成功。
|
||||
OS 按本轮目录核对无遗留 Electron/Node 进程,原始失败日志未覆盖。
|
||||
|
||||
文档检查覆盖本轮六份文档、81 个相对文件链接,无缺失;未逐一校验所有历史锚点。
|
||||
修改未提交、未推送、未发布。
|
||||
|
||||
## 2026-09-15 Runtime 执行清单设计(未实现)
|
||||
|
||||
对应 FR-CL1–FR-CL4。用户本轮明确只完善设计与进展文档,不改代码;
|
||||
|
||||
@@ -313,9 +313,10 @@ token、global dir、活动 Session 或权限/问题队列。设置替换及窗
|
||||
callback;会话释放才结束其可续读输出和工具资源,不在工具刚完成时删掉用户可查看结果。
|
||||
- 当前 50 个会话的 history 限制不是字节上限。先采堆与 retained size,再决定可从
|
||||
SQLite 重建的历史缓存是否使用字节约束和 LRU,不能先增加一套持久历史副本。
|
||||
- Renderer 最近 100 个会话整包 metadata 加载另做列表摘要、当前会话分页、展开后取
|
||||
详情的性能改造;本方案只记录依赖,不将这一尚未设计完成的 UI 改造算进进程修复交付。
|
||||
必须保持完整历史、搜索、重新打开和当前活动事件可用。
|
||||
- Renderer 整包 metadata 加载是独立于进程复用的性能工作。现有实现改为列表摘要、
|
||||
打开会话时读取完整历史,不截断已保存消息;搜索、复制、续聊、后台活动和缓存释放
|
||||
规则见[会话列表读取与前端保留](./execution-history-storage.md#会话列表读取与前端保留)。
|
||||
不将这项后续修改的收益计入原进程复用对照结果。
|
||||
- 旧 Runtime 磁盘缓存可单独评估版本清理策略,不在本次研究或进程 idle 时删除安装、
|
||||
用户工作区、数据库、备份或历史。
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ Main 从已保存图片 profile 的会话调用开关推导允许模型目录,
|
||||
|
||||
Main 仅在图片和会话引用持久化成功后发布完成事件。素材必须是当前会话可访问的附件或成果,沿用已有项目和会话边界;删除会话后的晚到结果不重建会话。
|
||||
|
||||
会话刷新共用保留对象引用的图片状态合并:无图片或图片状态未变化时,不重建消息并触发
|
||||
整段历史重新保存。未打开历史的读取与缓存释放遵循
|
||||
[会话列表读取与前端保留](../assistant-workbar/execution-history-storage.md#会话列表读取与前端保留),
|
||||
不改变图片操作的 Main 所有权、成果关联或时间优先级。
|
||||
|
||||
## 图片协议
|
||||
|
||||
沿用[图片技术设计](../image-generation/technical-design.md)的已有生成、编辑协议及素材限制。接收并校验有界内联图片,不下载 Provider 返回的图片 URL。沿用现有成果存储限制,实施时核对编码开销与超限反馈,不以配置检查成功代替真实生成验收。视频协议、下载、播放及交付计划均不属于本设计。
|
||||
|
||||
@@ -168,3 +168,20 @@ Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
|
||||
|
||||
**注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要
|
||||
把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。
|
||||
|
||||
## 列表刷新重建历史消息并触发重复保存
|
||||
|
||||
**症状**:焦点刷新后,正文完全未变,却再次序列化并保存全部会话消息;历史越多,
|
||||
前端和 Main 的短时内存、主线程工作量越大。
|
||||
|
||||
**规则**:会话消息的图片状态使用同一保留引用的合并函数,三个合并分支都必须在
|
||||
语义未变时返回选定原对象。列表、活动跳转、任务创建刷新都使用摘要或按 ID 读取,
|
||||
不以完整历史列表作为单条查询。搜索只保留命中 ID;视图淘汰同时释放已确认保存的
|
||||
详情引用,不把摘要误当成完整模型上下文或替换式保存输入。
|
||||
|
||||
**防复发**:App 回归检查 1,600 条历史刷新后保存消息数为 0;真实 SQLite 回归覆盖
|
||||
未加载正文搜索、较早会话、501 条完整消息及 header-only 保存。另覆盖后台审批
|
||||
晚于摘要请求到达、未打开会话复制、加载失败重试及恢复请求的问题投影。
|
||||
|
||||
**历史**:2026-09-15 在 `f8d0741` 引入的三个图片合并分支同时确认消息引用重建;
|
||||
同次检查还发现活动跳转与任务创建继续整包读取历史,均改为共用的摘要/详情读取。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.5",
|
||||
"version": "0.13.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.5",
|
||||
"version": "0.13.6",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.5",
|
||||
"version": "0.13.6",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.13.6",
|
||||
"releasedAt": "2026-09-15",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**历史会话更省内存**:减少打开应用、刷新列表和切换会话时加载的历史数据,降低资源占用。历史详情按需读取,完整记录、全文搜索、复制、导出和续聊保持可用。",
|
||||
"**避免重复保存历史消息**:修复切回应用或刷新会话时,未变化的消息仍被反复保存的问题。减少重复处理,同时保留图片状态更新、后台审批和远程待答问题。"
|
||||
],
|
||||
"notices": [
|
||||
"本次仅更新 Desktop,Agent 保持 **0.13.0**,无需因这两项修复重新更新 Host 环境。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Lower memory use with conversation history**: Reduced the history data loaded when opening the app, refreshing the list, and switching conversations. Details load on demand while complete records, full-text search, copy, export, and continued conversations remain available.",
|
||||
"**Avoid repeatedly saving unchanged messages**: Fixed unchanged messages being saved again when returning to the app or refreshing conversations. This reduces repeated processing while preserving image updates, background approvals, and pending remote questions."
|
||||
],
|
||||
"notices": [
|
||||
"This release updates Desktop only. Agent remains at **0.13.0**, and these fixes do not require another Host environment update."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.13.5",
|
||||
"releasedAt": "2026-09-15",
|
||||
|
||||
@@ -172,6 +172,44 @@ function claimManualScheduleQueueItem(
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('lists lightweight history without dropping searchable text or full process metadata', async () => {
|
||||
const database = await createDatabase()
|
||||
try {
|
||||
const snapshots = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: randomUUID(), title: `History ${index}`, updatedAt: index + 1,
|
||||
messages: Array.from({ length: 16 }, (_, messageIndex) => ({
|
||||
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
||||
content: messageIndex === 0 ? `ÄPFEL 中文 ${index} %_` : 'Body',
|
||||
reasoning: 'Complete retained process history. '.repeat(64),
|
||||
createdAt: index * 100 + messageIndex
|
||||
}))
|
||||
}))
|
||||
database.replaceConversations(snapshots)
|
||||
const summaries = database.listConversationSummaries()
|
||||
expect(summaries).toHaveLength(100)
|
||||
expect(summaries.every(item => item.messages.length === 0 &&
|
||||
item.messageSummary?.count === 16 && item.messageSummary.firstRole === 'assistant')).toBe(true)
|
||||
expect(JSON.stringify(summaries).length).toBeLessThan(JSON.stringify(snapshots).length * 0.02)
|
||||
const id = snapshots[0]!.id
|
||||
const detail = database.listConversationSummaries([id]).find(item => item.id === id)!
|
||||
expect(detail.messageSummary).toBeUndefined()
|
||||
expect(detail.messages).toEqual(snapshots[0]!.messages)
|
||||
expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages)
|
||||
expect(database.searchConversations('äpfel 中文 0 %_')).toEqual([id])
|
||||
expect(database.searchConversations('Complete retained process')).toEqual([])
|
||||
expect(database.searchConversations('history 99')).toEqual([snapshots[99]!.id])
|
||||
expect(database.searchConversations(' ')).toEqual([])
|
||||
const added = { id: randomUUID(), title: 'Newer', updatedAt: 1000 }
|
||||
database.saveLocalConversations([{ header: added, messages: [] }])
|
||||
expect(database.listConversationSummaries().some(item => item.id === id)).toBe(false)
|
||||
expect(database.searchConversations('äpfel 中文 0 %_', [id])).toEqual([id])
|
||||
expect(database.listConversationSummaries([id]).find(item => item.id === id)?.messages).toHaveLength(16)
|
||||
// A header-only save of a summary cannot replace or erase messages.
|
||||
database.saveLocalConversations([{ header: { id, title: 'Renamed', updatedAt: 101 }, messages: [] }])
|
||||
expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => {
|
||||
const database = await createDatabase()
|
||||
vi.useFakeTimers()
|
||||
@@ -5306,6 +5344,8 @@ describe('AssistantDatabase', () => {
|
||||
expect(restored.messages).toHaveLength(501)
|
||||
expect(restored.messages[0]?.id).toBe(messageId(0))
|
||||
expect(database.listConversations().find((item) => item.id === conversationId)?.messages).toHaveLength(501)
|
||||
expect(database.listConversationSummaries().find(item => item.id === conversationId)?.messageSummary?.count).toBe(501)
|
||||
expect(database.listConversationSummaries([conversationId]).find(item => item.id === conversationId)?.messages).toHaveLength(501)
|
||||
expect(restored.messages.at(-1)?.id).toBe(newestMessageId)
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
|
||||
@@ -2618,8 +2618,16 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
listConversations(): ConversationSnapshot[] {
|
||||
return this.readConversationList()
|
||||
}
|
||||
|
||||
listConversationSummaries(detailIds: string[] = []): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
|
||||
return this.readConversationList(new Set(detailIds))
|
||||
}
|
||||
|
||||
private listConversationRows(extraIds: ReadonlySet<string> = new Set()): ConversationRow[] {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = database
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, context_state_json, title, channel,
|
||||
@@ -2633,13 +2641,19 @@ export class AssistantDatabase {
|
||||
id IN (SELECT id FROM conversations WHERE status = 'active'
|
||||
ORDER BY updated_at DESC LIMIT 100)
|
||||
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
|
||||
OR id IN (SELECT value FROM json_each(?))
|
||||
OR EXISTS (SELECT 1 FROM messages
|
||||
WHERE conversation_id = conversations.id
|
||||
AND state = 'streaming')
|
||||
)
|
||||
ORDER BY updated_at DESC`
|
||||
)
|
||||
.all() as ConversationRow[]
|
||||
.all(JSON.stringify([...extraIds])) as ConversationRow[]
|
||||
}
|
||||
|
||||
private readConversationList(detailIds?: ReadonlySet<string>): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = this.listConversationRows(detailIds)
|
||||
const messageStatement = database.prepare(
|
||||
`SELECT id, conversation_id, role, content, state, metadata_json,
|
||||
created_at
|
||||
@@ -2652,12 +2666,44 @@ export class AssistantDatabase {
|
||||
)
|
||||
ORDER BY sequence ASC`
|
||||
)
|
||||
return conversations.map((conversation) =>
|
||||
toConversationSnapshot(
|
||||
conversation,
|
||||
messageStatement.all(conversation.id) as MessageRow[]
|
||||
)
|
||||
const summaryStatement = database.prepare(
|
||||
`SELECT count(*) AS count,
|
||||
max(state = 'streaming') AS streaming,
|
||||
(SELECT role FROM messages WHERE conversation_id = ? ORDER BY sequence LIMIT 1) AS firstRole
|
||||
FROM messages WHERE conversation_id = ?`
|
||||
)
|
||||
const activeIds = new Set(
|
||||
(database.prepare(`SELECT conversation_id FROM (${activeVisibleTaskSelect})`).all() as
|
||||
{ conversation_id: string }[]).map(row => row.conversation_id)
|
||||
)
|
||||
return conversations.map((conversation) => {
|
||||
if (detailIds && !detailIds.has(conversation.id) && !activeIds.has(conversation.id)) {
|
||||
const summary = summaryStatement.get(conversation.id, conversation.id) as {
|
||||
count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null
|
||||
}
|
||||
if (!summary.streaming) {
|
||||
return {
|
||||
...toConversationSnapshot(conversation, []),
|
||||
messageSummary: { count: summary.count, firstRole: summary.firstRole ?? undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
return toConversationSnapshot(conversation, messageStatement.all(conversation.id) as MessageRow[])
|
||||
})
|
||||
}
|
||||
|
||||
searchConversations(query: string, conversationIds: string[] = []): string[] {
|
||||
const normalized = query.trim().toLocaleLowerCase()
|
||||
if (!normalized) return []
|
||||
const database = this.requireDatabase()
|
||||
// Read only searchable text, never tool/subagent/image metadata. Use the
|
||||
// same Unicode case folding as the renderer's existing search.
|
||||
const messages = database.prepare('SELECT content FROM messages WHERE conversation_id = ?')
|
||||
return this.listConversationRows(new Set(conversationIds)).filter(conversation =>
|
||||
conversation.title.toLocaleLowerCase().includes(normalized) ||
|
||||
(messages.all(conversation.id) as { content: string }[]).some(message =>
|
||||
message.content.toLocaleLowerCase().includes(normalized))
|
||||
).map(conversation => conversation.id)
|
||||
}
|
||||
|
||||
getConversation(conversationId: string): ConversationSnapshot {
|
||||
|
||||
@@ -5073,6 +5073,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
>(() => true),
|
||||
appendRemoteConversationTaskEventOnce: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true),
|
||||
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
|
||||
listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []),
|
||||
listRecoverableRemoteTasks: vi.fn<
|
||||
() => Array<{
|
||||
taskId: string
|
||||
@@ -6800,6 +6801,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})
|
||||
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
|
||||
harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database))
|
||||
harness.assistantDatabase.listConversationSummaries.mockImplementation(database.listConversationSummaries.bind(database))
|
||||
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
|
||||
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
|
||||
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
|
||||
@@ -6813,6 +6815,11 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
harness.recoveryGetHandler?.(event)
|
||||
await vi.waitFor(() => expect(ready.size).toBe(2))
|
||||
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
|
||||
const summaries = electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
|
||||
expect(summaries).toEqual(list())
|
||||
for (const snapshot of summaries) {
|
||||
expect(electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot)
|
||||
}
|
||||
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
||||
const update = settingsKind === 'runtime'
|
||||
? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({
|
||||
|
||||
+25
-3
@@ -203,6 +203,9 @@ import {
|
||||
activityHistorySnapshotSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotsSchema,
|
||||
conversationListRequestSchema,
|
||||
conversationSearchRequestSchema,
|
||||
type ConversationSnapshot,
|
||||
localConversationSaveBatchSchema,
|
||||
memoryCreateSchema,
|
||||
normalizeInteractiveWorkMode,
|
||||
@@ -6761,8 +6764,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.conversationsList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
const projectConversationRequests = <T extends ConversationSnapshot>(conversations: T[]): T[] => {
|
||||
const questions = new Map<string, Extract<AgentEvent, { type: 'question' }>[]>()
|
||||
for (const pending of pendingAgentQuestions.values()) {
|
||||
const entries = questions.get(pending.requestId) ?? []
|
||||
@@ -6780,10 +6782,30 @@ export function registerIpcHandlers(
|
||||
: []
|
||||
)
|
||||
)
|
||||
return assistantDatabase.listConversations().map(conversation => ({
|
||||
return conversations.map(conversation => ({
|
||||
...conversation,
|
||||
...(recovered.has(conversation.id) ? { activeRequest: recovered.get(conversation.id) } : {})
|
||||
}))
|
||||
}
|
||||
registerHandler(ipcChannels.conversationsList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return projectConversationRequests(assistantDatabase.listConversations())
|
||||
})
|
||||
registerHandler(ipcChannels.conversationsListSummaries, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { detailIds } = conversationListRequestSchema.parse(input)
|
||||
return projectConversationRequests(assistantDatabase.listConversationSummaries([
|
||||
...detailIds, ...[...activeRequests.values()].map(request => request.conversationId)
|
||||
]))
|
||||
})
|
||||
registerHandler(ipcChannels.conversationsGet, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return projectConversationRequests([assistantDatabase.getConversation(assistantIdSchema.parse(input))])[0]
|
||||
})
|
||||
registerHandler(ipcChannels.conversationsSearch, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { query, conversationIds } = conversationSearchRequestSchema.parse(input)
|
||||
return assistantDatabase.searchConversations(query, conversationIds)
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
|
||||
@@ -1070,6 +1070,12 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.conversationsList
|
||||
) as Promise<ConversationSnapshot[]>,
|
||||
listSummaries: (detailIds = []) =>
|
||||
ipcRenderer.invoke(ipcChannels.conversationsListSummaries, { detailIds }),
|
||||
get: (conversationId) =>
|
||||
ipcRenderer.invoke(ipcChannels.conversationsGet, conversationId),
|
||||
search: (query, conversationIds = []) =>
|
||||
ipcRenderer.invoke(ipcChannels.conversationsSearch, { query, conversationIds }),
|
||||
replace: async (conversations: ConversationSnapshot[]) => {
|
||||
await ipcRenderer.invoke(
|
||||
ipcChannels.conversationsReplace,
|
||||
|
||||
@@ -489,6 +489,11 @@ const api: DesktopApi = {
|
||||
onChanged: vi.fn(() => () => undefined),
|
||||
},
|
||||
list: vi.fn(async () => []),
|
||||
// Existing full-history fixtures remain valid list projections. The lazy
|
||||
// history regressions below explicitly supply summaries and detail replies.
|
||||
listSummaries: vi.fn(async () => api.conversations.list()),
|
||||
get: vi.fn(),
|
||||
search: vi.fn(async () => []),
|
||||
replace: vi.fn(async () => {}),
|
||||
saveLocal: vi.fn(async () => {}),
|
||||
branchLocal: vi.fn(async (input) => ({
|
||||
@@ -1086,8 +1091,34 @@ describe("App", () => {
|
||||
};
|
||||
});
|
||||
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(api.conversations.replace).mockReset().mockResolvedValue();
|
||||
vi.mocked(api.conversations.saveLocal).mockReset().mockResolvedValue();
|
||||
const conversationFixtures = new Map<string, Awaited<ReturnType<DesktopApi["conversations"]["get"]>>>();
|
||||
vi.mocked(api.conversations.listSummaries).mockReset()
|
||||
.mockImplementation(async () => {
|
||||
const snapshots = await api.conversations.list();
|
||||
snapshots.forEach(snapshot => conversationFixtures.set(snapshot.id, snapshot));
|
||||
return snapshots;
|
||||
});
|
||||
vi.mocked(api.conversations.get).mockReset().mockImplementation(async id => {
|
||||
const snapshot = conversationFixtures.get(id);
|
||||
if (!snapshot) throw new Error("Missing conversation fixture");
|
||||
return snapshot;
|
||||
});
|
||||
vi.mocked(api.conversations.search).mockReset().mockImplementation(async query =>
|
||||
[...conversationFixtures.values()].filter(snapshot =>
|
||||
snapshot.title.toLocaleLowerCase().includes(query) ||
|
||||
snapshot.messages.some(message => message.content.toLocaleLowerCase().includes(query))
|
||||
).map(snapshot => snapshot.id));
|
||||
vi.mocked(api.conversations.replace).mockReset().mockImplementation(async snapshots => {
|
||||
snapshots.forEach(snapshot => conversationFixtures.set(snapshot.id, snapshot));
|
||||
});
|
||||
vi.mocked(api.conversations.saveLocal).mockReset().mockImplementation(async batch => {
|
||||
for (const { header, messages } of batch) {
|
||||
const existing = conversationFixtures.get(header.id);
|
||||
const byId = new Map(existing?.messages.map(message => [message.id, message]));
|
||||
messages.forEach(message => byId.set(message.id, message));
|
||||
conversationFixtures.set(header.id, { ...header, messages: [...byId.values()] });
|
||||
}
|
||||
});
|
||||
vi.mocked(api.conversations.branchLocal)
|
||||
.mockReset()
|
||||
.mockImplementation(async (input) => ({
|
||||
@@ -1529,6 +1560,81 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads only the opened history and saves no unchanged messages after a real-shaped list refresh", async () => {
|
||||
const snapshots = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: crypto.randomUUID(), projectId, title: `History ${index}`, updatedAt: 100 - index,
|
||||
messages: Array.from({ length: 16 }, (_, messageIndex) => ({
|
||||
id: crypto.randomUUID(), role: "assistant" as const, state: "complete" as const,
|
||||
content: `History body ${index}/${messageIndex}`, createdAt: messageIndex,
|
||||
reasoning: "Retained process output".repeat(100),
|
||||
})),
|
||||
}));
|
||||
vi.mocked(api.conversations.listSummaries).mockImplementation(async (detailIds = []) =>
|
||||
JSON.parse(JSON.stringify(snapshots.map(snapshot => detailIds.includes(snapshot.id)
|
||||
? snapshot : { ...snapshot, messages: [], messageSummary: { count: 16, firstRole: "assistant" } }))));
|
||||
vi.mocked(api.conversations.get).mockImplementation(async id => snapshots.find(snapshot => snapshot.id === id)!);
|
||||
render(<App />);
|
||||
expect(await screen.findByText("History body 0/15")).toBeInTheDocument();
|
||||
expect(api.conversations.get).toHaveBeenCalledExactlyOnceWith(snapshots[0]!.id);
|
||||
expect(api.conversations.list).not.toHaveBeenCalled();
|
||||
vi.mocked(api.conversations.saveLocal).mockClear();
|
||||
fireEvent(window, new Event("focus"));
|
||||
await waitFor(() => expect(api.conversations.listSummaries).toHaveBeenCalledTimes(2));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 650)); });
|
||||
const saved = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
|
||||
.flatMap(item => item.messages);
|
||||
expect(saved).toHaveLength(0);
|
||||
expect(api.conversations.get).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("History body 99/15")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches unloaded history and retries a failed detail load without losing the conversation", async () => {
|
||||
const first = { id: crypto.randomUUID(), projectId, title: "Opened", updatedAt: 2, messages: [
|
||||
{ id: crypto.randomUUID(), role: "assistant" as const, state: "complete" as const, content: "Opened body", createdAt: 1 },
|
||||
] };
|
||||
const second = { id: crypto.randomUUID(), projectId, title: "Unopened", updatedAt: 1, messages: [
|
||||
{ id: crypto.randomUUID(), role: "user" as const, state: "complete" as const, content: "Rare searchable history", createdAt: 1 },
|
||||
] };
|
||||
vi.mocked(api.conversations.listSummaries).mockResolvedValue([first, {
|
||||
...second, messages: [], messageSummary: { count: 1, firstRole: "user" },
|
||||
}]);
|
||||
vi.mocked(api.conversations.search).mockResolvedValue([second.id]);
|
||||
vi.mocked(api.conversations.get).mockRejectedValueOnce(new Error("Temporary history read failure"))
|
||||
.mockResolvedValueOnce(second);
|
||||
const { container } = render(<App />);
|
||||
expect(await screen.findByText("Opened body")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "rare searchable" } });
|
||||
const list = within(container.querySelector<HTMLElement>(".conversation-list")!);
|
||||
fireEvent.click(await list.findByText("Unopened"));
|
||||
expect(await screen.findByText("Temporary history read failure")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Rare searchable history")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新加载" }));
|
||||
expect(await screen.findByText("Rare searchable history")).toBeInTheDocument();
|
||||
expect(api.conversations.get).toHaveBeenNthCalledWith(1, second.id);
|
||||
expect(api.conversations.get).toHaveBeenNthCalledWith(2, second.id);
|
||||
expect(api.conversations.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies a complete unopened conversation instead of exporting its empty summary", async () => {
|
||||
const first = { id: crypto.randomUUID(), projectId, title: "Opened", updatedAt: 2, messages: [] };
|
||||
const second = { id: crypto.randomUUID(), projectId, title: "Copy unopened", updatedAt: 1, messages: [
|
||||
{ id: crypto.randomUUID(), role: "user" as const, state: "complete" as const, content: "All retained text", createdAt: 1 },
|
||||
] };
|
||||
vi.mocked(api.conversations.listSummaries).mockResolvedValue([first, {
|
||||
...second, messages: [], messageSummary: { count: 1, firstRole: "user" },
|
||||
}]);
|
||||
vi.mocked(api.conversations.get).mockResolvedValue(second);
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByLabelText("更多会话操作 Copy unopened"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
|
||||
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("All retained text")));
|
||||
expect(api.conversations.get).toHaveBeenCalledExactlyOnceWith(second.id);
|
||||
fireEvent.click(screen.getByLabelText("更多会话操作 Copy unopened"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
|
||||
await waitFor(() => expect(api.conversations.get).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("provides custom minimize, maximize, and close controls", async () => {
|
||||
const { unmount } = render(<App />);
|
||||
|
||||
@@ -2234,6 +2340,43 @@ describe("App", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves a scheduled approval arriving after an unopened summary refresh began", async () => {
|
||||
const taskId = crypto.randomUUID();
|
||||
const target = {
|
||||
id: crypto.randomUUID(), projectId, title: "Unopened scheduled history", updatedAt: 1,
|
||||
messages: [{ id: crypto.randomUUID(), role: "user" as const, state: "complete" as const,
|
||||
content: "Retained scheduled history", createdAt: 1 }],
|
||||
};
|
||||
const snapshots = [
|
||||
{ id: crypto.randomUUID(), projectId, title: "Opened history", updatedAt: 2, messages: [] },
|
||||
{ ...target, messages: [], messageSummary: { count: 1, firstRole: "user" as const } },
|
||||
];
|
||||
vi.mocked(api.conversations.listSummaries).mockResolvedValueOnce(snapshots);
|
||||
let finishRefresh: ((value: typeof snapshots) => void) | undefined;
|
||||
vi.mocked(api.conversations.listSummaries).mockImplementationOnce(() => new Promise(resolve => { finishRefresh = resolve; }));
|
||||
vi.mocked(api.conversations.get).mockResolvedValue(target);
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{
|
||||
id: taskId, projectId, conversationId: target.id, origin: "schedule", status: "running",
|
||||
title: "Scheduled approval", instructions: "Ask before writing", createdAt: "2026-09-15T00:00:00Z",
|
||||
}]);
|
||||
const { container } = render(<App />);
|
||||
await screen.findAllByText("Opened history");
|
||||
fireEvent(window, new Event("focus"));
|
||||
await waitFor(() => expect(finishRefresh).toBeDefined());
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: taskId, type: "approval", approvalId: crypto.randomUUID(),
|
||||
title: "Keep this approval", description: "A scheduled write", toolName: "write_file",
|
||||
argumentSummary: "test.txt", allowPermanent: false,
|
||||
});
|
||||
});
|
||||
await act(async () => { finishRefresh!(snapshots); });
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!).getByText(target.title));
|
||||
expect(await screen.findByText("Retained scheduled history")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "仅此次" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes scheduled Task approvals to the associated Conversation", async () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000831";
|
||||
const taskId = "00000000-0000-4000-8000-000000000832";
|
||||
|
||||
+217
-39
@@ -99,6 +99,7 @@ import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionForProvider,
|
||||
} from "./runtime-selection";
|
||||
import { mergeMessageImageState } from "./message-image-state";
|
||||
import type {
|
||||
ActivityHistorySnapshot,
|
||||
AssistantProject,
|
||||
@@ -116,6 +117,7 @@ import type {
|
||||
ConversationMessage,
|
||||
ConversationQueueItem,
|
||||
ConversationSnapshot,
|
||||
ConversationListSnapshot,
|
||||
ConversationAttachment,
|
||||
ConversationContextCompressionMarker,
|
||||
ConversationMessageBlock,
|
||||
@@ -490,7 +492,7 @@ function supportsSubagentSmartRouting(workMode: string): boolean {
|
||||
return workMode === "ask";
|
||||
}
|
||||
|
||||
type Conversation = Omit<ConversationSnapshot, "messages"> & {
|
||||
type Conversation = Omit<ConversationListSnapshot, "messages"> & {
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
@@ -781,8 +783,8 @@ function ConversationBranchBadge({
|
||||
function isUnusedConversation(conversation: Conversation): boolean {
|
||||
return (
|
||||
conversation.title === "新对话" &&
|
||||
conversation.messages.length === 1 &&
|
||||
conversation.messages[0]?.role === "assistant"
|
||||
(conversation.messageSummary?.count ?? conversation.messages.length) === 1 &&
|
||||
(conversation.messageSummary?.firstRole ?? conversation.messages[0]?.role) === "assistant"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -804,6 +806,30 @@ type ChatScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
function ConversationHistoryLoader({ conversationId, active, load }: {
|
||||
conversationId: string;
|
||||
active: boolean;
|
||||
load: (id: string) => Promise<Conversation>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation("app");
|
||||
const [error, setError] = useState<string>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void load(conversationId).catch(reason => {
|
||||
if (mounted) setError(displayErrorMessage(reason, t("conversation.historyLoadFailed")));
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [conversationId, load, attempt, t]);
|
||||
return <section hidden={!active} className="empty-state" aria-busy={!error}>
|
||||
<p role={error ? "alert" : "status"}>{error ?? t("conversation.historyLoading")}</p>
|
||||
{error && <button type="button" className="secondary-button" onClick={() => {
|
||||
setError(undefined);
|
||||
setAttempt(value => value + 1);
|
||||
}}>{t("conversation.historyRetry")}</button>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ChatHistoryPane({
|
||||
onOpenImageModelSettings,
|
||||
onReselectImageSources,
|
||||
@@ -1464,8 +1490,9 @@ function withRecoveredQuestions(conversation: Conversation): Conversation {
|
||||
|
||||
function mergePersistedConversations(
|
||||
current: readonly Conversation[],
|
||||
incoming: readonly ConversationSnapshot[],
|
||||
incoming: readonly ConversationListSnapshot[],
|
||||
persistedLocal: Map<string, Conversation>,
|
||||
retainDetails: ReadonlySet<string> = new Set(),
|
||||
): Conversation[] {
|
||||
const incomingById = new Map(
|
||||
incoming.map((conversation) => [conversation.id, conversation]),
|
||||
@@ -1474,6 +1501,28 @@ function mergePersistedConversations(
|
||||
current.map((conversation) => [conversation.id, conversation]),
|
||||
);
|
||||
const merged = incoming.map((conversation): Conversation => {
|
||||
if (conversation.messageSummary) {
|
||||
const local = currentById.get(conversation.id);
|
||||
const acknowledged = persistedLocal.get(conversation.id);
|
||||
const dirtyMessages = local &&
|
||||
local.messages.some(message => !acknowledged?.messages.includes(message));
|
||||
const keepMessages = local && (dirtyMessages ||
|
||||
local.messages.some(message => message.approval || message.pendingQuestions?.length || message.state === "streaming") ||
|
||||
(!local.messageSummary && retainDetails.has(conversation.id)));
|
||||
const next = local && local.updatedAt > conversation.updatedAt
|
||||
? { ...local, messageSummary: conversation.messageSummary, messages: [] }
|
||||
: conversation;
|
||||
if (keepMessages) {
|
||||
// A list reply started before navigation must not unload the newly
|
||||
// opened history, or any messages not acknowledged by Main yet.
|
||||
return {
|
||||
...next, messages: local.messages,
|
||||
messageSummary: local.messageSummary ? conversation.messageSummary : undefined,
|
||||
};
|
||||
}
|
||||
if (!conversation.remote) persistedLocal.set(conversation.id, conversation);
|
||||
return next;
|
||||
}
|
||||
if (conversation.remote) {
|
||||
return conversation;
|
||||
}
|
||||
@@ -1492,24 +1541,14 @@ function mergePersistedConversations(
|
||||
const messages = [
|
||||
...conversation.messages.map((persistedMessage) => {
|
||||
const localMessage = localMessageById.get(persistedMessage.id);
|
||||
const operations = new Map(persistedMessage.imageOperations?.map(operation => [operation.id, operation]));
|
||||
for (const operation of localMessage?.imageOperations ?? []) {
|
||||
if (operation.updatedAt > (operations.get(operation.id)?.updatedAt ?? -1)) operations.set(operation.id, operation);
|
||||
}
|
||||
const imageMetadata = {
|
||||
imageOperations: operations.size ? [...operations.values()] : undefined,
|
||||
imageSourceArtifactIds: persistedMessage.imageSourceArtifactIds ?? localMessage?.imageSourceArtifactIds,
|
||||
artifactIds: [...new Set([...(persistedMessage.artifactIds ?? []),
|
||||
...[...operations.values()].flatMap(operation => operation.artifactIds)])].slice(-8),
|
||||
};
|
||||
const message = { ...persistedMessage, ...imageMetadata };
|
||||
const message = mergeMessageImageState(persistedMessage, persistedMessage, localMessage);
|
||||
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
|
||||
return { ...message, pendingQuestions: undefined };
|
||||
}
|
||||
if (!localIsNewer) {
|
||||
// Pending prompts are live-only and are omitted from persisted messages.
|
||||
if (localMessage?.state === "streaming" && message.state === "streaming") {
|
||||
return { ...localMessage, ...imageMetadata };
|
||||
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -1519,13 +1558,12 @@ function mergePersistedConversations(
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
return { ...localMessage, ...imageMetadata,
|
||||
artifactIds: [...new Set([...(localMessage.artifactIds ?? []), ...(message.artifactIds ?? [])])].slice(-8) };
|
||||
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
|
||||
}),
|
||||
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
|
||||
];
|
||||
const next = localIsNewer
|
||||
? { ...local, messages, activeRequest: conversation.activeRequest }
|
||||
? { ...local, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
|
||||
: { ...conversation, messages };
|
||||
persistedLocal.set(conversation.id, conversation);
|
||||
return withRecoveredQuestions(next);
|
||||
@@ -2362,6 +2400,10 @@ function App(): React.JSX.Element {
|
||||
const [cachedConversationViews, setCachedConversationViews] = useState<
|
||||
KeepAliveCacheEntry<string>[]
|
||||
>(() => (activeId ? [{ key: activeId, lastVisitedAt: Date.now() }] : []));
|
||||
const cachedConversationViewsRef = useRef(cachedConversationViews);
|
||||
useLayoutEffect(() => {
|
||||
cachedConversationViewsRef.current = cachedConversationViews;
|
||||
}, [cachedConversationViews]);
|
||||
const commitView = useCallback((next: WorkspaceView): void => {
|
||||
const previous = viewRef.current;
|
||||
const now = Date.now();
|
||||
@@ -2510,8 +2552,10 @@ function App(): React.JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [searchConversationSnapshot, setSearchConversationSnapshot] =
|
||||
useState(conversations);
|
||||
const [localSearchMatches, setLocalSearchMatches] =
|
||||
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
|
||||
const [persistedSearchMatches, setPersistedSearchMatches] =
|
||||
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
|
||||
const [conversationLoadError, setConversationLoadError] = useState<string>();
|
||||
const [conversationLoadRetry, setConversationLoadRetry] = useState(0);
|
||||
const [conversationActionsId, setConversationActionsId] = useState("");
|
||||
@@ -2992,12 +3036,34 @@ function App(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(
|
||||
() => setSearchConversationSnapshot(conversations),
|
||||
() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
setLocalSearchMatches({ query, ids: new Set(conversations.filter(conversation =>
|
||||
conversation.messages.some(message => message.content.toLocaleLowerCase().includes(query))
|
||||
).map(conversation => conversation.id)) });
|
||||
},
|
||||
conversationSearchSnapshotDelayMs,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [conversations, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = deferredSearchQuery.trim().toLocaleLowerCase();
|
||||
if (!query || !conversationStoreReady) return;
|
||||
let active = true;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void window.goodbuddy.conversations.search(query, conversationsRef.current.map(item => item.id)).then(ids => {
|
||||
if (active) setPersistedSearchMatches({ query, ids: new Set(ids) });
|
||||
}).catch(() => {
|
||||
if (active) notify({
|
||||
tone: "error", message: tRef.current("notices.remoteConversationRefreshFailed"),
|
||||
dedupeKey: "conversation-search",
|
||||
});
|
||||
});
|
||||
}, conversationSearchSnapshotDelayMs);
|
||||
return () => { active = false; window.clearTimeout(timeout); };
|
||||
}, [deferredSearchQuery, conversationStoreReady, localSearchMatches]);
|
||||
|
||||
useEffect(() => {
|
||||
projectsRef.current = projects;
|
||||
}, [projects]);
|
||||
@@ -3752,24 +3818,23 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = deferredSearchQuery.trim().toLocaleLowerCase();
|
||||
const candidates = query ? searchConversationSnapshot : conversations;
|
||||
return candidates.filter(
|
||||
return conversations.filter(
|
||||
(conversation) =>
|
||||
(!activeProjectId || conversation.projectId === activeProjectId) &&
|
||||
(activeProject?.kind !== "channel" ||
|
||||
conversation.remote !== undefined) &&
|
||||
(!query ||
|
||||
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
|
||||
conversation.title.toLocaleLowerCase().includes(query) ||
|
||||
conversation.messages.some((message) =>
|
||||
message.content.toLocaleLowerCase().includes(query),
|
||||
)),
|
||||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
|
||||
);
|
||||
}, [
|
||||
activeProject,
|
||||
activeProjectId,
|
||||
conversations,
|
||||
deferredSearchQuery,
|
||||
searchConversationSnapshot,
|
||||
localSearchMatches,
|
||||
persistedSearchMatches,
|
||||
]);
|
||||
const productAssistantTasks = useMemo(
|
||||
() =>
|
||||
@@ -4994,6 +5059,38 @@ function App(): React.JSX.Element {
|
||||
viewRef.current = view;
|
||||
}, [view]);
|
||||
|
||||
const retainedConversationDetailIds = useCallback((): Set<string> => new Set([
|
||||
activeConversationIdRef.current,
|
||||
...cachedConversationViewsRef.current.map(entry => entry.key),
|
||||
...[...activeRuns.current.values()].map(run => run.conversationId),
|
||||
...preparingConversations.current,
|
||||
].filter(Boolean)), []);
|
||||
const conversationHistoryRequests = useRef(new Map<string, Promise<Conversation>>());
|
||||
const releaseUnretainedConversationHistories = useCallback((): void => {
|
||||
const retained = retainedConversationDetailIds();
|
||||
setConversations(current => {
|
||||
let changed = false;
|
||||
const next = current.map(conversation => {
|
||||
if (conversation.messageSummary || retained.has(conversation.id) ||
|
||||
conversationHistoryRequests.current.has(conversation.id) ||
|
||||
conversation.activeRequest ||
|
||||
(!conversation.remote && persistedLocalConversationsRef.current.get(conversation.id) !== conversation) ||
|
||||
conversation.messages.some(message => message.state === "streaming" ||
|
||||
message.approval || message.pendingQuestions?.length) ||
|
||||
assistantTasksRef.current.some(task => task.conversationId === conversation.id &&
|
||||
(task.status === "running" || task.status === "waiting_approval"))) return conversation;
|
||||
changed = true;
|
||||
const summary: Conversation = {
|
||||
...conversation, messages: [],
|
||||
messageSummary: { count: conversation.messages.length, firstRole: conversation.messages[0]?.role },
|
||||
};
|
||||
if (!conversation.remote) persistedLocalConversationsRef.current.set(conversation.id, summary);
|
||||
return summary;
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [retainedConversationDetailIds]);
|
||||
|
||||
const persistLocalConversationChanges = useCallback((): void => {
|
||||
const operation = conversationPersistenceQueueRef.current.then(async () => {
|
||||
if (conversationPersistencePausedRef.current) {
|
||||
@@ -5005,6 +5102,7 @@ function App(): React.JSX.Element {
|
||||
deletingLocalConversationIdsRef.current,
|
||||
);
|
||||
if (batch.length === 0) {
|
||||
releaseUnretainedConversationHistories();
|
||||
return;
|
||||
}
|
||||
await window.goodbuddy.conversations.saveLocal(batch);
|
||||
@@ -5014,6 +5112,7 @@ function App(): React.JSX.Element {
|
||||
conversation,
|
||||
);
|
||||
}
|
||||
releaseUnretainedConversationHistories();
|
||||
});
|
||||
conversationPersistenceQueueRef.current = operation.catch(() => undefined);
|
||||
void operation.catch(() => {
|
||||
@@ -5023,7 +5122,37 @@ function App(): React.JSX.Element {
|
||||
dedupeKey: "conversation-persistence",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [releaseUnretainedConversationHistories]);
|
||||
const ensureConversationHistory = useCallback((conversationId: string): Promise<Conversation> => {
|
||||
const current = conversationsRef.current.find(item => item.id === conversationId);
|
||||
if (!current) return Promise.reject(new Error(tRef.current("notices.remoteConversationRefreshFailed")));
|
||||
if (!current.messageSummary) return Promise.resolve(current);
|
||||
const pending = conversationHistoryRequests.current.get(conversationId);
|
||||
if (pending) return pending;
|
||||
const request = window.goodbuddy.conversations.get(conversationId).then(snapshot => {
|
||||
const latest = conversationsRef.current.find(item => item.id === conversationId);
|
||||
if (!latest || deletingLocalConversationIdsRef.current.has(conversationId)) {
|
||||
throw new Error(tRef.current("notices.remoteConversationRefreshFailed"));
|
||||
}
|
||||
if (!latest.messageSummary) return latest;
|
||||
const next = mergePersistedConversations(
|
||||
conversationsRef.current, [snapshot], persistedLocalConversationsRef.current,
|
||||
retainedConversationDetailIds(),
|
||||
);
|
||||
conversationsRef.current = next;
|
||||
setConversations(next);
|
||||
return next.find(item => item.id === conversationId)!;
|
||||
}).finally(() => { conversationHistoryRequests.current.delete(conversationId); });
|
||||
conversationHistoryRequests.current.set(conversationId, request);
|
||||
return request;
|
||||
}, [retainedConversationDetailIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationStoreReady) return;
|
||||
// Reuse the view cache's lifetime. Release only acknowledged, idle history,
|
||||
// including its persistence reference, never unsaved or executing messages.
|
||||
persistLocalConversationChanges();
|
||||
}, [cachedConversationViews, conversationStoreReady, persistLocalConversationChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationStoreReady) {
|
||||
@@ -5186,7 +5315,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
});
|
||||
const conversationsRefresh = window.goodbuddy.conversations
|
||||
.list()
|
||||
.listSummaries([...retainedConversationDetailIds()])
|
||||
.then((persisted) => {
|
||||
if (!active || sequence !== refreshSequence) {
|
||||
return;
|
||||
@@ -5230,6 +5359,7 @@ function App(): React.JSX.Element {
|
||||
current,
|
||||
persisted,
|
||||
persistedLocalConversationsRef.current,
|
||||
retainedConversationDetailIds(),
|
||||
),
|
||||
);
|
||||
settleActiveRunsFromPersistedMessages(persisted);
|
||||
@@ -5277,6 +5407,7 @@ function App(): React.JSX.Element {
|
||||
}, [
|
||||
conversationStoreReady,
|
||||
releaseConversationQueueAfterRun,
|
||||
retainedConversationDetailIds,
|
||||
setConversationActivity,
|
||||
]);
|
||||
|
||||
@@ -5589,7 +5720,7 @@ function App(): React.JSX.Element {
|
||||
let active = true;
|
||||
const initialization = Promise.all([
|
||||
window.goodbuddy.projects.list(false),
|
||||
window.goodbuddy.conversations.list(),
|
||||
window.goodbuddy.conversations.listSummaries(),
|
||||
]).then(async ([value, persistedConversations]) => {
|
||||
if (!active || value.length === 0) {
|
||||
return;
|
||||
@@ -5600,6 +5731,11 @@ function App(): React.JSX.Element {
|
||||
throw new Error("没有可用的本地项目");
|
||||
}
|
||||
setActiveProjectId(project.id);
|
||||
const initialConversation = persistedConversations.find(item => item.projectId === project.id);
|
||||
if (initialConversation?.messageSummary) {
|
||||
const detail = await window.goodbuddy.conversations.get(initialConversation.id);
|
||||
persistedConversations = persistedConversations.map(item => item.id === detail.id ? detail : item);
|
||||
}
|
||||
const persistedLocalConversations = persistedConversations.filter(
|
||||
(conversation) => !conversation.remote,
|
||||
);
|
||||
@@ -6763,9 +6899,22 @@ function App(): React.JSX.Element {
|
||||
[t],
|
||||
);
|
||||
|
||||
const readConversationForExport = async (item: Conversation): Promise<Conversation> => {
|
||||
if (!item.messageSummary) return item;
|
||||
const snapshot = await window.goodbuddy.conversations.get(item.id);
|
||||
// Export reads do not acquire a view-cache entry or retain tool metadata.
|
||||
return mergePersistedConversations([item], [snapshot], new Map())[0]!;
|
||||
};
|
||||
|
||||
const copyConversation = async (
|
||||
conversation: ConversationSnapshot,
|
||||
item: Conversation,
|
||||
): Promise<void> => {
|
||||
let conversation: Conversation;
|
||||
try { conversation = await readConversationForExport(item); }
|
||||
catch {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
return;
|
||||
}
|
||||
const transcript = conversation.messages
|
||||
.map((message) =>
|
||||
t("chat.exportSpeaker", {
|
||||
@@ -6786,7 +6935,13 @@ function App(): React.JSX.Element {
|
||||
[t, writeClipboardText],
|
||||
);
|
||||
|
||||
const exportConversation = (conversation: ConversationSnapshot): void => {
|
||||
const exportConversation = async (item: Conversation): Promise<void> => {
|
||||
let conversation: Conversation;
|
||||
try { conversation = await readConversationForExport(item); }
|
||||
catch {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
return;
|
||||
}
|
||||
const markdown = [
|
||||
`# ${conversation.title}`,
|
||||
"",
|
||||
@@ -6983,11 +7138,20 @@ function App(): React.JSX.Element {
|
||||
});
|
||||
}
|
||||
};
|
||||
const conversationSnapshot = queuedInput
|
||||
let conversationSnapshot = queuedInput
|
||||
? conversationsRef.current.find(
|
||||
(conversation) => conversation.id === queuedInput.conversationId,
|
||||
)
|
||||
: activeConversation;
|
||||
if (conversationSnapshot?.messageSummary) {
|
||||
try {
|
||||
conversationSnapshot = await ensureConversationHistory(conversationSnapshot.id);
|
||||
} catch {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
await releaseQueuedItem();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const recoveryProjectId =
|
||||
queuedInput?.projectId ?? conversationSnapshot?.projectId;
|
||||
if (
|
||||
@@ -7396,7 +7560,13 @@ function App(): React.JSX.Element {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const history = activeConversation.messages.filter(
|
||||
let completeConversation: Conversation;
|
||||
try { completeConversation = await ensureConversationHistory(activeConversation.id); }
|
||||
catch {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
return;
|
||||
}
|
||||
const history = completeConversation.messages.filter(
|
||||
(message) => message.state === "complete" && message.content.trim(),
|
||||
);
|
||||
if (history.length < 2) {
|
||||
@@ -7959,8 +8129,8 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
if (!conversation) {
|
||||
try {
|
||||
const persisted = await window.goodbuddy.conversations.list();
|
||||
conversation = persisted.find((candidate) => candidate.id === conversationId);
|
||||
conversation = await window.goodbuddy.conversations.get(conversationId);
|
||||
const persisted = [conversation];
|
||||
setConversations((current) => mergePersistedConversations(
|
||||
current, persisted, persistedLocalConversationsRef.current,
|
||||
));
|
||||
@@ -8060,7 +8230,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
const [conversationResult, taskResult, scheduleResult] =
|
||||
await Promise.allSettled([
|
||||
window.goodbuddy.conversations.list(),
|
||||
window.goodbuddy.conversations.listSummaries([...retainedConversationDetailIds(), schedule.conversationId]),
|
||||
window.goodbuddy.tasks.list(),
|
||||
window.goodbuddy.schedules.list(),
|
||||
]);
|
||||
@@ -8070,6 +8240,7 @@ function App(): React.JSX.Element {
|
||||
current,
|
||||
conversationResult.value,
|
||||
persistedLocalConversationsRef.current,
|
||||
retainedConversationDetailIds(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -9299,7 +9470,14 @@ function App(): React.JSX.Element {
|
||||
<KeepAliveRoute active={view === "chat"} route="chat">
|
||||
<PageShell variant="reading">
|
||||
<div className="chat-scroll-region">
|
||||
{cachedConversations.map((conversation) => (
|
||||
{cachedConversations.map((conversation) => conversation.messageSummary ? (
|
||||
<ConversationHistoryLoader
|
||||
key={conversation.id}
|
||||
conversationId={conversation.id}
|
||||
active={view === "chat" && conversation.id === activeId}
|
||||
load={ensureConversationHistory}
|
||||
/>
|
||||
) : (
|
||||
<ChatHistoryPane
|
||||
active={view === "chat" && conversation.id === activeId}
|
||||
artifactById={assistantArtifactById}
|
||||
|
||||
@@ -72,6 +72,9 @@ export const app = {
|
||||
switchDark: 'Switch to dark theme'
|
||||
},
|
||||
conversation: {
|
||||
historyLoading: 'Loading conversation history…',
|
||||
historyLoadFailed: 'Failed to load conversation history',
|
||||
historyRetry: 'Reload history',
|
||||
defaultTitle: 'New conversation',
|
||||
remoteTitle: 'Remote conversation',
|
||||
greeting:
|
||||
|
||||
@@ -69,6 +69,9 @@ export const app = {
|
||||
switchDark: '切换深色主题'
|
||||
},
|
||||
conversation: {
|
||||
historyLoading: '正在加载对话记录…',
|
||||
historyLoadFailed: '对话记录加载失败',
|
||||
historyRetry: '重新加载',
|
||||
defaultTitle: '新对话',
|
||||
remoteTitle: '远程会话',
|
||||
greeting:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import { mergeMessageImageState } from './message-image-state'
|
||||
|
||||
describe('mergeMessageImageState', () => {
|
||||
const message: Message = {
|
||||
id: 'message', role: 'assistant', content: 'Retained history',
|
||||
state: 'complete', createdAt: 1
|
||||
}
|
||||
|
||||
it('does not mark unchanged ordinary history as dirty after a snapshot refresh', () => {
|
||||
const incoming = Array.from({ length: 1600 }, () => ({ ...message }))
|
||||
const merged = incoming.map(item => mergeMessageImageState(item, item, { ...item }))
|
||||
expect(merged.filter((item, index) => item !== incoming[index])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves unchanged artifact references and streaming message identity', () => {
|
||||
const persisted = { ...message, artifactIds: ['image'] }
|
||||
expect(mergeMessageImageState(persisted, persisted, { ...persisted, artifactIds: ['image'] })).toBe(persisted)
|
||||
const streaming = { ...message, state: 'streaming' as const, content: 'New text' }
|
||||
expect(mergeMessageImageState(streaming, message, streaming)).toBe(streaming)
|
||||
})
|
||||
|
||||
it('merges newer local image operations without losing the selected message body', () => {
|
||||
const operation = {
|
||||
id: 'image-operation', conversationId: 'conversation', messageId: 'message',
|
||||
modelProfileId: 'profile', modelName: 'image', requestId: 'request', callId: 'call',
|
||||
input: { prompt: 'circle', intent: 'create' as const, sourceArtifactIds: [] },
|
||||
state: 'completed' as const, artifactIds: ['new-image'],
|
||||
createdAt: 1, updatedAt: 2
|
||||
}
|
||||
const local: Message = { ...message, imageOperations: [operation] }
|
||||
const result = mergeMessageImageState(message, message, local)
|
||||
expect(result.content).toBe(message.content)
|
||||
expect(result.imageOperations).toEqual([operation])
|
||||
expect(result.artifactIds).toEqual(['new-image'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Message } from './ChatTimeline'
|
||||
|
||||
function sameItems<T>(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean {
|
||||
return left === right || (
|
||||
(left?.length ?? 0) === (right?.length ?? 0) &&
|
||||
(left ?? []).every((item, index) => item === right?.[index])
|
||||
)
|
||||
}
|
||||
|
||||
/** Preserve message identity when reconciling unchanged, Main-owned image state. */
|
||||
export function mergeMessageImageState(
|
||||
base: Message,
|
||||
persisted: Message,
|
||||
local: Message | undefined
|
||||
): Message {
|
||||
if (!persisted.imageOperations?.length && !local?.imageOperations?.length &&
|
||||
!persisted.imageSourceArtifactIds?.length && !local?.imageSourceArtifactIds?.length &&
|
||||
!persisted.artifactIds?.length && !local?.artifactIds?.length) return base
|
||||
|
||||
const operations = new Map(persisted.imageOperations?.map(operation => [operation.id, operation]))
|
||||
for (const operation of local?.imageOperations ?? []) {
|
||||
if (operation.updatedAt > (operations.get(operation.id)?.updatedAt ?? -1)) {
|
||||
operations.set(operation.id, operation)
|
||||
}
|
||||
}
|
||||
const imageOperations = [...operations.values()]
|
||||
const imageSourceArtifactIds = persisted.imageSourceArtifactIds ?? local?.imageSourceArtifactIds
|
||||
const artifactIds = [...new Set([
|
||||
...(base === local ? local?.artifactIds ?? [] : []),
|
||||
...(persisted.artifactIds ?? []),
|
||||
...imageOperations.flatMap(operation => operation.artifactIds)
|
||||
])].slice(-8)
|
||||
if (sameItems(base.imageOperations, imageOperations) &&
|
||||
sameItems(base.imageSourceArtifactIds, imageSourceArtifactIds) &&
|
||||
sameItems(base.artifactIds, artifactIds)) return base
|
||||
|
||||
return {
|
||||
...base,
|
||||
imageOperations: imageOperations.length ? imageOperations : undefined,
|
||||
imageSourceArtifactIds,
|
||||
artifactIds: artifactIds.length ? artifactIds : undefined
|
||||
}
|
||||
}
|
||||
@@ -567,6 +567,22 @@ export const conversationSnapshotSchema = z
|
||||
export type ConversationSnapshot = z.infer<
|
||||
typeof conversationSnapshotSchema
|
||||
>
|
||||
// Read-only list projection. An absent summary means messages are complete.
|
||||
// Never serialize messageSummary as a persisted conversation/header.
|
||||
export type ConversationListSnapshot = ConversationSnapshot & {
|
||||
messageSummary?: {
|
||||
count: number
|
||||
firstRole?: ConversationMessage['role']
|
||||
}
|
||||
}
|
||||
export const conversationListRequestSchema = z.object({
|
||||
detailIds: z.array(assistantIdSchema).default([])
|
||||
}).strict()
|
||||
export const conversationSearchRequestSchema = z.object({
|
||||
query: z.string(),
|
||||
conversationIds: z.array(assistantIdSchema).default([])
|
||||
}).strict()
|
||||
|
||||
export const conversationSnapshotsSchema = z
|
||||
.array(conversationSnapshotSchema)
|
||||
.max(100)
|
||||
|
||||
@@ -2054,6 +2054,9 @@ export type DesktopApi = {
|
||||
conversations: {
|
||||
imageOperations: import('./image-operation-ipc').ImageOperationsApi
|
||||
list: () => Promise<ConversationSnapshot[]>
|
||||
listSummaries: (detailIds?: string[]) => Promise<import('./assistant-contracts').ConversationListSnapshot[]>
|
||||
get: (conversationId: string) => Promise<ConversationSnapshot>
|
||||
search: (query: string, conversationIds?: string[]) => Promise<string[]>
|
||||
replace: (conversations: ConversationSnapshot[]) => Promise<void>
|
||||
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
|
||||
branchLocal: (
|
||||
|
||||
@@ -185,6 +185,9 @@ export const ipcChannels = {
|
||||
remoteProjectRecoveryProgress:
|
||||
'projects:remote:recovery:progress',
|
||||
conversationsList: 'conversations:list',
|
||||
conversationsListSummaries: 'conversations:list-summaries',
|
||||
conversationsGet: 'conversations:get',
|
||||
conversationsSearch: 'conversations:search',
|
||||
conversationsReplace: 'conversations:replace',
|
||||
conversationsSaveLocal: 'conversations:save-local',
|
||||
conversationsBranchLocal: 'conversations:branch-local',
|
||||
|
||||
Reference in New Issue
Block a user