From a3b7a270450473e3acce834fb6a8b5a652b4ddc8 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 19 Sep 2026 23:09:48 +0800 Subject: [PATCH] fix: preserve drafts, canvas edits, and remote Continue tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep paragraph formatting and checklist todos when removing canvas pages, and preserve accepted undo/redo history after oversized input is rejected. Merge restored queued text into the latest conversation draft and retain image delivery when OCR returns image references and generated warnings only. Deliver session MCP servers to bridged remote Continue Execute while keeping Ask read-only and preserving local model-profile isolation. Add regression coverage and update feature documentation. Correct stale conversation-limit and forced-color assertions and the measured Electron layout test deadline. Validation: 4,826 tests passed, 67 skipped; full typecheck and lint passed. Current-source Linux x64 Execute/Ask/Execute MCP delivery passed, using 10 real text-provider requests and no image-provider requests. Image generation itself was not exercised. Staged whitespace checks passed before commit. Release note: 修复恢复队列时覆盖新输入、画布删页丢失清单格式及超限输入清空撤销历史的问题;图片 OCR 无正文时保留原图发送方式,远程 Continue Execute 正确保留会话 MCP 工具。 --- .../technical-design.md | 2 +- .../document-processing/changed-paths.md | 3 + .../document-processing/implementation.md | 6 +- .../document-processing/logic-design.md | 5 +- docs/features/document-processing/progress.md | 30 ++++- docs/features/magic-notes/progress.md | 21 ++++ docs/features/magic-notes/technical-design.md | 7 ++ .../runtime-checklist-validation.md | 27 ++++ docs/features/remote-host/technical-design.md | 8 +- docs/quality/recurring-defects.md | 14 +++ src/agent-daemon/continue-acp-helper.test.ts | 119 ++++++++++++++++++ src/agent-daemon/continue-acp-helper.ts | 19 ++- src/main/agent/continue-host-adapter.ts | 15 +++ src/main/assistant/assistant-database.test.ts | 22 ++-- src/main/document-extracted-text.test.ts | 23 ++++ src/main/document-extracted-text.ts | 8 ++ src/main/document-parsing-service.ts | 4 +- src/main/document-result-storage.ts | 6 +- .../http-document-ocr.integration.test.ts | 64 ++++++++++ src/main/http-document-ocr.ts | 7 +- src/renderer/src/App.test.tsx | 83 ++++++++++++ src/renderer/src/App.tsx | 4 +- src/renderer/src/WorkspacePrimitives.test.tsx | 14 +-- src/renderer/src/magic-canvas/README.md | 11 +- src/renderer/src/magic-canvas/canvas-flow.mjs | 37 +++++- .../src/magic-canvas/canvas-flow.test.mjs | 89 ++++++++++++- tests/canvas-core.electron.test.ts | 29 +++++ tests/magic-notes-layout.electron.test.ts | 7 +- tests/support/canvas-core-regression.tsx | 40 +++++- 29 files changed, 664 insertions(+), 60 deletions(-) create mode 100644 src/agent-daemon/continue-acp-helper.test.ts create mode 100644 src/main/document-extracted-text.test.ts create mode 100644 src/main/document-extracted-text.ts diff --git a/docs/features/conversation-media-generation/technical-design.md b/docs/features/conversation-media-generation/technical-design.md index 9d5d612e..ac9d5b48 100644 --- a/docs/features/conversation-media-generation/technical-design.md +++ b/docs/features/conversation-media-generation/technical-design.md @@ -71,7 +71,7 @@ Main 仅在图片和会话引用持久化成功后发布完成事件。素材必 | GoodBuddy Agent 远程适用 Runtime | 复用桌面与 Agent 通信及工具桥接,将请求绑定原会话,由 Main 调用图片模型 | 逐一验证实际支持的远程 Runtime,覆盖真实 Host 工具往返、生成编辑、本地与远程双向切换、断连及成果归属 | | 无工具能力的聊天模型 | 说明自动调用限制,可使用已有直连图片工作流 | 不解析普通文字伪装工具调用,不新增表单;Ask 不提交 | -本地 OpenCode、Continue 和 DeepSeek Harness 经内部 MCP gateway 注入;直连 Model 使用内置工具分发。远程通过 `main-image-tool-session.ts`、Agent 协议和 `image-tool-mcp.ts` 回到同一 Main 服务。现有 GoodBuddy Agent 的受管远程 Runtime 为 OpenCode,项目校验和 Runtime 创建均沿用此范围;共享契约包含其他 Runtime 不表示存在其远程执行路径。保留会话在目录清空时也刷新 MCP,Continue 准备失败时释放本次分配的令牌。工具发现成功或 Skill 注入成功不能代替真实生成与编辑。 +本地 OpenCode、Continue 和 DeepSeek Harness 经内部 MCP gateway 注入;直连 Model 使用内置工具分发。远程通过 `main-image-tool-session.ts`、Agent 协议和 `image-tool-mcp.ts` 回到同一 Main 服务。当前受管远程 Runtime 包括 OpenCode 和 Continue;Continue 模型桥的会话 MCP 交付及 Ask 边界见[远程 Runtime](../remote-host/technical-design.md#runtime)。保留会话在目录清空时也刷新 MCP,Continue 准备失败时释放本次分配的令牌。工具发现、无计费测试接收端往返或 Skill 注入成功不能代替真实生成与编辑。 Main 已接收的请求在桌面仍运行时独立于远程连接继续;未确认是否接收的请求不自动重放。聊天回合停止等待与用户取消图片是不同动作,须验证现有取消传播不会误杀 Main 图片请求。 diff --git a/docs/features/document-processing/changed-paths.md b/docs/features/document-processing/changed-paths.md index cb7d377a..40dd8b74 100644 --- a/docs/features/document-processing/changed-paths.md +++ b/docs/features/document-processing/changed-paths.md @@ -11,6 +11,8 @@ src/main/context-manager.ts src/main/context-manager.test.ts src/main/conversation-attachment-storage.ts src/main/conversation-attachment-storage.test.ts +src/main/document-extracted-text.ts +src/main/document-extracted-text.test.ts src/main/document-parsing-service.ts src/main/document-parsing-service.test.ts src/main/document-parsing-settings-store.ts @@ -92,6 +94,7 @@ docs/features/document-processing/chat-attachments-technical-design.md docs/features/document-processing/implementation.md docs/features/document-processing/progress.md docs/features/document-processing/changed-paths.md +docs/quality/recurring-defects.md ``` `scripts/paddleocr-vl-probe.mjs` 和导航 README 原有未提交内容保留,本轮未修改。 diff --git a/docs/features/document-processing/implementation.md b/docs/features/document-processing/implementation.md index 8a013675..0a246d82 100644 --- a/docs/features/document-processing/implementation.md +++ b/docs/features/document-processing/implementation.md @@ -54,6 +54,9 @@ HTTP 请求固定返回 Markdown 图片、关闭调试可视化。高级布尔 会话资产的 `request.json` 保存已转换的请求内容,用于恢复内存 context;原件字节单独保存, 缩放和 JPEG 请求副本不覆盖原图。提取图片文件使用 UUID 无扩展名,MIME 在清单记录。 图片显式 OCR 后生成解析文件。切回原图保留已有结果,并重新进入图片能力校验。 +HTTP OCR、显式图片提取及结果完整程度共用 `hasExtractedDocumentText`;图片引用与生成的 +缺图警告只作预览/诊断,不算正文。无正文的显式提取失败并保留原草稿;有有效插图的文档 +结果仍可保存为仅图片,不因缺图警告改判为可发送文本。 `ConversationAttachmentStorage` 的专用 SQLite schema 1 保存描述、路径与 `draft/message/queue/parsing` 引用,不在这些描述字段内存入原图 Base64。纯图片无需解析清单。 @@ -85,7 +88,8 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独 队列保存接受时的附件和发送方式。入队、分发、实际执行分别检查图片能力。 分发失败保留队列及可恢复错误,队列预览只读;“恢复到草稿”先合并并保存附件引用, -再移除队列项,文字用空行追加,超限保持原队列。恢复后的文字由 Renderer 草稿管理。 +再移除队列项,文字用空行追加,超限保持原队列。Renderer 请求恢复时不传入旧草稿文本, +收到队列正文后通过原会话的函数式状态更新追加到最新草稿,保留等待期间的编辑和清空。 文件附件的启动恢复不再把提前保存的用户消息误认为 Runtime 已接受并删除队列。 ## 界面与边界 diff --git a/docs/features/document-processing/logic-design.md b/docs/features/document-processing/logic-design.md index 5bda509d..515f9a24 100644 --- a/docs/features/document-processing/logic-design.md +++ b/docs/features/document-processing/logic-design.md @@ -26,7 +26,8 @@ L-I3(FR-I2、FR-I3;US-I3、US-I11):附件身份、原始名称与来源 单独记录为图片或提取文字。只有 OCR 返回非空可靠文字且结果保存成功,才切到提取文字; 显示名派生为“原文件名 · 提取文字”。提取文字模式只拼入文本,不同时传原图或把解析中 提取的其他图片自动附加。失败、取消、空文字保持原方式;用户可显式切回图片并重新校验 -能力。重试采用当前已保存 OCR 设置,开始时说明来源;旧结果在新结果成功前保持可用。 +能力。图片引用及解析器生成的缺图警告不算提取文字,即使同时保存了其他有效图片也不例外。 +重试采用当前已保存 OCR 设置,开始时说明来源;旧结果在新结果成功前保持可用。 ## 选图与发送 @@ -153,6 +154,8 @@ L-H7(FR-H10、FR-H11;US-H11、US-H13):知识库文档独立持有正文 取消正在生成的回复保留已发送输入。会话删除和项目删除均使用同一引用清理规则。 目标已有草稿时,将恢复文字以空行追加、附件按来源去重合并,提交前校验合并后的数量与 容量;超限则保留队列及原草稿,提示先发送或整理草稿,不覆盖已有输入。 +恢复等待期间允许继续编辑;文字追加到恢复完成时该会话的最新草稿,不覆盖期间的输入, +也不复活已清空的旧文字。切换会话不会改变原恢复目标。 L-H9(FR-I5、FR-H9、FR-H10;US-I8、US-I9):从聊天或知识库选出的图片在添加成功时 已是会话独立副本,其来源名称、定位和源 ID 随附件保存。来源删除或重解析不删除副本, diff --git a/docs/features/document-processing/progress.md b/docs/features/document-processing/progress.md index 963d3614..a2ea27c3 100644 --- a/docs/features/document-processing/progress.md +++ b/docs/features/document-processing/progress.md @@ -2,7 +2,7 @@ ## 状态 -当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)、[附件操作布局复验](#2026-09-19-附件操作布局复验)及[全局浮层复验](#2026-09-19-全局浮层复验)为准。 +当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)、[附件操作布局复验](#2026-09-19-附件操作布局复验)、[全局浮层复验](#2026-09-19-全局浮层复验)及[空正文与草稿恢复修正](#2026-09-19-空正文与草稿恢复修正)为准。 以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。 2026-09-18:完成规划文档和独立 HTTP 探索脚本。FR-H1 至 FR-H12 均未完成生产实现, @@ -330,3 +330,31 @@ Worker 整体回收),是具体实现对比,保留原文。本次新增内 最终 `npm test`:403 个文件通过、9 个跳过,4,793 项通过、67 项跳过,耗时 909.69 秒。 `npm run typecheck`、`npm run lint` 及隔离目录的 Electron Vite 生产构建通过; 相关文档的 48 个相对文件链接有效,`git diff --check` 通过。 + +## 2026-09-19 空正文与草稿恢复修正 + +对应 L-I3、L-H7、L-H8。修复前已复现:无识别文字、有效插图与缺图警告并存时, +警告被当作正文,显式提取后请求没有原图;队列恢复等待期间输入的文字也会被旧草稿覆盖。 +实现和判定归属见[生产实现说明](./implementation.md),未增加持久状态或修改数据 schema。 + +- 解析、结果完整程度和 HTTP Provider 共用提取正文判定。8 个解析/存储/共享 UI 测试文件 + 203 项通过,其中 loopback HTTP 回归覆盖缺图警告、正文存在与否及保存后的 `images-only`。 +- 生产 `App` 的 6 项回归通过,覆盖空草稿、已有草稿、恢复期间编辑、清空、失败及切换会话。 + IPC 响应由测试控制完成时机,不能称为完整桌面到 Main 的新增端到端验收。 +- 当前源码在隔离 Electron 中通过真实 `nativeImage`、HTTP 客户端、附件 SQLite/文件存储、 + `reparseDraft` 与 `enrichRequest` 复验:仅图片及图片加缺图警告均拒绝文字切换, + 草稿仍引用原附件,发往 Runtime 的请求保留一张图片;真实正文控制组仍切为文字发送。 + 三次请求均发往本机合成 OCR 服务,外部 OCR/文本/图像 Provider 请求为 0。 +- 本轮 OCR 判断和草稿合并仍位于 Desktop,共用同一 Main 请求构造与 Renderer 草稿机制; + 没有远端 OCR 分支、Agent 协议或模型桥变更,不据此扩大远端视觉模型验收结论。 + +验证用的临时 fixture、Electron profile 和数据库已清理。首次 OCR 探针因临时目录的模块 +解析失败,调整启动器的依赖路径后通过;新会话切换测试最初遗漏全局队列读取,修正 +fixture 后通过。上述均为验证工具问题,不修改产品行为来满足错误 fixture。 + +合并画布与 Continue 修正后的 `npm test` 完整结束:405 个文件通过、9 个跳过, +4,826 项通过、67 项跳过,耗时 982.48 秒,无失败。最终 `npm run typecheck`、 +`npm run lint` 及助手数据库 101 项单独复验均通过。会话列表测试更新为无 100 条截断的 +既定行为,共享高对比度测试检查全部 forced-colors 块;没有为旧断言恢复产品限制。 +隐藏 Electron 窗口的双帧等待实测令布局场景耗时约 63 秒,因此只把测试子进程期限从 +60 秒改为 90 秒,并对自有临时目录增加有界删除重试;完整布局断言未减少。 diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index 34c93047..f32a4bfc 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,26 @@ # Magic Notes Progress +## 2026-09-19: Canvas Content and History Preservation + +- Page-break deletion now removes only the embed, retaining existing paragraph + terminators and checklist/heading formats. Mid-line insertion retains Quill's + native paragraph split rather than guessing which newline is disposable. +- Oversized input is rejected before the canvas-local History records it, then + reverted with its inverse Delta. Accepted undo/redo and user selections survive; + normal rich-text editors retain their original History module. +- `npx vitest run tests/canvas-core.electron.test.ts src/renderer/src/magic-canvas` + passed 35 tests across 5 files. Electron exercised actual Quill selection and history, then production + SQLite/file create, update and reopen retained the same checklist todo. + This uses Quill API edits in an isolated Electron window, not physical keyboard + input or the full App/IPC save flow. +- Focused ESLint for the four changed source/test files and `git diff --check` + passed. The integrated full suite passed 4,826 tests with 67 skipped + (405 files passed, 9 skipped); full typecheck and lint passed. + The existing hidden-window layout scenario measured about 63 seconds, so its + child deadline is now 90 seconds with bounded temporary-directory cleanup + retries. All geometry, input and persistence assertions remain intact and passed. + No model calls, persistence/schema changes, remote Runtime changes or commits. + ## 2026-09-19: Canvas Image Import Size - Raised the canvas image file limit to 20 MiB inclusive and updated its error diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index 10f0505b..e52cfc96 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -228,6 +228,13 @@ PDF text layer. `capturePages()` provides PNG data URLs for AI input, not a PNG download button. Undo/redo is per annotation page or Quill mode; page operations and PDF imports are not global undo steps. +Flow page-break removal and rejected-input history handling follow the +[canvas editing and limit rules](../../../src/renderer/src/magic-canvas/README.md#editing-and-assets). +`canvas-core.electron.test.ts` feeds the real Electron flow's before/after content +into production `AssistantDatabase` create/update and reopen, checking that the +derived checklist todo and its identity survive page-break removal. History +coverage exercises accepted typing, rejected overflow, selection and redo. + The [canvas integration reference](../../../src/renderer/src/magic-canvas/README.md) owns core limits, import behavior and lifecycle details. Vite bundles Fabric, Quill, jsPDF, PDF.js workers and binary assets; PDF workers use explicit module diff --git a/docs/features/remote-host/runtime-checklist-validation.md b/docs/features/remote-host/runtime-checklist-validation.md index b350340f..8b2d95c3 100644 --- a/docs/features/remote-host/runtime-checklist-validation.md +++ b/docs/features/remote-host/runtime-checklist-validation.md @@ -223,3 +223,30 @@ provider HTTP 请求计数在 Agent gateway 的外部 fetch 层完成。 最终另复验项目选择、顶部清单和安装管理 3 文件、41 项通过;App 远程 Runtime 选择与 继承 3 项通过。`npm run typecheck`、`npm run lint` 均通过。中文审校扫描阻断项为 0, 复核项为测试列举、请求数分项与必要验收边界,按事实保留。 + +## 2026-09-19 Continue 会话 MCP 交付回归 + +模型桥 helper 原先把 Session MCP 写入原生配置,但独立模型 profile 生成分支只保留 +显式能力,导致 Execute 最终配置丢失 MCP。修复后通过 run options 传递会话 HTTP +服务器;本机原生 MCP 隔离和 Ask 只读规则不变,具体机制见[Runtime](./technical-design.md#runtime)。 +`continue-acp-helper.test.ts` 联合运行实际 helper 与 adapter 配置生成器,覆盖初次交付、 +续接替换、Ask 排除、加载空列表以及本机 profile 不继承原生 MCP。 + +共享 Linux x64 Host 经 LAN 固定身份连接,使用保存的 GoodBuddy 凭据,当前源码 Agent、 +helper、adapter 与 CN 1.5.47 在专用目录运行。Main 受管 Runtime → Agent-owned ACP → +Continue → Agent HTTP MCP → Main 工具桥往返通过;仅最末端图片服务替换为无计费 +测试接收端,校验输入并返回 `SAFE_MCP_ROUNDTRIP`,未连接图片 Provider。 +同一对话依次 Execute、Ask、Execute:两次 Execute 各实际调用工具一次,Ask 没有工具调用 +并回复 `ASK_NO_IMAGE_TOOL`;三次均正常终结。最终源码再次构建后完整重复该顺序。 + +首轮成功目录 `/root/tmp/gb-cn-runtime-2kQ7wc`,最终复验目录 +`/root/tmp/gb-cn-runtime-NNM89D`。每轮 5 次真实文本 Provider HTTP 请求,合计 **10 次**, +全部 HTTP 200;每轮上限 6 次,单次最大输出 1,024 tokens,单 Prompt 验证超时 120 秒。 +图片 Provider 请求 **0 次**。此前一次测试目录权限错误在 Runtime 验证阶段退出,模型请求 +0 次;修正测试目录权限后通过,未放宽产品校验。每轮结束确认所属剩余进程 0,专用运行 +及上传目录已清理,原 Host 安装未修改。此验证不包含正式包安装、UI 点击或真实图片生成。 + +复查脚本位于上述本机临时目录:`mcp-host-build.cjs`、`mcp-host-run.cjs`, +最终日志 `mcp-host-live.log`。专项 4 文件 38 项通过;改动文件 ESLint 和 Agent 类型检查 +通过。主任务随后完成合并后的 `npm test`:405 个文件通过、9 个跳过,4,826 项通过、 +67 项跳过,耗时 982.48 秒,无失败;最终完整类型检查与 lint 通过。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 9688b3fc..e5d61f69 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -230,7 +230,13 @@ Composer Runtime 菜单显示 OpenCode、Continue 和管理入口,不显示直 DeepSeek Harness。项目与历史会话保留 OC/CN 选择;不支持的旧 selection 恢复为项目 默认值。Main 校验已安装 Runtime 身份与选择一致,按相应 CN/OC 模型 profile 建立模型桥。 本机配置的 SKILL 包和 stdio MCP Server 不上传或分配给远程 Runtime;远程 ACP Session -继续使用空 MCP Server 列表。Node.js、Python 等工具执行环境同样不修改或同步到 Host, +仅接收当前请求由 Agent 管理的 HTTP MCP 能力(例如 Execute 图片工具),不继承本机原生 +MCP 配置。Continue helper 将 ACP Session 的服务器作为显式 `sessionMcpServers` 交给 +共享 `ContinueHostAdapter`,与模型桥配置一起生成最终配置;不先写入会被独立模型分支 +丢弃的原生配置。新建、加载和续接 Session 使用当前列表,空列表清除旧能力;Ask 在 helper +和 adapter 两层均不注入这些 Execute 能力。本机独立模型 profile 仍不继承原生 MCP, +既有知识库和自定义 MCP 的显式分配规则不变。 +Node.js、Python 等工具执行环境同样不修改或同步到 Host, 完整边界见[工具执行环境](../local-tool-environment/README.md)。 ### Ask diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index 58861cc0..bdf1857f 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -188,6 +188,20 @@ Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环; **注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要 把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。 +## 解析警告被当作提取正文 + +**症状**:OCR 没有识别文字,但缺图警告让图片附件切成文字发送,或让仅图片结果被判为 +可发送正文。 + +**规则**:HTTP OCR、显式图片提取及结果完整程度共用 `hasExtractedDocumentText`。 +图片引用和解析器生成的缺图警告不计入正文;预览中的警告保留,不靠删除诊断掩盖失败。 + +**防复发**:真实 loopback HTTP 回归同时检查有/无识别文字、有效图片与缺图并存、 +显式提取结果及保存后的完整程度;无正文时必须保留原图发送方式。 + +**历史**:2026-09-19 同次复验确认 `DocumentParsingService.extractImage` 与 +`parsedCompleteness` 两处误计警告,HTTP Provider 自身的检查却正确,故统一判定函数。 + ## 列表刷新重建历史消息并触发重复保存 **症状**:焦点刷新后,正文完全未变,却再次序列化并保存全部会话消息;历史越多, diff --git a/src/agent-daemon/continue-acp-helper.test.ts b/src/agent-daemon/continue-acp-helper.test.ts new file mode 100644 index 00000000..64c29492 --- /dev/null +++ b/src/agent-daemon/continue-acp-helper.test.ts @@ -0,0 +1,119 @@ +import { readFile, rm, mkdtemp, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Agent } from '@agentclientprotocol/sdk' +import { afterEach, expect, it, vi } from 'vitest' +import { ContinueHostAdapter, type ContinueHostRunOptions } from '../main/agent/continue-host-adapter' +import { runContinueAcpHelper } from './continue-acp-helper' + +const transport = vi.hoisted(() => ({ + agent: undefined as Agent | undefined, + close: () => {}, + mode: 'execute' as 'ask' | 'execute' +})) +vi.mock('@agentclientprotocol/sdk', () => ({ + PROTOCOL_VERSION: 1, + ndJsonStream: vi.fn(), + AgentSideConnection: class { + closed = new Promise(resolve => { transport.close = resolve }) + constructor(factory: () => Agent) { transport.agent = factory() } + sessionUpdate = vi.fn() + extNotification = vi.fn() + } +})) +vi.mock('./model-bridge-helper', () => ({ + MODEL_BRIDGE_SDK_AUTH_SENTINEL: 'bridge-test-sentinel', + openCodeModelBridgeModelId: () => 'bridge-model', + ModelBridgeLoopbackProxy: class { + listen = async () => 'http://127.0.0.1:12345' + close = async () => {} + } +})) +vi.mock('./model-bridge-broker', () => ({ + createUnixModelBridgeExchange: vi.fn() +})) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + transport.agent = undefined +}) + +const server = (name: string) => ({ + name, type: 'http' as const, url: `http://127.0.0.1:12346/${name}`, + headers: [{ name: 'Authorization', value: 'Bearer session-test-token' }] +}) + +// Keep the actual helper and adapter config builder together: mocking the +// adapter constructor would miss the model-profile configuration-loss bug. +it('delivers current ACP session MCP capabilities through the bridged model config, but not Ask', async () => { + const configs: Array> = [] + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + operationId: 'operation', workMode: transport.mode + }))) + vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function ( + this: ContinueHostAdapter, _prompt, _signal, _authorize, options + ) { + const path = await (this as unknown as { + createRunConfig(options: ContinueHostRunOptions): Promise + }).createRunConfig(options!) + configs.push(JSON.parse(await readFile(path, 'utf8'))) + await rm(path) + return { text: 'OK' } + }) + const running = runContinueAcpHelper({ + socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model', + supportsImageInput: false, workMode: 'execute', sharedSessions: true, entrypoint: 'unused' + }) + try { + await vi.waitFor(() => expect(transport.agent).toBeDefined()) + const agent = transport.agent! + const { sessionId } = await agent.newSession({ cwd: tmpdir(), mcpServers: [server('first')] }) + const prompt = () => agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'OK' }] }) + await prompt() + await agent.resumeSession!({ sessionId, cwd: tmpdir(), mcpServers: [server('replacement')] }) + await prompt() + transport.mode = 'ask' + await prompt() + transport.mode = 'execute' + await agent.loadSession!({ sessionId, cwd: tmpdir(), mcpServers: [] }) + await prompt() + expect(configs.map(config => config.mcpServers ?? [])).toEqual([ + [{ name: 'first', type: 'streamable-http', url: server('first').url, + requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], + [{ name: 'replacement', type: 'streamable-http', url: server('replacement').url, + requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], + [], [] + ]) + expect(configs[0].models).toEqual([expect.objectContaining({ model: 'test-model' })]) + } finally { + transport.close() + await running + } +}) + +it('keeps local profile MCP scoping and filters explicit session servers in Ask at the adapter boundary', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-')) + try { + const configPath = join(root, 'native.json') + await writeFile(configPath, JSON.stringify({ mcpServers: [{ name: 'local-native', command: 'unused' }] })) + const adapter = new ContinueHostAdapter({ + binaryPath: 'unused', cacheRoot: root, workspace: root, configPath, + modelProfile: { id: 'test', name: 'test', modelName: 'test', + protocol: 'openai-chat-completions', authentication: 'none', baseUrl: 'http://127.0.0.1:12345' } + }) + const create = (options: ContinueHostRunOptions) => (adapter as unknown as { + createRunConfig(options: ContinueHostRunOptions): Promise + }).createRunConfig(options) + for (const workMode of ['ask', 'execute'] as const) { + const path = await create({ workMode }) + expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([]) + } + const path = await create({ workMode: 'ask', sessionMcpServers: [{ + name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} } + }] }) + expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/agent-daemon/continue-acp-helper.ts b/src/agent-daemon/continue-acp-helper.ts index 550173ce..db3a47e4 100644 --- a/src/agent-daemon/continue-acp-helper.ts +++ b/src/agent-daemon/continue-acp-helper.ts @@ -1,7 +1,7 @@ import { randomBytes, randomUUID } from 'node:crypto' import { createServer } from 'node:http' import { spawn } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Readable, Writable } from 'node:stream' @@ -88,15 +88,13 @@ export async function runContinueAcpHelper(options: { return await response.json() as { operationId: string; workMode: 'ask' | 'execute' } }) : undefined const workMode = route?.workMode ?? options.workMode - const configPath = join(root, `${sessionId}.json`) - await writeFile(configPath, JSON.stringify({ name: 'GoodBuddy', version: '1.0.0', schema: 'v1', - mcpServers: workMode === 'execute' ? session.mcpServers.map(server => { - if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP') - return { name: server.name, type: 'streamable-http', url: server.url, - requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } } - }) : [] }), { mode: 0o600 }) + const sessionMcpServers = workMode === 'execute' ? session.mcpServers.map(server => { + if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP') + return { name: server.name, type: 'streamable-http' as const, url: server.url, + requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } } + }) : [] const adapter = new ContinueHostAdapter({ - binaryPath: options.entrypoint, configPath, workspace: session.cwd, cacheRoot: root, mode: 'chat', + binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat', modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model, protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL, baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput, @@ -114,7 +112,7 @@ export async function runContinueAcpHelper(options: { try { const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal, async () => workMode === 'execute' ? 'once' : 'deny', { - workMode, images, + workMode, images, sessionMcpServers, onEvent: async event => { if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: event.delta } @@ -156,7 +154,6 @@ export async function runContinueAcpHelper(options: { } finally { await adapter.dispose() for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id) - await rm(configPath, { force: true }) session.active = undefined finish() } diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index f81e0a34..cacacb27 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -253,6 +253,13 @@ export type ContinueHostAdapterDependencies = { export type ContinueHostRunOptions = { workMode?: 'ask' | 'execute' images?: AgentImage[] + /** Explicit session capabilities, not MCP servers from a local model config. */ + sessionMcpServers?: Array<{ + name: string + type: 'streamable-http' + url: string + requestOptions: { headers: Record } + }> knowledgeCapability?: { endpoint: string token: string @@ -1257,6 +1264,9 @@ export class ContinueHostAdapter { ) } const capabilityServers = [ + ...(runOptions.workMode === 'execute' + ? runOptions.sessionMcpServers ?? [] + : []), ...(knowledgeCapability ? [ createLoopbackMcpServer( @@ -1274,6 +1284,11 @@ export class ContinueHostAdapter { ] : []) ] + if (capabilityServers.length > maximumConfiguredMcpServers) { + throw new Error( + `Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个` + ) + } const selectedPreset = presetConfig(runOptions.preset) const hasPresetContent = selectedPreset.rules.length > 0 || diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 6b3d5b79..2682a099 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -140,14 +140,16 @@ it('includes older pinned conversations beyond the recent 100 and sorts pins by database.saveLocalConversations(headers.map(header => ({ header, messages: [] }))) database.setConversationPinned({ conversationId: headers[0]!.id, pinned: true }) database.setConversationPinned({ conversationId: headers[1]!.id, pinned: true }) - const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(3).reverse().map(header => header.id)] + const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(2).reverse().map(header => header.id)] expect(database.listConversations().map(item => item.id)).toEqual(expectedIds) const summaries = database.listConversationSummaries() expect(summaries.map(item => item.id)).toEqual(expectedIds) expect(summaries.slice(0, 2).every(item => item.pinned)).toBe(true) expect(database.searchConversations('Conversation 0')).toEqual([headers[0]!.id]) database.setConversationPinned({ conversationId: headers[0]!.id, pinned: false }) - expect(database.listConversations().map(item => item.id)).toEqual(expectedIds.filter(id => id !== headers[0]!.id)) + expect(database.listConversations().map(item => item.id)).toEqual([ + headers[1]!.id, ...headers.slice(2).reverse().map(header => header.id), headers[0]!.id + ]) expect(database.getConversation(headers[0]!.id)).toMatchObject({ pinned: false, updatedAt: 1000 }) } finally { database.close() @@ -450,7 +452,7 @@ describe('AssistantDatabase', () => { } }) - it('includes old live schedule and streaming conversations beyond recent history without exposing hidden or archived entries', async () => { + it('lists all active conversations while preserving task visibility and excluding archived conversations', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-')) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') @@ -466,7 +468,7 @@ describe('AssistantDatabase', () => { projectId, title, prompt: title, recurrence: 'daily', nextRunAt: '2027-01-01T00:00:00Z' })) - const [running, approval, pending, , hidden, archived] = schedules + const [running, approval, pending, idle, hidden, archived] = schedules for (const schedule of [running!, approval!, hidden!, archived!]) { claimManualScheduleQueueItem(database, schedule.id) } @@ -516,6 +518,7 @@ describe('AssistantDatabase', () => { }) const tasks = database.listTasks(1) expect(tasks).toHaveLength(6) + expect(tasks.some(task => task.id === hidden!.taskId)).toBe(false) expect(tasks).toEqual(expect.arrayContaining([ historyTask, expect.objectContaining({ id: running!.taskId, status: 'running' }), @@ -523,18 +526,19 @@ describe('AssistantDatabase', () => { expect.objectContaining({ id: archived!.taskId, status: 'running' }) ])) const conversations = database.listConversations() - expect(conversations).toHaveLength(105) + expect(conversations).toHaveLength(109) expect(new Set(conversations.map((conversation) => conversation.id))).toEqual( new Set([ - ...recent.slice(1).map((conversation) => conversation.header.id), - running!.conversationId, approval!.conversationId, streamingId, + ...recent.map((conversation) => conversation.header.id), + running!.conversationId, approval!.conversationId, pending!.conversationId, + idle!.conversationId, hidden!.conversationId, streamingId, ...liveConversations ]) ) expect(conversations.find((conversation) => conversation.id === streamingId)) .toMatchObject({ projectId, messages: [streamingMessage] }) - expect(conversations.slice(0, 100).map((conversation) => conversation.id)) - .toEqual(recent.slice(1).reverse().map((conversation) => conversation.header.id)) + expect(conversations.slice(0, recent.length).map((conversation) => conversation.id)) + .toEqual(recent.slice().reverse().map((conversation) => conversation.header.id)) } finally { raw.close() database.close() diff --git a/src/main/document-extracted-text.test.ts b/src/main/document-extracted-text.test.ts new file mode 100644 index 00000000..15a3ee9c --- /dev/null +++ b/src/main/document-extracted-text.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { hasExtractedDocumentText } from './document-extracted-text' + +describe('extracted document text', () => { + it.each([ + '', + ' \n ', + '![第 1 页图片](asset:image)', + '[第 1 页图片未保存:未返回内联图片字节]', + '![第 1 页图片](asset:image)\n[第 1 页图片未保存:图片格式不支持]', + '

 

\n[第 2 页图片未保存:Base64 无效]' + ])('does not count image references or generated warnings as text: %s', (content) => { + expect(hasExtractedDocumentText(content)).toBe(false) + }) + + it.each([ + 'Recognized text', + '![第 1 页图片](asset:image)\n实际正文\n[第 1 页图片未保存:Base64 无效]', + '
实际表格
' + ])('retains recognized text: %s', (content) => { + expect(hasExtractedDocumentText(content)).toBe(true) + }) +}) diff --git a/src/main/document-extracted-text.ts b/src/main/document-extracted-text.ts new file mode 100644 index 00000000..da333df2 --- /dev/null +++ b/src/main/document-extracted-text.ts @@ -0,0 +1,8 @@ +import { convert } from 'html-to-text' + +export function hasExtractedDocumentText(content: string): boolean { + const text = content + .replace(/!\[[^\]]*\]\([^)]*\)/gu, '') + .replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, '') + return Boolean(convert(text, { wordwrap: false }).trim()) +} diff --git a/src/main/document-parsing-service.ts b/src/main/document-parsing-service.ts index 4b2d5f1e..c73fc9af 100644 --- a/src/main/document-parsing-service.ts +++ b/src/main/document-parsing-service.ts @@ -1,5 +1,4 @@ import { extname } from 'node:path' -import { convert } from 'html-to-text' import { documentParsingDiagnosticSchema, documentParsingSettingsUpdateSchema, @@ -16,6 +15,7 @@ import type { DocumentOcrBroker } from './document-ocr-broker' import type { DocumentOcrModelManager } from './document-ocr-model-manager' import type { DocumentParsingSettingsStore } from './document-parsing-settings-store' import { HttpDocumentOcr } from './http-document-ocr' +import { hasExtractedDocumentText } from './document-extracted-text' import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf' import type { DocumentResultStorage } from './document-result-storage' import { extractPptxPages } from './knowledge/pptx-parser' @@ -242,7 +242,7 @@ export class DocumentParsingService { warnings = result.warnings } const content = sections.map((section) => section.content).join('\n\n') - if (!convert(content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()) throw new Error('未提取到文字,原图片发送方式保持不变') + if (!hasExtractedDocumentText(content)) throw new Error('未提取到文字,原图片发送方式保持不变') ensureNotAborted(signal) return { title: name, sourceFormat: extname(name).toLowerCase(), content, sections, warnings, pageCount: 1, images, missingImages, parsingSettings: settings, parsingDurationMs: Date.now() - startedAt } } diff --git a/src/main/document-result-storage.ts b/src/main/document-result-storage.ts index 37a9a230..3c7b18be 100644 --- a/src/main/document-result-storage.ts +++ b/src/main/document-result-storage.ts @@ -1,15 +1,15 @@ import { randomUUID } from 'node:crypto' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { basename, dirname, extname, join } from 'node:path' -import { convert } from 'html-to-text' import { createCanvas, loadImage } from '@napi-rs/canvas' import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts' import type { DocumentParsingSettings } from '../shared/document-parsing-contracts' import type { ParsedDocument } from './knowledge/document-parser' +import { hasExtractedDocumentText } from './document-extracted-text' export function parsedCompleteness(parsed: ParsedDocument): DocumentResult['completeness'] { - const text = convert(parsed.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim() - return !text && parsed.images?.length ? 'images-only' : parsed.warnings.length ? 'partial' : 'complete' + return !hasExtractedDocumentText(parsed.content) && parsed.images?.length + ? 'images-only' : parsed.warnings.length ? 'partial' : 'complete' } export function originalImageMime(data: Buffer): 'image/png' | 'image/jpeg' | 'image/webp' | undefined { diff --git a/src/main/http-document-ocr.integration.test.ts b/src/main/http-document-ocr.integration.test.ts index 265d332e..a5407017 100644 --- a/src/main/http-document-ocr.integration.test.ts +++ b/src/main/http-document-ocr.integration.test.ts @@ -1,10 +1,17 @@ // @vitest-environment node import { createServer } from 'node:http' import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createCanvas } from '@napi-rs/canvas' import { expect, it } from 'vitest' import { HttpDocumentOcr } from './http-document-ocr' import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts' +import { defaultDocumentParsingSettings } from './document-parsing-settings-store' +import { DocumentParsingService } from './document-parsing-service' +import { DocumentResultStorage } from './document-result-storage' it('uses the production HTTP client for Bearer authentication, errors, cancellation and limits', async () => { const calls: Array<{ authorization?: string; path?: string }> = [] @@ -39,3 +46,60 @@ it('uses the production HTTP client for Bearer authentication, errors, cancellat await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) } }) + +it.each([false, true])('classifies missing-image warnings consistently through HTTP parsing and storage (text=%s)', async (hasText) => { + const image = createCanvas(4, 4).toBuffer('image/png') + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ocr-text-')) + const results = new DocumentResultStorage(directory) + const paths: string[] = [] + const server = createServer(async (request, response) => { + for await (const chunk of request) void chunk + paths.push(request.url ?? '') + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify({ result: { layoutParsingResults: [{ + prunedResult: {}, + markdown: { + text: `${hasText ? 'Recognized body\n' : ''}![valid](valid.png)\n![missing](missing.png)`, + images: { 'valid.png': image.toString('base64') } + } + }] } })) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + try { + const settings = { + ...defaultDocumentParsingSettings, + ocrProvider: 'paddleocr-vl' as const, + httpOcr: { + ...defaultHttpOcrSettings, + baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` + } + } + const parsed = await new HttpDocumentOcr(settings.httpOcr).recognize(image, 1, [1]) + expect(parsed.images).toHaveLength(1) + expect(parsed.missingImages).toHaveLength(1) + const saved = await results.save('scan.png', image, { + ...parsed, title: 'scan.png', sourceFormat: '.png', + content: parsed.sections.map(section => section.content).join('\n\n'), + pageCount: 1 + }, settings, 0) + expect((await results.get(saved.id)).completeness).toBe(hasText ? 'partial' : 'images-only') + expect(saved.content).toContain('图片未保存') + + const service = new DocumentParsingService({ + forOperation: async () => ({ settings, apiKey: () => undefined }) + } as never, {} as never, {} as never) + const extracted = service.extractImage('scan.png', image) + if (hasText) { + await expect(extracted).resolves.toMatchObject({ content: expect.stringContaining('Recognized body') }) + } else { + await expect(extracted).rejects.toThrow('未提取到文字,原图片发送方式保持不变') + } + expect(paths).toEqual(['/layout-parsing', '/layout-parsing']) + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + await results.close() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/src/main/http-document-ocr.ts b/src/main/http-document-ocr.ts index 8233eeea..7c2c34f9 100644 --- a/src/main/http-document-ocr.ts +++ b/src/main/http-document-ocr.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'node:crypto' import { loadImage } from '@napi-rs/canvas' -import { convert } from 'html-to-text' import { z } from 'zod' import { maximumDocumentExtractedCharacters, type httpOcrSettingsSchema } from '../shared/document-parsing-contracts' import type { ParsedDocument, ParsedSection } from './knowledge/document-parser' +import { hasExtractedDocumentText } from './document-extracted-text' type Settings = z.infer export type ParsedDocumentImage = { @@ -203,8 +203,9 @@ export class HttpDocumentOcr { } } signal?.throwIfAborted() - const text = sections.map((section) => convert(section.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, '').replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, ''), { wordwrap: false })).join('').trim() - if (!text && images.length === 0) throw new Error('HTTP OCR 未返回文字或有效图片') + if (!hasExtractedDocumentText(sections.map((section) => section.content).join('\n\n')) && images.length === 0) { + throw new Error('HTTP OCR 未返回文字或有效图片') + } return { sections, images, missingImages, warnings, ...(restructure ? { restructure } : {}) } } } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 18e0a197..52017817 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1225,6 +1225,9 @@ describe("App", () => { return item; }); vi.mocked(api.conversationQueue.remove).mockReset().mockResolvedValue(); + vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockResolvedValue({ + conversationId: "", prompt: "", attachments: [], + }); vi.mocked(api.conversationQueue.interruptAndRun) .mockReset() .mockResolvedValue(); @@ -2233,6 +2236,86 @@ describe("App", () => { ); }); + it.each(["empty", "existing", "edited", "cleared"] as const)( + "merges restored queued text into the latest %s draft", + async (draftState) => { + const conversationId = "00000000-0000-4000-8000-000000000951"; + const itemId = "00000000-0000-4000-8000-000000000953"; + vi.mocked(api.conversations.list).mockResolvedValue([{ + id: conversationId, projectId, title: "恢复草稿", updatedAt: Date.now(), messages: [], + }]); + vi.mocked(api.conversationQueue.list).mockResolvedValue([{ + id: itemId, conversationId, source: "user", label: "待恢复输入", + createdAt: "2026-09-19T00:00:00.000Z", + }]); + const response = deferred>>(); + vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockReturnValue(response.promise); + render(); + const restore = await screen.findByRole("button", { name: "恢复到草稿:待恢复输入" }); + const input = screen.getByLabelText("向 GoodBuddy 提问"); + const original = draftState === "empty" ? "" : "Original draft"; + fireEvent.change(input, { target: { value: original } }); + fireEvent.click(restore); + await waitFor(() => expect(api.conversationQueue.restoreToDraft).toHaveBeenCalledWith(itemId, "")); + expect(input).not.toBeDisabled(); + const latest = draftState === "edited" ? "Edited while restoring" + : draftState === "cleared" ? "" : original; + fireEvent.change(input, { target: { value: latest } }); + await act(async () => response.resolve({ + conversationId, prompt: "Queued prompt", attachments: [], + })); + await waitFor(() => expect(input).toHaveValue([latest, "Queued prompt"].filter(Boolean).join("\n\n"))); + }, + ); + + it("preserves draft text when queued restoration fails", async () => { + const conversationId = "00000000-0000-4000-8000-000000000951"; + vi.mocked(api.conversations.list).mockResolvedValue([{ + id: conversationId, projectId, title: "恢复失败", updatedAt: Date.now(), messages: [], + }]); + vi.mocked(api.conversationQueue.list).mockResolvedValue([{ + id: "00000000-0000-4000-8000-000000000953", conversationId, + source: "user", label: "不可恢复输入", createdAt: "2026-09-19T00:00:00.000Z", + }]); + const response = deferred>>(); + vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockReturnValue(response.promise); + render(); + fireEvent.click(await screen.findByRole("button", { name: "恢复到草稿:不可恢复输入" })); + await waitFor(() => expect(api.conversationQueue.restoreToDraft).toHaveBeenCalled()); + const input = screen.getByLabelText("向 GoodBuddy 提问"); + fireEvent.change(input, { target: { value: "Retained draft" } }); + await act(async () => response.reject(new Error("Restore failed"))); + expect(input).toHaveValue("Retained draft"); + expect(await screen.findByText("Restore failed")).toBeInTheDocument(); + }); + + it("keeps queued restoration attached to its original conversation after switching", async () => { + const conversationId = "00000000-0000-4000-8000-000000000951"; + const otherId = "00000000-0000-4000-8000-000000000954"; + const item = { + id: "00000000-0000-4000-8000-000000000953", conversationId, + source: "user" as const, label: "原会话输入", createdAt: "2026-09-19T00:00:00.000Z", + }; + vi.mocked(api.conversations.list).mockResolvedValue([ + { id: conversationId, projectId, title: "Original draft conversation", updatedAt: 200, messages: [] }, + { id: otherId, projectId, title: "Other draft conversation", updatedAt: 100, messages: [] }, + ]); + vi.mocked(api.conversationQueue.list).mockImplementation(async id => !id || id === conversationId ? [item] : []); + const response = deferred>>(); + vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockReturnValue(response.promise); + render(); + const restore = await screen.findByRole("button", { name: "恢复到草稿:原会话输入" }); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Original draft" } }); + fireEvent.click(restore); + await waitFor(() => expect(api.conversationQueue.restoreToDraft).toHaveBeenCalled()); + fireEvent.click(screen.getByText("Other draft conversation").closest("button")!); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Other draft" } }); + await act(async () => response.resolve({ conversationId, prompt: "Queued prompt", attachments: [] })); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Other draft"); + fireEvent.click(screen.getByText("Original draft conversation").closest("button")!); + await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Original draft\n\nQueued prompt")); + }); + it("preserves scoped queue updates that arrive during another refresh", async () => { const firstConversationId = "00000000-0000-4000-8000-000000000954"; const secondConversationId = "00000000-0000-4000-8000-000000000955"; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a10662bf..9709bd38 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9939,8 +9939,8 @@ function App(): React.JSX.Element { onRemove={removeConversationQueueItem} onRestore={async (itemId) => { await attachmentSaveQueue.current; - const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, input); - setInput(restored.prompt); + const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, ""); + setInput((current) => [current, restored.prompt].filter(Boolean).join("\n\n")); }} running={conversationExecutionRunning} /> diff --git a/src/renderer/src/WorkspacePrimitives.test.tsx b/src/renderer/src/WorkspacePrimitives.test.tsx index e0c759d6..5d5378b2 100644 --- a/src/renderer/src/WorkspacePrimitives.test.tsx +++ b/src/renderer/src/WorkspacePrimitives.test.tsx @@ -267,17 +267,9 @@ describe('WorkspacePrimitives', () => { }) it('keeps forced-color and docked split contracts authoritative', () => { - const forcedColorsStart = stylesheet.indexOf( - '@media (forced-colors: active)' - ) - const forcedColorsEnd = stylesheet.indexOf( - '@media (prefers-reduced-motion: reduce)', - forcedColorsStart - ) - const forcedColors = stylesheet.slice( - forcedColorsStart, - forcedColorsEnd - ) + const forcedColors = [...stylesheet.matchAll( + /@media \(forced-colors: active\)\s*\{(?:[^{}]|\{[^{}]*\})*\}/gu + )].map(([block]) => block).join('\n') expect(forcedColors).toContain(':root :is(') expect(forcedColors).not.toContain(':where(') expect(forcedColors).toMatch( diff --git a/src/renderer/src/magic-canvas/README.md b/src/renderer/src/magic-canvas/README.md index 0c3860bf..bb706a18 100644 --- a/src/renderer/src/magic-canvas/README.md +++ b/src/renderer/src/magic-canvas/README.md @@ -73,7 +73,10 @@ reference refreshes the viewer. It does not own a modal or persistence. - Pen, highlighter, whole-object eraser, Fabric selection/move/scale/rotation, floating editable text, and JPEG/PNG/GIF/WebP images. - Quill body text flows across pages. Adding a page in flow mode inserts a page - break. Deleting a page removes its break and reflows body text. Deleting a + break. Deleting a page removes only its break and reflows body text, retaining + paragraph terminators and their checklist/heading formats. Mid-paragraph + insertion uses Quill's normal block split; removing the break does not merge + the resulting paragraphs or discard either paragraph's formatting. Deleting a nonempty annotation/PDF page requires a second click within five seconds. - Undo/redo is per annotation page or Quill body mode, matching PeopleLib. Page insertion/deletion and PDF import are not document-wide undo steps. @@ -112,7 +115,11 @@ Canvas assets do not use the rich-text image or aggregate attachment budgets. The shared canvas schema and Main validation accept this image size; persistence stores decoded assets in files and hydrates them on read without a smaller byte cap. Exactly 20,000 body code units plus Quill's terminal newline round-trip intact. -Oversized user edits revert to the preceding Delta and call `onError`. +Oversized user edits apply only the rejected edit's inverse Delta and call +`onError`, preserving the previous selection and accepted undo/redo history. +The canvas-local History module validates before recording, so rejection cannot +merge into accepted typing or clear redo; other Quill editors keep their normal +History module. Rejection does not emit an accepted content change. Oversized initial/API content throws a `RangeError`; `flush()` rejects rather than returning a document with missing flow text. Callers must not save a replacement empty document after that rejection. diff --git a/src/renderer/src/magic-canvas/canvas-flow.mjs b/src/renderer/src/magic-canvas/canvas-flow.mjs index fb69d821..97865944 100644 --- a/src/renderer/src/magic-canvas/canvas-flow.mjs +++ b/src/renderer/src/magic-canvas/canvas-flow.mjs @@ -10,6 +10,24 @@ const INLINE_FORMATS = ['bold', 'italic', 'underline', 'strike', 'code']; const BLOCK_FORMATS = ['header', 'blockquote', 'code-block', 'list', 'indent']; const FLOW_FORMATS = [...INLINE_FORMATS, ...BLOCK_FORMATS, 'canvasPageBreak']; +class FlowHistory extends Quill.import('modules/history') { + record(delta, oldDelta) { + try { + normalizeFlowContent({ version: FLOW_VERSION, ops: oldDelta.compose(delta).ops }); + } catch { + // Reject before History merges the edit or clears accepted redo entries. + this.rejectedRange = this.currentRange; + return; + } + super.record(delta, oldDelta); + } +} + +// Keep the custom History module local to canvas editors. +class FlowQuill extends Quill { + static imports = { ...Quill.imports, 'modules/history': FlowHistory }; +} + function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } @@ -173,7 +191,7 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options = layerHost.appendChild(editorHost); toolbarHost.appendChild(toolbar); - const quill = new Quill(editorHost, { + const quill = new FlowQuill(editorHost, { theme: 'snow', placeholder: '输入正文,内容超过纸张后会自动分页', formats: FLOW_FORMATS, @@ -307,7 +325,18 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options = try { content(); } catch (error) { - quill.setContents(oldDelta, 'silent'); + const history = quill.history; + const range = history.rejectedRange; + history.ignoreChange = true; + try { + quill.updateContents(delta.invert(oldDelta), 'silent'); + } finally { + history.ignoreChange = false; + } + if (range) quill.setSelection(range, 'silent'); + history.currentRange = range; + history.rejectedRange = null; + suppressUserFollowSelection = false; options.onError?.(error.message); scheduleLayout(); return; @@ -359,10 +388,6 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options = if (index < 0) return false; suppressUserFollowSelection = true; quill.deleteText(index, 1, 'user'); - if (index > 0 && quill.getText(index - 1, 1) === '\n') { - suppressUserFollowSelection = true; - quill.deleteText(index - 1, 1, 'user'); - } scheduleLayout(); return true; }, diff --git a/src/renderer/src/magic-canvas/canvas-flow.test.mjs b/src/renderer/src/magic-canvas/canvas-flow.test.mjs index a41a4192..55daa7d8 100644 --- a/src/renderer/src/magic-canvas/canvas-flow.test.mjs +++ b/src/renderer/src/magic-canvas/canvas-flow.test.mjs @@ -44,12 +44,66 @@ function mount(initial) { const toolbar = document.createElement('div'); document.body.append(layer, toolbar); const onError = vi.fn(); - const editor = mountFlowText(layer, toolbar, initial, { onError }); + const onChange = vi.fn(); + const editor = mountFlowText(layer, toolbar, initial, { onError, onChange }); mounted.push(editor); - return { editor, quill: Quill.find(layer.firstElementChild), onError }; + const quill = Quill.find(layer.firstElementChild); + // jsdom has no Range geometry; actual selection scrolling runs in Electron. + vi.spyOn(quill, 'scrollSelectionIntoView').mockImplementation(() => {}); + return { editor, quill, onError, onChange }; } describe('canvas flow limits', () => { + it('keeps normal accepted undo/redo and does not replace other Quill editors history modules', () => { + const History = Quill.import('modules/history'); + const { editor, quill, onError } = mount({ version: 1, ops: [{ insert: 'x'.repeat(19995) + '\n' }] }); + quill.insertText(19995, 'HELLO', 'user'); + editor.undo(); + expect(editor.text()).toBe('x'.repeat(19995)); + editor.redo(); + expect(editor.text()).toBe('x'.repeat(19995) + 'HELLO'); + expect(onError).not.toHaveBeenCalled(); + expect(Quill.import('modules/history')).toBe(History); + expect(quill.history.constructor).not.toBe(History); + }); + + it.each([false, true])('retains accepted undo and redo after overflow (cutoff: %s)', (cutoff) => { + const { editor, quill, onError, onChange } = mount({ version: 1, ops: [{ insert: 'x'.repeat(19995) + '\n' }] }); + quill.setSelection(19995, 0, 'api'); + quill.insertText(19995, 'HELLO', 'user'); + quill.setSelection(20000, 0, 'api'); + if (cutoff) quill.history.cutoff(); + quill.insertText(20000, '!', 'user'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(1); + expect(quill.getSelection()).toEqual({ index: 20000, length: 0 }); + expect(quill.history.stack.undo).toHaveLength(1); + editor.undo(); + expect(editor.text()).toBe('x'.repeat(19995)); + expect(editor.canRedo()).toBe(true); + quill.setSelection(3, 4, 'api'); + quill.insertText(3, 'REJECTED', 'user'); + expect(quill.getSelection()).toEqual({ index: 3, length: 4 }); + expect(editor.text()).toBe('x'.repeat(19995)); + expect(editor.canUndo()).toBe(false); + expect(editor.canRedo()).toBe(true); + editor.redo(); + expect(editor.text()).toBe('x'.repeat(19995) + 'HELLO'); + }); + + it('preserves replacement selections and accepted formatting when rejecting an oversized paste', () => { + const { editor, quill } = mount({ version: 1, ops: [{ insert: 'x'.repeat(20000) + '\n' }] }); + quill.formatText(0, 5, 'bold', true, 'user'); + const accepted = editor.content(); + quill.setSelection(10, 3, 'api'); + const Delta = Quill.import('delta'); + quill.updateContents(new Delta().retain(10).delete(3).insert('TOO LONG'), 'user'); + expect(editor.content()).toEqual(accepted); + expect(quill.getSelection()).toEqual({ index: 10, length: 3 }); + editor.undo(); + expect(editor.content()).toEqual({ version: 1, ops: [{ insert: 'x'.repeat(20000) + '\n' }] }); + }); + it.each([0.25, 0.5, 2, 3])('measures logical columns and selection at %sx display scale', (scale) => { const { editor, quill } = mount({ version: 1, ops: [{ insert: 'Body\n' }] }); editor.setLayout(640, 960, 1); @@ -93,3 +147,34 @@ describe('canvas flow limits', () => { expect(quill.getText()).toHaveLength(20002); }); }); + +describe('canvas page-break removal', () => { + it.each([{ list: 'unchecked' }, { list: 'checked' }, { header: 1 }, { blockquote: true }, {}])( + 'preserves existing paragraph terminators and formats: %j', (attributes) => { + const { editor, quill } = mount({ version: 1, ops: [ + { insert: 'Task' }, { insert: '\n', attributes }, { insert: '\n' } + ] }); + const original = editor.content(); + quill.setSelection(5, 0, 'api'); + expect(editor.insertPageBreak('page-2')).toBe(true); + expect(editor.insertPageBreak('page-2')).toBe(false); + expect(editor.removePageBreak('missing')).toBe(false); + expect(editor.removePageBreak('page-2')).toBe(true); + expect(editor.content()).toEqual(original); + expect(editor.removePageBreak('page-2')).toBe(false); + } + ); + + it('retains Quill paragraph splitting and both checklist formats when inserting within a line', () => { + const { editor, quill } = mount({ version: 1, ops: [ + { insert: 'Task body' }, { insert: '\n', attributes: { list: 'unchecked' } } + ] }); + quill.setSelection(4, 0, 'api'); + editor.insertPageBreak('page-2'); + editor.removePageBreak('page-2'); + expect(editor.content()).toEqual({ version: 1, ops: [ + { insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } }, + { insert: ' body' }, { insert: '\n', attributes: { list: 'unchecked' } } + ] }); + }); +}); diff --git a/tests/canvas-core.electron.test.ts b/tests/canvas-core.electron.test.ts index 12456126..b931693d 100644 --- a/tests/canvas-core.electron.test.ts +++ b/tests/canvas-core.electron.test.ts @@ -7,6 +7,8 @@ import { join, resolve } from 'node:path' import { createServer } from 'vite' import react from '@vitejs/plugin-react' import { expect, it } from 'vitest' +import { AssistantDatabase } from '../src/main/assistant/assistant-database' +import type { MagicNoteCanvasContent } from '../src/shared/magic-notes-contracts' it('preserves annotations when flow removes the active page and renders scoped thumbnails', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-canvas-core-')) @@ -54,6 +56,33 @@ it('preserves annotations when flow removes the active page and renders scoped t expect(code, output).toBe(0) } finally { clearTimeout(timeout) } const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8')) + expect(result.checklistAfter).toEqual(result.checklistBefore) + expect(result.overflowErrors).toHaveLength(2) + expect(result.selectionAfterRejection).toEqual({ index: 20000, length: 0 }) + expect(result.selectionWithRedo).toEqual({ index: 3, length: 4 }) + expect(result.acceptedLength).toBe(20000) + expect(result.undoneLength).toBe(19995) + expect(result.retainedRedo).toBe(true) + expect(result.redoneLength).toBe(20000) + const database = new AssistantDatabase(join(directory, 'assistant.sqlite')) + try { + database.initialize(directory) + const content: MagicNoteCanvasContent = { + version: 2, kind: 'paged-canvas', assets: [], flow: result.checklistBefore, + pages: [{ id: 'page-1', width: 794, height: 1123, objects: [], + background: { type: 'template', template: 'blank' } }] + } + const entry = database.createMagicNote({ title: 'Checklist preservation', content }).entries[0]! + const todos = database.listMagicTodos() + expect(todos).toHaveLength(1) + database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, + content: { ...content, flow: result.checklistAfter }, plainText: '' }) + expect(database.listMagicTodos()).toEqual(todos) + database.close() + database.initialize(directory) + expect(database.listMagicTodos()).toEqual(todos) + expect(database.getMagicNoteEntry(entry.id).content).toEqual({ ...content, flow: result.checklistAfter }) + } finally { database.close() } console.info('Canvas Electron regression:', JSON.stringify(result)) expect(result.errors).toEqual([]) expect(result.initialPages).toBe(2) diff --git a/tests/magic-notes-layout.electron.test.ts b/tests/magic-notes-layout.electron.test.ts index e488bf6f..15f01746 100644 --- a/tests/magic-notes-layout.electron.test.ts +++ b/tests/magic-notes-layout.electron.test.ts @@ -130,7 +130,8 @@ it('resizes record columns with native Electron input and preserves desktop widt let output = '' child.stdout.on('data', data => { output += data }) child.stderr.on('data', data => { output += data }) - const timeout = setTimeout(() => child.kill(), 60000) + // Hidden Electron windows can run rAF at 1 Hz; the measured scenario takes 63s. + const timeout = setTimeout(() => child.kill(), 90000) try { const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }) expect(code, output).toBe(0) @@ -140,6 +141,6 @@ it('resizes record columns with native Electron input and preserves desktop widt expect(result.persisted).toBe(true) } finally { await server.close() - await rm(directory, { recursive: true, force: true }) + await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } -}, 90000) +}, 120000) diff --git a/tests/support/canvas-core-regression.tsx b/tests/support/canvas-core-regression.tsx index df6dc96c..ec75a4b9 100644 --- a/tests/support/canvas-core-regression.tsx +++ b/tests/support/canvas-core-regression.tsx @@ -1,4 +1,5 @@ import Quill from 'quill' +import { mountFlowText } from '../../src/renderer/src/magic-canvas/canvas-flow.mjs' import { createRoot } from 'react-dom/client' import { mountCanvasNote } from '../../src/renderer/src/magic-canvas/canvas-note.mjs' import { MagicCanvasThumbnail } from '../../src/renderer/src/MagicCanvasThumbnail' @@ -88,10 +89,47 @@ async function canvasRegression() { await editor.destroy() } host.remove() + const layer = document.createElement('div') + const toolbar = document.createElement('div') + document.body.append(layer, toolbar) + let flow = mountFlowText(layer, toolbar, { version: 1, ops: [ + { insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '\n' } + ] }) + let flowQuill = Quill.find(layer.firstElementChild!) as Quill + const checklistBefore = flow.content() + flowQuill.setSelection(5, 0, 'api') + flow.insertPageBreak('page-2') + flow.removePageBreak('page-2') + const checklistAfter = await flow.flush() + flow.destroy() + const overflowErrors: string[] = [] + flow = mountFlowText(layer, toolbar, { version: 1, ops: [{ insert: 'x'.repeat(19995) + '\n' }] }, + { onError: (error: string) => overflowErrors.push(error) }) + flowQuill = Quill.find(layer.firstElementChild!) as Quill + flowQuill.setSelection(19995, 0, 'api') + flowQuill.insertText(19995, 'HELLO', 'user') + flowQuill.setSelection(20000, 0, 'api') + flowQuill.history.cutoff() + flowQuill.insertText(20000, '!', 'user') + const selectionAfterRejection = flowQuill.getSelection() + const acceptedLength = flow.text().length + flow.undo() + const undoneLength = flow.text().length + flowQuill.setSelection(3, 4, 'api') + flowQuill.insertText(3, 'REJECTED', 'user') + const selectionWithRedo = flowQuill.getSelection() + const retainedRedo = flow.canRedo() + flow.redo() + const redoneLength = flow.text().length + flow.destroy() + layer.remove() + toolbar.remove() return { errors, initialPages: initial.pages.length, initialObjects: initial.pages[0].objects, beforeShrink, afterShrink, savedPages: saved.pages.length, savedObjects: saved.pages[0].objects, reopenedObjects: reopened.pages[0].objects, visibleInkBefore, visibleInkAfter, reopenedInk, flowTransform, - editorStyles, thumbnailStyles, thumbnailMatchesEditor, imageImports } + editorStyles, thumbnailStyles, thumbnailMatchesEditor, imageImports, + checklistBefore, checklistAfter, overflowErrors, selectionAfterRejection, selectionWithRedo, + acceptedLength, undoneLength, retainedRedo, redoneLength } } Object.assign(window, { canvasRegression })