feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -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/ # 钩子
|
||||||
|
|||||||
@@ -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();
|
||||||
|
// 调试期保留 userData(main.log 排查用);稳定后恢复清理
|
||||||
|
if (!process.env['METONA_E2E_KEEP_USER_DATA']) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* Windows 文件句柄释放延迟 — 清理失败不影响断言 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { electronApp, page, mock, userDataDir, workspaceDir, cleanup };
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
|
||||||
// 调试期保留 userData(main.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
@@ -6,6 +6,12 @@
|
|||||||
* finish_reason=stop + usage + [DONE];
|
* finish_reason=stop + usage + [DONE];
|
||||||
* - POST /chat/completions(stream=false):非流式 JSON(压缩摘要等内部调用兜底)。
|
* - POST /chat/completions(stream=false):非流式 JSON(压缩摘要等内部调用兜底)。
|
||||||
*
|
*
|
||||||
|
* v0.8.2 P3-3: 新增两条可控脚本(内容标记协议,零外部依赖):
|
||||||
|
* - `__E2E_TOOL_CALL__`:返回 tool_calls(think 工具);后续请求带 tool 消息时
|
||||||
|
* 返回最终文本 —— 锁定"多轮工具调用 → 观察 → 最终回答"全链路;
|
||||||
|
* - `__E2E_HANG__`:推送首帧后挂起连接(不结束、不推 finish_reason)——
|
||||||
|
* 供中断链路测试:客户端 abort 时服务端感知连接关闭。
|
||||||
|
*
|
||||||
* 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。
|
* 端口随机分配(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 图片 → 下载后转 base64(content-type 作为 media_type)', async () => {
|
it('http URL 图片 → 下载后转 base64(content-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_p(API 强制覆盖)', async () => {
|
// v0.8.2 P2-6 修订:未配置时显式 disabled(与 DeepSeek/Agnes 的"用户意图优先"对齐;
|
||||||
|
// 旧行为隐式 enabled 会静默吞掉用户 temperature/top_p —— 服务端思考模式强制覆盖)
|
||||||
|
it('thinkingEnabled 未配置 → 显式 {type:disabled} 且 temperature/top_p 透传', async () => {
|
||||||
const adapter = makeAdapter();
|
const 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.1:pro/standard 均不钳制)', async () => {
|
it('max_completion_tokens 原样透传(v0.8.1:pro/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)→ 显式 disabled(v0.8.2 P2-6 与 DeepSeek/Agnes 对齐,不隐式吞用户 temperature)', async () => {
|
||||||
|
const adapter = new MimoAdapter({
|
||||||
|
provider: 'mimo',
|
||||||
|
baseURL: 'https://api.xiaomimimo.com/v1',
|
||||||
|
apiKey: 'k',
|
||||||
|
defaultModel: 'mimo-v2.5-pro',
|
||||||
|
});
|
||||||
|
const body = asNative(adapter)(makeRequest({ thinkingEnabled: undefined }), false);
|
||||||
|
expect(body.thinking).toEqual({ type: 'disabled' });
|
||||||
|
expect(body.temperature).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('Ollama: 探测不支持思考(服务端硬约束)→ 不发 think 参数(唯一保留的门控)', async () => {
|
it('Ollama: 探测不支持思考(服务端硬约束)→ 不发 think 参数(唯一保留的门控)', async () => {
|
||||||
const adapter = new OllamaAdapter({
|
const adapter = new OllamaAdapter({
|
||||||
provider: 'ollama',
|
provider: 'ollama',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 进入模型上下文(**数据可回读**的外泄通道)。
|
||||||
|
* 现统一走 fetchImageAsBase64:resolvePublicAddresses + DNS pinning 校验与连接
|
||||||
|
* 同源、逐跳重定向复检、10MB 字节上限、png/jpeg/gif/webp 类型白名单;
|
||||||
|
* 外部 abort 信号(用户中断)照常透传。失败时调用方按"跳过该图"降级。
|
||||||
|
*/
|
||||||
|
protected async fetchImageAsBase64(
|
||||||
|
url: string,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<{ base64: string; mediaType: string }> {
|
||||||
|
return fetchImageAsBase64(url, { timeoutMs, signal: this.externalAbortSignal });
|
||||||
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.listModels();
|
await this.listModels();
|
||||||
|
|||||||
@@ -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' };
|
||||||
// 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截)
|
// 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截)
|
||||||
|
|||||||
@@ -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×N(externalAbortSignal 是 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}`,
|
||||||
|
|||||||
@@ -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 mock:ssrfPinnedFetch 的 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 + mediaType(IP 直连,无需 DNS)', async () => {
|
||||||
|
const original = __imageFetcher.current;
|
||||||
|
__imageFetcher.current = (async () => imageResponse(PNG_MAGIC)) as unknown as ImageFetcher;
|
||||||
|
try {
|
||||||
|
const r = await fetchImageAsBase64('http://93.184.216.34/a.png');
|
||||||
|
expect(r.mediaType).toBe('image/png');
|
||||||
|
expect(Buffer.from(r.base64, 'base64').equals(PNG_MAGIC)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
__imageFetcher.current = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非图片 content-type(text/html)拒绝', async () => {
|
||||||
|
const original = __imageFetcher.current;
|
||||||
|
__imageFetcher.current = (async () =>
|
||||||
|
imageResponse(Buffer.from('<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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// 中断竞速赢时把底层读错误替换为标准 AbortError(reader.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 会造成内存峰值。
|
||||||
|
*
|
||||||
|
* 收口方案(根治):
|
||||||
|
* - 校验与连接同源:resolvePublicAddresses(ssrf-guard 单一事实来源)+ DNS
|
||||||
|
* pinning(ssrfPinnedFetch),代理激活时按既有语义退化为"仅入口校验";
|
||||||
|
* - 逐跳重定向复检:redirect:'manual' + resolveRedirectTarget,每一跳都重新
|
||||||
|
* 走完整校验(对齐 web_fetch 的逐跳语义),最多 3 跳;
|
||||||
|
* - 字节上限:content-length 预检 + 流式增量累计双闸(10MB,防大图内存峰值);
|
||||||
|
* - 类型白名单:content-type 必须 image/*(或缺失/二进制时按魔数嗅探),
|
||||||
|
* 并钳制到 Provider 实际支持集合(png/jpeg/gif/webp)。
|
||||||
|
*
|
||||||
|
* 失败语义由调用方决定:适配器保持"跳过该图、不阻断请求"(log.warn + 占位)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ssrfPinnedFetch, resolveRedirectTarget } from '../../tools/built-in/ssrf-dispatcher';
|
||||||
|
|
||||||
|
/** 实际下载器签名(与 ssrfPinnedFetch 对齐) */
|
||||||
|
export type ImageFetcher = (
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
timeoutMs: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => Promise<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 type(Anthropic Messages API 支持集合;Ollama 同样接受) */
|
||||||
|
const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
|
|
||||||
|
const MAX_REDIRECTS = 3;
|
||||||
|
|
||||||
|
export interface ImageFetchOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageFetchResult {
|
||||||
|
base64: string;
|
||||||
|
mediaType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 魔数嗅探:content-type 缺失或为通用二进制类型时判定真实图片类型 */
|
||||||
|
export function sniffImageMediaType(buf: Buffer): string | null {
|
||||||
|
if (buf.length >= 8 && buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (buf.length >= 6 && buf.subarray(0, 3).toString('ascii') === 'GIF') {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buf.length >= 12 &&
|
||||||
|
buf.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buf.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式增量读取并强制字节上限(content-length 可伪造,以实际累计为准) */
|
||||||
|
async function readBodyWithCap(res: Response, maxBytes: number): Promise<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_move(source_path / destination_path)等旧名单遗漏的工具
|
||||||
|
* 对根 MEMORY.md 的操作被拦截(读/写/删/移动/改名任一方向)
|
||||||
|
* - 子目录 MEMORY.md 不受保护(H-5 语义保持)
|
||||||
|
* - 相对路径以 workspacePath 为基解析
|
||||||
|
* - MCP 工具(任意带路径形参数的工具)同样纳入
|
||||||
|
* - 非根 MEMORY.md 的路径不误伤
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
vi.mock('electron-log', () => ({
|
||||||
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AgentLoopEngine } from '../engine';
|
||||||
|
|
||||||
|
const WORKSPACE = join(tmpdir(), 'metona-memory-gate-test');
|
||||||
|
const ROOT_MEMORY = join(WORKSPACE, 'MEMORY.md');
|
||||||
|
|
||||||
|
function makeEngine(): AgentLoopEngine {
|
||||||
|
const engine = new AgentLoopEngine({}, {
|
||||||
|
providerId: 'fake',
|
||||||
|
supportedModels: [],
|
||||||
|
supportsToolCalling: true,
|
||||||
|
supportsThinking: false,
|
||||||
|
send: vi.fn(),
|
||||||
|
sendStream: vi.fn(),
|
||||||
|
} as never);
|
||||||
|
engine.setWorkspacePath(WORKSPACE);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 白盒调用(私有方法契约测试) */
|
||||||
|
function gate(engine: AgentLoopEngine, args: Record<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 prompt(SOUL + MEMORY.md 注入可达数千 token,
|
||||||
|
// 旧估算只算 messages,system 大时系统性低估 → 压缩迟迟不触发 → API 413)。
|
||||||
|
if (!this.aborted) {
|
||||||
|
const effectiveContextWindow =
|
||||||
|
this.config.contextLength ?? this.config.contextWindow ?? 0;
|
||||||
|
const estimatedTokens = this.estimateContextTokens(messages, systemPrompt);
|
||||||
|
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
|
||||||
|
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
|
||||||
|
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
|
||||||
|
if (
|
||||||
|
effectiveContextWindow > 0 &&
|
||||||
|
actualTokens > compressionThreshold &&
|
||||||
|
messages.length >= 4
|
||||||
|
) {
|
||||||
|
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||||||
|
const compressed = await this.compressMessages(messages);
|
||||||
|
if (compressed) {
|
||||||
|
// 原地替换数组内容,确保下一轮请求构建引用同步更新
|
||||||
|
messages.splice(0, messages.length, ...compressed);
|
||||||
|
// 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
|
||||||
|
this.lastRealInputTokens = 0;
|
||||||
|
this.emit('compressed', {
|
||||||
|
iteration: this.currentIteration,
|
||||||
|
originalTokens: actualTokens,
|
||||||
|
compressedTokens: this.estimateContextTokens(compressed, systemPrompt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 压缩后回到 OBSERVING(下一轮迭代从 THINKING 重新开始)
|
||||||
|
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 循环退出判断
|
// 循环退出判断
|
||||||
@@ -550,6 +591,8 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
let tokenUsage: TokenUsage | undefined;
|
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 子进程)可自行终止
|
// 工具级信号:引擎中断镜像 + 超时联动 abort(P0-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。
|
||||||
|
*
|
||||||
|
* 旧压缩判定只估算 messages:system prompt(SOUL.md + 注入的 MEMORY.md 正文 +
|
||||||
|
* 安全准则,可达数千 token)被完全排除,system 大时系统性低估 → 压缩迟迟不
|
||||||
|
* 触发 → 首轮流式返回前完全靠估算的场景下直接 413。已知的估算边界:工具定义
|
||||||
|
* (tools JSON Schema)体积不在此估算内(与 Provider 的序列化形态差异大,
|
||||||
|
* 由 lastRealInputTokens 真实值校正兜底)。
|
||||||
|
*/
|
||||||
|
private estimateContextTokens(
|
||||||
|
messages: MetonaMessage[],
|
||||||
|
systemPrompt?: MetonaSystemPrompt,
|
||||||
|
): number {
|
||||||
|
let total = this.estimateMessagesTokens(messages);
|
||||||
|
if (systemPrompt) {
|
||||||
|
const systemText = [
|
||||||
|
systemPrompt.roleDefinition,
|
||||||
|
systemPrompt.outputConstraints,
|
||||||
|
systemPrompt.safetyGuidelines,
|
||||||
|
systemPrompt.dynamicReminders,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
total += estimateStringTokens(systemText);
|
||||||
|
// system 消息的结构开销
|
||||||
|
total += 8;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。
|
* 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 {
|
||||||
|
|||||||
@@ -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 upsert,access_count 不归零', () => {
|
||||||
|
mgr.store({
|
||||||
|
type: 'semantic',
|
||||||
|
content: '用户偏好深色主题',
|
||||||
|
source: 'user_input',
|
||||||
|
importance: 0.9,
|
||||||
|
});
|
||||||
|
db.prepare('UPDATE semantic_memories SET access_count = 7').run();
|
||||||
|
// 同内容重复写入(key = contentHash,冲突命中同一行)
|
||||||
|
mgr.store({
|
||||||
|
type: 'semantic',
|
||||||
|
content: '用户偏好深色主题',
|
||||||
|
source: 'user_input',
|
||||||
|
importance: 0.9,
|
||||||
|
});
|
||||||
|
const rows = db.prepare('SELECT access_count, value FROM semantic_memories').all() as Array<{
|
||||||
|
access_count: number;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
// 旧 REPLACE 语义会删除重插 → 2 行且 access_count 归零
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].access_count).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embedding_model 指纹:模型不匹配的向量按缺失处理并触发重算', async () => {
|
||||||
|
mgr.store({
|
||||||
|
type: 'semantic',
|
||||||
|
content: '指纹校验内容',
|
||||||
|
summary: 'fp-check',
|
||||||
|
source: 'agent_thought',
|
||||||
|
importance: 0.8,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
// 模拟"用户更换了 embedding 模型":旧向量标记为旧模型
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE semantic_memories SET embedding_model = 'old-model' WHERE key = 'fp-check'",
|
||||||
|
).run();
|
||||||
|
let reembedCalls = 0;
|
||||||
|
mgr.setEmbedder({
|
||||||
|
modelName: 'new-model',
|
||||||
|
embed: async () => {
|
||||||
|
reembedCalls += 1;
|
||||||
|
return [0.1, 0.2, 0.3];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const results = await mgr.search('指纹校验内容');
|
||||||
|
// 检索本身可用(回退 TF-IDF);旧向量被排队用新模型重算
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(reembedCalls).toBeGreaterThanOrEqual(1);
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT embedding, embedding_model FROM semantic_memories WHERE key = 'fp-check'")
|
||||||
|
.get() as { embedding: Buffer | null; embedding_model: string | null };
|
||||||
|
expect(row.embedding_model).toBe('new-model');
|
||||||
|
expect(row.embedding).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (CGNAT,v0.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 CGNAT(v0.8.2 P3-1)
|
||||||
|
if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return true; // 198.18/15 基准测试段(v0.8.2 P3-1)
|
||||||
if (parts[0] >= 224) return true; // 组播 + 保留
|
if (parts[0] >= 224) return true; // 组播 + 保留
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 纵深校验。
|
||||||
|
// 配置期已有 assertSafeConfigTarget(ipc/shared 写入链),但 DNS 记录可在配置
|
||||||
|
// 之后被切换(指向云元数据/链路本地)—— 运行时请求此前完全无校验。此处对
|
||||||
|
// 每次搜索会话做同口径静态校验 + DNS 深校验;本地回环/RFC1918 合法放行
|
||||||
|
// (SearXNG 常部署本机/内网),DNS 解析失败按 DeepCheckSoftFailure 留痕放行
|
||||||
|
// (离线实例合法,与配置期深校验语义一致)。
|
||||||
|
try {
|
||||||
|
await assertSafeConfigTargetDeep(baseUrl);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DeepCheckSoftFailure) {
|
||||||
|
logTool('web_search', `[SearXNG] DNS deep check skipped (soft-fail): ${err.message}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`SearXNG target blocked by security policy: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults
|
// 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;
|
||||||
|
|||||||
@@ -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.ts(MetonaContext / 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';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (仅 assistant)原始思考块(v0.8.2 P1-1)
|
||||||
|
*
|
||||||
|
* 与 reasoningContent(展示用纯文本)并行:thinkingBlocks 保留 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 {
|
||||||
|
|||||||
@@ -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-1,Anthropic 专用)。
|
||||||
|
*
|
||||||
|
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 错误格式 =====
|
// ===== 错误格式 =====
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -44,6 +44,18 @@ function buildTimezoneLabel(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.2 P2-4: 会话元数据实时刷新广播(专用 session:updated 通道)。
|
||||||
|
* 消费方:Sidebar(title / 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
|
||||||
|
// 后 detect,riskScore≥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 回归修复: 透传前端消息 id(ChatMessage.id 由 genMsgId 生成)——
|
// v0.7.4 回归修复: 透传前端消息 id(ChatMessage.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
@@ -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] };
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -546,7 +546,10 @@ async function initialize(): Promise<void> {
|
|||||||
// 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回
|
// 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回
|
||||||
// null,MemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。
|
// null,MemoryManager 全量回退纯 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
@@ -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);
|
||||||
|
|||||||
@@ -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_logs(safeStorage 只保护配置层),构成敏感信息二次扩散面。
|
||||||
|
* 键名匹配与配置层单源(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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 —— 迁移 13(working_memories 补 sessions 外键 CASCADE,
|
* v0.8.1 review: 4 → 5 —— 迁移 13(working_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 的
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=1,getServerStates 只映射 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取窗口
|
* 获取窗口
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Generated
+5
-5
@@ -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
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 会在
|
||||||
|
// 组件卸载后触发 setState(React 警告 / 测试环境 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 (
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 提取可展示的图片 src(displayResult 已剥离 dataUrl,不能用于取图)。
|
||||||
|
* 兼容三种形态:dataUrl(view_image / MCP)、image 字段(web_browser 裸 base64 /
|
||||||
|
* MCP data URI)。非 data:image/ 前缀且非合法 base64 形态的一律返回 null。
|
||||||
|
*/
|
||||||
|
function extractImageSrc(result: unknown): string | null {
|
||||||
|
if (typeof result !== 'object' || result === null || Array.isArray(result)) return null;
|
||||||
|
const rec = result as Record<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;
|
||||||
|
// 裸 base64(web_browser 截图契约:{ image, mime_type })→ 补 data URI 前缀
|
||||||
|
const mime = typeof rec.mime_type === 'string' && rec.mime_type ? rec.mime_type : 'image/png';
|
||||||
|
if (candidate.length > 64 && /^[A-Za-z0-9+/=\r\n]+$/.test(candidate.slice(0, 256))) {
|
||||||
|
return `data:${mime};base64,${candidate}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 displayResult 提取 unified diff 文本(diff 字符串体积可控,走裁剪层安全)。
|
||||||
|
* 覆盖 diff_viewer(顶层 diff)与 file_editor dry_run(preview.diff)两种形态。
|
||||||
|
*/
|
||||||
|
function extractDiffText(display: unknown): string | null {
|
||||||
|
if (typeof display !== 'object' || display === null || Array.isArray(display)) return null;
|
||||||
|
const rec = display as Record<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_run(preview.diff)→ 同样走 diff 视图', () => {
|
||||||
|
const result = {
|
||||||
|
dry_run: true,
|
||||||
|
preview: {
|
||||||
|
original: 'x',
|
||||||
|
modified: 'y',
|
||||||
|
diff: ['--- old', '+++ new', '@@ -1 +1 @@', '-x', '+y'].join('\n'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||||
|
expect(container.querySelector('pre')?.textContent).toContain('+y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_imageOmitted(registry 判定超限占位)→ 不渲染 <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('普通大字符串被截断显示(防御裁剪)', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -96,14 +96,20 @@ export function Sidebar(): React.JSX.Element {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播
|
// v0.8.2 P2-4 根治: 会话元数据实时刷新 —— ① 标题生成结果改走专用
|
||||||
// (合成 key:session.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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(+ apiKey,ollama 除外)+ 合法上下文长度。
|
||||||
|
const validateStep = (s: number): string | null => {
|
||||||
|
if (s === 1) {
|
||||||
|
if (!provider.trim()) return t('onboarding.validate.provider');
|
||||||
|
if (!model.trim()) return t('onboarding.validate.model');
|
||||||
|
if (provider !== 'ollama' && !apiKey.trim()) return t('onboarding.validate.apiKey');
|
||||||
|
if (ctxError) return t('onboarding.llm.ctx.minError', { min: ctxMin });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const 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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* v0.8.2 P0-4: ChatInput 受控输入桥
|
||||||
|
*
|
||||||
|
* 根治"引用回复 / Ctrl+L 聚焦输入框"的双层断裂:
|
||||||
|
* ① 旧实现 `document.querySelector('[data-chat-input]')` 命中的是 MUI InputBase
|
||||||
|
* 的**根 div**(可编辑元素是其内部 textarea)—— 对 div 赋 value/focus 无效;
|
||||||
|
* ② 即使命中 textarea,ChatInput 是受控组件(value={input}),直接赋 DOM value
|
||||||
|
* + dispatchEvent('input') 不会更新 React state(值追踪器判定无变化)。
|
||||||
|
*
|
||||||
|
* 根治方案:ChatInput 挂载时把"聚焦"与"受控插入文本"两个真实能力注册到本桥,
|
||||||
|
* 右键菜单(引用回复)与快捷键(Ctrl+L)调用桥接口 —— 不做任何 DOM 查询,
|
||||||
|
* 完全走 React state,与草稿保存/IME 保护天然兼容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatInputController {
|
||||||
|
/** 聚焦输入框(textarea 真实焦点) */
|
||||||
|
focus: () => void;
|
||||||
|
/**
|
||||||
|
* 受控插入文本:保留现有草稿,以换行分隔追加到尾部,并把光标移到末尾。
|
||||||
|
* 通过 React state 更新(setInput),草稿 useEffect 会照常持久化。
|
||||||
|
*/
|
||||||
|
appendText: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller: ChatInputController | null = null;
|
||||||
|
|
||||||
|
/** ChatInput 挂载时注册;卸载时传 null 注销 */
|
||||||
|
export function registerChatInputController(c: ChatInputController | null): void {
|
||||||
|
controller = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚焦输入框。无可用输入框(桥未注册)返回 false。 */
|
||||||
|
export function focusChatInput(): boolean {
|
||||||
|
if (!controller) return false;
|
||||||
|
controller.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向输入框追加文本(引用回复等场景)。
|
||||||
|
* 无可用输入框(桥未注册)返回 false —— 调用方据此 toast 提示。
|
||||||
|
*/
|
||||||
|
export function appendToChatInput(text: string): boolean {
|
||||||
|
if (!controller) return false;
|
||||||
|
controller.appendText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -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(此前固定 zhCN,en-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
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Vendored
+12
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user