feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+15 -15
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.8.1-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.8.2-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" /> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -54,19 +54,19 @@
--- ---
## 🆕 v0.8.1 更新亮点 ## 🆕 v0.8.2 更新亮点
| 特性 | 说明 | | 特性 | 说明 |
|:---|:---| |:---|:---|
| ⚙️ **上下文/输出上限 全局单一配置** | 硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息钳制)——唯一合法来源是设置面板「上下文长度」与「最大输出上限」,跨 Provider/模型原样透传 | | 🛡 **适配器图片下载 SSRF 收口** | Anthropic/Ollama 的图片 URL 下载统一走 DNS Pinning 安全通道(逐跳重定向复检 + 10MB 上限 + 类型白名单),根治"诱导模型回读内网数据"的可回读外泄通道 |
| 🧠 **本地向量混合检索** | 激活 Ollama embeddings:记忆检索升级为 0.6×向量余弦 + 0.4×TF-IDF 混合评分,同义改写可召回;存量记忆惰性回填,嵌入不可用自动回退 TF-IDF | | 🔐 **根 MEMORY.md 保护根治** | 闸门从工具名单枚举反转为"任意工具的路径形参数统一精确匹配"—— delete_file / file_move / MCP 文件类工具全部纳入保护 |
| 📝 **MEMORY.md 维护闭环** | 固化去重消除全文截断盲区;两阶段维护(AI 建议 → 用户勾选 → 原子改写 + 语义记忆双轨同步);>50KB 体积告警 | | 🔌 **Anthropic 协议保真** | extended thinking 块(含签名)在工具循环多轮回传;pause_turn 长回复自动续传(预算耗尽按截断语义收尾,不再静默丢失) |
| 📊 **成本/缓存可观测闭环** | Prompt Cache 命中率与估算成本(可选单价配置)进 Token 面板;输入框新增上下文占用指示条(60%/80% 变色) | | 📦 **上下文压缩正确性** | 压缩时机移至本轮消息入列后、估算纳入 System Prompt、摘要调用重试 + 纯截断兜底、故障转移后预算校正值重置 |
| 🔌 **MCP Prompts/Resources 对话可用** | `/mcp:{server}:{prompt}` 斜杠菜单填充输入框;`@mcp:{server}:{uri}` 资源注入附件管线(512KB 上限、二进制拒绝 | | 💾 **全局配置原子写 + 自愈** | global-config.json 改为 tmp+rename 原子落盘并维护备份 —— 崩溃不再丢失全部全局配置(含 LLM 凭据 |
| 🛡 **工具自定义策略** | 每个工具可配置拒绝/允许参数正则、频率上限、强制确认(设置 → 工具管理 → 策略),保存即热生效 | | 🧠 **向量记忆召回修复** | 向量检索与重要度预过滤解耦(独立召回 200 条池)、embedding 模型指纹(更换模型后惰性重算自愈)、语义记忆 access_count 更新后不再归零 |
| **批量确认 & 游标分页 & 自启** | 连续 ≥3 同类工具确认自动聚合为单弹框;会话消息游标分页(首屏 200 条向上翻页);开机自启开关 | | 🎨 **工具结果富媒体渲染** | view_image / 浏览器截图在聊天流内联预览;diff_viewer / file_editor dry_run 渲染为增删着色的 unified diff 视图 |
| 💾 **记忆生命周期接线** | 会话终态联动清理工作记忆;情节记忆 90 天 TTL 真实写入;语义记忆 access_count 检索回写(LRU 激活) | | ⌨️ **交互断链修复** | 引用回复 / Ctrl+L 聚焦输入框(受控输入桥根治)、IPC 桥缺失兜底、根级 ErrorBoundary 逐区隔离、/export 全量导出、会话列表实时刷新、Onboarding 首帧闪烁消除 |
| 🧪 **E2E 冒烟** | Playwright + Electron 端到端链路(本地 mock LLM,零外联、数据隔离);附带根治权限加固启动时序、回环代理放行、safeStorage 降级三处环境级缺陷 | | 🌐 **i18n 与行为对齐** | 托盘菜单/系统对话框/剩余 8 处文案出层;MiMo 思考未配置默认关闭(尊重用户意图)、MiMo 联网搜索引用回填正文、OpenAI 推理模型 stop 参数前置拦截 |
--- ---
@@ -262,7 +262,7 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎
| 🖼️ **多轮图片记忆** | 历史轮次的图片随上下文回传 LLM(最近 10 张,从最新向前收集);多模态总开关 `llm.multimodalEnabled` 控制上传入口 | | 🖼️ **多轮图片记忆** | 历史轮次的图片随上下文回传 LLM(最近 10 张,从最新向前收集);多模态总开关 `llm.multimodalEnabled` 控制上传入口 |
| 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s | | 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s |
| ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) | | ⏱️ **可配置迭代** | 最大迭代次数 (默认 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 禁自动下载) | | `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 + 需确认) | | `run_tests` | MEDIUM | 是 | 运行测试套件 (jest/vitest/mocha)filter 白名单防注入(v0.7.4: npm test 执行 scripts.test 任意命令,升 MEDIUM + 需确认) |
| `project_info` | SAFE | 否 | 项目结构分析,4 种 detail 级别 | | `project_info` | SAFE | 否 | 项目结构分析,4 种 detail 级别 |
| `delegate_task` | MEDIUM | 否 | 子任务委派给独立 SubAgent,最大深度 3 层 | | `delegate_task` | MEDIUM | 否 | 子任务委派给独立 SubAgent(每会话最多 3 个并发,无递归嵌套) |
#### 🔀 Git (4 tools) #### 🔀 Git (4 tools)
@@ -479,7 +479,7 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰:
│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │ │ ┌─────────────────────┐ ┌──────────────────────────────────┐ │
│ │ AgentLoopEngine │ │ TaskOrchestrator │ │ │ │ AgentLoopEngine │ │ TaskOrchestrator │ │
│ │ ReAct 八状态机 │ │ 子任务分解 · 并行执行 · 结果聚合 │ │ │ │ ReAct 八状态机 │ │ 子任务分解 · 并行执行 · 结果聚合 │ │
│ │ 流式解析 · 死循环检测 │ │ SubAgent 最大深度 3 │ │ │ │ 流式解析 · 死循环检测 │ │ SubAgent 并发上限 3 │ │
│ └─────────────────────┘ └──────────────────────────────────┘ │ │ └─────────────────────┘ └──────────────────────────────────┘ │
│ 类型: MetonaRequest / MetonaResponse / MetonaStreamEvent │ │ 类型: MetonaRequest / MetonaResponse / MetonaStreamEvent │
├──────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────┤
@@ -770,7 +770,7 @@ MetonaAI-Desktop/
│ │ │ ├── 📄 manager.ts # MemoryManager (TF-IDF, CJK 分词) │ │ │ ├── 📄 manager.ts # MemoryManager (TF-IDF, CJK 分词)
│ │ │ └── 📄 consolidator.ts # MemoryConsolidator (LLM 提取) │ │ │ └── 📄 consolidator.ts # MemoryConsolidator (LLM 提取)
│ │ ├── 📂 orchestration/ # 编排 │ │ ├── 📂 orchestration/ # 编排
│ │ │ └── 📄 orchestrator.ts # TaskOrchestrator (深度限制 3) │ │ │ └── 📄 orchestrator.ts # TaskOrchestrator (并发委派上限 3)
│ │ ├── 📂 prompts/ # 提示词构建 │ │ ├── 📂 prompts/ # 提示词构建
│ │ │ └── 📄 context-builder.ts # ContextBuilder (SOUL + MEMORY) │ │ │ └── 📄 context-builder.ts # ContextBuilder (SOUL + MEMORY)
│ │ ├── 📂 hooks/ # 钩子 │ │ ├── 📂 hooks/ # 钩子
+70
View File
@@ -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<void>;
}
export async function launchApp(): Promise<AppHarness> {
// 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<string, string>,
});
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
const cleanup = async (): Promise<void> => {
await electronApp.close();
await mock.close();
// 调试期保留 userDatamain.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 };
}
+63
View File
@@ -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 });
});
+39 -55
View File
@@ -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): * 端到端链路(Playwright + Electron):
* 启动应用(隔离 userData)→ 跳过引导(种子配置)→ 新建会话 → 输入消息 → * 启动应用(隔离 userData)→ 跳过引导(种子配置)→ 新建会话 → 输入消息 →
* 发送 → 引擎调用本地 mock LLM(SSE 流式)→ 断言回复渲染 + mock 收到合法 * 发送 → 引擎调用本地 mock LLM(SSE 流式)→ 断言回复渲染 + mock 收到合法
* 请求体(携带「最大输出上限」设置值)→ 中断按钮恢复 idle * 请求体(携带「最大输出上限」设置值)+ 多轮工具调用闭环
*
* 中断链路见 interrupt.spec.ts(独立应用实例,杜绝跨用例运行态竞态)。
* *
* 契约:不触外网(LLM 指向 127.0.0.1 随机端口 mock)、不污染真实用户数据 * 契约:不触外网(LLM 指向 127.0.0.1 随机端口 mock)、不污染真实用户数据
* METONA_USER_DATA_DIR 重定向)、断言的配置值即设置面板合法配置项。 * METONA_USER_DATA_DIR 重定向)、断言的配置值即设置面板合法配置项。
*/ */
import { expect, test, type ElectronApplication, type Page } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { _electron as electron } from 'playwright'; import { launchApp, type AppHarness } from './app-harness';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; import { MOCK_REPLY, TOOL_REPLY } from './mock-llm';
import { tmpdir } from 'os';
import { join } from 'path';
import { startMockLLM, MOCK_REPLY, type MockLLMHandle } from './mock-llm';
let electronApp: ElectronApplication; let harness: AppHarness;
let page: Page;
let mock: MockLLMHandle;
let userDataDir: string;
let workspaceDir: string;
test.beforeAll(async () => { test.beforeAll(async () => {
// 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1 harness = await launchApp();
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<string, string>,
});
page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
}); });
test.afterAll(async () => { test.afterAll(async () => {
await electronApp?.close(); await harness?.cleanup();
await mock?.close();
// 调试期保留 userDatamain.log 排查用);稳定后恢复清理
if (!process.env['METONA_E2E_KEEP_USER_DATA']) {
try {
rmSync(userDataDir, { recursive: true, force: true });
} catch {
/* Windows 文件句柄释放延迟 — 清理失败不影响断言 */
}
}
}); });
test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () => { test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () => {
// 等待聊天输入框可用(配置加载 + 工具就绪后启用) // 等待聊天输入框可用(配置加载 + 工具就绪后启用)
// InputBase 把 data-chat-input 挂在根 div —— 实际可编辑元素是内部 textarea // InputBase 把 data-chat-input 挂在根 div —— 实际可编辑元素是内部 textarea
//MUI 另渲染一个 readonly 隐藏 textarea 用于测量,需用 .first() 取可交互的) //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 }); await expect(input).toBeVisible({ timeout: 30_000 });
// 发送一条用户消息 // 发送一条用户消息
@@ -80,18 +39,43 @@ test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async ()
await input.press('Enter'); await input.press('Enter');
// mock LLM 的流式回复出现在聊天区(引擎 → SSE → 渲染层全链路) // 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 () => { test('冒烟:引擎请求体携带设置面板「最大输出上限」配置(2048)', async () => {
// beforeAll 后发送的第一条消息已在 mock.requests 中(顺序执行时上一测试已完成) // 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); expect(chatRequests.length).toBeGreaterThanOrEqual(1);
// llm.maxTokens 原样透传(无任何按模型钳制) // llm.maxTokens 原样透传(无任何按模型钳制)
expect(chatRequests[0].max_tokens).toBe(2048); expect(chatRequests[0].max_tokens).toBe(2048);
// 请求发往本地 mock(经 adapter 组装),模型名来自种子配置 // 请求发往本地 mock(经 adapter 组装),模型名来自种子配置
expect(chatRequests[0].model).toBe('deepseek-v4-flash'); 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();
});
+107 -7
View File
@@ -6,6 +6,12 @@
* finish_reason=stop + usage + [DONE] * finish_reason=stop + usage + [DONE]
* - POST /chat/completionsstream=false):非流式 JSON(压缩摘要等内部调用兜底)。 * - POST /chat/completionsstream=false):非流式 JSON(压缩摘要等内部调用兜底)。
* *
* v0.8.2 P3-3: 新增两条可控脚本(内容标记协议,零外部依赖):
* - `__E2E_TOOL_CALL__`:返回 tool_callsthink 工具);后续请求带 tool 消息时
* 返回最终文本 —— 锁定"多轮工具调用 → 观察 → 最终回答"全链路;
* - `__E2E_HANG__`:推送首帧后挂起连接(不结束、不推 finish_reason)——
* 供中断链路测试:客户端 abort 时服务端感知连接关闭。
*
* 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。 * 端口随机分配(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 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 { export interface MockLLMHandle {
url: string; url: string;
close: () => Promise<void>; close: () => Promise<void>;
@@ -22,6 +34,10 @@ export interface MockLLMHandle {
readonly requests: Array<Record<string, unknown>>; readonly requests: Array<Record<string, unknown>>;
} }
function frame(res: import('http').ServerResponse, payload: Record<string, unknown>): void {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
export function startMockLLM(): Promise<MockLLMHandle> { export function startMockLLM(): Promise<MockLLMHandle> {
const requests: Array<Record<string, unknown>> = []; const requests: Array<Record<string, unknown>> = [];
const server: Server = createServer((req, res) => { const server: Server = createServer((req, res) => {
@@ -43,6 +59,17 @@ export function startMockLLM(): Promise<MockLLMHandle> {
requests.push(parsed); requests.push(parsed);
const isStream = parsed.stream === true; const isStream = parsed.stream === true;
const messages = (parsed.messages as Array<Record<string, unknown>>) ?? [];
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) { if (!isStream) {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end( res.end(
@@ -67,17 +94,90 @@ export function startMockLLM(): Promise<MockLLMHandle> {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
Connection: 'keep-alive', Connection: 'keep-alive',
}); });
const frame = (payload: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(payload)}\n\n`); // ===== 中断脚本:首帧后挂起,客户端 abort 时感知连接关闭 =====
}; if (wantsHang) {
// 首 chunk:正文增量 frame(res, {
frame({ 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', id: 'mock-1',
model: parsed.model ?? 'mock', model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }], choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }],
}); });
// 末帧:finish_reason + usage frame(res, {
frame({
id: 'mock-1', id: 'mock-1',
model: parsed.model ?? 'mock', model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
@@ -15,6 +15,7 @@ vi.mock('electron-log', () => ({
})); }));
import { AnthropicAdapter } from '../anthropic.adapter'; import { AnthropicAdapter } from '../anthropic.adapter';
import { __imageFetcher } from '../shared/ssrf-image-fetch';
import { ContentFilterError } from '../base-adapter'; import { ContentFilterError } from '../base-adapter';
import type { MetonaRequest, MetonaStreamEvent } from '../../types'; import type { MetonaRequest, MetonaStreamEvent } from '../../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../../types';
@@ -235,17 +236,60 @@ describe('AnthropicAdapter — 图片块转换', () => {
it('http URL 图片 → 下载后转 base64content-type 作为 media_type', async () => { it('http URL 图片 → 下载后转 base64content-type 作为 media_type', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
const imageBytes = new TextEncoder().encode('PNG-DATA'); const imageBytes = new TextEncoder().encode('PNG-DATA');
// 第一个 fetch(图片下载)返回二进制;第二个 fetch(chat)返回 ok // v0.8.2 P0-1: 图片下载改走 SSRF 安全通道(独立下载器注入点,不走 global fetch
mockFetch const fetchRestore = __imageFetcher.current;
.mockResolvedValueOnce({ __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<Record<string, unknown>> }>)[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, ok: true,
status: 200, status: 200,
headers: { get: () => 'image/jpeg' }, json: async () => ({
arrayBuffer: async () => imageBytes.buffer, content: [{ type: 'text', text: 'ok' }],
} as unknown as Response) usage: {},
.mockResolvedValue(okResponse()); stop_reason: 'end_turn',
await adapter.send( }),
makeRequest({ } as unknown as Response);
const request = makeRequest({
messages: [ messages: [
{ {
role: 'user', role: 'user',
@@ -254,42 +298,12 @@ describe('AnthropicAdapter — 图片块转换', () => {
timestamp: Date.now(), timestamp: Date.now(),
}, },
], ],
}), });
); const res = await adapter.send(request);
const body = lastBody(); expect(res.content).toBe('ok'); // 请求未被阻断
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0]; } finally {
const imageBlock = userMsg.content.find((c) => c.type === 'image'); __imageFetcher.current = fetchRestore;
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'); // 请求未被阻断
}); });
it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => { it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => {
@@ -970,3 +984,281 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });
// ===== v0.8.2 P1-1: thinking 块回传 + pause_turn 续传 =====
describe('AnthropicAdapter — v0.8.2 P1-1', () => {
function okJson(data: Record<string, unknown>): Response {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function sse(lines: string[]): Response {
const body = new ReadableStream<Uint8Array>({
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<Record<string, unknown>> }>
).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<Record<string, unknown>> }>
).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<Record<string, unknown>> }>;
};
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');
});
});
@@ -16,6 +16,9 @@ vi.mock('electron-log', () => ({
})); }));
import { OllamaAdapter } from '../ollama.adapter'; 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 type { MetonaRequest } from '../../types';
import { MetonaStreamEventType } from '../../types'; import { MetonaStreamEventType } from '../../types';
@@ -604,48 +607,61 @@ describe('OllamaAdapter — 图片归一化', () => {
it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => { it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
const imageBytes = new TextEncoder().encode('IMG-BYTES'); const imageBytes = new TextEncoder().encode('IMG-BYTES');
mockFetch // v0.8.2 P0-1: 下载经 SSRF 安全通道(注入受控下载器)
.mockResolvedValueOnce({ const restore = __imageFetcher.current;
ok: true, __imageFetcher.current = (async () =>
new Response(imageBytes, {
status: 200, status: 200,
arrayBuffer: async () => imageBytes.buffer, headers: { 'content-type': 'image/png' },
} as unknown as Response) })) as unknown as ImageFetcher;
.mockResolvedValue(okChatResponse()); try {
await adapter.send( mockFetch.mockResolvedValue(okChatResponse());
makeRequest({ await adapter.send(
messages: [ makeRequest({
{ messages: [
role: 'user', {
content: '看图', role: 'user',
images: [{ url: 'https://example.com/pic.png' }], content: '看图',
timestamp: Date.now(), images: [{ url: 'https://example.com/pic.png' }],
}, timestamp: Date.now(),
], },
}), ],
); }),
const body = lastBody(); );
const userMsg = (body.messages as Array<Record<string, unknown>>).find( const body = lastBody();
(m) => m.role === 'user', const userMsg = (body.messages as Array<Record<string, unknown>>).find(
); (m) => m.role === 'user',
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]); );
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]);
} finally {
__imageFetcher.current = restore;
}
}); });
it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => { it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(okChatResponse()); const restore = __imageFetcher.current;
const res = await adapter.send( __imageFetcher.current = (async () => {
makeRequest({ throw new Error('ECONNREFUSED');
messages: [ }) as unknown as ImageFetcher;
{ try {
role: 'user', mockFetch.mockResolvedValue(okChatResponse());
content: '看图', const res = await adapter.send(
images: [{ url: 'https://example.com/pic.png' }], makeRequest({
timestamp: Date.now(), messages: [
}, {
], role: 'user',
}), content: '看图',
); images: [{ url: 'https://example.com/pic.png' }],
expect(res.content).toBe('ok'); timestamp: Date.now(),
},
],
}),
);
expect(res.content).toBe('ok');
} finally {
__imageFetcher.current = restore;
}
}); });
it('data URI 图片剥前缀;纯 base64 原样保留', async () => { it('data URI 图片剥前缀;纯 base64 原样保留', async () => {
@@ -675,26 +691,31 @@ describe('OllamaAdapter — 图片归一化', () => {
it('HTTP 下载响应非 2xx → 图片降级忽略', async () => { it('HTTP 下载响应非 2xx → 图片降级忽略', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
mockFetch const restore = __imageFetcher.current;
.mockResolvedValueOnce({ ok: false, status: 404 } as unknown as Response) __imageFetcher.current = (async () =>
.mockResolvedValue(okChatResponse()); new Response(null, { status: 404 })) as unknown as ImageFetcher;
await adapter.send( try {
makeRequest({ mockFetch.mockResolvedValue(okChatResponse());
messages: [ await adapter.send(
{ makeRequest({
role: 'user', messages: [
content: '看图', {
images: [{ url: 'https://example.com/missing.png' }], role: 'user',
timestamp: Date.now(), content: '看图',
}, images: [{ url: 'https://example.com/missing.png' }],
], timestamp: Date.now(),
}), },
); ],
const body = lastBody(); }),
const userMsg = (body.messages as Array<Record<string, unknown>>).find( );
(m) => m.role === 'user', const body = lastBody();
); const userMsg = (body.messages as Array<Record<string, unknown>>).find(
expect(userMsg!.images).toEqual([]); (m) => m.role === 'user',
);
expect(userMsg!.images).toEqual([]);
} finally {
__imageFetcher.current = restore;
}
}); });
}); });
@@ -901,15 +901,17 @@ describe('MimoAdapter — thinking 显式开关', () => {
expect(bodies[0].top_p).toBe(0.8); expect(bodies[0].top_p).toBe(0.8);
}); });
it('thinkingEnabled 未配置 → 默认 {type:enabled} 且不传 temperature/top_pAPI 强制覆盖)', 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 adapter = makeAdapter();
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await adapter.send( await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }), makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }),
); );
expect(bodies[0].thinking).toEqual({ type: 'enabled' }); expect(bodies[0].thinking).toEqual({ type: 'disabled' });
expect(bodies[0].temperature).toBeUndefined(); expect(bodies[0].temperature).toBe(0.7);
expect(bodies[0].top_p).toBeUndefined(); expect(bodies[0].top_p).toBe(0.8);
}); });
it('max_completion_tokens 原样透传(v0.8.1pro/standard 均不钳制)', async () => { it('max_completion_tokens 原样透传(v0.8.1pro/standard 均不钳制)', async () => {
@@ -710,3 +710,51 @@ describe('parseOpenAICompatibleResponse — 补充形态', () => {
expect(result.toolCalls![0].id).toBeUndefined(); 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');
});
});
@@ -155,6 +155,18 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
expect(body.temperature).toBe(0); expect(body.temperature).toBe(0);
}); });
it('MiMo: 未配置(undefined)→ 显式 disabledv0.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 () => { it('Ollama: 探测不支持思考(服务端硬约束)→ 不发 think 参数(唯一保留的门控)', async () => {
const adapter = new OllamaAdapter({ const adapter = new OllamaAdapter({
provider: 'ollama', provider: 'ollama',
+419 -238
View File
@@ -20,13 +20,31 @@ import { BaseAdapter, ContentFilterError } from './base-adapter';
import { truncatedArgumentsPayload, readStreamChunkWithIdleTimeout } from './shared/sse-stream'; import { truncatedArgumentsPayload, readStreamChunkWithIdleTimeout } from './shared/sse-stream';
import log from 'electron-log'; import log from 'electron-log';
import { nanoid } from 'nanoid'; 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 { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; 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 * 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 { function mapAnthropicStopReason(reason: string): string {
switch (reason) { switch (reason) {
@@ -37,8 +55,10 @@ function mapAnthropicStopReason(reason: string): string {
case 'refusal': case 'refusal':
case 'content_filter': case 'content_filter':
return 'content_filter'; return 'content_filter';
case 'end_turn':
case 'pause_turn': case 'pause_turn':
// 续传预算耗尽的兜底语义:按输出截断处理(不可静默当自然结束)
return 'length';
case 'end_turn':
case 'stop_sequence': case 'stop_sequence':
return 'stop'; return 'stop';
default: default:
@@ -90,64 +110,59 @@ export class AnthropicAdapter extends BaseAdapter {
// ===== POST /v1/messages(非流式) ===== // ===== POST /v1/messages(非流式) =====
async send(request: MetonaRequest): Promise<MetonaResponse> { async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = await this.toNativeRequest(request, false); // v0.8.2 P1-1: pause_turn 续传循环(与流式路径同语义)—— 本段 content 原样
const response = await this.fetchWithTimeout( // 作为 assistant 消息追加后重发,直到自然结束或续传预算耗尽
`${this.config.baseURL}/v1/messages`, let body = await this.toNativeRequest(request, false);
{ for (let continuation = 0; ; continuation++) {
method: 'POST', const response = await this.fetchWithTimeout(
headers: this.buildHeaders(), `${this.config.baseURL}/v1/messages`,
body: JSON.stringify(body), {
}, method: 'POST',
this.config.timeoutMs ?? 120_000, headers: this.buildHeaders(),
); body: JSON.stringify(body),
},
this.config.timeoutMs ?? 120_000,
);
if (!response.ok) { if (!response.ok) {
await this.throwHttpError(response, 'Anthropic API error'); await this.throwHttpError(response, 'Anthropic API error');
}
const data = (await response.json()) as Record<string, unknown>;
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<Record<string, unknown>>) ?? []),
{ role: 'assistant', content: (data.content as Array<unknown>) ?? [] },
],
};
continue;
}
return this.toMetonaResponse(data, request.meta.requestId);
} }
const data = (await response.json()) as Record<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId);
} }
// ===== POST /v1/messages(流式) ===== // ===== 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<MetonaStreamEvent> { async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = await this.toNativeRequest(request, true); let body = await this.toNativeRequest(request, true);
const response = await this.fetchWithTimeout( const collectedThinkingBlocks: MetonaThinkingBlock[] = [];
`${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 seq = 0; 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<number, { id: string; name: string; argsBuffer: string }>();
// v0.6.4 竞态修复: message_start 捕获的 input_tokens 改为本次调用的局部闭包变量。
// 原实现放在实例字段(this.lastInputTokens)—— fallback adapter 是跨引擎共享的
// 单例(agent-engine-manager 把同一实例注入所有引擎),故障转移后多个并发会话
// 共用该 Anthropic 实例时 input_tokens 会互相串号。局部化后天然隔离。
let messageStartInputTokens = 0;
const base = () => ({ const base = () => ({
requestId: request.meta.requestId, requestId: request.meta.requestId,
sessionId: request.meta.sessionId, sessionId: request.meta.sessionId,
@@ -187,65 +202,285 @@ export class AnthropicAdapter extends BaseAdapter {
} }
}; };
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => { // ===== pause_turn 续传主循环 =====
const events: MetonaStreamEvent[] = []; for (let continuation = 0; continuation <= MAX_PAUSE_CONTINUATIONS; continuation++) {
switch (name) { const response = await this.fetchWithTimeout(
case 'content_block_start': { `${this.config.baseURL}/v1/messages`,
const block = data.content_block as Record<string, unknown> | undefined; {
const index = (data.index as number) ?? 0; method: 'POST',
if (block?.type === 'tool_use') { headers: this.buildHeaders(),
toolBlocks.set(index, { body: JSON.stringify(body),
id: (block.id as string) ?? `tc_${nanoid(8)}`, },
name: (block.name as string) ?? '', this.config.timeoutMs ?? 300_000,
argsBuffer: '', );
});
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<number, { id: string; name: string; argsBuffer: string }>();
/** 本段原始 content 块(pause_turn 续传需按协议原样回传) */
const rawBlocks: Array<Record<string, unknown> | null> = [];
/** thinking 块签名(content_block_delta.signature_delta 累积) */
const thinkingSignatures = new Map<number, string>();
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
const events: MetonaStreamEvent[] = [];
switch (name) {
case 'content_block_start': {
const block = data.content_block as Record<string, unknown> | 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<string, unknown>;
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<string, unknown> | undefined;
case 'content_block_delta': { const index = (data.index as number) ?? 0;
const delta = data.delta as Record<string, unknown> | undefined; if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
const index = (data.index as number) ?? 0; const rb = rawBlocks[index];
if (delta?.type === 'text_delta' && typeof delta.text === 'string') { if (rb?.type === 'text') rb.text = ((rb.text as string) ?? '') + delta.text;
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text }); events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
events.push({ const rb = rawBlocks[index];
type: MetonaStreamEventType.REASONING_DELTA, if (rb?.type === 'thinking')
...base(), rb.thinking = ((rb.thinking as string) ?? '') + delta.thinking;
delta: delta.thinking, events.push({
}); type: MetonaStreamEventType.REASONING_DELTA,
} else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') { ...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); const block = toolBlocks.get(index);
if (block) { if (block) {
block.argsBuffer += delta.partial_json; let args: Record<string, unknown> = {};
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({ events.push({
type: MetonaStreamEventType.TOOL_CALL_DELTA, type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(), ...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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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': { return events;
const index = (data.index as number) ?? 0; };
const block = toolBlocks.get(index);
if (block) { // ===== 单段响应的 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<string, unknown>;
// message_start 携带 input_tokens
if (eventName === 'message_start') {
const msg = data.message as Record<string, unknown> | undefined;
const usage = msg?.usage as Record<string, unknown> | 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<string, unknown> = {}; let args: Record<string, unknown> = {};
try { try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch (err) { } catch (err) {
// v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致 args = truncatedArgumentsPayload(
// JSON 半截)—— 原实现静默降级 args={},与 v0.6.3 已修复的 OpenAI 共享层 (err as Error).message,
// 行为完全相同:工具以"缺少必要参数"泛化失败,模型无从得知发生了截断, block.argsBuffer.slice(-120),
// 长文件写入场景直接导致"空回复 → 会话无声终止"。现统一转为
// _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);
} }
events.push({ yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE, type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(), ...base(),
toolCall: { toolCall: {
@@ -255,153 +490,71 @@ export class AnthropicAdapter extends BaseAdapter {
iteration: request.meta.iteration, iteration: request.meta.iteration,
timestamp: Date.now(), timestamp: Date.now(),
}, },
}); };
toolBlocks.delete(index);
} }
break; } else {
} log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)');
case 'message_delta': {
// 结束时的 usage 统计(output_tokens 增量在此事件携带)
const usage = data.usage as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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;
} }
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 汇总) if (rawStopReason === 'pause_turn') {
while (true) { if (continuation < MAX_PAUSE_CONTINUATIONS) {
// v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能 // 协议续传:本段 content 原样(含 pause_turn 块、已完成 thinking/tool_use
// 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道 // 作为 assistant 消息追加后重发。无签名的 thinking 块剔除(回传必 400)。
const { done, value } = await readStreamChunkWithIdleTimeout(reader); const contentForContinuation = rawBlocks.filter((b) => {
if (done) break; if (!b) return false;
if (b.type === 'thinking' && !b.signature) return false;
buffer += decoder.decode(value, { stream: true }); return true;
const lines = buffer.split('\n'); }) as Array<Record<string, unknown>>;
buffer = lines.pop() ?? ''; log.info(
`[Anthropic] pause_turn — continuing stream turn (#${continuation + 1}, ${contentForContinuation.length} block(s) carried over)`,
for (const line of lines) { );
const trimmed = line.trim(); body = {
if (!trimmed) continue; ...body,
if (trimmed.startsWith('event:')) { messages: [
eventName = trimmed.slice(6).trim(); ...((body.messages as Array<Record<string, unknown>>) ?? []),
{ role: 'assistant', content: contentForContinuation },
],
};
continue; continue;
} }
if (!trimmed.startsWith('data:')) continue; // 续传预算耗尽:按输出截断语义收尾(前端展示"输出可能截断",不静默丢失)
const dataStr = trimmed.slice(5).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr) as Record<string, unknown>;
// message_start 携带 input_tokens
if (eventName === 'message_start') {
const msg = data.message as Record<string, unknown> | undefined;
const usage = msg?.usage as Record<string, unknown> | 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( 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) { yield {
let args: Record<string, unknown> = {}; type: MetonaStreamEventType.DONE,
try { ...base(),
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; finishReason: mapAnthropicStopReason('pause_turn'),
} catch (err) { ...(collectedThinkingBlocks.length > 0
args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120)); ? { thinkingBlocks: collectedThinkingBlocks.slice() }
} : {}),
yield { };
type: MetonaStreamEventType.TOOL_CALL_COMPLETE, return;
...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)');
} }
toolBlocks.clear();
// 自然结束:发射最终 DONE(映射原始停止原因 + 思考块)
yield { yield {
type: MetonaStreamEventType.DONE, type: MetonaStreamEventType.DONE,
...base(), ...base(),
// v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因 ...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}),
//(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束) ...(collectedThinkingBlocks.length > 0
...(streamStopReason ? { finishReason: streamStopReason } : {}), ? { thinkingBlocks: collectedThinkingBlocks.slice() }
: {}),
}; };
return;
} }
} }
@@ -480,6 +633,19 @@ export class AnthropicAdapter extends BaseAdapter {
if (m.role === 'assistant') { if (m.role === 'assistant') {
const content: Array<Record<string, unknown>> = []; const content: Array<Record<string, unknown>> = [];
// 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 }); if (m.content) content.push({ type: 'text', text: m.content });
for (const tc of m.toolCalls ?? []) { for (const tc of m.toolCalls ?? []) {
pendingToolUseIds.add(tc.id); 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] } }; return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } };
} }
if (url.startsWith('http://') || url.startsWith('https://')) { if (url.startsWith('http://') || url.startsWith('https://')) {
const res = await this.fetchWithTimeout(url, {}, 30_000); // v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验,
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 可被诱导回读内网数据;现含 DNS pinning/重定向复检/10MB 上限/类型白名单)
const contentType = res.headers.get('content-type') ?? 'image/png'; const { base64, mediaType } = await this.fetchImageAsBase64(url, 30_000);
const buf = Buffer.from(await res.arrayBuffer());
return { return {
type: 'image', type: 'image',
source: { type: 'base64', media_type: contentType, data: buf.toString('base64') }, source: { type: 'base64', media_type: mediaType, data: base64 },
}; };
} }
return null; return null;
@@ -621,6 +786,8 @@ export class AnthropicAdapter extends BaseAdapter {
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? []; const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
let text = ''; let text = '';
let reasoningContent: string | undefined; let reasoningContent: string | undefined;
// v0.8.2 P1-1: 原始思考块收集(非流式路径,供引擎透传实现协议回传)
const thinkingBlocks: MetonaThinkingBlock[] = [];
const toolCalls: MetonaResponse['toolCalls'] = []; const toolCalls: MetonaResponse['toolCalls'] = [];
for (const block of contentBlocks) { for (const block of contentBlocks) {
@@ -631,6 +798,15 @@ export class AnthropicAdapter extends BaseAdapter {
if (thinking) { if (thinking) {
reasoningContent = reasoningContent ? `${reasoningContent}\n\n${thinking}` : 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') { } else if (block.type === 'tool_use') {
let args: Record<string, unknown> = {}; let args: Record<string, unknown> = {};
const rawInput = block.input; const rawInput = block.input;
@@ -649,14 +825,18 @@ export class AnthropicAdapter extends BaseAdapter {
const stopReason = (data.stop_reason as string) ?? 'end_turn'; const stopReason = (data.stop_reason as string) ?? 'end_turn';
// v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把 // v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把
// "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路 // "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路
// v0.8.2 P1-1: pause_turn 映射为 LENGTH(续传预算耗尽的兜底语义,正常路径
// 已在 send() 内被续传循环消费,不会带 pause_turn 到达此处)
const finishReason: MetonaFinishReason = const finishReason: MetonaFinishReason =
stopReason === 'tool_use' stopReason === 'tool_use'
? MetonaFinishReason.TOOL_CALLS ? MetonaFinishReason.TOOL_CALLS
: stopReason === 'max_tokens' : stopReason === 'max_tokens'
? MetonaFinishReason.LENGTH ? MetonaFinishReason.LENGTH
: stopReason === 'refusal' || stopReason === 'content_filter' : stopReason === 'pause_turn'
? MetonaFinishReason.CONTENT_FILTER ? MetonaFinishReason.LENGTH
: MetonaFinishReason.STOP; : stopReason === 'refusal' || stopReason === 'content_filter'
? MetonaFinishReason.CONTENT_FILTER
: MetonaFinishReason.STOP;
return { return {
meta: { meta: {
@@ -668,6 +848,7 @@ export class AnthropicAdapter extends BaseAdapter {
}, },
content: text, content: text,
reasoningContent, reasoningContent,
...(thinkingBlocks.length > 0 ? { thinkingBlocks } : {}),
toolCalls, toolCalls,
usage: { usage: {
inputTokens: usage.input_tokens ?? 0, inputTokens: usage.input_tokens ?? 0,
+27
View File
@@ -25,6 +25,7 @@ import type {
MetonaStreamEvent, MetonaStreamEvent,
} from '../types'; } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; import type { MetonaModelInfo } from '../types/metona-adapter';
import { fetchImageAsBase64 } from './shared/ssrf-image-fetch';
/** /**
* 内容审核错误 — Provider 的安全过滤策略触发的错误 * 内容审核错误 — Provider 的安全过滤策略触发的错误
@@ -88,6 +89,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
this.externalAbortSignal = signal; 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 泄漏 * #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 进入模型上下文(**数据可回读**的外泄通道)。
* 现统一走 fetchImageAsBase64resolvePublicAddresses + 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<boolean> { async healthCheck(): Promise<boolean> {
try { try {
await this.listModels(); await this.listModels();
+11 -5
View File
@@ -100,8 +100,10 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
} }
// v0.6.4 P4-3: MiMo 服务端内置工具透出 —— config.providerOptions.enableWebSearch // v0.6.4 P4-3: MiMo 服务端内置工具透出 —— config.providerOptions.enableWebSearch
// 开启后附加 {type:'web_search'} 服务端搜索工具annotations 引用随响应返回, // 开启后附加 {type:'web_search'} 服务端搜索工具。与客户端 tools 定义互不影响。
// 由上层归并为文本内容展示)。与客户端 tools 定义互不影响。 // v0.8.2 P2-6: 引用注释(annotations)的采集与回填实现在共享层
// sse-stream.ts(流式 [DONE]/断流兜底 + 非流式解析统一回填 Markdown 引用列表),
// 此前注释宣称"由上层归并展示"但全链路无读取方,引用信息丢失。
const providerOptions = this.config.providerOptions as Record<string, unknown> | undefined; const providerOptions = this.config.providerOptions as Record<string, unknown> | undefined;
if (providerOptions?.['enableWebSearch'] === true) { if (providerOptions?.['enableWebSearch'] === true) {
const serverTools = body.tools const serverTools = body.tools
@@ -118,18 +120,22 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
} }
// Thinking 模式(与 DeepSeek 参数结构一致) // Thinking 模式(与 DeepSeek 参数结构一致)
// MiMo API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭
// v0.8.0 修订(用户意图优先): 思考参数完全遵循用户配置,不再按 // v0.8.0 修订(用户意图优先): 思考参数完全遵循用户配置,不再按
// supportsThinking 元信息硬门控 —— 预算耗尽风险由引擎空响应守卫的 // 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) { if (!wantThinking) {
// 显式禁用思考:传 disabled + temperature/top_p(非思考模式下这两个参数有效) // 显式禁用思考:传 disabled + temperature/top_p(非思考模式下这两个参数有效)
body.thinking = { type: 'disabled' }; body.thinking = { type: 'disabled' };
body.temperature = request.params.temperature; body.temperature = request.params.temperature;
body.top_p = request.params.topP; body.top_p = request.params.topP;
} else { } else {
// 启用思考(包括 undefined,因为 MiMo 默认 enabled // 启用思考(仅显式 true —— 未配置已在上分支显式 disabled,见 P2-6 注
// 思考模式下 temperature/top_p 被 API 强制覆盖为 1.0/0.95,不传 // 思考模式下 temperature/top_p 被 API 强制覆盖为 1.0/0.95,不传
body.thinking = { type: 'enabled' }; body.thinking = { type: 'enabled' };
// 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截) // 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截)
+10 -11
View File
@@ -137,7 +137,11 @@ export class OllamaAdapter extends BaseAdapter {
while (true) { while (true) {
// v0.7.4 P1-2: 空闲超时 — 本地模型加载/推理期间服务器可能长时间不推数据, // v0.7.4 P1-2: 空闲超时 — 本地模型加载/推理期间服务器可能长时间不推数据,
// 共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道 // 共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
const { done, value } = await readStreamChunkWithIdleTimeout(reader); const { done, value } = await readStreamChunkWithIdleTimeout(
reader,
60_000,
this.getExternalAbortSignal(),
);
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
@@ -578,16 +582,11 @@ export class OllamaAdapter extends BaseAdapter {
*/ */
private async resolveImageToBase64(url: string): Promise<string> { private async resolveImageToBase64(url: string): Promise<string> {
try { try {
// 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时, // v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验、
// 避免用户中断时图片下载最多阻塞 30s×NexternalAbortSignal 是 BaseAdapter 的 // 无大小上限;现含 DNS pinning/重定向复检/10MB 上限/类型白名单),外部
// private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout // 中断信号由 BaseAdapter.fetchImageAsBase64 透传。
// 该方法同时处理了 listener 泄漏问题) const { base64 } = await this.fetchImageAsBase64(url, 30_000);
const res = await this.fetchWithTimeout(url, {}, 30_000); return base64;
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
} catch (error) { } catch (error) {
log.warn( log.warn(
`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`, `[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`,
+16 -4
View File
@@ -102,8 +102,12 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
stream: boolean, stream: boolean,
): Record<string, unknown> { ): Record<string, unknown> {
// 推理模型检测(o 系列使用新参数名) // 推理模型检测(o 系列使用新参数名)
// v0.8.2 P2-6: 正则补边界 — 旧 /^(o\d|gpt-5)/ 对命名变体存在两处漏判/误判:
// ① `chatgpt-5-*` 系列(gpt-5 非前缀)漏判;② 未来 `o10` 等多位数编号无影响
// 但 `o\d` 会把 `openai/...` 路径形态误判(当前配置层不透传路径,防御性加边界)。
// 现改为带词边界的枚举前缀匹配(o<digits> / gpt-5 / chatgpt-5)。
const model = this.config.defaultModel; 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 // v0.6.4 升级: 原实现抛裸 Error 落入 UNKNOWN 错误码;现在抛 ModelCapabilityError
@@ -165,10 +169,18 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
} }
// 停止序列 // 停止序列
// 已知边界(协议限制,待上游放开后移除此注释):o 系列不支持 stop 参数, // v0.8.2 P2-6 根治: 推理模型不支持 stop 参数 —— 旧实现"仍透传,400 属上游约束"
// 当前仍透传 —— 若推理模型 + stop 组合触发 400 属上游约束而非本层缺陷。 // 的注释性放弃改为前置拦截:推理模型请求丢弃 stop 并告警(与拒图的
// ModelCapabilityError 同思路,但 stop 是可选增强参数,静默丢弃 + 告警优于
// 让整个请求 400 失败)。
if (request.params.stopSequences?.length) { 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; return body;
@@ -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 mockssrfPinnedFetch 的 pinned 路径需要 Agent + fetch
const undiciMock = vi.hoisted(() => {
class FakeAgent {
async close(): Promise<void> {}
}
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 + mediaTypeIP 直连,无需 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-typetext/html)拒绝', async () => {
const original = __imageFetcher.current;
__imageFetcher.current = (async () =>
imageResponse(Buffer.from('<html></html>'), '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;
}
});
});
@@ -102,6 +102,8 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
request.meta.requestId, request.meta.requestId,
request.meta.sessionId, request.meta.sessionId,
request.meta.iteration, request.meta.iteration,
// v0.8.2 P3-3: 流式消费阶段的中断贯通
this.getExternalAbortSignal(),
); );
} }
+151 -3
View File
@@ -53,13 +53,22 @@ export class SseUpstreamError extends Error {
* 引擎 chatStreamWithRetry 的 catch,自动走既有重试/故障转移通道。 * 引擎 chatStreamWithRetry 的 catch,自动走既有重试/故障转移通道。
* SSE / Ollama NDJSON / Anthropic 事件机三处读循环共用,杜绝三份重复实现漂移。 * 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 reader 流的 reader
* @param idleTimeoutMs 空闲超时(默认 60s — 慢速思考模型正常 chunk 间隔可达数十秒) * @param idleTimeoutMs 空闲超时(默认 60s — 慢速思考模型正常 chunk 间隔可达数十秒)
* @param externalSignal 外部中断信号(引擎 abortController;可选)
* @returns { done, value }done=true 表示流正常结束 * @returns { done, value }done=true 表示流正常结束
*/ */
export async function readStreamChunkWithIdleTimeout( export async function readStreamChunkWithIdleTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>, reader: ReadableStreamDefaultReader<Uint8Array>,
idleTimeoutMs = 60_000, idleTimeoutMs = 60_000,
externalSignal?: AbortSignal,
): Promise<{ done: boolean; value: Uint8Array | undefined }> { ): Promise<{ done: boolean; value: Uint8Array | undefined }> {
let idleExpired = false; let idleExpired = false;
let idleTimer: NodeJS.Timeout | undefined; let idleTimer: NodeJS.Timeout | undefined;
@@ -70,8 +79,37 @@ export async function readStreamChunkWithIdleTimeout(
}, idleTimeoutMs); }, idleTimeoutMs);
}); });
// 外部中断竞速(流式消费阶段的中断贯通)
let onExternalAbort: (() => void) | null = null;
const abortController = externalSignal
? new Promise<never>((_, 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 { 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) { if (idleExpired) {
throw new SseUpstreamError( throw new SseUpstreamError(
`Stream idle timeout after ${idleTimeoutMs}ms (no data received)`, `Stream idle timeout after ${idleTimeoutMs}ms (no data received)`,
@@ -79,8 +117,17 @@ export async function readStreamChunkWithIdleTimeout(
); );
} }
return result; return result;
} catch (err) {
// 中断竞速赢时把底层读错误替换为标准 AbortErrorreader.read 会因 cancel 拒绝)
if (externalSignal?.aborted) {
throw abortError();
}
throw err;
} finally { } finally {
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (externalSignal && onExternalAbort) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
} }
} }
@@ -90,13 +137,18 @@ interface SseStreamFrame {
delta?: { delta?: {
content?: string; content?: string;
reasoning_content?: string; reasoning_content?: string;
annotations?: unknown;
tool_calls?: Array<{ tool_calls?: Array<{
index?: number; index?: number;
function?: { name?: string; arguments?: string }; function?: { name?: string; arguments?: string };
}>; }>;
}; };
/** 部分网关在最后一个 chunk 附带完整 message(含 annotations */
message?: { annotations?: unknown };
finish_reason?: string; finish_reason?: string;
}>; }>;
/** v0.8.2 P2-6: MiMo 联网搜索引用注释(服务端 web_search 工具) */
annotations?: unknown;
usage?: { usage?: {
prompt_tokens?: number; prompt_tokens?: number;
completion_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<string, { title: string; url: string; siteName?: string }>,
): void {
if (!Array.isArray(annotations)) return;
for (const item of annotations) {
if (!item || typeof item !== 'object') continue;
const rec = item as Record<string, unknown>;
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, { title: string; url: string; siteName?: string }>,
): 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 数据帧中提取上游错误信息。 * 从一条已 JSON.parse 的 SSE 数据帧中提取上游错误信息。
* 兼容三种形态: * 兼容三种形态:
@@ -312,6 +412,7 @@ function* flushToolCallBuffer(
* @param requestId - 对应的请求 ID * @param requestId - 对应的请求 ID
* @param sessionId - 会话 ID * @param sessionId - 会话 ID
* @param iteration - 当前迭代轮次 * @param iteration - 当前迭代轮次
* @param externalSignal - 外部中断信号(v0.8.2 P3-3: 流式消费阶段的中断贯通,可选)
* @yields MetonaStreamEvent * @yields MetonaStreamEvent
*/ */
export async function* parseSSEStream( export async function* parseSSEStream(
@@ -319,6 +420,7 @@ export async function* parseSSEStream(
requestId: string, requestId: string,
sessionId: string, sessionId: string,
iteration: number, iteration: number,
externalSignal?: AbortSignal,
): AsyncGenerator<MetonaStreamEvent> { ): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader(); const reader = responseBody.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@@ -341,11 +443,17 @@ export async function* parseSSEStream(
// 工具调用缓冲区:index → { name, argsBuffer } // 工具调用缓冲区:index → { name, argsBuffer }
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>(); const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
// v0.8.2 P2-6: MiMo 联网搜索引用采集(按 url 去重,流结束时回填正文)
const annotationsSink = new Map<string, { title: string; url: string; siteName?: string }>();
try { try {
while (true) { while (true) {
// 数据到达即重置空闲窗口(辅助函数内部实现) // 数据到达即重置空闲窗口(辅助函数内部实现)
const { done, value } = await readStreamChunkWithIdleTimeout(reader, IDLE_TIMEOUT_MS); const { done, value } = await readStreamChunkWithIdleTimeout(
reader,
IDLE_TIMEOUT_MS,
externalSignal,
);
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
@@ -366,6 +474,20 @@ export async function* parseSSEStream(
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码 // L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef); 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 { yield {
type: MetonaStreamEventType.DONE, type: MetonaStreamEventType.DONE,
requestId, requestId,
@@ -405,6 +527,11 @@ export async function* parseSSEStream(
const delta = chunk.choices?.[0]?.delta; 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) { if (delta?.content) {
yield { yield {
@@ -532,6 +659,19 @@ export async function* parseSSEStream(
'[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)', '[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)',
); );
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef); 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 { yield {
type: MetonaStreamEventType.DONE, type: MetonaStreamEventType.DONE,
requestId, requestId,
@@ -568,8 +708,16 @@ export function parseOpenAICompatibleResponse(data: Record<string, unknown>): {
const usage = data.usage as Record<string, unknown> | undefined; const usage = data.usage as Record<string, unknown> | undefined;
const rawToolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined; const rawToolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
// v0.8.2 P2-6: 非流式路径的引用注释回填(MiMo 联网搜索)
const annotationsSink = new Map<string, { title: string; url: string; siteName?: string }>();
collectAnnotations(message?.annotations, annotationsSink);
collectAnnotations(data.annotations, annotationsSink);
let content = (message?.content as string) ?? '';
const citationBlock = formatAnnotationsBlock(annotationsSink);
if (citationBlock) content += citationBlock;
return { return {
content: (message?.content as string) ?? '', content,
reasoningContent: message?.reasoning_content as string | undefined, reasoningContent: message?.reasoning_content as string | undefined,
toolCalls: rawToolCalls?.map((tc) => { toolCalls: rawToolCalls?.map((tc) => {
const fn = tc.function as Record<string, unknown>; const fn = tc.function as Record<string, unknown>;
@@ -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 会造成内存峰值。
*
* 收口方案(根治):
* - 校验与连接同源:resolvePublicAddressesssrf-guard 单一事实来源)+ DNS
* pinningssrfPinnedFetch),代理激活时按既有语义退化为"仅入口校验";
* - 逐跳重定向复检: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<Response>;
/**
* 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 typeAnthropic 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<Buffer> {
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<ImageFetchResult> {
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 };
}
@@ -0,0 +1,102 @@
/**
* v0.8.2 P0-2: 根 MEMORY.md 保护闸门(工具无关的路径形参数匹配)
*
* 锁定契约:
* - delete_file / file_movesource_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<string, unknown>): boolean {
return (
engine as unknown as {
isTargetingRootMemoryMd: (tc: { args: Record<string, unknown> }) => 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);
});
});
+289 -170
View File
@@ -29,11 +29,12 @@ import type {
MetonaToolCall, MetonaToolCall,
MetonaToolResult, MetonaToolResult,
MetonaStreamEvent, MetonaStreamEvent,
MetonaThinkingBlock,
IMetonaProviderAdapter, IMetonaProviderAdapter,
MetonaToolDef, MetonaToolDef,
} from '../types'; } from '../types';
import { MetonaStreamEventType, MetonaErrorCode } 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 { ContentFilterError } from '../adapters/base-adapter';
import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream'; import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream';
import log from 'electron-log'; import log from 'electron-log';
@@ -378,6 +379,9 @@ export class AgentLoopEngine extends EventEmitter {
role: 'assistant', role: 'assistant',
content: step.thought?.content ?? null, content: step.thought?.content ?? null,
reasoningContent: step.thought?.reasoningContent, reasoningContent: step.thought?.reasoningContent,
// v0.8.2 P1-1: 透传原始思考块(Anthropic extended thinking 工具循环
// 多轮请求必须回传带签名的 thinking 块,否则 400 或丢失推理上下文)
thinkingBlocks: step.thinkingBlocks,
toolCalls: step.toolCalls, toolCalls: step.toolCalls,
timestamp: Date.now(), timestamp: Date.now(),
iteration: this.currentIteration, 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 promptSOUL + 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; let tokenUsage: TokenUsage | undefined;
// v0.8.0 P0-1: 本轮流的 Provider 原生停止原因(adapter DONE 携带) // v0.8.0 P0-1: 本轮流的 Provider 原生停止原因(adapter DONE 携带)
let iterationFinishReason: string | undefined; let iterationFinishReason: string | undefined;
// v0.8.2 P1-1: 本轮流的原始思考块(adapter DONE 携带,含 Provider 签名)
let iterationThinkingBlocks: MetonaThinkingBlock[] | undefined;
// 流式接收响应 // 流式接收响应
for await (const event of this.chatStreamWithRetry(request)) { for await (const event of this.chatStreamWithRetry(request)) {
@@ -560,6 +603,8 @@ export class AgentLoopEngine extends EventEmitter {
// 引擎与 TRACE 据此区分自然完成与输出上限截断(length 此前完全不可见) // 引擎与 TRACE 据此区分自然完成与输出上限截断(length 此前完全不可见)
if (event.type === MetonaStreamEventType.DONE) { if (event.type === MetonaStreamEventType.DONE) {
if (event.finishReason) iterationFinishReason = event.finishReason; if (event.finishReason) iterationFinishReason = event.finishReason;
// v0.8.2 P1-1: 捕获原始思考块(Anthropic extended thinking 协议回传的数据源)
if (event.thinkingBlocks?.length) iterationThinkingBlocks = event.thinkingBlocks;
continue; continue;
} }
@@ -662,6 +707,10 @@ export class AgentLoopEngine extends EventEmitter {
if (iterationFinishReason) { if (iterationFinishReason) {
step.finishReason = iterationFinishReason; step.finishReason = iterationFinishReason;
} }
// v0.8.2 P1-1: 记录本轮原始思考块(主循环写入 assistant 消息供协议回传)
if (iterationThinkingBlocks?.length) {
step.thinkingBlocks = iterationThinkingBlocks;
}
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段) // L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段)
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer); this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
@@ -791,41 +840,10 @@ export class AgentLoopEngine extends EventEmitter {
} }
} }
// === 上下文压缩(基于 token 使用率触发) === // === v0.8.2 P1-2: 上下文压缩判定已移至主循环(本轮 assistant/tool 消息
// 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。 // 入列之后)—— 旧位置(本方法内、工具执行后)在本轮消息 push 之前,本轮
// v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死 // 刚产生的大体积工具结果不在压缩输入里,最坏情况"压缩完又立刻装回同等
// 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算) // 体量"。压缩输入/估算/触发的完整实现见 executeRunStream
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);
}
step.completedAt = Date.now(); step.completedAt = Date.now();
step.state = AgentLoopState.OBSERVING; step.state = AgentLoopState.OBSERVING;
@@ -856,6 +874,15 @@ export class AgentLoopEngine extends EventEmitter {
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) { if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = []; step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) { 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<string, unknown>; let args: Record<string, unknown>;
try { try {
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {}; args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
@@ -975,18 +1002,21 @@ export class AgentLoopEngine extends EventEmitter {
// 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md, // 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
// 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。 // 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
// run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。 // run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) { //
if (this.isTargetingRootMemoryMd(toolCall)) { // v0.8.2 P0-2 根治:不再按工具名单枚举(名单制曾遗漏 delete_file / file_move
return { // 且对 MCP 文件类工具完全不设防),改为对**任意工具调用**的路径形参数做统一
toolCallId: toolCall.id, // 精确匹配 —— 只要某个路径形参数指向工作空间根 MEMORY.md(读/写/删/移动/
toolName: toolCall.name, // 改名任一方向),一律拦截。子目录 MEMORY.md 不受影响(保持 H-5 语义)。
result: null, if (this.isTargetingRootMemoryMd(toolCall)) {
success: false, return {
error: 'Access to workspace root MEMORY.md is protected by security policy', toolCallId: toolCall.id,
durationMs: Date.now() - startTs, toolName: toolCall.name,
timestamp: Date.now(), 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 堆积 // M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer // 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
let engineTimer: ReturnType<typeof setTimeout> | undefined; let engineTimer: ReturnType<typeof setTimeout> | 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 { try {
toolResult = await Promise.race([ toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, { this.toolRegistry.execute(toolCall, {
@@ -1005,14 +1047,15 @@ export class AgentLoopEngine extends EventEmitter {
workspacePath: this.workspacePath, workspacePath: this.workspacePath,
iteration: this.currentIteration, iteration: this.currentIteration,
requestId: this.currentRequestId, requestId: this.currentRequestId,
// P0-4: 引擎级 abort 信号透传——用户中断时工具内部(如 run_command 子进程)可自行终止 // 工具级信号:引擎中断镜像 + 超时联动 abortP0-4 / v0.8.2 P3-2
signal: this.abortController?.signal, signal: toolAbort.signal,
}), }),
new Promise<MetonaToolResult>((_, reject) => { new Promise<MetonaToolResult>((_, reject) => {
engineTimer = setTimeout( engineTimer = setTimeout(() => {
() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), // 超时即取消执行体(不只是放弃等待)
toolTimeout, toolAbort.abort();
); reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`));
}, toolTimeout);
}), }),
]); ]);
} catch (err) { } catch (err) {
@@ -1035,6 +1078,8 @@ export class AgentLoopEngine extends EventEmitter {
} finally { } finally {
// M-16 修复: 清理未触发的 timeout timer // M-16 修复: 清理未触发的 timeout timer
if (engineTimer) clearTimeout(engineTimer); if (engineTimer) clearTimeout(engineTimer);
// v0.8.2 P3-2: 清理引擎信号镜像监听器
if (engineSignal) engineSignal.removeEventListener('abort', onEngineAbort);
} }
// 后置 Hook 管道(P0-2: 钩子可返回修改后的结果——如 SecurityScanHook 对网页内容脱敏) // 后置 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; * 命中两类键名:① 精确集合 { path, file, target, destination, source, dir,
* subdirectory MEMORY.md files are unrestricted * workdir };② 任意以 path/dir 结尾的键(file_path / dir_path / source_path /
* * destination_path / filePath / dirpath 等含下划线与驼峰形态)。`target` 在
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md * search_files 中是枚举值("content"/"files"),resolve 后不可能与根 MEMORY.md
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。 * 绝对路径精确相等,误报风险为零;反之名单制枚举工具对未来新增工具 / MCP
* * 文件类工具存在结构性遗漏。
* @param toolCall 工具调用
* @returns 是否指向工作空间根目录的 MEMORY.md
*/ */
private static readonly PATH_ARG_KEY_EXACT = new Set([
'path',
'file',
'target',
'destination',
'source',
'dir',
'workdir',
]);
private extractPathArgs(args: Record<string, unknown>): 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 { private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
if (!this.workspacePath) return false; 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 // 解析路径,判断是否指向工作空间根目录的 MEMORY.md
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统 // 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
const resolved = resolve(pathStr).toLowerCase();
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase(); const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md // 精确匹配:任一路径形参数等于 {workspacePath}/MEMORY.md
return resolved === rootMemoryPath; // 相对路径以 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 this.adapter = this.fallbackAdapter; // 本 run 内后续迭代均使用 fallback
currentAdapter = this.fallbackAdapter; currentAdapter = this.fallbackAdapter;
this.syncContextWindow(); this.syncContextWindow();
// v0.8.2 P1-2: 故障转移后重置真实输入 token 校正值 —— 旧 Provider 的
// 真实值参与了新 Provider(窗口可能不同)的压缩判定,会造成预算失真
this.lastRealInputTokens = 0;
// 故障转移后重新注入 abort 信号(新 adapter 实例需要关联引擎的中断控制器) // 故障转移后重新注入 abort 信号(新 adapter 实例需要关联引擎的中断控制器)
if (this.abortController && currentAdapter.setAbortSignal) { if (this.abortController && currentAdapter.setAbortSignal) {
currentAdapter.setAbortSignal(this.abortController.signal); currentAdapter.setAbortSignal(this.abortController.signal);
@@ -1300,7 +1361,18 @@ export class AgentLoopEngine extends EventEmitter {
// 5xx 服务器错误 — 可重试 // 5xx 服务器错误 — 可重试
if (err.status && err.status >= 500 && err.status < 600) return true; 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; return true;
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判 // P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截) // SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
@@ -1341,6 +1413,36 @@ export class AgentLoopEngine extends EventEmitter {
return estimateMessagesTokens(messages); return estimateMessagesTokens(messages);
} }
/**
* v0.8.2 P1-2: 估算"下一轮请求"的总输入 token —— messages + system prompt。
*
* 旧压缩判定只估算 messagessystem promptSOUL.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。 * v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。
* *
@@ -1466,7 +1568,10 @@ export class AgentLoopEngine extends EventEmitter {
* 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组 * 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组
* 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断 * 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断
* *
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组) * v0.8.2 P1-2: 摘要调用失败重试一次;最终失败时降级为**纯截断压缩**(仅保留
* 近期消息,无 LLM 摘要)—— token 确定下降优于"放弃压缩 → 下一轮 413"。
*
* @returns 压缩后的消息数组;仅当无法构造任何有效压缩(无可压缩消息)时返回 null
*/ */
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> { private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
@@ -1531,101 +1636,115 @@ export class AgentLoopEngine extends EventEmitter {
}) })
.join('\n\n'); .join('\n\n');
const summaryRequest: MetonaRequest = { // v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断
meta: { // 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文
sessionId: this.currentSessionId, // v0.8.2 P1-2: 该截断提前到摘要调用之前完成 —— 纯截断兜底路径与摘要路径共用
iteration: this.currentIteration, const finalKeep = toKeep.map((msg) => {
requestId: `r_${nanoid(12)}`, const msgTokens = this.estimateMessagesTokens([msg]);
timestamp: Date.now(), if (msgTokens > keepBudget && msg.content) {
agentVersion: '1.0.0', // 截断内容,保留头部和尾部,中间用省略标记
}, const halfBudget = Math.floor(keepBudget / 2);
systemPrompt: { // v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致)
roleDefinition: 'You are a conversation summarizer.', // 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token,
outputConstraints: // 导致截断后保留字符过多,实际 token 仍超 keepBudget
'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.', const charsPerToken = 1.0;
safetyGuidelines: const keepChars = Math.floor(halfBudget * charsPerToken);
'Do not include sensitive data like passwords or API keys in the summary.', if (msg.content.length > keepChars * 2) {
}, const head = msg.content.slice(0, keepChars);
messages: [ const tail = msg.content.slice(-keepChars);
{ return {
role: 'user', ...msg,
content: `Please summarize the following conversation history:\n\n${conversationText}`, 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(), timestamp: Date.now(),
agentVersion: '1.0.0',
}, },
], systemPrompt: {
params: { roleDefinition: 'You are a conversation summarizer.',
maxTokens: 2048, outputConstraints:
temperature: 0.0, '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.',
stream: false, safetyGuidelines:
thinkingEnabled: false, 'Do not include sensitive data like passwords or API keys in the summary.',
thinkingEffort: 'low', },
}, 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 { log.info(
const response = await this.adapter.send(summaryRequest); `[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`,
const summary = response.content.trim(); );
if (!summary) return null; // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
const summaryMessage: MetonaMessage = { // 插入占位 user 消息保证对话流清晰。
// #30 修复: 改用 assistant 角色注入摘要,避免语义混淆 return [
// 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令, summaryMessage,
// 可能基于"Summary of previous conversation"字面意思执行奇怪操作。 // 审查修复: 插入占位 user 消息避免连续 assistant 消息
// 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会 { role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
// 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息), ...finalKeep,
// 用 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;
}
} }
private accumulateTokens(usage: TokenUsage): void { private accumulateTokens(usage: TokenUsage): void {
+7 -1
View File
@@ -4,7 +4,7 @@
* 用于 Agent Loop 引擎内部的状态管理和迭代记录。 * 用于 Agent Loop 引擎内部的状态管理和迭代记录。
*/ */
import type { MetonaToolCall, MetonaToolResult } from '../types'; import type { MetonaToolCall, MetonaToolResult, MetonaThinkingBlock } from '../types';
// ===== Agent Loop 状态机 ===== // ===== Agent Loop 状态机 =====
@@ -55,6 +55,12 @@ export interface IterationStep {
* 引擎据此区分"自然完成"与"输出上限截断"(P0-2 空响应守卫的输入)。 * 引擎据此区分"自然完成"与"输出上限截断"(P0-2 空响应守卫的输入)。
*/ */
finishReason?: string; finishReason?: string;
/**
* v0.8.2 P1-1: 本轮 LLM 流的原始思考块(含 Provider 签名)。
* 由 adapter DONE 事件携带、引擎捕获;主循环据此写入 push 的 assistant 消息
* MetonaMessage.thinkingBlocks),AnthropicAdapter 在下一轮请求按协议回传。
*/
thinkingBlocks?: MetonaThinkingBlock[];
} }
export interface TokenUsage { export interface TokenUsage {
@@ -31,7 +31,8 @@ try {
import { MemoryManager } from '../manager'; 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 { function createMemorySchema(db: any): void {
db.exec(` db.exec(`
CREATE TABLE episodic_memories ( CREATE TABLE episodic_memories (
@@ -44,7 +45,8 @@ function createMemorySchema(db: any): void {
created_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER, expires_at INTEGER,
tf_cache TEXT, tf_cache TEXT,
embedding BLOB embedding BLOB,
embedding_model TEXT
); );
CREATE TABLE semantic_memories ( CREATE TABLE semantic_memories (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -57,7 +59,8 @@ function createMemorySchema(db: any): void {
updated_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0,
access_count INTEGER DEFAULT 0, access_count INTEGER DEFAULT 0,
tf_cache TEXT, tf_cache TEXT,
embedding BLOB embedding BLOB,
embedding_model TEXT
); );
CREATE TABLE working_memories ( CREATE TABLE working_memories (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -783,9 +786,9 @@ describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => {
} }
}); });
// 注意:manager.store() 的 episodic INSERT 不含 expires_at 列(源码已知缺口, // v0.8.2 P3-6 注释修正:v0.8.1 P0-2 已让 store() 真实写入 expires_at(本组
// main.ts 注释亦确认"expires_at 无写入方")—— 本组用例直接经 SQL 写入 // 早期版本注释"INSERT 不含 expires_at 列"已过时)。此处直接经 SQL 写入仅为
// expires_at 模拟真实过期行,锁定 cleanupExpired 自身的删除契约。 // 测试夹具便利(绕过 store 的哈希/分词管线),锁定 cleanupExpired 的删除契约。
const insertExpiring = (id: string, expiresAt: number, content = '带过期记忆'): void => { const insertExpiring = (id: string, expiresAt: number, content = '带过期记忆'): void => {
db.prepare( db.prepare(
`INSERT INTO episodic_memories (id, content, source, importance, created_at, expires_at) `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); 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<typeof Database>;
let mgr: MemoryManager;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
});
it('semantic 同内容重复 store → ON CONFLICT upsertaccess_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();
});
});
+8
View File
@@ -16,4 +16,12 @@ export type MemoryEmbedFn = (text: string) => Promise<number[] | null>;
export interface MemoryEmbedder { export interface MemoryEmbedder {
embed: MemoryEmbedFn; embed: MemoryEmbedFn;
/**
* v0.8.2 P3-1: 嵌入模型标识(指纹)。
* 用户更换 embedding 模型后,旧向量与新查询向量维度/语义空间不匹配 ——
* 维度不同余弦静默为 0,同维不同模型产生噪声分数。Manager 以该标识标注
* 每条向量(embedding_model 列),检索时模型不匹配的向量视为缺失并惰性重算。
* 未提供时无法做指纹校验(旧向量一律接受 —— 保持旧行为兼容)。
*/
readonly modelName?: string;
} }
+195 -10
View File
@@ -414,7 +414,12 @@ export class MemoryManager {
source: row.source as MemorySource, source: row.source as MemorySource,
sessionId: row.session_id ?? undefined, sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? 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, queryVec,
}, },
queryTF, queryTF,
@@ -428,6 +433,7 @@ export class MemoryManager {
rows.map((r) => ({ rows.map((r) => ({
id: r.id, id: r.id,
embedding: (r as { embedding?: unknown }).embedding, embedding: (r as { embedding?: unknown }).embedding,
embedding_model: (r as { embedding_model?: string | null }).embedding_model ?? null,
text: r.content + ' ' + (r.summary ?? ''), text: r.content + ' ' + (r.summary ?? ''),
})), })),
); );
@@ -478,6 +484,7 @@ export class MemoryManager {
rows.map((r) => ({ rows.map((r) => ({
id: r.id, id: r.id,
embedding: (r as { embedding?: unknown }).embedding, embedding: (r as { embedding?: unknown }).embedding,
embedding_model: (r as { embedding_model?: string | null }).embedding_model ?? null,
text: r.key + ' ' + r.value, text: r.key + ' ' + r.value,
})), })),
); );
@@ -565,10 +572,24 @@ export class MemoryManager {
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
// v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE // v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE
// 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 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( 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, ?) 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( ).run(
id, id,
@@ -635,9 +656,10 @@ export class MemoryManager {
.embed(text.slice(0, 8000)) .embed(text.slice(0, 8000))
.then((vec) => { .then((vec) => {
if (!vec || vec.length === 0) return; if (!vec || vec.length === 0) return;
// v0.8.2 P3-1: 同步写入模型指纹(检索时按指纹校验,模型更换后惰性重算)
this.getDB() this.getDB()
.prepare(`UPDATE ${table} SET embedding = ? WHERE id = ?`) .prepare(`UPDATE ${table} SET embedding = ?, embedding_model = ? WHERE id = ?`)
.run(float32ToBlob(vec), id); .run(float32ToBlob(vec), embedder.modelName ?? null, id);
}) })
.catch((err) => { .catch((err) => {
log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`); 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 * v0.8.1 P1-1: 存量记忆向量惰性回填 —— 嵌入功能开启前写入的记忆(embedding
* IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量 * IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量
@@ -655,12 +815,20 @@ export class MemoryManager {
*/ */
private backfillMissingEmbeddings( private backfillMissingEmbeddings(
type: MemoryType, type: MemoryType,
rows: Array<{ id: string; embedding?: unknown; text: string }>, rows: Array<{ id: string; embedding?: unknown; embedding_model?: string | null; text: string }>,
): void { ): void {
if (!this.embedder) return; if (!this.embedder) return;
const currentModel = this.embedder.modelName;
for (const row of rows) { for (const row of rows) {
if (row.embedding != null) continue; if (row.embedding == null) {
this.enrichEmbedding(type, row.id, row.text); 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 ± 向量混合) // v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合)
const now = Date.now();
const tfidfResults = this.tfidfSearch(query, options, queryVec); const tfidfResults = this.tfidfSearch(query, options, queryVec);
if (tfidfResults.length > 0) {
this.bumpAccessCounts(tfidfResults); // v0.8.2 P3-1: 向量路独立召回合并 —— 低重要度但语义相关的记忆不再被
return tfidfResults; // importance 预过滤的候选池钳制(tfidfSearch 的向量分量只在重要度池内算)。
let merged = tfidfResults;
if (queryVec) {
const vectorResults = this.vectorRecall(queryVec, options, now);
if (vectorResults.length > 0) {
const byId = new Map<string, SearchResult>();
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 关键词搜索 // 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索
@@ -238,7 +238,7 @@ describe('TaskOrchestrator — 工具白名单', () => {
}); });
}); });
describe('TaskOrchestrator — 递归深度限制', () => { describe('TaskOrchestrator — 并发委派上限', () => {
it('同一会话并发达到 3 层后,第 4 次委派被拒绝(同步计数)', async () => { it('同一会话并发达到 3 层后,第 4 次委派被拒绝(同步计数)', async () => {
const { releases } = installLatchedAdapter(); const { releases } = installLatchedAdapter();
@@ -248,7 +248,8 @@ describe('TaskOrchestrator — 递归深度限制', () => {
const d4 = await orchestrator.delegate({ description: 'L4', parentSessionId: 's' }); const d4 = await orchestrator.delegate({ description: 'L4', parentSessionId: 's' });
expect(d4.success).toBe(false); 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); expect(d4.iterations).toBe(0);
// 等三个引擎都挂起到闩锁再释放(过早释放会扑空) // 等三个引擎都挂起到闩锁再释放(过早释放会扑空)
@@ -86,7 +86,7 @@ interface SubAgentHandle {
getStatus: () => { taskId: string; status: string; description: string; depth: number }; getStatus: () => { taskId: string; status: string; description: string; depth: number };
} }
/** 默认递归深度限制 */ /** 默认并发委派上限(每会话同时活跃的 SubAgent 数) */
const MAX_DELEGATION_DEPTH = 3; const MAX_DELEGATION_DEPTH = 3;
export class TaskOrchestrator extends EventEmitter { export class TaskOrchestrator extends EventEmitter {
@@ -139,16 +139,21 @@ export class TaskOrchestrator extends EventEmitter {
const taskId = params.taskId ?? `sub_${nanoid(8)}`; const taskId = params.taskId ?? `sub_${nanoid(8)}`;
const startMs = Date.now(); 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; const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
if (currentDepth >= MAX_DELEGATION_DEPTH) { if (currentDepth >= MAX_DELEGATION_DEPTH) {
log.warn( log.warn(
`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`, `[Orchestrator] Concurrent delegation limit reached (${currentDepth}) for session ${params.parentSessionId}`,
); );
return { return {
taskId, taskId,
parentSessionId: params.parentSessionId, 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, success: false,
durationMs: 0, durationMs: 0,
iterations: 0, iterations: 0,
+49 -17
View File
@@ -14,11 +14,7 @@ const MAX_DIFF_FILE_BYTES = 10 * 1024 * 1024;
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types'; import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { import { safeResolvePath, extractErrorMessage, decodeBufferWithDetection } from './file-guard';
safeResolvePath,
extractErrorMessage,
decodeBufferWithDetection,
} from './file-guard';
interface DiffLine { interface DiffLine {
type: 'context' | 'added' | 'removed'; type: 'context' | 'added' | 'removed';
@@ -53,12 +49,14 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
// 回溯生成 diff // 回溯生成 diff
const result: DiffLine[] = []; const result: DiffLine[] = [];
let i = sm, j = sn; let i = sm,
j = sn;
while (i > 0 || j > 0) { while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) { if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) {
result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 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)])) { } 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] }); result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] });
j--; j--;
@@ -72,7 +70,12 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
} }
/** 生成 unified diff 格式字符串 */ /** 生成 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[] = []; const lines: string[] = [];
lines.push(`--- ${oldLabel}`); lines.push(`--- ${oldLabel}`);
lines.push(`+++ ${newLabel}`); lines.push(`+++ ${newLabel}`);
@@ -89,7 +92,11 @@ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: st
if (hunkLines.length > 0) { if (hunkLines.length > 0) {
// 移除尾部多余的 context 行 // 移除尾部多余的 context 行
const trimmed: string[] = [...hunkLines]; 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(); trimmed.pop();
contextSinceChange--; contextSinceChange--;
} }
@@ -142,10 +149,29 @@ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: st
return lines.join('\n'); 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 { export class DiffViewerTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'diff_viewer', 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: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -158,7 +184,10 @@ export class DiffViewerTool implements IMetonaTool {
file_b: { type: 'string', description: 'Second file path (for files mode)' }, file_b: { type: 'string', description: 'Second file path (for files mode)' },
text_a: { type: 'string', description: 'First text content (for text mode)' }, text_a: { type: 'string', description: 'First text content (for text mode)' },
text_b: { type: 'string', description: 'Second 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'], required: ['mode'],
}, },
@@ -254,16 +283,19 @@ export class DiffViewerTool implements IMetonaTool {
lines_removed: removedCount, lines_removed: removedCount,
lines_unchanged: contextCount, lines_unchanged: contextCount,
total_changes: addedCount + removedCount, total_changes: addedCount + removedCount,
similarity: (oldSlicedLen + newSlicedLen) > 0 similarity:
? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100 oldSlicedLen + newSlicedLen > 0
: 1, ? Math.round(((contextCount * 2) / (oldSlicedLen + newSlicedLen)) * 100) / 100
: 1,
truncated, truncated,
}; };
// D4.6: unifiedDiff 大小限制 // D4.6: unifiedDiff 大小限制
const MAX_DIFF_CHARS = 50_000; const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS const MAX_DIFF_CHARS = 50_000;
? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)' const truncatedDiff =
: unifiedDiff; unifiedDiff.length > MAX_DIFF_CHARS
? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
: unifiedDiff;
return { return {
success: true, success: true,
@@ -30,6 +30,8 @@ import {
FILE_TOOL_TIMEOUT_MS, FILE_TOOL_TIMEOUT_MS,
isPotentiallyCatastrophicRegex, isPotentiallyCatastrophicRegex,
} from './file-guard'; } 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 导入(共享模块), * v0.7.4 P2-7: 灾难性正则检测从 file-guard 导入(共享模块),
@@ -403,6 +405,15 @@ export class FileEditorTool implements IMetonaTool {
preview: { preview: {
original: originalPreview, original: originalPreview,
modified: modifiedPreview, 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,
),
}, },
}; };
} }
@@ -803,6 +803,16 @@ export class SearchFilesTool implements IMetonaTool {
} else { } else {
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx" // F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx"
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue; 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); await callback(fullPath, entry.name);
} }
} }
@@ -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、 * - 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、 * 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 (CGNATv0.8.2 P3-1)、198.18.0.0/15 (基准测试段,P3-1)
* - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4 * - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4
*/ */
export function isPrivateIP(ip: string): boolean { 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] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网
if (parts[0] === 169 && parts[1] === 254) 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] === 0) return true; // 0.0.0.0/8
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // 100.64/10 CGNATv0.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; // 组播 + 保留 if (parts[0] >= 224) return true; // 组播 + 保留
return false; return false;
} }
+17 -1
View File
@@ -27,7 +27,7 @@ import {
logTool, logTool,
} from './network-utils'; } from './network-utils';
// v0.7.3 P2-1: 可达性预检经 SSRF 校验 + DNS pinning(结果 URL 是不可信外部输入) // 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 { ssrfPinnedFetch } from './ssrf-dispatcher';
import type { WebFetchTool } from './web-fetch'; 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 headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
const tr = timeRange || config.time_range; const tr = timeRange || config.time_range;
// v0.8.2 P1-6: SearXNG 运行时请求的 SSRF 纵深校验。
// 配置期已有 assertSafeConfigTargetipc/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 // SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults
const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条 const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条
let page = 1; let page = 1;
+7 -3
View File
@@ -30,14 +30,18 @@ export type {
MetonaTokenUsage, MetonaTokenUsage,
MetonaStreamEvent, MetonaStreamEvent,
MetonaValidationPayload, MetonaValidationPayload,
MetonaThinking,
MetonaError, MetonaError,
} from './metona-response'; } from './metona-response';
export { MetonaFinishReason, MetonaStreamEventType, MetonaErrorCode } from './metona-response'; export { MetonaFinishReason, MetonaStreamEventType, MetonaErrorCode } from './metona-response';
// ===== 上下文与记忆 ===== // v0.8.2 P3-6 IR 卫生: 删除 metona-context.tsMetonaContext / MetonaMemoryItem)——
export type { MetonaContext, MetonaMemoryItem } from './metona-context'; // 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'; export type { IMetonaProviderAdapter, AdapterConfig } from './metona-adapter';
-61
View File
@@ -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;
}
+40
View File
@@ -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 { export interface MetonaMessage {
role: 'system' | 'user' | 'assistant' | 'tool'; role: 'system' | 'user' | 'assistant' | 'tool';
@@ -92,6 +109,15 @@ export interface MetonaMessage {
/** (仅 assistant)思考/推理内容 */ /** (仅 assistant)思考/推理内容 */
reasoningContent?: string; reasoningContent?: string;
/**
* assistantv0.8.2 P1-1
*
* reasoningContentthinkingBlocks Provider
* DONE push assistant
* AnthropicAdapter
*/
thinkingBlocks?: MetonaThinkingBlock[];
/** (仅 assistant)工具调用请求 */ /** (仅 assistant)工具调用请求 */
toolCalls?: MetonaToolCall[]; toolCalls?: MetonaToolCall[];
@@ -144,6 +170,20 @@ export interface MetonaParamField {
description: string; description: string;
enum?: string[]; enum?: string[];
items?: MetonaParamField; items?: MetonaParamField;
/**
* v0.8.2 P2-7: MCP schema convertSchema type/description
* enum/anyOf/oneOf//items/default MCP 退
* MCP Zod
*/
/** 嵌套对象属性(type=object 时) */
properties?: Record<string, MetonaParamField>;
/** 嵌套对象的必填键 */
required?: string[];
/** 类型组合(anyOf/oneOf 的原样保留) */
anyOf?: MetonaParamField[];
oneOf?: MetonaParamField[];
/** 默认值(原样透传) */
default?: unknown;
} }
export enum MetonaToolCategory { export enum MetonaToolCategory {
+20 -12
View File
@@ -4,7 +4,7 @@
* @see docs/MetonaAI-Desktop API请求与响应标准.html * @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; content: string;
/** 思考/推理内容(Thinking 模式) */ /** 思考/推理内容(Thinking 模式) */
reasoningContent?: string; reasoningContent?: string;
/**
* v0.8.2 P1-1: 原始思考块 reasoningContent
* Provider assistant
*/
thinkingBlocks?: MetonaThinkingBlock[];
/** 结构化输出(如果模型原生支持 JSON Schema */ /** 结构化输出(如果模型原生支持 JSON Schema */
structuredOutput?: unknown; structuredOutput?: unknown;
/** 工具调用请求列表 */ /** 工具调用请求列表 */
@@ -156,6 +161,17 @@ export interface MetonaStreamEvent {
* v0.7.4 'stop'length * v0.7.4 'stop'length
*/ */
finishReason?: string; finishReason?: string;
/**
* DONE v0.8.2 P1-1Anthropic
*
* Anthropic extended thinking thinking/redacted_thinking Provider
* content_block_*
* adapter DONE
* push assistant MetonaMessage.thinkingBlocks
* Provider
*/
thinkingBlocks?: MetonaThinkingBlock[];
} }
/** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */ /** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */
@@ -173,17 +189,9 @@ export interface MetonaValidationPayload {
} }
// ===== 思考内容 ===== // ===== 思考内容 =====
// v0.8.2 P3-6 IR 卫生: 删除 MetonaThinking —— 自注册以来全链路零发送方零消费者
export interface MetonaThinking { // (思考边界由 reasoning_delta 流语义承载),且其字段与 P1-1 引入的
/** 思考内容文本 */ // MetonaThinkingBlock(协议回传所需的真实块结构)概念重叠,保留只会误导读者。
content: string;
/** 思考状态 */
status: 'thinking' | 'complete';
/** 思考耗时 (ms) */
durationMs: number;
/** 思考消耗的 token 数 */
tokensUsed: number;
}
// ===== 错误格式 ===== // ===== 错误格式 =====
+3 -4
View File
@@ -234,10 +234,9 @@ describe('agent:sendMessage — 前置检查', () => {
const result = await handler(null, VALID_MESSAGE, 'sess_1'); const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect((result as { success: boolean }).success).toBe(false); expect((result as { success: boolean }).success).toBe(false);
expect((result as { error: string }).error).toContain('blocked by prompt injection defense'); expect((result as { error: string }).error).toContain('blocked by prompt injection defense');
// 用户消息不保存(在注入检测前已保存?—— 现实现:先保存检测,验证已保存) // v0.8.2 P1-4: 检测前移 —— 被阻断的消息**不**落库(旧实现先保存检测,
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( // 恶意内容滞留会话历史)
expect.objectContaining({ role: 'user', sessionId: 'sess_1' }), expect(ctxRaw.sessionService.saveMessage).not.toHaveBeenCalled();
);
// 引擎不启动 // 引擎不启动
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
}); });
+72 -32
View File
@@ -44,6 +44,18 @@ function buildTimezoneLabel(): string {
} }
} }
/**
* v0.8.2 P2-4: 会话元数据实时刷新广播 session:updated
* Sidebartitle / messageCount / updatedAt
* config:changed key session.title.<id>
*/
function broadcastSessionUpdated(
sessionId: string,
patch: { title?: string; messageCount?: number; updatedAt?: number },
): void {
broadcast('session:updated', { sessionId, ...patch });
}
/** 单会话的 text_delta 节流状态 */ /** 单会话的 text_delta 节流状态 */
interface ThrottleState { interface ThrottleState {
buffer: string; buffer: string;
@@ -509,10 +521,46 @@ export function registerAgentHandlers(ctx: IPCContext): void {
let engineUserMessage: MetonaMessage; let engineUserMessage: MetonaMessage;
try { try {
// 提示注入检测(安全模块)
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
//
// v0.8.2 P1-4 根治: 检测提前到用户消息落库**之前** —— 旧顺序先 saveMessage
// 后 detectriskScore≥7 阻断时恶意内容已持久化进会话历史(被阻断的消息
// 仍可在历史中读到、参与后续上下文)。检测只依赖消息内容本身,无需
// systemPrompt,前置无任何依赖障碍。
const injectionEnabled =
configService.get<boolean>('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 回归修复: 透传前端消息 idChatMessage.id 由 genMsgId 生成)—— // v0.7.4 回归修复: 透传前端消息 idChatMessage.id 由 genMsgId 生成)——
// 否则 DB 用 msg_<nanoid> 生成不同 id,用户对刚发送消息"仅保存" // 否则 DB 用 msg_<nanoid> 生成不同 id,用户对刚发送消息"仅保存"
// updateMessageContent 按 id 匹配)会 0 行更新失败。 // updateMessageContent 按 id 匹配)会 0 行更新失败。
// v0.8.2 P1-4: 位于注入检测之后 —— 被阻断的内容不进入会话历史
sessionService.saveMessage({ sessionService.saveMessage({
sessionId, sessionId,
role: 'user', role: 'user',
@@ -521,6 +569,15 @@ export function registerAgentHandlers(ctx: IPCContext): void {
id: (userMessage as MetonaMessage & { id?: string }).id, 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: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文] // P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1); history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
@@ -600,39 +657,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
return { success: false, error: prepErr }; return { success: false, error: prepErr };
} }
// 提示注入检测在 try 内执行(需 systemPrompt 已构建,与引擎运行同域 // 提示注入检测已前移至数据准备 try 块的开头(v0.8.2 P1-4:先检测后落库
try { try {
// 提示注入检测(安全模块)
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
const injectionEnabled =
configService.get<boolean>('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 层:记录上下文构建 // TRACE 层:记录上下文构建
sessionRecorder.recordContextBuilt(sessionId, { sessionRecorder.recordContextBuilt(sessionId, {
tokenCount: estimateMessagesTokens(history), tokenCount: estimateMessagesTokens(history),
@@ -813,13 +840,15 @@ export function registerAgentHandlers(ctx: IPCContext): void {
} }
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默) // v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
// v0.8.2 P2-4: 标题广播改走专用 session:updated 事件 —— 此前伪装成
// config:changed(合成 key session.title.<id>),语义混用、渲染层需特判。
if (output.terminationReason === 'completed') { if (output.terminationReason === 'completed') {
titleGenerator titleGenerator
.maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer) .maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer)
.then((title) => { .then((title) => {
if (title) { if (title) {
// 广播重命名结果,前端 Sidebar 实时刷新标题 // 广播重命名结果,前端 Sidebar 实时刷新标题
broadcast('config:changed', { key: `session.title.${sessionId}`, value: title }); broadcastSessionUpdated(sessionId, { title });
} }
}) })
.catch(() => { .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 层:停止录制 // TOOL 层:记录会话结束 / TRACE 层:停止录制
auditService.logSessionEnd({ auditService.logSessionEnd({
sessionId, sessionId,
+18 -3
View File
@@ -10,6 +10,8 @@ import type { IPCContext } from './context';
import type { AuditEventType } from '../services/audit.service'; import type { AuditEventType } from '../services/audit.service';
import { UpdateService } from '../services/update.service'; import { UpdateService } from '../services/update.service';
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard'; 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'; import log from 'electron-log';
export function registerAppHandlers(ctx: IPCContext): void { 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.0 P2-3: 下载并安装更新(electron-updater;未启用时明确失败) =====
// v0.8.2 P1-3: 语义拆分 —— updateInstall 仅下载(完成后广播 downloaded
// 不再自动重启退出);重启安装由用户确认后经 app:updateInstallNow 触发
ipcMain.handle('app:updateInstall', async () => { ipcMain.handle('app:updateInstall', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service'); const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle(); const handle = getAutoUpdaterHandle();
if (!handle) { 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(); void handle.downloadAndInstall();
return { success: true }; 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 指标 + 最近健康检查报告)===== // ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)=====
ipcMain.handle('app:healthSnapshot', async () => { ipcMain.handle('app:healthSnapshot', async () => {
try { try {
@@ -116,7 +131,7 @@ export function registerAppHandlers(ctx: IPCContext): void {
const result = await dialog.showOpenDialog(callerWin, { const result = await dialog.showOpenDialog(callerWin, {
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
defaultPath: defaultPath ?? app.getPath('home'), defaultPath: defaultPath ?? app.getPath('home'),
title: '选择工作空间目录', title: mt('app.dialog.selectWorkspace.title'),
}); });
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' }; if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
return { canceled: false, path: result.filePaths[0] }; return { canceled: false, path: result.filePaths[0] };
+14
View File
@@ -53,6 +53,20 @@ export function registerMCPHandlers(ctx: IPCContext): void {
) { ) {
return { success: false, error: 'command is required for stdio transport' }; 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 // sse / streamable-http 类型必须有合法 url
if (config.transport === 'sse' || config.transport === 'streamable-http') { if (config.transport === 'sse' || config.transport === 'streamable-http') {
if (typeof config.url !== 'string' || !config.url.trim()) { if (typeof config.url !== 'string' || !config.url.trim()) {
+8 -2
View File
@@ -10,6 +10,8 @@ import log from 'electron-log';
import type { IPCContext } from './context'; import type { IPCContext } from './context';
import { broadcast } from './context'; import { broadcast } from './context';
import { isSensitiveConfigKey } from '../utils/secure-config'; import { isSensitiveConfigKey } from '../utils/secure-config';
// v0.8.2 P1-5: 掩码实现单源
import { maskSensitiveValue } from '../utils/mask';
// v0.8.1 P2-1: 工具自定义策略解析 // v0.8.1 P2-1: 工具自定义策略解析
import { parseToolPolicy } from '../harness/sandbox/permissions'; import { parseToolPolicy } from '../harness/sandbox/permissions';
// v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过) // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
@@ -77,10 +79,14 @@ export const LLM_CONFIG_KEYS = [
'llm.fallbackBaseURL', 'llm.fallbackBaseURL',
]; ];
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */ /**
* 4
* v0.8.2 P1-5: 掩码实现单源到 utils/mask.maskSensitiveValue
*
*/
export function maskSensitive(key: string, value: unknown): unknown { export function maskSensitive(key: string, value: unknown): unknown {
if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) { if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) {
return value.length > 4 ? '***' + value.slice(-4) : '***'; return maskSensitiveValue(value);
} }
return value; return value;
} }
+6 -4
View File
@@ -8,6 +8,8 @@ import { ipcMain } from 'electron';
import { join } from 'path'; import { join } from 'path';
import type { IPCContext } from './context'; import type { IPCContext } from './context';
import log from 'electron-log'; import log from 'electron-log';
// v0.8.2 P2-5: 工作空间校验文案出层(ui.locale 驱动)
import { mt } from '../utils/main-locale';
export function registerWorkspaceHandlers(ctx: IPCContext): void { export function registerWorkspaceHandlers(ctx: IPCContext): void {
const { workspaceService } = ctx; const { workspaceService } = ctx;
@@ -43,7 +45,7 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在 // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => { ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof 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'); const { existsSync, statSync } = await import('fs');
@@ -60,7 +62,7 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
missingFiles: ['SOUL.md', 'MEMORY.md'], missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true, isNewWorkspace: true,
dbExists: false, dbExists: false,
reason: '目录不存在,将在切换后自动创建', reason: mt('workspace.reason.notExists'),
}; };
} }
@@ -68,10 +70,10 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
try { try {
const stat = statSync(resolvedPath); const stat = statSync(resolvedPath);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
return { valid: false, reason: '路径不是目录' }; return { valid: false, reason: mt('workspace.reason.notDirectory') };
} }
} catch { } catch {
return { valid: false, reason: '无法访问路径' }; return { valid: false, reason: mt('workspace.reason.inaccessible') };
} }
// 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md // 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md
+8 -4
View File
@@ -546,7 +546,10 @@ async function initialize(): Promise<void> {
// 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回 // 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回
// nullMemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。 // nullMemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。
// adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。 // adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。
// v0.8.2 P3-1: 注入 modelName 指纹 —— 用户更换 embedding 模型后旧向量按
// 模型不匹配处理(检索时惰性重算自愈),不再产生跨模型噪声分数。
memoryManager.setEmbedder({ memoryManager.setEmbedder({
modelName: (configService.get<string>('memory.embeddingModel') ?? '').trim() || undefined,
embed: async (text) => { embed: async (text) => {
const adapter = agentEngineManager.getAdapter(); const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof OllamaAdapter)) return null; if (!(adapter instanceof OllamaAdapter)) return null;
@@ -1052,10 +1055,11 @@ if (!gotSingleInstanceLock) {
log.error('[Startup] Initialization failed:', err); log.error('[Startup] Initialization failed:', err);
try { try {
dialog.showErrorBox( dialog.showErrorBox(
'MetonaAI Desktop 启动失败', // v0.8.2 P2-5: 启动失败对话框文案出层(ui.locale 已随配置初始化)
`初始化过程中发生错误,应用即将退出。\n\n${(err as Error)?.message ?? String(err)}\n\n` + mt('app.dialog.startupFailed.title'),
'可能原因:工作空间目录不可写、数据库文件损坏。\n' + mt('app.dialog.startupFailed.body', {
'可尝试在设置中切换工作空间路径后重新启动。', message: (err as Error)?.message ?? String(err),
}),
); );
} catch { } catch {
// showErrorBox 失败(极端环境)时仅保留日志 // showErrorBox 失败(极端环境)时仅保留日志
+11 -1
View File
@@ -100,6 +100,13 @@ const metonaAPI = {
saveTrace: (sessionId: string, data: unknown) => saveTrace: (sessionId: string, data: unknown) =>
ipcRenderer.invoke('sessions:saveTrace', sessionId, data), ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId), 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 管理 ===== // ===== MCP 管理 =====
@@ -278,9 +285,12 @@ const metonaAPI = {
| { status: 'up-to-date'; latestVersion: string } | { status: 'up-to-date'; latestVersion: string }
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: 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: () => updateInstall: () =>
ipcRenderer.invoke('app:updateInstall') as Promise<{ success: boolean; error?: string }>, 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 // v0.8.0 P2-3: 订阅更新状态事件(checking/available/downloading/downloaded/error
onUpdateStatus: (callback: (event: unknown) => void) => { onUpdateStatus: (callback: (event: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
+7 -1
View File
@@ -15,6 +15,8 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import log from 'electron-log'; import log from 'electron-log';
// v0.8.2 P1-5: 审计 args 深度脱敏(与配置层脱敏同源)
import { deepMaskSensitive } from '../utils/mask';
/** /**
* #36 修复: 稳定序列化 key * #36 修复: 稳定序列化 key
@@ -194,6 +196,10 @@ export class AuditService {
/** /**
* *
*
* v0.8.2 P1-5: args //token
* audit_logssafeStorage
* utils/mask secure-config
*/ */
logToolCall(params: { logToolCall(params: {
sessionId: string; sessionId: string;
@@ -212,7 +218,7 @@ export class AuditService {
actor: 'agent', actor: 'agent',
target: params.toolName, target: params.toolName,
details: { details: {
args: params.args, args: deepMaskSensitive(params.args),
result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result, result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result,
error: params.error, error: params.error,
}, },
+9 -1
View File
@@ -132,8 +132,9 @@ export class DatabaseService {
* 12 llm.contextWindow Provider ollama.numCtx * 12 llm.contextWindow Provider ollama.numCtx
* v0.8.1 review: 4 5 13working_memories sessions CASCADE * v0.8.1 review: 4 5 13working_memories sessions CASCADE
* schema * schema
* v0.8.2: 5 6 14 embedding_model
*/ */
static readonly SCHEMA_VERSION = 5; static readonly SCHEMA_VERSION = 6;
constructor(workspacePath?: string) { constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); 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 // v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild // 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的 // 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
+79 -3
View File
@@ -23,7 +23,15 @@
import { app } from 'electron'; import { app } from 'electron';
import { join } from 'path'; 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 log from 'electron-log';
import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service'; import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service';
import { import {
@@ -34,6 +42,8 @@ import {
/** 全局配置文件路径(userData 下,与工作空间无关) */ /** 全局配置文件路径(userData 下,与工作空间无关) */
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json'); 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 视为全局配置) */ /** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */
const GLOBAL_KEY_PREFIXES = [ const GLOBAL_KEY_PREFIXES = [
@@ -105,7 +115,28 @@ export class GlobalConfigService {
try { try {
if (existsSync(GLOBAL_CONFIG_FILE)) { if (existsSync(GLOBAL_CONFIG_FILE)) {
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8'); 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), // v0.8.1 review: 清除已废除的配置键(分 Provider contextWindow / ollama.numCtx),
// 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活 // 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活
let purged = 0; let purged = 0;
@@ -140,6 +171,23 @@ export class GlobalConfigService {
this.initialized = true; 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 * P0-1: 敏感 key
*/ */
@@ -236,12 +284,40 @@ export class GlobalConfigService {
/** /**
* JSON * 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 { private flush(): void {
const tmpPath = `${GLOBAL_CONFIG_FILE}.tmp_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 8)}`;
try { 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) { } catch (err) {
log.error('[GlobalConfig] Failed to write config file:', 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}`);
} }
} }
} }
+112 -11
View File
@@ -23,7 +23,7 @@ import type Database from 'better-sqlite3';
import log from 'electron-log'; import log from 'electron-log';
import type { ToolRegistry } from '../harness/tools/registry'; import type { ToolRegistry } from '../harness/tools/registry';
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool'; 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'; import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用) // v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用)
import { buildSafeChildEnv } from '../utils/safe-env'; import { buildSafeChildEnv } from '../utils/safe-env';
@@ -289,24 +289,53 @@ class MCPToolAdapter implements IMetonaTool {
name: this.mcpTool.name, name: this.mcpTool.name,
arguments: args, arguments: args,
}); });
return result.content; const content = result.content as Array<Record<string, unknown>> | 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 * 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<string, unknown>): MetonaToolDef['parameters'] { private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
const properties: Record< const properties: Record<string, MetonaToolDef['parameters']['properties'][string]> = {};
string,
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
> = {};
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>; const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const [key, prop] of Object.entries(schemaProps)) { for (const [key, prop] of Object.entries(schemaProps)) {
properties[key] = { properties[key] = this.convertSchemaField(prop);
type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string',
description: (prop.description as string) ?? '',
};
} }
return { return {
@@ -315,6 +344,43 @@ class MCPToolAdapter implements IMetonaTool {
required: schema.required as string[] | undefined, required: schema.required as string[] | undefined,
}; };
} }
/** 单个参数字段递归转换(P2-7 schema 保真) */
private convertSchemaField(prop: Record<string, unknown>): 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<string, unknown>);
}
if (prop.properties && typeof prop.properties === 'object') {
const nested: Record<string, MetonaParamField> = {};
for (const [k, v] of Object.entries(
prop.properties as Record<string, Record<string, unknown>>,
)) {
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<Record<string, unknown>>).map((v) =>
this.convertSchemaField(v),
);
}
}
if (prop.default !== undefined) {
field.default = prop.default;
}
return field;
}
} }
// ===== MCP Manager ===== // ===== MCP Manager =====
@@ -819,6 +885,11 @@ export class MCPManager {
/** /**
* Server * Server
*
* v0.8.2 P2-7 根治: 已配置但**** server
* initialize() enabled=1getServerStates servers Map
* "已配置但禁用" DB status=disconnected
* enabled=false enabled
*/ */
getServerStates(): Array<{ getServerStates(): Array<{
name: string; name: string;
@@ -827,15 +898,45 @@ export class MCPManager {
error?: string; error?: string;
/** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */ /** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */
reconnectAttempt?: number; 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<string, boolean>();
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, name: s.config.name,
status: s.status, status: s.status,
toolCount: s.tools.length, toolCount: s.tools.length,
error: s.error, error: s.error,
reconnectAttempt: reconnectAttempt:
s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined, 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;
} }
/** /**
+9 -4
View File
@@ -14,6 +14,8 @@ import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'elect
import { join } from 'path'; import { join } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import log from 'electron-log'; 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'; export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error';
@@ -202,12 +204,15 @@ export class TrayManager {
enabled: false, 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, enabled: false,
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: '显示窗口', label: mt('tray.menu.showWindow'),
click: () => { click: () => {
if (this.mainWindow) { if (this.mainWindow) {
this.mainWindow.show(); this.mainWindow.show();
@@ -216,7 +221,7 @@ export class TrayManager {
}, },
}, },
{ {
label: '新建会话', label: mt('tray.menu.newSession'),
click: () => { click: () => {
if (this.mainWindow) { if (this.mainWindow) {
this.mainWindow.show(); this.mainWindow.show();
@@ -227,7 +232,7 @@ export class TrayManager {
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: '退出', label: mt('tray.menu.quit'),
click: () => { click: () => {
TrayManager.isQuitting = true; TrayManager.isQuitting = true;
app.quit(); app.quit();
+16
View File
@@ -108,7 +108,13 @@ export class UpdateService {
/** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed */ /** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed */
export interface AutoUpdaterHandle { export interface AutoUpdaterHandle {
checkForUpdates: () => Promise<void>; checkForUpdates: () => Promise<void>;
/** 仅下载更新包(下载完成后广播 downloaded,不自动重启) */
downloadAndInstall: () => Promise<void>; downloadAndInstall: () => Promise<void>;
/**
* v0.8.2 P1-3: 安装已下载的更新并重启应用
* downloaded
*/
installNow: () => void;
} }
let activeHandle: AutoUpdaterHandle | null = null; let activeHandle: AutoUpdaterHandle | null = null;
@@ -173,6 +179,16 @@ export async function startAutoUpdater(
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
onEvent({ status: 'downloaded' }); 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(); autoUpdater.quitAndInstall();
} catch (err) { } catch (err) {
onEvent({ status: 'error', message: (err as Error).message }); onEvent({ status: 'error', message: (err as Error).message });
+94 -3
View File
@@ -10,9 +10,9 @@
* @see docs/MetonaAI-Desktop UI UX .html * @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 { join } from 'path';
import { existsSync } from 'fs'; import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { is } from '@electron-toolkit/utils'; import { is } from '@electron-toolkit/utils';
import log from 'electron-log'; import log from 'electron-log';
@@ -24,6 +24,9 @@ export interface WindowState {
isMaximized?: boolean; isMaximized?: boolean;
} }
/** v0.8.2 P3-5: 窗口状态持久化文件(userData 下,机器级) */
const WINDOW_STATE_FILE = join(app.getPath('userData'), 'window-state.json');
export class WindowManager { export class WindowManager {
private windows = new Map<string, BrowserWindow>(); private windows = new Map<string, BrowserWindow>();
private activeWindowId: string | null = null; private activeWindowId: string | null = null;
@@ -51,7 +54,8 @@ export class WindowManager {
beforeLoad?: (win: BrowserWindow) => void; beforeLoad?: (win: BrowserWindow) => void;
}): BrowserWindow { }): BrowserWindow {
const id = options.id ?? `window_${Date.now()}`; 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({ const win = new BrowserWindow({
width: state.width, width: state.width,
@@ -94,8 +98,42 @@ export class WindowManager {
win.show(); 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', () => { win.on('closed', () => {
this.windows.delete(id); this.windows.delete(id);
// v0.8.2 P3-5: 崩溃自愈退避记录同步清理(窗口销毁后 Map 条目残留属泄漏)
this.crashReloadAttempts.delete(win.id);
if (this.activeWindowId === id) { if (this.activeWindowId === id) {
this.activeWindowId = this.activeWindowId =
this.windows.size > 0 ? (this.windows.keys().next().value ?? null) : null; this.windows.size > 0 ? (this.windows.keys().next().value ?? null) : null;
@@ -209,6 +247,59 @@ export class WindowManager {
return win; 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}`);
}
}
/** /**
* *
*/ */
+75
View File
@@ -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<string, string>).Authorization).toBe('***5678');
expect((out.headers as Record<string, string>)['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<string, unknown>
>;
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<string, unknown> = { name: 'a' };
a.self = a;
const out = deepMaskSensitive(a) as Record<string, unknown>;
expect(out.name).toBe('a');
expect(out.self).toBe('[circular]');
const deep: Record<string, unknown> = { v: 0 };
let cur = deep;
for (let i = 0; i < 20; i++) {
cur.next = { v: i + 1 };
cur = cur.next as Record<string, unknown>;
}
const deepOut = deepMaskSensitive(deep) as Record<string, unknown>;
expect(deepOut.v).toBe(0);
expect(JSON.stringify(deepOut)).toContain('depth-limit');
});
});
+40 -2
View File
@@ -71,7 +71,26 @@ const zh: Record<string, string> = {
'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写', 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写',
'config.error.workspaceSaveFailed': '工作空间路径保存失败:{{message}}', 'config.error.workspaceSaveFailed': '工作空间路径保存失败:{{message}}',
// ===== 记忆维护(v0.8.1 P1-2 ===== // ===== 记忆维护(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<string, string> = { const en: Record<string, string> = {
@@ -112,7 +131,26 @@ const en: Record<string, string> = {
'config.error.configIncomplete': 'config.error.configIncomplete':
'LLM config incomplete. Check that Provider, API Key, Base URL and Model are all filled in.', '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}}', '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<MainLocale, Record<string, string>> = { 'zh-CN': zh, 'en-US': en }; const DICTS: Record<MainLocale, Record<string, string>> = { 'zh-CN': zh, 'en-US': en };
+52
View File
@@ -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<T>(input: T, depth = 0, seen?: Set<object>): 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<object>();
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<string, unknown> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
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);
}
}
+6 -1
View File
@@ -60,7 +60,9 @@ export function resetEncryptionUsableForTests(): void {
encryptionUsable = null; encryptionUsable = null;
} }
/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) */ /** key IPC
* v0.8.2 P1-5: authorization / authkey / credential args
* HTTP Authorization */
const SENSITIVE_KEY_PATTERNS = [ const SENSITIVE_KEY_PATTERNS = [
'apikey', 'apikey',
'api_key', 'api_key',
@@ -69,6 +71,9 @@ const SENSITIVE_KEY_PATTERNS = [
'secret', 'secret',
'password', 'password',
'auth_key', 'auth_key',
'authkey',
'authorization',
'credential',
]; ];
/** /**
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.0", "version": "0.8.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.0", "version": "0.8.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -6140,9 +6140,9 @@
} }
}, },
"node_modules/entities": { "node_modules/entities": {
"version": "8.0.0", "version": "8.1.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmmirror.com/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.1", "version": "0.8.2",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
+31 -7
View File
@@ -83,6 +83,11 @@ export default function App(): React.JSX.Element {
}) })
.catch((err) => { .catch((err) => {
console.error('[App]', 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 // v0.7.2 P4-15: 恢复界面语言偏好(ui.locale;非法值回退默认 zh-CN
window.metona.config window.metona.config
@@ -110,6 +115,8 @@ export default function App(): React.JSX.Element {
} else { } else {
// 无 IPC 可用时也标记为已加载(避免永久禁用) // 无 IPC 可用时也标记为已加载(避免永久禁用)
useAgentStore.getState().setConfigLoaded(true); useAgentStore.getState().setConfigLoaded(true);
// v0.8.2 P3-4: 无 IPC 时同样解除向导门禁(避免永久隐藏)
useUIStore.getState().setOnboardingGateReady(true);
} }
// v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) // v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发)
@@ -117,7 +124,7 @@ export default function App(): React.JSX.Element {
// v0.3.18 修复: 注册监听器后立即查询一次,解决 tools:ready 事件在监听器注册前发出的竞态 // v0.3.18 修复: 注册监听器后立即查询一次,解决 tools:ready 事件在监听器注册前发出的竞态
if (window.metona?.tools?.onReady) { if (window.metona?.tools?.onReady) {
const unsubscribe = window.metona.tools.onReady((data) => { 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); useAgentStore.getState().setToolsReady(true);
}); });
// 竞态保护: 事件可能在监听器注册前已发出,主动查询当前状态 // 竞态保护: 事件可能在监听器注册前已发出,主动查询当前状态
@@ -125,7 +132,7 @@ export default function App(): React.JSX.Element {
.isReady?.() .isReady?.()
.then((status) => { .then((status) => {
if (status.ready) { 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); useAgentStore.getState().setToolsReady(true);
} }
}) })
@@ -166,13 +173,30 @@ export default function App(): React.JSX.Element {
}} }}
> >
{/* H-7 修复: 添加 Header Bar — 规范要求布局为 Header (全宽) + [Sidebar|ChatPanel|DetailPanel] + StatusBar */} {/* H-7 修复: 添加 Header Bar — 规范要求布局为 Header (全宽) + [Sidebar|ChatPanel|DetailPanel] + StatusBar */}
<Header /> {/* v0.8.2 P0-4: +/ ErrorBoundary
DetailPanel Tab
使 */}
<ErrorBoundary fallbackTitle={t('errorBoundary.header')}>
<Header />
</ErrorBoundary>
<div className="flex-1 flex overflow-hidden min-h-0"> <div className="flex-1 flex overflow-hidden min-h-0">
{sidebarVisible && <Sidebar />} {sidebarVisible && (
<ChatPanel /> <ErrorBoundary fallbackTitle={t('errorBoundary.sidebar')}>
{detailVisible && <DetailPanel />} <Sidebar />
</ErrorBoundary>
)}
<ErrorBoundary fallbackTitle={t('errorBoundary.chat')}>
<ChatPanel />
</ErrorBoundary>
{detailVisible && (
<ErrorBoundary fallbackTitle={t('errorBoundary.detail')}>
<DetailPanel />
</ErrorBoundary>
)}
</div> </div>
<StatusBar /> <ErrorBoundary fallbackTitle={t('errorBoundary.statusBar')}>
<StatusBar />
</ErrorBoundary>
<SettingsModal /> <SettingsModal />
<OnboardingWizard /> <OnboardingWizard />
<CommandPalette /> <CommandPalette />
+11 -9
View File
@@ -27,6 +27,8 @@ import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import // v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n'; import { t } from '@renderer/lib/i18n';
// v0.8.2 P0-4: 引用回复走受控输入桥
import { appendToChatInput } from '@renderer/lib/chat-input-bridge';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
// v0.6.4 死代码清理:'tool-call' / 'code-block' / 'trace-step' 三个从未被任何组件 // v0.6.4 死代码清理:'tool-call' / 'code-block' / 'trace-step' 三个从未被任何组件
@@ -261,15 +263,15 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
icon: Quote, icon: Quote,
label: t('contextMenu.quote'), label: t('contextMenu.quote'),
action: () => { action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]'); // v0.8.2 P0-4: 走受控输入桥 —— 旧实现 DOM 直查直赋(命中 InputBase
if (input) { // 根 div + 受控组件不吃注入),功能完全失效
input.value = const quoted =
content content
.split('\n') .split('\n')
.map((l: string) => `> ${l}`) .map((l: string) => `> ${l}`)
.join('\n') + '\n\n'; .join('\n') + '\n\n';
input.dispatchEvent(new Event('input', { bubbles: true })); if (!appendToChatInput(quoted)) {
input.focus(); copyWithToast(quoted);
} }
}, },
}, },
+55 -31
View File
@@ -7,6 +7,9 @@
*/ */
import { useEffect } from 'react'; 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 * Toast
@@ -19,35 +22,37 @@ import { useEffect } from 'react';
export function ToastContainer(): null { export function ToastContainer(): null {
useEffect(() => { useEffect(() => {
// 动态导入 metona-toast 并配置 // 动态导入 metona-toast 并配置
import('@metona-team/metona-toast').then((mod) => { import('@metona-team/metona-toast')
const MeToast = mod.default; .then((mod) => {
const MeToast = mod.default;
// 全局配置(设计规范指定的参数) // 全局配置(设计规范指定的参数)
MeToast.configure({ MeToast.configure({
position: 'top-right', position: 'top-right',
duration: 4000, duration: 4000,
max: 6, max: 6,
theme: 'auto', theme: 'auto',
animation: 'slide', animation: 'slide',
pauseOnHover: true, pauseOnHover: true,
closeOnClick: true, closeOnClick: true,
showProgress: true, showProgress: true,
draggable: true, draggable: true,
locale: 'zh-CN', 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) { if (window.metona?.toast?.onShow) {
const unsubscribe = window.metona.toast.onShow(async (data) => { 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 MeToast = (await import('@metona-team/metona-toast')).default;
const { type, message, options } = data; const { type, message, options } = data;
switch (type) { switch (type) {
case 'success': MeToast.success(message, options); break; case 'success':
case 'error': MeToast.error(message, options); break; MeToast.success(message, options);
case 'warning': MeToast.warning(message, options); break; break;
case 'info': MeToast.info(message, options); break; case 'error':
default: MeToast.info(message, options); 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) { } catch (err) {
console.error('[Toast]', 'Failed to show toast:', 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; return null;
} }
+12 -2
View File
@@ -11,7 +11,7 @@
* agentStatus thinking/executing/idle AssistantMessage * 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 { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material';
import { Bot, Copy, Check } from 'lucide-react'; import { Bot, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; 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 { function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// v0.8.2 P3-6: "已复制"复位定时器持有句柄并在卸载时清理 —— 裸 setTimeout 会在
// 组件卸载后触发 setStateReact 警告 / 测试环境 window 失效 unhandled error
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
},
[],
);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
@@ -318,7 +327,8 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
document.body.removeChild(ta); document.body.removeChild(ta);
} }
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, [code]); }, [code]);
return ( return (
+63 -3
View File
@@ -33,6 +33,8 @@ import { formatFileSize } from '@renderer/lib/formatters';
import { PROVIDER_LABELS } from '@renderer/lib/constants'; import { PROVIDER_LABELS } from '@renderer/lib/constants';
// v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测) // v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测)
import { supportsImageUpload } from '@renderer/lib/model-capabilities'; 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 // v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n'; import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
@@ -157,6 +159,29 @@ export function ChatInput(): React.JSX.Element {
if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input);
}, [input, currentSessionId]); }, [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'] => { const classifyFile = useCallback((file: File): Attachment['type'] => {
@@ -377,10 +402,45 @@ export function ChatInput(): React.JSX.Element {
} }
if (cmd === '/export') { if (cmd === '/export') {
// P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单 // P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单
// v0.8.2 P2-3 根治: 从 DB 全量拉取 —— v0.8.1 游标分页后内存 messages 仅
// 尾部 200 条窗口,旧实现静默导出残缺会话(与右键导出全量口径不一致)。
// DB 不可用(无会话/IPC 缺失)时回退内存窗口并提示。
import('@renderer/lib/export-markdown') import('@renderer/lib/export-markdown')
.then(({ buildSessionMarkdown, downloadMarkdown }) => { .then(async ({ buildSessionMarkdown, downloadMarkdown }) => {
const messages = useAgentStore.getState().messages; const sid = useAgentStore.getState().currentSessionId;
const md = buildSessionMarkdown(t('chat.export.title'), messages); let source: Array<{
id: string;
role: string;
content: string | null;
reasoningContent?: string;
toolCalls?: Array<{ name: string; args?: Record<string, unknown> }>;
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<string, unknown>;
}>,
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); downloadMarkdown(`session-${Date.now()}.md`, md);
}) })
.catch(() => {}); .catch(() => {});
+16 -1
View File
@@ -13,7 +13,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; 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 { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem'; import { MessageItem } from './MessageItem';
import { StreamingIndicator } from './StreamingIndicator'; import { StreamingIndicator } from './StreamingIndicator';
@@ -33,6 +33,20 @@ const ListFooter = (): React.JSX.Element => (
</Box> </Box>
); );
/**
* v0.8.2 P2-2: 向上翻页加载指示 ListFooter reconciliation
* loadingOlder store DB "像是没反应"
*/
const ListHeader = (): React.JSX.Element => {
const loadingOlder = useAgentStore((s) => s.loadingOlder);
if (!loadingOlder) return <Box sx={{ height: 8 }} />;
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1 }}>
<CircularProgress size={18} thickness={4} />
</Box>
);
};
export function MessageList(): React.JSX.Element { export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages); const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming); const isStreaming = useAgentStore((s) => s.isStreaming);
@@ -120,6 +134,7 @@ export function MessageList(): React.JSX.Element {
</Box> </Box>
)} )}
components={{ components={{
Header: ListHeader,
Footer: ListFooter, Footer: ListFooter,
}} }}
/> />
+164 -23
View File
@@ -1,9 +1,16 @@
/** /**
* ToolResultBlock * 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 { 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 type { ToolCallInfo } from '@renderer/stores/agent-store';
import { formatDuration } from '@renderer/lib/formatters'; import { formatDuration } from '@renderer/lib/formatters';
import { toDisplayResult } from '@renderer/lib/tool-result-display'; import { toDisplayResult } from '@renderer/lib/tool-result-display';
@@ -15,14 +22,125 @@ interface ToolResultBlockProps {
toolCall: ToolCallInfo; toolCall: ToolCallInfo;
} }
/** diff 视图最大渲染行数(超出截断,防 DOM 爆炸) */
const DIFF_MAX_LINES = 400;
/**
* **** result srcdisplayResult dataUrl
* dataUrlview_image / MCPimage 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<string, unknown>;
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;
// 裸 base64web_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_runpreview.diff
*/
function extractDiffText(display: unknown): string | null {
if (typeof display !== 'object' || display === null || Array.isArray(display)) return null;
const rec = display as Record<string, unknown>;
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<string, unknown>).diff === 'string' &&
((preview as Record<string, unknown>).diff as string).trim()
) {
return (preview as Record<string, unknown>).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 (
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
overflowY: 'auto',
bgcolor: 'background.default',
fontFamily: "'SF Mono',monospace",
maxHeight: 320,
m: 0,
whiteSpace: 'pre',
}}
>
{lines.map((line, i) => {
const isAdd = line.startsWith('+') && !line.startsWith('+++');
const isDel = line.startsWith('-') && !line.startsWith('---');
const isHunk = line.startsWith('@@');
return (
<Box
key={i}
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: isAdd
? 'success.dark'
: isDel
? 'error.dark'
: isHunk
? 'info.main'
: 'text.secondary',
bgcolor: isAdd
? 'rgba(52, 211, 153, 0.08)'
: isDel
? 'rgba(248, 113, 113, 0.08)'
: 'transparent',
fontWeight: isHunk ? 600 : 400,
}}
>
{line || ' '}
</Box>
);
})}
{allLines.length > DIFF_MAX_LINES && (
<Box sx={{ color: 'text.secondary', mt: 0.5 }}>
[ {allLines.length - DIFF_MAX_LINES} more lines]
</Box>
)}
</Box>
);
}
export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element { export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element {
if (toolCall.status !== 'success' || toolCall.result == null) return <></>; 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 看图能力不受影响) // store 内原始 result 不变(LLM 看图能力不受影响)
const displayResult = toDisplayResult(toolCall.result); const displayResult = toDisplayResult(toolCall.result);
const resultStr = const resultStr =
typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2); 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 ( return (
<Box <Box
sx={{ sx={{
@@ -39,7 +157,13 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
}} }}
> >
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<FileText size={12} style={{ color: '#22d3ee' }} /> {diffText ? (
<GitCompareArrows size={12} style={{ color: '#22d3ee' }} />
) : imageSrc ? (
<ImageIcon size={12} style={{ color: '#22d3ee' }} />
) : (
<FileText size={12} style={{ color: '#22d3ee' }} />
)}
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}> <Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
{t('toolResult.title', { name: toolCall.name })} {t('toolResult.title', { name: toolCall.name })}
</Typography> </Typography>
@@ -49,26 +173,43 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
</Typography> </Typography>
)} )}
</Stack> </Stack>
<Box {diffText ? (
component="pre" <DiffView diff={diffText} />
sx={{ ) : imageSrc ? (
fontSize: 11, <Box
borderRadius: 1, component="img"
px: 1, src={imageSrc}
py: 0.75, alt={t('toolResult.imageAlt')}
overflowX: 'auto', sx={{
overflowY: 'auto', maxWidth: '100%',
bgcolor: 'background.default', maxHeight: 320,
color: 'text.secondary', borderRadius: 1,
fontFamily: "'SF Mono',monospace", display: 'block',
maxHeight: 300, bgcolor: 'background.default',
m: 0, }}
whiteSpace: 'pre-wrap', />
wordBreak: 'break-all', ) : (
}} <Box
> component="pre"
{resultStr} sx={{
</Box> fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
overflowY: 'auto',
bgcolor: 'background.default',
color: 'text.secondary',
fontFamily: "'SF Mono',monospace",
maxHeight: 300,
m: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{resultStr}
</Box>
)}
</Box> </Box>
); );
} }
+1 -1
View File
@@ -51,7 +51,7 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
editContent.trim(), editContent.trim(),
); );
if (r?.success === false) { if (r?.success === false) {
throw new Error(r.error ?? '保存失败'); throw new Error(r.error ?? t('message.saveFailed'));
} }
} }
updateMessage(message.id, { content: editContent.trim() }); updateMessage(message.id, { content: editContent.trim() });
@@ -64,18 +64,84 @@ describe('ToolResultBlock — 结果渲染', () => {
}); });
}); });
describe('ToolResultBlock — dataUrl 剥离', () => { describe('ToolResultBlock — 富媒体渲染(v0.8.2 P2-1', () => {
it('含 dataUrl 字段时剥离并显示 _displayNote', () => { it('含 dataUrl 字段 → 内联 <img> 预览,base64 不再以文本进 DOM', () => {
const result = { const result = {
dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000), dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000),
path: '/tmp/screenshot.png', path: '/tmp/screenshot.png',
size: 1234, size: 1234,
}; };
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />); const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
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(
<ToolResultBlock toolCall={makeToolCall({ name: 'web_browser', result })} />,
);
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(
<ToolResultBlock toolCall={makeToolCall({ name: 'diff_viewer', result })} />,
);
const pre = container.querySelector('pre');
expect(pre?.textContent).toContain('added line');
expect(pre?.textContent).toContain('removed line');
});
it('file_editor dry_runpreview.diff)→ 同样走 diff 视图', () => {
const result = {
dry_run: true,
preview: {
original: 'x',
modified: 'y',
diff: ['--- old', '+++ new', '@@ -1 +1 @@', '-x', '+y'].join('\n'),
},
};
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
expect(container.querySelector('pre')?.textContent).toContain('+y');
});
it('_imageOmittedregistry 判定超限占位)→ 不渲染 <img>', () => {
const result = { image: 'data:image/png;base64,AAAA', _imageOmitted: true };
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
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(<ToolResultBlock toolCall={makeToolCall({ result })} />);
// <img> 预览之外,任何文本节点都不得包含 base64 主体
const text = Array.from(container.querySelectorAll('pre'))
.map((el) => el.textContent)
.join('');
expect(text).not.toContain('data:image/png;base64'); 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('普通大字符串被截断显示(防御裁剪)', () => { it('普通大字符串被截断显示(防御裁剪)', () => {
+19 -4
View File
@@ -75,13 +75,28 @@ export function DetailPanel(): React.JSX.Element {
'& .MuiTabs-indicator': { height: 2 }, '& .MuiTabs-indicator': { height: 2 },
}} }}
> >
<Tab icon={<Activity size={12} />} iconPosition="start" label="Trace" value="trace" /> <Tab
<Tab icon={<Brain size={12} />} iconPosition="start" label="Memory" value="memory" /> icon={<Activity size={12} />}
<Tab icon={<ListChecks size={12} />} iconPosition="start" label="Tasks" value="tasks" /> iconPosition="start"
label={t('detailPanel.tab.trace')}
value="trace"
/>
<Tab
icon={<Brain size={12} />}
iconPosition="start"
label={t('detailPanel.tab.memory')}
value="memory"
/>
<Tab
icon={<ListChecks size={12} />}
iconPosition="start"
label={t('detailPanel.tab.tasks')}
value="tasks"
/>
<Tab <Tab
icon={<FolderOpen size={12} />} icon={<FolderOpen size={12} />}
iconPosition="start" iconPosition="start"
label="Workspace" label={t('detailPanel.tab.workspace')}
value="workspace" value="workspace"
/> />
</Tabs> </Tabs>
+19 -12
View File
@@ -96,14 +96,20 @@ export function Sidebar(): React.JSX.Element {
}; };
}, []); }, []);
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播 // v0.8.2 P2-4 根治: 会话元数据实时刷新 —— ① 标题生成结果改走专用
// (合成 keysession.title.<sessionId>),此处消费并刷新侧栏标题 // session:updated 事件(此前伪装成 config:changed 合成 key,语义混用);
// ② messageCount/updatedAt 随主进程落库实时刷新(此前仅挂载时 list() 一次,
// "x 条消息 / 3 分钟前"停留在旧值直到重启)。
useEffect(() => { useEffect(() => {
if (!window.metona?.config?.onChanged) return; if (!window.metona?.sessions?.onSessionUpdated) return;
const unsubscribe = window.metona.config.onChanged((data) => { const unsubscribe = window.metona.sessions.onSessionUpdated((data) => {
const match = /^session\.title\.(.+)$/.exec(data.key); if (!data?.sessionId) return;
if (match && typeof data.value === 'string' && data.value) { const patch: { title?: string; messageCount?: number; updatedAt?: number } = {};
useSessionStore.getState().updateSession(match[1], { title: data.value }); 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; return unsubscribe;
@@ -788,13 +794,14 @@ function ToolManagerPanel() {
medium: 'warning.main', medium: 'warning.main',
high: 'error.main', high: 'error.main',
}; };
// v0.8.2 P2-5: 风险标签出层(此前硬编码英文 SAFE/LOW/...
const riskLabels: Record<string, string> = { const riskLabels: Record<string, string> = {
safe: 'SAFE', safe: t('sidebar.risk.safe'),
low: 'LOW', low: t('sidebar.risk.low'),
medium: 'MEDIUM', medium: t('sidebar.risk.medium'),
high: 'HIGH', high: t('sidebar.risk.high'),
// v0.8.0 P1-3.8: 补 critical —— 旧映射缺失导致 CRITICAL 工具显示原始字符串 // v0.8.0 P1-3.8: 补 critical —— 旧映射缺失导致 CRITICAL 工具显示原始字符串
critical: 'CRITICAL', critical: t('sidebar.risk.critical'),
}; };
return ( return (
<Box> <Box>
@@ -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<string, unknown> = {}): {
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<string, unknown> }).metona;
bridge.sessions = {
...(bridge.sessions as Record<string, unknown>),
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(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('第一个会话')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
});
it('渲染搜索框与新建会话按钮', () => {
render(<Sidebar />);
expect(screen.getByPlaceholderText(/搜索|Search/)).toBeInTheDocument();
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('接收 session:updated 后实时刷新条数(P2-4', async () => {
render(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
const input = screen.getByPlaceholderText(/搜索|Search/);
await user.type(input, '关键词');
await waitFor(
() => {
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
expect(bridge.sessions).toBeDefined();
expect(
(bridge.sessions as { searchContent: { mock: { calls: unknown[][] } } }).searchContent
.mock.calls.length,
).toBeGreaterThan(0);
},
{ timeout: 2_000 },
);
});
});
+24 -1
View File
@@ -47,6 +47,9 @@ const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
export function OnboardingWizard(): React.JSX.Element | null { export function OnboardingWizard(): React.JSX.Element | null {
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted); const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted); 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 [step, setStep] = useState(0);
const [provider, setProvider] = useState(''); const [provider, setProvider] = useState('');
// v0.5.4: 多模态总开关(保存到 llm.multimodalEnabled,控制图片上传入口) // v0.5.4: 多模态总开关(保存到 llm.multimodalEnabled,控制图片上传入口)
@@ -110,7 +113,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
} }
}, [step, provider, baseURL, model, workspacePath, contextWindow]); }, [step, provider, baseURL, model, workspacePath, contextWindow]);
if (onboardingCompleted) return null; if (onboardingCompleted || !onboardingGateReady) return null;
// v0.8.1 硬性契约: 上下文长度是全局单一配置(llm.contextWindow),不再存在 // v0.8.1 硬性契约: 上下文长度是全局单一配置(llm.contextWindow),不再存在
// 分 Provider 默认值 —— 删除了旧的 DEFAULT_CTX 写死表(1M/128K/200K/4096)。 // 分 Provider 默认值 —— 删除了旧的 DEFAULT_CTX 写死表(1M/128K/200K/4096)。
@@ -119,7 +122,27 @@ export function OnboardingWizard(): React.JSX.Element | null {
const ctxError = const ctxError =
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin); contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
// v0.8.2 P3-4: 步骤字段校验门禁 —— 此前"下一步"无任何校验,可以全空走完,
// 完成后 provider/model 缺失 → 首次发送必然 adapter 加载失败。LLM 配置步
// 强制 provider + model+ apiKeyollama 除外)+ 合法上下文长度。
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 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) { if (step < STEPS.length - 1) {
setStep(step + 1); setStep(step + 1);
return; return;
+4 -4
View File
@@ -107,10 +107,10 @@ export function AgentSettings() {
label={t('settings.agent.thinkingEffort')} label={t('settings.agent.thinkingEffort')}
onChange={(e) => setThinkingEffort(e.target.value)} onChange={(e) => setThinkingEffort(e.target.value)}
> >
<MenuItem value="low">Low</MenuItem> <MenuItem value="low">{t('agentSettings.effort.low')}</MenuItem>
<MenuItem value="medium">Medium</MenuItem> <MenuItem value="medium">{t('agentSettings.effort.medium')}</MenuItem>
<MenuItem value="high">High</MenuItem> <MenuItem value="high">{t('agentSettings.effort.high')}</MenuItem>
<MenuItem value="max">Max</MenuItem> <MenuItem value="max">{t('agentSettings.effort.max')}</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
)} )}
+16
View File
@@ -506,14 +506,18 @@ function UpdatePanel(): React.JSX.Element {
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
const [result, setResult] = useState<string | null>(null); const [result, setResult] = useState<string | null>(null);
const [progress, setProgress] = useState<number | null>(null); const [progress, setProgress] = useState<number | null>(null);
// v0.8.2 P1-3: 更新包已下载、等待用户确认重启安装
const [downloadedReady, setDownloadedReady] = useState(false);
useEffect(() => { useEffect(() => {
if (!window.metona?.app?.onUpdateStatus) return; if (!window.metona?.app?.onUpdateStatus) return;
const off = window.metona.app.onUpdateStatus((event) => { const off = window.metona.app.onUpdateStatus((event) => {
if (event.status === 'downloading') { if (event.status === 'downloading') {
setProgress(event.percent ?? 0); setProgress(event.percent ?? 0);
setDownloadedReady(false);
} else if (event.status === 'downloaded') { } else if (event.status === 'downloaded') {
setProgress(100); setProgress(100);
setDownloadedReady(true);
setResult(t('settings.logs.update.downloaded')); setResult(t('settings.logs.update.downloaded'));
} else if (event.status === 'available') { } else if (event.status === 'available') {
setResult(t('settings.logs.update.available', { version: event.latestVersion ?? '' })); setResult(t('settings.logs.update.available', { version: event.latestVersion ?? '' }));
@@ -584,6 +588,18 @@ function UpdatePanel(): React.JSX.Element {
: t('settings.logs.update.downloadInstall')} : t('settings.logs.update.downloadInstall')}
</Button> </Button>
</Stack> </Stack>
{downloadedReady && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => {
void window.metona?.app?.updateInstallNow();
}}
>
{t('settings.logs.update.restartToInstall')}
</Button>
)}
{progress != null && ( {progress != null && (
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
+15
View File
@@ -224,6 +224,21 @@ export function MCPSettings() {
<Typography variant="caption" sx={{ color: statusColors[s.status] }}> <Typography variant="caption" sx={{ color: statusColors[s.status] }}>
{statusLabel(s)} {statusLabel(s)}
</Typography> </Typography>
{/* v0.8.2 P2-7: 禁用态 server 现在也会出现在列表中(后端补齐),显式标注 */}
{(s as { enabled?: boolean }).enabled === false && (
<Typography
variant="caption"
sx={{
color: 'text.disabled',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
px: 0.5,
}}
>
{t('settings.mcp.disabled')}
</Typography>
)}
{s.toolCount > 0 && ( {s.toolCount > 0 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('settings.mcp.toolCount', { count: s.toolCount })} {t('settings.mcp.toolCount', { count: s.toolCount })}
@@ -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<string, unknown> = {
'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<string, unknown> }).metona;
bridge.config = {
...(bridge.config as Record<string, unknown>),
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<string, unknown>),
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<void> {
render(<LLMSettings />);
// 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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).metona;
bridge.config = {
...(bridge.config as Record<string, unknown>),
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();
});
});
+21 -1
View File
@@ -245,8 +245,28 @@ export function TokenUsage(): React.JSX.Element {
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{tokenUsage.cacheMissTokens != null && (
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('tokenUsage.cacheMiss')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{formatTokens(tokenUsage.cacheMissTokens)}
</TableCell>
</TableRow>
)}
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('tokenUsage.iterations')}
</TableCell>
<TableCell <TableCell
sx={{ sx={{
fontFamily: 'monospace', fontFamily: 'monospace',
+1 -1
View File
@@ -151,7 +151,7 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
variant="caption" variant="caption"
sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }} sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}
> >
💭 Thought {t('trace.step.thought')}
</Typography> </Typography>
<Box <Box
component="pre" component="pre"
+15 -4
View File
@@ -25,15 +25,22 @@ interface RunGroup {
steps: TraceStepType[]; steps: TraceStepType[];
isCurrent: boolean; // 是否为当前正在进行的 run isCurrent: boolean; // 是否为当前正在进行的 run
isCompleted: boolean; // 是否已完成(最后一个 step 有 completedAt isCompleted: boolean; // 是否已完成(最后一个 step 有 completedAt
/** v0.8.2 P2-2: 无 runId 的 legacy 步骤分组(旧版本会话恢复的 trace) */
isLegacy: boolean;
} }
/** v0.8.2 P2-2: legacy 步骤的分组键(runId 字段在 v0.8.0 前不存在) */
const LEGACY_RUN_ID = '__legacy__';
function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): RunGroup[] { function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): RunGroup[] {
const runOrder: string[] = []; const runOrder: string[] = [];
const runMap = new Map<string, TraceStepType[]>(); const runMap = new Map<string, TraceStepType[]>();
for (const step of traceSteps) { for (const step of traceSteps) {
const rid = step.runId; // v0.8.2 P2-2 根治: 无 runId 的 legacy 步骤此前被直接丢弃
if (!rid) continue; //`if (!rid) continue`)—— 升级用户的旧会话在 Trace 面板整体空白,
// 数据在、UI 不可见。现归入独立的 legacy 分组按原顺序展示。
const rid = step.runId || LEGACY_RUN_ID;
if (!runMap.has(rid)) { if (!runMap.has(rid)) {
runMap.set(rid, []); runMap.set(rid, []);
runOrder.push(rid); runOrder.push(rid);
@@ -54,6 +61,7 @@ function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): R
steps, steps,
isCurrent: rid === effectiveCurrentRunId, isCurrent: rid === effectiveCurrentRunId,
isCompleted: Boolean(lastStep?.completedAt), isCompleted: Boolean(lastStep?.completedAt),
isLegacy: rid === LEGACY_RUN_ID,
}; };
}); });
} }
@@ -96,7 +104,7 @@ export function TraceViewer(): React.JSX.Element {
color: 'text.secondary', color: 'text.secondary',
}} }}
> >
Trace Viewer {t('trace.viewer.title')}
</Typography> </Typography>
{!isEmpty && ( {!isEmpty && (
<Typography variant="caption" sx={{ color: 'text.disabled', ml: 'auto' }}> <Typography variant="caption" sx={{ color: 'text.disabled', ml: 'auto' }}>
@@ -161,7 +169,10 @@ export function TraceViewer(): React.JSX.Element {
letterSpacing: 0.5, 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 })}
</Typography> </Typography>
{group.isCurrent && agentStatus !== 'idle' && ( {group.isCurrent && agentStatus !== 'idle' && (
<Chip <Chip
+8 -1
View File
@@ -265,6 +265,13 @@ export function useAgentStream(): void {
store.updateMessage(targetId, { content: '', reasoningContent: '' }); store.updateMessage(targetId, { content: '', reasoningContent: '' });
} }
} }
// v0.8.2 P2-2: 同步清空当前 trace step 的 thought —— 消息侧已按卡片
// 精确清空,trace 侧此前半修:重试后旧思考内容残留在 TraceViewer 并
// 与重试后的新思考拼接。
const ts = store.traceSteps;
if (ts.length > 0) {
store.updateLastTraceStep({ thought: '' });
}
break; break;
} }
// 推理内容增量 // 推理内容增量
@@ -648,7 +655,7 @@ export function useAgentStream(): void {
// v0.8.0 P0-4: output_length_exceeded / invalid_response(空响应重试耗尽) // v0.8.0 P0-4: output_length_exceeded / invalid_response(空响应重试耗尽)
// 映射针对性文案(引擎 P0-2 空响应守卫的结构化错误码) // 映射针对性文案(引擎 P0-2 空响应守卫的结构化错误码)
const errorCode = data.error?.code; const errorCode = data.error?.code;
const errorMessage = data.error?.message ?? '未知错误'; const errorMessage = data.error?.message ?? t('agent.error.unknown');
const friendlyContent = const friendlyContent =
errorCode === 'content_filtered' errorCode === 'content_filtered'
? t('agent.error.content_filtered', { message: errorMessage }) ? t('agent.error.content_filtered', { message: errorMessage })
+5 -2
View File
@@ -11,6 +11,8 @@ import { useSessionStore } from '@renderer/stores/session-store';
import { toggleTheme } from './useTheme'; import { toggleTheme } from './useTheme';
// v0.8.1 P0-4: 文案出层(字典含注册副作用,须在 t() 使用前 import // v0.8.1 P0-4: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n'; 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'; import '@renderer/lib/i18n-strings';
/** /**
@@ -101,8 +103,9 @@ export function useKeyboardShortcuts(): void {
// Ctrl+L — 聚焦输入框 // Ctrl+L — 聚焦输入框
if (key === 'l' && matchModifier(e, true)) { if (key === 'l' && matchModifier(e, true)) {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]'); // v0.8.2 P0-4: 走受控输入桥(旧实现 querySelector 命中 InputBase 根 div
input?.focus(); // 无 tabindex 的 div 不可能获得焦点,快捷键完全失效)
focusChatInput();
e.preventDefault(); e.preventDefault();
return; return;
} }
+47
View File
@@ -0,0 +1,47 @@
/**
* v0.8.2 P0-4: ChatInput
*
* "引用回复 / Ctrl+L 聚焦输入框"
* `document.querySelector('[data-chat-input]')` MUI InputBase
* ** div** textarea div value/focus
* 使 textareaChatInput 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;
}
+8 -3
View File
@@ -8,13 +8,18 @@
*/ */
import { formatDistanceToNow, format } from 'date-fns'; 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(此前固定 zhCNen-US 用户看到中文)
import { getLocale } from './i18n';
// ===== 日期格式化(使用 date-fns===== // ===== 日期格式化(使用 date-fns=====
/** 相对时间(如 "3 分钟前"、"昨天" */ /** 相对时间(如 "3 分钟前"、"昨天";跟随界面语言 */
export function formatRelativeTime(timestamp: number): string { 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" */ /** 完整时间格式(如 "2026-06-27 14:30" */
+80 -2
View File
@@ -493,7 +493,8 @@ registerTranslations('zh-CN', {
'settings.logs.update.upToDate': '已是最新版本({{version}}', 'settings.logs.update.upToDate': '已是最新版本({{version}}',
'settings.logs.update.disabled': '未配置更新源(app.updateFeedUrl', 'settings.logs.update.disabled': '未配置更新源(app.updateFeedUrl',
'settings.logs.update.error': '更新失败:{{message}}', 'settings.logs.update.error': '更新失败:{{message}}',
'settings.logs.update.downloaded': '下载完成,即将重启安装', 'settings.logs.update.downloaded': '更新包已下载,可随时重启安装',
'settings.mcp.disabled': '已禁用',
'settings.logs.traceStats': 'settings.logs.traceStats':
'{{count}} 个文件 · 共 {{size}}(保留策略:最近 200 个,启动时自动清理)', '{{count}} 个文件 · 共 {{size}}(保留策略:最近 200 个,启动时自动清理)',
'settings.logs.statsLoading': '统计中...', 'settings.logs.statsLoading': '统计中...',
@@ -757,6 +758,48 @@ registerTranslations('zh-CN', {
'trace.viewer.inProgress': '进行中', 'trace.viewer.inProgress': '进行中',
'trace.viewer.completed': '已完成', 'trace.viewer.completed': '已完成',
'trace.viewer.steps': '{{count}} 步', '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===== // ===== 工作空间浏览器(WorkspaceViewer=====
'workspace.currentWorkspace': '当前工作空间', 'workspace.currentWorkspace': '当前工作空间',
@@ -1267,7 +1310,8 @@ registerTranslations('en-US', {
'settings.logs.update.upToDate': 'Up to date ({{version}})', 'settings.logs.update.upToDate': 'Up to date ({{version}})',
'settings.logs.update.disabled': 'No update feed configured (app.updateFeedUrl)', 'settings.logs.update.disabled': 'No update feed configured (app.updateFeedUrl)',
'settings.logs.update.error': 'Update failed: {{message}}', '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': 'settings.logs.traceStats':
'{{count}} file(s) · {{size}} total (retention: last 200, auto-cleaned on startup)', '{{count}} file(s) · {{size}} total (retention: last 200, auto-cleaned on startup)',
'settings.logs.statsLoading': 'Computing...', 'settings.logs.statsLoading': 'Computing...',
@@ -1538,6 +1582,40 @@ registerTranslations('en-US', {
'trace.viewer.inProgress': 'In progress', 'trace.viewer.inProgress': 'In progress',
'trace.viewer.completed': 'Completed', 'trace.viewer.completed': 'Completed',
'trace.viewer.steps': '{{count}} step(s)', '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 Viewer =====
'workspace.currentWorkspace': 'Current workspace', 'workspace.currentWorkspace': 'Current workspace',
+24 -2
View File
@@ -518,7 +518,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
get().addMessage({ get().addMessage({
id: genMsgId('error'), id: genMsgId('error'),
role: 'system', role: 'system',
content: `错误: 无法创建会话 — ${(err as Error).message}`, content: t('agent.error.sessionCreateFailed', { message: (err as Error).message }),
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
@@ -540,6 +540,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
timestamp: Date.now(), timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined, attachments: attachments && attachments.length > 0 ? attachments : undefined,
}; };
const optimisticMessageId = userMessage.id;
set((s) => ({ set((s) => ({
messages: [...s.messages, userMessage], messages: [...s.messages, userMessage],
@@ -668,7 +669,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
get().addMessage({ get().addMessage({
id: genMsgId('error'), id: genMsgId('error'),
role: 'system', role: 'system',
content: `错误: 消息发送失败 — ${(err as Error).message}`, content: t('agent.error.sendFailed', { message: (err as Error).message }),
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
@@ -679,6 +680,27 @@ export const useAgentStore = create<AgentState>((set, get) => ({
.catch(() => {}); .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(() => {});
} }
}, },
+46 -22
View File
@@ -24,6 +24,12 @@ interface UIState {
// 弹窗 // 弹窗
settingsOpen: boolean; settingsOpen: boolean;
onboardingCompleted: boolean; onboardingCompleted: boolean;
/**
* v0.8.2 P3-4: 引导向导的"配置就绪"
* onboarding.completed IPC false
*
*/
onboardingGateReady: boolean;
// v0.3.0: 面板可见性 // v0.3.0: 面板可见性
sidebarVisible: boolean; sidebarVisible: boolean;
@@ -47,6 +53,7 @@ interface UIState {
openSettings: () => void; openSettings: () => void;
closeSettings: () => void; closeSettings: () => void;
setOnboardingCompleted: (completed: boolean) => void; setOnboardingCompleted: (completed: boolean) => void;
setOnboardingGateReady: (ready: boolean) => void;
// v0.3.0: 面板控制 Actions // v0.3.0: 面板控制 Actions
setDetailTab: (tab: DetailTab) => void; setDetailTab: (tab: DetailTab) => void;
@@ -62,6 +69,7 @@ export const useUIStore = create<UIState>((set) => ({
resolvedTheme: 'dark', resolvedTheme: 'dark',
settingsOpen: false, settingsOpen: false,
onboardingCompleted: false, onboardingCompleted: false,
onboardingGateReady: false,
// v0.3.0: 面板默认可见 // v0.3.0: 面板默认可见
sidebarVisible: true, sidebarVisible: true,
@@ -78,37 +86,53 @@ export const useUIStore = create<UIState>((set) => ({
openSettings: () => set({ settingsOpen: true }), openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }), closeSettings: () => set({ settingsOpen: false }),
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }), setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
setOnboardingGateReady: (ready) => set({ onboardingGateReady: ready }),
// v0.3.0: 面板控制 // v0.3.0: 面板控制
setDetailTab: (tab) => set({ detailTab: tab }), setDetailTab: (tab) => set({ detailTab: tab }),
// 退出专注模式时恢复快照(若用户在专注模式下手动切换面板,视为退出专注模式) // 退出专注模式时恢复快照(若用户在专注模式下手动切换面板,视为退出专注模式)
// v0.3.0 修复: 退出专注模式时同时恢复另一个面板的快照,与 toggleFocusMode 关闭逻辑一致 // v0.3.0 修复: 退出专注模式时同时恢复另一个面板的快照,与 toggleFocusMode 关闭逻辑一致
toggleSidebar: () => set((s) => s.focusMode toggleSidebar: () =>
? { sidebarVisible: !s.sidebarVisible, detailVisible: s.preFocusDetailVisible, focusMode: false } set((s) =>
: { sidebarVisible: !s.sidebarVisible }), s.focusMode
toggleDetail: () => set((s) => s.focusMode ? {
? { detailVisible: !s.detailVisible, sidebarVisible: s.preFocusSidebarVisible, focusMode: false } sidebarVisible: !s.sidebarVisible,
: { detailVisible: !s.detailVisible }), 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 修复:专注模式开启时保存当前面板状态快照,关闭时恢复快照 // v0.3.0 修复:专注模式开启时保存当前面板状态快照,关闭时恢复快照
// 避免关闭时强制设为 true 覆盖用户进入专注模式前的原有状态 // 避免关闭时强制设为 true 覆盖用户进入专注模式前的原有状态
toggleFocusMode: () => set((s) => { toggleFocusMode: () =>
if (!s.focusMode) { set((s) => {
// 开启专注模式:保存快照,隐藏面板 if (!s.focusMode) {
// 开启专注模式:保存快照,隐藏面板
return {
focusMode: true,
preFocusSidebarVisible: s.sidebarVisible,
preFocusDetailVisible: s.detailVisible,
sidebarVisible: false,
detailVisible: false,
};
}
// 关闭专注模式:恢复快照
return { return {
focusMode: true, focusMode: false,
preFocusSidebarVisible: s.sidebarVisible, sidebarVisible: s.preFocusSidebarVisible,
preFocusDetailVisible: s.detailVisible, detailVisible: s.preFocusDetailVisible,
sidebarVisible: false,
detailVisible: false,
}; };
} }),
// 关闭专注模式:恢复快照
return {
focusMode: false,
sidebarVisible: s.preFocusSidebarVisible,
detailVisible: s.preFocusDetailVisible,
};
}),
// v0.3.6: 清理记忆后自增版本号,触发 MemoryViewer 重新加载 // v0.3.6: 清理记忆后自增版本号,触发 MemoryViewer 重新加载
bumpMemoryVersion: () => set((s) => ({ memoryVersion: s.memoryVersion + 1 })), bumpMemoryVersion: () => set((s) => ({ memoryVersion: s.memoryVersion + 1 })),
})); }));
+8 -1
View File
@@ -54,6 +54,8 @@ function createMetonaBridgeMock(): Record<string, unknown> {
saveTrace: resolve({ success: true }), saveTrace: resolve({ success: true }),
getTrace: resolve(null), getTrace: resolve(null),
updateMessageContent: resolve({ success: true }), updateMessageContent: resolve({ success: true }),
// v0.8.2 P2-4: 会话元数据实时刷新
onSessionUpdated: () => noop,
}, },
mcp: { mcp: {
listServers: resolve([]), listServers: resolve([]),
@@ -99,9 +101,14 @@ function createMetonaBridgeMock(): Record<string, unknown> {
getAppDataPath: resolve(''), getAppDataPath: resolve(''),
openExternal: resolve(), openExternal: resolve(),
showItemInFolder: resolve(), showItemInFolder: resolve(),
selectFolder: resolve(''), selectFolder: resolve({ canceled: false, path: '' }),
restart: resolve(), restart: resolve(),
updateCheck: resolve({ status: 'disabled', message: 'disabled' }), 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, reportError: noop,
getHealthSnapshot: resolve({ getHealthSnapshot: resolve({
success: true, success: true,
+12
View File
@@ -211,6 +211,15 @@ interface MetonaSessionsAPI {
data: { traceSteps: unknown[]; tokenUsage: unknown }, data: { traceSteps: unknown[]; tokenUsage: unknown },
) => Promise<{ success: boolean }>; ) => Promise<{ success: boolean }>;
getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>; 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 ===== // ===== MCP API =====
@@ -375,7 +384,10 @@ interface MetonaAppAPI {
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string }
>; >;
// ===== v0.8.0 P2-3: electron-updater 自动更新 ===== // ===== v0.8.0 P2-3: electron-updater 自动更新 =====
/** v0.8.2 P1-3: 仅下载更新包(完成后广播 downloaded,不自动重启) */
updateInstall: () => Promise<{ success: boolean; error?: string }>; updateInstall: () => Promise<{ success: boolean; error?: string }>;
/** v0.8.2 P1-3: 安装已下载的更新并重启(用户确认后调用) */
updateInstallNow: () => Promise<{ success: boolean; error?: string }>;
onUpdateStatus: ( onUpdateStatus: (
callback: (event: { callback: (event: {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';