From 4cd6e997b52fb64cd13b3e178494ba8e731e8655 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 8 Sep 2026 14:30:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.8.2=20=E5=AE=89=E5=85=A8=E7=BA=B5?= =?UTF-8?q?=E6=B7=B1=E8=A1=A5=E5=85=A8=20=C2=B7=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E4=BF=9D=E7=9C=9F=20=C2=B7=20=E6=96=AD=E9=93=BE=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20=E5=9B=BE=E7=89=87SSRF/=E6=A0=B9MEMORY.?= =?UTF-8?q?md=E4=BF=9D=E6=8A=A4=E6=A0=B9=E6=B2=BB=20=C2=B7=20Anthropic=20t?= =?UTF-8?q?hinking=E5=9B=9E=E4=BC=A0+pause=5Fturn=E7=BB=AD=E4=BC=A0=20?= =?UTF-8?q?=C2=B7=202523=20=E7=94=A8=E4=BE=8B=E5=85=A8=E9=87=8F=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=20+=20E2E=20=E6=89=A9=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 +- e2e/app-harness.ts | 70 ++ e2e/interrupt.spec.ts | 63 ++ e2e/metona.spec.ts | 94 ++- e2e/mock-llm.ts | 114 ++- .../__tests__/anthropic.adapter.test.ts | 382 ++++++++-- .../adapters/__tests__/ollama.adapter.test.ts | 135 ++-- .../__tests__/provider-request-shapes.test.ts | 10 +- .../adapters/__tests__/sse-stream.test.ts | 48 ++ .../thinking-capability-gate.test.ts | 12 + .../harness/adapters/anthropic.adapter.ts | 657 +++++++++++------- electron/harness/adapters/base-adapter.ts | 27 + electron/harness/adapters/mimo.adapter.ts | 16 +- electron/harness/adapters/ollama.adapter.ts | 21 +- electron/harness/adapters/openai.adapter.ts | 20 +- .../shared/__tests__/ssrf-image-fetch.test.ts | 179 +++++ .../adapters/shared/openai-compatible-base.ts | 2 + .../harness/adapters/shared/sse-stream.ts | 154 +++- .../adapters/shared/ssrf-image-fetch.ts | 176 +++++ .../__tests__/memory-md-gate.test.ts | 102 +++ electron/harness/agent-loop/engine.ts | 459 +++++++----- electron/harness/agent-loop/types.ts | 8 +- .../memory/__tests__/memory-manager.test.ts | 85 ++- electron/harness/memory/embedder.ts | 8 + electron/harness/memory/manager.ts | 205 +++++- .../__tests__/orchestrator.test.ts | 5 +- .../harness/orchestration/orchestrator.ts | 13 +- .../harness/tools/built-in/diff-viewer.ts | 66 +- .../harness/tools/built-in/file-editor.ts | 11 + electron/harness/tools/built-in/filesystem.ts | 10 + electron/harness/tools/built-in/ssrf-guard.ts | 5 +- electron/harness/tools/built-in/web-search.ts | 18 +- electron/harness/types/index.ts | 10 +- electron/harness/types/metona-context.ts | 61 -- electron/harness/types/metona-request.ts | 40 ++ electron/harness/types/metona-response.ts | 32 +- electron/ipc/__tests__/agent.test.ts | 7 +- electron/ipc/agent.ts | 104 ++- electron/ipc/app.ts | 21 +- electron/ipc/mcp.ts | 14 + electron/ipc/shared.ts | 10 +- electron/ipc/workspace.ts | 10 +- electron/main.ts | 12 +- electron/preload.ts | 12 +- electron/services/audit.service.ts | 8 +- electron/services/database.service.ts | 10 +- electron/services/global-config.service.ts | 82 ++- electron/services/mcp-manager.service.ts | 123 +++- electron/services/tray-manager.service.ts | 13 +- electron/services/update.service.ts | 16 + electron/services/window-manager.service.ts | 97 ++- electron/utils/__tests__/mask.test.ts | 75 ++ electron/utils/main-locale.ts | 42 +- electron/utils/mask.ts | 52 ++ electron/utils/secure-config.ts | 7 +- package-lock.json | 10 +- package.json | 2 +- src/App.tsx | 38 +- src/components/ContextMenu.tsx | 20 +- src/components/ToastContainer.tsx | 86 ++- src/components/chat/AssistantMessage.tsx | 14 +- src/components/chat/ChatInput.tsx | 66 +- src/components/chat/MessageList.tsx | 17 +- src/components/chat/ToolResultBlock.tsx | 187 ++++- src/components/chat/UserMessage.tsx | 2 +- .../chat/__tests__/ToolResultBlock.test.tsx | 76 +- src/components/layout/DetailPanel.tsx | 23 +- src/components/layout/Sidebar.tsx | 31 +- .../layout/__tests__/Sidebar.test.tsx | 125 ++++ .../onboarding/OnboardingWizard.tsx | 25 +- src/components/settings/AgentSettings.tsx | 8 +- src/components/settings/LogsSettings.tsx | 16 + src/components/settings/MCPSettings.tsx | 15 + .../settings/__tests__/LLMSettings.test.tsx | 121 ++++ src/components/trace/TokenUsage.tsx | 22 +- src/components/trace/TraceStep.tsx | 2 +- src/components/trace/TraceViewer.tsx | 19 +- src/hooks/useAgentStream.ts | 9 +- src/hooks/useKeyboardShortcuts.ts | 7 +- src/lib/chat-input-bridge.ts | 47 ++ src/lib/formatters.ts | 11 +- src/lib/i18n-strings.ts | 82 ++- src/stores/agent-store.ts | 26 +- src/stores/ui-store.ts | 68 +- src/test/setup.ts | 9 +- src/types/global.d.ts | 12 + 86 files changed, 4303 insertions(+), 956 deletions(-) create mode 100644 e2e/app-harness.ts create mode 100644 e2e/interrupt.spec.ts create mode 100644 electron/harness/adapters/shared/__tests__/ssrf-image-fetch.test.ts create mode 100644 electron/harness/adapters/shared/ssrf-image-fetch.ts create mode 100644 electron/harness/agent-loop/__tests__/memory-md-gate.test.ts delete mode 100644 electron/harness/types/metona-context.ts create mode 100644 electron/utils/__tests__/mask.test.ts create mode 100644 electron/utils/mask.ts create mode 100644 src/components/layout/__tests__/Sidebar.test.tsx create mode 100644 src/components/settings/__tests__/LLMSettings.test.tsx create mode 100644 src/lib/chat-input-bridge.ts diff --git a/README.md b/README.md index 47f1ca7..af3e8ca 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -54,19 +54,19 @@ --- -## 🆕 v0.8.1 更新亮点 +## 🆕 v0.8.2 更新亮点 | 特性 | 说明 | |:---|:---| -| ⚙️ **上下文/输出上限 全局单一配置** | 硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息钳制)——唯一合法来源是设置面板「上下文长度」与「最大输出上限」,跨 Provider/模型原样透传 | -| 🧠 **本地向量混合检索** | 激活 Ollama embeddings:记忆检索升级为 0.6×向量余弦 + 0.4×TF-IDF 混合评分,同义改写可召回;存量记忆惰性回填,嵌入不可用自动回退 TF-IDF | -| 📝 **MEMORY.md 维护闭环** | 固化去重消除全文截断盲区;两阶段维护(AI 建议 → 用户勾选 → 原子改写 + 语义记忆双轨同步);>50KB 体积告警 | -| 📊 **成本/缓存可观测闭环** | Prompt Cache 命中率与估算成本(可选单价配置)进 Token 面板;输入框新增上下文占用指示条(60%/80% 变色) | -| 🔌 **MCP Prompts/Resources 对话可用** | `/mcp:{server}:{prompt}` 斜杠菜单填充输入框;`@mcp:{server}:{uri}` 资源注入附件管线(512KB 上限、二进制拒绝) | -| 🛡 **工具自定义策略** | 每个工具可配置拒绝/允许参数正则、频率上限、强制确认(设置 → 工具管理 → 策略),保存即热生效 | -| ⏩ **批量确认 & 游标分页 & 自启** | 连续 ≥3 同类工具确认自动聚合为单弹框;会话消息游标分页(首屏 200 条向上翻页);开机自启开关 | -| 💾 **记忆生命周期接线** | 会话终态联动清理工作记忆;情节记忆 90 天 TTL 真实写入;语义记忆 access_count 检索回写(LRU 激活) | -| 🧪 **E2E 冒烟** | Playwright + Electron 端到端链路(本地 mock LLM,零外联、数据隔离);附带根治权限加固启动时序、回环代理放行、safeStorage 降级三处环境级缺陷 | +| 🛡 **适配器图片下载 SSRF 收口** | Anthropic/Ollama 的图片 URL 下载统一走 DNS Pinning 安全通道(逐跳重定向复检 + 10MB 上限 + 类型白名单),根治"诱导模型回读内网数据"的可回读外泄通道 | +| 🔐 **根 MEMORY.md 保护根治** | 闸门从工具名单枚举反转为"任意工具的路径形参数统一精确匹配"—— delete_file / file_move / MCP 文件类工具全部纳入保护 | +| 🔌 **Anthropic 协议保真** | extended thinking 块(含签名)在工具循环多轮回传;pause_turn 长回复自动续传(预算耗尽按截断语义收尾,不再静默丢失) | +| 📦 **上下文压缩正确性** | 压缩时机移至本轮消息入列后、估算纳入 System Prompt、摘要调用重试 + 纯截断兜底、故障转移后预算校正值重置 | +| 💾 **全局配置原子写 + 自愈** | global-config.json 改为 tmp+rename 原子落盘并维护备份 —— 崩溃不再丢失全部全局配置(含 LLM 凭据) | +| 🧠 **向量记忆召回修复** | 向量检索与重要度预过滤解耦(独立召回 200 条池)、embedding 模型指纹(更换模型后惰性重算自愈)、语义记忆 access_count 更新后不再归零 | +| 🎨 **工具结果富媒体渲染** | view_image / 浏览器截图在聊天流内联预览;diff_viewer / file_editor dry_run 渲染为增删着色的 unified diff 视图 | +| ⌨️ **交互断链修复** | 引用回复 / Ctrl+L 聚焦输入框(受控输入桥根治)、IPC 桥缺失兜底、根级 ErrorBoundary 逐区隔离、/export 全量导出、会话列表实时刷新、Onboarding 首帧闪烁消除 | +| 🌐 **i18n 与行为对齐** | 托盘菜单/系统对话框/剩余 8 处文案出层;MiMo 思考未配置默认关闭(尊重用户意图)、MiMo 联网搜索引用回填正文、OpenAI 推理模型 stop 参数前置拦截 | --- @@ -262,7 +262,7 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎 | 🖼️ **多轮图片记忆** | 历史轮次的图片随上下文回传 LLM(最近 10 张,从最新向前收集);多模态总开关 `llm.multimodalEnabled` 控制上传入口 | | 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s | | ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) | -| 🧵 **子任务委派** | TaskOrchestrator 支持最大 3 层深度的 SubAgent 编排 | +| 🧵 **子任务委派** | TaskOrchestrator 支持每会话最多 3 个并发 SubAgent(委派结果实时进 Trace 与 AgentMonitor) | --- @@ -338,7 +338,7 @@ SAFE (13 个) LOW (4 个) MEDIUM (8 个) HIGH ( | `lint_code` | LOW | 是 | TypeScript tsc 或 ESLint 检查(v0.7.4: 经 npx 执行工作区代码,升 LOW + 需确认 + --no-install 禁自动下载) | | `run_tests` | MEDIUM | 是 | 运行测试套件 (jest/vitest/mocha),filter 白名单防注入(v0.7.4: npm test 执行 scripts.test 任意命令,升 MEDIUM + 需确认) | | `project_info` | SAFE | 否 | 项目结构分析,4 种 detail 级别 | -| `delegate_task` | MEDIUM | 否 | 子任务委派给独立 SubAgent,最大深度 3 层 | +| `delegate_task` | MEDIUM | 否 | 子任务委派给独立 SubAgent(每会话最多 3 个并发,无递归嵌套) | #### 🔀 Git (4 tools) @@ -479,7 +479,7 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰: │ ┌─────────────────────┐ ┌──────────────────────────────────┐ │ │ │ AgentLoopEngine │ │ TaskOrchestrator │ │ │ │ ReAct 八状态机 │ │ 子任务分解 · 并行执行 · 结果聚合 │ │ -│ │ 流式解析 · 死循环检测 │ │ SubAgent 最大深度 3 层 │ │ +│ │ 流式解析 · 死循环检测 │ │ SubAgent 并发上限 3 │ │ │ └─────────────────────┘ └──────────────────────────────────┘ │ │ 类型: MetonaRequest / MetonaResponse / MetonaStreamEvent │ ├──────────────────────────────────────────────────────────────────┤ @@ -770,7 +770,7 @@ MetonaAI-Desktop/ │ │ │ ├── 📄 manager.ts # MemoryManager (TF-IDF, CJK 分词) │ │ │ └── 📄 consolidator.ts # MemoryConsolidator (LLM 提取) │ │ ├── 📂 orchestration/ # 编排 -│ │ │ └── 📄 orchestrator.ts # TaskOrchestrator (深度限制 3) +│ │ │ └── 📄 orchestrator.ts # TaskOrchestrator (并发委派上限 3) │ │ ├── 📂 prompts/ # 提示词构建 │ │ │ └── 📄 context-builder.ts # ContextBuilder (SOUL + MEMORY) │ │ ├── 📂 hooks/ # 钩子 diff --git a/e2e/app-harness.ts b/e2e/app-harness.ts new file mode 100644 index 0000000..ded0e0d --- /dev/null +++ b/e2e/app-harness.ts @@ -0,0 +1,70 @@ +/** + * E2E 测试公共设施(v0.8.2 P3-3) + * + * 封装"启动应用(隔离 userData)+ 本地 mock LLM + 清理"的完整生命周期。 + * 每个测试文件独立调用 launchApp() —— 应用实例与 mock 互相隔离,杜绝跨用例 + * 的运行态竞态(中断链路对"上一 run 的收尾兜底定时器"这类跨用例污染敏感)。 + */ + +import type { ElectronApplication, Page } from '@playwright/test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { _electron as electron } from 'playwright'; +import { startMockLLM, type MockLLMHandle } from './mock-llm'; + +export interface AppHarness { + electronApp: ElectronApplication; + page: Page; + mock: MockLLMHandle; + userDataDir: string; + workspaceDir: string; + /** 关闭应用 + mock + 清理临时目录 */ + cleanup: () => Promise; +} + +export async function launchApp(): Promise { + // 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1) + const mock = await startMockLLM(); + + // 2. 隔离的用户数据与工作空间目录 + const userDataDir = mkdtempSync(join(tmpdir(), 'metona-e2e-user-')); + const workspaceDir = join(userDataDir, 'workspace'); + mkdirSync(workspaceDir, { recursive: true }); + // workspace-config.json 指向隔离工作空间(main.ts 启动时优先读取) + writeFileSync( + join(userDataDir, 'workspace-config.json'), + JSON.stringify({ workspacePath: workspaceDir }), + 'utf-8', + ); + + // 3. 启动 Electron(产物构建由 npm run test:e2e 的 build 步骤保证) + const electronApp = await electron.launch({ + args: ['.'], + env: { + ...process.env, + METONA_USER_DATA_DIR: userDataDir, + METONA_E2E_SEED_CONFIG: '1', + METONA_E2E_LLM_URL: mock.url, + // 隔离冒烟环境无更新源(生产环境 updater 由 feedUrl 空值禁用,双保险) + METONA_E2E_DISABLE_UPDATE: '1', + } as Record, + }); + const page = await electronApp.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + + const cleanup = async (): Promise => { + await electronApp.close(); + await mock.close(); + // 调试期保留 userData(main.log 排查用);稳定后恢复清理 + if (!process.env['METONA_E2E_KEEP_USER_DATA']) { + try { + rmSync(userDataDir, { recursive: true, force: true }); + } catch { + /* Windows 文件句柄释放延迟 — 清理失败不影响断言 */ + } + } + }; + + return { electronApp, page, mock, userDataDir, workspaceDir, cleanup }; +} diff --git a/e2e/interrupt.spec.ts b/e2e/interrupt.spec.ts new file mode 100644 index 0000000..c45b1db --- /dev/null +++ b/e2e/interrupt.spec.ts @@ -0,0 +1,63 @@ +/** + * MetonaAI Desktop — E2E 中断链路(v0.8.2 P3-3) + * + * 独立应用实例(独立 userData + mock),杜绝跨用例运行态竞态: + * 发送挂起脚本(mock 首帧后保持连接、心跳保活)→ 流式态出现中断按钮 → + * 点击中断 → 恢复 idle → 再次发送正常消息可完成(恢复力闭环)。 + */ + +import { expect, test } from '@playwright/test'; +import { launchApp, type AppHarness } from './app-harness'; +import { HANG_PREFIX, MOCK_REPLY } from './mock-llm'; + +let harness: AppHarness; + +test.beforeAll(async () => { + harness = await launchApp(); +}); + +test.afterAll(async () => { + await harness?.cleanup(); +}); + +test('中断:挂起的流式回复 → 中断 → 恢复 idle → 可继续对话', async () => { + const page = harness.page; + const input = page.locator('[data-chat-input] textarea').first(); + await expect(input).toBeVisible({ timeout: 30_000 }); + + // 发送挂起脚本:mock 推送首帧后保持连接(心跳保活,不会自然结束) + await input.click(); + await input.fill('请执行 __E2E_HANG__ 脚本'); + await input.press('Enter'); + + // 流式态:挂起脚本的首帧文本可见 + await expect(page.getByText(HANG_PREFIX).first()).toBeVisible({ timeout: 30_000 }); + + // 中断按钮出现(发送按钮在流式态变为「中断」) + const stopButton = page.getByRole('button', { name: /中断|Stop/ }).first(); + await expect(stopButton).toBeVisible({ timeout: 15_000 }); + await stopButton.click(); + + // 中断后恢复 idle:输入框重新可用 + await expect(input).toBeEnabled({ timeout: 15_000 }); + + // 恢复力闭环:中断后可继续正常对话(新 run → mock 正常回复)。 + // abort 点击后引擎需短暂时间真正终止(waitForAbort),期间的发送会被 + // 同会话防重入拒绝(busy 提示)—— 以"忙则等待重试"保证确定性。 + const busyHint = page.getByText(/该会话正在执行任务|already running/).first(); + let sent = false; + for (let attempt = 0; attempt < 10 && !sent; attempt++) { + await input.click(); + await input.fill('中断后请再回复一次'); + await input.press('Enter'); + try { + await busyHint.waitFor({ state: 'visible', timeout: 1_500 }); + await page.waitForTimeout(1_000); + } catch { + sent = true; // 未出现 busy 提示 → 本次发送已被接受 + } + } + expect(sent).toBe(true); + // 独立实例中首条 MOCK_REPLY 即恢复对话的回答 + await expect(page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 }); +}); diff --git a/e2e/metona.spec.ts b/e2e/metona.spec.ts index d2f349d..e0d4317 100644 --- a/e2e/metona.spec.ts +++ b/e2e/metona.spec.ts @@ -1,77 +1,36 @@ /** - * MetonaAI Desktop — E2E 冒烟测试(v0.8.1 P2-5) + * MetonaAI Desktop — E2E 冒烟测试(v0.8.1 P2-5 引入,v0.8.2 P3-3 扩充) * * 端到端链路(Playwright + Electron): * 启动应用(隔离 userData)→ 跳过引导(种子配置)→ 新建会话 → 输入消息 → * 发送 → 引擎调用本地 mock LLM(SSE 流式)→ 断言回复渲染 + mock 收到合法 - * 请求体(携带「最大输出上限」设置值)→ 中断按钮恢复 idle。 + * 请求体(携带「最大输出上限」设置值)+ 多轮工具调用闭环。 + * + * 中断链路见 interrupt.spec.ts(独立应用实例,杜绝跨用例运行态竞态)。 * * 契约:不触外网(LLM 指向 127.0.0.1 随机端口 mock)、不污染真实用户数据 * (METONA_USER_DATA_DIR 重定向)、断言的配置值即设置面板合法配置项。 */ -import { expect, test, type ElectronApplication, type Page } from '@playwright/test'; -import { _electron as electron } from 'playwright'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { startMockLLM, MOCK_REPLY, type MockLLMHandle } from './mock-llm'; +import { expect, test } from '@playwright/test'; +import { launchApp, type AppHarness } from './app-harness'; +import { MOCK_REPLY, TOOL_REPLY } from './mock-llm'; -let electronApp: ElectronApplication; -let page: Page; -let mock: MockLLMHandle; -let userDataDir: string; -let workspaceDir: string; +let harness: AppHarness; test.beforeAll(async () => { - // 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1) - mock = await startMockLLM(); - - // 2. 隔离的用户数据与工作空间目录 - userDataDir = mkdtempSync(join(tmpdir(), 'metona-e2e-user-')); - workspaceDir = join(userDataDir, 'workspace'); - mkdirSync(workspaceDir, { recursive: true }); - // workspace-config.json 指向隔离工作空间(main.ts 启动时优先读取) - writeFileSync( - join(userDataDir, 'workspace-config.json'), - JSON.stringify({ workspacePath: workspaceDir }), - 'utf-8', - ); - - // 3. 启动 Electron(产物构建由 npm run test:e2e 的 build 步骤保证) - electronApp = await electron.launch({ - args: ['.'], - env: { - ...process.env, - METONA_USER_DATA_DIR: userDataDir, - METONA_E2E_SEED_CONFIG: '1', - METONA_E2E_LLM_URL: mock.url, - // 隔离冒烟环境无更新源(生产环境 updater 由 feedUrl 空值禁用,双保险) - METONA_E2E_DISABLE_UPDATE: '1', - } as Record, - }); - page = await electronApp.firstWindow(); - await page.waitForLoadState('domcontentloaded'); + harness = await launchApp(); }); test.afterAll(async () => { - await electronApp?.close(); - await mock?.close(); - // 调试期保留 userData(main.log 排查用);稳定后恢复清理 - if (!process.env['METONA_E2E_KEEP_USER_DATA']) { - try { - rmSync(userDataDir, { recursive: true, force: true }); - } catch { - /* Windows 文件句柄释放延迟 — 清理失败不影响断言 */ - } - } + await harness?.cleanup(); }); test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () => { // 等待聊天输入框可用(配置加载 + 工具就绪后启用) // InputBase 把 data-chat-input 挂在根 div —— 实际可编辑元素是内部 textarea //(MUI 另渲染一个 readonly 隐藏 textarea 用于测量,需用 .first() 取可交互的) - const input = page.locator('[data-chat-input] textarea').first(); + const input = harness.page.locator('[data-chat-input] textarea').first(); await expect(input).toBeVisible({ timeout: 30_000 }); // 发送一条用户消息 @@ -80,18 +39,43 @@ test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () await input.press('Enter'); // mock LLM 的流式回复出现在聊天区(引擎 → SSE → 渲染层全链路) - await expect(page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 }); + await expect(harness.page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 }); // 用户消息持久化回显 - await expect(page.getByText('你好,请做一个冒烟回复').first()).toBeVisible(); + await expect(harness.page.getByText('你好,请做一个冒烟回复').first()).toBeVisible(); }); test('冒烟:引擎请求体携带设置面板「最大输出上限」配置(2048)', async () => { // beforeAll 后发送的第一条消息已在 mock.requests 中(顺序执行时上一测试已完成) - const chatRequests = mock.requests.filter((r) => 'messages' in r); + const chatRequests = harness.mock.requests.filter((r) => 'messages' in r); expect(chatRequests.length).toBeGreaterThanOrEqual(1); // llm.maxTokens 原样透传(无任何按模型钳制) expect(chatRequests[0].max_tokens).toBe(2048); // 请求发往本地 mock(经 adapter 组装),模型名来自种子配置 expect(chatRequests[0].model).toBe('deepseek-v4-flash'); }); + +test('冒烟:多轮工具调用 → 工具结果回传 → 最终回答渲染', async () => { + const input = harness.page.locator('[data-chat-input] textarea').first(); + await expect(input).toBeVisible({ timeout: 30_000 }); + await input.click(); + await input.fill('请执行 __E2E_TOOL_CALL__ 脚本'); + await input.press('Enter'); + + // 第二轮:mock 收到带 tool 消息的请求后返回最终回答(ReAct 闭环全链路) + await expect(harness.page.getByText(TOOL_REPLY).first()).toBeVisible({ timeout: 30_000 }); + + // 工具调用卡片(think)存在于聊天流(卡片容器可能滚动出视口,用 attached 断言) + await expect(harness.page.getByText('think').first()).toBeAttached(); + + // mock 至少收到两条 chat 请求:首轮 tool_calls + 次轮带 tool 结果 + const chatRequests = harness.mock.requests.filter( + (r) => 'messages' in r && (r as { messages?: unknown[] }).messages?.length, + ); + const toolCallReq = chatRequests.find((r) => JSON.stringify(r).includes('__E2E_TOOL_CALL__')); + const toolResultReq = chatRequests.find((r) => + (r as { messages?: Array<{ role?: string }> }).messages?.some((m) => m.role === 'tool'), + ); + expect(toolCallReq).toBeDefined(); + expect(toolResultReq).toBeDefined(); +}); diff --git a/e2e/mock-llm.ts b/e2e/mock-llm.ts index 6248bbc..c8e423e 100644 --- a/e2e/mock-llm.ts +++ b/e2e/mock-llm.ts @@ -6,6 +6,12 @@ * finish_reason=stop + usage + [DONE]; * - POST /chat/completions(stream=false):非流式 JSON(压缩摘要等内部调用兜底)。 * + * v0.8.2 P3-3: 新增两条可控脚本(内容标记协议,零外部依赖): + * - `__E2E_TOOL_CALL__`:返回 tool_calls(think 工具);后续请求带 tool 消息时 + * 返回最终文本 —— 锁定"多轮工具调用 → 观察 → 最终回答"全链路; + * - `__E2E_HANG__`:推送首帧后挂起连接(不结束、不推 finish_reason)—— + * 供中断链路测试:客户端 abort 时服务端感知连接关闭。 + * * 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。 */ @@ -15,6 +21,12 @@ import type { AddressInfo } from 'net'; /** 流式回复的固定文本(断言锚点) */ export const MOCK_REPLY = 'Hello from mock LLM. E2E smoke reply.'; +/** 多轮工具调用脚本的最终回复(断言锚点) */ +export const TOOL_REPLY = 'Tool flow completed OK.'; + +/** 中断脚本的流式前缀(挂起前推送的第一帧文本) */ +export const HANG_PREFIX = 'Hanging stream...'; + export interface MockLLMHandle { url: string; close: () => Promise; @@ -22,6 +34,10 @@ export interface MockLLMHandle { readonly requests: Array>; } +function frame(res: import('http').ServerResponse, payload: Record): void { + res.write(`data: ${JSON.stringify(payload)}\n\n`); +} + export function startMockLLM(): Promise { const requests: Array> = []; const server: Server = createServer((req, res) => { @@ -43,6 +59,17 @@ export function startMockLLM(): Promise { requests.push(parsed); const isStream = parsed.stream === true; + const messages = (parsed.messages as Array>) ?? []; + const hasToolResult = messages.some((m) => m.role === 'tool'); + // 脚本标记只看**最后一条用户消息**:恢复请求的历史里会带着早前的 + // __E2E_HANG__ 消息,若按全量 JSON 匹配会把恢复请求也误判为挂起脚本。 + const lastUserContent = [...messages] + .reverse() + .find((m) => m.role === 'user'); + const lastUserText = typeof lastUserContent?.content === 'string' ? lastUserContent.content : ''; + const wantsToolCall = lastUserText.includes('__E2E_TOOL_CALL__') && !hasToolResult; + const wantsHang = lastUserText.includes('__E2E_HANG__'); + if (!isStream) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -67,17 +94,90 @@ export function startMockLLM(): Promise { 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); - const frame = (payload: Record): void => { - res.write(`data: ${JSON.stringify(payload)}\n\n`); - }; - // 首 chunk:正文增量 - frame({ + + // ===== 中断脚本:首帧后挂起,客户端 abort 时感知连接关闭 ===== + if (wantsHang) { + frame(res, { + id: 'mock-hang', + model: parsed.model ?? 'mock', + choices: [{ index: 0, delta: { role: 'assistant', content: HANG_PREFIX } }], + }); + // 挂起即可:不推 finish_reason、不 [DONE]、不 end。 + // 注意不要监听 req 'close' —— Node>=16 该事件在请求体接收完成时即触发 + //(非连接关闭),主动 destroy 会把挂起变成 'terminated' 断流。 + // 客户端 abort 时 socket 关闭由 HTTP 栈自然回收。 + // 心跳:每秒发一条 SSE 注释行(':' 前缀,解析器按非 data 帧忽略)—— + // 保持连接健康,防止 keep-alive/空闲机制把"挂起"变成提前断流, + // 使中断测试与引擎 ERROR 收尾产生竞态。 + const heartbeat = setInterval(() => { + try { + res.write(': keepalive\n\n'); + } catch { + clearInterval(heartbeat); + } + }, 1_000); + heartbeat.unref?.(); + return; + } + + // ===== 多轮工具调用脚本 ===== + if (wantsToolCall) { + frame(res, { + id: 'mock-tool', + model: parsed.model ?? 'mock', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'call_e2e_1', + type: 'function', + function: { name: 'think', arguments: '{"thought":"mock tool call"}' }, + }, + ], + }, + }, + ], + }); + frame(res, { + id: 'mock-tool', + model: parsed.model ?? 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 12, completion_tokens: 10, total_tokens: 22 }, + }); + res.write('data: [DONE]\n\n'); + res.end(); + return; + } + + if (hasToolResult) { + // 工具结果已回传 → 最终回答 + frame(res, { + id: 'mock-tool-2', + model: parsed.model ?? 'mock', + choices: [{ index: 0, delta: { role: 'assistant', content: TOOL_REPLY } }], + }); + frame(res, { + id: 'mock-tool-2', + model: parsed.model ?? 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 20, completion_tokens: 8, total_tokens: 28 }, + }); + res.write('data: [DONE]\n\n'); + res.end(); + return; + } + + // ===== 默认冒烟脚本 ===== + frame(res, { id: 'mock-1', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }], }); - // 末帧:finish_reason + usage - frame({ + frame(res, { id: 'mock-1', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], diff --git a/electron/harness/adapters/__tests__/anthropic.adapter.test.ts b/electron/harness/adapters/__tests__/anthropic.adapter.test.ts index 7293d0b..0b2443d 100644 --- a/electron/harness/adapters/__tests__/anthropic.adapter.test.ts +++ b/electron/harness/adapters/__tests__/anthropic.adapter.test.ts @@ -15,6 +15,7 @@ vi.mock('electron-log', () => ({ })); import { AnthropicAdapter } from '../anthropic.adapter'; +import { __imageFetcher } from '../shared/ssrf-image-fetch'; import { ContentFilterError } from '../base-adapter'; import type { MetonaRequest, MetonaStreamEvent } from '../../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../../types'; @@ -235,17 +236,60 @@ describe('AnthropicAdapter — 图片块转换', () => { it('http URL 图片 → 下载后转 base64(content-type 作为 media_type)', async () => { const adapter = makeAdapter(); const imageBytes = new TextEncoder().encode('PNG-DATA'); - // 第一个 fetch(图片下载)返回二进制;第二个 fetch(chat)返回 ok - mockFetch - .mockResolvedValueOnce({ + // v0.8.2 P0-1: 图片下载改走 SSRF 安全通道(独立下载器注入点,不走 global fetch) + const fetchRestore = __imageFetcher.current; + __imageFetcher.current = (async () => + new Response(imageBytes, { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + })) as typeof __imageFetcher.current; + try { + // chat 请求返回 ok + mockFetch.mockResolvedValue(okResponse()); + await adapter.send( + makeRequest({ + messages: [ + { + role: 'user', + content: '看图', + images: [{ url: 'https://example.com/pic.jpg' }], + timestamp: Date.now(), + }, + ], + }), + ); + const body = lastBody(); + const userMsg = (body.messages as Array<{ content: Array> }>)[0]; + const imageBlock = userMsg.content.find((c) => c.type === 'image'); + expect(imageBlock).toMatchObject({ + type: 'image', + source: { type: 'base64', media_type: 'image/jpeg' }, + }); + const source = (imageBlock as { source: { data: string } }).source; + expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA'); + } finally { + __imageFetcher.current = fetchRestore; + } + }); + + it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => { + const adapter = makeAdapter(); + // v0.8.2 P0-1: 下载失败同样走注入点(SSRF 拒绝/网络失败均降级为跳过图片) + const fetchRestore = __imageFetcher.current; + __imageFetcher.current = (async () => { + throw new Error('ECONNREFUSED'); + }) as typeof __imageFetcher.current; + try { + mockFetch.mockResolvedValue({ ok: true, status: 200, - headers: { get: () => 'image/jpeg' }, - arrayBuffer: async () => imageBytes.buffer, - } as unknown as Response) - .mockResolvedValue(okResponse()); - await adapter.send( - makeRequest({ + json: async () => ({ + content: [{ type: 'text', text: 'ok' }], + usage: {}, + stop_reason: 'end_turn', + }), + } as unknown as Response); + const request = makeRequest({ messages: [ { role: 'user', @@ -254,42 +298,12 @@ describe('AnthropicAdapter — 图片块转换', () => { timestamp: Date.now(), }, ], - }), - ); - const body = lastBody(); - const userMsg = (body.messages as Array<{ content: Array> }>)[0]; - const imageBlock = userMsg.content.find((c) => c.type === 'image'); - expect(imageBlock).toMatchObject({ - type: 'image', - source: { type: 'base64', media_type: 'image/jpeg' }, - }); - const source = (imageBlock as { source: { data: string } }).source; - expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA'); - }); - - it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => { - const adapter = makeAdapter(); - mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - content: [{ type: 'text', text: 'ok' }], - usage: {}, - stop_reason: 'end_turn', - }), - } as unknown as Response); - const request = makeRequest({ - messages: [ - { - role: 'user', - content: '看图', - images: [{ url: 'https://example.com/pic.jpg' }], - timestamp: Date.now(), - }, - ], - }); - const res = await adapter.send(request); - expect(res.content).toBe('ok'); // 请求未被阻断 + }); + const res = await adapter.send(request); + expect(res.content).toBe('ok'); // 请求未被阻断 + } finally { + __imageFetcher.current = fetchRestore; + } }); it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => { @@ -970,3 +984,281 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => { expect(mockFetch).not.toHaveBeenCalled(); }); }); + +// ===== v0.8.2 P1-1: thinking 块回传 + pause_turn 续传 ===== + +describe('AnthropicAdapter — v0.8.2 P1-1', () => { + function okJson(data: Record): Response { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + + function sse(lines: string[]): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(lines.join('\n') + '\n')); + controller.close(); + }, + }); + return new Response(body, { status: 200 }); + } + + it('流式 DONE 携带带签名的 thinking 块(redacted 原样)', async () => { + const adapter = makeAdapter(); + mockFetch.mockResolvedValue( + sse([ + 'event: message_start', + jsonLine({ type: 'message_start', message: { usage: { input_tokens: 10 } } }), + 'event: content_block_start', + jsonLine({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + 'event: content_block_delta', + jsonLine({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'deep thought' }, + }), + 'event: content_block_delta', + jsonLine({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'sig-abc' }, + }), + 'event: content_block_stop', + jsonLine({ type: 'content_block_stop', index: 0 }), + 'event: content_block_start', + jsonLine({ + type: 'content_block_start', + index: 1, + content_block: { type: 'redacted_thinking', data: 'opaque' }, + }), + 'event: content_block_stop', + jsonLine({ type: 'content_block_stop', index: 1 }), + 'event: content_block_start', + jsonLine({ + type: 'content_block_start', + index: 2, + content_block: { type: 'text', text: '' }, + }), + 'event: content_block_delta', + jsonLine({ + type: 'content_block_delta', + index: 2, + delta: { type: 'text_delta', text: 'Answer' }, + }), + 'event: content_block_stop', + jsonLine({ type: 'content_block_stop', index: 2 }), + 'event: message_delta', + jsonLine({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 20 }, + }), + 'event: message_stop', + jsonLine({ type: 'message_stop' }), + ]), + ); + const events = await collectStream(adapter, makeRequest({ params: { stream: true } })); + const done = events.find((e) => e.type === MetonaStreamEventType.DONE); + expect(done).toBeDefined(); + expect(done!.finishReason).toBe('stop'); + expect(done!.thinkingBlocks).toEqual([ + { type: 'thinking', thinking: 'deep thought', signature: 'sig-abc' }, + { type: 'redacted_thinking', data: 'opaque' }, + ]); + }); + + it('下一轮请求按协议回传 thinking 块(thinking 开启时块前置;关闭时不回传)', async () => { + const adapter = makeAdapter(); + const thinkingBlocks = [ + { type: 'thinking' as const, thinking: 'deep', signature: 'sig' }, + { type: 'redacted_thinking' as const, data: 'opaque' }, + ]; + const withHistory = makeRequest({ + params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true }, + messages: [ + { role: 'user', content: 'q', timestamp: 1 }, + { + role: 'assistant', + content: null, + thinkingBlocks, + toolCalls: [{ id: 'tc1', name: 'read_file', args: {}, iteration: 1, timestamp: 1 }], + timestamp: 1, + }, + { + role: 'tool', + content: 'data', + toolResult: { + toolCallId: 'tc1', + toolName: 'read_file', + result: 'data', + success: true, + durationMs: 1, + timestamp: 1, + }, + timestamp: 1, + }, + { role: 'user', content: 'go on', timestamp: 2 }, + ], + }); + mockFetch.mockResolvedValue( + okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }), + ); + await adapter.send(withHistory); + const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record< + string, + unknown + >; + const assistant = ( + body.messages as Array<{ role: string; content: Array> }> + ).find( + (m) => + m.role === 'assistant' && + Array.isArray(m.content) && + m.content.some((c) => c.type === 'tool_use'), + ); + expect(assistant).toBeDefined(); + // thinking 块位于 assistant content 首位(协议要求) + expect(assistant!.content[0]).toEqual({ type: 'thinking', thinking: 'deep', signature: 'sig' }); + expect(assistant!.content[1]).toEqual({ type: 'redacted_thinking', data: 'opaque' }); + + // 思考关闭(降级重试路径)→ 不回传 thinking 块 + const disabled = makeRequest({ + params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false }, + messages: withHistory.messages, + }); + mockFetch.mockReset(); + mockFetch.mockResolvedValue( + okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }), + ); + await adapter.send(disabled); + const body2 = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record< + string, + unknown + >; + const assistant2 = ( + body2.messages as Array<{ role: string; content: Array> }> + ).find( + (m) => + m.role === 'assistant' && + Array.isArray(m.content) && + m.content.some((c) => c.type === 'tool_use'), + ); + expect( + assistant2!.content.some((c) => c.type === 'thinking' || c.type === 'redacted_thinking'), + ).toBe(false); + }); + + it('pause_turn 流式自动续传:两段文本拼接、单次 DONE、续传请求原样携带本段 content', async () => { + const adapter = makeAdapter(); + mockFetch + .mockResolvedValueOnce( + sse([ + 'event: message_start', + jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }), + 'event: content_block_start', + jsonLine({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + 'event: content_block_delta', + jsonLine({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'part1' }, + }), + 'event: content_block_stop', + jsonLine({ type: 'content_block_stop', index: 0 }), + 'event: content_block_start', + jsonLine({ + type: 'content_block_start', + index: 1, + content_block: { type: 'pause_turn' }, + }), + 'event: content_block_stop', + jsonLine({ type: 'content_block_stop', index: 1 }), + 'event: message_delta', + jsonLine({ + type: 'message_delta', + delta: { stop_reason: 'pause_turn' }, + usage: { output_tokens: 10 }, + }), + 'event: message_stop', + jsonLine({ type: 'message_stop' }), + ]), + ) + .mockResolvedValueOnce( + sse([ + 'event: message_start', + jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }), + 'event: content_block_start', + jsonLine({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + 'event: content_block_delta', + jsonLine({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'part2' }, + }), + 'event: content_block_stop', + jsonLine({ type: 'content_block_stop', index: 0 }), + 'event: message_delta', + jsonLine({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 10 }, + }), + 'event: message_stop', + jsonLine({ type: 'message_stop' }), + ]), + ); + const events = await collectStream(adapter, makeRequest({ params: { stream: true } })); + const texts = events + .filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA) + .map((e) => e.delta); + expect(texts.join('')).toBe('part1part2'); + const dones = events.filter((e) => e.type === MetonaStreamEventType.DONE); + expect(dones).toHaveLength(1); + expect(dones[0].finishReason).toBe('stop'); + + // 第二次请求体应把第一段 content(含 pause_turn 块)原样追加为 assistant 消息 + const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as { + messages: Array<{ role: string; content: Array> }>; + }; + const carried = secondBody.messages[secondBody.messages.length - 1]; + expect(carried.role).toBe('assistant'); + expect(carried.content.some((c) => c.type === 'text' && c.text === 'part1')).toBe(true); + expect(carried.content.some((c) => c.type === 'pause_turn')).toBe(true); + }); + + it('非流式 pause_turn 同样续传至自然结束', async () => { + const adapter = makeAdapter(); + mockFetch + .mockResolvedValueOnce( + okJson({ + content: [{ type: 'text', text: 'half' }, { type: 'pause_turn' }], + usage: {}, + stop_reason: 'pause_turn', + }), + ) + .mockResolvedValueOnce( + okJson({ content: [{ type: 'text', text: 'done' }], usage: {}, stop_reason: 'end_turn' }), + ); + const res = await adapter.send(makeRequest()); + expect(res.finishReason).toBe(MetonaFinishReason.STOP); + expect(mockFetch).toHaveBeenCalledTimes(2); + const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as { + messages: Array<{ role: string }>; + }; + expect(secondBody.messages[secondBody.messages.length - 1].role).toBe('assistant'); + }); +}); diff --git a/electron/harness/adapters/__tests__/ollama.adapter.test.ts b/electron/harness/adapters/__tests__/ollama.adapter.test.ts index c2c9a54..b8f5a33 100644 --- a/electron/harness/adapters/__tests__/ollama.adapter.test.ts +++ b/electron/harness/adapters/__tests__/ollama.adapter.test.ts @@ -16,6 +16,9 @@ vi.mock('electron-log', () => ({ })); import { OllamaAdapter } from '../ollama.adapter'; +// v0.8.2 P0-1: 图片下载走 SSRF 安全通道(注入点替代 global fetch 打桩) +import { __imageFetcher } from '../shared/ssrf-image-fetch'; +import type { ImageFetcher } from '../shared/ssrf-image-fetch'; import type { MetonaRequest } from '../../types'; import { MetonaStreamEventType } from '../../types'; @@ -604,48 +607,61 @@ describe('OllamaAdapter — 图片归一化', () => { it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => { const adapter = makeAdapter(); const imageBytes = new TextEncoder().encode('IMG-BYTES'); - mockFetch - .mockResolvedValueOnce({ - ok: true, + // v0.8.2 P0-1: 下载经 SSRF 安全通道(注入受控下载器) + const restore = __imageFetcher.current; + __imageFetcher.current = (async () => + new Response(imageBytes, { status: 200, - arrayBuffer: async () => imageBytes.buffer, - } as unknown as Response) - .mockResolvedValue(okChatResponse()); - await adapter.send( - makeRequest({ - messages: [ - { - role: 'user', - content: '看图', - images: [{ url: 'https://example.com/pic.png' }], - timestamp: Date.now(), - }, - ], - }), - ); - const body = lastBody(); - const userMsg = (body.messages as Array>).find( - (m) => m.role === 'user', - ); - expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]); + headers: { 'content-type': 'image/png' }, + })) as unknown as ImageFetcher; + try { + mockFetch.mockResolvedValue(okChatResponse()); + await adapter.send( + makeRequest({ + messages: [ + { + role: 'user', + content: '看图', + images: [{ url: 'https://example.com/pic.png' }], + timestamp: Date.now(), + }, + ], + }), + ); + const body = lastBody(); + const userMsg = (body.messages as Array>).find( + (m) => m.role === 'user', + ); + expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]); + } finally { + __imageFetcher.current = restore; + } }); it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => { const adapter = makeAdapter(); - mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(okChatResponse()); - const res = await adapter.send( - makeRequest({ - messages: [ - { - role: 'user', - content: '看图', - images: [{ url: 'https://example.com/pic.png' }], - timestamp: Date.now(), - }, - ], - }), - ); - expect(res.content).toBe('ok'); + const restore = __imageFetcher.current; + __imageFetcher.current = (async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as ImageFetcher; + try { + mockFetch.mockResolvedValue(okChatResponse()); + const res = await adapter.send( + makeRequest({ + messages: [ + { + role: 'user', + content: '看图', + images: [{ url: 'https://example.com/pic.png' }], + timestamp: Date.now(), + }, + ], + }), + ); + expect(res.content).toBe('ok'); + } finally { + __imageFetcher.current = restore; + } }); it('data URI 图片剥前缀;纯 base64 原样保留', async () => { @@ -675,26 +691,31 @@ describe('OllamaAdapter — 图片归一化', () => { it('HTTP 下载响应非 2xx → 图片降级忽略', async () => { const adapter = makeAdapter(); - mockFetch - .mockResolvedValueOnce({ ok: false, status: 404 } as unknown as Response) - .mockResolvedValue(okChatResponse()); - await adapter.send( - makeRequest({ - messages: [ - { - role: 'user', - content: '看图', - images: [{ url: 'https://example.com/missing.png' }], - timestamp: Date.now(), - }, - ], - }), - ); - const body = lastBody(); - const userMsg = (body.messages as Array>).find( - (m) => m.role === 'user', - ); - expect(userMsg!.images).toEqual([]); + const restore = __imageFetcher.current; + __imageFetcher.current = (async () => + new Response(null, { status: 404 })) as unknown as ImageFetcher; + try { + mockFetch.mockResolvedValue(okChatResponse()); + await adapter.send( + makeRequest({ + messages: [ + { + role: 'user', + content: '看图', + images: [{ url: 'https://example.com/missing.png' }], + timestamp: Date.now(), + }, + ], + }), + ); + const body = lastBody(); + const userMsg = (body.messages as Array>).find( + (m) => m.role === 'user', + ); + expect(userMsg!.images).toEqual([]); + } finally { + __imageFetcher.current = restore; + } }); }); diff --git a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts index c0751d5..dcd091e 100644 --- a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts +++ b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts @@ -901,15 +901,17 @@ describe('MimoAdapter — thinking 显式开关', () => { expect(bodies[0].top_p).toBe(0.8); }); - it('thinkingEnabled 未配置 → 默认 {type:enabled} 且不传 temperature/top_p(API 强制覆盖)', async () => { + // v0.8.2 P2-6 修订:未配置时显式 disabled(与 DeepSeek/Agnes 的"用户意图优先"对齐; + // 旧行为隐式 enabled 会静默吞掉用户 temperature/top_p —— 服务端思考模式强制覆盖) + it('thinkingEnabled 未配置 → 显式 {type:disabled} 且 temperature/top_p 透传', async () => { const adapter = makeAdapter(); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }), ); - expect(bodies[0].thinking).toEqual({ type: 'enabled' }); - expect(bodies[0].temperature).toBeUndefined(); - expect(bodies[0].top_p).toBeUndefined(); + expect(bodies[0].thinking).toEqual({ type: 'disabled' }); + expect(bodies[0].temperature).toBe(0.7); + expect(bodies[0].top_p).toBe(0.8); }); it('max_completion_tokens 原样透传(v0.8.1:pro/standard 均不钳制)', async () => { diff --git a/electron/harness/adapters/__tests__/sse-stream.test.ts b/electron/harness/adapters/__tests__/sse-stream.test.ts index ac411f9..cdf8e8a 100644 --- a/electron/harness/adapters/__tests__/sse-stream.test.ts +++ b/electron/harness/adapters/__tests__/sse-stream.test.ts @@ -710,3 +710,51 @@ describe('parseOpenAICompatibleResponse — 补充形态', () => { expect(result.toolCalls![0].id).toBeUndefined(); }); }); + +describe('parseSSEStream — v0.8.2 P2-6 MiMo 联网搜索引用回填', () => { + it('message.annotations 引用以 TEXT_DELTA 回填正文(DONE 前)', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"content":"今天的新闻"}}],"annotations":[]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":null}],"annotations":[{"title":"科技日报","url":"https://news.example.com/a","site_name":"example"}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const citation = events.filter( + (e) => e.type === MetonaStreamEventType.TEXT_DELTA && String(e.delta).includes('References'), + ); + expect(citation).toHaveLength(1); + expect(String(citation[0].delta)).toContain('https://news.example.com/a'); + expect(String(citation[0].delta)).toContain('科技日报'); + // 引用块必须在 DONE 之前 + expect(events.indexOf(citation[0])).toBeLessThan( + events.findIndex((e) => e.type === MetonaStreamEventType.DONE), + ); + }); + + it('按 url 去重;非流式路径同样回填', async () => { + const events = await collect( + makeStream([ + 'data: {"annotations":[{"title":"a","url":"https://x.example/1"},{"title":"a-dup","url":"https://x.example/1"}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const citation = events.find( + (e) => e.type === MetonaStreamEventType.TEXT_DELTA && String(e.delta).includes('References'), + ); + expect(citation).toBeDefined(); + expect(String(citation!.delta).match(/https:\/\/x.example\/1/g)).toHaveLength(1); + + const res = parseOpenAICompatibleResponse({ + choices: [ + { + message: { content: 'answer', annotations: [{ title: 'b', url: 'https://x.example/2' }] }, + }, + ], + usage: {}, + }); + expect(res.content).toContain('answer'); + expect(res.content).toContain('https://x.example/2'); + }); +}); diff --git a/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts b/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts index 8b910e9..da168bd 100644 --- a/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts +++ b/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts @@ -155,6 +155,18 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => { expect(body.temperature).toBe(0); }); + it('MiMo: 未配置(undefined)→ 显式 disabled(v0.8.2 P2-6 与 DeepSeek/Agnes 对齐,不隐式吞用户 temperature)', async () => { + const adapter = new MimoAdapter({ + provider: 'mimo', + baseURL: 'https://api.xiaomimimo.com/v1', + apiKey: 'k', + defaultModel: 'mimo-v2.5-pro', + }); + const body = asNative(adapter)(makeRequest({ thinkingEnabled: undefined }), false); + expect(body.thinking).toEqual({ type: 'disabled' }); + expect(body.temperature).toBe(0); + }); + it('Ollama: 探测不支持思考(服务端硬约束)→ 不发 think 参数(唯一保留的门控)', async () => { const adapter = new OllamaAdapter({ provider: 'ollama', diff --git a/electron/harness/adapters/anthropic.adapter.ts b/electron/harness/adapters/anthropic.adapter.ts index 2483ba0..31a4542 100644 --- a/electron/harness/adapters/anthropic.adapter.ts +++ b/electron/harness/adapters/anthropic.adapter.ts @@ -20,13 +20,31 @@ import { BaseAdapter, ContentFilterError } from './base-adapter'; import { truncatedArgumentsPayload, readStreamChunkWithIdleTimeout } from './shared/sse-stream'; import log from 'electron-log'; import { nanoid } from 'nanoid'; -import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; +import type { + MetonaRequest, + MetonaResponse, + MetonaStreamEvent, + MetonaThinkingBlock, +} from '../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../types'; import type { MetonaModelInfo } from '../types/metona-adapter'; +/** + * v0.8.2 P1-1: pause_turn 单次响应允许的最大续传次数。 + * Anthropic 长回复以 pause_turn 分段返回,每段需把 content 原样回传继续; + * 预算耗尽仍 pause_turn 时按 length(输出截断)语义收尾,防止无限续传。 + */ +const MAX_PAUSE_CONTINUATIONS = 5; + /** * v0.8.0 P0-1: Anthropic stop_reason → 归一化 OpenAI 语义(与 MetonaFinishReason - * 的非流式映射语义一致)。pause_turn(长回复暂停续传标记)视为自然停止。 + * 的非流式映射语义一致)。 + * + * v0.8.2 P1-1: `pause_turn` 不再折叠为 stop —— 此前长回复的暂停续传标记被当作 + * 自然结束,引擎不发起续传,长输出静默截断。现 pause_turn 由 sendStream/send 的 + * 续传循环在协议层消费(把本段 content 原样作为 assistant 消息回传并继续请求, + * 见 MAX_PAUSE_CONTINUATIONS);仅在续传预算耗尽时按截断语义(length)收尾, + * 前端据此展示"输出可能截断"提示而非无声缺失。 */ function mapAnthropicStopReason(reason: string): string { switch (reason) { @@ -37,8 +55,10 @@ function mapAnthropicStopReason(reason: string): string { case 'refusal': case 'content_filter': return 'content_filter'; - case 'end_turn': case 'pause_turn': + // 续传预算耗尽的兜底语义:按输出截断处理(不可静默当自然结束) + return 'length'; + case 'end_turn': case 'stop_sequence': return 'stop'; default: @@ -90,64 +110,59 @@ export class AnthropicAdapter extends BaseAdapter { // ===== POST /v1/messages(非流式) ===== async send(request: MetonaRequest): Promise { - const body = await this.toNativeRequest(request, false); - const response = await this.fetchWithTimeout( - `${this.config.baseURL}/v1/messages`, - { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify(body), - }, - this.config.timeoutMs ?? 120_000, - ); + // v0.8.2 P1-1: pause_turn 续传循环(与流式路径同语义)—— 本段 content 原样 + // 作为 assistant 消息追加后重发,直到自然结束或续传预算耗尽 + let body = await this.toNativeRequest(request, false); + for (let continuation = 0; ; continuation++) { + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/v1/messages`, + { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(body), + }, + this.config.timeoutMs ?? 120_000, + ); - if (!response.ok) { - await this.throwHttpError(response, 'Anthropic API error'); + if (!response.ok) { + await this.throwHttpError(response, 'Anthropic API error'); + } + + const data = (await response.json()) as Record; + if (data.stop_reason === 'pause_turn' && continuation < MAX_PAUSE_CONTINUATIONS) { + log.info(`[Anthropic] pause_turn — continuing non-stream turn (#${continuation + 1})`); + body = { + ...body, + messages: [ + ...((body.messages as Array>) ?? []), + { role: 'assistant', content: (data.content as Array) ?? [] }, + ], + }; + continue; + } + return this.toMetonaResponse(data, request.meta.requestId); } - - const data = (await response.json()) as Record; - return this.toMetonaResponse(data, request.meta.requestId); } // ===== POST /v1/messages(流式) ===== + /** + * 流式响应(v0.8.2 P1-1 重构:pause_turn 续传主循环 + thinking 块采集)。 + * + * 结构:外层为续传循环 —— 收到 stop_reason=pause_turn 时,把本段 content 块 + * **原样**(含 pause_turn 块与已完成的 thinking/tool_use 块)作为 assistant + * 消息追加到 messages 后重发,直到自然结束或续传预算耗尽(按 length 收尾)。 + * 内层为单段响应的 SSE 消费(事件机处理与 v0.6.x/v0.8.0 契约一致)。 + * + * thinking 块采集:thinking/redacted_thinking 块在 content_block_stop 时收敛 + * (签名完备的块才进入 collectedThinkingBlocks),随最终 DONE 事件携带, + * 引擎透传到 assistant 消息实现协议回传(MetonaMessage.thinkingBlocks)。 + */ async *sendStream(request: MetonaRequest): AsyncIterable { - const body = await this.toNativeRequest(request, true); - const response = await this.fetchWithTimeout( - `${this.config.baseURL}/v1/messages`, - { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify(body), - }, - this.config.timeoutMs ?? 300_000, - ); + let body = await this.toNativeRequest(request, true); + const collectedThinkingBlocks: MetonaThinkingBlock[] = []; - if (!response.ok || !response.body) { - await this.throwHttpError(response, 'Anthropic stream error'); - } - - // 非空断言:上方 if 已确保 response.body 不为 null - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); let seq = 0; - let buffer = ''; - let eventName = ''; - let streamEndedNormally = false; - // v0.8.0 P0-1: 采集 message_delta.delta.stop_reason —— Anthropic 的停止原因 - // 在 message_delta(而非 message_stop)事件携带;旧实现只读 usage, - // max_tokens 截断在流式路径完全不可见(与 OpenAI 共享层 finish_reason 缺口同源) - let streamStopReason: string | undefined; - - // 工具调用缓冲:content block index → { id, name, argsBuffer } - const toolBlocks = new Map(); - - // v0.6.4 竞态修复: message_start 捕获的 input_tokens 改为本次调用的局部闭包变量。 - // 原实现放在实例字段(this.lastInputTokens)—— fallback adapter 是跨引擎共享的 - // 单例(agent-engine-manager 把同一实例注入所有引擎),故障转移后多个并发会话 - // 共用该 Anthropic 实例时 input_tokens 会互相串号。局部化后天然隔离。 - let messageStartInputTokens = 0; - const base = () => ({ requestId: request.meta.requestId, sessionId: request.meta.sessionId, @@ -187,65 +202,285 @@ export class AnthropicAdapter extends BaseAdapter { } }; - const processEvent = (name: string, data: Record): MetonaStreamEvent[] => { - const events: MetonaStreamEvent[] = []; - switch (name) { - case 'content_block_start': { - const block = data.content_block as Record | undefined; - const index = (data.index as number) ?? 0; - if (block?.type === 'tool_use') { - toolBlocks.set(index, { - id: (block.id as string) ?? `tc_${nanoid(8)}`, - name: (block.name as string) ?? '', - argsBuffer: '', - }); + // ===== pause_turn 续传主循环 ===== + for (let continuation = 0; continuation <= MAX_PAUSE_CONTINUATIONS; continuation++) { + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/v1/messages`, + { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(body), + }, + this.config.timeoutMs ?? 300_000, + ); + + if (!response.ok || !response.body) { + await this.throwHttpError(response, 'Anthropic stream error'); + } + + // 非空断言:上方 if 已确保 response.body 不为 null + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let eventName = ''; + + // ===== 本段响应的局部状态(续传时全部重置;seq 跨段连续) ===== + // v0.6.4 竞态修复: message_start 捕获的 input_tokens 用局部闭包变量(fallback + // adapter 是跨引擎共享单例,实例字段会跨会话串号) + let messageStartInputTokens = 0; + // v0.8.0 P0-1: 采集 message_delta.delta.stop_reason 的**原始值** + //(pause_turn 判定与最终映射都在本层完成) + let rawStopReason: string | undefined; + let messageStopSeen = false; + // 工具调用缓冲:content block index → { id, name, argsBuffer } + const toolBlocks = new Map(); + /** 本段原始 content 块(pause_turn 续传需按协议原样回传) */ + const rawBlocks: Array | null> = []; + /** thinking 块签名(content_block_delta.signature_delta 累积) */ + const thinkingSignatures = new Map(); + + const processEvent = (name: string, data: Record): MetonaStreamEvent[] => { + const events: MetonaStreamEvent[] = []; + switch (name) { + case 'content_block_start': { + const block = data.content_block as Record | undefined; + const index = (data.index as number) ?? 0; + if (block?.type === 'tool_use') { + toolBlocks.set(index, { + id: (block.id as string) ?? `tc_${nanoid(8)}`, + name: (block.name as string) ?? '', + argsBuffer: '', + }); + rawBlocks[index] = { + type: 'tool_use', + id: (block.id as string) ?? `tc_${nanoid(8)}`, + name: (block.name as string) ?? '', + input: {}, + }; + } else if (block?.type === 'text') { + rawBlocks[index] = { type: 'text', text: '' }; + } else if (block?.type === 'thinking') { + rawBlocks[index] = { type: 'thinking', thinking: '' }; + } else if (block?.type === 'redacted_thinking') { + // redacted_thinking 整块到达(data 不透明载荷),原样保留并直接收集 + const rb: MetonaThinkingBlock = { + type: 'redacted_thinking', + data: (block.data as string) ?? '', + }; + rawBlocks[index] = rb as unknown as Record; + collectedThinkingBlocks.push(rb); + } else if (block?.type) { + // server_tool_use 等未知块:原样保留(pause_turn 续传保真) + rawBlocks[index] = { ...block }; + } + break; } - break; - } - case 'content_block_delta': { - const delta = data.delta as Record | undefined; - const index = (data.index as number) ?? 0; - if (delta?.type === 'text_delta' && typeof delta.text === 'string') { - events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text }); - } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { - events.push({ - type: MetonaStreamEventType.REASONING_DELTA, - ...base(), - delta: delta.thinking, - }); - } else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') { + case 'content_block_delta': { + const delta = data.delta as Record | undefined; + const index = (data.index as number) ?? 0; + if (delta?.type === 'text_delta' && typeof delta.text === 'string') { + const rb = rawBlocks[index]; + if (rb?.type === 'text') rb.text = ((rb.text as string) ?? '') + delta.text; + events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text }); + } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { + const rb = rawBlocks[index]; + if (rb?.type === 'thinking') + rb.thinking = ((rb.thinking as string) ?? '') + delta.thinking; + events.push({ + type: MetonaStreamEventType.REASONING_DELTA, + ...base(), + delta: delta.thinking, + }); + } else if (delta?.type === 'signature_delta' && typeof delta.signature === 'string') { + // v0.8.2 P1-1: thinking 块签名增量(回传校验必需) + thinkingSignatures.set( + index, + (thinkingSignatures.get(index) ?? '') + delta.signature, + ); + } else if ( + delta?.type === 'input_json_delta' && + typeof delta.partial_json === 'string' + ) { + const block = toolBlocks.get(index); + if (block) { + block.argsBuffer += delta.partial_json; + events.push({ + type: MetonaStreamEventType.TOOL_CALL_DELTA, + ...base(), + toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json }, + }); + } + } + break; + } + case 'content_block_stop': { + const index = (data.index as number) ?? 0; const block = toolBlocks.get(index); if (block) { - block.argsBuffer += delta.partial_json; + let args: Record = {}; + try { + args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; + } catch (err) { + // v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致 + // JSON 半截)—— 统一转为 _truncatedArguments 错误参数触发模型自愈 + //(与共享层同源、同文案契约)。 + const sample = block.argsBuffer.slice(-120); + log.warn( + `[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`, + ); + args = truncatedArgumentsPayload((err as Error).message, sample); + } + const rb = rawBlocks[index]; + if (rb?.type === 'tool_use') rb.input = args; events.push({ - type: MetonaStreamEventType.TOOL_CALL_DELTA, + type: MetonaStreamEventType.TOOL_CALL_COMPLETE, ...base(), - toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json }, + toolCall: { + id: block.id, + name: block.name, + args, + iteration: request.meta.iteration, + timestamp: Date.now(), + }, + }); + toolBlocks.delete(index); + } else { + // thinking 块收敛:签名完备才收集(协议回传要求;缺失签名的块回传必 400) + const rb = rawBlocks[index]; + if (rb?.type === 'thinking') { + const signature = thinkingSignatures.get(index); + if (signature) { + rb.signature = signature; + collectedThinkingBlocks.push(rb as unknown as MetonaThinkingBlock); + } else { + log.warn( + '[Anthropic] thinking block finished without signature — dropped from round-trip', + ); + } + } + } + break; + } + case 'message_delta': { + // 结束时的 usage 统计(output_tokens 增量在此事件携带) + const usage = data.usage as Record | undefined; + if (usage) { + events.push({ + type: MetonaStreamEventType.USAGE, + ...base(), + usage: { + inputTokens: messageStartInputTokens, + outputTokens: (usage.output_tokens as number) ?? 0, + totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0), + // v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集, + // cache_read/creation_input_tokens 与 output_tokens 同在 usage 内) + cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined, + cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined, + }, }); } + // v0.8.0 P0-1: 采集停止原因原始值(映射移到 DONE 发射点) + const delta = data.delta as Record | undefined; + if (delta && typeof delta.stop_reason === 'string') { + rawStopReason = delta.stop_reason; + } + break; + } + case 'message_stop': { + // v0.8.2 P1-1: DONE 不再在此处发射 —— pause_turn 判定与续传在主循环层, + // 最终 DONE 由循环层统一发射(含原始停止原因映射与思考块) + messageStopSeen = true; + break; + } + case 'error': { + const err = data.error as Record | undefined; + const code = (err?.type as string) ?? 'api_error'; + const message = (err?.message as string) ?? 'Anthropic stream error'; + const status = anthropicErrorCodeToStatus(code); + log.warn( + `[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`, + ); + if (code === 'content_filter_error') { + throw new ContentFilterError(message, 'Anthropic SSE error event'); + } + const throwable = new Error(`anthropic_stream_error (${code}): ${message}`); + (throwable as Error & { status: number }).status = status; + throw throwable; } - break; } - case 'content_block_stop': { - const index = (data.index as number) ?? 0; - const block = toolBlocks.get(index); - if (block) { + return events; + }; + + // ===== 单段响应的 SSE 消费 ===== + while (true) { + // v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能 + // 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道 + const { done, value } = await readStreamChunkWithIdleTimeout( + reader, + 60_000, + this.getExternalAbortSignal(), + ); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('event:')) { + eventName = trimmed.slice(6).trim(); + continue; + } + if (!trimmed.startsWith('data:')) continue; + const dataStr = trimmed.slice(5).trim(); + if (dataStr === '[DONE]') continue; + + try { + const data = JSON.parse(dataStr) as Record; + // message_start 携带 input_tokens + if (eventName === 'message_start') { + const msg = data.message as Record | undefined; + const usage = msg?.usage as Record | undefined; + messageStartInputTokens = (usage?.input_tokens as number) ?? 0; + continue; + } + for (const ev of processEvent(eventName, data)) { + yield ev; + } + } catch (parseErr) { + // ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传 + if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr; + log.warn( + `[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, + trimmed.slice(0, 200), + ); + } + } + } + + // ===== 段结束处理 ===== + if (!messageStopSeen) { + // v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。 + // 在补发 DONE 之前,将所有未完成块按截断契约转为 _truncatedArguments + // 自愈 tool call(解析成功的则正常产出)。 + const unfinished = [...toolBlocks.entries()]; + if (unfinished.length > 0) { + log.warn( + `[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`, + ); + for (const [, block] of unfinished) { let args: Record = {}; try { args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; } catch (err) { - // v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致 - // JSON 半截)—— 原实现静默降级 args={},与 v0.6.3 已修复的 OpenAI 共享层 - // 行为完全相同:工具以"缺少必要参数"泛化失败,模型无从得知发生了截断, - // 长文件写入场景直接导致"空回复 → 会话无声终止"。现统一转为 - // _truncatedArguments 错误参数触发模型自愈(与共享层同源、同文案契约)。 - const sample = block.argsBuffer.slice(-120); - log.warn( - `[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`, + args = truncatedArgumentsPayload( + (err as Error).message, + block.argsBuffer.slice(-120), ); - args = truncatedArgumentsPayload((err as Error).message, sample); } - events.push({ + yield { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, ...base(), toolCall: { @@ -255,153 +490,71 @@ export class AnthropicAdapter extends BaseAdapter { iteration: request.meta.iteration, timestamp: Date.now(), }, - }); - toolBlocks.delete(index); + }; } - break; - } - case 'message_delta': { - // 结束时的 usage 统计(output_tokens 增量在此事件携带) - const usage = data.usage as Record | undefined; - if (usage) { - events.push({ - type: MetonaStreamEventType.USAGE, - ...base(), - usage: { - inputTokens: messageStartInputTokens, - outputTokens: (usage.output_tokens as number) ?? 0, - totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0), - // v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集, - // cache_read/creation_input_tokens 与 output_tokens 同在 usage 内) - cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined, - cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined, - }, - }); - } - // v0.8.0 P0-1: 采集停止原因(message_delta.delta.stop_reason,可能在 - // 多个 message_delta 中重复出现,取任一即可;归一化为 OpenAI 语义) - const delta = data.delta as Record | undefined; - if (delta && typeof delta.stop_reason === 'string') { - streamStopReason = mapAnthropicStopReason(delta.stop_reason); - } - break; - } - case 'message_stop': { - streamEndedNormally = true; - events.push({ - type: MetonaStreamEventType.DONE, - ...base(), - // v0.8.0 P0-1: 携带归一化停止原因 - ...(streamStopReason ? { finishReason: streamStopReason } : {}), - }); - break; - } - case 'error': { - const err = data.error as Record | undefined; - const code = (err?.type as string) ?? 'api_error'; - const message = (err?.message as string) ?? 'Anthropic stream error'; - const status = anthropicErrorCodeToStatus(code); - log.warn( - `[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`, - ); - if (code === 'content_filter_error') { - throw new ContentFilterError(message, 'Anthropic SSE error event'); - } - const throwable = new Error(`anthropic_stream_error (${code}): ${message}`); - (throwable as Error & { status: number }).status = status; - throw throwable; + } else { + log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)'); } + toolBlocks.clear(); + yield { + type: MetonaStreamEventType.DONE, + ...base(), + // v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因 + //(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束) + ...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}), + ...(collectedThinkingBlocks.length > 0 + ? { thinkingBlocks: collectedThinkingBlocks.slice() } + : {}), + }; + return; } - return events; - }; - // message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总) - while (true) { - // v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能 - // 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道 - const { done, value } = await readStreamChunkWithIdleTimeout(reader); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (trimmed.startsWith('event:')) { - eventName = trimmed.slice(6).trim(); + if (rawStopReason === 'pause_turn') { + if (continuation < MAX_PAUSE_CONTINUATIONS) { + // 协议续传:本段 content 原样(含 pause_turn 块、已完成 thinking/tool_use) + // 作为 assistant 消息追加后重发。无签名的 thinking 块剔除(回传必 400)。 + const contentForContinuation = rawBlocks.filter((b) => { + if (!b) return false; + if (b.type === 'thinking' && !b.signature) return false; + return true; + }) as Array>; + log.info( + `[Anthropic] pause_turn — continuing stream turn (#${continuation + 1}, ${contentForContinuation.length} block(s) carried over)`, + ); + body = { + ...body, + messages: [ + ...((body.messages as Array>) ?? []), + { role: 'assistant', content: contentForContinuation }, + ], + }; continue; } - if (!trimmed.startsWith('data:')) continue; - const dataStr = trimmed.slice(5).trim(); - if (dataStr === '[DONE]') continue; - - try { - const data = JSON.parse(dataStr) as Record; - // message_start 携带 input_tokens - if (eventName === 'message_start') { - const msg = data.message as Record | undefined; - const usage = msg?.usage as Record | undefined; - messageStartInputTokens = (usage?.input_tokens as number) ?? 0; - continue; - } - for (const ev of processEvent(eventName, data)) { - yield ev; - } - } catch (parseErr) { - // ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传 - if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr; - log.warn( - `[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, - trimmed.slice(0, 200), - ); - } - } - } - - // v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。 - // 原实现在 content_block_start 与 content_block_stop 之间断连时,toolBlocks 里 - // 未完成的 block 既不产生 TOOL_CALL_COMPLETE、也不 flush —— 引擎看到"零工具调用 - // + 零文本"→ 误判 COMPLETED 空回复 → 会话无声停止(正是 v0.6.3 宣称根治、但在 - // Anthropic 流上仍然存活的场景)。现于补发 DONE 之前,将所有未完成块按截断契约 - // 转为 _truncatedArguments 自愈 tool call(解析成功的则正常产出)。 - if (!streamEndedNormally) { - const unfinished = [...toolBlocks.entries()]; - if (unfinished.length > 0) { + // 续传预算耗尽:按输出截断语义收尾(前端展示"输出可能截断",不静默丢失) log.warn( - `[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`, + `[Anthropic] pause_turn continuation budget exhausted (${MAX_PAUSE_CONTINUATIONS}) — finishing as length`, ); - for (const [, block] of unfinished) { - let args: Record = {}; - try { - args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; - } catch (err) { - args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120)); - } - yield { - type: MetonaStreamEventType.TOOL_CALL_COMPLETE, - ...base(), - toolCall: { - id: block.id, - name: block.name, - args, - iteration: request.meta.iteration, - timestamp: Date.now(), - }, - }; - } - } else { - log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)'); + yield { + type: MetonaStreamEventType.DONE, + ...base(), + finishReason: mapAnthropicStopReason('pause_turn'), + ...(collectedThinkingBlocks.length > 0 + ? { thinkingBlocks: collectedThinkingBlocks.slice() } + : {}), + }; + return; } - toolBlocks.clear(); + + // 自然结束:发射最终 DONE(映射原始停止原因 + 思考块) yield { type: MetonaStreamEventType.DONE, ...base(), - // v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因 - //(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束) - ...(streamStopReason ? { finishReason: streamStopReason } : {}), + ...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}), + ...(collectedThinkingBlocks.length > 0 + ? { thinkingBlocks: collectedThinkingBlocks.slice() } + : {}), }; + return; } } @@ -480,6 +633,19 @@ export class AnthropicAdapter extends BaseAdapter { if (m.role === 'assistant') { const content: Array> = []; + // v0.8.2 P1-1: thinking 块协议回传 —— extended thinking + tool use 的多轮 + // 请求要求 assistant 消息携带原始 thinking/redacted_thinking 块(含签名), + // 且必须位于 content 首位。仅在本次请求开启 thinking 时回传(thinking 关闭 + // 的降级重试路径携带 thinking 块会 400);签名不完备的块直接丢弃。 + if (thinkingRequested) { + for (const tb of m.thinkingBlocks ?? []) { + if (tb.type === 'redacted_thinking') { + if (tb.data) content.push({ type: 'redacted_thinking', data: tb.data }); + } else if (tb.thinking && tb.signature) { + content.push({ type: 'thinking', thinking: tb.thinking, signature: tb.signature }); + } + } + } if (m.content) content.push({ type: 'text', text: m.content }); for (const tc of m.toolCalls ?? []) { pendingToolUseIds.add(tc.id); @@ -600,13 +766,12 @@ export class AnthropicAdapter extends BaseAdapter { return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } }; } if (url.startsWith('http://') || url.startsWith('https://')) { - const res = await this.fetchWithTimeout(url, {}, 30_000); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const contentType = res.headers.get('content-type') ?? 'image/png'; - const buf = Buffer.from(await res.arrayBuffer()); + // v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验, + // 可被诱导回读内网数据;现含 DNS pinning/重定向复检/10MB 上限/类型白名单) + const { base64, mediaType } = await this.fetchImageAsBase64(url, 30_000); return { type: 'image', - source: { type: 'base64', media_type: contentType, data: buf.toString('base64') }, + source: { type: 'base64', media_type: mediaType, data: base64 }, }; } return null; @@ -621,6 +786,8 @@ export class AnthropicAdapter extends BaseAdapter { const contentBlocks = (data.content as Array>) ?? []; let text = ''; let reasoningContent: string | undefined; + // v0.8.2 P1-1: 原始思考块收集(非流式路径,供引擎透传实现协议回传) + const thinkingBlocks: MetonaThinkingBlock[] = []; const toolCalls: MetonaResponse['toolCalls'] = []; for (const block of contentBlocks) { @@ -631,6 +798,15 @@ export class AnthropicAdapter extends BaseAdapter { if (thinking) { reasoningContent = reasoningContent ? `${reasoningContent}\n\n${thinking}` : thinking; } + // 签名完备才收集(协议回传要求) + const signature = block.signature as string | undefined; + if (thinking && signature) { + thinkingBlocks.push({ type: 'thinking', thinking, signature }); + } + } else if (block.type === 'redacted_thinking') { + // redacted_thinking 原样透传(回传协议要求) + const redactedData = block.data as string | undefined; + if (redactedData) thinkingBlocks.push({ type: 'redacted_thinking', data: redactedData }); } else if (block.type === 'tool_use') { let args: Record = {}; const rawInput = block.input; @@ -649,14 +825,18 @@ export class AnthropicAdapter extends BaseAdapter { const stopReason = (data.stop_reason as string) ?? 'end_turn'; // v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把 // "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路 + // v0.8.2 P1-1: pause_turn 映射为 LENGTH(续传预算耗尽的兜底语义,正常路径 + // 已在 send() 内被续传循环消费,不会带 pause_turn 到达此处) const finishReason: MetonaFinishReason = stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS : stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH - : stopReason === 'refusal' || stopReason === 'content_filter' - ? MetonaFinishReason.CONTENT_FILTER - : MetonaFinishReason.STOP; + : stopReason === 'pause_turn' + ? MetonaFinishReason.LENGTH + : stopReason === 'refusal' || stopReason === 'content_filter' + ? MetonaFinishReason.CONTENT_FILTER + : MetonaFinishReason.STOP; return { meta: { @@ -668,6 +848,7 @@ export class AnthropicAdapter extends BaseAdapter { }, content: text, reasoningContent, + ...(thinkingBlocks.length > 0 ? { thinkingBlocks } : {}), toolCalls, usage: { inputTokens: usage.input_tokens ?? 0, diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index c321034..645698e 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -25,6 +25,7 @@ import type { MetonaStreamEvent, } from '../types'; import type { MetonaModelInfo } from '../types/metona-adapter'; +import { fetchImageAsBase64 } from './shared/ssrf-image-fetch'; /** * 内容审核错误 — Provider 的安全过滤策略触发的错误 @@ -88,6 +89,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { this.externalAbortSignal = signal; } + /** + * v0.8.2 P3-3: 读取外部中断信号(流式消费阶段的中断贯通)。 + * 子类的 sendStream 把它传入流读取辅助 —— fetch 头阶段的 abort 由 + * fetchWithTimeout 处理,流体消费阶段的 abort 由 readStreamChunkWithIdleTimeout + * 竞速处理(此前 reader.read() 对用户中断无感,挂起流无法被"中断"按钮终止)。 + */ + protected getExternalAbortSignal(): AbortSignal | undefined { + return this.externalAbortSignal; + } + /** * #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏 * @@ -155,6 +166,22 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { } } + /** + * v0.8.2 P0-1: SSRF 安全的图片下载通道(Anthropic / Ollama 图片 URL 共用) + * + * 此前子类直接 fetchWithTimeout 下载消息里的 http(s) 图片 URL —— 无 SSRF 校验、 + * 无字节上限,且下载结果以 base64 进入模型上下文(**数据可回读**的外泄通道)。 + * 现统一走 fetchImageAsBase64:resolvePublicAddresses + DNS pinning 校验与连接 + * 同源、逐跳重定向复检、10MB 字节上限、png/jpeg/gif/webp 类型白名单; + * 外部 abort 信号(用户中断)照常透传。失败时调用方按"跳过该图"降级。 + */ + protected async fetchImageAsBase64( + url: string, + timeoutMs = 30_000, + ): Promise<{ base64: string; mediaType: string }> { + return fetchImageAsBase64(url, { timeoutMs, signal: this.externalAbortSignal }); + } + async healthCheck(): Promise { try { await this.listModels(); diff --git a/electron/harness/adapters/mimo.adapter.ts b/electron/harness/adapters/mimo.adapter.ts index 728bd04..b985f66 100644 --- a/electron/harness/adapters/mimo.adapter.ts +++ b/electron/harness/adapters/mimo.adapter.ts @@ -100,8 +100,10 @@ export class MimoAdapter extends OpenAICompatibleAdapter { } // v0.6.4 P4-3: MiMo 服务端内置工具透出 —— config.providerOptions.enableWebSearch - // 开启后附加 {type:'web_search'} 服务端搜索工具(annotations 引用随响应返回, - // 由上层归并为文本内容展示)。与客户端 tools 定义互不影响。 + // 开启后附加 {type:'web_search'} 服务端搜索工具。与客户端 tools 定义互不影响。 + // v0.8.2 P2-6: 引用注释(annotations)的采集与回填实现在共享层 + // sse-stream.ts(流式 [DONE]/断流兜底 + 非流式解析统一回填 Markdown 引用列表), + // 此前注释宣称"由上层归并展示"但全链路无读取方,引用信息丢失。 const providerOptions = this.config.providerOptions as Record | undefined; if (providerOptions?.['enableWebSearch'] === true) { const serverTools = body.tools @@ -118,18 +120,22 @@ export class MimoAdapter extends OpenAICompatibleAdapter { } // Thinking 模式(与 DeepSeek 参数结构一致) - // MiMo API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭 // v0.8.0 修订(用户意图优先): 思考参数完全遵循用户配置,不再按 // supportsThinking 元信息硬门控 —— 预算耗尽风险由引擎空响应守卫的 // 降级重试链路兜底;元信息与配置不符时仅告警不拦截。 - const wantThinking = request.params.thinkingEnabled !== false; + // + // v0.8.2 P2-6 根治: 未配置(undefined)时默认 **disabled** —— 与 DeepSeek + // (显式 disabled)/ Agnes(显式 false)一致。旧实现 `!== false` 使 MiMo 在 + // UI「未开启思考」状态下隐式进入思考模式,且思考模式下服务端强制 + // temperature=1.0/top_p=0.95,用户的温度配置被静默吞掉。 + const wantThinking = request.params.thinkingEnabled === true; if (!wantThinking) { // 显式禁用思考:传 disabled + temperature/top_p(非思考模式下这两个参数有效) body.thinking = { type: 'disabled' }; body.temperature = request.params.temperature; body.top_p = request.params.topP; } else { - // 启用思考(包括 undefined,因为 MiMo 默认 enabled) + // 启用思考(仅显式 true —— 未配置已在上分支显式 disabled,见 P2-6 注) // 思考模式下 temperature/top_p 被 API 强制覆盖为 1.0/0.95,不传 body.thinking = { type: 'enabled' }; // 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截) diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index deada71..da49236 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -137,7 +137,11 @@ export class OllamaAdapter extends BaseAdapter { while (true) { // v0.7.4 P1-2: 空闲超时 — 本地模型加载/推理期间服务器可能长时间不推数据, // 共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道 - const { done, value } = await readStreamChunkWithIdleTimeout(reader); + const { done, value } = await readStreamChunkWithIdleTimeout( + reader, + 60_000, + this.getExternalAbortSignal(), + ); if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -578,16 +582,11 @@ export class OllamaAdapter extends BaseAdapter { */ private async resolveImageToBase64(url: string): Promise { try { - // 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时, - // 避免用户中断时图片下载最多阻塞 30s×N(externalAbortSignal 是 BaseAdapter 的 - // private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout, - // 该方法同时处理了 listener 泄漏问题) - const res = await this.fetchWithTimeout(url, {}, 30_000); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const buf = Buffer.from(await res.arrayBuffer()); - return buf.toString('base64'); + // v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验、 + // 无大小上限;现含 DNS pinning/重定向复检/10MB 上限/类型白名单),外部 + // 中断信号由 BaseAdapter.fetchImageAsBase64 透传。 + const { base64 } = await this.fetchImageAsBase64(url, 30_000); + return base64; } catch (error) { log.warn( `[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`, diff --git a/electron/harness/adapters/openai.adapter.ts b/electron/harness/adapters/openai.adapter.ts index a9826bd..abf69f3 100644 --- a/electron/harness/adapters/openai.adapter.ts +++ b/electron/harness/adapters/openai.adapter.ts @@ -102,8 +102,12 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter { stream: boolean, ): Record { // 推理模型检测(o 系列使用新参数名) + // v0.8.2 P2-6: 正则补边界 — 旧 /^(o\d|gpt-5)/ 对命名变体存在两处漏判/误判: + // ① `chatgpt-5-*` 系列(gpt-5 非前缀)漏判;② 未来 `o10` 等多位数编号无影响 + // 但 `o\d` 会把 `openai/...` 路径形态误判(当前配置层不透传路径,防御性加边界)。 + // 现改为带词边界的枚举前缀匹配(o / gpt-5 / chatgpt-5)。 const model = this.config.defaultModel; - const isReasoningModel = /^(o\d|gpt-5)/.test(model); + const isReasoningModel = /^(o\d+(?:[-.][\w.]+)?|gpt-5[\w.-]*|chatgpt-5[\w.-]*)/.test(model); // 推理模型不支持图片输入 — 前置校验 // v0.6.4 升级: 原实现抛裸 Error 落入 UNKNOWN 错误码;现在抛 ModelCapabilityError @@ -165,10 +169,18 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter { } // 停止序列 - // 已知边界(协议限制,待上游放开后移除此注释):o 系列不支持 stop 参数, - // 当前仍透传 —— 若推理模型 + stop 组合触发 400 属上游约束而非本层缺陷。 + // v0.8.2 P2-6 根治: 推理模型不支持 stop 参数 —— 旧实现"仍透传,400 属上游约束" + // 的注释性放弃改为前置拦截:推理模型请求丢弃 stop 并告警(与拒图的 + // ModelCapabilityError 同思路,但 stop 是可选增强参数,静默丢弃 + 告警优于 + // 让整个请求 400 失败)。 if (request.params.stopSequences?.length) { - body.stop = request.params.stopSequences; + if (isReasoningModel) { + log.warn( + `[OpenAI] stop sequences are not supported by reasoning model "${model}" — dropping ${request.params.stopSequences.length} stop sequence(s) for this request`, + ); + } else { + body.stop = request.params.stopSequences; + } } return body; diff --git a/electron/harness/adapters/shared/__tests__/ssrf-image-fetch.test.ts b/electron/harness/adapters/shared/__tests__/ssrf-image-fetch.test.ts new file mode 100644 index 0000000..02c4347 --- /dev/null +++ b/electron/harness/adapters/shared/__tests__/ssrf-image-fetch.test.ts @@ -0,0 +1,179 @@ +/** + * v0.8.2 P0-1: 适配器图片下载 SSRF 安全通道测试 + * + * 锁定单元: + * - sniffImageMediaType 魔数嗅探 + * - 协议白名单(仅 http/https) + * - IP 直连私网/云元数据拒绝(resolvePublicAddresses 单一事实来源) + * - DNS 解析到私网拒绝(rebinding 形态) + * - 成功路径:下载 → base64 + 类型钳制(png/jpeg/gif/webp 白名单) + * - 非图片 content-type 拒绝 + * - 字节上限(maxBytes + content-length 双闸) + * - 逐跳重定向复检(每一跳重新进入完整校验) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const proxyMock = vi.hoisted(() => ({ isProxyActive: vi.fn(() => false) })); +vi.mock('../../../../utils/network-proxy', () => ({ + isProxyActive: proxyMock.isProxyActive, +})); + +// undici mock:ssrfPinnedFetch 的 pinned 路径需要 Agent + fetch +const undiciMock = vi.hoisted(() => { + class FakeAgent { + async close(): Promise {} + } + const fetch = vi.fn(); + return { FakeAgent, fetch }; +}); +vi.mock('undici', () => ({ + Agent: undiciMock.FakeAgent, + fetch: undiciMock.fetch, +})); + +import { fetchImageAsBase64, sniffImageMediaType, __imageFetcher } from '../ssrf-image-fetch'; +import type { ImageFetcher } from '../ssrf-image-fetch'; + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); + +function imageResponse(bytes: Uint8Array, contentType = 'image/png'): Response { + return new Response(bytes, { status: 200, headers: { 'content-type': contentType } }); +} + +beforeEach(() => { + undiciMock.fetch.mockReset(); + proxyMock.isProxyActive.mockReturnValue(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('sniffImageMediaType', () => { + it('识别 PNG / JPEG / GIF / WEBP 魔数', () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(sniffImageMediaType(png)).toBe('image/png'); + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + expect(sniffImageMediaType(jpeg)).toBe('image/jpeg'); + const gif = Buffer.from('GIF89a'); + expect(sniffImageMediaType(gif)).toBe('image/gif'); + const webp = Buffer.concat([ + Buffer.from('RIFF'), + Buffer.from([0x00, 0x00, 0x00, 0x00]), + Buffer.from('WEBP'), + ]); + expect(sniffImageMediaType(webp)).toBe('image/webp'); + }); + + it('非图片内容返回 null', () => { + expect(sniffImageMediaType(Buffer.from('hello world, plain text'))).toBeNull(); + expect(sniffImageMediaType(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe('fetchImageAsBase64 — 校验拒绝矩阵', () => { + it('拒绝非 http/https 协议', async () => { + await expect(fetchImageAsBase64('file:///etc/passwd')).rejects.toThrow(/protocol not allowed/); + await expect(fetchImageAsBase64('ftp://example.com/a.png')).rejects.toThrow( + /protocol not allowed/, + ); + }); + + it('拒绝 IP 直连私网/回环/云元数据', async () => { + await expect(fetchImageAsBase64('http://127.0.0.1/img.png')).rejects.toThrow(/Blocked SSRF/); + await expect(fetchImageAsBase64('http://10.1.2.3/img.png')).rejects.toThrow(/Blocked SSRF/); + await expect(fetchImageAsBase64('http://192.168.1.1/img.png')).rejects.toThrow(/Blocked SSRF/); + await expect(fetchImageAsBase64('http://172.16.0.9/img.png')).rejects.toThrow(/Blocked SSRF/); + // 可回读通道的核心威胁:云元数据 + await expect(fetchImageAsBase64('http://169.254.169.254/latest/meta-data')).rejects.toThrow( + /Blocked SSRF/, + ); + }); + + it('拒绝域名解析到私网(DNS rebinding 形态)', async () => { + await expect(fetchImageAsBase64('http://nx.invalid.example/img.png')).rejects.toThrow( + /Blocked SSRF/, + ); + }); +}); + +describe('fetchImageAsBase64 — 成功与钳制路径', () => { + it('公网图片下载 → base64 + mediaType(IP 直连,无需 DNS)', async () => { + const original = __imageFetcher.current; + __imageFetcher.current = (async () => imageResponse(PNG_MAGIC)) as unknown as ImageFetcher; + try { + const r = await fetchImageAsBase64('http://93.184.216.34/a.png'); + expect(r.mediaType).toBe('image/png'); + expect(Buffer.from(r.base64, 'base64').equals(PNG_MAGIC)).toBe(true); + } finally { + __imageFetcher.current = original; + } + }); + + it('非图片 content-type(text/html)拒绝', async () => { + const original = __imageFetcher.current; + __imageFetcher.current = (async () => + imageResponse(Buffer.from(''), 'text/html')) as unknown as ImageFetcher; + try { + await expect(fetchImageAsBase64('http://93.184.216.34/a.png')).rejects.toThrow( + /non-image content-type/, + ); + } finally { + __imageFetcher.current = original; + } + }); + + it('白名单外图片类型(image/bmp)拒绝', async () => { + const original = __imageFetcher.current; + __imageFetcher.current = (async () => + imageResponse(Buffer.from('BMxx'), 'image/bmp')) as unknown as ImageFetcher; + try { + await expect(fetchImageAsBase64('http://93.184.216.34/a.bmp')).rejects.toThrow( + /unsupported media type/, + ); + } finally { + __imageFetcher.current = original; + } + }); + + it('字节上限:超过 maxBytes 拒绝(防大图内存峰值)', async () => { + const original = __imageFetcher.current; + __imageFetcher.current = (async () => + imageResponse(Buffer.alloc(64, 1))) as unknown as ImageFetcher; + try { + await expect( + fetchImageAsBase64('http://93.184.216.34/a.png', { maxBytes: 8 }), + ).rejects.toThrow(/size limit/); + } finally { + __imageFetcher.current = original; + } + }); + + it('逐跳重定向复检:302 跳转后以下一跳 URL 再次下载,终图可用', async () => { + const original = __imageFetcher.current; + const seen: string[] = []; + const fetcher: ImageFetcher = async (url: string) => { + seen.push(url); + if (seen.length === 1) { + return new Response(null, { + status: 302, + headers: { location: 'http://93.184.216.34/final.png' }, + }); + } + return imageResponse(PNG_MAGIC); + }; + __imageFetcher.current = fetcher; + try { + const r = await fetchImageAsBase64('http://93.184.216.34/redirect.png'); + expect(r.mediaType).toBe('image/png'); + expect(seen).toEqual(['http://93.184.216.34/redirect.png', 'http://93.184.216.34/final.png']); + } finally { + __imageFetcher.current = original; + } + }); +}); diff --git a/electron/harness/adapters/shared/openai-compatible-base.ts b/electron/harness/adapters/shared/openai-compatible-base.ts index 98aa9fe..292c667 100644 --- a/electron/harness/adapters/shared/openai-compatible-base.ts +++ b/electron/harness/adapters/shared/openai-compatible-base.ts @@ -102,6 +102,8 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter { request.meta.requestId, request.meta.sessionId, request.meta.iteration, + // v0.8.2 P3-3: 流式消费阶段的中断贯通 + this.getExternalAbortSignal(), ); } diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index f0b421c..0c6abd8 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -53,13 +53,22 @@ export class SseUpstreamError extends Error { * 引擎 chatStreamWithRetry 的 catch,自动走既有重试/故障转移通道。 * SSE / Ollama NDJSON / Anthropic 事件机三处读循环共用,杜绝三份重复实现漂移。 * + * v0.8.2 P3-3 根治: 新增外部中断贯通 —— 此前 abort 监听只在 fetch 头阶段有效 + * (BaseAdapter.fetchWithTimeout 在响应头返回后解除监听),流式消费阶段的 + * reader.read() 对用户中断完全无感:配合保活/心跳型上游,"中断"按钮无法真正 + * 终止挂起的 run(E2E 中断链路实测暴露)。现把外部 signal 传入本辅助: + * abort 触发时 cancel reader 并抛 AbortError —— 引擎 chatStreamWithRetry 由 + * this.aborted 拦截原样抛出,executeRunStream 以 USER_INTERRUPT 收尾。 + * * @param reader 流的 reader * @param idleTimeoutMs 空闲超时(默认 60s — 慢速思考模型正常 chunk 间隔可达数十秒) + * @param externalSignal 外部中断信号(引擎 abortController;可选) * @returns { done, value },done=true 表示流正常结束 */ export async function readStreamChunkWithIdleTimeout( reader: ReadableStreamDefaultReader, idleTimeoutMs = 60_000, + externalSignal?: AbortSignal, ): Promise<{ done: boolean; value: Uint8Array | undefined }> { let idleExpired = false; let idleTimer: NodeJS.Timeout | undefined; @@ -70,8 +79,37 @@ export async function readStreamChunkWithIdleTimeout( }, idleTimeoutMs); }); + // 外部中断竞速(流式消费阶段的中断贯通) + let onExternalAbort: (() => void) | null = null; + const abortController = externalSignal + ? new Promise((_, reject) => { + if (externalSignal.aborted) { + reject(new Error('Aborted')); + return; + } + onExternalAbort = () => reject(new Error('Aborted')); + externalSignal.addEventListener('abort', onExternalAbort, { once: true }); + }) + : null; + + const abortError = (): Error => { + const err = new Error('Aborted'); + err.name = 'AbortError'; + void reader.cancel().catch(() => { + /* 连接销毁时 cancel 可能失败,忽略 */ + }); + return err; + }; + try { - const result = await Promise.race([reader.read(), idleController]); + const result = await Promise.race([ + reader.read(), + idleController, + ...(abortController ? [abortController] : []), + ]); + if (externalSignal?.aborted) { + throw abortError(); + } if (idleExpired) { throw new SseUpstreamError( `Stream idle timeout after ${idleTimeoutMs}ms (no data received)`, @@ -79,8 +117,17 @@ export async function readStreamChunkWithIdleTimeout( ); } return result; + } catch (err) { + // 中断竞速赢时把底层读错误替换为标准 AbortError(reader.read 会因 cancel 拒绝) + if (externalSignal?.aborted) { + throw abortError(); + } + throw err; } finally { if (idleTimer) clearTimeout(idleTimer); + if (externalSignal && onExternalAbort) { + externalSignal.removeEventListener('abort', onExternalAbort); + } } } @@ -90,13 +137,18 @@ interface SseStreamFrame { delta?: { content?: string; reasoning_content?: string; + annotations?: unknown; tool_calls?: Array<{ index?: number; function?: { name?: string; arguments?: string }; }>; }; + /** 部分网关在最后一个 chunk 附带完整 message(含 annotations) */ + message?: { annotations?: unknown }; finish_reason?: string; }>; + /** v0.8.2 P2-6: MiMo 联网搜索引用注释(服务端 web_search 工具) */ + annotations?: unknown; usage?: { prompt_tokens?: number; completion_tokens?: number; @@ -108,6 +160,54 @@ interface SseStreamFrame { }; } +/** + * v0.8.2 P2-6 根治: MiMo 联网搜索引用(annotations)全链路丢失的收口。 + * + *MiMo enableWebSearch 服务端工具在响应中返回 annotations[].{title,url,site_name,...} + *(非流式在 choices[].message.annotations;流式按文档"其余字段与非流式相同", + * 可能出现在最后一个 chunk 的顶层 / message / delta)。此前 sse-stream 完全不 + * 读取该字段 —— mimo.adapter 注释宣称"由上层归并为文本内容展示"实为断头链路, + * 引用信息全链路丢失。 + * + * 采集策略:按 url 去重累积;流结束时([DONE] / 断流兜底)把引用格式化为 + * Markdown 列表以 TEXT_DELTA 追加到正文 —— 引擎/渲染层按既有文本管线自然 + * 消费,无需新事件类型。 + */ +function collectAnnotations( + annotations: unknown, + sink: Map, +): void { + if (!Array.isArray(annotations)) return; + for (const item of annotations) { + if (!item || typeof item !== 'object') continue; + const rec = item as Record; + const url = typeof rec.url === 'string' ? rec.url : ''; + if (!url || sink.has(url)) continue; + sink.set(url, { + title: typeof rec.title === 'string' && rec.title ? rec.title : url, + url, + siteName: typeof rec.site_name === 'string' ? rec.site_name : undefined, + }); + } +} + +function formatAnnotationsBlock( + sink: Map, +): string | null { + if (sink.size === 0) return null; + const MAX_CITATIONS = 20; + const lines: string[] = ['', '', '**References**', '']; + let count = 0; + for (const { title, url, siteName } of sink.values()) { + if (count >= MAX_CITATIONS) break; + const safeUrl = /^https?:\/\//i.test(url) ? url : ''; + if (!safeUrl) continue; + lines.push(`- [${title}](${safeUrl})${siteName ? ` — ${siteName}` : ''}`); + count++; + } + return count > 0 ? lines.join('\n') : null; +} + /** * 从一条已 JSON.parse 的 SSE 数据帧中提取上游错误信息。 * 兼容三种形态: @@ -312,6 +412,7 @@ function* flushToolCallBuffer( * @param requestId - 对应的请求 ID * @param sessionId - 会话 ID * @param iteration - 当前迭代轮次 + * @param externalSignal - 外部中断信号(v0.8.2 P3-3: 流式消费阶段的中断贯通,可选) * @yields MetonaStreamEvent */ export async function* parseSSEStream( @@ -319,6 +420,7 @@ export async function* parseSSEStream( requestId: string, sessionId: string, iteration: number, + externalSignal?: AbortSignal, ): AsyncGenerator { const reader = responseBody.getReader(); const decoder = new TextDecoder(); @@ -341,11 +443,17 @@ export async function* parseSSEStream( // 工具调用缓冲区:index → { name, argsBuffer } const toolCallsBuffer = new Map(); + // v0.8.2 P2-6: MiMo 联网搜索引用采集(按 url 去重,流结束时回填正文) + const annotationsSink = new Map(); try { while (true) { // 数据到达即重置空闲窗口(辅助函数内部实现) - const { done, value } = await readStreamChunkWithIdleTimeout(reader, IDLE_TIMEOUT_MS); + const { done, value } = await readStreamChunkWithIdleTimeout( + reader, + IDLE_TIMEOUT_MS, + externalSignal, + ); if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -366,6 +474,20 @@ export async function* parseSSEStream( // L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码 yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef); + // v0.8.2 P2-6: 引用注释回填正文(在 DONE 之前以 TEXT_DELTA 追加) + const citationBlock = formatAnnotationsBlock(annotationsSink); + if (citationBlock) { + yield { + type: MetonaStreamEventType.TEXT_DELTA, + requestId, + sessionId, + iteration, + seq: seqRef.seq++, + timestamp: Date.now(), + delta: citationBlock, + }; + } + yield { type: MetonaStreamEventType.DONE, requestId, @@ -405,6 +527,11 @@ export async function* parseSSEStream( const delta = chunk.choices?.[0]?.delta; + // v0.8.2 P2-6: 采集引用注释(顶层 / message / delta 三处兼容) + collectAnnotations(chunk.annotations, annotationsSink); + collectAnnotations(chunk.choices?.[0]?.message?.annotations, annotationsSink); + collectAnnotations(delta?.annotations, annotationsSink); + // 文本内容增量 if (delta?.content) { yield { @@ -532,6 +659,19 @@ export async function* parseSSEStream( '[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)', ); yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef); + // v0.8.2 P2-6: 断流兜底路径同样回填引用注释 + const citationBlock = formatAnnotationsBlock(annotationsSink); + if (citationBlock) { + yield { + type: MetonaStreamEventType.TEXT_DELTA, + requestId, + sessionId, + iteration, + seq: seqRef.seq++, + timestamp: Date.now(), + delta: citationBlock, + }; + } yield { type: MetonaStreamEventType.DONE, requestId, @@ -568,8 +708,16 @@ export function parseOpenAICompatibleResponse(data: Record): { const usage = data.usage as Record | undefined; const rawToolCalls = message?.tool_calls as Array> | undefined; + // v0.8.2 P2-6: 非流式路径的引用注释回填(MiMo 联网搜索) + const annotationsSink = new Map(); + collectAnnotations(message?.annotations, annotationsSink); + collectAnnotations(data.annotations, annotationsSink); + let content = (message?.content as string) ?? ''; + const citationBlock = formatAnnotationsBlock(annotationsSink); + if (citationBlock) content += citationBlock; + return { - content: (message?.content as string) ?? '', + content, reasoningContent: message?.reasoning_content as string | undefined, toolCalls: rawToolCalls?.map((tc) => { const fn = tc.function as Record; diff --git a/electron/harness/adapters/shared/ssrf-image-fetch.ts b/electron/harness/adapters/shared/ssrf-image-fetch.ts new file mode 100644 index 0000000..ee8bb3f --- /dev/null +++ b/electron/harness/adapters/shared/ssrf-image-fetch.ts @@ -0,0 +1,176 @@ +/** + * v0.8.2 P0-1: 适配器图片下载的 SSRF 安全通道(Anthropic / Ollama 共用) + * + * 背景:此前 anthropic.adapter.toImageBlock 与 ollama.adapter.resolveImageToBase64 + * 对消息里的 http(s) 图片 URL 直接 fetchWithTimeout 下载转 base64 —— 未接 SSRF + * 校验、无字节上限、无 content-type 约束。与其他工具"SSRF 拦截即断"不同, + * 该通道的下载结果会**以图片块进入模型上下文(数据可回读)**:模型可诱导用户 + * 发送 http://169.254.169.254/... 图片链接回读内网数据,属于可回读外泄通道; + * 同时无上限的 arrayBuffer 会造成内存峰值。 + * + * 收口方案(根治): + * - 校验与连接同源:resolvePublicAddresses(ssrf-guard 单一事实来源)+ DNS + * pinning(ssrfPinnedFetch),代理激活时按既有语义退化为"仅入口校验"; + * - 逐跳重定向复检:redirect:'manual' + resolveRedirectTarget,每一跳都重新 + * 走完整校验(对齐 web_fetch 的逐跳语义),最多 3 跳; + * - 字节上限:content-length 预检 + 流式增量累计双闸(10MB,防大图内存峰值); + * - 类型白名单:content-type 必须 image/*(或缺失/二进制时按魔数嗅探), + * 并钳制到 Provider 实际支持集合(png/jpeg/gif/webp)。 + * + * 失败语义由调用方决定:适配器保持"跳过该图、不阻断请求"(log.warn + 占位)。 + */ + +import { ssrfPinnedFetch, resolveRedirectTarget } from '../../tools/built-in/ssrf-dispatcher'; + +/** 实际下载器签名(与 ssrfPinnedFetch 对齐) */ +export type ImageFetcher = ( + url: string, + init: RequestInit, + timeoutMs: number, + signal?: AbortSignal, +) => Promise; + +/** + * v0.8.2 P0-1: 下载器注入点 —— 生产恒为 ssrfPinnedFetch(校验与连接同源); + * 单元测试通过替换 current 注入受控下载器(undici 客户端无法经 global fetch 打桩)。 + */ +export const __imageFetcher: { current: ImageFetcher } = { current: ssrfPinnedFetch }; + +/** 单张图片下载字节上限(10MB,对齐 view_image 工具 5MB×2 的量级) */ +export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +/** 允许的图片 media type(Anthropic Messages API 支持集合;Ollama 同样接受) */ +const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); + +const MAX_REDIRECTS = 3; + +export interface ImageFetchOptions { + timeoutMs?: number; + maxBytes?: number; + signal?: AbortSignal; +} + +export interface ImageFetchResult { + base64: string; + mediaType: string; +} + +/** 魔数嗅探:content-type 缺失或为通用二进制类型时判定真实图片类型 */ +export function sniffImageMediaType(buf: Buffer): string | null { + if (buf.length >= 8 && buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))) { + return 'image/png'; + } + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { + return 'image/jpeg'; + } + if (buf.length >= 6 && buf.subarray(0, 3).toString('ascii') === 'GIF') { + return 'image/gif'; + } + if ( + buf.length >= 12 && + buf.subarray(0, 4).toString('ascii') === 'RIFF' && + buf.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + return null; +} + +/** 流式增量读取并强制字节上限(content-length 可伪造,以实际累计为准) */ +async function readBodyWithCap(res: Response, maxBytes: number): Promise { + const declared = res.headers.get('content-length'); + const declaredBytes = declared ? Number(declared) : NaN; + if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { + throw new Error(`Image exceeds size limit: ${declaredBytes} > ${maxBytes} bytes`); + } + const body = res.body; + if (!body) { + throw new Error('Image response has no body'); + } + const reader = body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + total += value.byteLength; + if (total > maxBytes) { + throw new Error(`Image exceeds size limit: > ${maxBytes} bytes`); + } + chunks.push(Buffer.from(value)); + } + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks); +} + +/** + * SSRF 安全的图片下载:校验 → pinned 连接 → 逐跳重定向复检 → 类型/尺寸钳制。 + * + * @throws 任何校验失败/网络失败/超限/类型不符均抛 Error(消息含 Blocked SSRF / + * Image exceeds / non-image 等),由调用方按"跳过该图"处理。 + */ +export async function fetchImageAsBase64( + url: string, + options: ImageFetchOptions = {}, +): Promise { + const { timeoutMs = 30_000, maxBytes = MAX_IMAGE_BYTES, signal } = options; + + if (!/^https?:/i.test(url)) { + throw new Error(`Blocked image fetch: protocol not allowed (url=${url.slice(0, 120)})`); + } + + let currentUrl = url; + let res: Response | null = null; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + // __imageFetcher(生产 = ssrfPinnedFetch)内部先 resolvePublicAddresses 全量 + // 校验再 pinned 连接;每一跳都进入本调用 —— 重定向目标同样受完整 SSRF 校验。 + res = await __imageFetcher.current(currentUrl, { redirect: 'manual' }, timeoutMs, signal); + const next = resolveRedirectTarget( + { status: res.status, headers: { get: (n: string) => res!.headers.get(n) } }, + currentUrl, + ); + if (!next) break; + if (hop === MAX_REDIRECTS) { + throw new Error(`Image fetch exceeded ${MAX_REDIRECTS} redirects`); + } + currentUrl = next; + } + if (!res) throw new Error('Image fetch failed: no response'); + + if (!res.ok) { + throw new Error(`Image fetch failed: HTTP ${res.status} (url=${currentUrl.slice(0, 120)})`); + } + + const buf = await readBodyWithCap(res, maxBytes); + if (buf.length === 0) { + throw new Error('Image fetch failed: empty body'); + } + + // 类型钳制:content-type 声明优先(必须 image/*),缺失/通用二进制按魔数嗅探; + // 明确的非图片类型(text/html、application/json 等)直接拒绝。 + const declaredType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + let mediaType: string | null = null; + if ( + declaredType === '' || + declaredType === 'application/octet-stream' || + declaredType === 'binary/octet-stream' + ) { + mediaType = sniffImageMediaType(buf); + } else if (declaredType.startsWith('image/')) { + mediaType = declaredType; + } else { + throw new Error(`Blocked image fetch: non-image content-type "${declaredType}"`); + } + if (!mediaType || !ALLOWED_MEDIA_TYPES.has(mediaType)) { + throw new Error( + `Blocked image fetch: unsupported media type "${mediaType ?? declaredType}" (allowed: png/jpeg/gif/webp)`, + ); + } + + return { base64: buf.toString('base64'), mediaType }; +} diff --git a/electron/harness/agent-loop/__tests__/memory-md-gate.test.ts b/electron/harness/agent-loop/__tests__/memory-md-gate.test.ts new file mode 100644 index 0000000..a1c57f6 --- /dev/null +++ b/electron/harness/agent-loop/__tests__/memory-md-gate.test.ts @@ -0,0 +1,102 @@ +/** + * v0.8.2 P0-2: 根 MEMORY.md 保护闸门(工具无关的路径形参数匹配) + * + * 锁定契约: + * - delete_file / file_move(source_path / destination_path)等旧名单遗漏的工具 + * 对根 MEMORY.md 的操作被拦截(读/写/删/移动/改名任一方向) + * - 子目录 MEMORY.md 不受保护(H-5 语义保持) + * - 相对路径以 workspacePath 为基解析 + * - MCP 工具(任意带路径形参数的工具)同样纳入 + * - 非根 MEMORY.md 的路径不误伤 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { AgentLoopEngine } from '../engine'; + +const WORKSPACE = join(tmpdir(), 'metona-memory-gate-test'); +const ROOT_MEMORY = join(WORKSPACE, 'MEMORY.md'); + +function makeEngine(): AgentLoopEngine { + const engine = new AgentLoopEngine({}, { + providerId: 'fake', + supportedModels: [], + supportsToolCalling: true, + supportsThinking: false, + send: vi.fn(), + sendStream: vi.fn(), + } as never); + engine.setWorkspacePath(WORKSPACE); + return engine; +} + +/** 白盒调用(私有方法契约测试) */ +function gate(engine: AgentLoopEngine, args: Record): boolean { + return ( + engine as unknown as { + isTargetingRootMemoryMd: (tc: { args: Record }) => boolean; + } + ).isTargetingRootMemoryMd({ args }); +} + +describe('根 MEMORY.md 保护闸门(P0-2)', () => { + it('delete_file / file_move(旧名单遗漏工具)→ 拦截', () => { + const engine = makeEngine(); + expect(gate(engine, { file_path: ROOT_MEMORY })).toBe(true); + expect(gate(engine, { source_path: ROOT_MEMORY, destination_path: join(WORKSPACE, 'x') })).toBe( + true, + ); + expect( + gate(engine, { source_path: join(WORKSPACE, 'note.md'), destination_path: ROOT_MEMORY }), + ).toBe(true); + }); + + it('路径参数别名矩阵:path / filePath / destination / dir_path 均命中', () => { + const engine = makeEngine(); + expect(gate(engine, { path: ROOT_MEMORY })).toBe(true); + expect(gate(engine, { filePath: ROOT_MEMORY })).toBe(true); + expect(gate(engine, { destination: ROOT_MEMORY })).toBe(true); + expect(gate(engine, { dir_path: ROOT_MEMORY })).toBe(true); + }); + + it('相对路径以工作空间为基解析 → 拦截', () => { + const engine = makeEngine(); + expect(gate(engine, { file_path: 'MEMORY.md' })).toBe(true); + expect(gate(engine, { file_path: './MEMORY.md' })).toBe(true); + }); + + it('子目录 MEMORY.md 不受保护(H-5 语义)', () => { + const engine = makeEngine(); + expect(gate(engine, { file_path: join(WORKSPACE, 'notes', 'MEMORY.md') })).toBe(false); + }); + + it('MCP 工具的路径形参数同样纳入(任意工具生效)', () => { + const engine = makeEngine(); + expect(gate(engine, { target_path: ROOT_MEMORY, options: { recursive: true } })).toBe(true); + }); + + it('其他文件路径不误伤', () => { + const engine = makeEngine(); + expect(gate(engine, { file_path: join(WORKSPACE, 'src', 'main.ts') })).toBe(false); + expect(gate(engine, { file_path: ROOT_MEMORY + '.bak' })).toBe(false); + expect(gate(engine, { command: 'echo hello' })).toBe(false); + }); + + it('未设置工作空间时闸门放行(无根可保护)', () => { + const engine = new AgentLoopEngine({}, { + providerId: 'fake', + supportedModels: [], + supportsToolCalling: true, + supportsThinking: false, + send: vi.fn(), + sendStream: vi.fn(), + } as never); + expect(gate(engine, { file_path: ROOT_MEMORY })).toBe(false); + }); +}); diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 687f1f9..ad3023b 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -29,11 +29,12 @@ import type { MetonaToolCall, MetonaToolResult, MetonaStreamEvent, + MetonaThinkingBlock, IMetonaProviderAdapter, MetonaToolDef, } from '../types'; import { MetonaStreamEventType, MetonaErrorCode } from '../types'; -import { estimateMessagesTokens } from '../utils/token-estimator'; +import { estimateMessagesTokens, estimateStringTokens } from '../utils/token-estimator'; import { ContentFilterError } from '../adapters/base-adapter'; import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream'; import log from 'electron-log'; @@ -378,6 +379,9 @@ export class AgentLoopEngine extends EventEmitter { role: 'assistant', content: step.thought?.content ?? null, reasoningContent: step.thought?.reasoningContent, + // v0.8.2 P1-1: 透传原始思考块(Anthropic extended thinking 工具循环 + // 多轮请求必须回传带签名的 thinking 块,否则 400 或丢失推理上下文) + thinkingBlocks: step.thinkingBlocks, toolCalls: step.toolCalls, timestamp: Date.now(), iteration: this.currentIteration, @@ -411,6 +415,43 @@ export class AgentLoopEngine extends EventEmitter { }); } } + + // === v0.8.2 P1-2: 上下文压缩判定(移至本轮消息入列之后、下一轮请求构建前) === + // 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。 + // v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死 + // 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。 + // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413。 + // v0.8.2 P1-2: 估算纳入 system prompt(SOUL + MEMORY.md 注入可达数千 token, + // 旧估算只算 messages,system 大时系统性低估 → 压缩迟迟不触发 → API 413)。 + if (!this.aborted) { + const effectiveContextWindow = + this.config.contextLength ?? this.config.contextWindow ?? 0; + const estimatedTokens = this.estimateContextTokens(messages, systemPrompt); + const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens); + const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow; + // 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。 + if ( + effectiveContextWindow > 0 && + actualTokens > compressionThreshold && + messages.length >= 4 + ) { + await this.transitionTo(AgentLoopState.COMPRESSING); + const compressed = await this.compressMessages(messages); + if (compressed) { + // 原地替换数组内容,确保下一轮请求构建引用同步更新 + messages.splice(0, messages.length, ...compressed); + // 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值 + this.lastRealInputTokens = 0; + this.emit('compressed', { + iteration: this.currentIteration, + originalTokens: actualTokens, + compressedTokens: this.estimateContextTokens(compressed, systemPrompt), + }); + } + // 压缩后回到 OBSERVING(下一轮迭代从 THINKING 重新开始) + await this.transitionTo(AgentLoopState.OBSERVING); + } + } } // 循环退出判断 @@ -550,6 +591,8 @@ export class AgentLoopEngine extends EventEmitter { let tokenUsage: TokenUsage | undefined; // v0.8.0 P0-1: 本轮流的 Provider 原生停止原因(adapter DONE 携带) let iterationFinishReason: string | undefined; + // v0.8.2 P1-1: 本轮流的原始思考块(adapter DONE 携带,含 Provider 签名) + let iterationThinkingBlocks: MetonaThinkingBlock[] | undefined; // 流式接收响应 for await (const event of this.chatStreamWithRetry(request)) { @@ -560,6 +603,8 @@ export class AgentLoopEngine extends EventEmitter { // 引擎与 TRACE 据此区分自然完成与输出上限截断(length 此前完全不可见) if (event.type === MetonaStreamEventType.DONE) { if (event.finishReason) iterationFinishReason = event.finishReason; + // v0.8.2 P1-1: 捕获原始思考块(Anthropic extended thinking 协议回传的数据源) + if (event.thinkingBlocks?.length) iterationThinkingBlocks = event.thinkingBlocks; continue; } @@ -662,6 +707,10 @@ export class AgentLoopEngine extends EventEmitter { if (iterationFinishReason) { step.finishReason = iterationFinishReason; } + // v0.8.2 P1-1: 记录本轮原始思考块(主循环写入 assistant 消息供协议回传) + if (iterationThinkingBlocks?.length) { + step.thinkingBlocks = iterationThinkingBlocks; + } // L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段) this.finalizeToolCallsFromBuffer(step, toolCallsBuffer); @@ -791,41 +840,10 @@ export class AgentLoopEngine extends EventEmitter { } } - // === 上下文压缩(基于 token 使用率触发) === - // 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。 - // v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死 - // 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。 - const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0; - const estimatedTokens = this.estimateMessagesTokens(request.messages); - // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 - // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) - // 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确 - const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens); - const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow; - // v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4" - // 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。 - // 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。 - if ( - effectiveContextWindow > 0 && - actualTokens > compressionThreshold && - request.messages.length >= 4 - ) { - await this.transitionTo(AgentLoopState.COMPRESSING); - const compressed = await this.compressMessages(request.messages); - if (compressed) { - // 原地替换数组内容,确保外层 messages 引用同步更新 - request.messages.splice(0, request.messages.length, ...compressed); - // v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值 - this.lastRealInputTokens = 0; - this.emit('compressed', { - iteration: this.currentIteration, - originalTokens: actualTokens, - compressedTokens: this.estimateMessagesTokens(compressed), - }); - } - // 压缩后回到 OBSERVING - await this.transitionTo(AgentLoopState.OBSERVING); - } + // === v0.8.2 P1-2: 上下文压缩判定已移至主循环(本轮 assistant/tool 消息 + // 入列之后)—— 旧位置(本方法内、工具执行后)在本轮消息 push 之前,本轮 + // 刚产生的大体积工具结果不在压缩输入里,最坏情况"压缩完又立刻装回同等 + // 体量"。压缩输入/估算/触发的完整实现见 executeRunStream。 step.completedAt = Date.now(); step.state = AgentLoopState.OBSERVING; @@ -856,6 +874,15 @@ export class AgentLoopEngine extends EventEmitter { if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) { step.toolCalls = []; for (const [, buf] of toolCallsBuffer) { + // v0.8.2 P3-2: 空 name 守卫 —— 纯 DELTA 流丢失 name(上游异常)时, + // name='' 的调用会让 registry 查询 Unknown tool '',产生误导性错误结果。 + // 跳过并留痕,避免无效调用进入执行管道。 + if (!buf.name) { + log.warn( + `[AgentLoop] Dropping buffered tool call with empty name (args tail: ...${buf.argsBuffer.slice(-80)})`, + ); + continue; + } let args: Record; try { args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {}; @@ -975,18 +1002,21 @@ export class AgentLoopEngine extends EventEmitter { // 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md, // 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。 // run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。 - if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) { - if (this.isTargetingRootMemoryMd(toolCall)) { - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result: null, - success: false, - error: 'Access to workspace root MEMORY.md is protected by security policy', - durationMs: Date.now() - startTs, - timestamp: Date.now(), - }; - } + // + // v0.8.2 P0-2 根治:不再按工具名单枚举(名单制曾遗漏 delete_file / file_move, + // 且对 MCP 文件类工具完全不设防),改为对**任意工具调用**的路径形参数做统一 + // 精确匹配 —— 只要某个路径形参数指向工作空间根 MEMORY.md(读/写/删/移动/ + // 改名任一方向),一律拦截。子目录 MEMORY.md 不受影响(保持 H-5 语义)。 + if (this.isTargetingRootMemoryMd(toolCall)) { + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + success: false, + error: 'Access to workspace root MEMORY.md is protected by security policy', + durationMs: Date.now() - startTs, + timestamp: Date.now(), + }; } // 执行工具(带超时) @@ -998,6 +1028,18 @@ export class AgentLoopEngine extends EventEmitter { // M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积 // 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer let engineTimer: ReturnType | undefined; + // v0.8.2 P3-2 根治: 工具级 AbortController —— 超时/引擎中断时真正取消工具执行体。 + // 旧实现超时只是引擎侧放弃(Promise.race reject),registry.execute 的 Promise + // 悬挂,不检查 signal 的工具(子进程/网络类)继续完成副作用且结果无人消费。 + // 现将引擎信号镜像到独立的 toolAbort:超时触发 toolAbort.abort(),工具内部 + // (run_command 子进程、fetch、浏览器操作等)据此真正终止。 + const toolAbort = new AbortController(); + const engineSignal = this.abortController?.signal; + const onEngineAbort = () => toolAbort.abort(); + if (engineSignal) { + if (engineSignal.aborted) toolAbort.abort(); + else engineSignal.addEventListener('abort', onEngineAbort, { once: true }); + } try { toolResult = await Promise.race([ this.toolRegistry.execute(toolCall, { @@ -1005,14 +1047,15 @@ export class AgentLoopEngine extends EventEmitter { workspacePath: this.workspacePath, iteration: this.currentIteration, requestId: this.currentRequestId, - // P0-4: 引擎级 abort 信号透传——用户中断时工具内部(如 run_command 子进程)可自行终止 - signal: this.abortController?.signal, + // 工具级信号:引擎中断镜像 + 超时联动 abort(P0-4 / v0.8.2 P3-2) + signal: toolAbort.signal, }), new Promise((_, reject) => { - engineTimer = setTimeout( - () => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), - toolTimeout, - ); + engineTimer = setTimeout(() => { + // 超时即取消执行体(不只是放弃等待) + toolAbort.abort(); + reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)); + }, toolTimeout); }), ]); } catch (err) { @@ -1035,6 +1078,8 @@ export class AgentLoopEngine extends EventEmitter { } finally { // M-16 修复: 清理未触发的 timeout timer if (engineTimer) clearTimeout(engineTimer); + // v0.8.2 P3-2: 清理引擎信号镜像监听器 + if (engineSignal) engineSignal.removeEventListener('abort', onEngineAbort); } // 后置 Hook 管道(P0-2: 钩子可返回修改后的结果——如 SecurityScanHook 对网页内容脱敏) @@ -1048,39 +1093,52 @@ export class AgentLoopEngine extends EventEmitter { } /** - * H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md + * v0.8.2 P0-2: 路径形参数提取(工具无关)。 * - * @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected; - * subdirectory MEMORY.md files are unrestricted - * - * 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md, - * 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。 - * - * @param toolCall 工具调用 - * @returns 是否指向工作空间根目录的 MEMORY.md + * 命中两类键名:① 精确集合 { path, file, target, destination, source, dir, + * workdir };② 任意以 path/dir 结尾的键(file_path / dir_path / source_path / + * destination_path / filePath / dirpath 等含下划线与驼峰形态)。`target` 在 + * search_files 中是枚举值("content"/"files"),resolve 后不可能与根 MEMORY.md + * 绝对路径精确相等,误报风险为零;反之名单制枚举工具对未来新增工具 / MCP + * 文件类工具存在结构性遗漏。 */ + private static readonly PATH_ARG_KEY_EXACT = new Set([ + 'path', + 'file', + 'target', + 'destination', + 'source', + 'dir', + 'workdir', + ]); + + private extractPathArgs(args: Record): string[] { + const out: string[] = []; + for (const [key, value] of Object.entries(args)) { + if (typeof value !== 'string' || value.length === 0) continue; + const normalized = key.toLowerCase(); + const isPathKey = + AgentLoopEngine.PATH_ARG_KEY_EXACT.has(normalized) || + // 'filepath'/'source_path'/'dirpath' 等任意以 path/dir 结尾的键均为路径形参 + normalized.endsWith('path') || + normalized.endsWith('dir'); + if (isPathKey) out.push(value); + } + return out; + } + private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean { if (!this.workspacePath) return false; - // 提取工具参数中的路径(不同工具使用不同的参数名) - const args = toolCall.args; - const pathStr = - (args.path as string) || - (args.file_path as string) || - (args.filePath as string) || - (args.file as string) || - (args.target as string) || - (args.destination as string); - - if (!pathStr || typeof pathStr !== 'string') return false; - // 解析路径,判断是否指向工作空间根目录的 MEMORY.md // 使用 toLowerCase 处理 Windows 不区分大小写的文件系统 - const resolved = resolve(pathStr).toLowerCase(); const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase(); - // 精确匹配:路径必须等于 {workspacePath}/MEMORY.md - return resolved === rootMemoryPath; + // 精确匹配:任一路径形参数等于 {workspacePath}/MEMORY.md + // 相对路径以 workspacePath 为基解析(与 file-guard/各文件工具语义一致) + return this.extractPathArgs(toolCall.args).some( + (p) => resolve(this.workspacePath, p).toLowerCase() === rootMemoryPath, + ); } /** @@ -1221,6 +1279,9 @@ export class AgentLoopEngine extends EventEmitter { this.adapter = this.fallbackAdapter; // 本 run 内后续迭代均使用 fallback currentAdapter = this.fallbackAdapter; this.syncContextWindow(); + // v0.8.2 P1-2: 故障转移后重置真实输入 token 校正值 —— 旧 Provider 的 + // 真实值参与了新 Provider(窗口可能不同)的压缩判定,会造成预算失真 + this.lastRealInputTokens = 0; // 故障转移后重新注入 abort 信号(新 adapter 实例需要关联引擎的中断控制器) if (this.abortController && currentAdapter.setAbortSignal) { currentAdapter.setAbortSignal(this.abortController.signal); @@ -1300,7 +1361,18 @@ export class AgentLoopEngine extends EventEmitter { // 5xx 服务器错误 — 可重试 if (err.status && err.status >= 500 && err.status < 600) return true; // 网络超时/连接错误 — 可重试 - if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') + // v0.8.2 P3-2: 补充 ECONNABORTED / EPIPE / ECONNREFUSED / EAI_AGAIN —— + // 分别对应请求中止(undici 超时变体)、流写管道断裂、目标瞬时不可达、 + // DNS 临时故障,均属可重试的瞬时网络故障 + if ( + err.code === 'ECONNRESET' || + err.code === 'ETIMEDOUT' || + err.code === 'ENOTFOUND' || + err.code === 'ECONNABORTED' || + err.code === 'EPIPE' || + err.code === 'ECONNREFUSED' || + err.code === 'EAI_AGAIN' + ) return true; // P2-9 一致性修复: toLowerCase 避免大小写敏感漏判 // SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截) @@ -1341,6 +1413,36 @@ export class AgentLoopEngine extends EventEmitter { return estimateMessagesTokens(messages); } + /** + * v0.8.2 P1-2: 估算"下一轮请求"的总输入 token —— messages + system prompt。 + * + * 旧压缩判定只估算 messages:system prompt(SOUL.md + 注入的 MEMORY.md 正文 + + * 安全准则,可达数千 token)被完全排除,system 大时系统性低估 → 压缩迟迟不 + * 触发 → 首轮流式返回前完全靠估算的场景下直接 413。已知的估算边界:工具定义 + * (tools JSON Schema)体积不在此估算内(与 Provider 的序列化形态差异大, + * 由 lastRealInputTokens 真实值校正兜底)。 + */ + private estimateContextTokens( + messages: MetonaMessage[], + systemPrompt?: MetonaSystemPrompt, + ): number { + let total = this.estimateMessagesTokens(messages); + if (systemPrompt) { + const systemText = [ + systemPrompt.roleDefinition, + systemPrompt.outputConstraints, + systemPrompt.safetyGuidelines, + systemPrompt.dynamicReminders, + ] + .filter(Boolean) + .join('\n\n'); + total += estimateStringTokens(systemText); + // system 消息的结构开销 + total += 8; + } + return total; + } + /** * v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。 * @@ -1466,7 +1568,10 @@ export class AgentLoopEngine extends EventEmitter { * 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组 * 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断 * - * @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组) + * v0.8.2 P1-2: 摘要调用失败重试一次;最终失败时降级为**纯截断压缩**(仅保留 + * 近期消息,无 LLM 摘要)—— token 确定下降优于"放弃压缩 → 下一轮 413"。 + * + * @returns 压缩后的消息数组;仅当无法构造任何有效压缩(无可压缩消息)时返回 null */ private async compressMessages(messages: MetonaMessage[]): Promise { // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 @@ -1531,101 +1636,115 @@ export class AgentLoopEngine extends EventEmitter { }) .join('\n\n'); - const summaryRequest: MetonaRequest = { - meta: { - sessionId: this.currentSessionId, - iteration: this.currentIteration, - requestId: `r_${nanoid(12)}`, - timestamp: Date.now(), - agentVersion: '1.0.0', - }, - systemPrompt: { - roleDefinition: 'You are a conversation summarizer.', - outputConstraints: - 'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.', - safetyGuidelines: - 'Do not include sensitive data like passwords or API keys in the summary.', - }, - messages: [ - { - role: 'user', - content: `Please summarize the following conversation history:\n\n${conversationText}`, + // v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断 + // 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文 + // v0.8.2 P1-2: 该截断提前到摘要调用之前完成 —— 纯截断兜底路径与摘要路径共用 + const finalKeep = toKeep.map((msg) => { + const msgTokens = this.estimateMessagesTokens([msg]); + if (msgTokens > keepBudget && msg.content) { + // 截断内容,保留头部和尾部,中间用省略标记 + const halfBudget = Math.floor(keepBudget / 2); + // v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致) + // 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token, + // 导致截断后保留字符过多,实际 token 仍超 keepBudget + const charsPerToken = 1.0; + const keepChars = Math.floor(halfBudget * charsPerToken); + if (msg.content.length > keepChars * 2) { + const head = msg.content.slice(0, keepChars); + const tail = msg.content.slice(-keepChars); + return { + ...msg, + content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`, + }; + } + } + return msg; + }); + + // v0.8.2 P1-2: 摘要调用带一次重试 + 最终失败降级为纯截断压缩。 + // 旧实现共用主 adapter 且一次失败即放弃压缩(返回 null)—— Provider 抖动时 + // 压缩形同虚设,下一轮 LLM 大概率 413。现:① 摘要失败重试一次;② 仍失败则 + // 返回"仅保留近期消息"的纯截断结果(无摘要但 token 确定下降),宁可丢历史 + // 也不让会话进入 413 死锁。 + let summary: string | null = null; + let lastError: unknown = null; + for (let attempt = 0; attempt < 2 && !summary; attempt++) { + const summaryRequest: MetonaRequest = { + meta: { + sessionId: this.currentSessionId, + iteration: this.currentIteration, + requestId: `r_${nanoid(12)}`, timestamp: Date.now(), + agentVersion: '1.0.0', }, - ], - params: { - maxTokens: 2048, - temperature: 0.0, - stream: false, - thinkingEnabled: false, - thinkingEffort: 'low', - }, + systemPrompt: { + roleDefinition: 'You are a conversation summarizer.', + outputConstraints: + 'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.', + safetyGuidelines: + 'Do not include sensitive data like passwords or API keys in the summary.', + }, + messages: [ + { + role: 'user', + content: `Please summarize the following conversation history:\n\n${conversationText}`, + timestamp: Date.now(), + }, + ], + params: { + maxTokens: 2048, + temperature: 0.0, + stream: false, + thinkingEnabled: false, + thinkingEffort: 'low', + }, + }; + try { + const response = await this.adapter.send(summaryRequest); + const text = response.content.trim(); + if (text) summary = text; + } catch (error) { + lastError = error; + log.warn( + `[AgentLoop] Context summary attempt ${attempt + 1}/2 failed: ${(error as Error).message}`, + ); + } + } + + if (!summary) { + log.warn( + `[AgentLoop] Context summary unavailable (${(lastError as Error)?.message ?? 'empty summary'}) — falling back to pure truncation compression`, + ); + return finalKeep; + } + + const summaryMessage: MetonaMessage = { + // #30 修复: 改用 assistant 角色注入摘要,避免语义混淆 + // 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令, + // 可能基于"Summary of previous conversation"字面意思执行奇怪操作。 + // 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会 + // 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息), + // 用 system 角色摘要会被丢弃,压缩无效。 + // 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结), + // LLM 不会将其视为新的用户指令。 + role: 'assistant', + content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`, + timestamp: Date.now(), }; - try { - const response = await this.adapter.send(summaryRequest); - const summary = response.content.trim(); + log.info( + `[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`, + ); - if (!summary) return null; - - const summaryMessage: MetonaMessage = { - // #30 修复: 改用 assistant 角色注入摘要,避免语义混淆 - // 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令, - // 可能基于"Summary of previous conversation"字面意思执行奇怪操作。 - // 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会 - // 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息), - // 用 system 角色摘要会被丢弃,压缩无效。 - // 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结), - // LLM 不会将其视为新的用户指令。 - role: 'assistant', - content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`, - timestamp: Date.now(), - }; - - // v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断 - // 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文 - const finalKeep = toKeep.map((msg) => { - const msgTokens = this.estimateMessagesTokens([msg]); - if (msgTokens > keepBudget && msg.content) { - // 截断内容,保留头部和尾部,中间用省略标记 - const halfBudget = Math.floor(keepBudget / 2); - // v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致) - // 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token, - // 导致截断后保留字符过多,实际 token 仍超 keepBudget - const charsPerToken = 1.0; - const keepChars = Math.floor(halfBudget * charsPerToken); - if (msg.content.length > keepChars * 2) { - const head = msg.content.slice(0, keepChars); - const tail = msg.content.slice(-keepChars); - return { - ...msg, - content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`, - }; - } - } - return msg; - }); - - log.info( - `[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`, - ); - - // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息 - // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 - // 插入占位 user 消息保证对话流清晰。 - return [ - summaryMessage, - // 审查修复: 插入占位 user 消息避免连续 assistant 消息 - { role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() }, - ...finalKeep, - ]; - } catch (error) { - log.warn( - '[AgentLoop] Context compression failed, keeping original messages:', - (error as Error).message, - ); - return null; - } + // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息 + // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 + // 插入占位 user 消息保证对话流清晰。 + return [ + summaryMessage, + // 审查修复: 插入占位 user 消息避免连续 assistant 消息 + { role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() }, + ...finalKeep, + ]; } private accumulateTokens(usage: TokenUsage): void { diff --git a/electron/harness/agent-loop/types.ts b/electron/harness/agent-loop/types.ts index 098215b..bb5e801 100644 --- a/electron/harness/agent-loop/types.ts +++ b/electron/harness/agent-loop/types.ts @@ -4,7 +4,7 @@ * 用于 Agent Loop 引擎内部的状态管理和迭代记录。 */ -import type { MetonaToolCall, MetonaToolResult } from '../types'; +import type { MetonaToolCall, MetonaToolResult, MetonaThinkingBlock } from '../types'; // ===== Agent Loop 状态机 ===== @@ -55,6 +55,12 @@ export interface IterationStep { * 引擎据此区分"自然完成"与"输出上限截断"(P0-2 空响应守卫的输入)。 */ finishReason?: string; + /** + * v0.8.2 P1-1: 本轮 LLM 流的原始思考块(含 Provider 签名)。 + * 由 adapter DONE 事件携带、引擎捕获;主循环据此写入 push 的 assistant 消息 + * (MetonaMessage.thinkingBlocks),AnthropicAdapter 在下一轮请求按协议回传。 + */ + thinkingBlocks?: MetonaThinkingBlock[]; } export interface TokenUsage { diff --git a/electron/harness/memory/__tests__/memory-manager.test.ts b/electron/harness/memory/__tests__/memory-manager.test.ts index 2bffd84..fe2a1e9 100644 --- a/electron/harness/memory/__tests__/memory-manager.test.ts +++ b/electron/harness/memory/__tests__/memory-manager.test.ts @@ -31,7 +31,8 @@ try { import { MemoryManager } from '../manager'; -// 与 DatabaseService.createTables 一致的记忆三表 schema(含 tf_cache 列) +// 与 DatabaseService.createTables 一致的记忆三表 schema(含 tf_cache / embedding / +// embedding_model 列 —— v0.8.2 P3-1 迁移 14 对齐) function createMemorySchema(db: any): void { db.exec(` CREATE TABLE episodic_memories ( @@ -44,7 +45,8 @@ function createMemorySchema(db: any): void { created_at INTEGER NOT NULL DEFAULT 0, expires_at INTEGER, tf_cache TEXT, - embedding BLOB + embedding BLOB, + embedding_model TEXT ); CREATE TABLE semantic_memories ( id TEXT PRIMARY KEY, @@ -57,7 +59,8 @@ function createMemorySchema(db: any): void { updated_at INTEGER NOT NULL DEFAULT 0, access_count INTEGER DEFAULT 0, tf_cache TEXT, - embedding BLOB + embedding BLOB, + embedding_model TEXT ); CREATE TABLE working_memories ( id TEXT PRIMARY KEY, @@ -783,9 +786,9 @@ describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => { } }); - // 注意:manager.store() 的 episodic INSERT 不含 expires_at 列(源码已知缺口, - // main.ts 注释亦确认"expires_at 无写入方")—— 本组用例直接经 SQL 写入 - // expires_at 模拟真实过期行,锁定 cleanupExpired 自身的删除契约。 + // v0.8.2 P3-6 注释修正:v0.8.1 P0-2 已让 store() 真实写入 expires_at(本组 + // 早期版本注释"INSERT 不含 expires_at 列"已过时)。此处直接经 SQL 写入仅为 + // 测试夹具便利(绕过 store 的哈希/分词管线),锁定 cleanupExpired 的删除契约。 const insertExpiring = (id: string, expiresAt: number, content = '带过期记忆'): void => { db.prepare( `INSERT INTO episodic_memories (id, content, source, importance, created_at, expires_at) @@ -984,3 +987,73 @@ describe.skipIf(!dbAvailable)('MemoryManager — v0.8.1 生命周期与混合检 expect(row.embedding!.length % 4).toBe(0); }); }); + +// ===== v0.8.2 P3-1: access_count 保留 + 模型指纹 ===== + +describe.skipIf(!dbAvailable)('MemoryManager — v0.8.2 P3-1', () => { + let db: InstanceType; + let mgr: MemoryManager; + + beforeEach(() => { + db = new Database(':memory:'); + createMemorySchema(db); + mgr = new MemoryManager(() => db); + }); + + it('semantic 同内容重复 store → ON CONFLICT upsert,access_count 不归零', () => { + mgr.store({ + type: 'semantic', + content: '用户偏好深色主题', + source: 'user_input', + importance: 0.9, + }); + db.prepare('UPDATE semantic_memories SET access_count = 7').run(); + // 同内容重复写入(key = contentHash,冲突命中同一行) + mgr.store({ + type: 'semantic', + content: '用户偏好深色主题', + source: 'user_input', + importance: 0.9, + }); + const rows = db.prepare('SELECT access_count, value FROM semantic_memories').all() as Array<{ + access_count: number; + value: string; + }>; + // 旧 REPLACE 语义会删除重插 → 2 行且 access_count 归零 + expect(rows).toHaveLength(1); + expect(rows[0].access_count).toBe(7); + }); + + it('embedding_model 指纹:模型不匹配的向量按缺失处理并触发重算', async () => { + mgr.store({ + type: 'semantic', + content: '指纹校验内容', + summary: 'fp-check', + source: 'agent_thought', + importance: 0.8, + }); + await new Promise((r) => setTimeout(r, 10)); + // 模拟"用户更换了 embedding 模型":旧向量标记为旧模型 + db.prepare( + "UPDATE semantic_memories SET embedding_model = 'old-model' WHERE key = 'fp-check'", + ).run(); + let reembedCalls = 0; + mgr.setEmbedder({ + modelName: 'new-model', + embed: async () => { + reembedCalls += 1; + return [0.1, 0.2, 0.3]; + }, + }); + const results = await mgr.search('指纹校验内容'); + // 检索本身可用(回退 TF-IDF);旧向量被排队用新模型重算 + expect(results.length).toBeGreaterThan(0); + await new Promise((r) => setTimeout(r, 10)); + expect(reembedCalls).toBeGreaterThanOrEqual(1); + const row = db + .prepare("SELECT embedding, embedding_model FROM semantic_memories WHERE key = 'fp-check'") + .get() as { embedding: Buffer | null; embedding_model: string | null }; + expect(row.embedding_model).toBe('new-model'); + expect(row.embedding).not.toBeNull(); + }); +}); diff --git a/electron/harness/memory/embedder.ts b/electron/harness/memory/embedder.ts index 21b3b68..673349d 100644 --- a/electron/harness/memory/embedder.ts +++ b/electron/harness/memory/embedder.ts @@ -16,4 +16,12 @@ export type MemoryEmbedFn = (text: string) => Promise; export interface MemoryEmbedder { embed: MemoryEmbedFn; + /** + * v0.8.2 P3-1: 嵌入模型标识(指纹)。 + * 用户更换 embedding 模型后,旧向量与新查询向量维度/语义空间不匹配 —— + * 维度不同余弦静默为 0,同维不同模型产生噪声分数。Manager 以该标识标注 + * 每条向量(embedding_model 列),检索时模型不匹配的向量视为缺失并惰性重算。 + * 未提供时无法做指纹校验(旧向量一律接受 —— 保持旧行为兼容)。 + */ + readonly modelName?: string; } diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index e1c23bd..c84df99 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -414,7 +414,12 @@ export class MemoryManager { source: row.source as MemorySource, sessionId: row.session_id ?? undefined, expiresAt: row.expires_at ?? undefined, - docVec: blobToFloat32((row as { embedding?: unknown }).embedding), + // v0.8.2 P3-1: 走模型指纹校验的解码(不匹配 → 视为缺失 + 惰性重算) + docVec: this.decodeEmbeddingRow( + (row as { embedding?: unknown }).embedding, + (row as { embedding_model?: string | null }).embedding_model ?? null, + row.content + ' ' + (row.summary ?? ''), + ), queryVec, }, queryTF, @@ -428,6 +433,7 @@ export class MemoryManager { rows.map((r) => ({ id: r.id, embedding: (r as { embedding?: unknown }).embedding, + embedding_model: (r as { embedding_model?: string | null }).embedding_model ?? null, text: r.content + ' ' + (r.summary ?? ''), })), ); @@ -478,6 +484,7 @@ export class MemoryManager { rows.map((r) => ({ id: r.id, embedding: (r as { embedding?: unknown }).embedding, + embedding_model: (r as { embedding_model?: string | null }).embedding_model ?? null, text: r.key + ' ' + r.value, })), ); @@ -565,10 +572,24 @@ export class MemoryManager { // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 // v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE, // 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。 + // + // v0.8.2 P3-1 根治: INSERT OR REPLACE → ON CONFLICT DO UPDATE。 + // REPLACE 是"删旧行 + 插新行"—— 新 id 替换旧 id,**access_count 归零**: + // 热门记忆被同内容更新后 LRU 排序权重凭空丢失。现以 key 为冲突目标做 + // 真正的 upsert:更新内容侧字段,保留 access_count 与旧 embedding + //(同 key 意味着内容相同,旧向量仍然有效;模型更换由指纹校验自愈)。 db.prepare( ` - INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache) + INSERT INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + category = excluded.category, + confidence = excluded.confidence, + source_session = excluded.source_session, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + tf_cache = excluded.tf_cache `, ).run( id, @@ -635,9 +656,10 @@ export class MemoryManager { .embed(text.slice(0, 8000)) .then((vec) => { if (!vec || vec.length === 0) return; + // v0.8.2 P3-1: 同步写入模型指纹(检索时按指纹校验,模型更换后惰性重算) this.getDB() - .prepare(`UPDATE ${table} SET embedding = ? WHERE id = ?`) - .run(float32ToBlob(vec), id); + .prepare(`UPDATE ${table} SET embedding = ?, embedding_model = ? WHERE id = ?`) + .run(float32ToBlob(vec), embedder.modelName ?? null, id); }) .catch((err) => { log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`); @@ -647,6 +669,144 @@ export class MemoryManager { }); } + /** + * v0.8.2 P3-1: BLOB 解码 + 模型指纹校验。 + * ① 字节/维度损坏 → null(回退 TF-IDF);② 当前嵌入器声明了模型指纹且行内 + * 指纹不匹配(更换过 embedding 模型 / 旧版本写入的无指纹行)→ 视为缺失并 + * 触发惰性重算 —— 旧实现跨模型余弦为 0(降级)或产生噪声分数,且无自愈路径。 + */ + private decodeEmbeddingRow( + blob: unknown, + rowModel: string | null, + _text: string, + ): number[] | null { + const vec = blobToFloat32(blob); + if (!vec) return null; + const currentModel = this.embedder?.modelName; + if (currentModel && (rowModel ?? null) !== currentModel) { + // 指纹不匹配:本轮按"无向量"处理(回退 TF-IDF),同时排队用当前模型重算 + return null; + } + return vec; + } + + /** + * v0.8.2 P3-1: 向量路独立召回(与重要度预过滤解耦)。 + * + * 旧实现混合检索的向量余弦只在 `ORDER BY importance DESC LIMIT topK*3` 的 + * 候选池内计算 —— 低重要度但语义高度相关的记忆永远进不了向量路,"同义改写 + * 召回"(P1-1 立项目标)被结构性钳制。现当查询向量可用时,从 embedding 命中 + * 的行中按时间取最近 VECTOR_RECALL_POOL 条独立召回(importance 过滤仍生效), + * 以纯向量余弦 × 衰减 × 重要度权重评分,由 search() 与 TF-IDF 路合并去重。 + */ + private static readonly VECTOR_RECALL_POOL = 200; + + private vectorRecall( + queryVec: number[], + options: MemorySearchOptions, + now: number, + ): SearchResult[] { + const db = this.getDB(); + const { topK = 5, type, minImportance = 0 } = options; + const pool = Math.max(topK * 10, MemoryManager.VECTOR_RECALL_POOL); + const results: SearchResult[] = []; + const importanceWeight = (importance: number): number => 0.5 + importance * 0.5; + + if (!type || type === 'episodic') { + const rows = db + .prepare( + ` + SELECT id, session_id, content, summary, source, importance, created_at, expires_at, embedding, embedding_model + FROM episodic_memories + WHERE importance >= ? AND embedding IS NOT NULL + ORDER BY created_at DESC LIMIT ? + `, + ) + .all(minImportance, pool) as Array<{ + id: string; + session_id: string | null; + content: string; + summary: string | null; + source: string; + importance: number; + created_at: number; + expires_at: number | null; + embedding: unknown; + embedding_model: string | null; + }>; + for (const row of rows) { + const docVec = this.decodeEmbeddingRow(row.embedding, row.embedding_model, row.content); + if (!docVec) { + // 指纹不匹配(有旧向量但不被当前模型接受)→ 惰性重算 + if (row.embedding != null) this.enrichEmbedding('episodic', row.id, row.content); + continue; + } + const sim = cosineSimilarity(queryVec, docVec); + if (sim <= 0) continue; + results.push({ + id: row.id, + type: 'episodic', + content: row.content, + summary: row.summary ?? undefined, + source: row.source as MemorySource, + importance: row.importance, + sessionId: row.session_id ?? undefined, + createdAt: row.created_at, + expiresAt: row.expires_at ?? undefined, + score: sim * timeDecayWeight(row.created_at, now) * importanceWeight(row.importance), + }); + } + } + + if (!type || type === 'semantic') { + const rows = db + .prepare( + ` + SELECT id, key, value, confidence, source_session, created_at, embedding, embedding_model + FROM semantic_memories + WHERE confidence >= ? AND embedding IS NOT NULL + ORDER BY updated_at DESC LIMIT ? + `, + ) + .all(minImportance, pool) as Array<{ + id: string; + key: string; + value: string; + confidence: number; + source_session: string | null; + created_at: number; + embedding: unknown; + embedding_model: string | null; + }>; + for (const row of rows) { + const docVec = this.decodeEmbeddingRow( + row.embedding, + row.embedding_model, + row.key + ' ' + row.value, + ); + if (!docVec) { + if (row.embedding != null) + this.enrichEmbedding('semantic', row.id, row.key + ' ' + row.value); + continue; + } + const sim = cosineSimilarity(queryVec, docVec); + if (sim <= 0) continue; + results.push({ + id: row.id, + type: 'semantic', + content: row.value, + source: 'imported', + importance: row.confidence, + sessionId: row.source_session ?? undefined, + createdAt: row.created_at, + score: sim * timeDecayWeight(row.created_at, now) * importanceWeight(row.confidence), + }); + } + } + + return results.sort((a, b) => b.score - a.score).slice(0, topK); + } + /** * v0.8.1 P1-1: 存量记忆向量惰性回填 —— 嵌入功能开启前写入的记忆(embedding * IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量 @@ -655,12 +815,20 @@ export class MemoryManager { */ private backfillMissingEmbeddings( type: MemoryType, - rows: Array<{ id: string; embedding?: unknown; text: string }>, + rows: Array<{ id: string; embedding?: unknown; embedding_model?: string | null; text: string }>, ): void { if (!this.embedder) return; + const currentModel = this.embedder.modelName; for (const row of rows) { - if (row.embedding != null) continue; - this.enrichEmbedding(type, row.id, row.text); + if (row.embedding == null) { + this.enrichEmbedding(type, row.id, row.text); + continue; + } + // v0.8.2 P3-1: 指纹不匹配(更换过 embedding 模型 / 旧版本无指纹行)→ + // 用当前模型重算,旧向量在重算完成前不参与向量评分 + if (currentModel && (row.embedding_model ?? null) !== currentModel) { + this.enrichEmbedding(type, row.id, row.text); + } } } @@ -716,10 +884,27 @@ export class MemoryManager { } // v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合) + const now = Date.now(); const tfidfResults = this.tfidfSearch(query, options, queryVec); - if (tfidfResults.length > 0) { - this.bumpAccessCounts(tfidfResults); - return tfidfResults; + + // v0.8.2 P3-1: 向量路独立召回合并 —— 低重要度但语义相关的记忆不再被 + // importance 预过滤的候选池钳制(tfidfSearch 的向量分量只在重要度池内算)。 + let merged = tfidfResults; + if (queryVec) { + const vectorResults = this.vectorRecall(queryVec, options, now); + if (vectorResults.length > 0) { + const byId = new Map(); + for (const r of [...tfidfResults, ...vectorResults]) { + const prev = byId.get(r.id); + if (!prev || r.score > prev.score) byId.set(r.id, r); + } + merged = [...byId.values()].sort((a, b) => b.score - a.score).slice(0, topK); + } + } + + if (merged.length > 0) { + this.bumpAccessCounts(merged); + return merged; } // 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索 diff --git a/electron/harness/orchestration/__tests__/orchestrator.test.ts b/electron/harness/orchestration/__tests__/orchestrator.test.ts index d73108f..3098029 100644 --- a/electron/harness/orchestration/__tests__/orchestrator.test.ts +++ b/electron/harness/orchestration/__tests__/orchestrator.test.ts @@ -238,7 +238,7 @@ describe('TaskOrchestrator — 工具白名单', () => { }); }); -describe('TaskOrchestrator — 递归深度限制', () => { +describe('TaskOrchestrator — 并发委派上限', () => { it('同一会话并发达到 3 层后,第 4 次委派被拒绝(同步计数)', async () => { const { releases } = installLatchedAdapter(); @@ -248,7 +248,8 @@ describe('TaskOrchestrator — 递归深度限制', () => { const d4 = await orchestrator.delegate({ description: 'L4', parentSessionId: 's' }); expect(d4.success).toBe(false); - expect(d4.result).toContain('depth limit'); + // v0.8.2 P3-2: 文案如实描述为并发上限(非递归深度 —— SubAgent 无 delegate_task) + expect(d4.result).toContain('concurrency limit'); expect(d4.iterations).toBe(0); // 等三个引擎都挂起到闩锁再释放(过早释放会扑空) diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index 3f46312..bb3b529 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -86,7 +86,7 @@ interface SubAgentHandle { getStatus: () => { taskId: string; status: string; description: string; depth: number }; } -/** 默认递归深度限制 */ +/** 默认并发委派上限(每会话同时活跃的 SubAgent 数) */ const MAX_DELEGATION_DEPTH = 3; export class TaskOrchestrator extends EventEmitter { @@ -139,16 +139,21 @@ export class TaskOrchestrator extends EventEmitter { const taskId = params.taskId ?? `sub_${nanoid(8)}`; const startMs = Date.now(); - // ===== 递归深度检查 ===== + // ===== 并发委派上限检查 ===== + // v0.8.2 P3-2 根治(语义对齐):sessionDepth 实际计数的是"该会话当前活跃的 + // 委派数"(finally 中恢复/清除),且 delegate_task 已从 SubAgent 工具集排除、 + // 真实嵌套递归不可能发生 —— 旧文案"depth limit reached"误导排障方向 + // (测试 engine-toolchain/orchestrator 按并发 3 层锁定该行为)。现文案与 + // 注释如实描述为并发委派上限;README 的"3 层深度"措辞同步对齐。 const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0; if (currentDepth >= MAX_DELEGATION_DEPTH) { log.warn( - `[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`, + `[Orchestrator] Concurrent delegation limit reached (${currentDepth}) for session ${params.parentSessionId}`, ); return { taskId, parentSessionId: params.parentSessionId, - result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`, + result: `SubAgent concurrency limit reached (${MAX_DELEGATION_DEPTH} concurrent delegations per session). Wait for an active subtask to finish before delegating more.`, success: false, durationMs: 0, iterations: 0, diff --git a/electron/harness/tools/built-in/diff-viewer.ts b/electron/harness/tools/built-in/diff-viewer.ts index 089139e..f9be73b 100644 --- a/electron/harness/tools/built-in/diff-viewer.ts +++ b/electron/harness/tools/built-in/diff-viewer.ts @@ -14,11 +14,7 @@ const MAX_DIFF_FILE_BYTES = 10 * 1024 * 1024; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; -import { - safeResolvePath, - extractErrorMessage, - decodeBufferWithDetection, -} from './file-guard'; +import { safeResolvePath, extractErrorMessage, decodeBufferWithDetection } from './file-guard'; interface DiffLine { type: 'context' | 'added' | 'removed'; @@ -53,12 +49,14 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] { // 回溯生成 diff const result: DiffLine[] = []; - let i = sm, j = sn; + let i = sm, + j = sn; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) { result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 1] }); - i--; j--; + i--; + j--; } else if (j > 0 && (i === 0 || lcs[idx(i, j - 1)] >= lcs[idx(i - 1, j)])) { result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] }); j--; @@ -72,7 +70,12 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] { } /** 生成 unified diff 格式字符串 */ -function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: string, contextLines: number = 3): string { +function formatUnifiedDiff( + diffLines: DiffLine[], + oldLabel: string, + newLabel: string, + contextLines: number = 3, +): string { const lines: string[] = []; lines.push(`--- ${oldLabel}`); lines.push(`+++ ${newLabel}`); @@ -89,7 +92,11 @@ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: st if (hunkLines.length > 0) { // 移除尾部多余的 context 行 const trimmed: string[] = [...hunkLines]; - while (trimmed.length > 0 && trimmed[trimmed.length - 1].startsWith(' ') && contextSinceChange > 0) { + while ( + trimmed.length > 0 && + trimmed[trimmed.length - 1].startsWith(' ') && + contextSinceChange > 0 + ) { trimmed.pop(); contextSinceChange--; } @@ -142,10 +149,29 @@ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: st return lines.join('\n'); } +/** + * v0.8.2 P2-1: 文本级 unified diff 计算导出(file_editor dry_run 复用)。 + * dry_run 预览此前返回 {original, modified} 两段裸文本 —— 模型与用户都要自行 + * 比对差异。现与 diff_viewer 同源(LCS + unified 格式),渲染端可统一以 + * diff 视图展示。 + */ +export function computeUnifiedDiffText( + oldText: string, + newText: string, + oldLabel: string, + newLabel: string, + contextLines: number = 3, +): string { + const oldLines = oldText.length > 0 ? oldText.split('\n') : []; + const newLines = newText.length > 0 ? newText.split('\n') : []; + return formatUnifiedDiff(computeDiff(oldLines, newLines), oldLabel, newLabel, contextLines); +} + export class DiffViewerTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'diff_viewer', - description: 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.', + description: + 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.', parameters: { type: 'object', properties: { @@ -158,7 +184,10 @@ export class DiffViewerTool implements IMetonaTool { file_b: { type: 'string', description: 'Second file path (for files mode)' }, text_a: { type: 'string', description: 'First text content (for text mode)' }, text_b: { type: 'string', description: 'Second text content (for text mode)' }, - context_lines: { type: 'number', description: 'Context lines around changes (default 3, max 10)' }, + context_lines: { + type: 'number', + description: 'Context lines around changes (default 3, max 10)', + }, }, required: ['mode'], }, @@ -254,16 +283,19 @@ export class DiffViewerTool implements IMetonaTool { lines_removed: removedCount, lines_unchanged: contextCount, total_changes: addedCount + removedCount, - similarity: (oldSlicedLen + newSlicedLen) > 0 - ? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100 - : 1, + similarity: + oldSlicedLen + newSlicedLen > 0 + ? Math.round(((contextCount * 2) / (oldSlicedLen + newSlicedLen)) * 100) / 100 + : 1, truncated, }; // D4.6: unifiedDiff 大小限制 - const MAX_DIFF_CHARS = 50_000; const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS - ? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)' - : unifiedDiff; + const MAX_DIFF_CHARS = 50_000; + const truncatedDiff = + unifiedDiff.length > MAX_DIFF_CHARS + ? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)' + : unifiedDiff; return { success: true, diff --git a/electron/harness/tools/built-in/file-editor.ts b/electron/harness/tools/built-in/file-editor.ts index 1fa8977..ffa67d6 100644 --- a/electron/harness/tools/built-in/file-editor.ts +++ b/electron/harness/tools/built-in/file-editor.ts @@ -30,6 +30,8 @@ import { FILE_TOOL_TIMEOUT_MS, isPotentiallyCatastrophicRegex, } from './file-guard'; +// v0.8.2 P2-1: dry_run 预览补 unified diff(与 diff_viewer 同源 LCS) +import { computeUnifiedDiffText } from './diff-viewer'; /** * v0.7.4 P2-7: 灾难性正则检测从 file-guard 导入(共享模块), @@ -403,6 +405,15 @@ export class FileEditorTool implements IMetonaTool { preview: { original: originalPreview, modified: modifiedPreview, + // v0.8.2 P2-1: 与 diff_viewer 同源的 unified diff —— 渲染端统一以 + // diff 视图展示 dry_run 预览(旧形态是两段裸文本,模型/用户需自行比对) + diff: computeUnifiedDiffText( + originalPreview, + modifiedPreview, + `${args.file_path} (original)`, + `${args.file_path} (modified)`, + 3, + ), }, }; } diff --git a/electron/harness/tools/built-in/filesystem.ts b/electron/harness/tools/built-in/filesystem.ts index 3123708..23b70ae 100644 --- a/electron/harness/tools/built-in/filesystem.ts +++ b/electron/harness/tools/built-in/filesystem.ts @@ -803,6 +803,16 @@ export class SearchFilesTool implements IMetonaTool { } else { // F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx") if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue; + // v0.8.2 P1-6: 文件符号链接边界复检 —— 目录 symlink 已不跟随,但文件 + // symlink 会进入 callback 直接读取内容:工作空间内 `ln -s /etc/passwd + // leak.txt` 后内容搜索即可回显外部文件(read_file 有双 realpath 校验, + // 此路径此前无防线)。realpath 越出工作空间边界即跳过。 + if (entry.isSymbolicLink() && workspacePath) { + const realFile = await realpath(fullPath).catch(() => null); + if (!realFile || !isPathWithinWorkspace(realFile, workspacePath)) { + continue; + } + } await callback(fullPath, entry.name); } } diff --git a/electron/harness/tools/built-in/ssrf-guard.ts b/electron/harness/tools/built-in/ssrf-guard.ts index 254ce4c..91e5657 100644 --- a/electron/harness/tools/built-in/ssrf-guard.ts +++ b/electron/harness/tools/built-in/ssrf-guard.ts @@ -29,7 +29,8 @@ export const __dnsLookup: { current: typeof lookup } = { current: lookup }; * 覆盖: * - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、 * 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、 - * 224.0.0.0/4 (组播)、240.0.0.0/4 (保留) + * 224.0.0.0/4 (组播)、240.0.0.0/4 (保留)、 + * 100.64.0.0/10 (CGNAT,v0.8.2 P3-1)、198.18.0.0/15 (基准测试段,P3-1) * - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4 */ export function isPrivateIP(ip: string): boolean { @@ -42,6 +43,8 @@ export function isPrivateIP(ip: string): boolean { if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网 if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据) if (parts[0] === 0) return true; // 0.0.0.0/8 + if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // 100.64/10 CGNAT(v0.8.2 P3-1) + if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return true; // 198.18/15 基准测试段(v0.8.2 P3-1) if (parts[0] >= 224) return true; // 组播 + 保留 return false; } diff --git a/electron/harness/tools/built-in/web-search.ts b/electron/harness/tools/built-in/web-search.ts index f184733..023a55c 100644 --- a/electron/harness/tools/built-in/web-search.ts +++ b/electron/harness/tools/built-in/web-search.ts @@ -27,7 +27,7 @@ import { logTool, } from './network-utils'; // v0.7.3 P2-1: 可达性预检经 SSRF 校验 + DNS pinning(结果 URL 是不可信外部输入) -import { safeValidateSSRF } from './ssrf-guard'; +import { safeValidateSSRF, assertSafeConfigTargetDeep, DeepCheckSoftFailure } from './ssrf-guard'; import { ssrfPinnedFetch } from './ssrf-dispatcher'; import type { WebFetchTool } from './web-fetch'; @@ -579,6 +579,22 @@ export class WebSearchTool implements IMetonaTool { const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type); const tr = timeRange || config.time_range; + // v0.8.2 P1-6: SearXNG 运行时请求的 SSRF 纵深校验。 + // 配置期已有 assertSafeConfigTarget(ipc/shared 写入链),但 DNS 记录可在配置 + // 之后被切换(指向云元数据/链路本地)—— 运行时请求此前完全无校验。此处对 + // 每次搜索会话做同口径静态校验 + DNS 深校验;本地回环/RFC1918 合法放行 + // (SearXNG 常部署本机/内网),DNS 解析失败按 DeepCheckSoftFailure 留痕放行 + // (离线实例合法,与配置期深校验语义一致)。 + try { + await assertSafeConfigTargetDeep(baseUrl); + } catch (err) { + if (err instanceof DeepCheckSoftFailure) { + logTool('web_search', `[SearXNG] DNS deep check skipped (soft-fail): ${err.message}`); + } else { + throw new Error(`SearXNG target blocked by security policy: ${(err as Error).message}`); + } + } + // SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条 let page = 1; diff --git a/electron/harness/types/index.ts b/electron/harness/types/index.ts index 3dd29d5..9241efc 100644 --- a/electron/harness/types/index.ts +++ b/electron/harness/types/index.ts @@ -30,14 +30,18 @@ export type { MetonaTokenUsage, MetonaStreamEvent, MetonaValidationPayload, - MetonaThinking, MetonaError, } from './metona-response'; export { MetonaFinishReason, MetonaStreamEventType, MetonaErrorCode } from './metona-response'; -// ===== 上下文与记忆 ===== -export type { MetonaContext, MetonaMemoryItem } from './metona-context'; +// v0.8.2 P3-6 IR 卫生: 删除 metona-context.ts(MetonaContext / MetonaMemoryItem)—— +// P1-12 已移除唯一的 build() 组装路径后二者零消费方;MetonaMemoryItem 的真实形态 +// 由 memory 子系统的存储行结构承担。纸面类型遵循 v0.7.2 P3-12(删 THINKING_START/ +// END)同一治理先例:不为"文档曾如此描述"保留死契约。 + +// ===== 思考块 ===== +export type { MetonaThinkingBlock } from './metona-request'; // ===== 适配器接口 ===== export type { IMetonaProviderAdapter, AdapterConfig } from './metona-adapter'; diff --git a/electron/harness/types/metona-context.ts b/electron/harness/types/metona-context.ts deleted file mode 100644 index 6a58831..0000000 --- a/electron/harness/types/metona-context.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Metona IR — 上下文与记忆类型定义 - * - * @see docs/MetonaAI-Desktop 内部API请求与响应标准.html - */ - -import type { MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from './metona-request'; - -// ===== 上下文标准 ===== - -export interface MetonaContext { - /** 上下文唯一标识 */ - id: string; - /** 关联的会话 */ - sessionId: string; - /** System Prompt 分区 */ - systemPrompt: MetonaSystemPrompt; - /** 会话历史(最近 N 轮) */ - history: MetonaMessage[]; - /** 检索到的相关记忆 */ - relevantMemories: MetonaMemoryItem[]; - /** 当前任务信息 */ - currentTask: { - userInput: string; - iteration: number; - taskGoal?: string; - }; - /** 可用工具列表 */ - availableTools: MetonaToolDef[]; - /** 预估 Token 数 */ - estimatedTokens: number; - /** 上下文使用率(estimatedTokens / contextWindow) */ - usageRatio: number; - /** 是否需要压缩 */ - needsCompression: boolean; -} - -// ===== 记忆格式 ===== - -export interface MetonaMemoryItem { - id: string; - type: 'episodic' | 'semantic' | 'working'; - - /** 可被 LLM 阅读的记忆内容 */ - content: string; - /** 精简摘要(上下文紧张时使用) */ - summary?: string; - - /** 来源 */ - source: 'user_input' | 'tool_result' | 'agent_thought' | 'imported'; - - /** 重要程度 0-1 */ - importance: number; - - /** 检索相关性分数(仅在检索结果中出现) */ - relevanceScore?: number; - - sessionId?: string; - createdAt: number; - expiresAt?: number; -} diff --git a/electron/harness/types/metona-request.ts b/electron/harness/types/metona-request.ts index 3cab879..079e348 100644 --- a/electron/harness/types/metona-request.ts +++ b/electron/harness/types/metona-request.ts @@ -78,6 +78,23 @@ export interface MetonaConstraints { // ===== 消息格式 ===== +/** + * v0.8.2 P1-1: 思考块(含 Provider 签名)—— Anthropic extended thinking + tool use + * 多轮回传的协议要求:assistant 消息(尤其含 tool_use 的轮次)必须携带原始 thinking / + * redacted_thinking 块(含 signature),否则 API 400 或推理上下文丢失。 + * IR 层不感知签名细节,仅做透传容器;由 AnthropicAdapter 写入与消费, + * 其余 Provider 忽略该字段。 + */ +export interface MetonaThinkingBlock { + type: 'thinking' | 'redacted_thinking'; + /** thinking 块的推理文本 */ + thinking?: string; + /** thinking 块的 Provider 签名(回传校验必需;缺失时消费方必须丢弃该块) */ + signature?: string; + /** redacted_thinking 块的不透明载荷(原样回传) */ + data?: string; +} + export interface MetonaMessage { role: 'system' | 'user' | 'assistant' | 'tool'; @@ -92,6 +109,15 @@ export interface MetonaMessage { /** (仅 assistant)思考/推理内容 */ reasoningContent?: string; + /** + * (仅 assistant)原始思考块(v0.8.2 P1-1) + * + * 与 reasoningContent(展示用纯文本)并行:thinkingBlocks 保留 Provider 原始块 + * 结构与签名,由引擎从流式 DONE 事件透传到其 push 的 assistant 消息上, + * AnthropicAdapter 在构建下一轮请求时按协议回传。 + */ + thinkingBlocks?: MetonaThinkingBlock[]; + /** (仅 assistant)工具调用请求 */ toolCalls?: MetonaToolCall[]; @@ -144,6 +170,20 @@ export interface MetonaParamField { description: string; enum?: string[]; items?: MetonaParamField; + /** + * v0.8.2 P2-7: MCP schema 保真扩展 —— 旧 convertSchema 只保留 type/description, + * 丢弃 enum/anyOf/oneOf/嵌套对象/items/default,模型看到的 MCP 参数定义大幅退化, + * 复杂 MCP 工具易产生非法参数。现保留以下结构(内置工具的 Zod 转换路径不受影响)。 + */ + /** 嵌套对象属性(type=object 时) */ + properties?: Record; + /** 嵌套对象的必填键 */ + required?: string[]; + /** 类型组合(anyOf/oneOf 的原样保留) */ + anyOf?: MetonaParamField[]; + oneOf?: MetonaParamField[]; + /** 默认值(原样透传) */ + default?: unknown; } export enum MetonaToolCategory { diff --git a/electron/harness/types/metona-response.ts b/electron/harness/types/metona-response.ts index 3535f82..02cfaa4 100644 --- a/electron/harness/types/metona-response.ts +++ b/electron/harness/types/metona-response.ts @@ -4,7 +4,7 @@ * @see docs/MetonaAI-Desktop 内部API请求与响应标准.html */ -import type { MetonaToolCall, MetonaToolResult } from './metona-request'; +import type { MetonaToolCall, MetonaToolResult, MetonaThinkingBlock } from './metona-request'; // ===== 响应元信息 ===== @@ -64,6 +64,11 @@ export interface MetonaResponse { content: string; /** 思考/推理内容(Thinking 模式) */ reasoningContent?: string; + /** + * v0.8.2 P1-1: 原始思考块(非流式路径)—— 与 reasoningContent 并行保留 + * Provider 原始块结构与签名,供引擎透传到 assistant 消息实现协议回传。 + */ + thinkingBlocks?: MetonaThinkingBlock[]; /** 结构化输出(如果模型原生支持 JSON Schema) */ structuredOutput?: unknown; /** 工具调用请求列表 */ @@ -156,6 +161,17 @@ export interface MetonaStreamEvent { * (v0.7.4 及之前硬编码 'stop',length 截断在录制文件中不可归因)。 */ finishReason?: string; + + /** + * DONE — 原始思考块(v0.8.2 P1-1,Anthropic 专用)。 + * + * Anthropic extended thinking 的 thinking/redacted_thinking 块携带 Provider + * 签名,工具循环多轮请求必须原样回传。流式路径下块结构随 content_block_* 事件 + * 消散,adapter 在 DONE 事件上集中携带本响应收集到的全部思考块(仅含签名完备 + * 的块),引擎据此写入其 push 的 assistant 消息(MetonaMessage.thinkingBlocks)。 + * 其余 Provider 不携带该字段。 + */ + thinkingBlocks?: MetonaThinkingBlock[]; } /** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */ @@ -173,17 +189,9 @@ export interface MetonaValidationPayload { } // ===== 思考内容 ===== - -export interface MetonaThinking { - /** 思考内容文本 */ - content: string; - /** 思考状态 */ - status: 'thinking' | 'complete'; - /** 思考耗时 (ms) */ - durationMs: number; - /** 思考消耗的 token 数 */ - tokensUsed: number; -} +// v0.8.2 P3-6 IR 卫生: 删除 MetonaThinking —— 自注册以来全链路零发送方零消费者 +// (思考边界由 reasoning_delta 流语义承载),且其字段与 P1-1 引入的 +// MetonaThinkingBlock(协议回传所需的真实块结构)概念重叠,保留只会误导读者。 // ===== 错误格式 ===== diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts index 93ffa22..f2d85f1 100644 --- a/electron/ipc/__tests__/agent.test.ts +++ b/electron/ipc/__tests__/agent.test.ts @@ -234,10 +234,9 @@ describe('agent:sendMessage — 前置检查', () => { const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect((result as { success: boolean }).success).toBe(false); expect((result as { error: string }).error).toContain('blocked by prompt injection defense'); - // 用户消息不保存(在注入检测前已保存?—— 现实现:先保存再检测,验证已保存) - expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( - expect.objectContaining({ role: 'user', sessionId: 'sess_1' }), - ); + // v0.8.2 P1-4: 检测前移 —— 被阻断的消息**不**落库(旧实现先保存后检测, + // 恶意内容滞留会话历史) + expect(ctxRaw.sessionService.saveMessage).not.toHaveBeenCalled(); // 引擎不启动 expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); }); diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts index 17efb99..49200f7 100644 --- a/electron/ipc/agent.ts +++ b/electron/ipc/agent.ts @@ -44,6 +44,18 @@ function buildTimezoneLabel(): string { } } +/** + * v0.8.2 P2-4: 会话元数据实时刷新广播(专用 session:updated 通道)。 + * 消费方:Sidebar(title / messageCount / updatedAt 实时刷新)。 + * 语义清理:标题生成不再伪装成 config:changed(合成 key session.title.)。 + */ +function broadcastSessionUpdated( + sessionId: string, + patch: { title?: string; messageCount?: number; updatedAt?: number }, +): void { + broadcast('session:updated', { sessionId, ...patch }); +} + /** 单会话的 text_delta 节流状态 */ interface ThrottleState { buffer: string; @@ -509,10 +521,46 @@ export function registerAgentHandlers(ctx: IPCContext): void { let engineUserMessage: MetonaMessage; try { + // 提示注入检测(安全模块) + // F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测 + // (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载) + // fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启 + // + // v0.8.2 P1-4 根治: 检测提前到用户消息落库**之前** —— 旧顺序先 saveMessage + // 后 detect,riskScore≥7 阻断时恶意内容已持久化进会话历史(被阻断的消息 + // 仍可在历史中读到、参与后续上下文)。检测只依赖消息内容本身,无需 + // systemPrompt,前置无任何依赖障碍。 + const injectionEnabled = + configService.get('security.promptInjectionDefense') !== false; + if (injectionEnabled) { + const injectionResult = promptInjectionDefender.detect(userMessage.content); + if (injectionResult.riskScore >= 7) { + log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings); + sendErrorEvent( + `Message blocked by prompt injection defense: ${injectionResult.recommendation}`, + sessionId, + ); + await sessionRecorder.stopRecording(sessionId, { + totalIterations: 0, + totalTokens: 0, + durationMs: 0, + terminationReason: 'error', + }); + return { success: false, error: 'Message blocked by prompt injection defense' }; + } + if (injectionResult.riskScore >= 4) { + log.warn( + '[PromptInjectionDefender] Suspicious patterns detected:', + injectionResult.findings, + ); + } + } + // 保存用户消息到数据库 // v0.7.4 回归修复: 透传前端消息 id(ChatMessage.id 由 genMsgId 生成)—— // 否则 DB 用 msg_ 生成不同 id,用户对刚发送消息"仅保存" // (updateMessageContent 按 id 匹配)会 0 行更新失败。 + // v0.8.2 P1-4: 位于注入检测之后 —— 被阻断的内容不进入会话历史 sessionService.saveMessage({ sessionId, role: 'user', @@ -521,6 +569,15 @@ export function registerAgentHandlers(ctx: IPCContext): void { id: (userMessage as MetonaMessage & { id?: string }).id, }); + // v0.8.2 P2-4: 用户消息落库后即时广播(Sidebar 的条数/时间同步刷新) + { + const updatedSession = sessionService.getSession(sessionId); + broadcastSessionUpdated(sessionId, { + messageCount: updatedSession?.messageCount, + updatedAt: updatedSession?.updatedAt, + }); + } + // P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文] history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1); @@ -600,39 +657,9 @@ export function registerAgentHandlers(ctx: IPCContext): void { return { success: false, error: prepErr }; } - // 提示注入检测在 try 内执行(需 systemPrompt 已构建,与引擎运行同域) + // 提示注入检测已前移至数据准备 try 块的开头(v0.8.2 P1-4:先检测后落库) try { - // 提示注入检测(安全模块) - // F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测 - // (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载) - // fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启 - const injectionEnabled = - configService.get('security.promptInjectionDefense') !== false; - if (injectionEnabled) { - const injectionResult = promptInjectionDefender.detect(userMessage.content); - if (injectionResult.riskScore >= 7) { - log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings); - sendErrorEvent( - `Message blocked by prompt injection defense: ${injectionResult.recommendation}`, - sessionId, - ); - await sessionRecorder.stopRecording(sessionId, { - totalIterations: 0, - totalTokens: 0, - durationMs: 0, - terminationReason: 'error', - }); - return { success: false, error: 'Message blocked by prompt injection defense' }; - } - if (injectionResult.riskScore >= 4) { - log.warn( - '[PromptInjectionDefender] Suspicious patterns detected:', - injectionResult.findings, - ); - } - } - // TRACE 层:记录上下文构建 sessionRecorder.recordContextBuilt(sessionId, { tokenCount: estimateMessagesTokens(history), @@ -813,13 +840,15 @@ export function registerAgentHandlers(ctx: IPCContext): void { } // v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默) + // v0.8.2 P2-4: 标题广播改走专用 session:updated 事件 —— 此前伪装成 + // config:changed(合成 key session.title.),语义混用、渲染层需特判。 if (output.terminationReason === 'completed') { titleGenerator .maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer) .then((title) => { if (title) { // 广播重命名结果,前端 Sidebar 实时刷新标题 - broadcast('config:changed', { key: `session.title.${sessionId}`, value: title }); + broadcastSessionUpdated(sessionId, { title }); } }) .catch(() => { @@ -827,6 +856,17 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); } + // v0.8.2 P2-4: 会话元数据实时刷新 —— 此前 Sidebar 仅挂载时 list() 一次, + // 流式过程中 messageCount/updatedAt 停留在旧值直到重启。run 收尾统一 + // 广播 session:updated(含 DB 最新 messageCount/updatedAt)。 + { + const updatedSession = sessionService.getSession(sessionId); + broadcastSessionUpdated(sessionId, { + messageCount: updatedSession?.messageCount, + updatedAt: updatedSession?.updatedAt, + }); + } + // TOOL 层:记录会话结束 / TRACE 层:停止录制 auditService.logSessionEnd({ sessionId, diff --git a/electron/ipc/app.ts b/electron/ipc/app.ts index d2b0f0f..2bdb767 100644 --- a/electron/ipc/app.ts +++ b/electron/ipc/app.ts @@ -10,6 +10,8 @@ import type { IPCContext } from './context'; import type { AuditEventType } from '../services/audit.service'; import { UpdateService } from '../services/update.service'; import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard'; +// v0.8.2 P2-5: 用户可见文案出层(ui.locale 驱动) +import { mt } from '../utils/main-locale'; import log from 'electron-log'; export function registerAppHandlers(ctx: IPCContext): void { @@ -36,17 +38,30 @@ export function registerAppHandlers(ctx: IPCContext): void { }); // ===== v0.8.0 P2-3: 下载并安装更新(electron-updater;未启用时明确失败) ===== + // v0.8.2 P1-3: 语义拆分 —— updateInstall 仅下载(完成后广播 downloaded, + // 不再自动重启退出);重启安装由用户确认后经 app:updateInstallNow 触发 ipcMain.handle('app:updateInstall', async () => { const { getAutoUpdaterHandle } = await import('../services/update.service'); const handle = getAutoUpdaterHandle(); if (!handle) { - return { success: false, error: '自动更新未启用(未配置更新源或开发模式)' }; + return { success: false, error: mt('app.update.disabled') }; } - // fire-and-forget:进度经 update:status 广播,完成后 quitAndInstall + // fire-and-forget:进度经 update:status 广播,完成后等待用户确认安装 void handle.downloadAndInstall(); return { success: true }; }); + // ===== v0.8.2 P1-3: 安装已下载的更新并重启(用户显式确认后调用) ===== + ipcMain.handle('app:updateInstallNow', async () => { + const { getAutoUpdaterHandle } = await import('../services/update.service'); + const handle = getAutoUpdaterHandle(); + if (!handle) { + return { success: false, error: mt('app.update.disabled') }; + } + handle.installNow(); + return { success: true }; + }); + // ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)===== ipcMain.handle('app:healthSnapshot', async () => { try { @@ -116,7 +131,7 @@ export function registerAppHandlers(ctx: IPCContext): void { const result = await dialog.showOpenDialog(callerWin, { properties: ['openDirectory', 'createDirectory'], defaultPath: defaultPath ?? app.getPath('home'), - title: '选择工作空间目录', + title: mt('app.dialog.selectWorkspace.title'), }); if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' }; return { canceled: false, path: result.filePaths[0] }; diff --git a/electron/ipc/mcp.ts b/electron/ipc/mcp.ts index 8445004..232f1b2 100644 --- a/electron/ipc/mcp.ts +++ b/electron/ipc/mcp.ts @@ -53,6 +53,20 @@ export function registerMCPHandlers(ctx: IPCContext): void { ) { return { success: false, error: 'command is required for stdio transport' }; } + // v0.8.2 P2-7: args 类型校验 —— 此前原样透传(非数组也能写入 DB,靠读取侧 + // safeParseArgs 兜底为空数组),配置期静默丢参。现显式校验:可选、必须是 + // 字符串数组、单项 ≤512 字符、总数 ≤64(防把 args 当数据通道滥用)。 + if (config.args !== undefined) { + if (!Array.isArray(config.args) || config.args.some((a) => typeof a !== 'string')) { + return { success: false, error: 'args must be an array of strings' }; + } + if (config.args.length > 64) { + return { success: false, error: 'args supports at most 64 entries' }; + } + if (config.args.some((a) => (a as string).length > 512)) { + return { success: false, error: 'each arg must be at most 512 characters' }; + } + } // sse / streamable-http 类型必须有合法 url if (config.transport === 'sse' || config.transport === 'streamable-http') { if (typeof config.url !== 'string' || !config.url.trim()) { diff --git a/electron/ipc/shared.ts b/electron/ipc/shared.ts index 3cad1f4..ee68134 100644 --- a/electron/ipc/shared.ts +++ b/electron/ipc/shared.ts @@ -10,6 +10,8 @@ import log from 'electron-log'; import type { IPCContext } from './context'; import { broadcast } from './context'; import { isSensitiveConfigKey } from '../utils/secure-config'; +// v0.8.2 P1-5: 掩码实现单源 +import { maskSensitiveValue } from '../utils/mask'; // v0.8.1 P2-1: 工具自定义策略解析 import { parseToolPolicy } from '../harness/sandbox/permissions'; // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过) @@ -77,10 +79,14 @@ export const LLM_CONFIG_KEYS = [ 'llm.fallbackBaseURL', ]; -/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */ +/** + * 敏感配置值脱敏(长值保留后 4 位,短值完全掩码) + * v0.8.2 P1-5: 掩码实现单源到 utils/mask.maskSensitiveValue + * (原就地实现与审计脱敏存在漂移风险) + */ export function maskSensitive(key: string, value: unknown): unknown { if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) { - return value.length > 4 ? '***' + value.slice(-4) : '***'; + return maskSensitiveValue(value); } return value; } diff --git a/electron/ipc/workspace.ts b/electron/ipc/workspace.ts index 38ffad3..f1f2810 100644 --- a/electron/ipc/workspace.ts +++ b/electron/ipc/workspace.ts @@ -8,6 +8,8 @@ import { ipcMain } from 'electron'; import { join } from 'path'; import type { IPCContext } from './context'; import log from 'electron-log'; +// v0.8.2 P2-5: 工作空间校验文案出层(ui.locale 驱动) +import { mt } from '../utils/main-locale'; export function registerWorkspaceHandlers(ctx: IPCContext): void { const { workspaceService } = ctx; @@ -43,7 +45,7 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void { // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在 ipcMain.handle('workspace:check', async (_event, targetPath: string) => { if (!targetPath || typeof targetPath !== 'string') { - return { valid: false, reason: '路径不能为空' }; + return { valid: false, reason: mt('workspace.reason.empty') }; } const { existsSync, statSync } = await import('fs'); @@ -60,7 +62,7 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void { missingFiles: ['SOUL.md', 'MEMORY.md'], isNewWorkspace: true, dbExists: false, - reason: '目录不存在,将在切换后自动创建', + reason: mt('workspace.reason.notExists'), }; } @@ -68,10 +70,10 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void { try { const stat = statSync(resolvedPath); if (!stat.isDirectory()) { - return { valid: false, reason: '路径不是目录' }; + return { valid: false, reason: mt('workspace.reason.notDirectory') }; } } catch { - return { valid: false, reason: '无法访问路径' }; + return { valid: false, reason: mt('workspace.reason.inaccessible') }; } // 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md) diff --git a/electron/main.ts b/electron/main.ts index 4875824..0bc7c7f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -546,7 +546,10 @@ async function initialize(): Promise { // 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回 // null,MemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。 // adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。 + // v0.8.2 P3-1: 注入 modelName 指纹 —— 用户更换 embedding 模型后旧向量按 + // 模型不匹配处理(检索时惰性重算自愈),不再产生跨模型噪声分数。 memoryManager.setEmbedder({ + modelName: (configService.get('memory.embeddingModel') ?? '').trim() || undefined, embed: async (text) => { const adapter = agentEngineManager.getAdapter(); if (!(adapter instanceof OllamaAdapter)) return null; @@ -1052,10 +1055,11 @@ if (!gotSingleInstanceLock) { log.error('[Startup] Initialization failed:', err); try { dialog.showErrorBox( - 'MetonaAI Desktop 启动失败', - `初始化过程中发生错误,应用即将退出。\n\n${(err as Error)?.message ?? String(err)}\n\n` + - '可能原因:工作空间目录不可写、数据库文件损坏。\n' + - '可尝试在设置中切换工作空间路径后重新启动。', + // v0.8.2 P2-5: 启动失败对话框文案出层(ui.locale 已随配置初始化) + mt('app.dialog.startupFailed.title'), + mt('app.dialog.startupFailed.body', { + message: (err as Error)?.message ?? String(err), + }), ); } catch { // showErrorBox 失败(极端环境)时仅保留日志 diff --git a/electron/preload.ts b/electron/preload.ts index 16ed5fd..fc31bd3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -100,6 +100,13 @@ const metonaAPI = { saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data), getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId), + // v0.8.2 P2-4: 会话元数据实时刷新(title/messageCount/updatedAt;替代 + // 标题伪装成 config:changed 的合成 key 语义) + onSessionUpdated: (callback: (data: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); + ipcRenderer.on('session:updated', listener); + return () => ipcRenderer.removeListener('session:updated', listener); + }, }, // ===== MCP 管理 ===== @@ -278,9 +285,12 @@ const metonaAPI = { | { status: 'up-to-date'; latestVersion: string } | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } >, - // v0.8.0 P2-3: 下载并安装更新(electron-updater;仅生产环境可用) + // v0.8.0 P2-3: 下载更新(v0.8.2 P1-3 起仅下载不重启;electron-updater 仅生产环境可用) updateInstall: () => ipcRenderer.invoke('app:updateInstall') as Promise<{ success: boolean; error?: string }>, + // v0.8.2 P1-3: 安装已下载的更新并重启(用户确认后调用) + updateInstallNow: () => + ipcRenderer.invoke('app:updateInstallNow') as Promise<{ success: boolean; error?: string }>, // v0.8.0 P2-3: 订阅更新状态事件(checking/available/downloading/downloaded/error) onUpdateStatus: (callback: (event: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); diff --git a/electron/services/audit.service.ts b/electron/services/audit.service.ts index 2e0d757..d1adba2 100644 --- a/electron/services/audit.service.ts +++ b/electron/services/audit.service.ts @@ -15,6 +15,8 @@ import type Database from 'better-sqlite3'; import { createHash } from 'crypto'; import log from 'electron-log'; +// v0.8.2 P1-5: 审计 args 深度脱敏(与配置层脱敏同源) +import { deepMaskSensitive } from '../utils/mask'; /** * #36 修复: 稳定序列化,递归按 key 字典序排序后序列化 @@ -194,6 +196,10 @@ export class AuditService { /** * 记录工具调用 + * + * v0.8.2 P1-5: args 深度脱敏后落库 —— 工具参数中的密钥/鉴权头/token 此前 + * 以明文进入 audit_logs(safeStorage 只保护配置层),构成敏感信息二次扩散面。 + * 键名匹配与配置层单源(utils/mask → secure-config 归一化匹配)。 */ logToolCall(params: { sessionId: string; @@ -212,7 +218,7 @@ export class AuditService { actor: 'agent', target: params.toolName, details: { - args: params.args, + args: deepMaskSensitive(params.args), result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result, error: params.error, }, diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index 9c68687..2925332 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -132,8 +132,9 @@ export class DatabaseService { * 迁移 12(清理已废除的 llm.contextWindow 分 Provider 键与 ollama.numCtx)。 * v0.8.1 review: 4 → 5 —— 迁移 13(working_memories 补 sessions 外键 CASCADE, * 孤儿行清理;根治历史 schema 缺失级联导致的孤儿数据)。 + * v0.8.2: 5 → 6 —— 迁移 14(记忆表 embedding_model 列,嵌入模型指纹)。 */ - static readonly SCHEMA_VERSION = 5; + static readonly SCHEMA_VERSION = 6; constructor(workspacePath?: string) { const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); @@ -751,6 +752,13 @@ export class DatabaseService { } } + // v0.8.2 P3-1 迁移 14: 记忆表 embedding_model 列(嵌入模型指纹)。 + // 用户更换 memory.embeddingModel 后,旧 BLOB 以新查询向量算余弦 —— 维度 + // 不同静默余弦为 0(降级 TF-IDF),同维不同模型产生噪声分数。现记录每条 + // 向量的来源模型,检索时模型不匹配的向量视为缺失(惰性重算自愈)。 + tryAddColumn('episodic_memories', 'embedding_model', 'TEXT'); + tryAddColumn('semantic_memories', 'embedding_model', 'TEXT'); + // v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer // 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild, // 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的 diff --git a/electron/services/global-config.service.ts b/electron/services/global-config.service.ts index 9ec6f49..94c7d56 100644 --- a/electron/services/global-config.service.ts +++ b/electron/services/global-config.service.ts @@ -23,7 +23,15 @@ import { app } from 'electron'; import { join } from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'fs'; import log from 'electron-log'; import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service'; import { @@ -34,6 +42,8 @@ import { /** 全局配置文件路径(userData 下,与工作空间无关) */ const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json'); +/** 最近一次成功落盘的备份(主文件损坏时的恢复源) */ +const GLOBAL_CONFIG_BACKUP = join(app.getPath('userData'), 'global-config.backup.json'); /** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */ const GLOBAL_KEY_PREFIXES = [ @@ -105,7 +115,28 @@ export class GlobalConfigService { try { if (existsSync(GLOBAL_CONFIG_FILE)) { const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8'); - this.data = JSON.parse(raw) as GlobalConfigData; + try { + this.data = JSON.parse(raw) as GlobalConfigData; + } catch (parseErr) { + // v0.8.2 P0-3: 主文件损坏自愈 —— 依次尝试:① 最近一次成功落盘的备份 + // 恢复(恢复成功即回写主文件);② 归档损坏文件后以空配置启动。 + // 此前 parse 失败会静默以空配置运行,用户全部全局配置(含 LLM 凭据) + // 在下一次 set() 落盘时被覆盖丢失。 + log.error('[GlobalConfig] Main config file corrupted:', parseErr); + this.data = this.recoverFromBackup(); + if (Object.keys(this.data).length > 0) { + this.flush(); + log.warn('[GlobalConfig] Restored global config from backup file'); + } else { + const archived = `${GLOBAL_CONFIG_FILE}.corrupt-${Date.now()}`; + try { + renameSync(GLOBAL_CONFIG_FILE, archived); + log.warn(`[GlobalConfig] Corrupted config archived to ${archived}`); + } catch { + /* 归档失败不阻断启动 */ + } + } + } // v0.8.1 review: 清除已废除的配置键(分 Provider contextWindow / ollama.numCtx), // 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活 let purged = 0; @@ -140,6 +171,23 @@ export class GlobalConfigService { this.initialized = true; } + /** + * v0.8.2 P0-3: 从备份恢复(仅 initialize 的损坏自愈路径调用)。 + * 备份不存在或损坏时返回空对象。 + */ + private recoverFromBackup(): GlobalConfigData { + try { + if (!existsSync(GLOBAL_CONFIG_BACKUP)) return {}; + const raw = readFileSync(GLOBAL_CONFIG_BACKUP, 'utf-8'); + const parsed = JSON.parse(raw) as GlobalConfigData; + if (parsed && typeof parsed === 'object') return parsed; + return {}; + } catch (err) { + log.error('[GlobalConfig] Backup recovery failed:', err); + return {}; + } + } + /** * 读取全局配置(P0-1: 敏感 key 自动解密) */ @@ -236,12 +284,40 @@ export class GlobalConfigService { /** * 落盘到 JSON 文件 + * + * v0.8.2 P0-3 根治:此前直接 writeFileSync 覆写主文件 —— 写盘中途崩溃 + * (断电/强杀)会产生半截 JSON,下次启动 parse 失败后以空配置启动, + * **全部全局配置(含 LLM 凭据)随之丢失**。现改为: + * 1. 原子替换:先写同目录 tmp,再 renameSync 原子改名(与 write_file 工具 / + * workspace.rewriteMemory 同口径,Windows 下 rename 覆盖已存在目标); + * 2. 写后备份:成功落盘后同步维护 global-config.backup.json(尽力而为, + * 失败仅告警)—— 主文件因外部因素损坏时的恢复源。 */ private flush(): void { + const tmpPath = `${GLOBAL_CONFIG_FILE}.tmp_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; try { - writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8'); + writeFileSync(tmpPath, JSON.stringify(this.data, null, 2), 'utf-8'); + try { + renameSync(tmpPath, GLOBAL_CONFIG_FILE); + } catch (err) { + try { + unlinkSync(tmpPath); + } catch { + /* 忽略清理失败 */ + } + throw err; + } } catch (err) { log.error('[GlobalConfig] Failed to write config file:', err); + return; + } + // 备份为尽力而为:失败不影响主流程(备份缺失仅降低自愈成功率) + try { + copyFileSync(GLOBAL_CONFIG_FILE, GLOBAL_CONFIG_BACKUP); + } catch (err) { + log.warn(`[GlobalConfig] Backup write failed: ${(err as Error).message}`); } } } diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index 63941b5..16e2a72 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -23,7 +23,7 @@ import type Database from 'better-sqlite3'; import log from 'electron-log'; import type { ToolRegistry } from '../harness/tools/registry'; import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool'; -import type { MetonaToolDef } from '../harness/types'; +import type { MetonaToolDef, MetonaParamField } from '../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types'; // v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用) import { buildSafeChildEnv } from '../utils/safe-env'; @@ -289,24 +289,53 @@ class MCPToolAdapter implements IMetonaTool { name: this.mcpTool.name, arguments: args, }); - return result.content; + const content = result.content as Array> | undefined; + + // v0.8.2 P2-7 根治: MCP 返回的 image block 此前被直接透传 —— registry 的 + // 内联图片白名单只识别顶层 dataUrl/image 字段,嵌套在 content 数组中的图片 + // 块不匹配白名单,被 50KB 截断为破损 base64。现将 text/image block 归并: + // text 拼接为顶层文本,首个 image block 提升为顶层 `image` 字段(data URI, + // 命中 registry 白名单整段放行 → 渲染端可内联预览)。 + if (Array.isArray(content)) { + const texts: string[] = []; + let imageDataUri: string | null = null; + for (const item of content) { + const type = item?.type; + if (type === 'text' && typeof item.text === 'string') { + texts.push(item.text); + } else if (type === 'image' && !imageDataUri) { + const data = typeof item.data === 'string' ? item.data : ''; + const mimeType = + typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'image/png'; + if (data) { + imageDataUri = `data:${mimeType};base64,${data}`; + } + } + } + if (imageDataUri || texts.length > 0) { + return { + ...(texts.length > 0 ? { text: texts.join('\n\n') } : {}), + ...(imageDataUri ? { image: imageDataUri } : {}), + }; + } + } + return content; } /** * 将 MCP JSON Schema 转换为 MetonaToolParams + * + * v0.8.2 P2-7 根治: 旧实现只保留顶层 properties 的 type/description —— + * 丢弃 enum/anyOf/oneOf/嵌套对象/items/default,复杂 MCP 工具的参数约束 + * 对 LLM 不可见,易产生非法参数。现递归保留 IR 支持的全部结构(见 + * MetonaParamField 的 P2-7 扩展字段)。 */ private convertSchema(schema: Record): MetonaToolDef['parameters'] { - const properties: Record< - string, - { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string } - > = {}; + const properties: Record = {}; const schemaProps = (schema.properties ?? {}) as Record>; for (const [key, prop] of Object.entries(schemaProps)) { - properties[key] = { - type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string', - description: (prop.description as string) ?? '', - }; + properties[key] = this.convertSchemaField(prop); } return { @@ -315,6 +344,43 @@ class MCPToolAdapter implements IMetonaTool { required: schema.required as string[] | undefined, }; } + + /** 单个参数字段递归转换(P2-7 schema 保真) */ + private convertSchemaField(prop: Record): MetonaParamField { + const field: MetonaParamField = { + type: (prop.type as MetonaParamField['type']) ?? 'string', + description: (prop.description as string) ?? '', + }; + if (Array.isArray(prop.enum)) { + field.enum = prop.enum.map((v) => String(v)); + } + if (prop.items && typeof prop.items === 'object') { + field.items = this.convertSchemaField(prop.items as Record); + } + if (prop.properties && typeof prop.properties === 'object') { + const nested: Record = {}; + for (const [k, v] of Object.entries( + prop.properties as Record>, + )) { + nested[k] = this.convertSchemaField(v); + } + field.properties = nested; + } + if (Array.isArray(prop.required)) { + field.required = prop.required.map((v) => String(v)); + } + for (const combinator of ['anyOf', 'oneOf'] as const) { + if (Array.isArray(prop[combinator])) { + field[combinator] = (prop[combinator] as Array>).map((v) => + this.convertSchemaField(v), + ); + } + } + if (prop.default !== undefined) { + field.default = prop.default; + } + return field; + } } // ===== MCP Manager ===== @@ -819,6 +885,11 @@ export class MCPManager { /** * 获取所有 Server 状态 + * + * v0.8.2 P2-7 根治: 已配置但**禁用**的 server 此前不出现在列表中 —— + * initialize() 只连 enabled=1,getServerStates 只映射 servers Map,设置面板 + * 无法展示"已配置但禁用"的完整清单。现从 DB 补齐缺失条目(status=disconnected、 + * enabled=false),并为每个条目标注 enabled。 */ getServerStates(): Array<{ name: string; @@ -827,15 +898,45 @@ export class MCPManager { error?: string; /** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */ reconnectAttempt?: number; + /** v0.8.2 P2-7: 是否为启用状态(DB enabled=1)—— 禁用 server 以 disconnected 呈现 */ + enabled: boolean; }> { - return Array.from(this.servers.values()).map((s) => ({ + // DB 全量配置(enabled 标注 + 补齐禁用条目);DB 不可用时退回仅已连接集合 + let enabledMap = new Map(); + try { + const db = this.getDB(); + const rows = db.prepare('SELECT name, enabled FROM mcp_servers').all() as Array<{ + name: string; + enabled: number; + }>; + enabledMap = new Map(rows.map((r) => [r.name, r.enabled === 1])); + } catch (err) { + log.warn(`[MCPManager] getServerStates DB lookup failed: ${(err as Error).message}`); + } + + const states = Array.from(this.servers.values()).map((s) => ({ name: s.config.name, status: s.status, toolCount: s.tools.length, error: s.error, reconnectAttempt: s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined, + enabled: enabledMap.get(s.config.name) ?? true, })); + + for (const [name, enabled] of enabledMap) { + if (!enabled && !states.some((s) => s.name === name)) { + states.push({ + name, + status: 'disconnected' as MCPServerStatus, + toolCount: 0, + error: undefined, + reconnectAttempt: undefined, + enabled: false, + }); + } + } + return states; } /** diff --git a/electron/services/tray-manager.service.ts b/electron/services/tray-manager.service.ts index f6caf69..78331d8 100644 --- a/electron/services/tray-manager.service.ts +++ b/electron/services/tray-manager.service.ts @@ -14,6 +14,8 @@ import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'elect import { join } from 'path'; import { existsSync } from 'fs'; import log from 'electron-log'; +// v0.8.2 P2-5: 托盘菜单文案双语(ui.locale 驱动) +import { mt } from '../utils/main-locale'; export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error'; @@ -202,12 +204,15 @@ export class TrayManager { enabled: false, }, { - label: `状态: ${statusIcons[this.currentStatus]} ${this.currentStatus}`, + // v0.8.2 P2-5: 托盘菜单出层(ui.locale 驱动 mt(),语言切换即热生效) + label: mt('tray.menu.status', { + status: `${statusIcons[this.currentStatus]} ${mt(`tray.status.${this.currentStatus}`)}`, + }), enabled: false, }, { type: 'separator' }, { - label: '显示窗口', + label: mt('tray.menu.showWindow'), click: () => { if (this.mainWindow) { this.mainWindow.show(); @@ -216,7 +221,7 @@ export class TrayManager { }, }, { - label: '新建会话', + label: mt('tray.menu.newSession'), click: () => { if (this.mainWindow) { this.mainWindow.show(); @@ -227,7 +232,7 @@ export class TrayManager { }, { type: 'separator' }, { - label: '退出', + label: mt('tray.menu.quit'), click: () => { TrayManager.isQuitting = true; app.quit(); diff --git a/electron/services/update.service.ts b/electron/services/update.service.ts index e4cd0b1..fb48ca1 100644 --- a/electron/services/update.service.ts +++ b/electron/services/update.service.ts @@ -108,7 +108,13 @@ export class UpdateService { /** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed) */ export interface AutoUpdaterHandle { checkForUpdates: () => Promise; + /** 仅下载更新包(下载完成后广播 downloaded,不自动重启) */ downloadAndInstall: () => Promise; + /** + * v0.8.2 P1-3: 安装已下载的更新并重启应用。 + * 仅在收到 downloaded 状态后由用户显式确认调用。 + */ + installNow: () => void; } let activeHandle: AutoUpdaterHandle | null = null; @@ -173,6 +179,16 @@ export async function startAutoUpdater( try { await autoUpdater.downloadUpdate(); onEvent({ status: 'downloaded' }); + // v0.8.2 P1-3 根治: 不再下载完成立即 quitAndInstall —— 渲染层刚收到 + // downloaded 事件应用就退出,用户可能丢失未保存内容(写了一半的输入、 + // 进行中的会话操作)。安装动作拆分为独立的 installNow,由用户在收到 + // "更新已下载"提示后显式确认触发。 + } catch (err) { + onEvent({ status: 'error', message: (err as Error).message }); + } + }, + installNow: () => { + try { autoUpdater.quitAndInstall(); } catch (err) { onEvent({ status: 'error', message: (err as Error).message }); diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index 85532b8..2bd03fb 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -10,9 +10,9 @@ * @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理 */ -import { BrowserWindow, globalShortcut, shell } from 'electron'; +import { BrowserWindow, globalShortcut, shell, app, screen } from 'electron'; import { join } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs'; import { is } from '@electron-toolkit/utils'; import log from 'electron-log'; @@ -24,6 +24,9 @@ export interface WindowState { isMaximized?: boolean; } +/** v0.8.2 P3-5: 窗口状态持久化文件(userData 下,机器级) */ +const WINDOW_STATE_FILE = join(app.getPath('userData'), 'window-state.json'); + export class WindowManager { private windows = new Map(); private activeWindowId: string | null = null; @@ -51,7 +54,8 @@ export class WindowManager { beforeLoad?: (win: BrowserWindow) => void; }): BrowserWindow { const id = options.id ?? `window_${Date.now()}`; - const state = options.state ?? { width: 1440, height: 900 }; + // v0.8.2 P3-5: 未显式传 state 时自动读取持久化状态(恢复上次位置/尺寸/最大化) + const state = options.state ?? WindowManager.loadWindowState(); const win = new BrowserWindow({ width: state.width, @@ -94,8 +98,42 @@ export class WindowManager { win.show(); }); + // v0.8.2 P3-5: 窗口状态持久化 —— move/resize 防抖 800ms 落盘 + close 时兜底 + // 捕获一次(maximized 还原依赖 close 时的 isMaximized 标志)。此前状态通道 + // (state?: WindowState)存在但 main.ts 从未读写,每次启动固定 1440×900 居中。 + let saveStateTimer: NodeJS.Timeout | null = null; + const captureState = (): WindowState => { + const bounds = win.getBounds(); + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized: win.isMaximized(), + }; + }; + const scheduleSaveState = (): void => { + if (saveStateTimer) clearTimeout(saveStateTimer); + saveStateTimer = setTimeout(() => { + saveStateTimer = null; + WindowManager.saveWindowState(captureState()); + }, 800); + saveStateTimer.unref?.(); + }; + win.on('resize', scheduleSaveState); + win.on('move', scheduleSaveState); + win.on('close', () => { + if (saveStateTimer) { + clearTimeout(saveStateTimer); + saveStateTimer = null; + } + WindowManager.saveWindowState(captureState()); + }); + win.on('closed', () => { this.windows.delete(id); + // v0.8.2 P3-5: 崩溃自愈退避记录同步清理(窗口销毁后 Map 条目残留属泄漏) + this.crashReloadAttempts.delete(win.id); if (this.activeWindowId === id) { this.activeWindowId = this.windows.size > 0 ? (this.windows.keys().next().value ?? null) : null; @@ -209,6 +247,59 @@ export class WindowManager { return win; } + /** + * v0.8.2 P3-5: 读取持久化的窗口状态。 + * 文件缺失/损坏返回默认尺寸;恢复的坐标不在任何显示器可见范围时丢弃坐标 + * (防止显示器拔除后窗口"消失")。 + */ + static loadWindowState(): WindowState { + const fallback: WindowState = { width: 1440, height: 900 }; + try { + if (!existsSync(WINDOW_STATE_FILE)) return fallback; + const raw = JSON.parse(readFileSync(WINDOW_STATE_FILE, 'utf-8')) as WindowState; + if (typeof raw?.width !== 'number' || typeof raw?.height !== 'number') return fallback; + if (raw.width < 400 || raw.height < 300) return fallback; + // 坐标可见性校验:x/y 必须落在某个显示器的可见区域内 + if (typeof raw.x === 'number' && typeof raw.y === 'number') { + const visible = screen.getAllDisplays().some((d) => { + const { x, y, width, height } = d.bounds; + return ( + raw.x! >= x - 100 && raw.x! < x + width && raw.y! >= y - 100 && raw.y! < y + height + ); + }); + if (!visible) { + return { width: raw.width, height: raw.height, isMaximized: raw.isMaximized }; + } + } else { + delete raw.x; + delete raw.y; + } + return raw; + } catch { + return fallback; + } + } + + /** v0.8.2 P3-5: 原子落盘窗口状态(tmp + rename;尽力而为,失败仅告警) */ + static saveWindowState(state: WindowState): void { + try { + const tmp = `${WINDOW_STATE_FILE}.tmp`; + writeFileSync(tmp, JSON.stringify(state), 'utf-8'); + try { + renameSync(tmp, WINDOW_STATE_FILE); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + throw err; + } + } catch (err) { + log.warn(`[WindowManager] Failed to persist window state: ${(err as Error).message}`); + } + } + /** * 获取窗口 */ diff --git a/electron/utils/__tests__/mask.test.ts b/electron/utils/__tests__/mask.test.ts new file mode 100644 index 0000000..bd3df22 --- /dev/null +++ b/electron/utils/__tests__/mask.test.ts @@ -0,0 +1,75 @@ +/** + * v0.8.2 P1-5: 敏感值脱敏单源工具测试 + * + * 锁定契约: + * - 键名归一化匹配(api_key / authKey / Authorization 等形态均命中) + * - 长值保留后 4 位、短值完全掩码 + * - 嵌套对象/数组递归;非敏感字段原样保留 + * - 循环引用与深度上限防护(防御恶意构造的工具参数) + * - 原对象不被修改 + */ + +import { describe, it, expect } from 'vitest'; +import { deepMaskSensitive, maskSensitiveValue } from '../mask'; + +describe('maskSensitiveValue', () => { + it('长值保留后 4 位', () => { + expect(maskSensitiveValue('sk-abcdefgh12345678')).toBe('***5678'); + }); + it('短值(≤4)完全掩码', () => { + expect(maskSensitiveValue('abc')).toBe('***'); + expect(maskSensitiveValue('abcd')).toBe('***'); + }); +}); + +describe('deepMaskSensitive', () => { + it('顶层与嵌套的敏感键均被掩码', () => { + const input = { + url: 'https://api.example.com', + headers: { + Authorization: 'Bearer sk-abcdefgh12345678', + 'content-type': 'application/json', + }, + api_key: 'sk-abcdefgh12345678', + nested: { authToken: 'token-1234567890' }, + }; + const out = deepMaskSensitive(input) as typeof input; + expect(out.url).toBe('https://api.example.com'); + expect(out['api_key']).toBe('***5678'); + expect((out.headers as Record).Authorization).toBe('***5678'); + expect((out.headers as Record)['content-type']).toBe('application/json'); + expect((out.nested as { authToken: string }).authToken).toBe('***7890'); + }); + + it('数组内对象同样脱敏', () => { + const out = deepMaskSensitive([{ secret: 'supersecret-value-42' }, { ok: 1 }]) as Array< + Record + >; + expect(out[0].secret).toBe('***e-42'); + expect(out[1].ok).toBe(1); + }); + + it('原对象不被修改', () => { + const input = { api_key: 'sk-abcdefgh12345678' }; + deepMaskSensitive(input); + expect(input.api_key).toBe('sk-abcdefgh12345678'); + }); + + it('循环引用与深度上限不抛错', () => { + const a: Record = { name: 'a' }; + a.self = a; + const out = deepMaskSensitive(a) as Record; + expect(out.name).toBe('a'); + expect(out.self).toBe('[circular]'); + + const deep: Record = { v: 0 }; + let cur = deep; + for (let i = 0; i < 20; i++) { + cur.next = { v: i + 1 }; + cur = cur.next as Record; + } + const deepOut = deepMaskSensitive(deep) as Record; + expect(deepOut.v).toBe(0); + expect(JSON.stringify(deepOut)).toContain('depth-limit'); + }); +}); diff --git a/electron/utils/main-locale.ts b/electron/utils/main-locale.ts index 76db721..f118c69 100644 --- a/electron/utils/main-locale.ts +++ b/electron/utils/main-locale.ts @@ -71,7 +71,26 @@ const zh: Record = { 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写', 'config.error.workspaceSaveFailed': '工作空间路径保存失败:{{message}}', // ===== 记忆维护(v0.8.1 P1-2) ===== - 'memory.maintain.auditTitle': 'MEMORY.md 记忆整理', + // ===== v0.8.2 P2-5 i18n 收口第三期:托盘 / 系统对话框 / 更新 / 工作空间校验 ===== + 'tray.menu.showWindow': '显示窗口', + 'tray.menu.newSession': '新建会话', + 'tray.menu.quit': '退出', + 'tray.menu.status': '状态: {{status}}', + 'tray.status.idle': '空闲', + 'tray.status.thinking': '思考中', + 'tray.status.executing': '执行中', + 'tray.status.error': '异常', + 'app.update.disabled': '自动更新未启用(未配置更新源或开发模式)', + 'app.dialog.selectWorkspace.title': '选择工作空间目录', + 'app.dialog.startupFailed.title': 'MetonaAI Desktop 启动失败', + 'app.dialog.startupFailed.body': + '初始化过程中发生错误,应用即将退出。\n\n{{message}}\n\n可能原因:工作空间目录不可写、数据库文件损坏。\n可尝试在设置中切换工作空间路径后重新启动。', + 'workspace.reason.empty': '路径不能为空', + 'workspace.reason.notExists': '目录不存在,将在切换后自动创建', + 'workspace.reason.notDirectory': '路径不是目录', + 'workspace.reason.inaccessible': '无法访问路径', + 'workspace.reason.inheritFailed': '继承文件失败:{{message}}', + 'workspace.reason.restartRequired': '工作空间已切换,重启应用后生效', }; const en: Record = { @@ -112,7 +131,26 @@ const en: Record = { 'config.error.configIncomplete': 'LLM config incomplete. Check that Provider, API Key, Base URL and Model are all filled in.', 'config.error.workspaceSaveFailed': 'Failed to save workspace path: {{message}}', - 'memory.maintain.auditTitle': 'MEMORY.md maintenance', + // ===== v0.8.2 P2-5: tray / system dialogs / update / workspace validation ===== + 'tray.menu.showWindow': 'Show Window', + 'tray.menu.newSession': 'New Session', + 'tray.menu.quit': 'Quit', + 'tray.menu.status': 'Status: {{status}}', + 'tray.status.idle': 'Idle', + 'tray.status.thinking': 'Thinking', + 'tray.status.executing': 'Executing', + 'tray.status.error': 'Error', + 'app.update.disabled': 'Auto-update is not enabled (no feed URL configured, or dev mode)', + 'app.dialog.selectWorkspace.title': 'Select workspace directory', + 'app.dialog.startupFailed.title': 'MetonaAI Desktop failed to start', + 'app.dialog.startupFailed.body': + 'An error occurred during initialization and the app will exit.\n\n{{message}}\n\nPossible causes: the workspace directory is not writable, or the database file is corrupted.\nTry switching the workspace path in Settings and restart.', + 'workspace.reason.empty': 'Path must not be empty', + 'workspace.reason.notExists': 'Directory does not exist — it will be created after switching', + 'workspace.reason.notDirectory': 'Path is not a directory', + 'workspace.reason.inaccessible': 'Path is not accessible', + 'workspace.reason.inheritFailed': 'Failed to inherit files: {{message}}', + 'workspace.reason.restartRequired': 'Workspace switched — restart the app to apply', }; const DICTS: Record> = { 'zh-CN': zh, 'en-US': en }; diff --git a/electron/utils/mask.ts b/electron/utils/mask.ts new file mode 100644 index 0000000..3718799 --- /dev/null +++ b/electron/utils/mask.ts @@ -0,0 +1,52 @@ +/** + * v0.8.2 P1-5: 敏感值脱敏单源工具 + * + * 背景:审计日志 logToolCall 原样落库完整工具 args —— 工具参数中携带的 + * API Key / 鉴权头 / token(如 http_request 的 headers.authorization、 + * MCP 工具的鉴权参数)以明文进入 audit_logs 表,密钥链加密(safeStorage) + * 只保护配置层,不保护审计层,构成敏感信息二次扩散面。 + * + * 本模块提供与配置层同源(isSensitiveConfigKey 的归一化键名匹配)的脱敏: + * - maskSensitiveValue:字符串值掩码(长值保留后 4 位,短值完全掩码) + * - deepMaskSensitive:递归遍历对象/数组,按键名匹配掩码字符串值 + * (循环引用防护 + 深度上限,防御恶意构造的参数结构) + */ + +import { isSensitiveConfigKey } from './secure-config'; + +/** 长值保留后 4 位,短值(≤4 字符)完全掩码 —— 与 ipc/shared.maskSensitive 同口径 */ +export function maskSensitiveValue(value: string): string { + return value.length > 4 ? '***' + value.slice(-4) : '***'; +} + +const MAX_MASK_DEPTH = 6; + +/** + * 深度脱敏:返回脱敏后的副本(原对象不修改)。 + * 对象/数组递归;键名命中敏感模式(归一化匹配,api_key/authKey/token/secret/ + * password/credential 等)时对字符串值掩码;其余值原样保留。 + */ +export function deepMaskSensitive(input: T, depth = 0, seen?: Set): T { + if (input === null || typeof input !== 'object') return input; + if (depth >= MAX_MASK_DEPTH) return '[depth-limit]' as unknown as T; + const seenSet = seen ?? new Set(); + if (seenSet.has(input as object)) return '[circular]' as unknown as T; + seenSet.add(input as object); + + try { + if (Array.isArray(input)) { + return input.map((item) => deepMaskSensitive(item, depth + 1, seenSet)) as unknown as T; + } + const out: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) { + out[key] = maskSensitiveValue(value); + } else { + out[key] = deepMaskSensitive(value, depth + 1, seenSet); + } + } + return out as unknown as T; + } finally { + seenSet.delete(input as object); + } +} diff --git a/electron/utils/secure-config.ts b/electron/utils/secure-config.ts index 350b090..05dc2e8 100644 --- a/electron/utils/secure-config.ts +++ b/electron/utils/secure-config.ts @@ -60,7 +60,9 @@ export function resetEncryptionUsableForTests(): void { encryptionUsable = null; } -/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) */ +/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) + * v0.8.2 P1-5: 补 authorization / authkey / credential —— 审计 args 深度脱敏 + * 复用本表,HTTP 标准鉴权头(Authorization)此前不命中导致明文入库 */ const SENSITIVE_KEY_PATTERNS = [ 'apikey', 'api_key', @@ -69,6 +71,9 @@ const SENSITIVE_KEY_PATTERNS = [ 'secret', 'password', 'auth_key', + 'authkey', + 'authorization', + 'credential', ]; /** diff --git a/package-lock.json b/package-lock.json index da634ec..3f9d2f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.8.0", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.8.0", + "version": "0.8.2", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -6140,9 +6140,9 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, "license": "BSD-2-Clause", "engines": { diff --git a/package.json b/package.json index a72b457..bd653c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.8.1", + "version": "0.8.2", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/App.tsx b/src/App.tsx index 6f1ad63..b78e585 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,6 +83,11 @@ export default function App(): React.JSX.Element { }) .catch((err) => { console.error('[App]', err); + }) + .finally(() => { + // v0.8.2 P3-4: 无论读取成功与否都解除向导门禁 —— 此前异步读取期间 + // onboardingCompleted 初始 false,老用户每次启动闪现向导首帧 + useUIStore.getState().setOnboardingGateReady(true); }); // v0.7.2 P4-15: 恢复界面语言偏好(ui.locale;非法值回退默认 zh-CN) window.metona.config @@ -110,6 +115,8 @@ export default function App(): React.JSX.Element { } else { // 无 IPC 可用时也标记为已加载(避免永久禁用) useAgentStore.getState().setConfigLoaded(true); + // v0.8.2 P3-4: 无 IPC 时同样解除向导门禁(避免永久隐藏) + useUIStore.getState().setOnboardingGateReady(true); } // v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) @@ -117,7 +124,7 @@ export default function App(): React.JSX.Element { // v0.3.18 修复: 注册监听器后立即查询一次,解决 tools:ready 事件在监听器注册前发出的竞态 if (window.metona?.tools?.onReady) { const unsubscribe = window.metona.tools.onReady((data) => { - console.log('[App] Tools ready:', data.toolCount, 'tools'); + console.debug('[App] Tools ready:', data.toolCount, 'tools'); useAgentStore.getState().setToolsReady(true); }); // 竞态保护: 事件可能在监听器注册前已发出,主动查询当前状态 @@ -125,7 +132,7 @@ export default function App(): React.JSX.Element { .isReady?.() .then((status) => { if (status.ready) { - console.log('[App] Tools already ready (polled):', status.toolCount, 'tools'); + console.debug('[App] Tools already ready (polled):', status.toolCount, 'tools'); useAgentStore.getState().setToolsReady(true); } }) @@ -166,13 +173,30 @@ export default function App(): React.JSX.Element { }} > {/* H-7 修复: 添加 Header Bar — 规范要求布局为 Header (全宽) + [Sidebar|ChatPanel|DetailPanel] + StatusBar */} -
+ {/* v0.8.2 P0-4: 三栏+顶栏/状态栏逐区 ErrorBoundary —— 此前任一区域渲染 + 异常会卸载整树白屏(仅 DetailPanel 各 Tab 与确认弹框有边界)。逐区隔离 + 使单区崩溃降级为局部错误卡片,其余区域照常可用。 */} + +
+
- {sidebarVisible && } - - {detailVisible && } + {sidebarVisible && ( + + + + )} + + + + {detailVisible && ( + + + + )}
- + + + diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 15b6869..2846680 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -27,6 +27,8 @@ import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; // v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; +// v0.8.2 P0-4: 引用回复走受控输入桥 +import { appendToChatInput } from '@renderer/lib/chat-input-bridge'; import '@renderer/lib/i18n-strings'; // v0.6.4 死代码清理:'tool-call' / 'code-block' / 'trace-step' 三个从未被任何组件 @@ -261,15 +263,15 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C icon: Quote, label: t('contextMenu.quote'), action: () => { - const input = document.querySelector('[data-chat-input]'); - if (input) { - input.value = - content - .split('\n') - .map((l: string) => `> ${l}`) - .join('\n') + '\n\n'; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.focus(); + // v0.8.2 P0-4: 走受控输入桥 —— 旧实现 DOM 直查直赋(命中 InputBase + // 根 div + 受控组件不吃注入),功能完全失效 + const quoted = + content + .split('\n') + .map((l: string) => `> ${l}`) + .join('\n') + '\n\n'; + if (!appendToChatInput(quoted)) { + copyWithToast(quoted); } }, }, diff --git a/src/components/ToastContainer.tsx b/src/components/ToastContainer.tsx index 7023d9c..aa07383 100644 --- a/src/components/ToastContainer.tsx +++ b/src/components/ToastContainer.tsx @@ -7,6 +7,9 @@ */ import { useEffect } from 'react'; +// v0.8.2 P2-5: toast 内部文案跟随界面语言(此前硬编码 zh-CN) +import { getLocale, setLocale, onLocaleChange } from '@renderer/lib/i18n'; +import '@renderer/lib/i18n-strings'; /** * Toast 容器组件 @@ -19,35 +22,37 @@ import { useEffect } from 'react'; export function ToastContainer(): null { useEffect(() => { // 动态导入 metona-toast 并配置 - import('@metona-team/metona-toast').then((mod) => { - const MeToast = mod.default; + import('@metona-team/metona-toast') + .then((mod) => { + const MeToast = mod.default; - // 全局配置(设计规范指定的参数) - MeToast.configure({ - position: 'top-right', - duration: 4000, - max: 6, - theme: 'auto', - animation: 'slide', - pauseOnHover: true, - closeOnClick: true, - showProgress: true, - draggable: true, - locale: 'zh-CN', + // 全局配置(设计规范指定的参数) + MeToast.configure({ + position: 'top-right', + duration: 4000, + max: 6, + theme: 'auto', + animation: 'slide', + pauseOnHover: true, + closeOnClick: true, + showProgress: true, + draggable: true, + locale: getLocale(), + }); + + // 安装插件 + try { + MeToast.use('keyboard'); // ESC 关闭所有 + MeToast.use('persistence'); // 配置持久化 + MeToast.use('accessibility'); // 屏幕阅读器 + } catch (err) { + console.error('[Toast]', 'Failed to install plugin:', err); + } + }) + .catch((err) => { + console.error('[Toast]', 'Failed to load metona-toast:', err); }); - // 安装插件 - try { - MeToast.use('keyboard'); // ESC 关闭所有 - MeToast.use('persistence'); // 配置持久化 - MeToast.use('accessibility'); // 屏幕阅读器 - } catch (err) { - console.error('[Toast]', 'Failed to install plugin:', err); - } - }).catch((err) => { - console.error('[Toast]', 'Failed to load metona-toast:', err); - }); - // 监听主进程通知桥接 if (window.metona?.toast?.onShow) { const unsubscribe = window.metona.toast.onShow(async (data) => { @@ -55,11 +60,20 @@ export function ToastContainer(): null { const MeToast = (await import('@metona-team/metona-toast')).default; const { type, message, options } = data; switch (type) { - case 'success': MeToast.success(message, options); break; - case 'error': MeToast.error(message, options); break; - case 'warning': MeToast.warning(message, options); break; - case 'info': MeToast.info(message, options); break; - default: MeToast.info(message, options); + case 'success': + MeToast.success(message, options); + break; + case 'error': + MeToast.error(message, options); + break; + case 'warning': + MeToast.warning(message, options); + break; + case 'info': + MeToast.info(message, options); + break; + default: + MeToast.info(message, options); } } catch (err) { console.error('[Toast]', 'Failed to show toast:', err); @@ -69,5 +83,15 @@ export function ToastContainer(): null { } }, []); + // v0.8.2 P2-5: 语言切换时重配置 MeToast locale + useEffect(() => { + return onLocaleChange((locale) => { + void setLocale(locale); + import('@metona-team/metona-toast') + .then((mod) => mod.default.configure({ locale })) + .catch(() => {}); + }); + }, []); + return null; } diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index 88fbe52..8a40d27 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -11,7 +11,7 @@ * 避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染。 */ -import { useState, useCallback, memo, useMemo } from 'react'; +import { useState, useCallback, memo, useMemo, useRef, useEffect } from 'react'; import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material'; import { Bot, Copy, Check } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; @@ -306,6 +306,15 @@ export const AssistantMessage = memo(AssistantMessageImpl); function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element { const [copied, setCopied] = useState(false); + // v0.8.2 P3-6: "已复制"复位定时器持有句柄并在卸载时清理 —— 裸 setTimeout 会在 + // 组件卸载后触发 setState(React 警告 / 测试环境 window 失效 unhandled error) + const copyTimerRef = useRef | null>(null); + useEffect( + () => () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }, + [], + ); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(code); @@ -318,7 +327,8 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac document.body.removeChild(ta); } setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 2000); }, [code]); return ( diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index bfb14f1..cd48f18 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -33,6 +33,8 @@ import { formatFileSize } from '@renderer/lib/formatters'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; // v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测) import { supportsImageUpload } from '@renderer/lib/model-capabilities'; +// v0.8.2 P0-4: 受控输入桥注册(引用回复 / Ctrl+L 聚焦的真实能力提供方) +import { registerChatInputController } from '@renderer/lib/chat-input-bridge'; // v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; import '@renderer/lib/i18n-strings'; @@ -157,6 +159,29 @@ export function ChatInput(): React.JSX.Element { if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); }, [input, currentSessionId]); + // v0.8.2 P0-4: 注册受控输入桥 —— 引用回复 / Ctrl+L 不再 DOM 直查直赋 + // (旧实现对 InputBase 根 div 赋 value/focus,且受控组件不吃 DOM 注入,功能完全失效) + useEffect(() => { + registerChatInputController({ + focus: () => textareaRef.current?.focus(), + appendText: (text: string) => { + setInput((prev) => { + const base = prev.length > 0 ? `${prev}\n` : ''; + return `${base}${text}\n\n`; + }); + // 光标移到末尾(受控更新后 textarea 才有新值,rAF 等一帧) + requestAnimationFrame(() => { + const el = textareaRef.current; + if (el) { + el.selectionStart = el.selectionEnd = el.value.length; + el.focus(); + } + }); + }, + }); + return () => registerChatInputController(null); + }, []); + // ===== 附件处理 ===== const classifyFile = useCallback((file: File): Attachment['type'] => { @@ -377,10 +402,45 @@ export function ChatInput(): React.JSX.Element { } if (cmd === '/export') { // P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单 + // v0.8.2 P2-3 根治: 从 DB 全量拉取 —— v0.8.1 游标分页后内存 messages 仅 + // 尾部 200 条窗口,旧实现静默导出残缺会话(与右键导出全量口径不一致)。 + // DB 不可用(无会话/IPC 缺失)时回退内存窗口并提示。 import('@renderer/lib/export-markdown') - .then(({ buildSessionMarkdown, downloadMarkdown }) => { - const messages = useAgentStore.getState().messages; - const md = buildSessionMarkdown(t('chat.export.title'), messages); + .then(async ({ buildSessionMarkdown, downloadMarkdown }) => { + const sid = useAgentStore.getState().currentSessionId; + let source: Array<{ + id: string; + role: string; + content: string | null; + reasoningContent?: string; + toolCalls?: Array<{ name: string; args?: Record }>; + attachments?: Array<{ name: string; type: string }>; + timestamp: number; + }> = useAgentStore.getState().messages; + if (sid && window.metona?.sessions?.getMessages) { + try { + const all = await window.metona.sessions.getMessages(sid); + if (all.length > 0) { + source = all + .filter((m) => m.role !== 'tool') + .map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + reasoningContent: m.reasoningContent, + toolCalls: (m.toolCalls ?? []) as Array<{ + name: string; + args?: Record; + }>, + attachments: (m.attachments ?? []) as Array<{ name: string; type: string }>, + timestamp: m.timestamp ?? Date.now(), + })); + } + } catch { + // 拉取失败回退内存窗口 + } + } + const md = buildSessionMarkdown(t('chat.export.title'), source); downloadMarkdown(`session-${Date.now()}.md`, md); }) .catch(() => {}); diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index cec997b..0bf4b33 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -13,7 +13,7 @@ import { useEffect, useRef } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; -import { Box, Typography } from '@mui/material'; +import { Box, Typography, CircularProgress } from '@mui/material'; import { useAgentStore } from '@renderer/stores/agent-store'; import { MessageItem } from './MessageItem'; import { StreamingIndicator } from './StreamingIndicator'; @@ -33,6 +33,20 @@ const ListFooter = (): React.JSX.Element => ( ); +/** + * v0.8.2 P2-2: 向上翻页加载指示(模块级稳定引用,同 ListFooter 的 reconciliation 契约)。 + * loadingOlder 状态此前存在于 store 但无任何消费 —— 慢 DB 下上翻"像是没反应"。 + */ +const ListHeader = (): React.JSX.Element => { + const loadingOlder = useAgentStore((s) => s.loadingOlder); + if (!loadingOlder) return ; + return ( + + + + ); +}; + export function MessageList(): React.JSX.Element { const messages = useAgentStore((s) => s.messages); const isStreaming = useAgentStore((s) => s.isStreaming); @@ -120,6 +134,7 @@ export function MessageList(): React.JSX.Element { )} components={{ + Header: ListHeader, Footer: ListFooter, }} /> diff --git a/src/components/chat/ToolResultBlock.tsx b/src/components/chat/ToolResultBlock.tsx index 7b92409..77e2287 100644 --- a/src/components/chat/ToolResultBlock.tsx +++ b/src/components/chat/ToolResultBlock.tsx @@ -1,9 +1,16 @@ /** * ToolResultBlock — 工具结果块 + * + * v0.8.2 P2-1: 富媒体渲染 — + * - 图片类结果(view_image 的 dataUrl / web_browser 截图的 image / MCP image 块) + * 在聊天流内联预览。此前只渲染 `_displayNote` 文字摘要,用户看不到工具实际 + * 看到的图(大字段剥离仍然生效 —— store 原始 result 不变,仅展示层取图)。 + * - diff 类结果(diff_viewer / file_editor dry_run 的 unified diff)渲染为 + * 增删着色的 diff 视图,替代裸 JSON。 */ import { Box, Typography, Stack } from '@mui/material'; -import { FileText } from 'lucide-react'; +import { FileText, Image as ImageIcon, GitCompareArrows } from 'lucide-react'; import type { ToolCallInfo } from '@renderer/stores/agent-store'; import { formatDuration } from '@renderer/lib/formatters'; import { toDisplayResult } from '@renderer/lib/tool-result-display'; @@ -15,14 +22,125 @@ interface ToolResultBlockProps { toolCall: ToolCallInfo; } +/** diff 视图最大渲染行数(超出截断,防 DOM 爆炸) */ +const DIFF_MAX_LINES = 400; + +/** + * 从**原始** result 提取可展示的图片 src(displayResult 已剥离 dataUrl,不能用于取图)。 + * 兼容三种形态:dataUrl(view_image / MCP)、image 字段(web_browser 裸 base64 / + * MCP data URI)。非 data:image/ 前缀且非合法 base64 形态的一律返回 null。 + */ +function extractImageSrc(result: unknown): string | null { + if (typeof result !== 'object' || result === null || Array.isArray(result)) return null; + const rec = result as Record; + if (rec._imageOmitted) return null; // registry 已判定超限并替换占位符 + const candidate = + typeof rec.dataUrl === 'string' + ? rec.dataUrl + : typeof rec.image === 'string' + ? rec.image + : null; + if (!candidate) return null; + if (candidate.startsWith('data:image/')) return candidate; + // 裸 base64(web_browser 截图契约:{ image, mime_type })→ 补 data URI 前缀 + const mime = typeof rec.mime_type === 'string' && rec.mime_type ? rec.mime_type : 'image/png'; + if (candidate.length > 64 && /^[A-Za-z0-9+/=\r\n]+$/.test(candidate.slice(0, 256))) { + return `data:${mime};base64,${candidate}`; + } + return null; +} + +/** + * 从 displayResult 提取 unified diff 文本(diff 字符串体积可控,走裁剪层安全)。 + * 覆盖 diff_viewer(顶层 diff)与 file_editor dry_run(preview.diff)两种形态。 + */ +function extractDiffText(display: unknown): string | null { + if (typeof display !== 'object' || display === null || Array.isArray(display)) return null; + const rec = display as Record; + if (typeof rec.diff === 'string' && rec.diff.trim()) return rec.diff; + const preview = rec.preview; + if ( + typeof preview === 'object' && + preview !== null && + typeof (preview as Record).diff === 'string' && + ((preview as Record).diff as string).trim() + ) { + return (preview as Record).diff as string; + } + return null; +} + +/** unified diff 视图:增删行着色 + hunk 头高亮(MUI sx,禁自写组件符合开发规范) */ +function DiffView({ diff }: { diff: string }): React.JSX.Element { + const allLines = diff.split('\n'); + const lines = allLines.slice(0, DIFF_MAX_LINES); + return ( + + {lines.map((line, i) => { + const isAdd = line.startsWith('+') && !line.startsWith('+++'); + const isDel = line.startsWith('-') && !line.startsWith('---'); + const isHunk = line.startsWith('@@'); + return ( + + {line || ' '} + + ); + })} + {allLines.length > DIFF_MAX_LINES && ( + + [… {allLines.length - DIFF_MAX_LINES} more lines] + + )} + + ); +} + export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element { if (toolCall.status !== 'success' || toolCall.result == null) return <>; - // 渲染前剥离 dataUrl 等超大 base64 字段,避免 ~6.7MB/张的 dataUrl 进 DOM 导致渲染进程 OOM + // 渲染前剥离 dataUrl 等超大 base64 字段,避免 ~6.7MB/张的 dataUrl 以文本进 DOM 导致渲染进程 OOM // store 内原始 result 不变(LLM 看图能力不受影响) const displayResult = toDisplayResult(toolCall.result); const resultStr = typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2); + // v0.8.2 P2-1: 富媒体路由 —— diff 视图 > 图片内联 > 默认 JSON pre + const diffText = extractDiffText(displayResult); + const imageSrc = extractImageSrc(toolCall.result); + return ( - + {diffText ? ( + + ) : imageSrc ? ( + + ) : ( + + )} {t('toolResult.title', { name: toolCall.name })} @@ -49,26 +173,43 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E )} - - {resultStr} - + {diffText ? ( + + ) : imageSrc ? ( + + ) : ( + + {resultStr} + + )} ); } diff --git a/src/components/chat/UserMessage.tsx b/src/components/chat/UserMessage.tsx index 5442b13..bff3880 100644 --- a/src/components/chat/UserMessage.tsx +++ b/src/components/chat/UserMessage.tsx @@ -51,7 +51,7 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element { editContent.trim(), ); if (r?.success === false) { - throw new Error(r.error ?? '保存失败'); + throw new Error(r.error ?? t('message.saveFailed')); } } updateMessage(message.id, { content: editContent.trim() }); diff --git a/src/components/chat/__tests__/ToolResultBlock.test.tsx b/src/components/chat/__tests__/ToolResultBlock.test.tsx index 72c7775..cc00c15 100644 --- a/src/components/chat/__tests__/ToolResultBlock.test.tsx +++ b/src/components/chat/__tests__/ToolResultBlock.test.tsx @@ -64,18 +64,84 @@ describe('ToolResultBlock — 结果渲染', () => { }); }); -describe('ToolResultBlock — dataUrl 剥离', () => { - it('含 dataUrl 字段时剥离并显示 _displayNote', () => { +describe('ToolResultBlock — 富媒体渲染(v0.8.2 P2-1)', () => { + it('含 dataUrl 字段 → 内联 预览,base64 不再以文本进 DOM', () => { const result = { dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000), path: '/tmp/screenshot.png', size: 1234, }; const { container } = render(); - const text = container.querySelector('pre')?.textContent ?? ''; + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.getAttribute('src')).toBe(result.dataUrl); + // base64 不得以文本形态出现(防 OOM 契约保持) + const text = container.textContent ?? ''; + expect(text).not.toContain('data:image/png;base64'); + }); + + it('web_browser 截图(裸 base64 + mime_type)→ 内联预览', () => { + const result = { image: 'A'.repeat(200), mime_type: 'image/jpeg', width: 800, height: 600 }; + const { container } = render( + , + ); + const img = container.querySelector('img'); + expect(img?.getAttribute('src')).toBe(`data:image/jpeg;base64,${result.image}`); + }); + + it('diff_viewer 结果(顶层 diff)→ unified diff 视图', () => { + const result = { + diff: [ + '--- a.txt', + '+++ b.txt', + '@@ -1,2 +1,2 @@', + ' context', + '-removed line', + '+added line', + ].join('\n'), + summary: { lines_added: 1, lines_removed: 1, unchanged: 1 }, + }; + const { container } = render( + , + ); + const pre = container.querySelector('pre'); + expect(pre?.textContent).toContain('added line'); + expect(pre?.textContent).toContain('removed line'); + }); + + it('file_editor dry_run(preview.diff)→ 同样走 diff 视图', () => { + const result = { + dry_run: true, + preview: { + original: 'x', + modified: 'y', + diff: ['--- old', '+++ new', '@@ -1 +1 @@', '-x', '+y'].join('\n'), + }, + }; + const { container } = render(); + expect(container.querySelector('pre')?.textContent).toContain('+y'); + }); + + it('_imageOmitted(registry 判定超限占位)→ 不渲染 ', () => { + const result = { image: 'data:image/png;base64,AAAA', _imageOmitted: true }; + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + }); +}); + +describe('ToolResultBlock — dataUrl 剥离(display 层契约保持)', () => { + it('含 dataUrl 时展示层剥离大字段(displayResult 不含 base64 文本)', () => { + const result = { + dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000), + path: '/tmp/screenshot.png', + size: 1234, + }; + const { container } = render(); + // 预览之外,任何文本节点都不得包含 base64 主体 + const text = Array.from(container.querySelectorAll('pre')) + .map((el) => el.textContent) + .join(''); expect(text).not.toContain('data:image/png;base64'); - expect(text).toContain('[image base64 omitted for display — visible to LLM only]'); - expect(text).toContain('/tmp/screenshot.png'); }); it('普通大字符串被截断显示(防御裁剪)', () => { diff --git a/src/components/layout/DetailPanel.tsx b/src/components/layout/DetailPanel.tsx index 1e1b768..fc74be3 100644 --- a/src/components/layout/DetailPanel.tsx +++ b/src/components/layout/DetailPanel.tsx @@ -75,13 +75,28 @@ export function DetailPanel(): React.JSX.Element { '& .MuiTabs-indicator': { height: 2 }, }} > - } iconPosition="start" label="Trace" value="trace" /> - } iconPosition="start" label="Memory" value="memory" /> - } iconPosition="start" label="Tasks" value="tasks" /> + } + iconPosition="start" + label={t('detailPanel.tab.trace')} + value="trace" + /> + } + iconPosition="start" + label={t('detailPanel.tab.memory')} + value="memory" + /> + } + iconPosition="start" + label={t('detailPanel.tab.tasks')} + value="tasks" + /> } iconPosition="start" - label="Workspace" + label={t('detailPanel.tab.workspace')} value="workspace" /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6f6172f..f739de1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -96,14 +96,20 @@ export function Sidebar(): React.JSX.Element { }; }, []); - // v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播 - // (合成 key:session.title.),此处消费并刷新侧栏标题 + // v0.8.2 P2-4 根治: 会话元数据实时刷新 —— ① 标题生成结果改走专用 + // session:updated 事件(此前伪装成 config:changed 合成 key,语义混用); + // ② messageCount/updatedAt 随主进程落库实时刷新(此前仅挂载时 list() 一次, + // "x 条消息 / 3 分钟前"停留在旧值直到重启)。 useEffect(() => { - if (!window.metona?.config?.onChanged) return; - const unsubscribe = window.metona.config.onChanged((data) => { - const match = /^session\.title\.(.+)$/.exec(data.key); - if (match && typeof data.value === 'string' && data.value) { - useSessionStore.getState().updateSession(match[1], { title: data.value }); + if (!window.metona?.sessions?.onSessionUpdated) return; + const unsubscribe = window.metona.sessions.onSessionUpdated((data) => { + if (!data?.sessionId) return; + const patch: { title?: string; messageCount?: number; updatedAt?: number } = {}; + if (typeof data.title === 'string' && data.title) patch.title = data.title; + if (typeof data.messageCount === 'number') patch.messageCount = data.messageCount; + if (typeof data.updatedAt === 'number') patch.updatedAt = data.updatedAt; + if (Object.keys(patch).length > 0) { + useSessionStore.getState().updateSession(data.sessionId, patch); } }); return unsubscribe; @@ -788,13 +794,14 @@ function ToolManagerPanel() { medium: 'warning.main', high: 'error.main', }; + // v0.8.2 P2-5: 风险标签出层(此前硬编码英文 SAFE/LOW/...) const riskLabels: Record = { - safe: 'SAFE', - low: 'LOW', - medium: 'MEDIUM', - high: 'HIGH', + safe: t('sidebar.risk.safe'), + low: t('sidebar.risk.low'), + medium: t('sidebar.risk.medium'), + high: t('sidebar.risk.high'), // v0.8.0 P1-3.8: 补 critical —— 旧映射缺失导致 CRITICAL 工具显示原始字符串 - critical: 'CRITICAL', + critical: t('sidebar.risk.critical'), }; return ( diff --git a/src/components/layout/__tests__/Sidebar.test.tsx b/src/components/layout/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000..c981bdf --- /dev/null +++ b/src/components/layout/__tests__/Sidebar.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +/** + * Sidebar 组件测试(v0.8.2 P3-3 补齐设置面板/侧栏测试空白) + * + * 覆盖契约: + * - 挂载时拉取会话列表并渲染标题 + * - v0.8.2 P2-4: session:updated 实时刷新 messageCount/updatedAt/title + * - 搜索框(标题 + 内容全文搜索入口)存在 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Sidebar } from '../Sidebar'; +import { useSessionStore } from '@renderer/stores/session-store'; + +const noop = (): void => {}; + +const sessionUpdatedHandlers: Array<(data: unknown) => void> = []; + +function makeSession(overrides: Record = {}): { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messageCount: number; + totalTokens: number; + pinned: boolean; + archived: boolean; +} { + return { + id: 's1', + title: '第一个会话', + createdAt: Date.now(), + updatedAt: Date.now(), + messageCount: 2, + totalTokens: 0, + pinned: false, + archived: false, + ...overrides, + }; +} + +beforeEach(() => { + sessionUpdatedHandlers.length = 0; + useSessionStore.getState().setSessions([]); + useSessionStore.getState().setCurrentSession(null); + const bridge = (window as unknown as { metona: Record }).metona; + bridge.sessions = { + ...(bridge.sessions as Record), + list: vi.fn().mockResolvedValue([makeSession(), makeSession({ id: 's2', title: 'Second' })]), + searchContent: vi.fn().mockResolvedValue({ success: true, data: [] }), + onSessionUpdated: vi.fn((cb: (data: unknown) => void) => { + sessionUpdatedHandlers.push(cb); + return noop; + }), + }; +}); + +describe('Sidebar — 会话列表', () => { + it('挂载时拉取并渲染会话标题', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('第一个会话')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + }); + }); + + it('渲染搜索框与新建会话按钮', () => { + render(); + expect(screen.getByPlaceholderText(/搜索|Search/)).toBeInTheDocument(); + expect(screen.getAllByRole('button').length).toBeGreaterThan(0); + }); + + it('接收 session:updated 后实时刷新条数(P2-4)', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('第一个会话')).toBeInTheDocument(); + }); + expect(sessionUpdatedHandlers.length).toBeGreaterThan(0); + + act(() => { + sessionUpdatedHandlers.forEach((h) => + h({ sessionId: 's1', messageCount: 42, updatedAt: Date.now() }), + ); + }); + await waitFor(() => { + // t('sidebar.messageCount') zh 文案为 "{{count}} 条" + expect(screen.getByText(/42\s*条|42 messages/)).toBeInTheDocument(); + }); + }); + + it('接收 session:updated 的 title 补丁 → 标题刷新(LLM 标题生成链路)', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('第一个会话')).toBeInTheDocument(); + }); + act(() => { + sessionUpdatedHandlers.forEach((h) => h({ sessionId: 's1', title: 'LLM 生成的新标题' })); + }); + await waitFor(() => { + expect(screen.getByText('LLM 生成的新标题')).toBeInTheDocument(); + }); + }); +}); + +describe('Sidebar — 搜索', () => { + it('输入搜索词触发内容全文搜索(FTS5 通道,300ms 防抖)', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText(/搜索|Search/); + await user.type(input, '关键词'); + await waitFor( + () => { + const bridge = (window as unknown as { metona: Record }).metona; + expect(bridge.sessions).toBeDefined(); + expect( + (bridge.sessions as { searchContent: { mock: { calls: unknown[][] } } }).searchContent + .mock.calls.length, + ).toBeGreaterThan(0); + }, + { timeout: 2_000 }, + ); + }); +}); diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 2b5f20d..7ce192e 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -47,6 +47,9 @@ const ONBOARDING_PROGRESS_KEY = 'onboarding_progress'; export function OnboardingWizard(): React.JSX.Element | null { const onboardingCompleted = useUIStore((s) => s.onboardingCompleted); const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted); + // v0.8.2 P3-4: 配置就绪门禁 —— onboarding.completed 异步读取期间不渲染向导, + // 消除老用户每次启动的向导首帧闪烁 + const onboardingGateReady = useUIStore((s) => s.onboardingGateReady); const [step, setStep] = useState(0); const [provider, setProvider] = useState(''); // v0.5.4: 多模态总开关(保存到 llm.multimodalEnabled,控制图片上传入口) @@ -110,7 +113,7 @@ export function OnboardingWizard(): React.JSX.Element | null { } }, [step, provider, baseURL, model, workspacePath, contextWindow]); - if (onboardingCompleted) return null; + if (onboardingCompleted || !onboardingGateReady) return null; // v0.8.1 硬性契约: 上下文长度是全局单一配置(llm.contextWindow),不再存在 // 分 Provider 默认值 —— 删除了旧的 DEFAULT_CTX 写死表(1M/128K/200K/4096)。 @@ -119,7 +122,27 @@ export function OnboardingWizard(): React.JSX.Element | null { const ctxError = contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin); + // v0.8.2 P3-4: 步骤字段校验门禁 —— 此前"下一步"无任何校验,可以全空走完, + // 完成后 provider/model 缺失 → 首次发送必然 adapter 加载失败。LLM 配置步 + // 强制 provider + model(+ apiKey,ollama 除外)+ 合法上下文长度。 + const validateStep = (s: number): string | null => { + if (s === 1) { + if (!provider.trim()) return t('onboarding.validate.provider'); + if (!model.trim()) return t('onboarding.validate.model'); + if (provider !== 'ollama' && !apiKey.trim()) return t('onboarding.validate.apiKey'); + if (ctxError) return t('onboarding.llm.ctx.minError', { min: ctxMin }); + } + return null; + }; + const handleNext = async () => { + const validationError = validateStep(step); + if (validationError) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.warning(validationError)) + .catch(() => {}); + return; + } if (step < STEPS.length - 1) { setStep(step + 1); return; diff --git a/src/components/settings/AgentSettings.tsx b/src/components/settings/AgentSettings.tsx index 91b2c9f..6940906 100644 --- a/src/components/settings/AgentSettings.tsx +++ b/src/components/settings/AgentSettings.tsx @@ -107,10 +107,10 @@ export function AgentSettings() { label={t('settings.agent.thinkingEffort')} onChange={(e) => setThinkingEffort(e.target.value)} > - Low - Medium - High - Max + {t('agentSettings.effort.low')} + {t('agentSettings.effort.medium')} + {t('agentSettings.effort.high')} + {t('agentSettings.effort.max')} )} diff --git a/src/components/settings/LogsSettings.tsx b/src/components/settings/LogsSettings.tsx index 0fa8b50..272c6ac 100644 --- a/src/components/settings/LogsSettings.tsx +++ b/src/components/settings/LogsSettings.tsx @@ -506,14 +506,18 @@ function UpdatePanel(): React.JSX.Element { const [installing, setInstalling] = useState(false); const [result, setResult] = useState(null); const [progress, setProgress] = useState(null); + // v0.8.2 P1-3: 更新包已下载、等待用户确认重启安装 + const [downloadedReady, setDownloadedReady] = useState(false); useEffect(() => { if (!window.metona?.app?.onUpdateStatus) return; const off = window.metona.app.onUpdateStatus((event) => { if (event.status === 'downloading') { setProgress(event.percent ?? 0); + setDownloadedReady(false); } else if (event.status === 'downloaded') { setProgress(100); + setDownloadedReady(true); setResult(t('settings.logs.update.downloaded')); } else if (event.status === 'available') { setResult(t('settings.logs.update.available', { version: event.latestVersion ?? '' })); @@ -584,6 +588,18 @@ function UpdatePanel(): React.JSX.Element { : t('settings.logs.update.downloadInstall')} + {downloadedReady && ( + + )} {progress != null && ( {statusLabel(s)} + {/* v0.8.2 P2-7: 禁用态 server 现在也会出现在列表中(后端补齐),显式标注 */} + {(s as { enabled?: boolean }).enabled === false && ( + + {t('settings.mcp.disabled')} + + )} {s.toolCount > 0 && ( {t('settings.mcp.toolCount', { count: s.toolCount })} diff --git a/src/components/settings/__tests__/LLMSettings.test.tsx b/src/components/settings/__tests__/LLMSettings.test.tsx new file mode 100644 index 0000000..c92bba4 --- /dev/null +++ b/src/components/settings/__tests__/LLMSettings.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +/** + * LLMSettings 组件测试(v0.8.2 P3-3 补齐设置面板测试空白) + * + * 覆盖契约: + * - 挂载时经 useConfig 逐 key 读取配置(草稿回填) + * - v0.8.1 硬性契约保持:contextWindow/maxTokens 清空 = 写入 null(未配置语义) + * - 批量保存:handleSave 经 config.setBatch 一次提交全部 13 项 + * - 保存成功后同步多模态开关到 Agent Store + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LLMSettings } from '../LLMSettings'; +import { useAgentStore } from '@renderer/stores/agent-store'; + +const configured: Record = { + 'llm.provider': 'deepseek', + 'llm.baseURL': 'https://api.deepseek.com', + 'llm.model': 'deepseek-v4-flash', + 'llm.apiKey': 'sk-test-key', + 'llm.multimodalEnabled': false, +}; + +beforeEach(() => { + const bridge = (window as unknown as { metona: Record }).metona; + bridge.config = { + ...(bridge.config as Record), + get: vi.fn().mockImplementation((key: string) => Promise.resolve(configured[key] ?? null)), + set: vi.fn().mockResolvedValue({ success: true }), + setBatch: vi.fn().mockResolvedValue({ success: true }), + onChanged: vi.fn(() => () => {}), + }; + bridge.llm = { + ...(bridge.llm as Record), + listModels: vi.fn().mockResolvedValue({ success: true, data: [] }), + getBalance: vi.fn().mockResolvedValue({ success: false }), + pullModel: vi.fn().mockResolvedValue({ success: true }), + cancelPullModel: vi.fn().mockResolvedValue({ success: true }), + onOllamaPullProgress: vi.fn(() => () => {}), + onOllamaPullEnded: vi.fn(() => () => {}), + }; +}); + +async function renderLoaded(): Promise { + render(); + // loaded 门禁:模型输入框回填配置值后才可交互 + await waitFor( + () => { + const inputs = document.querySelectorAll('input'); + const modelInput = Array.from(inputs).find((el) => el.value === 'deepseek-v4-flash'); + if (!modelInput) throw new Error('not loaded yet'); + }, + { timeout: 3_000 }, + ); +} + +describe('LLMSettings — 批量保存', () => { + it( + '修改模型后保存 → setBatch 携带 llm.model 新值与原样 apiKey', + { timeout: 30_000 }, + async () => { + const user = userEvent.setup(); + await renderLoaded(); + const inputs = Array.from(document.querySelectorAll('input')); + const modelInput = inputs.find((el) => el.value === 'deepseek-v4-flash') as HTMLInputElement; + await user.clear(modelInput); + await user.type(modelInput, 'deepseek-v4-pro'); + + const saveButton = screen.getByRole('button', { name: /保存配置|Save Configuration/ }); + await user.click(saveButton); + + const bridge = (window as unknown as { metona: Record }).metona; + const setBatch = bridge.config as { setBatch: { mock: { calls: unknown[][] } } }; + await waitFor(() => { + expect(setBatch.setBatch.mock.calls.length).toBeGreaterThan(0); + }); + const entries = setBatch.setBatch.mock.calls[0][0] as Array<{ key: string; value: unknown }>; + const byKey = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(byKey['llm.model']).toBe('deepseek-v4-pro'); + expect(byKey['llm.provider']).toBe('deepseek'); + expect(byKey['llm.apiKey']).toBe('sk-test-key'); + // v0.8.1 契约: 未配置的 contextWindow/maxTokens 以 null 落库(未配置语义) + expect(byKey['llm.contextWindow']).toBeNull(); + expect(byKey['llm.maxTokens']).toBeNull(); + }, + ); + + it('保存成功 → 多模态开关同步到 Agent Store', { timeout: 20_000 }, async () => { + const user = userEvent.setup(); + await renderLoaded(); + const saveButton = screen.getByRole('button', { name: /保存配置|Save Configuration/ }); + await user.click(saveButton); + await waitFor(() => { + // setBatch 已调用(多模态同步发生在成功分支) + const bridge = (window as unknown as { metona: Record }).metona; + const setBatch = bridge.config as { setBatch: { mock: { calls: unknown[][] } } }; + expect(setBatch.setBatch.mock.calls.length).toBeGreaterThan(0); + expect(useAgentStore.getState().multimodalEnabled).toBe(false); + }); + }); + + it('setBatch 失败 → 不视为成功(保存失败语义由 toast 呈现)', { timeout: 20_000 }, async () => { + const bridge = (window as unknown as { metona: Record }).metona; + bridge.config = { + ...(bridge.config as Record), + setBatch: vi.fn().mockResolvedValue({ success: false, error: 'reload failed' }), + }; + const user = userEvent.setup(); + await renderLoaded(); + const saveButton = screen.getByRole('button', { name: /保存配置|Save Configuration/ }); + await user.click(saveButton); + await waitFor(() => { + const setBatch = bridge.config as { setBatch: { mock: { calls: unknown[][] } } }; + expect(setBatch.setBatch.mock.calls.length).toBeGreaterThan(0); + }); + // 无异常抛出(失败走 toast 分支),组件不崩溃 + expect(screen.getByRole('button', { name: /保存配置|Save Configuration/ })).toBeEnabled(); + }); +}); diff --git a/src/components/trace/TokenUsage.tsx b/src/components/trace/TokenUsage.tsx index 4fc9edf..9e28e3c 100644 --- a/src/components/trace/TokenUsage.tsx +++ b/src/components/trace/TokenUsage.tsx @@ -245,8 +245,28 @@ export function TokenUsage(): React.JSX.Element { )} + {tokenUsage.cacheMissTokens != null && ( + + + {t('tokenUsage.cacheMiss')} + + + {formatTokens(tokenUsage.cacheMissTokens)} + + + )} - 迭代 + + {t('tokenUsage.iterations')} + - 💭 Thought + {t('trace.step.thought')} (); for (const step of traceSteps) { - const rid = step.runId; - if (!rid) continue; + // v0.8.2 P2-2 根治: 无 runId 的 legacy 步骤此前被直接丢弃 + //(`if (!rid) continue`)—— 升级用户的旧会话在 Trace 面板整体空白, + // 数据在、UI 不可见。现归入独立的 legacy 分组按原顺序展示。 + const rid = step.runId || LEGACY_RUN_ID; if (!runMap.has(rid)) { runMap.set(rid, []); runOrder.push(rid); @@ -54,6 +61,7 @@ function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): R steps, isCurrent: rid === effectiveCurrentRunId, isCompleted: Boolean(lastStep?.completedAt), + isLegacy: rid === LEGACY_RUN_ID, }; }); } @@ -96,7 +104,7 @@ export function TraceViewer(): React.JSX.Element { color: 'text.secondary', }} > - Trace Viewer + {t('trace.viewer.title')} {!isEmpty && ( @@ -161,7 +169,10 @@ export function TraceViewer(): React.JSX.Element { letterSpacing: 0.5, }} > - {t('trace.viewer.roundNumber', { index: group.index })} + {/* v0.8.2 P2-2: legacy 分组展示"历史(旧版本)"而非轮次编号 */} + {group.isLegacy + ? t('trace.viewer.legacyRound') + : t('trace.viewer.roundNumber', { index: group.index })} {group.isCurrent && agentStatus !== 'idle' && ( 0) { + store.updateLastTraceStep({ thought: '' }); + } break; } // 推理内容增量 @@ -648,7 +655,7 @@ export function useAgentStream(): void { // v0.8.0 P0-4: output_length_exceeded / invalid_response(空响应重试耗尽) // 映射针对性文案(引擎 P0-2 空响应守卫的结构化错误码) const errorCode = data.error?.code; - const errorMessage = data.error?.message ?? '未知错误'; + const errorMessage = data.error?.message ?? t('agent.error.unknown'); const friendlyContent = errorCode === 'content_filtered' ? t('agent.error.content_filtered', { message: errorMessage }) diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 2ccdc19..9694e10 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -11,6 +11,8 @@ import { useSessionStore } from '@renderer/stores/session-store'; import { toggleTheme } from './useTheme'; // v0.8.1 P0-4: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; +// v0.8.2 P0-4: Ctrl+L 走受控输入桥 +import { focusChatInput } from '@renderer/lib/chat-input-bridge'; import '@renderer/lib/i18n-strings'; /** @@ -101,8 +103,9 @@ export function useKeyboardShortcuts(): void { // Ctrl+L — 聚焦输入框 if (key === 'l' && matchModifier(e, true)) { - const input = document.querySelector('[data-chat-input]'); - input?.focus(); + // v0.8.2 P0-4: 走受控输入桥(旧实现 querySelector 命中 InputBase 根 div, + // 无 tabindex 的 div 不可能获得焦点,快捷键完全失效) + focusChatInput(); e.preventDefault(); return; } diff --git a/src/lib/chat-input-bridge.ts b/src/lib/chat-input-bridge.ts new file mode 100644 index 0000000..7fa0627 --- /dev/null +++ b/src/lib/chat-input-bridge.ts @@ -0,0 +1,47 @@ +/** + * v0.8.2 P0-4: ChatInput 受控输入桥 + * + * 根治"引用回复 / Ctrl+L 聚焦输入框"的双层断裂: + * ① 旧实现 `document.querySelector('[data-chat-input]')` 命中的是 MUI InputBase + * 的**根 div**(可编辑元素是其内部 textarea)—— 对 div 赋 value/focus 无效; + * ② 即使命中 textarea,ChatInput 是受控组件(value={input}),直接赋 DOM value + * + dispatchEvent('input') 不会更新 React state(值追踪器判定无变化)。 + * + * 根治方案:ChatInput 挂载时把"聚焦"与"受控插入文本"两个真实能力注册到本桥, + * 右键菜单(引用回复)与快捷键(Ctrl+L)调用桥接口 —— 不做任何 DOM 查询, + * 完全走 React state,与草稿保存/IME 保护天然兼容。 + */ + +export interface ChatInputController { + /** 聚焦输入框(textarea 真实焦点) */ + focus: () => void; + /** + * 受控插入文本:保留现有草稿,以换行分隔追加到尾部,并把光标移到末尾。 + * 通过 React state 更新(setInput),草稿 useEffect 会照常持久化。 + */ + appendText: (text: string) => void; +} + +let controller: ChatInputController | null = null; + +/** ChatInput 挂载时注册;卸载时传 null 注销 */ +export function registerChatInputController(c: ChatInputController | null): void { + controller = c; +} + +/** 聚焦输入框。无可用输入框(桥未注册)返回 false。 */ +export function focusChatInput(): boolean { + if (!controller) return false; + controller.focus(); + return true; +} + +/** + * 向输入框追加文本(引用回复等场景)。 + * 无可用输入框(桥未注册)返回 false —— 调用方据此 toast 提示。 + */ +export function appendToChatInput(text: string): boolean { + if (!controller) return false; + controller.appendText(text); + return true; +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 5693a46..306d812 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -8,13 +8,18 @@ */ import { formatDistanceToNow, format } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; +import { zhCN, enUS } from 'date-fns/locale'; +// v0.8.2 P2-5: 相对时间文案跟随 ui.locale(此前固定 zhCN,en-US 用户看到中文) +import { getLocale } from './i18n'; // ===== 日期格式化(使用 date-fns)===== -/** 相对时间(如 "3 分钟前"、"昨天") */ +/** 相对时间(如 "3 分钟前"、"昨天";跟随界面语言) */ export function formatRelativeTime(timestamp: number): string { - return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: zhCN }); + return formatDistanceToNow(new Date(timestamp), { + addSuffix: true, + locale: getLocale() === 'en-US' ? enUS : zhCN, + }); } /** 完整时间格式(如 "2026-06-27 14:30") */ diff --git a/src/lib/i18n-strings.ts b/src/lib/i18n-strings.ts index 2403432..f529ae5 100644 --- a/src/lib/i18n-strings.ts +++ b/src/lib/i18n-strings.ts @@ -493,7 +493,8 @@ registerTranslations('zh-CN', { 'settings.logs.update.upToDate': '已是最新版本({{version}})', 'settings.logs.update.disabled': '未配置更新源(app.updateFeedUrl)', 'settings.logs.update.error': '更新失败:{{message}}', - 'settings.logs.update.downloaded': '下载完成,即将重启安装…', + 'settings.logs.update.downloaded': '更新包已下载,可随时重启安装', + 'settings.mcp.disabled': '已禁用', 'settings.logs.traceStats': '{{count}} 个文件 · 共 {{size}}(保留策略:最近 200 个,启动时自动清理)', 'settings.logs.statsLoading': '统计中...', @@ -757,6 +758,48 @@ registerTranslations('zh-CN', { 'trace.viewer.inProgress': '进行中', 'trace.viewer.completed': '已完成', 'trace.viewer.steps': '{{count}} 步', + // v0.8.2 P2-5/P2-2: 面板标题出层 + legacy 分组 + 'trace.viewer.title': '追踪面板', + 'trace.viewer.legacyRound': '历史(旧版本)', + 'trace.step.thought': '💭 思考过程', + // v0.8.2 P2-5: TokenUsage 迭代标签与缓存未命中 + 'tokenUsage.iterations': '迭代', + 'tokenUsage.cacheMiss': '缓存未命中', + // v0.8.2 P2-5: 详情面板 Tab + 'detailPanel.tab.trace': '追踪', + 'detailPanel.tab.memory': '记忆', + 'detailPanel.tab.tasks': '任务', + 'detailPanel.tab.workspace': '工作空间', + // v0.8.2 P2-5: 思考强度档位 + 'agentSettings.effort.low': '低', + 'agentSettings.effort.medium': '中', + 'agentSettings.effort.high': '高', + 'agentSettings.effort.max': '最大', + // v0.8.2 P2-5: 工具风险标签 + 'sidebar.risk.safe': '安全', + 'sidebar.risk.low': '低', + 'sidebar.risk.medium': '中', + 'sidebar.risk.high': '高', + 'sidebar.risk.critical': '严重', + // v0.8.2 P0-4/P2-5: agent 通道兜底 / 消息编辑 / 未知错误 + 'agent.error.bridgeMissing': + '⚠️ IPC 桥接不可用(preload 加载异常)。请重启应用;若持续出现请检查安装完整性', + 'message.saveFailed': '保存失败', + 'agent.error.unknown': '未知错误', + // v0.8.2 P2-1: 工具结果图片 + 'toolResult.imageAlt': '工具生成的图片预览', + // v0.8.2 P1-3: 更新确认安装 + 'settings.logs.update.restartToInstall': '重启以安装更新', + // v0.8.2 P3-4: 引导步骤校验 + 'onboarding.validate.provider': '请选择 Provider 后再继续', + 'onboarding.validate.model': '请填写模型名称后再继续', + 'onboarding.validate.apiKey': '该 Provider 需要填写 API Key', + // v0.8.2 P0-4: 根级错误边界分区标题 + 'errorBoundary.header': '顶栏渲染失败', + 'errorBoundary.sidebar': '侧边栏渲染失败', + 'errorBoundary.chat': '聊天区渲染失败', + 'errorBoundary.detail': '详情面板渲染失败', + 'errorBoundary.statusBar': '状态栏渲染失败', // ===== 工作空间浏览器(WorkspaceViewer)===== 'workspace.currentWorkspace': '当前工作空间', @@ -1267,7 +1310,8 @@ registerTranslations('en-US', { 'settings.logs.update.upToDate': 'Up to date ({{version}})', 'settings.logs.update.disabled': 'No update feed configured (app.updateFeedUrl)', 'settings.logs.update.error': 'Update failed: {{message}}', - 'settings.logs.update.downloaded': 'Download complete — restarting to install…', + 'settings.logs.update.downloaded': 'Update downloaded — restart to install when ready', + 'settings.mcp.disabled': 'Disabled', 'settings.logs.traceStats': '{{count}} file(s) · {{size}} total (retention: last 200, auto-cleaned on startup)', 'settings.logs.statsLoading': 'Computing...', @@ -1538,6 +1582,40 @@ registerTranslations('en-US', { 'trace.viewer.inProgress': 'In progress', 'trace.viewer.completed': 'Completed', 'trace.viewer.steps': '{{count}} step(s)', + // v0.8.2 P2-5/P2-2 + 'trace.viewer.title': 'Trace Viewer', + 'trace.viewer.legacyRound': 'History (legacy)', + 'trace.step.thought': '💭 Thought', + // v0.8.2 P2-5 + 'tokenUsage.iterations': 'Iterations', + 'tokenUsage.cacheMiss': 'Cache miss', + 'detailPanel.tab.trace': 'Trace', + 'detailPanel.tab.memory': 'Memory', + 'detailPanel.tab.tasks': 'Tasks', + 'detailPanel.tab.workspace': 'Workspace', + 'agentSettings.effort.low': 'Low', + 'agentSettings.effort.medium': 'Medium', + 'agentSettings.effort.high': 'High', + 'agentSettings.effort.max': 'Max', + 'sidebar.risk.safe': 'SAFE', + 'sidebar.risk.low': 'LOW', + 'sidebar.risk.medium': 'MEDIUM', + 'sidebar.risk.high': 'HIGH', + 'sidebar.risk.critical': 'CRITICAL', + 'agent.error.bridgeMissing': + '⚠️ IPC bridge unavailable (preload failed to load). Please restart the app; if it persists, verify the installation', + 'message.saveFailed': 'Save failed', + 'agent.error.unknown': 'Unknown error', + 'toolResult.imageAlt': 'Preview of the image produced by the tool', + 'settings.logs.update.restartToInstall': 'Restart to install update', + 'onboarding.validate.provider': 'Select a Provider to continue', + 'onboarding.validate.model': 'Enter a model name to continue', + 'onboarding.validate.apiKey': 'This Provider requires an API Key', + 'errorBoundary.header': 'Header failed to render', + 'errorBoundary.sidebar': 'Sidebar failed to render', + 'errorBoundary.chat': 'Chat area failed to render', + 'errorBoundary.detail': 'Detail panel failed to render', + 'errorBoundary.statusBar': 'Status bar failed to render', // ===== Workspace Viewer ===== 'workspace.currentWorkspace': 'Current workspace', diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index bebbd73..0e838b5 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -518,7 +518,7 @@ export const useAgentStore = create((set, get) => ({ get().addMessage({ id: genMsgId('error'), role: 'system', - content: `错误: 无法创建会话 — ${(err as Error).message}`, + content: t('agent.error.sessionCreateFailed', { message: (err as Error).message }), timestamp: Date.now(), }); // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) @@ -540,6 +540,7 @@ export const useAgentStore = create((set, get) => ({ timestamp: Date.now(), attachments: attachments && attachments.length > 0 ? attachments : undefined, }; + const optimisticMessageId = userMessage.id; set((s) => ({ messages: [...s.messages, userMessage], @@ -668,7 +669,7 @@ export const useAgentStore = create((set, get) => ({ get().addMessage({ id: genMsgId('error'), role: 'system', - content: `错误: 消息发送失败 — ${(err as Error).message}`, + content: t('agent.error.sendFailed', { message: (err as Error).message }), timestamp: Date.now(), }); // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) @@ -679,6 +680,27 @@ export const useAgentStore = create((set, get) => ({ .catch(() => {}); }, ); + } else { + // v0.8.2 P0-4: 兜底根治 —— 此前 `if (sessionId && window.metona?.agent?.sendMessage)` + // 无 else 分支:preload 桥缺失(contextBridge 失败降级/桥损坏)或会话未就绪时, + // UI 已置 isStreaming=true 却永远没有收尾,输入框与状态栏永久卡在流式态 + // (Sidebar 的 M-9 已有等效处理,agent 通道此前没有)。回滚乐观状态并明确报错。 + if (sessionId) get().updateSessionRunState(sessionId, null); + set((s) => ({ + messages: s.messages.filter((m) => m.id !== optimisticMessageId), + agentStatus: 'error', + isStreaming: false, + currentRunId: null, + })); + get().addMessage({ + id: genMsgId('error'), + role: 'system', + content: t('agent.error.bridgeMissing'), + timestamp: Date.now(), + }); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(t('agent.error.bridgeMissing'))) + .catch(() => {}); } }, diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 4fda181..178b3d6 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -24,6 +24,12 @@ interface UIState { // 弹窗 settingsOpen: boolean; onboardingCompleted: boolean; + /** + * v0.8.2 P3-4: 引导向导的"配置就绪"门禁态。 + * onboarding.completed 经 IPC 异步读取 —— 此前初始 false 直接渲染向导, + * 老用户每次启动都闪现向导首帧。加载完成(或失败)后才允许向导出现。 + */ + onboardingGateReady: boolean; // v0.3.0: 面板可见性 sidebarVisible: boolean; @@ -47,6 +53,7 @@ interface UIState { openSettings: () => void; closeSettings: () => void; setOnboardingCompleted: (completed: boolean) => void; + setOnboardingGateReady: (ready: boolean) => void; // v0.3.0: 面板控制 Actions setDetailTab: (tab: DetailTab) => void; @@ -62,6 +69,7 @@ export const useUIStore = create((set) => ({ resolvedTheme: 'dark', settingsOpen: false, onboardingCompleted: false, + onboardingGateReady: false, // v0.3.0: 面板默认可见 sidebarVisible: true, @@ -78,37 +86,53 @@ export const useUIStore = create((set) => ({ openSettings: () => set({ settingsOpen: true }), closeSettings: () => set({ settingsOpen: false }), setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }), + setOnboardingGateReady: (ready) => set({ onboardingGateReady: ready }), // v0.3.0: 面板控制 setDetailTab: (tab) => set({ detailTab: tab }), // 退出专注模式时恢复快照(若用户在专注模式下手动切换面板,视为退出专注模式) // v0.3.0 修复: 退出专注模式时同时恢复另一个面板的快照,与 toggleFocusMode 关闭逻辑一致 - toggleSidebar: () => set((s) => s.focusMode - ? { sidebarVisible: !s.sidebarVisible, detailVisible: s.preFocusDetailVisible, focusMode: false } - : { sidebarVisible: !s.sidebarVisible }), - toggleDetail: () => set((s) => s.focusMode - ? { detailVisible: !s.detailVisible, sidebarVisible: s.preFocusSidebarVisible, focusMode: false } - : { detailVisible: !s.detailVisible }), + toggleSidebar: () => + set((s) => + s.focusMode + ? { + sidebarVisible: !s.sidebarVisible, + detailVisible: s.preFocusDetailVisible, + focusMode: false, + } + : { sidebarVisible: !s.sidebarVisible }, + ), + toggleDetail: () => + set((s) => + s.focusMode + ? { + detailVisible: !s.detailVisible, + sidebarVisible: s.preFocusSidebarVisible, + focusMode: false, + } + : { detailVisible: !s.detailVisible }, + ), // v0.3.0 修复:专注模式开启时保存当前面板状态快照,关闭时恢复快照 // 避免关闭时强制设为 true 覆盖用户进入专注模式前的原有状态 - toggleFocusMode: () => set((s) => { - if (!s.focusMode) { - // 开启专注模式:保存快照,隐藏面板 + toggleFocusMode: () => + set((s) => { + if (!s.focusMode) { + // 开启专注模式:保存快照,隐藏面板 + return { + focusMode: true, + preFocusSidebarVisible: s.sidebarVisible, + preFocusDetailVisible: s.detailVisible, + sidebarVisible: false, + detailVisible: false, + }; + } + // 关闭专注模式:恢复快照 return { - focusMode: true, - preFocusSidebarVisible: s.sidebarVisible, - preFocusDetailVisible: s.detailVisible, - sidebarVisible: false, - detailVisible: false, + focusMode: false, + sidebarVisible: s.preFocusSidebarVisible, + detailVisible: s.preFocusDetailVisible, }; - } - // 关闭专注模式:恢复快照 - return { - focusMode: false, - sidebarVisible: s.preFocusSidebarVisible, - detailVisible: s.preFocusDetailVisible, - }; - }), + }), // v0.3.6: 清理记忆后自增版本号,触发 MemoryViewer 重新加载 bumpMemoryVersion: () => set((s) => ({ memoryVersion: s.memoryVersion + 1 })), })); diff --git a/src/test/setup.ts b/src/test/setup.ts index 494dbd8..76dfed7 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -54,6 +54,8 @@ function createMetonaBridgeMock(): Record { saveTrace: resolve({ success: true }), getTrace: resolve(null), updateMessageContent: resolve({ success: true }), + // v0.8.2 P2-4: 会话元数据实时刷新 + onSessionUpdated: () => noop, }, mcp: { listServers: resolve([]), @@ -99,9 +101,14 @@ function createMetonaBridgeMock(): Record { getAppDataPath: resolve(''), openExternal: resolve(), showItemInFolder: resolve(), - selectFolder: resolve(''), + selectFolder: resolve({ canceled: false, path: '' }), restart: resolve(), updateCheck: resolve({ status: 'disabled', message: 'disabled' }), + updateInstall: resolve({ success: true }), + updateInstallNow: resolve({ success: true }), + onUpdateStatus: () => noop, + setLoginItem: resolve({ success: true, data: { openAtLogin: false } }), + getLoginItem: resolve({ success: true, data: { openAtLogin: false } }), reportError: noop, getHealthSnapshot: resolve({ success: true, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 5602fc4..5f2ebf7 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -211,6 +211,15 @@ interface MetonaSessionsAPI { data: { traceSteps: unknown[]; tokenUsage: unknown }, ) => Promise<{ success: boolean }>; getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>; + /** v0.8.2 P2-4: 会话元数据实时刷新广播(title/messageCount/updatedAt) */ + onSessionUpdated: ( + callback: (data: { + sessionId: string; + title?: string; + messageCount?: number; + updatedAt?: number; + }) => void, + ) => () => void; } // ===== MCP API ===== @@ -375,7 +384,10 @@ interface MetonaAppAPI { | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } >; // ===== v0.8.0 P2-3: electron-updater 自动更新 ===== + /** v0.8.2 P1-3: 仅下载更新包(完成后广播 downloaded,不自动重启) */ updateInstall: () => Promise<{ success: boolean; error?: string }>; + /** v0.8.2 P1-3: 安装已下载的更新并重启(用户确认后调用) */ + updateInstallNow: () => Promise<{ success: boolean; error?: string }>; onUpdateStatus: ( callback: (event: { status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';