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 })