diff --git a/FEATURES.md b/FEATURES.md index 6bf0c7c4..7d482ed9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -86,8 +86,9 @@ otherwise. the same project and different projects can run in parallel. Request-scoped dynamic MCP tools remain isolated through default wildcard disablement and explicit current-request enablement. OpenCode and Continue runs do not stop - at a fixed tool-call or activity count, and exceeding local display capture - capacity truncates the displayed copy without cancelling the native task. + at a fixed tool-call or activity count, and every observed tool and Subagent + activity remains in the local conversation instead of being discarded while + the native task continues. Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels, delegated work, and other background runs fail immediately with guidance to rerun in the foreground @@ -393,10 +394,14 @@ otherwise. activity, and independent artifact files. Ordinary chat responses remain only in the conversation and are no longer copied into Artifacts; existing duplicate chat Markdown is hidden from the artifact list but not physically - deleted. Token usage is grouped by Runtime and model and normalizes the - different OpenAI-compatible and Anthropic Messages cache-reporting semantics - when showing cache hit rate. Activity is grouped by conversation and - collapsed by default so long histories do not fill the page. + deleted. Run history is stored in Main SQLite without the previous 500-item, + 4,000-character, or 2 MB Renderer limits; the page renders it in batches, + and a persistent warning remains when a migrated legacy cache may already + have lost older records or detail. Token usage is grouped by Runtime and + model and normalizes the different OpenAI-compatible and Anthropic Messages + cache-reporting semantics when showing cache hit rate. Activity is grouped + by conversation and collapsed by default so long histories do not fill the + page. - [x] **Task and custom-task experience**: Each product-level Task belongs to one Conversation, while one Conversation can contain multiple Tasks. The left conversation list exposes Task children through a leading expand diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 02890efb..26fb06e3 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -28,7 +28,7 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。 -- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。 +- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。 - [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.17`,Desktop 发布候选为 `0.12.3`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。 - [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。 - [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。 @@ -71,7 +71,7 @@ ### 工作管理、长期协作与工作流 -- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 +- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 - [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。 - [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 3609ac8f..23130ac7 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -360,7 +360,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 面板提供: -- 有界目录树和文本文件预览。 +- 有界目录树和分页文本文件预览;文件超过单页传输大小时先显示已读取内容,并允许用户继续加载后续内容,不因文件总大小超过单页上限而拒绝预览。 - 当前选择、规范化路径和执行空间。 - 可选 Git 状态、Diff 和仓库信息。 - 显式打开、下载副本或在终端中打开。 @@ -756,7 +756,10 @@ remote_operations 终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。 - 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。 - Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。 -- 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。 +- 运行记录由 Main SQLite 持久化,Renderer 只保留当前内存视图。升级时迁移旧 + `localStorage` 记录;若旧缓存达到原数量、详情或总大小边界,页面持续说明更早记录或 + 部分详情可能已经丢失。新记录不再按固定活动数或详情字符数丢弃,长列表只做可继续展开的 + 分批渲染。 ### 11.3 IPC 与 Preload diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index be6df752..56103041 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -168,7 +168,7 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、 - 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。 - Ask Workspace handle 不暴露写入方法。 - Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。 -- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。 +- 文件预览按有界页面传输并保持 UTF-8 字符边界;超过单页大小时先返回当前页,用户可继续加载,不把单页传输上限误作文件总预览上限。搜索、Git diff、目录项和显式文件传输继续保持各自的字节、条目数和路径长度上限。 ## Runtime @@ -225,11 +225,11 @@ Execute 直接启动已签名 Runtime: 调度、跨 channel 超车、批发送或第二套拥塞控制。 - Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。 - Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。 -- Runtime 事件仍对单项展示字段做有界截断,但不按固定工具调用数或 Desktop 累计展示量 - 中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 Runtime。 - Renderer 和 Main 为每条回复保留最多 500 条工具与子 Agent 活动以及 2,000 个交错 - 展示块;超过后显示本地截断提示,但继续执行 Runtime,也不把展示容量当作 Prompt - 工具调用上限。已保留活动的后续状态更新仍会正常归并。 +- Runtime 事件仍对协议定义的单项传输字段应用既有边界,但不按固定工具调用数或 Desktop + 累计展示量中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 + Runtime。Main 和 Renderer 不再按 500 条工具/子 Agent 活动或 2,000 个交错展示块丢弃 + 对话详情;独立运行记录也改由 Main SQLite 完整保存并按批渲染。旧版已经触发本地上限的 + 历史继续显示可能不完整的提示,不能通过迁移隐藏既有数据缺口。 - OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID 解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP 首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent @@ -413,6 +413,10 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture Host 的唯一 `/tmp/goodbuddy-e2e-*` 目录完成 Runtime 激活和 Agent 本机模型 gateway 验证。模型 gateway 通过所选 Anthropic 连接返回预期随机哨兵,HTTP 状态为 200,实际 文本模型调用恰好 1 次;Host 临时目录随后删除,共享 Agent/Runtime 安装和进程未修改。 +- 2026-09-04 使用当前源码 Desktop 和共享 Linux x64 Host 的既有 Agent/Workspace + 生产链路验证分页文件预览。专用测试文本共 262,166 bytes,首个 256 KiB 页面在三字节 + UTF-8 字符前回退并显示 262,143 bytes;继续加载后完整追加剩余文本,页面不再显示加载 + 按钮且没有错误。测试未调用模型;专用远端文件、目录和本机隔离用户数据均已清理。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 diff --git a/docs/roadmap/long-term-assistant-roadmap.md b/docs/roadmap/long-term-assistant-roadmap.md index 29c60544..5d6dad00 100644 --- a/docs/roadmap/long-term-assistant-roadmap.md +++ b/docs/roadmap/long-term-assistant-roadmap.md @@ -277,7 +277,8 @@ GoodBuddy 应能够: ### 阶段 0:持久化基础 - 新增 `assistant.sqlite` 和迁移框架。 -- 将会话与活动从 `localStorage` 迁移到主进程数据库。 +- 会话与运行记录已从 `localStorage` 迁移到主进程数据库;旧运行记录可能触及历史上限时 + 保留可见的不完整提示。 - 拆分共享契约和 IPC 注册。 - 保持现有对话、知识库、设置和审批行为不变。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 2f394dbb..7f89b2fb 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -159,11 +159,11 @@ describe('AssistantDatabase', () => { unchanged.close() }) - it('persists more than 100 tool activities in one message', async () => { + it('persists more than 500 tool activities in one message', async () => { const database = await createDatabase() const conversationId = '00000000-0000-4000-8000-000000000901' const messageId = '00000000-0000-4000-8000-000000000902' - const tools = Array.from({ length: 101 }, (_, index) => ({ + const tools = Array.from({ length: 501 }, (_, index) => ({ callId: `call-${index + 1}`, name: 'read', state: 'completed' as const, @@ -192,7 +192,79 @@ describe('AssistantDatabase', () => { expect( database.getConversation(conversationId)?.messages[0]?.tools - ).toHaveLength(101) + ).toHaveLength(501) + database.close() + }) + + it('persists complete Run history beyond the former Renderer limits', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-activity-history-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const records = Array.from({ length: 501 }, (_, index) => ({ + id: `activity-${index}`, + conversationId: 'conversation-1', + requestId: 'request-1', + scope: { kind: 'global' as const }, + kind: 'tool' as const, + title: `工具调用 ${index}`, + detail: index === 500 ? 'x'.repeat(4_001) : '读取文件', + status: 'completed' as const, + createdAt: index + })) + + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + initial.replaceActivityHistory({ + records, + legacyHistoryMayBeIncomplete: true + }) + initial.close() + + const reopened = new AssistantDatabase(databasePath) + reopened.initialize('C:\\Workspace') + const history = reopened.getActivityHistory() + expect(history.records).toHaveLength(501) + expect(history.records.at(-1)?.detail).toHaveLength(4_001) + expect(history.legacyHistoryMayBeIncomplete).toBe(true) + + reopened.clearAssistantData() + expect(reopened.getActivityHistory()).toEqual({ + records: [], + legacyHistoryMayBeIncomplete: false + }) + reopened.close() + }) + + it('keeps the truthful truncation marker on legacy messages', async () => { + const database = await createDatabase() + const conversationId = '00000000-0000-4000-8000-000000000911' + + database.saveLocalConversations([ + { + header: { + id: conversationId, + title: '旧版截断记录', + updatedAt: 1 + }, + messages: [ + { + id: '00000000-0000-4000-8000-000000000912', + role: 'assistant', + content: '', + createdAt: 1, + state: 'complete', + displayCaptureTruncated: true + } + ] + } + ]) + + expect( + database.getConversation(conversationId).messages[0] + ?.displayCaptureTruncated + ).toBe(true) database.close() }) @@ -239,7 +311,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 32', async () => { + it('migrates existing databases to schema version 33', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -268,7 +340,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(32) + ).toBe(33) expect( current .prepare( @@ -374,6 +446,14 @@ describe('AssistantDatabase', () => { ) .get() ).toEqual({ name: 'conversations_branch_source_idx' }) + expect( + current + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name = 'activity_history'` + ) + .get() + ).toEqual({ name: 'activity_history' }) current.close() }) @@ -430,7 +510,7 @@ describe('AssistantDatabase', () => { enableForeignKeyConstraints: true }) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 32 + user_version: 33 }) expect( inspected.prepare('SELECT * FROM projects ORDER BY rowid').all() @@ -528,7 +608,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 32 + user_version: 33 }) expect( inspected @@ -605,7 +685,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 32 + user_version: 33 }) expect( inspected @@ -855,7 +935,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(32) + ).toBe(33) expect( current .prepare( @@ -993,7 +1073,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 32 }) + ).toEqual({ user_version: 33 }) expect( inspected .prepare( @@ -2690,7 +2770,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 32 + user_version: 33 }) expect( inspected.prepare('PRAGMA table_info(task_events)').all() diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 13827165..4f00b061 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -3,12 +3,11 @@ import { DatabaseSync } from 'node:sqlite' import { builtInDefaultProjectSeedDescription, builtInDefaultProjectSeedName, + activityHistorySnapshotSchema, assistantIdSchema, conversationMessageSchema, conversationSnapshotSchema, expertCreateSchema, - maximumConversationMessageBlocks, - maximumConversationRetainedActivities, normalizeInteractiveWorkMode, persistedProjectExecutionSpaceSchema, projectCreateSchema, @@ -24,6 +23,7 @@ import type { AssistantProject, AssistantSchedule, AssistantTask, + ActivityHistorySnapshot, ConversationQueueItem, ConversationBranchInput, ConversationMessageBlock, @@ -95,7 +95,7 @@ import { } from '../magic-notes/rich-content' import { computeNextHeartbeatRun } from './heartbeat-recurrence' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 32 +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33 export type RemoteTaskEventInput = { taskId: string @@ -1123,6 +1123,8 @@ function toConversationSnapshot( blocks: interrupted ? interruptActiveToolBlocks(metadata.blocks) : metadata.blocks, + displayCaptureTruncated: + metadata.displayCaptureTruncated, createdAt: metadata.createdAt ?? Date.parse(message.created_at), state: interrupted ? ('error' as const) : message.state, @@ -1192,9 +1194,6 @@ function appendRecoveredMessageBlock( ) } } else { - if (current.length >= maximumConversationMessageBlocks) { - return blocks - } current.push({ id: randomUUID(), type, @@ -1225,9 +1224,6 @@ function upsertRecoveredToolBlock( : block ) } - if (blocks.length >= maximumConversationMessageBlocks) { - return blocks - } return [ ...blocks, { id: randomUUID(), type: 'tool' as const, tool } @@ -1267,9 +1263,6 @@ function upsertRecoveredSubagentBlock( : block ) } - if (blocks.length >= maximumConversationMessageBlocks) { - return blocks - } return [ ...blocks, { id: randomUUID(), type: 'subagent' as const, childTaskId } @@ -1302,16 +1295,6 @@ function failedToolRepresentsError( ) } -function canRetainRecoveredActivity( - message: ConversationMessage -): boolean { - return ( - (message.tools?.length ?? 0) + - (message.subagents?.length ?? 0) < - maximumConversationRetainedActivities - ) -} - function reduceRecoveredAgentEvent( message: ConversationMessage, event: RemoteConversationTaskEventInput['event'] @@ -1332,9 +1315,6 @@ function reduceRecoveredAgentEvent( ...message, content: `${message.content}${delta}`, blocks, - displayCaptureTruncated: - message.displayCaptureTruncated || - Boolean(delta && message.blocks && blocks === message.blocks), status: event.delta.length > remaining ? '回答过长,已在本地截断显示' @@ -1355,9 +1335,6 @@ function reduceRecoveredAgentEvent( ...message, reasoning: `${reasoning}${delta}`, blocks, - displayCaptureTruncated: - message.displayCaptureTruncated || - Boolean(delta && message.blocks && blocks === message.blocks), status: undefined } } else if (event.type === 'status') { @@ -1405,25 +1382,14 @@ function reduceRecoveredAgentEvent( ) if (index >= 0) { tools[index] = tool - } else if (!canRetainRecoveredActivity(message)) { - next = { - ...message, - displayCaptureTruncated: true - } - } - if (index < 0 && next === message) { + } else { tools.push(tool) } - if (next === message) { - const blocks = upsertRecoveredToolBlock(message.blocks, tool) - next = { - ...message, - tools, - blocks, - displayCaptureTruncated: - message.displayCaptureTruncated || - Boolean(message.blocks && blocks === message.blocks) - } + const blocks = upsertRecoveredToolBlock(message.blocks, tool) + next = { + ...message, + tools, + blocks } } else if (event.type === 'subagent') { const commonSubagent = { @@ -1448,49 +1414,25 @@ function reduceRecoveredAgentEvent( const index = subagents.findIndex( (candidate) => candidate.childTaskId === event.childTaskId ) - let retainSubagent = true if (index >= 0) { subagents[index] = subagent } else { - const replacesTool = Boolean( - event.runtimeCallId && - message.tools?.some( - (tool) => tool.callId === event.runtimeCallId - ) - ) - if ( - !replacesTool && - !canRetainRecoveredActivity(message) - ) { - retainSubagent = false - } else { - subagents.push(subagent) - } + subagents.push(subagent) } - if (!retainSubagent) { - next = { - ...message, - displayCaptureTruncated: true - } - } else { - const blocks = upsertRecoveredSubagentBlock( - message.blocks, - event.childTaskId, - event.runtimeCallId - ) - next = { - ...message, - subagents, - tools: event.runtimeCallId - ? message.tools?.filter( - (tool) => tool.callId !== event.runtimeCallId - ) - : message.tools, - blocks, - displayCaptureTruncated: - message.displayCaptureTruncated || - Boolean(message.blocks && blocks === message.blocks) - } + const blocks = upsertRecoveredSubagentBlock( + message.blocks, + event.childTaskId, + event.runtimeCallId + ) + next = { + ...message, + subagents, + tools: event.runtimeCallId + ? message.tools?.filter( + (tool) => tool.callId !== event.runtimeCallId + ) + : message.tools, + blocks } } else if (event.type === 'artifact') { next = { @@ -2087,6 +2029,11 @@ export class AssistantDatabase { ]) { database.exec(`DELETE FROM ${table}`) } + database.exec( + `UPDATE activity_history + SET records_json = '[]', legacy_history_may_be_incomplete = 0 + WHERE singleton = 1` + ) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') @@ -4283,6 +4230,40 @@ export class AssistantDatabase { return rows.map(toTask) } + getActivityHistory(): ActivityHistorySnapshot { + const row = this.requireDatabase() + .prepare( + `SELECT records_json, legacy_history_may_be_incomplete + FROM activity_history + WHERE singleton = 1` + ) + .get() as + | { + records_json: string + legacy_history_may_be_incomplete: number + } + | undefined + return activityHistorySnapshotSchema.parse({ + records: row ? JSON.parse(row.records_json) : [], + legacyHistoryMayBeIncomplete: + row?.legacy_history_may_be_incomplete === 1 + }) + } + + replaceActivityHistory(input: ActivityHistorySnapshot): void { + const snapshot = activityHistorySnapshotSchema.parse(input) + this.requireDatabase() + .prepare( + `UPDATE activity_history + SET records_json = ?, legacy_history_may_be_incomplete = ? + WHERE singleton = 1` + ) + .run( + JSON.stringify(snapshot.records), + Number(snapshot.legacyHistoryMayBeIncomplete) + ) + } + createTask(input: { id: string projectId?: string @@ -9648,6 +9629,22 @@ export class AssistantDatabase { throw error } } + if (version.user_version < 33) { + database.exec(` + BEGIN IMMEDIATE; + CREATE TABLE IF NOT EXISTS activity_history ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + records_json TEXT NOT NULL, + legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0 + CHECK(legacy_history_may_be_incomplete IN (0, 1)) + ); + INSERT OR IGNORE INTO activity_history + (singleton, records_json, legacy_history_may_be_incomplete) + VALUES (1, '[]', 0); + PRAGMA user_version = 33; + COMMIT; + `) + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/workspace-changes-service.test.ts b/src/main/assistant/workspace-changes-service.test.ts index 97972497..d3a3e7ec 100644 --- a/src/main/assistant/workspace-changes-service.test.ts +++ b/src/main/assistant/workspace-changes-service.test.ts @@ -95,7 +95,10 @@ describe('workspace file browsing', () => { path: 'remote.md', name: 'remote.md', content: '# fake\n', - size: 7 + size: 7, + offsetBytes: 0, + bytesRead: 7, + truncated: false })) const getChanges = vi.fn(async () => ({ rootPath: '/remote', @@ -136,7 +139,9 @@ describe('workspace file browsing', () => { expect(readText).toHaveBeenCalledWith( expect.objectContaining({ path: 'remote.md', - maximumBytes: 256 * 1024 + offsetBytes: 0, + maximumBytes: 256 * 1024, + allowTruncated: true }) ) }) @@ -171,15 +176,11 @@ describe('workspace file browsing', () => { }) }) - it('rejects traversal, unsupported files, invalid UTF-8, and oversized files', async () => { + it('rejects traversal, unsupported files, and invalid UTF-8', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-')) temporaryDirectories.push(directory) await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2])) await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff])) - await writeFile( - join(directory, 'large.txt'), - Buffer.alloc(256 * 1024 + 1, 97) - ) await expect( readWorkspaceFile(directory, '../outside.txt') @@ -190,8 +191,33 @@ describe('workspace file browsing', () => { await expect( readWorkspaceFile(directory, 'invalid.txt') ).rejects.toThrow('有效 UTF-8') - await expect( - readWorkspaceFile(directory, 'large.txt') - ).rejects.toThrow('超过 256KB') + }) + + it('reads large UTF-8 files page by page without a total preview limit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-')) + temporaryDirectories.push(directory) + const firstPage = 'a'.repeat(256 * 1024 - 1) + const content = `${firstPage}你\n第二页` + await writeFile(join(directory, 'large.txt'), content) + + const first = await readWorkspaceFile(directory, 'large.txt') + expect(first).toMatchObject({ + content: firstPage, + offsetBytes: 0, + nextOffsetBytes: 256 * 1024 - 1, + truncated: true + }) + + const second = await readWorkspaceFile( + directory, + 'large.txt', + first.nextOffsetBytes + ) + expect(second).toMatchObject({ + content: '你\n第二页', + offsetBytes: first.nextOffsetBytes, + truncated: false + }) + expect(`${first.content}${second.content}`).toBe(content) }) }) diff --git a/src/main/assistant/workspace-changes-service.ts b/src/main/assistant/workspace-changes-service.ts index f35a62b9..27cfc776 100644 --- a/src/main/assistant/workspace-changes-service.ts +++ b/src/main/assistant/workspace-changes-service.ts @@ -10,7 +10,7 @@ import { } from '../workspace' const MAX_DIRECTORY_ENTRIES = 500 -const MAX_PREVIEW_BYTES = 256 * 1024 +const PREVIEW_PAGE_BYTES = 256 * 1024 const previewExtensions = new Set([ '.c', '.cpp', @@ -92,6 +92,7 @@ export class WorkspaceChangesService { async readFile( inputPath: string, + offsetBytes = 0, signal?: AbortSignal ): Promise { const entry = await this.workspace.stat({ @@ -110,8 +111,9 @@ export class WorkspaceChangesService { } const preview = await this.workspace.readText({ path: inputPath, - maximumBytes: MAX_PREVIEW_BYTES, - tooLargeMessage: '工作区文件超过 256KB 预览限制', + offsetBytes, + maximumBytes: PREVIEW_PAGE_BYTES, + allowTruncated: true, invalidUtf8Message: '工作区文件不是有效 UTF-8 文本', signal }) @@ -119,6 +121,9 @@ export class WorkspaceChangesService { path: preview.path, name: preview.name, content: preview.content, + offsetBytes: preview.offsetBytes, + nextOffsetBytes: preview.offsetBytes + preview.bytesRead, + truncated: preview.truncated, mimeType: extension === '.md' || extension === '.markdown' ? 'text/markdown' @@ -177,12 +182,16 @@ export async function listWorkspaceDirectory( export async function readWorkspaceFile( workspace: string | WorkspaceAccess, - inputPath: string + inputPath: string, + offsetBytes = 0 ): Promise { if (typeof workspace !== 'string') { - return new WorkspaceChangesService(workspace).readFile(inputPath) + return new WorkspaceChangesService(workspace).readFile( + inputPath, + offsetBytes + ) } return withLocalWorkspace(workspace, (access) => - new WorkspaceChangesService(access).readFile(inputPath) + new WorkspaceChangesService(access).readFile(inputPath, offsetBytes) ) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6708a2bc..9d8fdb71 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -186,6 +186,7 @@ import { } from '../shared/magic-notes-contracts' import { assistantIdSchema, + activityHistorySnapshotSchema, conversationBranchInputSchema, conversationSnapshotsSchema, localConversationSaveBatchSchema, @@ -6598,7 +6599,8 @@ export function registerIpcHandlers( try { return await readWorkspaceFile( executionSpace.workspaceAccess, - value.path + value.path, + value.offsetBytes ) } finally { await executionSpace.workspaceAccess.dispose() @@ -6649,6 +6651,19 @@ export function registerIpcHandlers( parsed.status ) }) + registerHandler(ipcChannels.activityHistoryGet, (event) => { + assertTrustedSender(event, window) + return assistantDatabase.getActivityHistory() + }) + registerHandler( + ipcChannels.activityHistoryReplace, + (event, input: unknown) => { + assertTrustedSender(event, window) + assistantDatabase.replaceActivityHistory( + activityHistorySnapshotSchema.parse(input) + ) + } + ) registerHandler(ipcChannels.tokenUsageSummary, (event) => { assertTrustedSender(event, window) diff --git a/src/main/workspace-file-access.ts b/src/main/workspace-file-access.ts index b256743f..52124308 100644 --- a/src/main/workspace-file-access.ts +++ b/src/main/workspace-file-access.ts @@ -67,6 +67,84 @@ export async function readBoundedUtf8File( } } +export async function readUtf8FileRange( + filePath: string, + offsetBytes: number, + maximumBytes: number, + invalidUtf8Message: string +): Promise<{ + content: string + size: number + offsetBytes: number + bytesRead: number + truncated: boolean +}> { + const handle = await open(filePath, 'r') + try { + const metadata = await handle.stat() + if ( + !metadata.isFile() || + !Number.isSafeInteger(offsetBytes) || + offsetBytes < 0 || + offsetBytes > metadata.size + ) { + throw new Error('工作区读取范围无效') + } + const requestedBytes = Math.min( + maximumBytes, + metadata.size - offsetBytes + ) + const data = Buffer.alloc(requestedBytes) + let bytesRead = 0 + while (bytesRead < data.length) { + const result = await handle.read( + data, + bytesRead, + data.length - bytesRead, + offsetBytes + bytesRead + ) + if (result.bytesRead === 0) { + break + } + bytesRead += result.bytesRead + } + const selected = data.subarray(0, bytesRead) + const reachesEnd = offsetBytes + bytesRead >= metadata.size + let decoded: string | undefined + let decodedBytes = bytesRead + for ( + let trailingBytes = 0; + trailingBytes <= (reachesEnd ? 0 : 3); + trailingBytes += 1 + ) { + decodedBytes = bytesRead - trailingBytes + if (decodedBytes < 0) { + break + } + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode( + selected.subarray(0, decodedBytes) + ) + break + } catch { + // A page may end in the middle of one UTF-8 character. + } + } + if (decoded === undefined) { + throw new Error(invalidUtf8Message) + } + return { + content: decoded, + size: metadata.size, + offsetBytes, + bytesRead: decodedBytes, + truncated: offsetBytes + decodedBytes < metadata.size + } + } finally { + await handle.close() + } +} + export async function readBoundedFile( filePath: string, maximumBytes: number, diff --git a/src/main/workspace/local-workspace-access.ts b/src/main/workspace/local-workspace-access.ts index 7b52fd50..4b8636e1 100644 --- a/src/main/workspace/local-workspace-access.ts +++ b/src/main/workspace/local-workspace-access.ts @@ -23,6 +23,7 @@ import { isPathInside, listBoundedDirectoryEntries, readBoundedUtf8File, + readUtf8FileRange, resolveExistingWorkspacePath } from '../workspace-file-access' import type { @@ -562,6 +563,30 @@ export class LocalWorkspaceAccess implements WorkspaceAccess { '工作区读取上限无效' ) const file = await this.resolveExisting(input.path, 'file') + const offsetBytes = input.offsetBytes ?? 0 + if (input.allowTruncated || offsetBytes > 0) { + const preview = await readUtf8FileRange( + file.canonicalPath, + offsetBytes, + maximumBytes, + input.invalidUtf8Message ?? + '工作区读取目标不是有效 UTF-8 文本' + ) + if (preview.truncated && !input.allowTruncated) { + throw new Error( + input.tooLargeMessage ?? '工作区文本文件超过安全限制' + ) + } + if (preview.truncated && preview.bytesRead === 0) { + throw new Error('工作区读取分页无法继续') + } + input.signal?.throwIfAborted() + return { + path: file.path, + name: basename(file.canonicalPath), + ...preview + } + } const preview = await readBoundedUtf8File( file.canonicalPath, maximumBytes, @@ -573,7 +598,10 @@ export class LocalWorkspaceAccess implements WorkspaceAccess { path: file.path, name: basename(file.canonicalPath), content: preview.content, - size: preview.size + size: preview.size, + offsetBytes: 0, + bytesRead: preview.size, + truncated: false } } diff --git a/src/main/workspace/remote-workspace-access.test.ts b/src/main/workspace/remote-workspace-access.test.ts index d677c443..88fe4f4c 100644 --- a/src/main/workspace/remote-workspace-access.test.ts +++ b/src/main/workspace/remote-workspace-access.test.ts @@ -168,7 +168,10 @@ describe('RemoteWorkspaceAccess', () => { path: 'README.md', name: 'README.md', content: 'hello', - size: 5 + size: 5, + offsetBytes: 0, + bytesRead: 5, + truncated: false }) await expect( access.search({ @@ -365,6 +368,70 @@ describe('RemoteWorkspaceAccess', () => { expect(transport.acquireLease).not.toHaveBeenCalled() }) + it('returns remote file pages and keeps UTF-8 boundaries intact', async () => { + const invalidBoundary = Object.assign( + new Error('Workspace file is not valid UTF-8'), + { data: { code: 'invalid-utf8' } } + ) + const readWorkspaceText = vi + .fn() + .mockRejectedValueOnce(invalidBoundary) + .mockResolvedValueOnce({ + relativePath: 'large.txt', + content: '你a', + offsetBytes: 0, + bytesRead: 4, + totalBytes: 7, + digest, + truncated: true + }) + .mockResolvedValueOnce({ + relativePath: 'large.txt', + content: '好', + offsetBytes: 4, + bytesRead: 3, + totalBytes: 7, + digest, + truncated: false + }) + const lease = createLease({ readWorkspaceText }) + const { access } = createAccess(lease) + + const first = await access.readText({ + path: 'large.txt', + maximumBytes: 5, + allowTruncated: true + }) + const second = await access.readText({ + path: 'large.txt', + offsetBytes: first.offsetBytes + first.bytesRead, + maximumBytes: 5, + allowTruncated: true + }) + + expect(first).toMatchObject({ + content: '你a', + offsetBytes: 0, + bytesRead: 4, + truncated: true + }) + expect(second).toMatchObject({ + content: '好', + offsetBytes: 4, + truncated: false + }) + expect( + readWorkspaceText.mock.calls.map(([request]) => ({ + offsetBytes: request.offsetBytes, + maximumBytes: request.maximumBytes + })) + ).toEqual([ + { offsetBytes: 0, maximumBytes: 5 }, + { offsetBytes: 0, maximumBytes: 4 }, + { offsetBytes: 4, maximumBytes: 5 } + ]) + }) + it('retries one inconsistent Git snapshot and maps status and diff', async () => { const getGitStatus = vi .fn() diff --git a/src/main/workspace/remote-workspace-access.ts b/src/main/workspace/remote-workspace-access.ts index f7e358a5..1050316b 100644 --- a/src/main/workspace/remote-workspace-access.ts +++ b/src/main/workspace/remote-workspace-access.ts @@ -149,6 +149,20 @@ function boundedInteger( return resolved } +function isInvalidUtf8RpcError(error: unknown): boolean { + if ( + !error || + typeof error !== 'object' || + !('data' in error) || + !error.data || + typeof error.data !== 'object' || + !('code' in error.data) + ) { + return false + } + return error.data.code === 'invalid-utf8' +} + function assertTransportBinding( expected: RemoteWorkspaceProjectBinding, actual: RemoteWorkspaceTransportBinding @@ -419,16 +433,43 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { '工作区读取上限无效' ) const opened = await this.open(input.signal) - const request = remoteWorkspaceReadTextRequestSchema.parse({ - ...this.reference(opened), - relativePath: input.path, - offsetBytes: 0, - maximumBytes - }) - const rawResult = await opened.lease.readWorkspaceText( - request, - input.signal - ) + const offsetBytes = input.offsetBytes ?? 0 + let rawResult: RemoteWorkspaceReadTextResult | undefined + let boundaryError: unknown + const maximumTrailingBytes = input.allowTruncated + ? Math.min(3, maximumBytes - 1) + : 0 + for ( + let trailingBytes = 0; + trailingBytes <= maximumTrailingBytes; + trailingBytes += 1 + ) { + const request = remoteWorkspaceReadTextRequestSchema.parse({ + ...this.reference(opened), + relativePath: input.path, + offsetBytes, + maximumBytes: maximumBytes - trailingBytes + }) + try { + rawResult = await opened.lease.readWorkspaceText( + request, + input.signal + ) + break + } catch (error) { + if ( + !input.allowTruncated || + trailingBytes === maximumTrailingBytes || + !isInvalidUtf8RpcError(error) + ) { + throw error + } + boundaryError = error + } + } + if (!rawResult) { + throw boundaryError + } let result: RemoteWorkspaceReadTextResult try { result = remoteWorkspaceReadTextResultSchema.parse(rawResult) @@ -444,21 +485,32 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { } if ( result.relativePath !== input.path || - result.offsetBytes !== 0 + result.offsetBytes !== offsetBytes || + result.offsetBytes + result.bytesRead > result.totalBytes || + (!result.truncated && + result.offsetBytes + result.bytesRead !== result.totalBytes) || + (result.truncated && + result.offsetBytes + result.bytesRead >= result.totalBytes) ) { throw new Error('远程工作区返回了不匹配的文件') } - if (result.truncated || result.bytesRead !== result.totalBytes) { + if (result.truncated && !input.allowTruncated) { throw new Error( input.tooLargeMessage ?? '工作区文本文件超过安全限制' ) } + if (result.truncated && result.bytesRead === 0) { + throw new Error('远程工作区读取分页无法继续') + } const name = input.path.split('/').at(-1) ?? input.path return { path: input.path, name, content: result.content, - size: result.totalBytes + size: result.totalBytes, + offsetBytes: result.offsetBytes, + bytesRead: result.bytesRead, + truncated: result.truncated } } diff --git a/src/main/workspace/workspace-access.ts b/src/main/workspace/workspace-access.ts index ee5994d7..d40d5f63 100644 --- a/src/main/workspace/workspace-access.ts +++ b/src/main/workspace/workspace-access.ts @@ -39,7 +39,9 @@ export type WorkspaceEntry = WorkspaceDirectoryEntry & { } export type WorkspaceReadInput = WorkspacePathInput & { + offsetBytes?: number maximumBytes?: number + allowTruncated?: boolean tooLargeMessage?: string invalidUtf8Message?: string } @@ -49,6 +51,9 @@ export type FilePreview = { name: string content: string size: number + offsetBytes: number + bytesRead: number + truncated: boolean } export type WorkspaceWriteInput = WorkspacePathInput & { diff --git a/src/preload/index.ts b/src/preload/index.ts index 81cfc5b8..9b1fea69 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -49,6 +49,8 @@ import type { AssistantHeartbeatRun, AssistantExpert, AssistantTask, + ActivityHistorySnapshot, + ActivityRecord, TokenUsageSummary, ConversationBranchInput, ConversationSnapshot, @@ -1096,10 +1098,15 @@ const desktopApi: DesktopApi = { projectId, path }) as Promise, - readFile: (projectId: string, path: string) => + readFile: ( + projectId: string, + path: string, + offsetBytes?: number + ) => ipcRenderer.invoke(ipcChannels.workspaceFileRead, { projectId, - path + path, + offsetBytes }) as Promise, openPath: async ( projectId: string, @@ -1126,6 +1133,21 @@ const desktopApi: DesktopApi = { }) } }, + activityHistory: { + get: () => + ipcRenderer.invoke( + ipcChannels.activityHistoryGet + ) as Promise, + replace: async ( + records: ActivityRecord[], + legacyHistoryMayBeIncomplete: boolean + ) => { + await ipcRenderer.invoke(ipcChannels.activityHistoryReplace, { + records, + legacyHistoryMayBeIncomplete + }) + } + }, usage: { getTokenSummary: () => ipcRenderer.invoke( diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index b5e7841b..72e483b7 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -12,10 +12,7 @@ import { builtInDefaultProjectSeedName } from '../../shared/assistant-contracts' import { ActivityPanel } from './ActivityPanel' -import { - MAX_ACTIVITY_RECORDS, - type ActivityRecord -} from './activity-store' +import { type ActivityRecord } from './activity-store' import i18n from './i18n' function makeRecord( @@ -642,9 +639,9 @@ describe('ActivityPanel', () => { expect(within(detail).getByText('项目:项目甲')).toBeInTheDocument() }) - it('never renders more than 500 records', () => { + it('reveals retained records beyond the initial render batch', () => { const records = Array.from( - { length: MAX_ACTIVITY_RECORDS + 1 }, + { length: 501 }, (_, index) => makeRecord(index) ) render( @@ -658,6 +655,30 @@ describe('ActivityPanel', () => { expect(screen.getByText('活动 499')).toBeInTheDocument() expect(screen.queryByText('活动 500')).not.toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { + name: '继续显示(剩余 1 条)' + }) + ) + expect(screen.getByText('活动 500')).toBeInTheDocument() + }) + + it('warns when the migrated legacy history may already be incomplete', () => { + render( + + ) + + expect( + screen.getByText( + '旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。' + ) + ).toHaveAttribute('role', 'status') }) it('shows totals without double-counting cache tokens', () => { diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 3979bb96..a06f091f 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -6,10 +6,7 @@ import type { TokenUsageSummary } from '../../shared/assistant-contracts' import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts' -import { - MAX_ACTIVITY_RECORDS, - type ActivityRecord -} from './activity-store' +import { type ActivityRecord } from './activity-store' import { getTokenUsageTotals, groupTokenUsage, @@ -35,7 +32,10 @@ type ActivityActorKind = | 'tool' | 'approval' +const activityRenderBatchSize = 500 + export type ActivityPanelProps = { + legacyHistoryMayBeIncomplete?: boolean projects?: readonly AssistantProject[] records: readonly ActivityRecord[] tokenUsage: TokenUsageSummary @@ -240,6 +240,7 @@ function groupActivityRecordsByProject( } export function ActivityPanel({ + legacyHistoryMayBeIncomplete = false, projects = [], records, tokenUsage, @@ -256,6 +257,9 @@ export function ActivityPanel({ const [confirmingClear, setConfirmingClear] = useState(false) const [selectedTimelineRecordId, setSelectedTimelineRecordId] = useState() + const [visibleRecordCount, setVisibleRecordCount] = useState( + activityRenderBatchSize + ) const dateTimeFormatter = useMemo( () => new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', { @@ -378,13 +382,13 @@ export function ActivityPanel({ }), [projectDisplayNames, tokenUsage] ) - const visibleRecords = useMemo( - () => displayRecords.slice(0, MAX_ACTIVITY_RECORDS), - [displayRecords] + const matchingRecords = useMemo( + () => displayRecords.filter((record) => matchesFilter(record, filter)), + [displayRecords, filter] ) const filteredRecords = useMemo( - () => visibleRecords.filter((record) => matchesFilter(record, filter)), - [filter, visibleRecords] + () => matchingRecords.slice(0, visibleRecordCount), + [matchingRecords, visibleRecordCount] ) const projectGroups = useMemo( () => groupActivityRecordsByProject(filteredRecords, displayRecords), @@ -418,8 +422,8 @@ export function ActivityPanel({ () => getConversationTitles(displayRecords), [displayRecords] ) - const activeCount = visibleRecords.filter(isActive).length - const failedCount = visibleRecords.filter(isFailed).length + const activeCount = displayRecords.filter(isActive).length + const failedCount = displayRecords.filter(isFailed).length const filters: ReadonlyArray<{ value: ActivityFilter label: string @@ -427,8 +431,8 @@ export function ActivityPanel({ { value: 'all', label: t('filters.all', { - count: visibleRecords.length, - formattedCount: formatCount(visibleRecords.length) + count: displayRecords.length, + formattedCount: formatCount(displayRecords.length) }) }, { @@ -620,19 +624,19 @@ export function ActivityPanel({ />