feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
+22 -7
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.8.1-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,6 +54,22 @@
--- ---
## 🆕 v0.8.1 更新亮点
| 特性 | 说明 |
|:---|:---|
| ⚙️ **上下文/输出上限 全局单一配置** | 硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息钳制)——唯一合法来源是设置面板「上下文长度」与「最大输出上限」,跨 Provider/模型原样透传 |
| 🧠 **本地向量混合检索** | 激活 Ollama embeddings:记忆检索升级为 0.6×向量余弦 + 0.4×TF-IDF 混合评分,同义改写可召回;存量记忆惰性回填,嵌入不可用自动回退 TF-IDF |
| 📝 **MEMORY.md 维护闭环** | 固化去重消除全文截断盲区;两阶段维护(AI 建议 → 用户勾选 → 原子改写 + 语义记忆双轨同步);>50KB 体积告警 |
| 📊 **成本/缓存可观测闭环** | Prompt Cache 命中率与估算成本(可选单价配置)进 Token 面板;输入框新增上下文占用指示条(60%/80% 变色) |
| 🔌 **MCP Prompts/Resources 对话可用** | `/mcp:{server}:{prompt}` 斜杠菜单填充输入框;`@mcp:{server}:{uri}` 资源注入附件管线(512KB 上限、二进制拒绝) |
| 🛡 **工具自定义策略** | 每个工具可配置拒绝/允许参数正则、频率上限、强制确认(设置 → 工具管理 → 策略),保存即热生效 |
| ⏩ **批量确认 & 游标分页 & 自启** | 连续 ≥3 同类工具确认自动聚合为单弹框;会话消息游标分页(首屏 200 条向上翻页);开机自启开关 |
| 💾 **记忆生命周期接线** | 会话终态联动清理工作记忆;情节记忆 90 天 TTL 真实写入;语义记忆 access_count 检索回写(LRU 激活) |
| 🧪 **E2E 冒烟** | Playwright + Electron 端到端链路(本地 mock LLM,零外联、数据隔离);附带根治权限加固启动时序、回环代理放行、safeStorage 降级三处环境级缺陷 |
---
## 🆕 v0.8.0 更新亮点 ## 🆕 v0.8.0 更新亮点
| 特性 | 说明 | | 特性 | 说明 |
@@ -678,12 +694,10 @@ OLLAMA_BASE_URL=http://localhost:11434
| `memory.consolidationMinChars` | `200` | 固化内容门控:回答字符数阈值(或存在成功工具调用) | | `memory.consolidationMinChars` | `200` | 固化内容门控:回答字符数阈值(或存在成功工具调用) |
| `memory.consolidationIntervalMs` | `600000` | 固化频率窗口(同会话两次固化的最小间隔) | | `memory.consolidationIntervalMs` | `600000` | 固化频率窗口(同会话两次固化的最小间隔) |
| `mcp.autoReconnect` | `true` | MCP 断连自动重连(指数退避 5s/15s/60s,最多 3 次) | | `mcp.autoReconnect` | `true` | MCP 断连自动重连(指数退避 5s/15s/60s,最多 3 次) |
| `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 | | `llm.contextWindow` | `131072` | **上下文长度(全局唯一合法配置,v0.8.1)** —— 驱动引擎压缩预算与占用指示,Ollama 场景同时作为 num_ctx 下发;分 Provider 的 contextWindow 键与 ollama.numCtx 已废除(迁移 12 清理) |
| `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 | | `llm.maxTokens` | `63488` | **最大输出上限(全局唯一合法配置,v0.8.1)** —— 原样透传请求参数,代码中不存在任何按模型钳制 |
| `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 | | `llm.priceInput` / `llm.priceOutput` | (空) | 成本估算单价(每百万 tokens,可选;留空隐藏成本行) |
| `openai.contextWindow` | `128000` | OpenAI 上下文窗口 | | `memory.embeddingModel` | (空) | 本地向量记忆嵌入模型(Ollama embedding 模型名;留空 = 纯 TF-IDF 检索) |
| `anthropic.contextWindow` | `200000` | Anthropic 上下文窗口 |
| `ollama.numCtx` | (空) | Ollama num_ctx 参数(空 = 由模型决定) |
### SearXNG 元搜索引擎 (可选) ### SearXNG 元搜索引擎 (可选)
@@ -897,6 +911,7 @@ npm run format # Prettier 格式化
npm test # 运行单元测试 (Vitest, 系统 Node — SQLite 依赖用例因 better-sqlite3 ABI 自动跳过) npm test # 运行单元测试 (Vitest, 系统 Node — SQLite 依赖用例因 better-sqlite3 ABI 自动跳过)
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 全部用例执行, 含 SQLite 审计链哈希 + 引擎工具链集成) npm run test:electron # 运行全量单元测试 (Electron Node ABI, 全部用例执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
npm run test:watch # 测试监听模式 npm run test:watch # 测试监听模式
npm run test:e2e # E2E 冒烟(构建产物 + Playwright + Electron,本地 mock LLM 零外联)
# ─── 构建 ───────────────────────────────── # ─── 构建 ─────────────────────────────────
npm run build # 构建生产包 (Windows NSIS + 便携版) npm run build # 构建生产包 (Windows NSIS + 便携版)
@@ -663,7 +663,7 @@
}; <span class="hl-cm">// TOOL_CALL_DELTA</span> }; <span class="hl-cm">// TOOL_CALL_DELTA</span>
<span class="hl-prop">toolCall</span>?: <span class="hl-type">MetonaToolCall</span>; <span class="hl-cm">// TOOL_CALL_COMPLETE(拼接完成后的完整调用)</span> <span class="hl-prop">toolCall</span>?: <span class="hl-type">MetonaToolCall</span>; <span class="hl-cm">// TOOL_CALL_COMPLETE(拼接完成后的完整调用)</span>
<span class="hl-prop">usage</span>?: <span class="hl-type">MetonaTokenUsage</span>; <span class="hl-cm">// USAGE</span> <span class="hl-prop">usage</span>?: <span class="hl-type">MetonaTokenUsage</span>; <span class="hl-cm">// USAGE</span>
<span class="hl-cm">// v0.8.0: DONE 事件新增 finishReason(归一化 OpenAI 语义:stop/length/</span> <span class="hl-cm">// v0.8.1: USAGE 事件 cacheHitTokens / cacheMissTokens 全链路透传(引擎 → 渲染层「Prompt 缓存命中率」展示);v0.8.1 硬性契约:max_tokens 等输出上限参数原样透传设置面板「最大输出上限」(llm.maxTokens),六家 Adapter 均不再携带模型元信息钳制或写死默认值;上下文窗口唯一来源为设置面板「上下文长度」(llm.contextWindow)。 v0.8.0: DONE 事件新增 finishReason(归一化 OpenAI 语义:stop/length/</span>
<span class="hl-cm">// tool_calls/content_filterMiMo repetition_truncation→stop)。引擎据此</span> <span class="hl-cm">// tool_calls/content_filterMiMo repetition_truncation→stop)。引擎据此</span>
<span class="hl-cm">// 区分自然完成与输出上限截断(空响应守卫 + 降级重试)。</span> <span class="hl-cm">// 区分自然完成与输出上限截断(空响应守卫 + 降级重试)。</span>
<span class="hl-prop">error</span>?: <span class="hl-type">MetonaError</span>; <span class="hl-cm">// ERROR</span> <span class="hl-prop">error</span>?: <span class="hl-type">MetonaError</span>; <span class="hl-cm">// ERROR</span>
+239
View File
@@ -0,0 +1,239 @@
# v0.8.1 迭代实施清单 —「记忆深化 · 观测闭环 · 体验收口 · 配置单一化」
> 版本基线:v0.8.08398600)→ 0.8.1
> 本文档是 v0.8.1 的**唯一实施与验收依据**。每项含:根因 / 根治方案 / 涉及文件 / 验收标准。
> 硬性契约(用户指令,贯穿全版本):**代码中不存在任何写死的上下文长度或最大输出上限;
> 唯一合法来源是设置面板 LLM 配置的「上下文长度」(llm.contextWindow)与「最大输出上限」
> llm.maxTokens),对一切 Provider / 模型生效,不做模型钳制。**
---
## P0 — 正确性收口
### P0-1 上下文窗口 / 最大输出上限 全局单一配置化(硬性契约,取代原"按模型真值修正")
- **根因**:① 六家 adapter 各自持有 MODEL_INFO 的 contextWindow / maxOutputTokens 写死值,
并按其钳制 max_tokensvision-exp 128K 与配置 1M 冲突时压缩阈值失真,先 413 再压缩);
② base-adapter / openai-compatible-base / engine / agent-store / 引导页均有写死兜底
128K / 4096 / 1M / 63488 / 2048);③ 分 Provider contextWindow 配置键与 ollama.numCtx
造成多源语义。
- **根治方案**
1. CONFIG_DEFAULTS 新增 `llm.contextWindow`(种子默认 131072)为唯一上下文窗口配置;
`llm.maxTokens`(63488)为唯一输出上限配置;删除 deepseek/agnes/mimo/openai/
anthropic.contextWindow 与 ollama.numCtx 五个种子键。
2. 迁移 11SCHEMA_VERSION 3→4):episodic/semantic_memories 增 embedding BLOB 列(P1-1);
迁移 12:DELETE 存量库中已废除的六个配置键。
3. 全部 adapter 的 MODEL_INFO 删除窗口/上限数值字段;deepseek/agnes/mimo/openai 的
max_tokens / max_completion_tokens / num_predict 原样透传 `params.maxTokens`(未配置
不下发);anthropic 删除模型钳制,仅保留 thinking 协议下限 2048(协议不变量,
非输出上限);ollama 删除 num_ctx 探测缓存与 4096 默认。
4. getContextWindow 契约单一化:config(设置面板值)> 0 返回之,否则返回 0
(引擎 syncContextWindow 仅采纳 >0;压缩预算 effectiveContextWindow<=0 时跳过压缩)。
5. engine DEFAULT_CONFIG 删除 contextWindow/maxTokensAgentLoopConfig 二字段改可选;
SubAgentorchestrator)不再携带 63488/128_000 兜底。
6. main.ts / shared.tsLLM_CONFIG_KEYS、applyEngineConfigKey):
`llm.contextWindow` 全局键驱动 adapter 重建 + 引擎 contextWindow 热更新,
Ollama 场景同步 contextLengthnum_ctx);分 Provider 键进入兼容分支仅 WARN。
7. LLMSettings 重写:全局唯一「上下文长度」「最大输出上限」两个输入项;
删除 supportsOutputLimitConfig 门控、模型元信息钳制提示与 numCtx 分字段;
OnboardingWizard 删除 DEFAULT_CTX 写死表,写 `llm.contextWindow`
8. agent-store 删除 4096/1M 常量;setProvider 读 `llm.contextWindow`;新增
setContextWindow 热更新(config:changed 联动)。
- **涉及文件**database.service / main / ipc/shared / ipc/agent(无)/ engine / agent-loop
types / 六家 adapter / base-adapter / openai-compatible-base / agent-store / useAgentStream /
LLMSettings / OnboardingWizard / model-capabilities / 相关测试
- **验收**typecheck 三绿;max-tokens-clamp 契约测试重写为"原样透传 + 未配置不下发 +
anthropic thinking 协议下限"provider-request-shapes 钳制矩阵全部改为透传矩阵;
E2E 冒烟断言请求体 max_tokens === 设置值(2048)。
### P0-2 记忆生命周期接线(死账清理)
- **根因**`clearWorkingMemory` 零调用方(working_memories 随会话永久残留);
`episodic_memories.expires_at` 无写入方(cleanupExpired 空转);semantic
`access_count` 只读不写(LRU 淘汰死语义)。
- **根治方案**:会话终态(sessions:delete / purge / clearMessages、data:clearSessions、
SubAgent 终态 finishSubTrace 与 abort 路径)调用 `memoryManager.clearWorkingMemory`
MemoryTriggerHook 写 episodic 时携带 90 天 TTL`search()` 命中后回写 access_count。
- **涉及文件**memory/manager、hooks/post-tool、ipc/sessions、ipc/agent、ipc/data
- **验收**memory-manager 测试新增 expiresAt 写入 / access_count 递增用例(Electron ABI)。
### P0-3 回放缓冲终态清理
- **根因**v0.8.0 引入的 replayBuffers 为 ipc/agent.ts 内部 Map,会话删除/彻底删除
无联动清理,已删会话最多 4MB/会话滞留(仅 LRU 50 兜底)。
- **根治方案**:抽为 `electron/ipc/replay-buffer.ts` 独立模块(append/reset/mark/clear/get);
sessions:delete / purge 调用 clearReplayBuffer。
- **涉及文件**ipc/replay-buffer(新)、ipc/agent、ipc/sessions
- **验收**replay-buffer.test 六用例(有界、truncated、runId、TERMINATED 保留、INIT 重置、
终态清除)全绿。
### P0-4 i18n 收口第二期
- **根因**:主进程 toast/系统通知(压缩、死循环、故障转移、固化、sendMessage 错误路径、
更新/完成通知)与渲染层 14 处 toast 硬编码中文。
- **根治方案**:新增 `electron/utils/main-locale.ts`ui.locale 驱动、mt(key,params) 取词、
zh/en 双表、启动注入 + applyConfigSideEffects 热切换);ipc/agent.ts、main.ts、ipc/shared.ts
全部文案出层;渲染层 agent-store / App / useKeyboardShortcuts / UserMessage / useAgentStream
(Provider 切换、输出验证)出层并入 i18n-stringszh/en)。
- **涉及文件**utils/main-locale(新)、ipc/agent、ipc/shared、main、agent-store、App、
useKeyboardShortcuts、UserMessage、useAgentStream、i18n-strings
- **验收**`grep` 断言渲染层 toast 调用零硬编码中文;typecheck/lint 绿。
## P1 — 能力演进
### P1-1 本地向量混合检索(激活 embed 死代码)
- **根因**OllamaAdapter.embed() 全项目零调用;TF-IDF bigram 对同义改写零召回。
- **根治方案**`memory/embedder.ts` 契约 + main.ts 装配(仅 Ollama Provider 且用户配置
`memory.embeddingModel` 时启用,adapter 闭包动态读取);迁移 11 embedding BLOB 列;
store 写入后异步回填向量(in-flight 去重);search 升级 async:混合评分 =
0.6×向量余弦 + 0.4×TF-IDF(各自叠加时间衰减与重要度;单路缺失回退单路);
**存量记忆惰性回填**:检索候选中 embedding 为 NULL 的行排队补算(本轮仍走 TF-IDF,
后续查询命中向量路径,无独立迁移任务、嵌入器不可用零开销);AgentSettings 增
「向量嵌入模型」配置(空 = 关闭)。
- **涉及文件**memory/embedder(新)、memory/manager、main、ipc/memory、AgentSettings、
i18n-strings、database.service(迁移 11
- **验收**memory 套件 86 用例绿(含 5 个新增:TTL 写入、access_count、同义改写混合命中、
embedder 抛错/返回 null 回退、异步回填 BLOB)。
### P1-2 MEMORY.md 维护闭环
- **根因**:固化 append-only;固化 prompt 全文截 3000 字符 → 尾部条目对 LLM 不可见,
去重失效重复写入;无任何回收路径,MEMORY.md 无限膨胀。
- **根治方案**:① digest 共享化 —— `parseMemoryEntries` / `buildMemoryEntriesDigest`
(纯条目行、8000 字符预算)同时用于固化去重与维护分析;② `memory/maintainer.ts`
两阶段维护:analyzeLLM 产出 delete/update 建议,条目精确匹配校验防幻觉)→
apply(用户勾选确认后:重写 MEMORY.md —— workspaceService.rewriteMemory 原子写、
同步 semantic_memories、审计留痕);③ IPC `memory:analyzeMaintenance` /
`memory:applyMaintenance`(结构校验 + 审计);④ MemoryViewer「整理记忆」入口 +
建议勾选弹框;⑤ MEMORY.md > 50KB 固化后 WARN 提示。
- **涉及文件**memory/maintainer(新)、memory/consolidator、services/workspace、ipc/memory、
ipc/context、main、preload、global.d.ts、MemoryViewer、ipc/agent(超限告警)、i18n-strings
- **验收**maintainer.test 五用例(解析、digest 无盲区、精确匹配防幻觉、update 双轨同步、
动作数上限)+ consolidator 套件回归全绿。
### P1-3 上下文 / 成本可观测闭环
- **根因**adapter 采集的 cacheHitTokens/cacheMissTokens 在引擎 USAGE 映射与前端
TokenUsage 类型被丢弃;ChatInput 无上下文占用指示(UI/UX 文档预留)。
- **根治方案**engine TokenUsage 增缓存字段并随 USAGE/accumulate 透传;agent-store
TokenUsage 扩展 + useAgentStream 累加(Provider 不上报保持 undefined);TokenUsage
面板新增「Prompt 缓存命中」行与「估算成本」行(单价 llm.priceInput/llm.priceOutput
为设置面板可选配置,未配置隐藏 —— 成本无任何写死价格);ChatInput 顶部上下文占用
指示条(lastInputTokens / llm.contextWindow60%/80% 变色)。
- **涉及文件**agent-loop/types、engine、agent-store、useAgentStream、TokenUsage、ChatInput、
LLMSettings(单价输入)、i18n-strings
- **验收**typecheck/web 绿;E2E 冒烟在真实渲染界面运行(contextWindow/maxTokens 种子值
驱动)。
### P1-4 MCP Prompts / Resources 可用化
- **根因**v0.8.0 仅"发现与列表"prompts/resources 在对话中不可用。
- **根治方案**mcp-manager 增 getPromptprompts/get,单 ContentBlock 文本提取)/
readResourceresources/read,二进制 blob 显式拒绝);IPC `mcp:getPrompt` /
`mcp:readResource`512KB 截断);ChatInput:斜杠菜单动态合并 MCP prompts
`/mcp:{server}:{prompt}` → getPrompt 填充输入框);@ 提及合并 MCP resources
`@mcp:{server}:{uri}` → readResource 注入附件管线,token 集合扩展 ':');
server 不支持时菜单自然不出现。
- **涉及文件**mcp-manager、ipc/mcp、preload、global.d.ts、ChatInput、i18n-strings、
mcp-contents.test(新)
- **验收**mcp-contents.test 五用例(getPrompt 展平 / 未连接抛错 / text 返回 / blob 拒绝 /
空 contents null+ ChatInput typecheck。
## P2 — 体验补全
### P2-1 工具自定义策略(UI/UX 文档预留项落地)
- **根治方案**permissions.ts 增 `parseToolPolicy`(JSON 配置 → 正则编译,非法正则跳过、
fail-closed)与 `setPolicyOverride/getPolicyOverride`(覆盖层优先于默认策略,字段级合并
保留默认安全项);存储键 `tools.{name}.policy`main.ts 启动冷加载 + shared.ts
config:set 热加载(IPCContext 增 policyEngine);ToolsSettings 每工具「策略」编辑器
(拒绝/允许正则、频率上限、强制确认,inline 非法正则提示)。
- **涉及文件**sandbox/permissions、ipc/context、ipc/shared、main、ToolsSettings、i18n-strings
- **验收**typecheck/lint 绿;覆盖层优先级由 resolvePolicy 单一解析点保证。
### P2-2 连续同类工具批量确认聚合(UI/UX 文档预留项落地)
- **根治方案**ConfirmationHook 聚合窗口(800ms)—— 同 (session, tool) 并行请求达
3 条即 flush 为单条 `tool:confirmationRequestBatch` 广播(低于阈值逐条广播,原行为);
clearPending 同步丢弃未广播缓冲;preload/global.d.ts 增批量监听;ConfirmationDialog
消费批量事件(倒计时取最早 expiresAt)。
- **涉及文件**confirmation-hook、preload、global.d.ts、ConfirmationDialog、
confirmation-hook.test(广播测试改 fake timers + 新增 2 用例)
- **验收**hooks 套件 48 用例绿(≥3 聚合单事件、<2 逐条)。
### P2-3 会话消息游标分页加载
- **根因**sessions:getMessages 全量加载无上限,超长会话切换 IPC 载荷大。
- **根治方案**session.service 增游标语义(无 limit 全量兼容;limit 无游标 = 尾部窗口;
limit+beforeRowid = 游标前 N 条,统一升序);IPC 参数校验;preload/global.d.ts 类型;
agent-store 首屏尾部窗口(200 条)+ loadOlderMessages(防重入 + 完整性判定 +
竞态保护)+ ChatMessage.rowIdMessageList startReached 触发向上加载。
- **涉及文件**session.service、ipc/sessions、preload、global.d.ts、agent-store、
MessageList、session-pagination.test(新)
- **验收**pagination.test 四用例(全量兼容 / 尾部窗口 / 游标翻页 / 开头完整性)绿。
### P2-4 开机自启
- **根治方案**IPC `app:setLoginItem` / `app:getLoginItem`app.setLoginItemSettings +
读回真实生效状态,Linux 不可用平台 fail-safe);AppearanceSettings 增开关
(乐观更新 + 读回校正 + 不支持平台提示);preload / global.d.ts 接线。
- **涉及文件**ipc/app、preload、global.d.ts、AppearanceSettings、i18n-strings
- **验收**typecheck 绿;读回语义保证 UI 与平台真实状态一致。
### P2-5 E2E 冒烟(Playwright + Electron
- **根治方案**`e2e/mock-llm.ts`(本地 OpenAI 兼容 SSE Provider,随机端口,零外联);
`e2e/metona.spec.ts`(隔离 userData + 种子确定性合法 LLM 配置 → 发消息 → 断言流式
回复渲染 + 请求体携带设置 maxTokens);playwright.configmain.ts 增
`METONA_USER_DATA_DIR` / `METONA_E2E_SEED_CONFIG` 引导钩子(显式 env 才生效);
npm script `test:e2e`build + playwright)。
**附带根治三项环境级缺陷**:① 权限白名单在 app ready 前注册抛异常静默失效
(延迟到 ready 后,防线真正生效);② 代理 dispatcher 不放行回环目标(Loopback
Bypass 组合 dispatcher,本地 Ollama/SearXNG/E2E 全部修复);③ safeStorage 加密
回读失败无降级(roundtrip probe,失败会话降级明文存储)。
- **涉及文件**e2e/*(新)、playwright.config(新)、main、utils/network-proxy、
utils/secure-config、package.json
- **验收**`npx playwright test` 2/2 绿(本机含代理环境实测通过)。
## P3 — 收尾
- package.json → 0.8.1README 徽章 / 亮点表 / 配置说明 / 测试命令对齐;
- IR 标准文档补 usage.cacheTokens 透传与"输出上限无模型钳制"语义;
- 全量 typecheck + lint + `npm test`(系统 Node+ `npm run test:electron`(全量)。
---
## Review 回归修复(2026-09-08 第二轮)
完整回归 review(配对审计 + 高风险 diff 逐行复核)发现并修复:
| # | 类型 | 内容 |
|---|---|---|
| R1 | 真 bug | **MCP Prompt 斜杠命令大小写失配** —— cmd 被整体 toLowerCase,大写 server 名与 MCPManager 原始名 Map key 失配("not connected");改为对 mcpPrompts 清单大小写不敏感匹配反查原始名 |
| R2 | 状态一致性 | **分页状态复位缺失** —— agent-store 的 clearMessages / resetSessionState 未复位 messagesComplete/loadingOlder;已补 |
| F1 | 观察项 O1 | **LLMSettings 清空输入静默回写种子默认值**63488/131072+ shared.ts `Number(null)=0` 会把引擎预算清零 —— 语义改为"清空 = 写入 null(未配置)":引擎跳过压缩预算 / 输出上限参数不下发(由服务端默认值决定),helper 文案同步 |
| F2 | 观察项 O2 | **维护弹框空分区提示** —— analyze proposal 增加 sectionEntryCounts,弹框计算"应用后变空的分区"并向用户提示(分区头保留) |
| F3 | 告警位置 | **MEMORY.md >50KB 告警移出固化分支** —— 此前仅固化触发时检查,跳过固化的大文件永不告警;改为每次成功 run 后检查 + 每会话一次去重 |
| F4 | 历史 schema | **working_memories 缺 sessions 外键**(v0.2.0 建表起缺失级联)—— 迁移 13SCHEMA_VERSION 5):孤儿行清理 + 带 FK(CASCADE) 重建;createTables 新库口径对齐 |
| F5 | 历史残留 | **全局配置层废键未清理** —— GLOBAL_KEY_PREFIXES 移除五个 provider 前缀与 ollama.(其下唯一键已废除);initialize 清除全局 JSON 中的 DEPRECATED_CONFIG_KEYS,与工作空间迁移 12 对齐 |
**验证**typecheck 0 错误 / lint 0 问题 / test:electron **2478/2478**(净增 sectionEntryCounts 用例)/ E2E 2/2。
---
## 验证记录(实施完成后回填)
> 全局验证(2026-09-07):
> - `npm run typecheck` 0 错误;`npm run lint` 0 问题
> - `npx playwright test` **2/2 通过**E2E 冒烟)
> - `npm test`(系统 Node):2167 通过 / 310 按 ABI 设计跳过
> - `npm run test:electron`Electron ABI 全量):**2478/2478 通过,0 跳过**
> (基线 2445 → 0.8.1 净增 33 个用例:向量混合检索 / 维护闭环 / 回放缓冲 /
> 分页 / 批量确认聚合 / MCP contents / secure-config probe / sectionEntryCounts
> - sandbox 符号链接用例曾在本机暴露悬空链接绕过 realpath 的真实缺陷(P0 级),
> 已随 0.8.1 根治并通过
| 项 | 验证方式 | 结果 |
|---|---|---|
| P0-1 配置单一化 | max-tokens-clamp 透传契约 12 用例;provider-request-shapes 透传矩阵 19 处重写;anthropic/openai/ollama getContextWindow 0 回退契约;迁移 12 清理键;E2E 断言 max_tokens=2048 透传;附带根治:权限白名单 ready 时序 / 代理回环放行 / safeStorage roundtrip 降级 / 悬空 symlink 白名单逃逸(sandbox lstat | ✅ |
| P0-2 记忆生命周期 | memory-manager 新增 expiresAt / access_count 用例;sessions/data/agent 终态接线(ipc 测试 stub 同步) | ✅ |
| P0-3 回放缓冲 | replay-buffer.test 六用例 + sessions:delete/purge 联动 | ✅ |
| P0-4 i18n 收口 | main-locale zh/en 双表 + 热切换;渲染层 17 处出层;grep 断言零残留 | ✅ |
| P1-1 向量混合检索 | memory 套件 86 用例(含同义改写命中 / 回退 / 惰性回填 / BLOB 回填) | ✅ |
| P1-2 MEMORY.md 维护 | maintainer.test 五用例 + 固化 digest 共享 + MemoryViewer 弹框 + 审计 | ✅ |
| P1-3 可观测闭环 | engine/agent-store cache 字段透传 + TokenUsage 命中率/成本行 + ChatInput 指示条 | ✅ |
| P1-4 MCP 可用化 | mcp-contents.test 五用例 + ChatInput 斜杠/@mcp 接线 | ✅ |
| P2-1 工具自定义策略 | parseToolPolicy fail-closed 解析 + setPolicyOverride 优先级 + ToolsSettings 编辑器 | ✅ |
| P2-2 批量确认聚合 | confirmation-hook.test +2(≥3 聚合 / <3 逐条),既有广播测试 fake timers 适配 | ✅ |
| P2-3 游标分页 | session-pagination.test 四用例 + store/MessageList startReached 接线 | ✅ |
| P2-4 开机自启 | setLoginItem/getLoginItem + 读回语义 + AppearanceSettings 开关 | ✅ |
| P2-5 E2E 冒烟 | playwright 2/2;附带根治权限加固时序 / 回环代理放行 / safeStorage 降级 | ✅ |
| 收尾 | package.json 0.8.1README / IR 标准 / 本清单入库;全量三绿 | ✅ |
+97
View File
@@ -0,0 +1,97 @@
/**
* MetonaAI Desktop — E2E 冒烟测试(v0.8.1 P2-5
*
* 端到端链路(Playwright + Electron):
* 启动应用(隔离 userData)→ 跳过引导(种子配置)→ 新建会话 → 输入消息 →
* 发送 → 引擎调用本地 mock LLM(SSE 流式)→ 断言回复渲染 + mock 收到合法
* 请求体(携带「最大输出上限」设置值)→ 中断按钮恢复 idle。
*
* 契约:不触外网(LLM 指向 127.0.0.1 随机端口 mock)、不污染真实用户数据
* METONA_USER_DATA_DIR 重定向)、断言的配置值即设置面板合法配置项。
*/
import { expect, test, type ElectronApplication, type Page } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startMockLLM, MOCK_REPLY, type MockLLMHandle } from './mock-llm';
let electronApp: ElectronApplication;
let page: Page;
let mock: MockLLMHandle;
let userDataDir: string;
let workspaceDir: string;
test.beforeAll(async () => {
// 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1
mock = await startMockLLM();
// 2. 隔离的用户数据与工作空间目录
userDataDir = mkdtempSync(join(tmpdir(), 'metona-e2e-user-'));
workspaceDir = join(userDataDir, 'workspace');
mkdirSync(workspaceDir, { recursive: true });
// workspace-config.json 指向隔离工作空间(main.ts 启动时优先读取)
writeFileSync(
join(userDataDir, 'workspace-config.json'),
JSON.stringify({ workspacePath: workspaceDir }),
'utf-8',
);
// 3. 启动 Electron(产物构建由 npm run test:e2e 的 build 步骤保证)
electronApp = await electron.launch({
args: ['.'],
env: {
...process.env,
METONA_USER_DATA_DIR: userDataDir,
METONA_E2E_SEED_CONFIG: '1',
METONA_E2E_LLM_URL: mock.url,
// 隔离冒烟环境无更新源(生产环境 updater 由 feedUrl 空值禁用,双保险)
METONA_E2E_DISABLE_UPDATE: '1',
} as Record<string, string>,
});
page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
});
test.afterAll(async () => {
await electronApp?.close();
await mock?.close();
// 调试期保留 userDatamain.log 排查用);稳定后恢复清理
if (!process.env['METONA_E2E_KEEP_USER_DATA']) {
try {
rmSync(userDataDir, { recursive: true, force: true });
} catch {
/* Windows 文件句柄释放延迟 — 清理失败不影响断言 */
}
}
});
test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () => {
// 等待聊天输入框可用(配置加载 + 工具就绪后启用)
// InputBase 把 data-chat-input 挂在根 div —— 实际可编辑元素是内部 textarea
//MUI 另渲染一个 readonly 隐藏 textarea 用于测量,需用 .first() 取可交互的)
const input = page.locator('[data-chat-input] textarea').first();
await expect(input).toBeVisible({ timeout: 30_000 });
// 发送一条用户消息
await input.click();
await input.fill('你好,请做一个冒烟回复');
await input.press('Enter');
// mock LLM 的流式回复出现在聊天区(引擎 → SSE → 渲染层全链路)
await expect(page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 });
// 用户消息持久化回显
await expect(page.getByText('你好,请做一个冒烟回复').first()).toBeVisible();
});
test('冒烟:引擎请求体携带设置面板「最大输出上限」配置(2048)', async () => {
// beforeAll 后发送的第一条消息已在 mock.requests 中(顺序执行时上一测试已完成)
const chatRequests = mock.requests.filter((r) => 'messages' in r);
expect(chatRequests.length).toBeGreaterThanOrEqual(1);
// llm.maxTokens 原样透传(无任何按模型钳制)
expect(chatRequests[0].max_tokens).toBe(2048);
// 请求发往本地 mock(经 adapter 组装),模型名来自种子配置
expect(chatRequests[0].model).toBe('deepseek-v4-flash');
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Mock LLM Server — E2E 冒烟测试的本地 OpenAI 兼容 Providerv0.8.1 P2-5
*
* 实现 DeepSeek 适配器实际消费的最小协议面:
* - POST {baseURL}/chat/completionsstream=true):按 SSE 推送一段固定文本 +
* finish_reason=stop + usage + [DONE]
* - POST /chat/completionsstream=false):非流式 JSON(压缩摘要等内部调用兜底)。
*
* 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。
*/
import { createServer, type Server } from 'http';
import type { AddressInfo } from 'net';
/** 流式回复的固定文本(断言锚点) */
export const MOCK_REPLY = 'Hello from mock LLM. E2E smoke reply.';
export interface MockLLMHandle {
url: string;
close: () => Promise<void>;
/** 收到的 chat/completions 请求体列表(断言用) */
readonly requests: Array<Record<string, unknown>>;
}
export function startMockLLM(): Promise<MockLLMHandle> {
const requests: Array<Record<string, unknown>> = [];
const server: Server = createServer((req, res) => {
if (!req.url?.includes('/chat/completions')) {
res.writeHead(404).end();
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
let parsed: Record<string, unknown> = {};
try {
parsed = JSON.parse(body) as Record<string, unknown>;
} catch {
/* 忽略解析失败 */
}
requests.push(parsed);
const isStream = parsed.stream === true;
if (!isStream) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: 'mock-1',
model: parsed.model ?? 'mock',
choices: [
{
index: 0,
message: { role: 'assistant', content: MOCK_REPLY },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 },
}),
);
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const frame = (payload: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
// 首 chunk:正文增量
frame({
id: 'mock-1',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }],
});
// 末帧:finish_reason + usage
frame({
id: 'mock-1',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 },
});
res.write('data: [DONE]\n\n');
res.end();
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve({
url: `http://127.0.0.1:${addr.port}`,
close: () => new Promise<void>((r) => server.close(() => r())),
requests,
});
});
});
}
@@ -950,11 +950,12 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
expect(adapter.getContextWindow()).toBe(50_000); expect(adapter.getContextWindow()).toBe(50_000);
}); });
it('未知模型 → 兜底 200K', () => { // v0.8.1: 窗口唯一来源是设置面板 llm.contextWindow,未配置返回 0(无写死兜底)
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(200_000); it('未知模型且未配置 → 返回 0(无写死兜底窗口)', () => {
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(0);
}); });
it('listModels 返回本地模型元信息(无网络请求)', async () => { it('listModels 返回本地模型元信息(无网络请求,不含窗口/上限数值', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
const models = await adapter.listModels(); const models = await adapter.listModels();
expect(models.map((m) => m.id)).toEqual([ expect(models.map((m) => m.id)).toEqual([
@@ -962,7 +963,10 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
'claude-opus-4-1', 'claude-opus-4-1',
'claude-haiku-4-5', 'claude-haiku-4-5',
]); ]);
expect(models[0]).toMatchObject({ contextWindow: 200_000, maxOutputTokens: 64_000 }); // v0.8.1 硬性契约: 元信息不再承载 contextWindow / maxOutputTokens
expect(models[0]).toMatchObject({ supportsThinking: true });
expect(models[0].contextWindow).toBeUndefined();
expect(models[0].maxOutputTokens).toBeUndefined();
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });
@@ -130,10 +130,11 @@ describe('BaseAdapter — getContextWindow', () => {
expect(adapter.getContextWindow()).toBe(128_000); expect(adapter.getContextWindow()).toBe(128_000);
}); });
// v0.7.4 P4-5: 兜底从 1M 降至 128K(未知模型按最保守主流窗口预算,防 413) // v0.8.1 硬性契约: 上下文窗口唯一来源是设置面板 llm.contextWindow
it('未配置返回兜底默认值 128K(子类应覆盖真实窗口)', () => { // 未配置返回 0(引擎据此跳过压缩预算)—— 任何写死兜底值均已删除
it('未配置时返回 0(无任何写死兜底窗口,引擎跳过压缩判定)', () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
expect(adapter.getContextWindow()).toBe(128_000); expect(adapter.getContextWindow()).toBe(0);
}); });
}); });
@@ -443,15 +444,15 @@ describe('BaseAdapter — throwHttpError 错误体解析', () => {
// ===== 追加:getContextWindow / listModels / healthCheck ===== // ===== 追加:getContextWindow / listModels / healthCheck =====
describe('BaseAdapter — getContextWindow 回退链', () => { describe('BaseAdapter — getContextWindow 非法值契约', () => {
it('contextWindow=0 视为未配置(需 >0', () => { it('contextWindow=0 视为未配置(返回 0,引擎跳过压缩判定', () => {
const adapter = makeAdapter({ contextWindow: 0 }); const adapter = makeAdapter({ contextWindow: 0 });
expect(adapter.getContextWindow()).toBe(128_000); expect(adapter.getContextWindow()).toBe(0);
}); });
it('contextWindow 为负数视为未配置', () => { it('contextWindow 为负数视为未配置(返回 0', () => {
const adapter = makeAdapter({ contextWindow: -1 }); const adapter = makeAdapter({ contextWindow: -1 });
expect(adapter.getContextWindow()).toBe(128_000); expect(adapter.getContextWindow()).toBe(0);
}); });
}); });
@@ -104,7 +104,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4', () => {
expect(userMsg.content).toBe('这张图片里有什么?'); expect(userMsg.content).toBe('这张图片里有什么?');
}); });
it('vision 模型 max_tokens 钳制到 8192MODEL_INFO 上限', async () => { it('vision 模型 max_tokens 原样透传(v0.8.1:8192 钳制已废除', async () => {
mockFetch.mockResolvedValue(okResponse()); mockFetch.mockResolvedValue(okResponse());
const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
@@ -114,7 +114,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4', () => {
} as MetonaRequest); } as MetonaRequest);
const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string); const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string);
expect(body.max_tokens).toBe(8_192); expect(body.max_tokens).toBe(63_488);
}); });
it('vision 模型无图片时不转换(content 保持纯文本)', async () => { it('vision 模型无图片时不转换(content 保持纯文本)', async () => {
@@ -215,7 +215,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4', () => {
}); });
}); });
it('vision 模型 max_tokens 未配置 → 默认 8192MODEL_INFO 上限', async () => { it('vision 模型 max_tokens 未配置 → 不下发该字段(v0.8.1:无写死兜底', async () => {
mockFetch.mockResolvedValue(okResponse()); mockFetch.mockResolvedValue(okResponse());
const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
@@ -225,7 +225,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4', () => {
} as MetonaRequest); } as MetonaRequest);
const body = requestBody(); const body = requestBody();
expect(body.max_tokens).toBe(8_192); expect(body.max_tokens).toBeUndefined();
}); });
it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => { it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => {
@@ -1,10 +1,10 @@
/** /**
* Provider maxTokens 上限钳制契约测试(v0.5.3 * maxTokens 透传契约测试(v0.8.1 硬性契约重写
* *
* 背景:引擎默认 maxTokens=63488engine.ts DEFAULT_CONFIG),超过部分模型 * 背景:v0.5.3 曾引入"按 MODEL_INFO.maxOutputTokens 钳制"逻辑。v0.8.1 按硬性
* 上限时 API 直接 400 —— OpenAI gpt-4o(16384)/gpt-4.1(32768)、Anthropic * 契约废除 —— 设置面板「最大输出上限」(llm.maxTokens)是唯一合法的输出上限
* opus/haiku(32000)、MiMo standard(32768) 曾不可用。v0.5.3 各 adapter 按 * 配置,adapter 对一切 Provider/模型**原样透传** `params.maxTokens`,代码中
* MODEL_INFO.maxOutputTokens 钳制 * 不存在任何写死的输出上限或按模型元信息的钳制行为
* *
* 测试策略(v0.5.2 教训):mock fetch 记录真实请求体并断言契约 —— * 测试策略(v0.5.2 教训):mock fetch 记录真实请求体并断言契约 ——
* 不 mock adapter 内部方法,验证"发出的 HTTP 请求体"这个最终事实。 * 不 mock adapter 内部方法,验证"发出的 HTTP 请求体"这个最终事实。
@@ -33,7 +33,7 @@ afterEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
}); });
/** 引擎默认形态的请求(maxTokens=63488,与 engine DEFAULT_CONFIG 一致 */ /** 设置面板「最大输出上限」配置生效时的请求形态(llm.maxTokens → params.maxTokens */
function makeRequest(overrides: Partial<MetonaRequest> = {}): MetonaRequest { function makeRequest(overrides: Partial<MetonaRequest> = {}): MetonaRequest {
return { return {
meta: { meta: {
@@ -79,8 +79,8 @@ function requestBody(): Record<string, unknown> {
return JSON.parse(init.body as string) as Record<string, unknown>; return JSON.parse(init.body as string) as Record<string, unknown>;
} }
describe('maxTokens 模型上限钳制(引擎默认 63488 场景', () => { describe('maxTokens 原样透传(v0.8.1:设置面板是唯一合法上限配置', () => {
it('DeepSeek v4 pro(上限 384K):63488 未超限,原样传递', async () => { it('DeepSeekmax_tokens 原样透传(不按模型钳制)', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const adapter = new DeepSeekAdapter({ const adapter = new DeepSeekAdapter({
provider: 'deepseek', provider: 'deepseek',
@@ -92,7 +92,19 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
expect(requestBody().max_tokens).toBe(63_488); expect(requestBody().max_tokens).toBe(63_488);
}); });
it('Agnes flash(上限 65536):63488 未超限,原样传递', async () => { it('DeepSeek:超出旧模型元信息上限的值同样原样透传(钳制已废除)', async () => {
mockFetch.mockResolvedValue(openAIResponse());
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'sk',
defaultModel: 'deepseek-v4-flash-vision-exp',
});
await adapter.send(makeRequest());
expect(requestBody().max_tokens).toBe(63_488);
});
it('Agnesmax_tokens 原样透传', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const adapter = new AgnesAdapter({ const adapter = new AgnesAdapter({
provider: 'agnes', provider: 'agnes',
@@ -104,7 +116,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
expect(requestBody().max_tokens).toBe(63_488); expect(requestBody().max_tokens).toBe(63_488);
}); });
it('MiMo standard(上限 32768):钳制到 32768(原为 400 错误场景', async () => { it('MiMo standardmax_completion_tokens 原样透传(旧 32768 钳制已废除', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const adapter = new MimoAdapter({ const adapter = new MimoAdapter({
provider: 'mimo', provider: 'mimo',
@@ -113,10 +125,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
defaultModel: 'mimo-v2.5', defaultModel: 'mimo-v2.5',
}); });
await adapter.send(makeRequest()); await adapter.send(makeRequest());
expect(requestBody().max_completion_tokens).toBe(32_768); expect(requestBody().max_completion_tokens).toBe(63_488);
}); });
it('MiMo pro(上限 131072):63488 未超限,原样传递', async () => { it('MiMo promax_completion_tokens 原样透传', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const adapter = new MimoAdapter({ const adapter = new MimoAdapter({
provider: 'mimo', provider: 'mimo',
@@ -128,7 +140,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
expect(requestBody().max_completion_tokens).toBe(63_488); expect(requestBody().max_completion_tokens).toBe(63_488);
}); });
it('OpenAI gpt-4o上限 16384):钳制到 16384(原为 400 错误场景', async () => { it('OpenAI gpt-4o非推理模型):max_tokens 原样透传(旧 16384 钳制已废除', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const adapter = new OpenAIAdapter({ const adapter = new OpenAIAdapter({
provider: 'openai', provider: 'openai',
@@ -137,10 +149,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
defaultModel: 'gpt-4o', defaultModel: 'gpt-4o',
}); });
await adapter.send(makeRequest()); await adapter.send(makeRequest());
expect(requestBody().max_tokens).toBe(16_384); expect(requestBody().max_tokens).toBe(63_488);
}); });
it('OpenAI o3-mini上限 100K):63488 未超限,推理模型字段名正确', async () => { it('OpenAI o3-mini(推理模型):max_completion_tokens 原样透传,字段名路由正确', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const adapter = new OpenAIAdapter({ const adapter = new OpenAIAdapter({
provider: 'openai', provider: 'openai',
@@ -153,7 +165,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
expect(requestBody().max_tokens).toBeUndefined(); expect(requestBody().max_tokens).toBeUndefined();
}); });
it('Anthropic opus(上限 32000):钳制到 32000(原为 400 错误场景', async () => { it('Anthropic opusmax_tokens 原样透传(旧 32000 钳制已废除', async () => {
mockFetch.mockResolvedValue(anthropicResponse()); mockFetch.mockResolvedValue(anthropicResponse());
const adapter = new AnthropicAdapter({ const adapter = new AnthropicAdapter({
provider: 'anthropic', provider: 'anthropic',
@@ -162,10 +174,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
defaultModel: 'claude-opus-4-1', defaultModel: 'claude-opus-4-1',
}); });
await adapter.send(makeRequest()); await adapter.send(makeRequest());
expect(requestBody().max_tokens).toBe(32_000); expect(requestBody().max_tokens).toBe(63_488);
}); });
it('Anthropic sonnet(上限 64000):63488 未超限', async () => { it('Anthropic sonnetmax_tokens 原样透传', async () => {
mockFetch.mockResolvedValue(anthropicResponse()); mockFetch.mockResolvedValue(anthropicResponse());
const adapter = new AnthropicAdapter({ const adapter = new AnthropicAdapter({
provider: 'anthropic', provider: 'anthropic',
@@ -177,13 +189,12 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
expect(requestBody().max_tokens).toBe(63_488); expect(requestBody().max_tokens).toBe(63_488);
}); });
it('未配置 maxTokens 时各 adapter 使用安全默认值(不超过模型上限', async () => { it('未配置 maxTokens:不下发任何输出上限字段(由 Provider 服务端默认值决定', async () => {
mockFetch.mockResolvedValue(openAIResponse()); mockFetch.mockResolvedValue(openAIResponse());
const request = makeRequest({ const request = makeRequest({
params: { temperature: 0, stream: false } as MetonaRequest['params'], params: { temperature: 0, stream: false } as MetonaRequest['params'],
}); });
// MiMo standard + thinking 默认 → 兜底 32768= 上限,安全)
const mimo = new MimoAdapter({ const mimo = new MimoAdapter({
provider: 'mimo', provider: 'mimo',
baseURL: 'https://api.mimo.com/v1', baseURL: 'https://api.mimo.com/v1',
@@ -191,6 +202,33 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
defaultModel: 'mimo-v2.5', defaultModel: 'mimo-v2.5',
}); });
await mimo.send(request); await mimo.send(request);
expect(requestBody().max_completion_tokens).toBe(32_768); expect(requestBody().max_completion_tokens).toBeUndefined();
const deepseek = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'sk',
defaultModel: 'deepseek-v4-pro',
});
mockFetch.mockReset();
mockFetch.mockResolvedValue(openAIResponse());
await deepseek.send(request);
expect(requestBody().max_tokens).toBeUndefined();
});
it('Anthropic 未配置 maxTokens + thinking 开启:仅满足协议下限 2048(协议不变量,非上限)', async () => {
mockFetch.mockResolvedValue(anthropicResponse());
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'https://api.anthropic.com',
apiKey: 'k',
defaultModel: 'claude-opus-4-1',
});
await adapter.send(
makeRequest({
params: { temperature: 0, stream: false, thinkingEnabled: true } as MetonaRequest['params'],
}),
);
expect(requestBody().max_tokens).toBe(2048);
}); });
}); });
@@ -452,13 +452,25 @@ describe('OllamaAdapter — 能力探测', () => {
expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true }); expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true });
}); });
it('capabilities 为空数组(showModel 缺省 [])→ 三能力全 false(非 null', async () => { // v0.8.1: showModel 保留 undefined 语义 —— 响应缺 capabilities 字段 = 未知 → null
//fail-open),不再把"字段缺失"与"权威空"混同(探测竞态下曾误判不支持思考)
it('capabilities 字段缺失 → 返回 null(未知,fail-open', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ parameters: '', template: '' }), json: async () => ({ parameters: '', template: '' }),
} as unknown as Response); } as unknown as Response);
expect(await adapter.probeCapabilities('qwen3')).toBeNull();
});
it('capabilities 为显式空数组(服务端权威无能力)→ 三能力全 false', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ parameters: '', template: '', capabilities: [] }),
} as unknown as Response);
expect(await adapter.probeCapabilities('qwen3')).toEqual({ expect(await adapter.probeCapabilities('qwen3')).toEqual({
supportsTools: false, supportsTools: false,
supportsVision: false, supportsVision: false,
@@ -688,28 +700,33 @@ describe('OllamaAdapter — 图片归一化', () => {
// ===== getContextWindow ===== // ===== getContextWindow =====
describe('OllamaAdapter — getContextWindow', () => { describe('OllamaAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => {
it('未探测到时返回默认 4096', () => { it('未配置 contextWindow → 返回 0(无 4096 写死兜底,引擎跳过压缩判定)', () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
expect(adapter.getContextWindow()).toBe(4096); expect(adapter.getContextWindow()).toBe(0);
}); });
it('探测到 num_ctx 后返回实测值(机会主义缓存收敛)', async () => { it('config.contextWindowllm.contextWindow 注入)→ 返回配置值', () => {
// 需在构造前就位:构造时的 fire-and-forget 探测消费首个 fetch const adapter = makeAdapter('qwen3', { contextWindow: 32_768 });
expect(adapter.getContextWindow()).toBe(32_768);
});
it('/api/show 探测不再缓存窗口数值(num_ctx 由引擎 contextLength 下发)', async () => {
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }), json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }),
} as unknown as Response); } as unknown as Response);
const adapter = makeAdapter(); const adapter = makeAdapter();
// 等待构造时的探测完成并写入缓存 // 探测完成(含失败路径)后窗口仍为 0 —— 探测只服务于能力门控
await vi.waitFor(() => expect(adapter.getContextWindow()).toBe(32768)); await new Promise((r) => setTimeout(r, 20));
expect(adapter.getContextWindow()).toBe(0);
}); });
it('探测失败(网络错误)→ 保持默认 4096', async () => { it('探测失败(网络错误)→ 仍返回配置值/0,不抛错不阻塞', async () => {
mockFetch.mockRejectedValue(new Error('down')); mockFetch.mockRejectedValue(new Error('down'));
const adapter = makeAdapter(); const adapter = makeAdapter();
await new Promise((r) => setTimeout(r, 20)); await new Promise((r) => setTimeout(r, 20));
expect(adapter.getContextWindow()).toBe(4096); expect(adapter.getContextWindow()).toBe(0);
}); });
}); });
@@ -205,12 +205,12 @@ describe('OpenAIAdapter — 推理模型拒图(ModelCapabilityError', () =>
// ===== max_completion_tokens / max_tokens 路由 ===== // ===== max_completion_tokens / max_tokens 路由 =====
describe('OpenAIAdapter — token 参数路由', () => { describe('OpenAIAdapter — token 参数路由v0.8.1:原样透传,无模型钳制)', () => {
it.each([ it.each([
['o3-mini', 63_488, 63_488, 'max_completion_tokens'], ['o3-mini', 63_488, 63_488, 'max_completion_tokens'],
['o3-mini', 200_000, 100_000, 'max_completion_tokens'], // 上限 100000 ['o3-mini', 200_000, 200_000, 'max_completion_tokens'], // 超过任何旧元信息上限 → 原样
['gpt-4o', 63_488, 16_384, 'max_tokens'], ['gpt-4o', 63_488, 63_488, 'max_tokens'],
['gpt-4.1', 63_488, 32_768, 'max_tokens'], ['gpt-4.1', 63_488, 63_488, 'max_tokens'],
] as const)('%s maxTokens=%d → %s=%d', async (model, requested, expected, field) => { ] as const)('%s maxTokens=%d → %s=%d', async (model, requested, expected, field) => {
const adapter = makeAdapter(model); const adapter = makeAdapter(model);
mockFetch.mockResolvedValue(okResponse()); mockFetch.mockResolvedValue(okResponse());
@@ -224,18 +224,18 @@ describe('OpenAIAdapter — token 参数路由', () => {
expect(body[other]).toBeUndefined(); expect(body[other]).toBeUndefined();
}); });
it('o3-mini 未配置 maxTokens → 默认 32768thinking 场景安全值', async () => { it('o3-mini 未配置 maxTokens → 不下发 max_completion_tokens(无写死兜底', async () => {
const adapter = makeAdapter('o3-mini'); const adapter = makeAdapter('o3-mini');
mockFetch.mockResolvedValue(okResponse()); mockFetch.mockResolvedValue(okResponse());
await adapter.send(makeRequest({ params: { temperature: 0, stream: false } })); await adapter.send(makeRequest({ params: { temperature: 0, stream: false } }));
expect(lastBody().max_completion_tokens).toBe(32_768); expect(lastBody().max_completion_tokens).toBeUndefined();
}); });
it('非推理模型未配置 maxTokens → 默认模型上限', async () => { it('非推理模型未配置 maxTokens → 不下发 max_tokens(无写死兜底)', async () => {
const adapter = makeAdapter('gpt-4o'); const adapter = makeAdapter('gpt-4o');
mockFetch.mockResolvedValue(okResponse()); mockFetch.mockResolvedValue(okResponse());
await adapter.send(makeRequest({ params: { temperature: 0, stream: false } })); await adapter.send(makeRequest({ params: { temperature: 0, stream: false } }));
expect(lastBody().max_tokens).toBe(16_384); expect(lastBody().max_tokens).toBeUndefined();
}); });
}); });
@@ -277,23 +277,17 @@ describe('OpenAIAdapter — temperature 路由', () => {
// ===== getContextWindow 回退链 ===== // ===== getContextWindow 回退链 =====
describe('OpenAIAdapter — getContextWindow 回退链', () => { describe('OpenAIAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => {
it('gpt-4.1 返回 1M 上下文', () => { it('config.contextWindow 显式配置(llm.contextWindow 注入)返回配置值', () => {
expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(1_000_000);
});
it('o3-mini 返回 200K', () => {
expect(makeAdapter('o3-mini').getContextWindow()).toBe(200_000);
});
it('未知模型 → 兜底 128Kv0.7.4 P4-5 从 1M 降级)', () => {
expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(128_000);
});
it('config.contextWindow 显式配置优先', () => {
const adapter = makeAdapter('gpt-4o', { contextWindow: 64_000 }); const adapter = makeAdapter('gpt-4o', { contextWindow: 64_000 });
expect(adapter.getContextWindow()).toBe(64_000); expect(adapter.getContextWindow()).toBe(64_000);
}); });
it('未配置(任意模型,含已知/未知)→ 返回 0(引擎跳过压缩判定,无写死兜底)', () => {
expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(0);
expect(makeAdapter('o3-mini').getContextWindow()).toBe(0);
expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(0);
});
}); });
// ===== listModels ===== // ===== listModels =====
@@ -303,7 +297,9 @@ describe('OpenAIAdapter — listModels 动态发现与降级', () => {
mockFetch.mockResolvedValue(okResponse({ data: [{ id: 'gpt-4o' }, { id: 'custom-model' }] })); mockFetch.mockResolvedValue(okResponse({ data: [{ id: 'gpt-4o' }, { id: 'custom-model' }] }));
const models = await makeAdapter('gpt-4o').listModels(); const models = await makeAdapter('gpt-4o').listModels();
expect(models).toHaveLength(2); expect(models).toHaveLength(2);
expect(models[0]).toMatchObject({ id: 'gpt-4o', contextWindow: 128_000 }); // v0.8.1: 元信息不再承载窗口/上限数值
expect(models[0]).toMatchObject({ id: 'gpt-4o', name: 'GPT-4o' });
expect(models[0].contextWindow).toBeUndefined();
expect(models[1]).toEqual({ id: 'custom-model' }); expect(models[1]).toEqual({ id: 'custom-model' });
// /models 请求头携带 Bearer // /models 请求头携带 Bearer
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
@@ -178,7 +178,7 @@ describe('AnthropicAdapter — 请求体契约', () => {
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1'); expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
}); });
it('A2: max_tokens 按模型上限钳制(63488 → sonnet 64000 / opus 32000', async () => { it('A2: max_tokens 原样透传(v0.8.1:模型钳制已废除,设置面板是唯一上限来源', async () => {
const sonnet = new AnthropicAdapter({ const sonnet = new AnthropicAdapter({
provider: 'anthropic', provider: 'anthropic',
baseURL: 'http://a.test', baseURL: 'http://a.test',
@@ -194,9 +194,9 @@ describe('AnthropicAdapter — 请求体契约', () => {
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await sonnet.send(makeRequest()); await sonnet.send(makeRequest());
await opus.send(makeRequest()); await opus.send(makeRequest());
// 引擎默认 63488 低于 sonnet 上限 64000 → 原样保留;opus 上限 32000 → 钳制生效 // v0.8.1: 设置面板「最大输出上限」对一切模型原样透传,无任何按模型钳制
expect(bodies[0].max_tokens).toBe(63_488); expect(bodies[0].max_tokens).toBe(63_488);
expect(bodies[1].max_tokens).toBe(32_000); expect(bodies[1].max_tokens).toBe(63_488);
}); });
it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024v0.6.4 边界加固)', async () => { it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024v0.6.4 边界加固)', async () => {
@@ -516,8 +516,8 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
['low', 1024], ['low', 1024],
['medium', 4096], ['medium', 4096],
['high', 16384], ['high', 16384],
// max=32768 但 sonnet 的 max_tokens 先钳到 64000 → budget 二次钳到 floor(64000/2)=32000 // v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768
['max', 32000], ['max', 32768],
] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => { ] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => {
const adapter = makeAdapter(); const adapter = makeAdapter();
const { bodies } = captureFetch(); const { bodies } = captureFetch();
@@ -570,13 +570,13 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
}); });
}); });
describe('AnthropicAdapter — max_tokens 钳制矩阵', () => { describe('AnthropicAdapter — max_tokens 透传矩阵(v0.8.1 无钳制)', () => {
it.each([ it.each([
['claude-sonnet-4-5', 63_488, 63_488], // 引擎默认低于上限 → 原样 ['claude-sonnet-4-5', 63_488, 63_488],
['claude-sonnet-4-5', 70_000, 64_000], // 超上限 → 钳到 sonnet 64000 ['claude-sonnet-4-5', 70_000, 70_000], // 超过任何旧元信息上限 → 原样透传
['claude-opus-4-1', 63_488, 32_000], // opus 上限 32000 ['claude-opus-4-1', 63_488, 63_488],
['claude-haiku-4-5', 63_488, 32_000], // haiku 上限 32000 ['claude-haiku-4-5', 63_488, 63_488],
['claude-sonnet-4-5', 500, 500], // 低于上限 → 原样 ['claude-sonnet-4-5', 500, 500],
])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => { ])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => {
const adapter = new AnthropicAdapter({ const adapter = new AnthropicAdapter({
provider: 'anthropic', provider: 'anthropic',
@@ -795,7 +795,7 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
expect(bodies[0].stop).toEqual(['<END>']); expect(bodies[0].stop).toEqual(['<END>']);
}); });
it('max_tokens 按模型钳制(pro 384000 / vision 8192', async () => { it('max_tokens 原样透传(v0.8.1pro/vision 均不钳制', async () => {
const pro = makeAdapter('deepseek-v4-pro'); const pro = makeAdapter('deepseek-v4-pro');
const vision = makeAdapter('deepseek-v4-flash-vision-exp'); const vision = makeAdapter('deepseek-v4-flash-vision-exp');
const { bodies } = captureFetch(); const { bodies } = captureFetch();
@@ -803,8 +803,8 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
await vision.send( await vision.send(
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }), makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
); );
expect(bodies[0].max_tokens).toBe(384_000); expect(bodies[0].max_tokens).toBe(500_000);
expect(bodies[1].max_tokens).toBe(8_192); expect(bodies[1].max_tokens).toBe(63_488);
}); });
}); });
@@ -864,8 +864,8 @@ describe('AgnesAdapter — enable_thinking 对称性矩阵', () => {
makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }), makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }),
); );
expect(bodies[0].temperature).toBe(0.9); expect(bodies[0].temperature).toBe(0.9);
// 65536 上限钳制 // v0.8.1: 原样透传,无 65536 钳制
expect(bodies[0].max_tokens).toBe(65_536); expect(bodies[0].max_tokens).toBe(70_000);
}); });
}); });
@@ -912,21 +912,21 @@ describe('MimoAdapter — thinking 显式开关', () => {
expect(bodies[0].top_p).toBeUndefined(); expect(bodies[0].top_p).toBeUndefined();
}); });
it('max_completion_tokens 钳制矩阵(pro 131072 / standard 32768', async () => { it('max_completion_tokens 原样透传(v0.8.1pro/standard 均不钳制', async () => {
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' }); const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
const std = makeAdapter({ defaultModel: 'mimo-v2.5' }); const std = makeAdapter({ defaultModel: 'mimo-v2.5' });
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } })); await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }));
await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } })); await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }));
expect(bodies[0].max_completion_tokens).toBe(131_072); expect(bodies[0].max_completion_tokens).toBe(200_000);
expect(bodies[1].max_completion_tokens).toBe(32_768); expect(bodies[1].max_completion_tokens).toBe(63_488);
}); });
it('thinking 未关闭时未配置 maxTokens → 兜底 32768(思考占配额,防截断', async () => { it('thinking 未关闭时未配置 maxTokens → 不下发该字段(v0.8.1:无写死兜底值', async () => {
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' }); const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { temperature: 0, stream: false } })); await pro.send(makeRequest({ params: { temperature: 0, stream: false } }));
expect(bodies[0].max_completion_tokens).toBe(32_768); expect(bodies[0].max_completion_tokens).toBeUndefined();
}); });
it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => { it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => {
@@ -1029,27 +1029,36 @@ describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1', () => {
expect(bodies[0].reasoning_effort).toBeUndefined(); expect(bodies[0].reasoning_effort).toBeUndefined();
}); });
it('gpt-4.1 长上下文 1M → getContextWindow 返回 1M(模型元信息表', () => { it('gpt-4.1 → getContextWindow 返回设置面板配置值(v0.8.1:元信息不再承载窗口', () => {
const adapter = makeAdapter('gpt-4.1'); const adapter = new OpenAIAdapter({
provider: 'openai',
baseURL: 'http://o.test',
apiKey: 'k',
defaultModel: 'gpt-4.1',
contextWindow: 1_000_000,
});
expect(adapter.getContextWindow()).toBe(1_000_000); expect(adapter.getContextWindow()).toBe(1_000_000);
// 未配置 → 0(引擎跳过压缩判定),无任何写死兜底
const noCfg = makeAdapter('gpt-4.1');
expect(noCfg.getContextWindow()).toBe(0);
}); });
it('o3-mini max_completion_tokens 钳制到 100000', async () => { it('o3-mini max_completion_tokens 原样透传(v0.8.1:无 100000 钳制)', async () => {
const adapter = makeAdapter('o3-mini'); const adapter = makeAdapter('o3-mini');
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await adapter.send( await adapter.send(
makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }), makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }),
); );
expect(bodies[0].max_completion_tokens).toBe(100_000); expect(bodies[0].max_completion_tokens).toBe(200_000);
}); });
it('gpt-4o max_tokens 钳制到 16384', async () => { it('gpt-4o max_tokens 原样透传(v0.8.1:无 16384 钳制)', async () => {
const adapter = makeAdapter('gpt-4o'); const adapter = makeAdapter('gpt-4o');
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await adapter.send( await adapter.send(
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }), makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
); );
expect(bodies[0].max_tokens).toBe(16_384); expect(bodies[0].max_tokens).toBe(63_488);
}); });
}); });
@@ -1255,16 +1264,16 @@ describe('OllamaAdapter — options 缺省与工具定义', () => {
// ===== 跨 Provider maxTokens 钳制矩阵 ===== // ===== 跨 Provider maxTokens 钳制矩阵 =====
describe('跨 Provider — maxTokens 钳制矩阵汇总', () => { describe('跨 Provider — maxTokens 透传矩阵汇总v0.8.1 无钳制)', () => {
it.each([ it.each([
['anthropic', 'claude-opus-4-1', 100_000, 32_000], ['anthropic', 'claude-opus-4-1', 100_000, 100_000],
['anthropic', 'claude-sonnet-4-5', 100_000, 64_000], ['anthropic', 'claude-sonnet-4-5', 100_000, 100_000],
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 8_192], ['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 100_000],
['agnes', 'agnes-2.0-flash', 100_000, 65_536], ['agnes', 'agnes-2.0-flash', 100_000, 100_000],
['mimo', 'mimo-v2.5', 100_000, 32_768], ['mimo', 'mimo-v2.5', 100_000, 100_000],
['openai', 'gpt-4o', 100_000, 16_384], ['openai', 'gpt-4o', 100_000, 100_000],
] as const)( ] as const)(
'%s %s maxTokens=100000 → 钳制为 %d', '%s %s maxTokens=100000 → 原样透传 %d',
async (provider, model, requested, expected) => { async (provider, model, requested, expected) => {
const adapterMap: Record<string, unknown> = { const adapterMap: Record<string, unknown> = {
anthropic: new AnthropicAdapter({ anthropic: new AnthropicAdapter({
@@ -56,7 +56,7 @@ function asNative(
} }
describe('P0-3 修订: 用户思考意图优先于模型元信息', () => { describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
it('DeepSeek: vision-exp(元信息 false+ 用户开启思考 → 照发 enabled + reasoning_effortmax_tokens 仍按模型钳制 8192', async () => { it('DeepSeek: vision-exp(元信息 false+ 用户开启思考 → 照发 enabled + reasoning_effortmax_tokens 原样透传(v0.8.1 无钳制)', async () => {
const adapter = new DeepSeekAdapter({ const adapter = new DeepSeekAdapter({
provider: 'deepseek', provider: 'deepseek',
baseURL: 'https://api.deepseek.com', baseURL: 'https://api.deepseek.com',
@@ -66,7 +66,7 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
const body = asNative(adapter)(makeRequest(), false); const body = asNative(adapter)(makeRequest(), false);
expect(body.thinking).toEqual({ type: 'enabled' }); expect(body.thinking).toEqual({ type: 'enabled' });
expect(body.reasoning_effort).toBe('max'); expect(body.reasoning_effort).toBe('max');
expect(body.max_tokens).toBe(8192); expect(body.max_tokens).toBe(63488);
}); });
it('DeepSeek: 用户关闭思考 → 显式 disabled', async () => { it('DeepSeek: 用户关闭思考 → 显式 disabled', async () => {
+6 -10
View File
@@ -23,16 +23,15 @@ export class AgnesAdapter extends OpenAICompatibleAdapter {
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
// H-2 修复: Agnes 模型元信息(1M 上下文,65.5K 最大输出) // H-2 修复: Agnes 模型元信息(v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限
// 数值已按硬性契约删除,唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = { private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'agnes-2.0-flash': { 'agnes-2.0-flash': {
id: 'agnes-2.0-flash', id: 'agnes-2.0-flash',
name: 'Agnes 2.0 Flash', name: 'Agnes 2.0 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 65_536,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片(URL + Base64)与思考模式', description: 'Agnes AI 快速版,支持多模态图片(URL + Base64)与思考模式',
}, },
}; };
@@ -72,16 +71,13 @@ export class AgnesAdapter extends OpenAICompatibleAdapter {
); );
} }
// v0.5.3: max_tokens 按模型上限钳制(agnes-2.0-flash 上限 65536 // v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens),
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel]; // 删除了旧的按模型元信息钳制逻辑
const maxOutput = modelInfo?.maxOutputTokens ?? 65_536;
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: this.config.defaultModel, model: this.config.defaultModel,
messages, messages,
temperature: request.params.temperature, temperature: request.params.temperature,
max_tokens: maxTokens, max_tokens: request.params.maxTokens,
stream, stream,
}; };
+19 -27
View File
@@ -52,21 +52,19 @@ export class AnthropicAdapter extends BaseAdapter {
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
// v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = { private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'claude-sonnet-4-5': { 'claude-sonnet-4-5': {
id: 'claude-sonnet-4-5', id: 'claude-sonnet-4-5',
name: 'Claude Sonnet 4.5', name: 'Claude Sonnet 4.5',
contextWindow: 200_000,
maxOutputTokens: 64_000,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用', description: 'Anthropic 旗舰模型,支持扩展思考与工具调用',
}, },
'claude-opus-4-1': { 'claude-opus-4-1': {
id: 'claude-opus-4-1', id: 'claude-opus-4-1',
name: 'Claude Opus 4.1', name: 'Claude Opus 4.1',
contextWindow: 200_000,
maxOutputTokens: 32_000,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'Anthropic 深度推理模型', description: 'Anthropic 深度推理模型',
@@ -74,8 +72,6 @@ export class AnthropicAdapter extends BaseAdapter {
'claude-haiku-4-5': { 'claude-haiku-4-5': {
id: 'claude-haiku-4-5', id: 'claude-haiku-4-5',
name: 'Claude Haiku 4.5', name: 'Claude Haiku 4.5',
contextWindow: 200_000,
maxOutputTokens: 32_000,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'Anthropic 低延迟模型', description: 'Anthropic 低延迟模型',
@@ -416,13 +412,9 @@ export class AnthropicAdapter extends BaseAdapter {
return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id }); return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id });
} }
override getContextWindow(): number { // getContextWindow 使用基类实现 —— v0.8.1 硬性契约:唯一来源是
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { // 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow),
return this.config.contextWindow; // 未配置返回 0,引擎据此跳过压缩预算计算。
}
const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 200_000;
}
// ========== 私有方法 ========== // ========== 私有方法 ==========
@@ -531,22 +523,21 @@ export class AnthropicAdapter extends BaseAdapter {
}); });
} }
// v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)— // v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens),
// 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400thinking budget 已在此值内二分 // 删除了旧的按模型元信息钳制逻辑(MODEL_INFO.maxOutputTokens 已删除)。
const anthropicMaxOutput = // 唯一保留的协议不变量:thinking 开启时 max_tokens ≥ 2048 —— Anthropic 协议
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000; // 要求 budget_tokens >= 1024 且 < max_tokens,用户配置低于该下限时 API 必然
// 400,此为协议正确性下限而非输出上限(不修改用户配置的持久化值,仅在
// v0.6.4 边界加固: thinking 开启时保证 max_tokens ≥ 2048 —— 协议要求 // 本次请求体上满足协议约束)。
// budget_tokens >= 1024 且 < max_tokens。原实现当用户配置极小 maxTokens const requestedMaxTokens = request.params.maxTokens;
// (如 1500)时 Math.floor(1500/2)=750 < 1024 直接 API 400。
const requestedMaxTokens = request.params.maxTokens ?? 8192;
const maxTokensForRequest = thinkingRequested const maxTokensForRequest = thinkingRequested
? Math.max(2048, Math.min(requestedMaxTokens, anthropicMaxOutput)) ? Math.max(2048, requestedMaxTokens ?? 2048)
: Math.min(requestedMaxTokens, anthropicMaxOutput); : requestedMaxTokens;
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: this.config.defaultModel, model: this.config.defaultModel,
max_tokens: maxTokensForRequest, // v0.8.1: 未配置「最大输出上限」时不下发 max_tokens(服务端默认值生效)
...(maxTokensForRequest != null ? { max_tokens: maxTokensForRequest } : {}),
messages: merged, messages: merged,
stream, stream,
}; };
@@ -581,7 +572,8 @@ export class AnthropicAdapter extends BaseAdapter {
max: 32768, max: 32768,
}; };
const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384; const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384;
const budget = Math.min(effortBudget, Math.floor(maxTokensForRequest / 2)); // thinking 路径 maxTokensForRequest 恒为数字(Math.max(2048, …) 兜底)
const budget = Math.min(effortBudget, Math.floor((maxTokensForRequest ?? 2048) / 2));
body.thinking = { type: 'enabled', budget_tokens: budget }; body.thinking = { type: 'enabled', budget_tokens: budget };
} else { } else {
body.temperature = request.params.temperature; body.temperature = request.params.temperature;
+5 -9
View File
@@ -69,19 +69,15 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
/** /**
* H-2 修复: 获取上下文窗口大小 * H-2 修复: 获取上下文窗口大小
* *
* config contextWindow * v0.8.1 LLM
* Engine 使 * llm.contextWindow main.ts AdapterConfig.contextWindow
* * 0
* @returns token * syncContextWindow >0
*/ */
getContextWindow(): number { getContextWindow(): number {
// 优先使用 AdapterConfig.contextWindow(如果存在)
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow; const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
if (typeof ctx === 'number' && ctx > 0) return ctx; if (typeof ctx === 'number' && ctx > 0) return ctx;
// v0.7.4 P4-5: 兜底从 1M 降至 128K —— 旧默认值 1M 在 config 与模型元信息均缺失时 return 0;
// (如 DeepSeek 未知模型),压缩阈值按 1M 算,实际 64K/128K 模型会先 413 再压缩。
// 128K 是当前最保守的主流窗口,未知模型按最小值预算更安全。
return 128_000;
} }
/** /**
+10 -18
View File
@@ -28,32 +28,27 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
// H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出) // H-2 修复: DeepSeek 模型元信息(v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限
// 数值已按硬性契约删除,唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = { private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'deepseek-v4-pro': { 'deepseek-v4-pro': {
id: 'deepseek-v4-pro', id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro', name: 'DeepSeek V4 Pro',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用', description: 'DeepSeek 旗舰模型,支持深度推理与工具调用',
}, },
'deepseek-v4-flash': { 'deepseek-v4-flash': {
id: 'deepseek-v4-flash', id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash', name: 'DeepSeek V4 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'DeepSeek 快速版,1M 上下文,低延迟推理', description: 'DeepSeek 快速版,低延迟推理',
}, },
// v0.5.4: DeepSeek 多模态实验模型(OpenAI image_url content parts 格式) // v0.5.4: DeepSeek 多模态实验模型(OpenAI image_url content parts 格式)
'deepseek-v4-flash-vision-exp': { 'deepseek-v4-flash-vision-exp': {
id: 'deepseek-v4-flash-vision-exp', id: 'deepseek-v4-flash-vision-exp',
name: 'DeepSeek V4 Flash Vision (Exp)', name: 'DeepSeek V4 Flash Vision (Exp)',
contextWindow: 128_000,
maxOutputTokens: 8_192,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: false, supportsThinking: false,
description: 'DeepSeek 多模态实验模型,支持图片输入(image_url content parts', description: 'DeepSeek 多模态实验模型,支持图片输入(image_url content parts',
@@ -173,16 +168,13 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
const messages = buildOpenAICompatibleMessages(request, this.isVisionModel()); const messages = buildOpenAICompatibleMessages(request, this.isVisionModel());
const tools = buildOpenAICompatibleTools(request.tools); const tools = buildOpenAICompatibleTools(request.tools);
// v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400 // v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens),
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]; // 删除了旧的按模型元信息钳制逻辑 —— 代码中不存在任何写死的输出上限。
const maxOutput = modelInfo?.maxOutputTokens ?? 384_000;
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: this.config.defaultModel, model: this.config.defaultModel,
messages, messages,
temperature: request.params.temperature, temperature: request.params.temperature,
max_tokens: maxTokens, max_tokens: request.params.maxTokens,
stream, stream,
}; };
@@ -228,11 +220,11 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
`[DeepSeek] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`, `[DeepSeek] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`,
); );
} }
// v0.8.0 P0-3: 思考会占用输出预算 —— 钳制后预算过小时显式告警 // v0.8.0 P0-3: 思考会占用输出预算 —— 用户配置的输出预算过小时显式告警
//(思考 token 计入 max_tokens,预算过小会出现"思考耗尽正文为零"截断) //(思考 token 计入 max_tokens,预算过小会出现"思考耗尽正文为零"截断)
if (maxTokens < 8192) { if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) {
log.warn( log.warn(
`[DeepSeek] thinking enabled with small output budget (${maxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`, `[DeepSeek] thinking enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
); );
} }
} }
+9 -16
View File
@@ -24,13 +24,12 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
readonly supportsThinking = true; readonly supportsThinking = true;
// MiMo 模型元信息 // MiMo 模型元信息
// mimo-v2.5-pro: 1M 上下文 / 131072 max_tokensmimo-v2.5: 1M 上下文 / 32768 max_tokens // v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = { private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'mimo-v2.5-pro': { 'mimo-v2.5-pro': {
id: 'mimo-v2.5-pro', id: 'mimo-v2.5-pro',
name: 'MiMo V2.5 Pro', name: 'MiMo V2.5 Pro',
contextWindow: 1_000_000,
maxOutputTokens: 131_072,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: '小米 MiMo 旗舰模型,支持深度思考与工具调用', description: '小米 MiMo 旗舰模型,支持深度思考与工具调用',
@@ -38,8 +37,6 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
'mimo-v2.5': { 'mimo-v2.5': {
id: 'mimo-v2.5', id: 'mimo-v2.5',
name: 'MiMo V2.5', name: 'MiMo V2.5',
contextWindow: 1_000_000,
maxOutputTokens: 32_768,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: '小米 MiMo 标准模型,低延迟推理', description: '小米 MiMo 标准模型,低延迟推理',
@@ -82,17 +79,13 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
const tools = buildOpenAICompatibleTools(request.tools); const tools = buildOpenAICompatibleTools(request.tools);
// MiMo 使用 max_completion_tokens(非 max_tokens // MiMo 使用 max_completion_tokens(非 max_tokens
// #41 修复: thinking 模式下未配置时兜底 32768thinking 占用 token 配额,API 默认值过小会截断输出) // v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)— // 旧的"#41 thinking 兜底 32768"与"按模型上限钳制"逻辑 —— 代码中不存在任何
// 引擎默认 63488 超过 standard 上限时 API 直接 400 // 写死的输出上限;未配置时该字段不下发,由服务端默认值决定。
const mimoMaxOutput =
MimoAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 131_072;
const mimoDefault = request.params.thinkingEnabled !== false ? 32_768 : mimoMaxOutput;
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: this.config.defaultModel, model: this.config.defaultModel,
messages, messages,
max_completion_tokens: Math.min(request.params.maxTokens ?? mimoDefault, mimoMaxOutput), max_completion_tokens: request.params.maxTokens,
stream, stream,
}; };
@@ -145,11 +138,11 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
`[MiMo] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`, `[MiMo] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`,
); );
} }
// v0.8.0 P0-3: 思考占用输出预算 —— 钳制后预算过小时显式告警 // v0.8.0 P0-3: 思考占用输出预算 —— 用户配置的输出预算过小时显式告警
const effectiveMax = body.max_completion_tokens as number; const effectiveMax = body.max_completion_tokens as number | undefined;
if (typeof effectiveMax === 'number' && effectiveMax < 8192) { if (typeof effectiveMax === 'number' && effectiveMax < 8192) {
log.warn( log.warn(
`[MiMo] thinking enabled with small output budget (${effectiveMax} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`, `[MiMo] thinking enabled with small output budget (${effectiveMax} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
); );
} }
} }
+51 -63
View File
@@ -37,18 +37,48 @@ export class OllamaAdapter extends BaseAdapter {
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
private baseURL: string; private baseURL: string;
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) { constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
super(config); super(config);
this.baseURL = config.baseURL || 'http://localhost:11434'; this.baseURL = config.baseURL || 'http://localhost:11434';
// v0.6.4 P4-1: 每个适配器实例(= 每会话独立引擎)启动时做一次 /api/show 探测, // v0.8.1: 上下文窗口不再从 /api/show 探测或写死默认值获取 —— 唯一合法来源是
// 把 num_ctx 实测值填充进 getContextWindow 缓存。fire-and-forget:失败静默 // 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow
// 不阻塞/不影响首个请求;此后压缩预算基于实测窗口而非保守默认 4096。 // 引擎侧经 contextLength=num_ctx 下发)。构造时仅探测能力(thinking/tools/vision
this.refreshContextWindow(); // 属协议正确性门控),不再缓存窗口数值。
this.probeCapabilitiesOnce();
}
/** /api/show 能力探测只发一次(构造函数发起),失败不重试(fail-open) */
private probeAttempted = false;
private probeInProgress = false;
/**
* /api/show capabilities
* /null = /fail-open
* listModels 退false = think
*/
private cachedThinkingSupport: boolean | null = null;
/**
* v0.8.1: 构造时 fire-and-forget thinking
* num_ctx "窗口唯一来源是设置面板"
*/
private probeCapabilitiesOnce(): void {
if (this.probeAttempted) return;
this.probeAttempted = true;
this.probeInProgress = true;
void this.showModel(this.config.defaultModel)
.then((info) => {
if (Array.isArray(info?.capabilities)) {
this.cachedThinkingSupport = info!.capabilities.map(String).includes('thinking');
}
})
.catch(() => {
/* 模型探测失败不阻塞对话(fail-open) */
})
.finally(() => {
this.probeInProgress = false;
});
} }
// ===== POST /api/chat ===== // ===== POST /api/chat =====
@@ -356,14 +386,13 @@ export class OllamaAdapter extends BaseAdapter {
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性) // 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
// v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行), // v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行),
// 供上传入口拒绝不支持图片的本地语言模型 // 供上传入口拒绝不支持图片的本地语言模型
// v0.8.1: 不再填充 contextWindow —— 窗口唯一来源是设置面板「上下文长度」
const enriched = await Promise.all( const enriched = await Promise.all(
data.models.map(async (m) => { data.models.map(async (m) => {
const caps = await this.probeCapabilities(m.name); const caps = await this.probeCapabilities(m.name);
return { return {
id: m.name, id: m.name,
name: m.name, name: m.name,
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: caps ? caps.supportsTools : true, supportsToolCalling: caps ? caps.supportsTools : true,
supportsThinking: caps ? caps.supportsThinking : true, supportsThinking: caps ? caps.supportsThinking : true,
supportsVision: caps ? caps.supportsVision : undefined, supportsVision: caps ? caps.supportsVision : undefined,
@@ -386,59 +415,15 @@ export class OllamaAdapter extends BaseAdapter {
/** /**
* H-2 修复: 获取上下文窗口大小 * H-2 修复: 获取上下文窗口大小
* *
* Ollama options.num_ctx 4096 * v0.8.1 硬性契约: 唯一来源是设置面板llm.contextWindow
* Engine MetonaRequest.params.contextLength * 0 4096 /api/show num_ctx
* Engine * Ollama num_ctx params.contextLength
*/ */
override getContextWindow(): number { override getContextWindow(): number {
return this.cachedContextWindow ?? OllamaAdapter.DEFAULT_CONTEXT_WINDOW; if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
} return this.config.contextWindow;
}
/** return 0;
* v0.6.4 P4-1: /api/show parameters num_ctx
*
* IMetonaProviderAdapter.getContextWindow
* await"机会主义缓存"
* send/sendStream fire-and-forget 4096
* 使
*/
private cachedContextWindow: number | null = null;
private refreshingContextWindow = false;
/** v0.8.0 P0-3: /api/show 探测只发一次(构造函数发起),失败不重试(fail-open) */
private probeAttempted = false;
/**
* v0.8.0 P0-3: /api/show capabilities
* null = /fail-open listModels 退
* false = toNativeRequest think
*/
private cachedThinkingSupport: boolean | null = null;
private refreshContextWindow(): void {
if (this.refreshingContextWindow || this.probeAttempted) return;
this.probeAttempted = true;
this.refreshingContextWindow = true;
void this.showModel(this.config.defaultModel)
.then((info) => {
if (!info?.parameters) return;
const match = /^num_ctx\s+(\d+)\s*$/m.exec(info.parameters);
if (match) {
const value = Number(match[1]);
if (Number.isFinite(value) && value > 0) {
this.cachedContextWindow = value;
log.info(`[Ollama] Context window (num_ctx) detected: ${value}`);
}
}
// v0.8.0 P0-3: 同一次探测顺带缓存思考能力(供 toNativeRequest 同步门控)
if (Array.isArray(info.capabilities)) {
this.cachedThinkingSupport = info.capabilities.map(String).includes('thinking');
}
})
.catch(() => {
/* 模型探测失败不阻塞对话 */
})
.finally(() => {
this.refreshingContextWindow = false;
});
} }
/** /**
@@ -466,7 +451,7 @@ export class OllamaAdapter extends BaseAdapter {
async showModel( async showModel(
model: string, model: string,
): Promise<{ parameters: string; template: string; capabilities: string[] } | null> { ): Promise<{ parameters: string; template: string; capabilities?: string[] } | null> {
try { try {
const response = await fetch(`${this.baseURL}/api/show`, { const response = await fetch(`${this.baseURL}/api/show`, {
method: 'POST', method: 'POST',
@@ -483,7 +468,10 @@ export class OllamaAdapter extends BaseAdapter {
return { return {
parameters: data.parameters ?? '', parameters: data.parameters ?? '',
template: data.template ?? '', template: data.template ?? '',
capabilities: data.capabilities ?? [], // v0.8.1: 保留 undefined 语义 —— 响应未携带 capabilities 字段 = 未知
//(fail-open),显式数组(含空数组 = 服务端权威"无任何能力")才参与门控。
// 旧实现 `?? []` 把"字段缺失"与"权威空"混同,探测竞态下会误判不支持思考。
capabilities: data.capabilities,
}; };
} catch { } catch {
return null; return null;
@@ -707,7 +695,7 @@ export class OllamaAdapter extends BaseAdapter {
// think 会导致每次请求 400 "does not support thinking",而非静默忽略), // think 会导致每次请求 400 "does not support thinking",而非静默忽略),
// 故此处门控属协议正确性而非用户意图覆盖;探测为服务端实时真值(非静态 // 故此处门控属协议正确性而非用户意图覆盖;探测为服务端实时真值(非静态
// 元信息)。未探测/探测失败(nullfail-open 放行,与 listModels 能力回退 // 元信息)。未探测/探测失败(nullfail-open 放行,与 listModels 能力回退
// 策略一致。探测在适配器实例创建时 fire-and-forget 发起(refreshContextWindow)。 // 策略一致。探测在适配器实例创建时 fire-and-forget 发起(probeCapabilitiesOnce)。
if (request.params.thinkingEnabled) { if (request.params.thinkingEnabled) {
const modelThinkingSupported = this.cachedThinkingSupport !== false; const modelThinkingSupported = this.cachedThinkingSupport !== false;
if (modelThinkingSupported) { if (modelThinkingSupported) {
+13 -29
View File
@@ -24,39 +24,33 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
// v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = { private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'gpt-4o': { 'gpt-4o': {
id: 'gpt-4o', id: 'gpt-4o',
name: 'GPT-4o', name: 'GPT-4o',
contextWindow: 128_000,
maxOutputTokens: 16_384,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: false, supportsThinking: false,
description: 'OpenAI 旗舰多模态模型128K 上下文', description: 'OpenAI 旗舰多模态模型',
}, },
'gpt-4o-mini': { 'gpt-4o-mini': {
id: 'gpt-4o-mini', id: 'gpt-4o-mini',
name: 'GPT-4o mini', name: 'GPT-4o mini',
contextWindow: 128_000,
maxOutputTokens: 16_384,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: false, supportsThinking: false,
description: 'OpenAI 高性价比模型128K 上下文', description: 'OpenAI 高性价比模型',
}, },
'gpt-4.1': { 'gpt-4.1': {
id: 'gpt-4.1', id: 'gpt-4.1',
name: 'GPT-4.1', name: 'GPT-4.1',
contextWindow: 1_000_000,
maxOutputTokens: 32_768,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: false, supportsThinking: false,
description: 'OpenAI 长上下文模型1M 上下文', description: 'OpenAI 长上下文模型',
}, },
'o3-mini': { 'o3-mini': {
id: 'o3-mini', id: 'o3-mini',
name: 'o3-mini', name: 'o3-mini',
contextWindow: 200_000,
maxOutputTokens: 100_000,
supportsToolCalling: true, supportsToolCalling: true,
supportsThinking: true, supportsThinking: true,
description: 'OpenAI 推理模型,支持 reasoning_effort', description: 'OpenAI 推理模型,支持 reasoning_effort',
@@ -81,11 +75,6 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
return 'OpenAI'; return 'OpenAI';
} }
// v0.6.4: OpenAI 家族兜底窗口为 128K(其余 OpenAI 兼容 Provider 为 1M
protected override defaultContextWindowFallback(): number {
return 128_000;
}
// ===== GET /v1/models ===== // ===== GET /v1/models =====
override async listModels(): Promise<MetonaModelInfo[]> { override async listModels(): Promise<MetonaModelInfo[]> {
@@ -137,18 +126,13 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
}; };
// Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens // Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens
// v0.5.3: 按模型上限钳制(gpt-4o 16384 / gpt-4.1 32768 / o3-mini 100000)— // v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了
// 引擎默认 63488 超过 gpt-4o/gpt-4.1 上限时 API 直接 400 // 旧的按模型元信息钳制逻辑与未配置时的 32_768 兜底 —— 未配置时不下发该字段。
const oaMaxOutput = OpenAIAdapter.MODEL_INFO[model]?.maxOutputTokens ?? 128_000; if (request.params.maxTokens != null) {
const oaMaxTokens = Math.min(
request.params.maxTokens ?? (isReasoningModel ? 32_768 : oaMaxOutput),
oaMaxOutput,
);
if (oaMaxTokens) {
if (isReasoningModel) { if (isReasoningModel) {
body.max_completion_tokens = oaMaxTokens; body.max_completion_tokens = request.params.maxTokens;
} else { } else {
body.max_tokens = oaMaxTokens; body.max_tokens = request.params.maxTokens;
} }
} }
@@ -169,10 +153,10 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
max: 'high', max: 'high',
}; };
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
// v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 钳制后预算过小时告警 // v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 用户配置的预算过小时告警
if (oaMaxTokens < 8192) { if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) {
log.warn( log.warn(
`[OpenAI] reasoning enabled with small output budget (${oaMaxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`, `[OpenAI] reasoning enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
); );
} }
} else if (!isReasoningModel) { } else if (!isReasoningModel) {
@@ -41,16 +41,9 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
/** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120sAgnes 历史 300s,保留其值。 */ /** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120sAgnes 历史 300s,保留其值。 */
protected abstract sendTimeoutMs(): number; protected abstract sendTimeoutMs(): number;
/** 模型元信息表(子类持有;用于 getContextWindow 回退链与钳制 */ /** 模型元信息表(子类持有;仅承载展示与能力声明,不含任何窗口/输出上限数值 */
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>; protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
/** getContextWindow 的最终兜底窗口(未配置且模型未知时使用) */
// v0.7.4 P4-5: 1M → 128K(与 base-adapter 兜底对齐)——未知模型按最保守主流窗口预算,
// 防止压缩阈值按 1M 计算导致实际小窗口模型先 413 再压缩
protected defaultContextWindowFallback(): number {
return 128_000;
}
// ===== 认证头 ===== // ===== 认证头 =====
protected buildHeaders(): Record<string, string> { protected buildHeaders(): Record<string, string> {
@@ -161,15 +154,16 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
} }
/** /**
* 退v0.6.3 * v0.8.1
* config.contextWindow Provider * llm.contextWindow AdapterConfig.contextWindow
* config 退
* 0"用户未声明窗口"
*/ */
override getContextWindow(): number { override getContextWindow(): number {
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow; return this.config.contextWindow;
} }
const modelInfo = this.modelInfoTable()[this.config.defaultModel]; return 0;
return modelInfo?.contextWindow ?? this.defaultContextWindowFallback();
} }
} }
+33 -14
View File
@@ -84,10 +84,11 @@ const DEFAULT_CONFIG: AgentLoopConfig = {
totalTimeoutMs: 600_000, totalTimeoutMs: 600_000,
enableReflection: false, enableReflection: false,
compressionThreshold: 0.8, compressionThreshold: 0.8,
contextWindow: 128_000, // v0.8.1: contextWindow / maxTokens 不再携带任何写死默认值 —— 唯一合法来源是
// 设置面板 LLM 配置(llm.contextWindow / llm.maxTokens),由 main.ts baseConfig
// 与 applyEngineConfigKey 注入。未配置时压缩判定跳过、输出上限参数不下发。
retryCount: 3, retryCount: 3,
temperature: 0.0, temperature: 0.0,
maxTokens: 63488,
thinkingEnabled: true, thinkingEnabled: true,
thinkingEffort: 'high', thinkingEffort: 'high',
toolExecutionTimeoutMs: 120_000, toolExecutionTimeoutMs: 120_000,
@@ -185,10 +186,9 @@ export class AgentLoopEngine extends EventEmitter {
/** /**
* #3 修复: adapter contextWindow Engine * #3 修复: adapter contextWindow Engine
* *
* Engine DEFAULT_CONFIG.contextWindow 128_000 Provider * v0.8.1 契约: 窗口唯一来源是设置面板 config adapter
* DeepSeek 1M / Agnes 1M / MiMo 1M / OpenAI 128K~1M / * config adapter >0
* Anthropic 200K / Ollama 4096 v0.7.4 64K * 0
* compressionThreshold * contextWindow
*/ */
private syncContextWindow(): void { private syncContextWindow(): void {
const adapterCtx = this.adapter.getContextWindow?.(); const adapterCtx = this.adapter.getContextWindow?.();
@@ -628,6 +628,10 @@ export class AgentLoopEngine extends EventEmitter {
promptTokens: event.usage.inputTokens ?? 0, promptTokens: event.usage.inputTokens ?? 0,
completionTokens: event.usage.outputTokens ?? 0, completionTokens: event.usage.outputTokens ?? 0,
totalTokens: event.usage.totalTokens ?? 0, totalTokens: event.usage.totalTokens ?? 0,
// v0.8.1 P1-3: 透传 Prompt Cache 命中字段(前端命中率展示的数据源;
// 此前引擎在此处丢弃 adapter 已采集的 cache 字段,观测链路断头)
cacheHitTokens: event.usage.cacheHitTokens,
cacheMissTokens: event.usage.cacheMissTokens,
}; };
// v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断 // v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断
// 估算值可能偏低(尤其中文场景),导致不压缩但 API 413 // 估算值可能偏低(尤其中文场景),导致不压缩但 API 413
@@ -788,10 +792,10 @@ export class AgentLoopEngine extends EventEmitter {
} }
// === 上下文压缩(基于 token 使用率触发) === // === 上下文压缩(基于 token 使用率触发) ===
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow // 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发 // v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死
const effectiveContextWindow = // 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。
this.config.contextLength ?? this.config.contextWindow ?? 128_000; const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0;
const estimatedTokens = this.estimateMessagesTokens(request.messages); const estimatedTokens = this.estimateMessagesTokens(request.messages);
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
@@ -801,7 +805,11 @@ export class AgentLoopEngine extends EventEmitter {
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4" // v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。 // 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。 // 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
if (actualTokens > compressionThreshold && request.messages.length >= 4) { if (
effectiveContextWindow > 0 &&
actualTokens > compressionThreshold &&
request.messages.length >= 4
) {
await this.transitionTo(AgentLoopState.COMPRESSING); await this.transitionTo(AgentLoopState.COMPRESSING);
const compressed = await this.compressMessages(request.messages); const compressed = await this.compressMessages(request.messages);
if (compressed) { if (compressed) {
@@ -1462,9 +1470,10 @@ export class AgentLoopEngine extends EventEmitter {
*/ */
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> { private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
// 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN // v0.8.1: 窗口唯一来源是设置面板「上下文长度」,无写死兜底 —— 未配置时无法
const effectiveContextWindow = // 计算保留预算,直接放弃压缩(调用方保持原数组,行为安全)
this.config.contextLength ?? this.config.contextWindow ?? 128_000; const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0;
if (!(effectiveContextWindow > 0)) return null;
const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50% const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文 const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
@@ -1623,6 +1632,16 @@ export class AgentLoopEngine extends EventEmitter {
this.totalTokens.promptTokens += usage.promptTokens; this.totalTokens.promptTokens += usage.promptTokens;
this.totalTokens.completionTokens += usage.completionTokens; this.totalTokens.completionTokens += usage.completionTokens;
this.totalTokens.totalTokens += usage.totalTokens; this.totalTokens.totalTokens += usage.totalTokens;
// v0.8.1 P1-3: 累计缓存命中/未命中(未上报的 Provider 保持 undefined
// 不把 undefined 污染为数字 0 —— 前端以 undefined 判定"该 Provider 不上报"
if (usage.cacheHitTokens != null) {
this.totalTokens.cacheHitTokens =
(this.totalTokens.cacheHitTokens ?? 0) + usage.cacheHitTokens;
}
if (usage.cacheMissTokens != null) {
this.totalTokens.cacheMissTokens =
(this.totalTokens.cacheMissTokens ?? 0) + usage.cacheMissTokens;
}
} }
private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput { private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput {
+21 -4
View File
@@ -61,6 +61,13 @@ export interface TokenUsage {
promptTokens: number; promptTokens: number;
completionTokens: number; completionTokens: number;
totalTokens: number; totalTokens: number;
/**
* v0.8.1 P1-3: Prompt Cache / token Provider
* TokenUsage
* Provider undefinedUI
*/
cacheHitTokens?: number;
cacheMissTokens?: number;
} }
// ===== Agent Loop 配置 ===== // ===== Agent Loop 配置 =====
@@ -70,16 +77,26 @@ export interface AgentLoopConfig {
totalTimeoutMs: number; totalTimeoutMs: number;
enableReflection: boolean; enableReflection: boolean;
compressionThreshold: number; compressionThreshold: number;
contextWindow: number; /**
* token LLM
* llm.contextWindow main.ts baseConfig / applyEngineConfigKey
* undefined/0
*/
contextWindow?: number;
retryCount: number; retryCount: number;
temperature: number; temperature: number;
/** 最大生成 token 数(默认 63488 */ /**
maxTokens: number; * token LLM
* llm.maxTokens main.ts baseConfig / applyEngineConfigKey
*
* Provider
*/
maxTokens?: number;
/** 是否启用思考模式(默认 true) */ /** 是否启用思考模式(默认 true) */
thinkingEnabled: boolean; thinkingEnabled: boolean;
/** 思考强度(默认 'high' */ /** 思考强度(默认 'high' */
thinkingEffort: 'low' | 'medium' | 'high' | 'max'; thinkingEffort: 'low' | 'medium' | 'high' | 'max';
/** Ollama 上下文窗口大小(num_ctx),其他 Provider 忽略 */ /** Ollama num_ctx(与「上下文长度」同源:llm.contextWindow,仅 Ollama Provider 下发) */
contextLength?: number; contextLength?: number;
/** 工具执行兜底超时(ms,默认 120000),实际取 max(此值, tool.timeoutMs) */ /** 工具执行兜底超时(ms,默认 120000),实际取 max(此值, tool.timeoutMs) */
toolExecutionTimeoutMs?: number; toolExecutionTimeoutMs?: number;
@@ -88,6 +88,7 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7', () => {
} }
it('确认请求广播到所有存活窗口(而非仅 mainWindow', async () => { it('确认请求广播到所有存活窗口(而非仅 mainWindow', async () => {
vi.useFakeTimers();
const a = makeTrackedWindow(); const a = makeTrackedWindow();
const b = makeTrackedWindow(); const b = makeTrackedWindow();
const destroyed = makeTrackedWindow(true); const destroyed = makeTrackedWindow(true);
@@ -96,6 +97,8 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7', () => {
const hook = new ConfirmationHook(null, null); const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]); hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess'); const p = hook.beforeExecute(makeToolCall(), 'sess');
// v0.8.1 P2-2: 聚合窗口(800ms)结束才广播 —— 推进 fake timers
await vi.advanceTimersByTimeAsync(1000);
// 所有存活窗口均收到确认请求(携带 expiresAt 倒计时契约) // 所有存活窗口均收到确认请求(携带 expiresAt 倒计时契约)
expect(a.send).toHaveBeenCalledWith( expect(a.send).toHaveBeenCalledWith(
@@ -111,20 +114,25 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7', () => {
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false); hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p).blocked).toBe(false); expect((await p).blocked).toBe(false);
vi.useRealTimers();
}); });
it('getAllWindows 为空时回退到注入的 mainWindow(向后兼容)', async () => { it('getAllWindows 为空时回退到注入的 mainWindow(向后兼容)', async () => {
vi.useFakeTimers();
const mainWin = makeMockWindow(); const mainWin = makeMockWindow();
getAllWindowsMock.mockReturnValue([]); getAllWindowsMock.mockReturnValue([]);
const hook = new ConfirmationHook(mainWin, null); const hook = new ConfirmationHook(mainWin, null);
hook.setToolDefs([HIGH_RISK_DEF]); hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess'); const p = hook.beforeExecute(makeToolCall(), 'sess');
// v0.8.1 P2-2: 推进聚合窗口后断言广播
await vi.advanceTimersByTimeAsync(1000);
expect((mainWin.webContents as unknown as { send: Mock }).send).toHaveBeenCalledWith( expect((mainWin.webContents as unknown as { send: Mock }).send).toHaveBeenCalledWith(
'tool:confirmationRequest', 'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command' }), expect.objectContaining({ toolName: 'run_command' }),
); );
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false); hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p).blocked).toBe(false); expect((await p).blocked).toBe(false);
vi.useRealTimers();
}); });
it('全部窗口不可达时 fail-closed 阻断(no main window available', async () => { it('全部窗口不可达时 fail-closed 阻断(no main window available', async () => {
@@ -524,3 +532,92 @@ describe('ConfirmationHook — 跨会话隔离(v0.5.0', () => {
expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false); expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
}); });
}); });
// ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 =====
describe('ConfirmationHook — 同类工具批量确认聚合(v0.8.1 P2-2', () => {
/** 单次 promptbeforeExecute 阻塞等待确认,不消费 promise) */
function startPrompt(hook: ConfirmationHook, id: string): Promise<unknown> {
return hook.beforeExecute(
{ id, name: 'run_command', args: {}, iteration: 1, timestamp: Date.now() },
'sess',
);
}
/** 本 describe 专用的 send 追踪窗口(makeTrackedWindow 定义在上一 describe 作用域) */
function makeWindow(): { win: BrowserWindow; send: Mock } {
const send = vi.fn();
const win = {
isDestroyed: () => false,
webContents: { send },
} as unknown as BrowserWindow;
return { win, send };
}
function makeRunCommandDef(): MetonaToolDef {
return {
...HIGH_RISK_DEF,
name: 'run_command',
requiresPermission: true,
};
}
it('3 个同类并行请求 → 聚合为单条 batch 事件(无逐条事件)', async () => {
vi.useFakeTimers();
const w = makeWindow();
getAllWindowsMock.mockReturnValue([w.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([makeRunCommandDef()]);
const prompts = [
startPrompt(hook, 'tc_1'),
startPrompt(hook, 'tc_2'),
startPrompt(hook, 'tc_3'),
];
// 达到阈值立即 flush
await vi.advanceTimersByTimeAsync(0);
const batchCalls = (w.send as Mock).mock.calls.filter(
(c) => c[0] === 'tool:confirmationRequestBatch',
);
expect(batchCalls).toHaveLength(1);
expect(
(batchCalls[0][1] as unknown[]).map((r) => (r as { toolCallId: string }).toolCallId),
).toEqual(['tc_1', 'tc_2', 'tc_3']);
// 不应再发送逐条事件
const individual = (w.send as Mock).mock.calls.filter(
(c) => c[0] === 'tool:confirmationRequest',
);
expect(individual).toHaveLength(0);
for (const id of ['tc_1', 'tc_2', 'tc_3']) {
hook.resolveConfirmation(id, true, false, false);
}
await Promise.all(prompts);
vi.useRealTimers();
});
it('2 个同类请求(低于阈值)→ 窗口结束逐条广播(原行为)', async () => {
vi.useFakeTimers();
const w = makeWindow();
getAllWindowsMock.mockReturnValue([w.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([makeRunCommandDef()]);
const prompts = [startPrompt(hook, 'tc_a'), startPrompt(hook, 'tc_b')];
await vi.advanceTimersByTimeAsync(1000);
expect(
(w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequestBatch'),
).toHaveLength(0);
expect(
(w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequest'),
).toHaveLength(2);
for (const id of ['tc_a', 'tc_b']) {
hook.resolveConfirmation(id, true, false, false);
}
await Promise.all(prompts);
vi.useRealTimers();
});
});
+68 -4
View File
@@ -467,6 +467,39 @@ export class ConfirmationHook implements PreToolHook {
*/ */
private lastTimeoutToastAt = 0; private lastTimeoutToastAt = 0;
// ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 =====
/**
* (sessionId, toolName) AGGREGATION_WINDOW_MS
* >= AGGREGATION_THRESHOLD 广 `tool:confirmationRequestBatch`
* / pending广
* 3+ N
*/
private static readonly AGGREGATION_WINDOW_MS = 800;
private static readonly AGGREGATION_THRESHOLD = 3;
private aggregationBuffers = new Map<
string,
{ requests: ConfirmationRequest[]; timer: NodeJS.Timeout | null }
>();
private flushAggregationBuffer(key: string): void {
const buf = this.aggregationBuffers.get(key);
if (!buf) return;
this.aggregationBuffers.delete(key);
if (buf.timer) {
clearTimeout(buf.timer);
buf.timer = null;
}
if (buf.requests.length >= ConfirmationHook.AGGREGATION_THRESHOLD) {
// 批量事件:携带完整请求列表(expiresAt 已含)
this.broadcastToAllWindows('tool:confirmationRequestBatch', buf.requests);
} else {
// 逐条广播(原行为)
for (const req of buf.requests) {
this.broadcastToAllWindows('tool:confirmationRequest', req);
}
}
}
private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise<boolean> { private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise<boolean> {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const expiresAt = Date.now() + this.confirmationTimeoutMs; const expiresAt = Date.now() + this.confirmationTimeoutMs;
@@ -518,10 +551,24 @@ export class ConfirmationHook implements PreToolHook {
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时) // 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
// v0.7.2 P2-7: 广播到所有窗口 —— 多窗口场景下任意窗口发起的会话 // v0.7.2 P2-7: 广播到所有窗口 —— 多窗口场景下任意窗口发起的会话
// 触发的确认请求都可达(ConfirmationDialog 按会话过滤展示) // 触发的确认请求都可达(ConfirmationDialog 按会话过滤展示)
this.broadcastToAllWindows('tool:confirmationRequest', { // v0.8.1 P2-2: 聚合窗口 —— 同会话同类工具的并行请求进入缓冲;
...request, // 窗口结束达到阈值时合并为单条批量事件,否则逐条广播(原行为)
expiresAt, const aggKey = `${sessionId}:${request.toolName}`;
}); let buf = this.aggregationBuffers.get(aggKey);
if (!buf) {
buf = { requests: [], timer: null };
this.aggregationBuffers.set(aggKey, buf);
buf.timer = setTimeout(
() => this.flushAggregationBuffer(aggKey),
ConfirmationHook.AGGREGATION_WINDOW_MS,
);
}
buf.requests.push({ ...request, expiresAt });
// 窗口内积压已达阈值 → 立即 flush(不等满窗口)
if (buf.requests.length >= ConfirmationHook.AGGREGATION_THRESHOLD) {
this.flushAggregationBuffer(aggKey);
}
}); });
} }
@@ -540,6 +587,9 @@ export class ConfirmationHook implements PreToolHook {
pending.resolve(false); pending.resolve(false);
} }
this.pendingConfirmations.clear(); this.pendingConfirmations.clear();
// v0.8.1 P2-2: 清空聚合缓冲(resolve(false) 已由逐条 timer 覆盖……缓冲内的
// 请求本身尚未注册 pending,需丢弃防止稍后广播已失效请求)
this.aggregationBuffers.clear();
return; return;
} }
for (const [id, pending] of this.pendingConfirmations) { for (const [id, pending] of this.pendingConfirmations) {
@@ -548,6 +598,20 @@ export class ConfirmationHook implements PreToolHook {
pending.resolve(false); pending.resolve(false);
this.pendingConfirmations.delete(id); this.pendingConfirmations.delete(id);
} }
// v0.8.1 P2-2: 丢弃该会话尚未广播的聚合缓冲
for (const [key, buf] of this.aggregationBuffers) {
if (!key.startsWith(`${sessionId}:`)) continue;
if (buf.timer) clearTimeout(buf.timer);
for (const req of buf.requests) {
const pending = this.pendingConfirmations.get(req.toolCallId);
if (pending) {
clearTimeout(pending.timer);
pending.resolve(false);
this.pendingConfirmations.delete(req.toolCallId);
}
}
this.aggregationBuffers.delete(key);
}
} }
/** /**
+21 -3
View File
@@ -29,7 +29,11 @@ export interface PostToolHook {
export class AuditLogHook implements PostToolHook { export class AuditLogHook implements PostToolHook {
constructor(private auditService: AuditService) {} constructor(private auditService: AuditService) {}
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> { async afterExecute(
toolCall: MetonaToolCall,
result: MetonaToolResult,
sessionId: string,
): Promise<void> {
// #17 修复: AuditHook 应为 "fire and forget"hook 失败不应影响工具执行链 // #17 修复: AuditHook 应为 "fire and forget"hook 失败不应影响工具执行链
// 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御, // 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御,
// 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry // 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry
@@ -59,11 +63,24 @@ export class MemoryTriggerHook implements PostToolHook {
/** 单次工具结果存储上限(字符),防止过大内容淹没记忆系统 */ /** 单次工具结果存储上限(字符),防止过大内容淹没记忆系统 */
private readonly MAX_MEMORY_CONTENT = 500; private readonly MAX_MEMORY_CONTENT = 500;
/**
* v0.8.1 P0-2: 工具结果类情节记忆的 TTL90
* episodic_memories.expires_at cleanupExpired
* 90
* consolidator semantic
*/
private readonly EPISODIC_TTL_MS = 90 * 24 * 60 * 60 * 1000;
constructor(private memoryManager: MemoryManager) {} constructor(private memoryManager: MemoryManager) {}
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> { async afterExecute(
toolCall: MetonaToolCall,
result: MetonaToolResult,
sessionId: string,
): Promise<void> {
if (this.memorableTools.includes(toolCall.name) && result.success) { if (this.memorableTools.includes(toolCall.name) && result.success) {
const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); const content =
typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
try { try {
this.memoryManager.store({ this.memoryManager.store({
type: 'episodic', type: 'episodic',
@@ -71,6 +88,7 @@ export class MemoryTriggerHook implements PostToolHook {
source: 'tool_result', source: 'tool_result',
sessionId, sessionId,
importance: 0.6, importance: 0.6,
expiresAt: Date.now() + this.EPISODIC_TTL_MS,
}); });
} catch (error) { } catch (error) {
// 记忆存储失败不应影响工具执行结果 // 记忆存储失败不应影响工具执行结果
@@ -0,0 +1,182 @@
/**
* MemoryMaintainer v0.8.1 P1-2 MEMORY.md
*
*
* 1. parseMemoryEntries / buildMemoryEntriesDigest
* Consolidator
* 2. apply 线 LLM entry
* 3. delete/update MEMORY.md + semantic_memories
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { MemoryMaintainer, parseMemoryEntries, buildMemoryEntriesDigest } from '../maintainer';
import type { MemoryMaintenanceAction } from '../maintainer';
const SAMPLE = `# MEMORY.md — AI 持久记忆
> 最后更新: 2026-09-07
##
- []
- [] 使 pnpm
##
- [Metona] 技术栈: Electron + React
##
- [done]
`;
describe('parseMemoryEntries / buildMemoryEntriesDigestv0.8.1 P1-2', () => {
it('解析分区与条目(跳过元数据头)', () => {
const sections = parseMemoryEntries(SAMPLE);
expect(sections).toHaveLength(3);
expect(sections[0].section).toBe('用户偏好');
expect(sections[0].entries).toEqual([
'[沟通风格] 用户喜欢简洁的回答',
'[工具偏好] 项目使用 pnpm',
]);
});
it('digest 为纯条目行形态且条目全文可见(无 3000 字符截断盲区)', () => {
const digest = buildMemoryEntriesDigest(parseMemoryEntries(SAMPLE));
expect(digest).toContain('## 用户偏好');
expect(digest).toContain('- [沟通风格] 用户喜欢简洁的回答');
// 旧全文形态的头部元数据不进入 digest
expect(digest).not.toContain('最后更新');
// 超过旧 3000 字符预算的记忆尾部条目同样完整进入 digest
const manyEntries = Array.from({ length: 200 }, (_, i) => `- 条目 ${i} ${'x'.repeat(20)}`);
const bigMemory = `## 项目上下文\n${manyEntries.join('\n')}`;
const bigDigest = buildMemoryEntriesDigest(parseMemoryEntries(bigMemory));
expect(bigDigest).toContain('条目 199');
});
});
describe('MemoryMaintainer.apply — 精确匹配与双轨同步', () => {
function makeMaintainer(memory: string): {
maintainer: MemoryMaintainer;
getMemory: () => string;
db: { prepare(sql: string): { run(...args: unknown[]): { changes: number } } };
} {
let current = memory;
const semanticRows: Array<{ content: string }> = [{ content: '[沟通风格] 用户喜欢简洁的回答' }];
const db = {
prepare: (sql: string) => ({
run: (...args: unknown[]) => {
if (sql.startsWith('DELETE')) {
const before = semanticRows.length;
const target = semanticRows.find((r) => r.content === args[0]);
if (target) semanticRows.splice(semanticRows.indexOf(target), 1);
return { changes: before - semanticRows.length };
}
if (sql.startsWith('UPDATE')) {
const row = semanticRows.find((r) => r.content === args[2]);
if (row) {
row.content = args[0] as string;
return { changes: 1 };
}
return { changes: 0 };
}
return { changes: 0 };
},
}),
};
const maintainer = new MemoryMaintainer(
() => {
throw new Error('not used in apply');
},
{
getFiles: () => ({ soul: '', memory: current }),
rewriteMemory: (content: string) => {
current = content;
},
} as never,
() => db as never,
);
return { maintainer, getMemory: () => current, db };
}
it('delete 精确命中 → 行被移除;未命中条目被跳过(防幻觉改写)', () => {
const { maintainer, getMemory } = makeMaintainer(SAMPLE);
const actions: MemoryMaintenanceAction[] = [
{ action: 'delete', section: '待办事项', entry: '[done] 旧待办已完成' },
// 幻觉条目:文件中不存在 → 必须跳过
{ action: 'delete', section: '用户偏好', entry: '不存在的条目' },
];
const result = maintainer.apply(actions);
expect(result.applied).toBe(1);
expect(result.skipped).toBe(1);
const after = getMemory();
expect(after).not.toContain('[done] 旧待办已完成');
expect(after).toContain('用户喜欢简洁的回答');
expect(after).toContain('## 待办事项'); // 分区头保留(空分区仍保留结构)
});
it('update(合并)→ 替换条目并同步 semantic_memories', () => {
const { maintainer, getMemory, db } = makeMaintainer(SAMPLE);
const actions: MemoryMaintenanceAction[] = [
{
action: 'update',
section: '用户偏好',
entry: '[沟通风格] 用户喜欢简洁的回答',
newEntry: '[沟通风格] 用户喜欢简洁的回答,不需要过度解释',
},
];
const result = maintainer.apply(actions);
expect(result.applied).toBe(1);
expect(getMemory()).toContain('不需要过度解释');
const row = db
.prepare('SELECT * FROM semantic_memories WHERE content = ?')
.run('[沟通风格] 用户喜欢简洁的回答,不需要过度解释');
expect(row).toBeDefined();
});
it('动作数上限 30(防 LLM 过度建议)', () => {
const { maintainer } = makeMaintainer(SAMPLE);
const actions: MemoryMaintenanceAction[] = Array.from({ length: 40 }, () => ({
action: 'delete' as const,
section: '待办事项',
entry: '不存在的条目',
}));
const result = maintainer.apply(actions);
expect(result.skipped).toBe(40);
expect(result.applied).toBe(0);
});
});
describe('MemoryMaintainer.analyze — sectionEntryCountsv0.8.1 review O2', () => {
function makeAnalyzer(
memory: string,
llmReply: string,
): {
maintainer: MemoryMaintainer;
} {
const adapter = {
send: vi.fn().mockResolvedValue({ content: llmReply }),
} as never;
return {
maintainer: new MemoryMaintainer(
() => adapter,
{
getFiles: () => ({ soul: '', memory }),
rewriteMemory: () => {},
} as never,
(() => ({})) as never,
),
};
}
it('proposal 携带各分区条目数(空分区提示的数据源)', async () => {
const { maintainer } = makeAnalyzer(
SAMPLE,
'[{"action":"delete","section":"待办事项","entry":"[done] 旧待办已完成","reason":"已完成"}]',
);
const proposal = await maintainer.analyze();
expect(proposal.sectionEntryCounts['待办事项']).toBe(1);
expect(proposal.sectionEntryCounts['用户偏好']).toBe(2);
});
});
@@ -43,7 +43,8 @@ function createMemorySchema(db: any): void {
importance REAL DEFAULT 0.5, importance REAL DEFAULT 0.5,
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
); );
CREATE TABLE semantic_memories ( CREATE TABLE semantic_memories (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -55,7 +56,8 @@ function createMemorySchema(db: any): void {
created_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0,
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
); );
CREATE TABLE working_memories ( CREATE TABLE working_memories (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -349,10 +351,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
).toThrow(/Unknown memory type/); ).toThrow(/Unknown memory type/);
}); });
it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', () => { it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', async () => {
// v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。 // v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。
mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 }); mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 });
mgr.search('hello'); // 建立 IDF 缓存 await mgr.search('hello'); // 建立 IDF 缓存
// 再 store 一条 → 缓存应失效,新内容可被检索 // 再 store 一条 → 缓存应失效,新内容可被检索
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
@@ -360,10 +362,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
source: 'user_input', source: 'user_input',
importance: 0.7, importance: 0.7,
}); });
const results = mgr.search('another'); const results = await mgr.search('another');
expect(results.some((r) => r.content === 'another content')).toBe(true); expect(results.some((r) => r.content === 'another content')).toBe(true);
// 双向验证:缓存重建后旧内容仍可检索(不因重建丢失) // 双向验证:缓存重建后旧内容仍可检索(不因重建丢失)
const oldResults = mgr.search('hello'); const oldResults = await mgr.search('hello');
expect(oldResults.some((r) => r.content === 'hello world')).toBe(true); expect(oldResults.some((r) => r.content === 'hello world')).toBe(true);
}); });
@@ -401,7 +403,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
} }
}); });
it('相同关键词:得分高者排前(内容重复度越高得分越高)', () => { it('相同关键词:得分高者排前(内容重复度越高得分越高)', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: 'memory hello world test', content: 'memory hello world test',
@@ -416,7 +418,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.7, importance: 0.7,
}); });
const results = mgr.search('hello world'); const results = await mgr.search('hello world');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
expect(results.every((r) => r.score > 0)).toBe(true); expect(results.every((r) => r.score > 0)).toBe(true);
// 两条命中的按分数降序 // 两条命中的按分数降序
@@ -424,7 +426,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
expect([...scores].sort((a, b) => b - a)).toEqual(scores); expect([...scores].sort((a, b) => b - a)).toEqual(scores);
}); });
it('时间衰减:同内容越新得分越高(30 天半衰期)', () => { it('时间衰减:同内容越新得分越高(30 天半衰期)', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: '关键 bug 修复方案', content: '关键 bug 修复方案',
@@ -455,7 +457,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
newId, newId,
); );
const results = mgr.search('关键 bug'); const results = await mgr.search('关键 bug');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
const newResult = results.find((r) => r.id === newId); const newResult = results.find((r) => r.id === newId);
const oldResult = results.find((r) => r.id === oldId); const oldResult = results.find((r) => r.id === oldId);
@@ -464,7 +466,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
expect(newResult!.score).toBeGreaterThan(oldResult!.score); expect(newResult!.score).toBeGreaterThan(oldResult!.score);
}); });
it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5', () => { it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5', async () => {
// 新鲜记录(0 天) // 新鲜记录(0 天)
const freshId = mgr.store({ const freshId = mgr.store({
type: 'episodic', type: 'episodic',
@@ -484,7 +486,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
agedId, agedId,
); );
const results = mgr.search('衰减数学验证内容'); const results = await mgr.search('衰减数学验证内容');
const fresh = results.find((r) => r.id === freshId)!; const fresh = results.find((r) => r.id === freshId)!;
const aged = results.find((r) => r.id === agedId)!; const aged = results.find((r) => r.id === agedId)!;
// score = cosine * decay * importanceFactor;两记录余弦与 importance 相同 // score = cosine * decay * importanceFactor;两记录余弦与 importance 相同
@@ -492,7 +494,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
expect(aged.score / fresh.score).toBeCloseTo(0.5, 1); expect(aged.score / fresh.score).toBeCloseTo(0.5, 1);
}); });
it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', () => { it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', async () => {
const lowId = mgr.store({ const lowId = mgr.store({
type: 'episodic', type: 'episodic',
content: '重要性权重验证', content: '重要性权重验证',
@@ -506,24 +508,24 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 1, importance: 1,
}); });
// 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5 // 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5
const results = mgr.search('重要性权重验证'); const results = await mgr.search('重要性权重验证');
const low = results.find((r) => r.id === lowId)!; const low = results.find((r) => r.id === lowId)!;
const high = results.find((r) => r.id === highId)!; const high = results.find((r) => r.id === highId)!;
expect(high.score / low.score).toBeCloseTo(2.0, 1); expect(high.score / low.score).toBeCloseTo(2.0, 1);
}); });
it('semantic 记忆可被检索(key+value 参与分词)', () => { it('semantic 记忆可被检索(key+value 参与分词)', async () => {
mgr.store({ mgr.store({
type: 'semantic', type: 'semantic',
content: '用户偏好深色主题', content: '用户偏好深色主题',
source: 'imported', source: 'imported',
importance: 0.5, importance: 0.5,
}); });
const results = mgr.search('偏好'); const results = await mgr.search('偏好');
expect(results.some((r) => r.type === 'semantic')).toBe(true); expect(results.some((r) => r.type === 'semantic')).toBe(true);
}); });
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5', () => { it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5', async () => {
mgr.store({ mgr.store({
type: 'working', type: 'working',
content: '当前任务文件', content: '当前任务文件',
@@ -531,11 +533,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.5, importance: 0.5,
source: 'agent_thought', source: 'agent_thought',
}); });
const results = mgr.search('当前任务'); const results = await mgr.search('当前任务');
expect(results.some((r) => r.type === 'working')).toBe(true); expect(results.some((r) => r.type === 'working')).toBe(true);
}); });
it('type 过滤:仅返回指定类型', () => { it('type 过滤:仅返回指定类型', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: 'typefilter 内容', content: 'typefilter 内容',
@@ -549,13 +551,13 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.5, importance: 0.5,
}); });
const episodic = mgr.search('typefilter', { type: 'episodic' }); const episodic = await mgr.search('typefilter', { type: 'episodic' });
expect(episodic.every((r) => r.type === 'episodic')).toBe(true); expect(episodic.every((r) => r.type === 'episodic')).toBe(true);
const semantic = mgr.search('typefilter', { type: 'semantic' }); const semantic = await mgr.search('typefilter', { type: 'semantic' });
expect(semantic.every((r) => r.type === 'semantic')).toBe(true); expect(semantic.every((r) => r.type === 'semantic')).toBe(true);
}); });
it('topK 限制返回条数', () => { it('topK 限制返回条数', async () => {
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
@@ -564,22 +566,22 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.7, importance: 0.7,
}); });
} }
const results = mgr.search('topk 内容'); const results = await mgr.search('topk 内容');
expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5 expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5
const results2 = mgr.search('topk 内容', { topK: 2 }); const results2 = await mgr.search('topk 内容', { topK: 2 });
expect(results2.length).toBeLessThanOrEqual(2); expect(results2.length).toBeLessThanOrEqual(2);
}); });
it('minImportance 过滤低重要性记忆', () => { it('minImportance 过滤低重要性记忆', async () => {
mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 }); mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 });
mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 }); mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 });
const results = mgr.search('重要', { minImportance: 0.5 }); const results = await mgr.search('重要', { minImportance: 0.5 });
expect(results.every((r) => r.importance >= 0.5)).toBe(true); expect(results.every((r) => r.importance >= 0.5)).toBe(true);
}); });
it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)>0 才返回)', () => { it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)>0 才返回)', async () => {
mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 }); mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 });
const results = mgr.search('score 数学'); const results = await mgr.search('score 数学');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
for (const r of results) { for (const r of results) {
expect(r.score).toBeGreaterThan(0); expect(r.score).toBeGreaterThan(0);
@@ -604,19 +606,19 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
} }
}); });
it('空查询与纯空白查询返回空数组', () => { it('空查询与纯空白查询返回空数组', async () => {
expect(mgr.search('')).toEqual([]); expect(await mgr.search('')).toEqual([]);
expect(mgr.search(' ')).toEqual([]); expect(await mgr.search(' ')).toEqual([]);
expect(mgr.search('', { topK: 3 })).toEqual([]); expect(await mgr.search('', { topK: 3 })).toEqual([]);
}); });
it('无匹配关键词返回空数组(不抛错)', () => { it('无匹配关键词返回空数组(不抛错)', async () => {
mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 }); mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 });
// "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → [] // "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → []
expect(mgr.search('完全无关联')).toEqual([]); expect(await mgr.search('完全无关联')).toEqual([]);
}); });
it('英文无命中时回退 LIKE 子串搜索', () => { it('英文无命中时回退 LIKE 子串搜索', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: 'hello world network', content: 'hello world network',
@@ -625,11 +627,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
}); });
// query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中 // query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中
// 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中 // 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中
const results = mgr.search('lo wo'); const results = await mgr.search('lo wo');
expect(results.some((r) => r.content.includes('hello world'))).toBe(true); expect(results.some((r) => r.content.includes('hello world'))).toBe(true);
}); });
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', () => { it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: '使用 50% 折扣 与 under_score', content: '使用 50% 折扣 与 under_score',
@@ -643,15 +645,15 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.7, importance: 0.7,
}); });
// 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录 // 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录
const pct = mgr.search('%'); const pct = await mgr.search('%');
expect(pct.some((r) => r.content.includes('50%'))).toBe(true); expect(pct.some((r) => r.content.includes('50%'))).toBe(true);
expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false); expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false);
// 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录 // 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录
const underscore = mgr.search('_'); const underscore = await mgr.search('_');
expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false); expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false);
}); });
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', () => { it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: '路径 C:\\Users\\test', content: '路径 C:\\Users\\test',
@@ -659,10 +661,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.7, importance: 0.7,
}); });
// 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错 // 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错
expect(() => mgr.search('\\')).not.toThrow(); await expect(mgr.search('\\')).resolves.toBeInstanceOf(Array);
}); });
it('search 的 topK 同时作用于回退路径', () => { it('search 的 topK 同时作用于回退路径', async () => {
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
@@ -671,11 +673,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.7, importance: 0.7,
}); });
} }
const results = mgr.search('backup', { topK: 3 }); const results = await mgr.search('backup', { topK: 3 });
expect(results.length).toBeLessThanOrEqual(3); expect(results.length).toBeLessThanOrEqual(3);
}); });
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', () => { it('search 无结果时回退 LIKE 的 score = importance * timeDecay', async () => {
mgr.store({ mgr.store({
type: 'episodic', type: 'episodic',
content: 'fallbackscore 内容', content: 'fallbackscore 内容',
@@ -683,16 +685,16 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.8, importance: 0.8,
}); });
// "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中 // "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中
const results = mgr.search('allbackscor'); const results = await mgr.search('allbackscor');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
// 新记录 timeDecay≈1 → score≈importance=0.8 // 新记录 timeDecay≈1 → score≈importance=0.8
expect(results[0].score).toBeCloseTo(0.8, 1); expect(results[0].score).toBeCloseTo(0.8, 1);
}); });
it('LIKE 回退:episodic 按 importance 降序返回', () => { it('LIKE 回退:episodic 按 importance 降序返回', async () => {
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 }); mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 });
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 }); mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 });
const results = mgr.search('排序验证'); const results = await mgr.search('排序验证');
expect(results[0].importance).toBe(0.9); expect(results[0].importance).toBe(0.9);
}); });
}); });
@@ -844,3 +846,141 @@ describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => {
expect(db.prepare('SELECT COUNT(*) AS c FROM episodic_memories').get().c).toBe(1); expect(db.prepare('SELECT COUNT(*) AS c FROM episodic_memories').get().c).toBe(1);
}); });
}); });
// ===== v0.8.1: P0-2 生命周期 + P1-1 向量混合检索 =====
describe.skipIf(!dbAvailable)('MemoryManager — v0.8.1 生命周期与混合检索', () => {
let db: any;
let mgr: MemoryManager;
beforeAll(() => {
if (!dbAvailable) return;
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
mgr.initialize();
});
afterAll(() => {
if (db) db.close();
});
afterEach(() => {
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
mgr.setEmbedder(null);
});
it('P0-2: store 接受 expiresAt 并写入 episodic_memories.expires_at', () => {
const ttl = Date.now() + 1000;
mgr.store({
type: 'episodic',
content: 'TTL 验证内容',
source: 'tool_result',
importance: 0.6,
expiresAt: ttl,
});
const row = db
.prepare('SELECT expires_at FROM episodic_memories WHERE content = ?')
.get('TTL 验证内容') as {
expires_at: number | null;
};
expect(row.expires_at).toBe(ttl);
});
it('P0-2: semantic 检索命中后 access_count 递增', async () => {
mgr.store({
type: 'semantic',
content: '用户偏好简洁回答',
summary: 'pref-brief',
source: 'agent_thought',
importance: 0.9,
});
const before = (
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
access_count: number;
}
).access_count;
await mgr.search('偏好简洁');
const after = (
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
access_count: number;
}
).access_count;
expect(after).toBeGreaterThan(before);
});
it('P1-1: 注入 embedder 后混合检索命中同义改写(TF-IDF 单路召回不到的查询)', async () => {
// 文档:"回复要短" — 查询"我喜欢简洁回答"(同义改写,无字面重叠)
mgr.store({
type: 'semantic',
content: '回复要短',
summary: 'style-rule',
source: 'agent_thought',
importance: 0.9,
});
// 词表不重叠 → TF-IDF 嵌入向量正交 → 纯 TF-IDF 0 分
const tfidfOnly = await mgr.search('我喜欢简洁回答');
expect(tfidfOnly).toHaveLength(0);
// 注入固定向量的 embedder:同义改写在向量空间中余弦 > 0
const VECTORS: Record<string, number[]> = {
'回复要短 style-rule': [1, 0.9, 0],
: [0.95, 1, 0.1],
xyz: [0, 0.1, 1],
};
mgr.setEmbedder({
embed: async (text) => {
for (const [k, v] of Object.entries(VECTORS)) {
if (text.includes(k)) return v;
}
return [0, 0, 1];
},
});
// 首次检索触发存量记忆的惰性向量回填(本轮仍走 TF-IDF → 0 命中)
await mgr.search('我喜欢简洁回答');
await new Promise((r) => setTimeout(r, 10));
// 回填完成后,向量路径生效 → 同义改写命中
const hybrid = await mgr.search('我喜欢简洁回答');
expect(hybrid.length).toBeGreaterThan(0);
expect(hybrid[0].content).toBe('回复要短');
expect(hybrid[0].score).toBeGreaterThan(0);
});
it('P1-1: embedder 抛错/返回 null → 回退纯 TF-IDF(行为兼容)', async () => {
mgr.store({
type: 'semantic',
content: 'TF-IDF 兜底验证',
summary: 'fallback-vec',
source: 'agent_thought',
importance: 0.9,
});
mgr.setEmbedder({
embed: async () => {
throw new Error('embed down');
},
});
const results = await mgr.search('TF-IDF 兜底验证');
expect(results.length).toBeGreaterThan(0);
mgr.setEmbedder({ embed: async () => null });
const results2 = await mgr.search('TF-IDF 兜底验证');
expect(results2.length).toBeGreaterThan(0);
});
it('P1-1: store 写入异步回填 embedding BLOB', async () => {
mgr.setEmbedder({ embed: async () => [0.5, 0.5, 0.5] });
mgr.store({
type: 'semantic',
content: '向量化回填验证',
summary: 'vec-backfill',
source: 'agent_thought',
importance: 0.8,
});
await new Promise((r) => setTimeout(r, 10));
const row = db
.prepare('SELECT embedding FROM semantic_memories WHERE key = ?')
.get('vec-backfill') as { embedding: Buffer | null };
expect(row.embedding).not.toBeNull();
expect(row.embedding!.length % 4).toBe(0);
});
});
+41 -31
View File
@@ -26,6 +26,8 @@ import type { MetonaRequest } from '../types';
import type { WorkspaceService } from '../../services/workspace.service'; import type { WorkspaceService } from '../../services/workspace.service';
import type { IterationStep } from '../agent-loop/types'; import type { IterationStep } from '../agent-loop/types';
import type { MemoryManager } from './manager'; import type { MemoryManager } from './manager';
// v0.8.1 P1-2: 分区条目摘要(与 Maintainer 共用,消除全文截断去重盲区)
import { parseMemoryEntries, buildMemoryEntriesDigest } from './maintainer';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */ /** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
@@ -91,10 +93,7 @@ export class MemoryConsolidator {
}, timeoutMs); }, timeoutMs);
}); });
try { try {
await Promise.race([ await Promise.race([this.runningPromise.catch(() => {}), timer]);
this.runningPromise.catch(() => {}),
timer,
]);
return !timedOut; return !timedOut;
} finally { } finally {
if (timerHandle) clearTimeout(timerHandle); if (timerHandle) clearTimeout(timerHandle);
@@ -142,14 +141,21 @@ export class MemoryConsolidator {
): Promise<ConsolidationResult> { ): Promise<ConsolidationResult> {
try { try {
// 1. 构建对话摘要 // 1. 构建对话摘要
const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations); const conversationDigest = this.buildConversationDigest(
userMessage,
assistantAnswer,
iterations,
);
if (!conversationDigest) { if (!conversationDigest) {
return { appended: 0, entries: [], skipped: 0 }; return { appended: 0, entries: [], skipped: 0 };
} }
// 2. 读取当前 MEMORY.md 内容(供 LLM 去重) // 2. 读取当前 MEMORY.md 条目摘要(供 LLM 去重)
// v0.8.1 P1-2 根治: 旧实现全文截 3000 字符,尾部条目对 LLM 不可见 → 去重
// 失效、重复写入。现用纯条目摘要(8000 字符预算),完整覆盖全部条目。
const currentMemory = this.workspaceService.getFiles().memory; const currentMemory = this.workspaceService.getFiles().memory;
const memoryDigest = this.truncateMemoryForPrompt(currentMemory); const memoryDigest =
buildMemoryEntriesDigest(parseMemoryEntries(currentMemory ?? '')) || '(empty)';
// 3. 调用 LLM 提取需要持久化的记忆 // 3. 调用 LLM 提取需要持久化的记忆
const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest); const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest);
@@ -205,7 +211,9 @@ export class MemoryConsolidator {
} }
if (validEntries.length > 0) { if (validEntries.length > 0) {
log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`); log.info(
`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`,
);
} }
return { appended: validEntries.length, entries: validEntries, skipped }; return { appended: validEntries.length, entries: validEntries, skipped };
@@ -238,8 +246,10 @@ export class MemoryConsolidator {
const status = result?.success ? 'ok' : 'error'; const status = result?.success ? 'ok' : 'error';
const resultPreview = result?.result const resultPreview = result?.result
? this.truncate(JSON.stringify(result.result), 200) ? this.truncate(JSON.stringify(result.result), 200)
: result?.error ?? ''; : (result?.error ?? '');
toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`); toolSummaries.push(
` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`,
);
} }
} }
if (toolSummaries.length > 0) { if (toolSummaries.length > 0) {
@@ -252,16 +262,6 @@ export class MemoryConsolidator {
return parts.join('\n\n'); return parts.join('\n\n');
} }
/**
* MEMORY.md prompt
*/
private truncateMemoryForPrompt(memory: string): string {
if (!memory) return '(empty)';
// 截取前 3000 字符,保留分区结构概览
if (memory.length <= 3000) return memory;
return memory.slice(0, 3000) + '\n... (truncated)';
}
/** /**
* LLM * LLM
*/ */
@@ -280,7 +280,8 @@ export class MemoryConsolidator {
agentVersion: '1.0.0', agentVersion: '1.0.0',
}, },
systemPrompt: { systemPrompt: {
roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.', roleDefinition:
"You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent's long-term memory file (MEMORY.md) for future sessions.",
outputConstraints: [ outputConstraints: [
'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:', 'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:',
'1. Long-term value: will be useful in future conversations (not transient task state)', '1. Long-term value: will be useful in future conversations (not transient task state)',
@@ -294,13 +295,16 @@ export class MemoryConsolidator {
'If nothing is worth persisting, output an empty array: []', 'If nothing is worth persisting, output an empty array: []',
'Output ONLY the JSON array, no markdown fences, no explanation.', 'Output ONLY the JSON array, no markdown fences, no explanation.',
].join('\n'), ].join('\n'),
safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.', safetyGuidelines:
'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
}, },
messages: [{ messages: [
role: 'user', {
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`, role: 'user',
timestamp: Date.now(), content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
}], timestamp: Date.now(),
},
],
params: { params: {
maxTokens: 1024, maxTokens: 1024,
temperature: 0.0, temperature: 0.0,
@@ -341,7 +345,10 @@ export class MemoryConsolidator {
// 移除可能的 markdown 代码围栏 // 移除可能的 markdown 代码围栏
if (cleaned.startsWith('```')) { if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); cleaned = cleaned
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/, '')
.trim();
} }
try { try {
@@ -349,9 +356,12 @@ export class MemoryConsolidator {
if (!Array.isArray(parsed)) return []; if (!Array.isArray(parsed)) return [];
return parsed return parsed
.filter((item): item is { section: string; entry: string } => .filter(
typeof item === 'object' && item !== null && (item): item is { section: string; entry: string } =>
typeof item.section === 'string' && typeof item.entry === 'string', typeof item === 'object' &&
item !== null &&
typeof item.section === 'string' &&
typeof item.entry === 'string',
) )
.map((item) => ({ .map((item) => ({
section: item.section.trim(), section: item.section.trim(),
+19
View File
@@ -0,0 +1,19 @@
/**
* Memory Embedder v0.8.1 P1-1
*
* Provider
* main.ts Provider Ollama
* `memory.embeddingModel` null
* MemoryManager 退 TF-IDF
*
* OllamaAdapter.embed()
* TF-IDF bigram "我喜欢简洁回答" vs "回复要短"
* × TF-IDF
*/
/** 嵌入失败/不可用的统一返回:null = 本次无法向量化(调用方回退 TF-IDF 路径) */
export type MemoryEmbedFn = (text: string) => Promise<number[] | null>;
export interface MemoryEmbedder {
embed: MemoryEmbedFn;
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Memory Maintainer MEMORY.md v0.8.1 P1-2
*
* MemoryConsolidator append-only prompt
* 3000 LLM
* /MEMORY.md 使>50KB
* prompt system
*
*
* 1. analyze()LLM "分区条目摘要"
* {deletes[], updates[]} / /
* 2. apply() MEMORY.mdWorkspaceService.rewriteMemory
* / semantic_memories
*
* LLM
* audit_logs
*/
import { nanoid } from 'nanoid';
import log from 'electron-log';
import type Database from 'better-sqlite3';
import type { IMetonaProviderAdapter } from '../types/metona-adapter';
import type { MetonaRequest } from '../types';
import type { WorkspaceService } from '../../services/workspace.service';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE / Consolidator 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
/** 动作数上限(防 LLM 过度建议) */
const MAX_ACTIONS = 30;
/** 单条目在 prompt 中的截断长度 */
const ENTRY_PROMPT_CHARS = 160;
/** 条目摘要总预算(字符)—— 纯条目行远小于全文,同预算下覆盖完整文件 */
const DIGEST_BUDGET_CHARS = 8000;
/** 一条维护动作(用户确认的输入/输出单元) */
export interface MemoryMaintenanceAction {
/** 动作类型:delete = 删除整行;merge = 用 newEntry 替换该行(合并多条时产生多条 update 指向同一 newEntry */
action: 'delete' | 'update';
section: string;
/** MEMORY.md 中该条目的当前完整文本(不含 "- " 前缀;精确匹配锚点) */
entry: string;
/** action=update 时的替换文本(合并后的新条目) */
newEntry?: string;
/** LLM 给出的理由(UI 展示) */
reason?: string;
}
export interface MemoryMaintenanceProposal {
actions: MemoryMaintenanceAction[];
/** 当前文件条目总数(UI 展示上下文) */
totalEntries: number;
/**
* v0.8.1 review (O2): "应用后变空的分区"
*
*/
sectionEntryCounts: Record<string, number>;
}
/** 解析后的分区结构(模块级类型 —— parseEntries/apply 共用) */
interface ParsedSection {
section: string;
entries: string[];
}
/** 解析 MEMORY.md 的分区与条目(模块级工具 —— Maintainer 与 Consolidator 共用) */
export function parseMemoryEntries(memory: string): ParsedSection[] {
const sections: ParsedSection[] = [];
let current: ParsedSection | null = null;
let inHead = true;
for (const line of memory.split('\n')) {
if (inHead) {
if (line.startsWith('## ')) inHead = false;
else continue;
}
const m = line.match(/^## (.+)$/);
if (m) {
current = { section: m[1].trim(), entries: [] };
sections.push(current);
continue;
}
const em = line.match(/^- (.+)$/);
if (em && current) {
current.entries.push(em[1].trim());
}
}
return sections;
}
/**
* "分区条目摘要"
* Consolidator Maintainer 8000
* 3000 LLM
*
*/
export function buildMemoryEntriesDigest(sections: ParsedSection[]): string {
const parts: string[] = [];
let used = 0;
for (const s of sections) {
if (s.entries.length === 0) continue;
const lines: string[] = [`## ${s.section}`];
for (const e of s.entries) {
const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e;
lines.push(`- ${clipped}`);
}
const block = lines.join('\n');
if (used + block.length > DIGEST_BUDGET_CHARS) break;
parts.push(block);
used += block.length;
}
return parts.join('\n\n');
}
export class MemoryMaintainer {
constructor(
private getAdapter: () => IMetonaProviderAdapter,
private workspaceService: WorkspaceService,
private getDB: () => Database.Database,
) {}
/** 分析当前 MEMORY.md,产出维护建议(不改任何文件/DB) */
async analyze(): Promise<MemoryMaintenanceProposal> {
const memory = this.workspaceService.getFiles().memory ?? '';
const sections = this.parseEntries(memory);
const totalEntries = sections.reduce((n, s) => n + s.entries.length, 0);
const sectionEntryCounts: Record<string, number> = {};
for (const s of sections) {
sectionEntryCounts[s.section] = s.entries.length;
}
if (totalEntries === 0) {
return { actions: [], totalEntries: 0, sectionEntryCounts };
}
const digest = this.buildDigest(sections);
const raw = await this.callLLM(digest);
const actions = this.parseActions(raw, sections);
return { actions, totalEntries, sectionEntryCounts };
}
/** 应用用户确认的动作(只处理精确命中当前文件内容的动作,防幻觉改写) */
apply(actions: MemoryMaintenanceAction[]): { applied: number; skipped: number } {
const memory = this.workspaceService.getFiles().memory ?? '';
const sections = this.parseEntries(memory);
// 精确匹配校验:entry 必须原样存在于对应分区(LLM 响应与文件状态之间的一致性锚点)
const valid: MemoryMaintenanceAction[] = [];
for (const a of actions.slice(0, MAX_ACTIONS)) {
const section = sections.find((s) => s.section === a.section);
const exists = section?.entries.includes(a.entry) ?? false;
if (!exists) continue;
if (a.action === 'update' && (!a.newEntry || !a.newEntry.trim())) continue;
valid.push(a);
}
if (valid.length === 0) return { applied: 0, skipped: actions.length };
// 应用到内存结构:delete 直接删;update 替换文本
for (const a of valid) {
const section = sections.find((s) => s.section === a.section);
if (!section) continue;
if (a.action === 'delete') {
section.entries = section.entries.filter((e) => e !== a.entry);
} else {
section.entries = section.entries.map((e) => (e === a.entry ? a.newEntry!.trim() : e));
}
}
// 序列化回 Markdown(保留原文件头;分区结构重建)
const head = this.extractHead(memory);
const body = sections
.map((s) => `## ${s.section}\n${s.entries.map((e) => `- ${e}`).join('\n')}`)
.filter((s) => !s.endsWith('## ') && s.split('\n').length > 1)
.join('\n\n');
this.workspaceService.rewriteMemory(`${head}${body}\n`);
// 双轨一致:同步 semantic_memoriescontent 以 entry 写入 —— Consolidator 同口径)
const db = this.getDB();
const delStmt = db.prepare('DELETE FROM semantic_memories WHERE content = ?');
const updStmt = db.prepare(
'UPDATE semantic_memories SET content = ?, summary = ? WHERE content = ?',
);
let dbOps = 0;
for (const a of valid) {
try {
if (a.action === 'delete') {
dbOps += delStmt.run(a.entry).changes;
} else {
dbOps += updStmt.run(
a.newEntry!.trim(),
`[${a.section}] ${a.newEntry!.trim().slice(0, 60)}`,
a.entry,
).changes;
}
} catch (err) {
// DB 同步失败不影响 MEMORY.md 已写入结果(与 Consolidator 同语义)
log.warn('[MemoryMaintainer] semantic_memories sync failed:', (err as Error).message);
}
}
log.info(
`[MemoryMaintainer] applied ${valid.length} action(s) (db rows touched: ${dbOps}, skipped: ${actions.length - valid.length})`,
);
return { applied: valid.length, skipped: actions.length - valid.length };
}
// ===== 私有方法 =====
/** 解析 MEMORY.md 为 {section, entries[]} 结构(跳过元数据头;复用模块级工具) */
private parseEntries(memory: string): ParsedSection[] {
return parseMemoryEntries(memory);
}
/** 提取文件头(H1 + > 元数据区),供重建时保留 */
private extractHead(memory: string): string {
const lines = memory.split('\n');
let headEnd = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('## ')) {
headEnd = i;
break;
}
}
const headLines = lines.slice(0, headEnd).join('\n').trimEnd();
return headLines.length > 0 ? `${headLines}\n\n` : '';
}
/** 构建"分区条目摘要"(纯条目行,消除全文截断盲区) */
private buildDigest(sections: ParsedSection[]): string {
const parts: string[] = [];
let used = 0;
for (const s of sections) {
if (s.entries.length === 0) continue;
const lines: string[] = [`## ${s.section}`];
for (const e of s.entries) {
const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e;
lines.push(`- ${clipped}`);
}
const block = lines.join('\n');
if (used + block.length > DIGEST_BUDGET_CHARS) break;
parts.push(block);
used += block.length;
}
return parts.join('\n\n');
}
/** LLM 分析(结构化 JSON 输出,30s 超时与 Consolidator 同口径) */
private async callLLM(digest: string): Promise<string | null> {
const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', ');
const request: MetonaRequest = {
meta: {
sessionId: 'memory-maintenance',
iteration: 0,
requestId: `mm_${nanoid(12)}`,
timestamp: Date.now(),
agentVersion: '1.0.0',
},
systemPrompt: {
roleDefinition:
"You are a memory curator maintaining the agent's long-term memory file (MEMORY.md).",
outputConstraints: [
'Analyze the memory entries below and propose maintenance actions:',
'- "delete": remove stale, superseded, duplicated, or completed entries',
'- "update": merge two or more duplicate/similar entries into ONE consolidated entry',
'Keep valuable, still-valid information — do NOT delete aggressively.',
`Every action must reference an existing entry EXACTLY as written (section must be one of ${sectionsList}).`,
'',
'Output ONLY a JSON array, no markdown fences:',
'[{"action":"delete","section":"...","entry":"...","reason":"..."},',
' {"action":"update","section":"...","entry":"old entry","newEntry":"merged entry","reason":"..."}]',
'If nothing needs maintenance, output []',
].join('\n'),
safetyGuidelines: 'Never propose deleting user preference facts without a clear reason.',
},
messages: [
{
role: 'user',
content: `## Current MEMORY.md entries:\n\n${digest}\n\n## Task:\nPropose maintenance actions. Output JSON array only.`,
timestamp: Date.now(),
},
],
params: {
maxTokens: 2048,
temperature: 0.0,
stream: false,
thinkingEnabled: false,
thinkingEffort: 'low',
},
};
try {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('maintenance analysis timeout')), 30_000);
});
const response = await Promise.race([this.getAdapter().send(request), timeoutPromise]);
return response.content.trim();
} finally {
if (timer) clearTimeout(timer);
}
} catch (error) {
log.warn('[MemoryMaintainer] LLM call failed:', (error as Error).message);
return null;
}
}
/** 解析 LLM 建议(丢弃非法 section / 空条目 / 超限动作) */
private parseActions(raw: string | null, sections: ParsedSection[]): MemoryMaintenanceAction[] {
if (!raw) return [];
let cleaned = raw.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/, '')
.trim();
}
let parsed: unknown;
try {
parsed = JSON.parse(cleaned);
} catch {
log.warn('[MemoryMaintainer] failed to parse LLM response as JSON:', cleaned.slice(0, 200));
return [];
}
if (!Array.isArray(parsed)) return [];
const validSections = new Set<string>(ALLOWED_SECTIONS);
// 仅允许引用当前文件中真实存在的条目(先过滤一轮,双保险在 apply 中再做精确校验)
const existing = new Set<string>();
for (const s of sections) {
for (const e of s.entries) existing.add(e);
}
const out: MemoryMaintenanceAction[] = [];
for (const item of parsed.slice(0, MAX_ACTIONS)) {
if (!item || typeof item !== 'object') continue;
const a = item as Record<string, unknown>;
const action = a.action;
const section = typeof a.section === 'string' ? a.section.trim() : '';
const entry = typeof a.entry === 'string' ? a.entry.trim() : '';
if (action !== 'delete' && action !== 'update') continue;
if (!validSections.has(section) || !entry || !existing.has(entry)) continue;
if (action === 'update' && (typeof a.newEntry !== 'string' || !a.newEntry.trim())) continue;
out.push({
action,
section,
entry,
newEntry: action === 'update' ? (a.newEntry as string).trim() : undefined,
reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : undefined,
});
}
return out;
}
}
+430 -104
View File
@@ -17,6 +17,7 @@ import { nanoid } from 'nanoid';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import log from 'electron-log'; import log from 'electron-log';
import type { MemoryEmbedder } from './embedder';
export type MemoryType = 'episodic' | 'semantic' | 'working'; export type MemoryType = 'episodic' | 'semantic' | 'working';
export type MemorySource = 'user_input' | 'tool_result' | 'agent_thought' | 'imported'; export type MemorySource = 'user_input' | 'tool_result' | 'agent_thought' | 'imported';
@@ -94,7 +95,11 @@ function computeTF(tokens: string[]): Map<string, number> {
} }
/** 计算余弦相似度的点积部分 */ /** 计算余弦相似度的点积部分 */
function dotProduct(tf1: Map<string, number>, tf2: Map<string, number>, idf: Map<string, number>): number { function dotProduct(
tf1: Map<string, number>,
tf2: Map<string, number>,
idf: Map<string, number>,
): number {
let sum = 0; let sum = 0;
for (const [term, freq1] of tf1) { for (const [term, freq1] of tf1) {
const freq2 = tf2.get(term); const freq2 = tf2.get(term);
@@ -123,6 +128,37 @@ function timeDecayWeight(createdAt: number, now: number = Date.now()): number {
return Math.pow(0.5, ageDays / halfLifeDays); return Math.pow(0.5, ageDays / halfLifeDays);
} }
// ===== 向量工具(v0.8.1 P1-1 本地向量混合检索) =====
/** Float32Array → SQLite BLOBlittle-endian 原生布局,Node/SQLite 同机读写安全) */
function float32ToBlob(vec: number[]): Buffer {
const f32 = Float32Array.from(vec);
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength);
}
/** SQLite BLOB → number[](维度/字节损坏时返回 null,调用方回退 TF-IDF 路径) */
function blobToFloat32(blob: unknown): number[] | null {
if (!Buffer.isBuffer(blob)) return null;
if (blob.length === 0 || blob.length % 4 !== 0) return null;
const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.length / 4);
return Array.from(f32);
}
/** 余弦相似度(零向量/维度不匹配返回 0) */
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || a.length !== b.length) return 0;
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
if (na === 0 || nb === 0) return 0;
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
/** /**
* *
*/ */
@@ -136,8 +172,20 @@ export class MemoryManager {
/** 缓存有效期(5 分钟) */ /** 缓存有效期(5 分钟) */
private readonly CACHE_TTL = 5 * 60 * 1000; private readonly CACHE_TTL = 5 * 60 * 1000;
/**
* v0.8.1 P1-1: 本地向量嵌入器
* main.ts Provider=Ollama memory.embeddingModel
* null = search/store TF-IDF
*/
private embedder: MemoryEmbedder | null = null;
constructor(private getDB: () => Database.Database) {} constructor(private getDB: () => Database.Database) {}
/** 注入向量嵌入器(传 null 关闭向量路径;热切换由 main.ts 在 adapter 重载时联动) */
setEmbedder(embedder: MemoryEmbedder | null): void {
this.embedder = embedder;
}
/** /**
* DatabaseService * DatabaseService
*/ */
@@ -165,9 +213,15 @@ export class MemoryManager {
const newIdfCache = new Map<string, number>(); const newIdfCache = new Map<string, number>();
// 获取所有记忆内容(episodic + semantic + working // 获取所有记忆内容(episodic + semantic + working
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>; const episodicRows = db
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>; .prepare('SELECT content, summary FROM episodic_memories')
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>; .all() as Array<{ content: string; summary: string | null }>;
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{
value: string;
}>;
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{
value: string;
}>;
const allDocs = [ const allDocs = [
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')), ...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
@@ -244,6 +298,10 @@ export class MemoryManager {
source: MemorySource; source: MemorySource;
sessionId?: string; sessionId?: string;
expiresAt?: number; expiresAt?: number;
/** v0.8.1 P1-1: 文档向量(embedding 列缺失/损坏时为 null → 单路 TF-IDF 评分) */
docVec?: number[] | null;
/** v0.8.1 P1-1: 查询向量(null → 单路 TF-IDF 评分) */
queryVec?: number[] | null;
}, },
queryTF: Map<string, number>, queryTF: Map<string, number>,
queryNorm: number, queryNorm: number,
@@ -253,31 +311,59 @@ export class MemoryManager {
const docTF = computeTF(params.docTokens); const docTF = computeTF(params.docTokens);
const docNorm = vectorNorm(docTF, this.idfCache); const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) return; if (docNorm === 0 && !(params.docVec && params.queryVec)) return;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
// 时间衰减 // 时间衰减
const decayWeight = timeDecayWeight(params.createdAt, now); const decayWeight = timeDecayWeight(params.createdAt, now);
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重 const importanceWeight = 0.5 + params.importance * 0.5;
const finalScore = cosineSim * decayWeight * (0.5 + params.importance * 0.5);
// TF-IDF 路(docNorm=0 时得 0 分,交由向量路兜底)
const tfidfScore =
docNorm > 0
? (dotProduct(queryTF, docTF, this.idfCache) / (queryNorm * docNorm)) *
decayWeight *
importanceWeight
: 0;
// 向量路(双侧齐备时计算余弦)
const vectorScore =
params.docVec && params.queryVec
? cosineSimilarity(params.queryVec, params.docVec) * decayWeight * importanceWeight
: 0;
// v0.8.1 P1-1 混合评分:双侧齐备 0.6 向量 + 0.4 TF-IDF;否则取可用单路
let finalScore: number;
if (params.docVec && params.queryVec) {
finalScore = 0.6 * vectorScore + 0.4 * tfidfScore;
} else {
finalScore = tfidfScore > 0 ? tfidfScore : vectorScore;
}
if (finalScore > 0) { if (finalScore > 0) {
results.push({ results.push({
id: params.id, type: params.type, content: params.content, id: params.id,
summary: params.summary, source: params.source, type: params.type,
importance: params.importance, sessionId: params.sessionId, content: params.content,
createdAt: params.createdAt, expiresAt: params.expiresAt, summary: params.summary,
source: params.source,
importance: params.importance,
sessionId: params.sessionId,
createdAt: params.createdAt,
expiresAt: params.expiresAt,
score: finalScore, score: finalScore,
}); });
} }
} }
/** /**
* TF-IDF * TF-IDF v0.8.1 P1-1: 可选向量混合评分
*
* @param queryVec embedder / null TF-IDF
*/ */
private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] { private tfidfSearch(
query: string,
options: MemorySearchOptions,
queryVec: number[] | null,
): SearchResult[] {
const db = this.getDB(); const db = this.getDB();
this.updateIdfCache(); this.updateIdfCache();
@@ -296,71 +382,142 @@ export class MemoryManager {
// 搜索 episodic 记忆 // 搜索 episodic 记忆
if (!type || type === 'episodic') { if (!type || type === 'episodic') {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM episodic_memories WHERE importance >= ? SELECT * FROM episodic_memories WHERE importance >= ?
ORDER BY importance DESC, created_at DESC LIMIT ? ORDER BY importance DESC, created_at DESC LIMIT ?
`).all(minImportance, topK * 3) as Array<{ `,
id: string; session_id: string | null; content: string; summary: string | null; )
source: string; importance: number; created_at: number; expires_at: number | null; .all(minImportance, topK * 3) as Array<{
id: string;
session_id: string | null;
content: string;
summary: string | null;
source: string;
importance: number;
created_at: number;
expires_at: number | null;
tf_cache: string | null; tf_cache: string | null;
}>; }>;
for (const row of rows) { for (const row of rows) {
this.scoreAndPushMemory({ this.scoreAndPushMemory(
docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')), {
createdAt: row.created_at, docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')),
importance: row.importance, createdAt: row.created_at,
id: row.id, type: 'episodic', content: row.content, importance: row.importance,
summary: row.summary ?? undefined, id: row.id,
source: row.source as MemorySource, type: 'episodic',
sessionId: row.session_id ?? undefined, content: row.content,
expiresAt: row.expires_at ?? undefined, summary: row.summary ?? undefined,
}, queryTF, queryNorm, now, results); source: row.source as MemorySource,
sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? undefined,
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
queryVec,
},
queryTF,
queryNorm,
now,
results,
);
} }
this.backfillMissingEmbeddings(
'episodic',
rows.map((r) => ({
id: r.id,
embedding: (r as { embedding?: unknown }).embedding,
text: r.content + ' ' + (r.summary ?? ''),
})),
);
} }
// 搜索 semantic 记忆 // 搜索 semantic 记忆
if (!type || type === 'semantic') { if (!type || type === 'semantic') {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM semantic_memories WHERE confidence >= ? SELECT * FROM semantic_memories WHERE confidence >= ?
ORDER BY confidence DESC, access_count DESC LIMIT ? ORDER BY confidence DESC, access_count DESC LIMIT ?
`).all(minImportance, Math.ceil(topK * 1.5)) as Array<{ `,
id: string; key: string; value: string; category: string | null; )
confidence: number; source_session: string | null; created_at: number; .all(minImportance, Math.ceil(topK * 1.5)) as Array<{
id: string;
key: string;
value: string;
category: string | null;
confidence: number;
source_session: string | null;
created_at: number;
tf_cache: string | null; tf_cache: string | null;
}>; }>;
for (const row of rows) { for (const row of rows) {
this.scoreAndPushMemory({ this.scoreAndPushMemory(
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value), {
createdAt: row.created_at, docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
importance: row.confidence, createdAt: row.created_at,
id: row.id, type: 'semantic', content: row.value, importance: row.confidence,
source: 'imported', id: row.id,
sessionId: row.source_session ?? undefined, type: 'semantic',
}, queryTF, queryNorm, now, results); content: row.value,
source: 'imported',
sessionId: row.source_session ?? undefined,
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
queryVec,
},
queryTF,
queryNorm,
now,
results,
);
} }
this.backfillMissingEmbeddings(
'semantic',
rows.map((r) => ({
id: r.id,
embedding: (r as { embedding?: unknown }).embedding,
text: r.key + ' ' + r.value,
})),
);
} }
// 搜索 working 记忆 // 搜索 working 记忆
if (!type || type === 'working') { if (!type || type === 'working') {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ? SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?
`).all(topK * 3) as Array<{ `,
id: string; session_id: string; task_id: string; )
key: string; value: string; updated_at: number; .all(topK * 3) as Array<{
id: string;
session_id: string;
task_id: string;
key: string;
value: string;
updated_at: number;
tf_cache: string | null; tf_cache: string | null;
}>; }>;
for (const row of rows) { for (const row of rows) {
this.scoreAndPushMemory({ this.scoreAndPushMemory(
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value), {
createdAt: row.updated_at, docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
importance: 0.5, createdAt: row.updated_at,
id: row.id, type: 'working', content: row.value, importance: 0.5,
source: 'agent_thought', id: row.id,
sessionId: row.session_id, type: 'working',
}, queryTF, queryNorm, now, results); content: row.value,
source: 'agent_thought',
sessionId: row.session_id,
},
queryTF,
queryNorm,
now,
results,
);
} }
} }
@@ -383,11 +540,22 @@ export class MemoryManager {
switch (item.type) { switch (item.type) {
case 'episodic': case 'episodic':
db.prepare(` db.prepare(
INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, tf_cache) `
VALUES (?, ?, ?, ?, ?, ?, ?, ?) INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, expires_at, tf_cache)
`).run( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
id, item.sessionId ?? null, item.content, item.summary ?? null, item.source, importance, now, `,
).run(
id,
item.sessionId ?? null,
item.content,
item.summary ?? null,
item.source,
importance,
now,
// v0.8.1 P0-2: expires_at 真实写入方 —— 调用方(MemoryTriggerHook 等)可携带
// TTL;此前该列全链路无写入方,cleanupExpired 空转,情节记忆只增不减
item.expiresAt ?? null,
// P2-12: 写入时预计算分词缓存,加速后续检索 // P2-12: 写入时预计算分词缓存,加速后续检索
JSON.stringify(tokenize(item.content + ' ' + (item.summary ?? ''))), JSON.stringify(tokenize(item.content + ' ' + (item.summary ?? ''))),
); );
@@ -397,22 +565,38 @@ 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。
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 OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
`).run( `,
id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now, ).run(
id,
item.summary ?? this.contentHash(item.content),
item.content,
'general',
importance,
item.sessionId ?? null,
now,
now,
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)), JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
); );
break; break;
case 'working': case 'working':
// v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖 // v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
db.prepare(` db.prepare(
`
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at, tf_cache) INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run( `,
id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now, ).run(
id,
item.sessionId ?? 'default',
'default',
item.summary ?? this.contentHash(item.content),
item.content,
now,
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)), JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
); );
break; break;
@@ -424,28 +608,117 @@ export class MemoryManager {
// 使 IDF 缓存失效 // 使 IDF 缓存失效
this.cacheUpdatedAt = 0; this.cacheUpdatedAt = 0;
// v0.8.1 P1-1: 异步向量化回填(fire-and-forget)—— 嵌入不可用时静默跳过,
// 该记忆保留 NULL embedding,检索时自动回退 TF-IDF 路径
this.enrichEmbedding(item.type, id, (item.summary ?? '') + ' ' + item.content);
log.debug(`Memory stored: ${id} (${item.type})`); log.debug(`Memory stored: ${id} (${item.type})`);
return id; return id;
} }
/** /**
* v0.2.0: TF-IDF + * v0.8.1 P1-1: 异步生成并回填 embedding BLOB
* * TF-IDF/
* v0.2.0
* - 使 TF-IDF LIKE
* - bigram
* - 30
* - IDF 5
*/ */
search(query: string, options: MemorySearchOptions = {}): SearchResult[] { private embeddingBackfillInFlight = new Set<string>();
private enrichEmbedding(type: MemoryType, id: string, text: string): void {
const embedder = this.embedder;
if (!embedder) return;
const table =
type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : null;
if (!table) return; // working 记忆会话级生命周期短,不参与向量检索
const key = `${table}:${id}`;
if (this.embeddingBackfillInFlight.has(key)) return;
this.embeddingBackfillInFlight.add(key);
void embedder
.embed(text.slice(0, 8000))
.then((vec) => {
if (!vec || vec.length === 0) return;
this.getDB()
.prepare(`UPDATE ${table} SET embedding = ? WHERE id = ?`)
.run(float32ToBlob(vec), id);
})
.catch((err) => {
log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`);
})
.finally(() => {
this.embeddingBackfillInFlight.delete(key);
});
}
/**
* v0.8.1 P1-1: 存量记忆向量惰性回填 embedding
* IS NULL TF-IDF
*
*
*/
private backfillMissingEmbeddings(
type: MemoryType,
rows: Array<{ id: string; embedding?: unknown; text: string }>,
): void {
if (!this.embedder) return;
for (const row of rows) {
if (row.embedding != null) continue;
this.enrichEmbedding(type, row.id, row.text);
}
}
/**
* v0.8.1 P0-2: 检索命中后回写 semantic_memories.access_countLRU
* ORDER BY LRU
*/
private bumpAccessCounts(results: SearchResult[]): void {
const semanticIds = results.filter((r) => r.type === 'semantic').map((r) => r.id);
if (semanticIds.length === 0) return;
try {
const placeholders = semanticIds.map(() => '?').join(', ');
this.getDB()
.prepare(
`UPDATE semantic_memories SET access_count = access_count + 1 WHERE id IN (${placeholders})`,
)
.run(...semanticIds);
} catch (err) {
// 计数回写失败不影响检索结果
log.debug('MemoryManager: access_count bump failed:', (err as Error).message);
}
}
/**
* v0.2.0: TF-IDF + v0.8.1 P1-1: 本地向量混合检索
*
* v0.8.1
* - async MemoryEmbedder Ollama
* - score = 0.6× + 0.4×TF-IDF
* 退
* embedder
* - semantic access_countLRU P0-2
*/
async search(query: string, options: MemorySearchOptions = {}): Promise<SearchResult[]> {
const db = this.getDB(); const db = this.getDB();
const { topK = 5, type, minImportance = 0 } = options; const { topK = 5, type, minImportance = 0 } = options;
// v0.3.0 修复:拦截空 query 和纯空格 query // v0.3.0 修复:拦截空 query 和纯空格 query
if (!query || !query.trim()) return []; if (!query || !query.trim()) return [];
// v0.2.0: 优先使用 TF-IDF 语义搜索 // v0.8.1: 查询向量生成一次(嵌入器缺失/失败 → null,全量回退 TF-IDF
const tfidfResults = this.tfidfSearch(query, options); let queryVec: number[] | null = null;
if (this.embedder) {
try {
queryVec = await this.embedder.embed(query.slice(0, 8000));
if (queryVec && queryVec.length === 0) queryVec = null;
} catch (err) {
log.debug(
'MemoryManager: query embedding failed, falling back to TF-IDF:',
(err as Error).message,
);
queryVec = null;
}
}
// v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合)
const tfidfResults = this.tfidfSearch(query, options, queryVec);
if (tfidfResults.length > 0) { if (tfidfResults.length > 0) {
this.bumpAccessCounts(tfidfResults);
return tfidfResults; return tfidfResults;
} }
@@ -458,20 +731,35 @@ export class MemoryManager {
// 搜索情节记忆 // 搜索情节记忆
if (!type || type === 'episodic') { if (!type || type === 'episodic') {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM episodic_memories SELECT * FROM episodic_memories
WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ? WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ?
ORDER BY importance DESC, created_at DESC LIMIT ? ORDER BY importance DESC, created_at DESC LIMIT ?
`).all(pattern, pattern, minImportance, topK) as Array<{ `,
id: string; session_id: string | null; content: string; summary: string | null; )
source: string; importance: number; created_at: number; expires_at: number | null; .all(pattern, pattern, minImportance, topK) as Array<{
id: string;
session_id: string | null;
content: string;
summary: string | null;
source: string;
importance: number;
created_at: number;
expires_at: number | null;
}>; }>;
for (const row of rows) { for (const row of rows) {
results.push({ results.push({
id: row.id, type: 'episodic', content: row.content, id: row.id,
summary: row.summary ?? undefined, source: row.source as MemorySource, type: 'episodic',
importance: row.importance, sessionId: row.session_id ?? undefined, content: row.content,
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined, 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: row.importance * timeDecayWeight(row.created_at), score: row.importance * timeDecayWeight(row.created_at),
}); });
} }
@@ -479,20 +767,33 @@ export class MemoryManager {
// 搜索语义记忆 // 搜索语义记忆
if (!type || type === 'semantic') { if (!type || type === 'semantic') {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM semantic_memories SELECT * FROM semantic_memories
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ? WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ?
ORDER BY confidence DESC, access_count DESC LIMIT ? ORDER BY confidence DESC, access_count DESC LIMIT ?
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{ `,
id: string; key: string; value: string; category: string | null; )
confidence: number; source_session: string | null; created_at: number; .all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
id: string;
key: string;
value: string;
category: string | null;
confidence: number;
source_session: string | null;
created_at: number;
}>; }>;
for (const row of rows) { for (const row of rows) {
results.push({ results.push({
id: row.id, type: 'semantic', content: row.value, id: row.id,
source: 'imported', importance: row.confidence, type: 'semantic',
content: row.value,
source: 'imported',
importance: row.confidence,
sessionId: row.source_session ?? undefined, sessionId: row.source_session ?? undefined,
createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at), createdAt: row.created_at,
score: row.confidence * timeDecayWeight(row.created_at),
}); });
} }
} }
@@ -500,25 +801,39 @@ export class MemoryManager {
// 搜索工作记忆 // 搜索工作记忆
// v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致) // v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致)
if (!type || type === 'working') { if (!type || type === 'working') {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM working_memories SELECT * FROM working_memories
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
ORDER BY updated_at DESC LIMIT ? ORDER BY updated_at DESC LIMIT ?
`).all(pattern, pattern, topK) as Array<{ `,
id: string; session_id: string; task_id: string; )
key: string; value: string; updated_at: number; .all(pattern, pattern, topK) as Array<{
id: string;
session_id: string;
task_id: string;
key: string;
value: string;
updated_at: number;
}>; }>;
for (const row of rows) { for (const row of rows) {
results.push({ results.push({
id: row.id, type: 'working', content: row.value, id: row.id,
source: 'agent_thought', importance: 0.5, type: 'working',
sessionId: row.session_id, createdAt: row.updated_at, content: row.value,
source: 'agent_thought',
importance: 0.5,
sessionId: row.session_id,
createdAt: row.updated_at,
score: 0.3 * timeDecayWeight(row.updated_at), score: 0.3 * timeDecayWeight(row.updated_at),
}); });
} }
} }
return results.sort((a, b) => b.score - a.score).slice(0, topK); const finalResults = results.sort((a, b) => b.score - a.score).slice(0, topK);
this.bumpAccessCounts(finalResults);
return finalResults;
} }
/** /**
@@ -526,9 +841,13 @@ export class MemoryManager {
*/ */
getWorkingMemory(sessionId: string, taskId: string = 'default'): Map<string, string> { getWorkingMemory(sessionId: string, taskId: string = 'default'): Map<string, string> {
const db = this.getDB(); const db = this.getDB();
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT key, value FROM working_memories WHERE session_id = ? AND task_id = ? SELECT key, value FROM working_memories WHERE session_id = ? AND task_id = ?
`).all(sessionId, taskId) as Array<{ key: string; value: string }>; `,
)
.all(sessionId, taskId) as Array<{ key: string; value: string }>;
return new Map(rows.map((r) => [r.key, r.value])); return new Map(rows.map((r) => [r.key, r.value]));
} }
@@ -537,10 +856,12 @@ export class MemoryManager {
*/ */
setWorkingMemory(sessionId: string, taskId: string, key: string, value: string): void { setWorkingMemory(sessionId: string, taskId: string, key: string, value: string): void {
const db = this.getDB(); const db = this.getDB();
db.prepare(` db.prepare(
`
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at) INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now()); `,
).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now());
} }
/** /**
@@ -549,7 +870,10 @@ export class MemoryManager {
clearWorkingMemory(sessionId: string, taskId?: string): void { clearWorkingMemory(sessionId: string, taskId?: string): void {
const db = this.getDB(); const db = this.getDB();
if (taskId) { if (taskId) {
db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(sessionId, taskId); db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(
sessionId,
taskId,
);
} else { } else {
db.prepare('DELETE FROM working_memories WHERE session_id = ?').run(sessionId); db.prepare('DELETE FROM working_memories WHERE session_id = ?').run(sessionId);
} }
@@ -560,7 +884,9 @@ export class MemoryManager {
*/ */
cleanupExpired(): number { cleanupExpired(): number {
const db = this.getDB(); const db = this.getDB();
const result = db.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?').run(Date.now()); const result = db
.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?')
.run(Date.now());
return result.changes; return result.changes;
} }
@@ -172,14 +172,14 @@ export class TaskOrchestrator extends EventEmitter {
thinkingEnabled: this.defaultConfig?.thinkingEnabled ?? true, thinkingEnabled: this.defaultConfig?.thinkingEnabled ?? true,
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium', thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
contextLength: this.defaultConfig?.contextLength, contextLength: this.defaultConfig?.contextLength,
contextWindow: this.defaultConfig?.contextWindow ?? 128_000, contextWindow: this.defaultConfig?.contextWindow,
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflectionREFLECTING 状态开关) // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflectionREFLECTING 状态开关)
enableReflection: this.defaultConfig?.enableReflection ?? false, enableReflection: this.defaultConfig?.enableReflection ?? false,
// v0.7.4 P3-2 修正: SubAgent 继承主引擎的 temperature/maxTokens —— // v0.7.4 P3-2 修正: SubAgent 继承主引擎的 temperature/maxTokens ——
// 旧实现不读这两个键,新 SubAgent 恒用引擎 DEFAULT_CONFIG0.0/63488), // 旧实现不读这两个键导致"热生效"对子任务不完整。
// 导致"热生效"对子任务不完整 // v0.8.1 硬性契约: 不携带任何写死兜底值 —— 与主引擎同源继承设置面板配置
temperature: this.defaultConfig?.temperature ?? 0.0, temperature: this.defaultConfig?.temperature ?? 0.0,
maxTokens: this.defaultConfig?.maxTokens ?? 63488, maxTokens: this.defaultConfig?.maxTokens,
}, },
this.engines.createAdapter(), this.engines.createAdapter(),
this.toolRegistry, this.toolRegistry,
+95
View File
@@ -172,9 +172,101 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ }, { toolName: 'file_info', requiredLevel: PermissionLevel.READ },
]; ];
/**
* v0.8.1 P2-1: 用户自定义策略解析 ToolsSettings
*
* `tools.{toolName}.policy`JSON
* - deniedPatterns / allowedPatterns: string[]
* - maxFrequency: number/
* - requireConfirmation: boolean
* null退
* fail-closed
*/
export interface ParsedToolPolicy {
deniedPatterns?: RegExp[];
allowedPatterns?: RegExp[];
maxFrequency?: number;
requireConfirmation?: boolean;
}
export function parseToolPolicy(raw: unknown): ParsedToolPolicy | null {
let obj: unknown = raw;
if (typeof raw === 'string') {
try {
obj = JSON.parse(raw);
} catch {
return null;
}
}
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
const o = obj as Record<string, unknown>;
const compileList = (value: unknown): RegExp[] | undefined => {
if (value === undefined) return undefined;
if (!Array.isArray(value)) return undefined;
const compiled: RegExp[] = [];
for (const item of value.slice(0, 50)) {
if (typeof item !== 'string' || item.length === 0 || item.length > 500) continue;
try {
compiled.push(new RegExp(item));
} catch {
/* 非法正则跳过 */
}
}
return compiled;
};
const out: ParsedToolPolicy = {};
const denied = compileList(o.deniedPatterns);
if (denied) out.deniedPatterns = denied;
const allowed = compileList(o.allowedPatterns);
if (allowed) out.allowedPatterns = allowed;
if (typeof o.maxFrequency === 'number' && Number.isFinite(o.maxFrequency) && o.maxFrequency > 0) {
out.maxFrequency = Math.floor(o.maxFrequency);
}
if (typeof o.requireConfirmation === 'boolean') {
out.requireConfirmation = o.requireConfirmation;
}
return Object.keys(out).length > 0 ? out : null;
}
export class PolicyEngine { export class PolicyEngine {
private policies: Map<string, PermissionPolicy> = new Map(); private policies: Map<string, PermissionPolicy> = new Map();
/**
* v0.8.1 P2-1: 用户自定义策略覆盖层settings
* resolvePolicy
* deniedPatterns
*/
private policyOverrides: Map<string, PermissionPolicy> = new Map();
/** 设置/清除某工具的用户策略覆盖(null = 清除,回退默认策略) */
setPolicyOverride(toolName: string, override: ParsedToolPolicy | null): void {
if (!override) {
this.policyOverrides.delete(toolName);
return;
}
const base = this.policies.get(toolName);
this.policyOverrides.set(toolName, {
...(base ?? {
toolName,
requiredLevel: PermissionLevel.WRITE,
}),
toolName,
...override,
});
}
/** 获取某工具当前的覆盖策略(UI 回显用;无覆盖返回 null) */
getPolicyOverride(toolName: string): ParsedToolPolicy | null {
const o = this.policyOverrides.get(toolName);
if (!o) return null;
return {
deniedPatterns: o.deniedPatterns?.map((r) => r.source),
allowedPatterns: o.allowedPatterns?.map((r) => r.source),
maxFrequency: o.maxFrequency,
requireConfirmation: o.requireConfirmation,
} as unknown as ParsedToolPolicy;
}
/** /**
* v0.4.1: 工具调用频率追踪 key -> * v0.4.1: 工具调用频率追踪 key ->
* key : `${sessionId}:${toolName}` * key : `${sessionId}:${toolName}`
@@ -217,6 +309,9 @@ export class PolicyEngine {
* checkAuthorization requiresConfirmation * checkAuthorization requiresConfirmation
*/ */
private resolvePolicy(toolName: string): PermissionPolicy | undefined { private resolvePolicy(toolName: string): PermissionPolicy | undefined {
// v0.8.1 P2-1: 用户覆盖层最高优先级(settings 面板 → setPolicyOverride
const override = this.policyOverrides.get(toolName);
if (override) return override;
const exact = this.policies.get(toolName); const exact = this.policies.get(toolName);
if (exact) return exact; if (exact) return exact;
// C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具) // C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具)
+39 -4
View File
@@ -7,7 +7,7 @@
*/ */
import { resolve, sep } from 'path'; import { resolve, sep } from 'path';
import { existsSync, realpathSync } from 'fs'; import { lstatSync, realpathSync } from 'fs';
// v0.6.4 死代码清理:networkPolicy / resourceLimits 配置壳已删除。 // v0.6.4 死代码清理:networkPolicy / resourceLimits 配置壳已删除。
// 原字段被赋值后无任何方法消费(SandboxManager 没有进程沙箱执行器), // 原字段被赋值后无任何方法消费(SandboxManager 没有进程沙箱执行器),
@@ -43,7 +43,11 @@ export class SandboxManager {
const resolved = resolve(requestedPath); const resolved = resolve(requestedPath);
if (this.allowedPaths.size === 0) { if (this.allowedPaths.size === 0) {
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' }; return {
allowed: false,
resolvedPath: resolved,
reason: 'No allowed paths configured (fail-closed)',
};
} }
// 先做字符串级白名单校验 // 先做字符串级白名单校验
@@ -54,9 +58,40 @@ export class SandboxManager {
return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' }; return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' };
} }
// 解析符号链接(如果路径存在) // 解析符号链接(v0.8.1 根治:lstat 判定 —— 旧实现用 existsSync 前置判定,
if (existsSync(resolved)) { // 而 existsSync 跟随链接目标:悬空 symlink(目标不存在)会跳过 realpath
// 校验整体放行,构成白名单逃逸 —— 写操作可在白名单外创建目标文件)。
// 现契约:lstat 判定条目存在性(不跟随目标);符号链接一律 realpath 解析,
// 悬空链接(realpath ENOENTfail-closed 拒绝;普通条目维持既有 realpath 复核。
let st: ReturnType<typeof lstatSync> | null = null;
try {
st = lstatSync(resolved);
} catch {
st = null; // 条目不存在 → 允许(新建文件场景,行为不变)
}
if (st) {
try { try {
if (st.isSymbolicLink()) {
// 悬空 symlinkrealpathSync 抛 ENOENT → 显式拒绝(非偶然 catch)
let realPath: string;
try {
realPath = realpathSync(resolved);
} catch {
return {
allowed: false,
resolvedPath: resolved,
reason: 'Dangling symlink target outside workspace',
};
}
const realAllowed = Array.from(this.allowedPaths).some(
(allowed) => realPath === allowed || realPath.startsWith(allowed + sep),
);
if (!realAllowed) {
return { allowed: false, resolvedPath: realPath, reason: 'Symlink escape detected' };
}
return { allowed: true, resolvedPath: realPath };
}
// 普通条目:realpath 复核父级链接逃逸(既有行为)
const realPath = realpathSync(resolved); const realPath = realpathSync(resolved);
const realAllowed = Array.from(this.allowedPaths).some( const realAllowed = Array.from(this.allowedPaths).some(
(allowed) => realPath === allowed || realPath.startsWith(allowed + sep), (allowed) => realPath === allowed || realPath.startsWith(allowed + sep),
+30 -9
View File
@@ -16,14 +16,24 @@ import type { MemoryManager } from '../../memory/manager';
export class MemoryStoreTool implements IMetonaTool { export class MemoryStoreTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'memory_store', name: 'memory_store',
description: 'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.', description:
'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
content: { type: 'string', description: 'The memory content to store' }, content: { type: 'string', description: 'The memory content to store' },
type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] }, type: {
type: 'string',
description:
'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)',
enum: ['episodic', 'semantic', 'working'],
},
importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' }, importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' },
source: { type: 'string', description: 'Source of the memory', enum: ['user_input', 'tool_result', 'agent_thought', 'imported'] }, source: {
type: 'string',
description: 'Source of the memory',
enum: ['user_input', 'tool_result', 'agent_thought', 'imported'],
},
}, },
required: ['content', 'type'], required: ['content', 'type'],
}, },
@@ -39,7 +49,9 @@ export class MemoryStoreTool implements IMetonaTool {
const content = args.content as string; const content = args.content as string;
const type = args.type as 'episodic' | 'semantic' | 'working'; const type = args.type as 'episodic' | 'semantic' | 'working';
const importance = (args.importance as number) ?? 0.5; const importance = (args.importance as number) ?? 0.5;
const source = (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? 'agent_thought'; const source =
(args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ??
'agent_thought';
// v0.3.0 修复: store() 是同步方法,移除多余的 await 避免误导维护者 // v0.3.0 修复: store() 是同步方法,移除多余的 await 避免误导维护者
const id = this.memoryManager.store({ const id = this.memoryManager.store({
@@ -59,14 +71,23 @@ export class MemoryStoreTool implements IMetonaTool {
export class MemorySearchTool implements IMetonaTool { export class MemorySearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'memory_search', name: 'memory_search',
description: 'Search persistent memory for relevant information. Returns memories sorted by relevance.', description:
'Search persistent memory for relevant information. Returns memories sorted by relevance.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
query: { type: 'string', description: 'Search query or keywords' }, query: { type: 'string', description: 'Search query or keywords' },
type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] }, type: {
type: 'string',
description: 'Filter by memory type',
enum: ['episodic', 'semantic', 'working'],
},
topK: { type: 'number', description: 'Number of results (default 5)' }, topK: { type: 'number', description: 'Number of results (default 5)' },
threshold: { type: 'number', description: 'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.' }, threshold: {
type: 'number',
description:
'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.',
},
}, },
required: ['query'], required: ['query'],
}, },
@@ -84,8 +105,8 @@ export class MemorySearchTool implements IMetonaTool {
const topK = (args.topK as number) ?? 5; const topK = (args.topK as number) ?? 5;
const threshold = (args.threshold as number) ?? 0.7; const threshold = (args.threshold as number) ?? 0.7;
// v0.3.0 修复: search() 是同步方法,移除多余的 await 避免误导维护者 // v0.8.1: search() 升级为 async(向量混合检索),查询向量异步生成
const results = this.memoryManager.search(query, { const results = await this.memoryManager.search(query, {
topK, topK,
type, type,
minImportance: threshold, minImportance: threshold,
+1 -1
View File
@@ -127,7 +127,7 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
logSessionEnd: vi.fn(), logSessionEnd: vi.fn(),
log: vi.fn(), log: vi.fn(),
}, },
memoryManager: { search: vi.fn(() => []) }, memoryManager: { search: vi.fn(() => []), clearWorkingMemory: vi.fn() },
promptInjectionDefender: { promptInjectionDefender: {
detect: vi.fn(() => ({ detect: vi.fn(() => ({
isInjection: false, isInjection: false,
@@ -0,0 +1,80 @@
/**
* Replay Buffer v0.8.1 P0-3
*
* /truncated runId
* TERMINATED +
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
appendReplay,
resetReplayBuffer,
markReplayTerminated,
clearReplayBuffer,
getReplayBufferData,
} from '../replay-buffer';
describe('replay-buffer — 有界回放缓冲(v0.8.1 P0-3', () => {
beforeEach(() => {
clearReplayBuffer('s1');
});
it('append + get:按序返回事件与 runId', () => {
appendReplay('s1', { channel: 'stateChange', payload: { runId: 'run_a' }, ts: 1 });
appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 });
const data = getReplayBufferData('s1');
expect(data.events).toHaveLength(2);
expect(data.events[0].channel).toBe('stateChange');
expect(data.runId).toBe('run_a');
expect(data.truncated).toBe(false);
});
it('条数上限:超过 2000 条丢弃最旧并置 truncated', () => {
for (let i = 0; i < 2005; i++) {
appendReplay('s1', { channel: 'streamEvent', payload: { i }, ts: i });
}
const data = getReplayBufferData('s1');
expect(data.events.length).toBeLessThanOrEqual(2000);
expect(data.truncated).toBe(true);
// 最旧的已被丢弃
expect((data.events[0].payload as { i: number }).i).toBeGreaterThan(0);
});
it('字节上限:单条 1MB 的超大 payload 被丢弃并置 truncated', () => {
const big = 'x'.repeat(1024 * 1024);
for (let i = 0; i < 6; i++) {
appendReplay('s1', { channel: 'streamEvent', payload: { big }, ts: i });
}
const data = getReplayBufferData('s1');
expect(data.truncated).toBe(true);
// 缓冲字节被控制在 4MB 上限附近(至少留最后一条)
expect(data.events.length).toBeGreaterThanOrEqual(1);
expect(data.events.length).toBeLessThan(6);
});
it('TERMINATED 保留缓冲;新内容到达兜底重置', () => {
appendReplay('s1', { channel: 'stateChange', payload: { state: 'TERMINATED' }, ts: 1 });
markReplayTerminated('s1');
// 完成后仍可回放最终内容
expect(getReplayBufferData('s1').events).toHaveLength(1);
// 下一次 run 的新内容 → 缓冲重置
appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 });
const data = getReplayBufferData('s1');
expect(data.events).toHaveLength(1);
expect(data.truncated).toBe(false);
});
it('INIT 重置:清除上一 run 的缓冲', () => {
appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 });
resetReplayBuffer('s1');
expect(getReplayBufferData('s1').events).toHaveLength(0);
});
it('clearReplayBuffer:终态清除(会话删除联动)', () => {
appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 });
clearReplayBuffer('s1');
const data = getReplayBufferData('s1');
expect(data.events).toHaveLength(0);
expect(data.runId).toBeNull();
});
});
@@ -115,7 +115,8 @@ describe('sessions 域 — 参数校验矩阵', () => {
expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]); expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]);
await getHandler('sessions:getMessages')(null, 's1'); await getHandler('sessions:getMessages')(null, 's1');
expect(svc.getMessages).toHaveBeenCalledWith('s1'); // v0.8.1 P2-3: 未传选项时以 undefined 透传(全量语义向后兼容)
expect(svc.getMessages).toHaveBeenCalledWith('s1', undefined);
}); });
it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => { it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => {
@@ -149,6 +150,8 @@ describe('sessions:clearMessages — v0.7.2 A1 语义修正', () => {
function makeCtx(): IPCContext { function makeCtx(): IPCContext {
return { return {
sessionService: { clearMessages: vi.fn() }, sessionService: { clearMessages: vi.fn() },
// v0.8.1 P0-2: clearMessages 联动清理工作记忆
memoryManager: { clearWorkingMemory: vi.fn() },
} as unknown as IPCContext; } as unknown as IPCContext;
} }
@@ -442,6 +445,8 @@ describe('sessions 域 — 补充校验矩阵', () => {
disposeEngine: vi.fn(), disposeEngine: vi.fn(),
}, },
confirmationHook: { forgetSession: vi.fn() }, confirmationHook: { forgetSession: vi.fn() },
// v0.8.1 P0-2: delete/purge/clearMessages 联动清理工作记忆
memoryManager: { clearWorkingMemory: vi.fn() },
}; };
return { ctx: raw as unknown as IPCContext, raw }; return { ctx: raw as unknown as IPCContext, raw };
} }
+58 -103
View File
@@ -29,6 +29,8 @@ import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompt
// v0.7.3 P1-5: 记忆固化触发决策(纯函数) // v0.7.3 P1-5: 记忆固化触发决策(纯函数)
import { shouldConsolidate } from '../harness/memory/consolidation-policy'; import { shouldConsolidate } from '../harness/memory/consolidation-policy';
import log from 'electron-log'; import log from 'electron-log';
// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动)
import { mt } from '../utils/main-locale';
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */ /** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
function buildTimezoneLabel(): string { function buildTimezoneLabel(): string {
@@ -67,34 +69,14 @@ interface IterationTrace {
finishReason?: string; finishReason?: string;
} }
/** // v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除
* v0.8.0 P1-1a: 单会话的流式回放缓冲 // 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留
* streamEvent + stateChange import {
* agent:getReplayState 线 appendReplay,
* "后台会话运行内容不丢失" resetReplayBuffer,
* markReplayTerminated,
* MAX_REPLAY_EVENTS / MAX_REPLAY_BYTES getReplayBufferData,
* truncated TERMINATED "完成后切回" } from './replay-buffer';
* run INIT stateChange
*/
interface ReplayEntry {
channel: 'streamEvent' | 'stateChange';
payload: unknown;
ts: number;
}
interface ReplayBuffer {
events: ReplayEntry[];
bytes: number;
truncated: boolean;
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
runId?: string;
terminated: boolean;
}
const MAX_REPLAY_EVENTS = 2000;
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
const MAX_REPLAY_SESSIONS = 50;
export function registerAgentHandlers(ctx: IPCContext): void { export function registerAgentHandlers(ctx: IPCContext): void {
const { const {
@@ -118,54 +100,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// ===== 常驻事件管道:text_delta 按会话节流(F8 ===== // ===== 常驻事件管道:text_delta 按会话节流(F8 =====
const throttleStates = new Map<string, ThrottleState>(); const throttleStates = new Map<string, ThrottleState>();
const iterationTraces = new Map<string, IterationTrace>(); const iterationTraces = new Map<string, IterationTrace>();
// v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复)
const replayBuffers = new Map<string, ReplayBuffer>();
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */ // v0.8.1 review: MEMORY.md 体积告警去重(每会话一次)
const appendReplay = (sessionId: string, entry: ReplayEntry): void => { const memoryOversizeWarned = new Set<string>();
let buf = replayBuffers.get(sessionId);
if (!buf) {
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
const oldest = replayBuffers.keys().next().value as string | undefined;
if (oldest !== undefined) replayBuffers.delete(oldest);
}
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
if (buf.terminated) {
replayBuffers.delete(sessionId);
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
let size = 0;
try {
size = JSON.stringify(entry.payload).length;
} catch {
size = 256; // 序列化失败按保守值计入
}
buf.events.push(entry);
buf.bytes += size;
while (
buf.events.length > MAX_REPLAY_EVENTS ||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
) {
const dropped = buf.events.shift();
buf.truncated = true;
if (dropped) {
try {
buf.bytes -= JSON.stringify(dropped.payload).length;
} catch {
buf.bytes -= 256;
}
}
}
// 记录 runId(首个携带者)
const payload = entry.payload as { runId?: string } | null;
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
};
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方) // v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。 // v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
@@ -340,12 +277,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (sessionId) { if (sessionId) {
const stateRaw = data.state ?? data.current ?? ''; const stateRaw = data.state ?? data.current ?? '';
if (stateRaw === 'INIT') { if (stateRaw === 'INIT') {
replayBuffers.delete(sessionId); resetReplayBuffer(sessionId);
} else { } else {
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() }); appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
if (stateRaw === 'TERMINATED') { if (stateRaw === 'TERMINATED') {
const buf = replayBuffers.get(sessionId); markReplayTerminated(sessionId);
if (buf) buf.terminated = true;
} }
} }
} }
@@ -434,7 +370,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// toast 通知用户压缩已发生 // toast 通知用户压缩已发生
broadcast('toast:show', { broadcast('toast:show', {
type: 'info', type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`, message: mt('agent.toast.compressed', {
original: data.originalTokens ?? '?',
compressed: data.compressedTokens ?? '?',
saved: savedTokens,
}),
}); });
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store // 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
broadcast('agent:streamEvent', { broadcast('agent:streamEvent', {
@@ -454,7 +394,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`); log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
broadcast('toast:show', { broadcast('toast:show', {
type: 'warning', type: 'warning',
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`, message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }),
}); });
}); });
@@ -470,7 +410,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}); });
broadcast('toast:show', { broadcast('toast:show', {
type: 'warning', type: 'warning',
message: `Provider 故障转移: ${data.from ?? '?'}${data.to ?? '?'}(主 Provider 请求失败)`, message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }),
}); });
}, },
); );
@@ -498,7 +438,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (!sessionId || typeof sessionId !== 'string') { if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId'); log.warn('[AGENT] sendMessage rejected: invalid sessionId');
sendErrorEvent('无效的会话 ID', sessionId ?? ''); sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? '');
return { success: false, error: 'Invalid sessionId' }; return { success: false, error: 'Invalid sessionId' };
} }
if ( if (
@@ -507,7 +447,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
typeof userMessage.content !== 'string' typeof userMessage.content !== 'string'
) { ) {
log.warn('[AGENT] sendMessage rejected: invalid userMessage'); log.warn('[AGENT] sendMessage rejected: invalid userMessage');
sendErrorEvent('无效的消息格式', sessionId); sendErrorEvent(mt('agent.error.invalidMessage'), sessionId);
return { success: false, error: 'Invalid message format' }; return { success: false, error: 'Invalid message format' };
} }
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80)); log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
@@ -517,7 +457,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并 // 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
// 广播明确错误事件,前端 isStreaming 正常收尾。 // 广播明确错误事件,前端 isStreaming 正常收尾。
if (agentEngineManager.isRunning(sessionId)) { if (agentEngineManager.isRunning(sessionId)) {
const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送'; const busyMsg = mt('agent.error.sessionBusy');
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`); log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
sendErrorEvent(busyMsg, sessionId); sendErrorEvent(busyMsg, sessionId);
return { success: false, error: busyMsg }; return { success: false, error: busyMsg };
@@ -529,7 +469,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。 // 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
const sessionExists = sessionService.getSession(sessionId) != null; const sessionExists = sessionService.getSession(sessionId) != null;
if (!sessionExists) { if (!sessionExists) {
const missingMsg = '会话不存在或已被删除,请刷新后重试'; const missingMsg = mt('agent.error.sessionMissing');
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`); log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
sendErrorEvent(missingMsg, sessionId); sendErrorEvent(missingMsg, sessionId);
await sessionRecorder.stopRecording(sessionId, { await sessionRecorder.stopRecording(sessionId, {
@@ -543,8 +483,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送) // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!ctx.reloadAdapter()) { if (!ctx.reloadAdapter()) {
const errorMsg = const errorMsg = mt('agent.error.adapterLoadFailed');
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
log.error('[AGENT]', errorMsg); log.error('[AGENT]', errorMsg);
sendErrorEvent(errorMsg, sessionId); sendErrorEvent(errorMsg, sessionId);
await sessionRecorder.stopRecording(sessionId, { await sessionRecorder.stopRecording(sessionId, {
@@ -593,8 +532,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (contextBuilder.isUsingFallbackRole()) { if (contextBuilder.isUsingFallbackRole()) {
broadcast('toast:show', { broadcast('toast:show', {
type: 'info', type: 'info',
message: message: mt('agent.soul.fallbackToast'),
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
}); });
} }
@@ -603,7 +541,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入 // 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
// LLM 语义等价),system prompt 保持跨 run 字节级稳定。 // LLM 语义等价),system prompt 保持跨 run 字节级稳定。
try { try {
const memories = memoryManager.search(userMessage.content, { // v0.8.1: search 升级 async(向量混合检索)
const memories = await memoryManager.search(userMessage.content, {
topK: 5, topK: 5,
minImportance: 0.3, minImportance: 0.3,
}); });
@@ -850,7 +789,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
); );
broadcast('toast:show', { broadcast('toast:show', {
type: 'info', type: 'info',
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`, message: mt('agent.toast.consolidated', { count: result.appended }),
}); });
} }
}) })
@@ -861,6 +800,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`); log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
} }
// v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后
// 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警
// 一次,避免连续消息刷屏。
const memorySize = (workspaceService.getFiles().memory ?? '').length;
if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) {
memoryOversizeWarned.add(sessionId);
broadcast('toast:show', {
type: 'warning',
message: mt('agent.toast.memoryOversize'),
});
}
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默) // v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
if (output.terminationReason === 'completed') { if (output.terminationReason === 'completed') {
titleGenerator titleGenerator
@@ -955,7 +906,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
} }
const balance = await adapter.getBalance(); const balance = await adapter.getBalance();
if (!balance) { if (!balance) {
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' }; return { success: false, error: mt('llm.balance.queryFailed') };
} }
return { success: true, data: balance }; return { success: true, data: balance };
} catch (error) { } catch (error) {
@@ -975,18 +926,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
const model = configService.get<string>('llm.model') ?? ''; const model = configService.get<string>('llm.model') ?? '';
const apiKey = configService.get<string>('llm.apiKey') || ''; const apiKey = configService.get<string>('llm.apiKey') || '';
if (!provider || !model) { if (!provider || !model) {
return { success: false, error: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' }; return { success: false, error: mt('llm.listModels.notConfigured') };
} }
if (!apiKey && provider !== 'ollama') { if (!apiKey && provider !== 'ollama') {
return { success: false, error: 'API Key 未配置,无法获取模型列表' }; return { success: false, error: mt('llm.listModels.noApiKey') };
} }
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op // 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op
if (!ctx.reloadAdapter()) { if (!ctx.reloadAdapter()) {
return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' }; return { success: false, error: mt('llm.listModels.configInvalid') };
} }
const adapter = agentEngineManager.getAdapter(); const adapter = agentEngineManager.getAdapter();
if (!adapter.listModels) { if (!adapter.listModels) {
return { success: false, error: '当前 Provider 不支持模型列表查询' }; return { success: false, error: mt('llm.listModels.unsupported') };
} }
const models = await adapter.listModels(); const models = await adapter.listModels();
return { success: true, data: models }; return { success: true, data: models };
@@ -1014,10 +965,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
} }
const adapter = agentEngineManager.getAdapter(); const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof OllamaAdapter)) { if (!(adapter instanceof OllamaAdapter)) {
return { success: false, error: '仅 Ollama Provider 支持模型下载' }; return { success: false, error: mt('llm.pull.ollamaOnly') };
} }
if (ollamaPullController) { if (ollamaPullController) {
return { success: false, error: '已有模型下载任务进行中,请先取消' }; return { success: false, error: mt('llm.pull.inProgress') };
} }
const controller = new AbortController(); const controller = new AbortController();
ollamaPullController = controller; ollamaPullController = controller;
@@ -1045,7 +996,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
ipcMain.handle('llm:ollamaPullCancel', async () => { ipcMain.handle('llm:ollamaPullCancel', async () => {
if (!ollamaPullController) { if (!ollamaPullController) {
return { success: false, error: '没有进行中的下载任务' }; return { success: false, error: mt('llm.pull.none') };
} }
ollamaPullController.abort(); ollamaPullController.abort();
return { success: true }; return { success: true };
@@ -1071,6 +1022,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目 // 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
for (const taskId of abortedTaskIds) { for (const taskId of abortedTaskIds) {
confirmationHook.forgetSession(taskId); confirmationHook.forgetSession(taskId);
// v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理
memoryManager.clearWorkingMemory(taskId);
subTraces.delete(taskId); subTraces.delete(taskId);
subMeta.delete(taskId); subMeta.delete(taskId);
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止) // 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
@@ -1104,14 +1057,14 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (typeof sessionId !== 'string' || !sessionId) { if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' }; return { success: false, error: 'Invalid sessionId' };
} }
const buf = replayBuffers.get(sessionId); const data = getReplayBufferData(sessionId);
return { return {
success: true, success: true,
data: { data: {
isRunning: agentEngineManager.isRunning(sessionId), isRunning: agentEngineManager.isRunning(sessionId),
runId: buf?.runId ?? null, runId: data.runId,
truncated: buf?.truncated ?? false, truncated: data.truncated,
events: buf?.events ?? [], events: data.events,
}, },
}; };
}); });
@@ -1151,6 +1104,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
subMeta.delete(taskId); subMeta.delete(taskId);
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏) // v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
confirmationHook.forgetSession(taskId); confirmationHook.forgetSession(taskId);
// v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入)
memoryManager.clearWorkingMemory(taskId);
}; };
orchestrator.on( orchestrator.on(
+26
View File
@@ -122,6 +122,32 @@ export function registerAppHandlers(ctx: IPCContext): void {
return { canceled: false, path: result.filePaths[0] }; return { canceled: false, path: result.filePaths[0] };
}); });
// ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings,跨平台尽力语义)=====
ipcMain.handle('app:setLoginItem', async (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled flag' };
}
try {
app.setLoginItemSettings({ openAtLogin: enabled });
// 设置后回读真实状态(Linux 等 setLoginItemSettings 不可用平台保持 false
const actual = app.getLoginItemSettings().openAtLogin;
if (enabled && !actual) {
log.warn('[APP] setLoginItemSettings not effective on this platform (Linux?)');
}
return { success: true, data: { openAtLogin: actual } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:getLoginItem', async () => {
try {
return { success: true, data: { openAtLogin: app.getLoginItemSettings().openAtLogin } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 重启应用(工作空间切换后调用) // 重启应用(工作空间切换后调用)
ipcMain.handle('app:restart', async () => { ipcMain.handle('app:restart', async () => {
log.info('[APP] Restart requested'); log.info('[APP] Restart requested');
+6
View File
@@ -13,6 +13,7 @@ import type { WorkspaceService } from '../services/workspace.service';
import type { ContextBuilder } from '../harness/prompts/context-builder'; import type { ContextBuilder } from '../harness/prompts/context-builder';
import type { AgentEngineManager } from '../services/agent-engine-manager.service'; import type { AgentEngineManager } from '../services/agent-engine-manager.service';
import type { ToolRegistry } from '../harness/tools/registry'; import type { ToolRegistry } from '../harness/tools/registry';
import type { PolicyEngine } from '../harness/sandbox/permissions';
import type { AuditService } from '../services/audit.service'; import type { AuditService } from '../services/audit.service';
import type { SessionRecorder } from '../services/session-recorder.service'; import type { SessionRecorder } from '../services/session-recorder.service';
import type { MemoryManager } from '../harness/memory/manager'; import type { MemoryManager } from '../harness/memory/manager';
@@ -21,6 +22,7 @@ import type { PromptInjectionDefender } from '../harness/security/prompt-injecti
import type { OutputValidator } from '../harness/verification/output-validator'; import type { OutputValidator } from '../harness/verification/output-validator';
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook'; import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
import type { MemoryConsolidator } from '../harness/memory/consolidator'; import type { MemoryConsolidator } from '../harness/memory/consolidator';
import type { MemoryMaintainer } from '../harness/memory/maintainer';
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator'; import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
import type { SessionSummaryService } from '../services/session-summary.service'; import type { SessionSummaryService } from '../services/session-summary.service';
import type { TitleGenerator } from '../services/title-generator.service'; import type { TitleGenerator } from '../services/title-generator.service';
@@ -49,6 +51,8 @@ export interface IPCContext {
contextBuilder: ContextBuilder; contextBuilder: ContextBuilder;
agentEngineManager: AgentEngineManager; agentEngineManager: AgentEngineManager;
toolRegistry: ToolRegistry; toolRegistry: ToolRegistry;
/** v0.8.1 P2-1: 策略引擎(tools.{name}.policy 用户自定义策略热加载) */
policyEngine: PolicyEngine;
auditService: AuditService; auditService: AuditService;
sessionRecorder: SessionRecorder; sessionRecorder: SessionRecorder;
memoryManager: MemoryManager; memoryManager: MemoryManager;
@@ -58,6 +62,8 @@ export interface IPCContext {
outputValidator: OutputValidator; outputValidator: OutputValidator;
confirmationHook: ConfirmationHook; confirmationHook: ConfirmationHook;
memoryConsolidator: MemoryConsolidator; memoryConsolidator: MemoryConsolidator;
/** v0.8.1 P1-2: MEMORY.md 维护闭环(memory:analyzeMaintenance / memory:applyMaintenance */
memoryMaintainer: MemoryMaintainer;
orchestrator: TaskOrchestrator; orchestrator: TaskOrchestrator;
sessionSummaryService: SessionSummaryService; sessionSummaryService: SessionSummaryService;
/** v0.7.3 P4-1: 会话标题生成器 */ /** v0.7.3 P4-1: 会话标题生成器 */
+3
View File
@@ -107,6 +107,9 @@ export function registerDataHandlers(ctx: IPCContext): void {
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number }; const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
db.exec('DELETE FROM messages'); db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions'); db.exec('DELETE FROM sessions');
// v0.8.1 P0-2: working_memories 无 sessions 外键(历史 schema),此处显式清理
// 防止全量清空后工作记忆成为无主孤数据
db.exec('DELETE FROM working_memories');
db.exec('COMMIT'); db.exec('COMMIT');
log.info( log.info(
`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`, `[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`,
+56
View File
@@ -152,4 +152,60 @@ export function registerMCPHandlers(ctx: IPCContext): void {
} }
return { success: true, data: mcpManager.getServerContents(name) }; return { success: true, data: mcpManager.getServerContents(name) };
}); });
// ===== v0.8.1 P1-4: Prompt 渲染(prompts/getChatInput 斜杠菜单消费) =====
ipcMain.handle(
'mcp:getPrompt',
async (_event, serverName: unknown, promptName: unknown, args: unknown) => {
if (typeof serverName !== 'string' || !serverName.trim()) {
return { success: false, error: 'Invalid server name' };
}
if (typeof promptName !== 'string' || !promptName.trim()) {
return { success: false, error: 'Invalid prompt name' };
}
// args 可选,必须是 string→string 扁平对象(MCP prompts/get arguments 契约)
let promptArgs: Record<string, string> | undefined;
if (args !== undefined && args !== null) {
if (!args || typeof args !== 'object' || Array.isArray(args)) {
return { success: false, error: 'Invalid arguments' };
}
promptArgs = {};
for (const [k, v] of Object.entries(args as Record<string, unknown>)) {
if (typeof v !== 'string') {
return { success: false, error: `Argument "${k.slice(0, 40)}" must be a string` };
}
promptArgs[k] = v;
}
}
try {
const result = await mcpManager.getPrompt(serverName, promptName, promptArgs);
return { success: true, data: result };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
);
// ===== v0.8.1 P1-4: Resource 读取(resources/read@mcp 提及消费;512KB 上限) =====
const MAX_RESOURCE_BYTES = 512 * 1024;
ipcMain.handle('mcp:readResource', async (_event, serverName: unknown, uri: unknown) => {
if (typeof serverName !== 'string' || !serverName.trim()) {
return { success: false, error: 'Invalid server name' };
}
if (typeof uri !== 'string' || !uri.trim()) {
return { success: false, error: 'Invalid resource uri' };
}
try {
const result = await mcpManager.readResource(serverName, uri);
if (result && result.text.length > MAX_RESOURCE_BYTES) {
return {
success: true,
data: { text: result.text.slice(0, MAX_RESOURCE_BYTES), truncated: true },
};
}
return { success: true, data: { text: result?.text ?? '', truncated: false } };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
} }
+110 -12
View File
@@ -5,11 +5,72 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import type { IPCContext } from './context'; import type { IPCContext } from './context';
import type { MemoryType } from '../harness/memory/manager'; import type { MemoryType } from '../harness/memory/manager';
import type { MemoryMaintenanceAction } from '../harness/memory/maintainer';
import log from 'electron-log';
const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working']; const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working'];
export function registerMemoryHandlers(ctx: IPCContext): void { export function registerMemoryHandlers(ctx: IPCContext): void {
const { memoryManager, sessionService } = ctx; const { memoryManager, sessionService, memoryMaintainer, auditService } = ctx;
// ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段) =====
// 阶段一:LLM 分析当前 MEMORY.md → 结构化维护建议(不改任何文件/DB)
ipcMain.handle('memory:analyzeMaintenance', async () => {
try {
const proposal = await memoryMaintainer.analyze();
return { success: true, data: proposal };
} catch (error) {
log.warn('[IPC] memory:analyzeMaintenance failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
// 阶段二:应用用户确认(勾选)后的动作 —— 精确匹配校验 + 重写 MEMORY.md +
// 同步 semantic_memories + 审计留痕
ipcMain.handle('memory:applyMaintenance', async (_event, actions: unknown) => {
if (!Array.isArray(actions)) {
return { success: false, error: 'Invalid actions: must be an array' };
}
// 结构校验:只透传合法字段,其余拒绝
const valid: MemoryMaintenanceAction[] = [];
for (const item of actions.slice(0, 30)) {
if (!item || typeof item !== 'object') continue;
const a = item as Record<string, unknown>;
if (a.action !== 'delete' && a.action !== 'update') continue;
if (typeof a.section !== 'string' || typeof a.entry !== 'string') continue;
if (a.action === 'update' && typeof a.newEntry !== 'string') continue;
valid.push({
action: a.action,
section: a.section,
entry: a.entry,
newEntry: typeof a.newEntry === 'string' ? a.newEntry : undefined,
reason: typeof a.reason === 'string' ? a.reason : undefined,
});
}
try {
const result = memoryMaintainer.apply(valid);
auditService.log({
sessionId: '',
eventType: 'tool_call',
actor: 'user',
target: 'memory_maintenance',
details: { requested: valid.length, applied: result.applied, skipped: result.skipped },
outcome: 'success',
});
return { success: true, data: result };
} catch (error) {
auditService.log({
sessionId: '',
eventType: 'error',
actor: 'user',
target: 'memory_maintenance',
details: { error: (error as Error).message },
outcome: 'error',
});
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => { ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
// M-37 修复: query 和 options 校验 // M-37 修复: query 和 options 校验
@@ -17,7 +78,12 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
return []; return [];
} }
// 构造合法的搜索选项(仅保留已知字段,强制类型安全) // 构造合法的搜索选项(仅保留已知字段,强制类型安全)
const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {}; const searchOptions: {
topK?: number;
sessionId?: string;
type?: MemoryType;
minImportance?: number;
} = {};
if (options && typeof options === 'object') { if (options && typeof options === 'object') {
const opts = options as Record<string, unknown>; const opts = options as Record<string, unknown>;
// topK 限制范围 1-100(防止过大查询) // topK 限制范围 1-100(防止过大查询)
@@ -26,12 +92,15 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
if (Number.isFinite(topK) && topK >= 1 && topK <= 100) { if (Number.isFinite(topK) && topK >= 1 && topK <= 100) {
searchOptions.topK = topK; searchOptions.topK = topK;
} else { } else {
searchOptions.topK = 10; // 默认值 searchOptions.topK = 10; // 默认值
} }
} }
if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId; if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId;
// type 必须是合法的 MemoryType 枚举值 // type 必须是合法的 MemoryType 枚举值
if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) { if (
typeof opts.type === 'string' &&
(VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)
) {
searchOptions.type = opts.type as MemoryType; searchOptions.type = opts.type as MemoryType;
} }
if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) { if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) {
@@ -52,7 +121,10 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
const opts = options as Record<string, unknown>; const opts = options as Record<string, unknown>;
if (opts.type !== undefined) { if (opts.type !== undefined) {
if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) { if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` }; return {
success: false,
error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`,
};
} }
type = opts.type; type = opts.type;
} }
@@ -69,16 +141,34 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
const results: Record<string, unknown[]> = {}; const results: Record<string, unknown[]> = {};
try { try {
if (!type || type === 'episodic') { if (!type || type === 'episodic') {
const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>; const rows = db
.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?')
.all(limit) as Array<Record<string, unknown>>;
results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' })); results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' }));
} }
if (!type || type === 'semantic') { if (!type || type === 'semantic') {
const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>; const rows = db
results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at })); .prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?')
.all(limit) as Array<Record<string, unknown>>;
results.semantic = rows.map((r) => ({
...r,
type: 'semantic',
content: r.value ?? r.key ?? '',
importance: r.confidence ?? 0,
created_at: r.created_at ?? r.updated_at,
}));
} }
if (!type || type === 'working') { if (!type || type === 'working') {
const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>; const rows = db
results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() })); .prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?')
.all(limit) as Array<Record<string, unknown>>;
results.working = rows.map((r) => ({
...r,
type: 'working',
content: r.value ?? r.key ?? '',
importance: 0.5,
created_at: r.updated_at ?? Date.now(),
}));
} }
return { success: true, data: results }; return { success: true, data: results };
} catch (error) { } catch (error) {
@@ -89,14 +179,22 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => { ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => {
// M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型 // M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型
if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) { if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` }; return {
success: false,
error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`,
};
} }
if (typeof id !== 'string' || !id) { if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid memory id' }; return { success: false, error: 'Invalid memory id' };
} }
const db = sessionService.getDB(); const db = sessionService.getDB();
try { try {
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories'; const table =
type === 'episodic'
? 'episodic_memories'
: type === 'semantic'
? 'semantic_memories'
: 'working_memories';
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id); db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
+112
View File
@@ -0,0 +1,112 @@
/**
* Replay Buffer v0.8.0 P1-1a v0.8.1 P0-3
*
*
* streamEvent + stateChange
* agent:getReplayState 线"后台会话运行内容不丢失"
*
* v0.8.1 P0-3 ipc/agent.ts Map /
* 50 LRU 4MB/
* sessions:delete / sessions:purge
*
* MAX_REPLAY_EVENTS / MAX_REPLAY_BYTES
* truncated TERMINATED "完成后切回"
* run INIT stateChange
*/
const MAX_REPLAY_EVENTS = 2000;
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
const MAX_REPLAY_SESSIONS = 50;
export interface ReplayEntry {
channel: 'streamEvent' | 'stateChange';
payload: unknown;
ts: number;
}
export interface ReplayBuffer {
events: ReplayEntry[];
bytes: number;
truncated: boolean;
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
runId?: string;
terminated: boolean;
}
const replayBuffers = new Map<string, ReplayBuffer>();
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
export function appendReplay(sessionId: string, entry: ReplayEntry): void {
let buf = replayBuffers.get(sessionId);
if (!buf) {
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
const oldest = replayBuffers.keys().next().value as string | undefined;
if (oldest !== undefined) replayBuffers.delete(oldest);
}
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
if (buf.terminated) {
replayBuffers.delete(sessionId);
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
let size = 0;
try {
size = JSON.stringify(entry.payload).length;
} catch {
size = 256; // 序列化失败按保守值计入
}
buf.events.push(entry);
buf.bytes += size;
while (
buf.events.length > MAX_REPLAY_EVENTS ||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
) {
const dropped = buf.events.shift();
buf.truncated = true;
if (dropped) {
try {
buf.bytes -= JSON.stringify(dropped.payload).length;
} catch {
buf.bytes -= 256;
}
}
}
// 记录 runId(首个携带者)
const payload = entry.payload as { runId?: string } | null;
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
}
/** run 启动(INIT)时清空上一 run 的缓冲 */
export function resetReplayBuffer(sessionId: string): void {
replayBuffers.delete(sessionId);
}
/** TERMINATED 时置终态标记(保留缓冲供"完成后切回"回放最终内容) */
export function markReplayTerminated(sessionId: string): void {
const buf = replayBuffers.get(sessionId);
if (buf) buf.terminated = true;
}
/** v0.8.1 P0-3: 会话终态(删除/彻底删除)时清除缓冲,杜绝内存滞留 */
export function clearReplayBuffer(sessionId: string): void {
replayBuffers.delete(sessionId);
}
/** 拉取会话回放状态(agent:getReplayState 消费) */
export function getReplayBufferData(sessionId: string): {
runId: string | null;
truncated: boolean;
events: ReplayEntry[];
} {
const buf = replayBuffers.get(sessionId);
return {
runId: buf?.runId ?? null,
truncated: buf?.truncated ?? false,
events: buf?.events ?? [],
};
}
+32 -2
View File
@@ -9,6 +9,8 @@ import { ipcMain } from 'electron';
import { join, resolve as resolvePath, sep } from 'path'; import { join, resolve as resolvePath, sep } from 'path';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import type { IPCContext } from './context'; import type { IPCContext } from './context';
// v0.8.1 P0-3: 会话终态清除流式回放缓冲
import { clearReplayBuffer } from './replay-buffer';
/** M-34 修复: 统一 sessionId 校验辅助函数 */ /** M-34 修复: 统一 sessionId 校验辅助函数 */
const isValidSessionId = (id: unknown): id is string => const isValidSessionId = (id: unknown): id is string =>
@@ -54,6 +56,11 @@ export function registerSessionHandlers(ctx: IPCContext): void {
// (防 rememberedDecisions 随会话数累积泄漏) // (防 rememberedDecisions 随会话数累积泄漏)
if (result.success) { if (result.success) {
confirmationHook.forgetSession(sessionId); confirmationHook.forgetSession(sessionId);
// v0.8.1 P0-2: 会话终态联动清理工作记忆(此前 clearWorkingMemory 零调用方,
// working_memories 随会话数永久残留)
ctx.memoryManager.clearWorkingMemory(sessionId);
// v0.8.1 P0-3: 清除该会话的流式回放缓冲(最多 4MB/会话)
clearReplayBuffer(sessionId);
// v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口) // v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口)
// —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留 // —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留
agentEngineManager.disposeEngine(sessionId); agentEngineManager.disposeEngine(sessionId);
@@ -78,14 +85,35 @@ export function registerSessionHandlers(ctx: IPCContext): void {
if (result.success) { if (result.success) {
confirmationHook.forgetSession(sessionId); confirmationHook.forgetSession(sessionId);
agentEngineManager.disposeEngine(sessionId); agentEngineManager.disposeEngine(sessionId);
// v0.8.1 P0-2: 终态联动清理工作记忆
ctx.memoryManager.clearWorkingMemory(sessionId);
// v0.8.1 P0-3: 清除该会话的流式回放缓冲
clearReplayBuffer(sessionId);
} }
return result; return result;
}); });
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => { ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown, options?: unknown) => {
// M-34 修复: 校验 sessionId // M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return []; if (!isValidSessionId(sessionId)) return [];
return sessionService.getMessages(sessionId); // v0.8.1 P2-3: 游标分页选项(limit 1-1000 / beforeRowid 正整数),非法值忽略
let safeOptions: { limit?: number; beforeRowid?: number } | undefined;
if (options && typeof options === 'object') {
const o = options as Record<string, unknown>;
safeOptions = {};
if (typeof o.limit === 'number' && Number.isFinite(o.limit) && o.limit >= 1) {
safeOptions.limit = Math.min(1000, Math.floor(o.limit));
}
if (
typeof o.beforeRowid === 'number' &&
Number.isFinite(o.beforeRowid) &&
o.beforeRowid > 0
) {
safeOptions.beforeRowid = Math.floor(o.beforeRowid);
}
if (Object.keys(safeOptions).length === 0) safeOptions = undefined;
}
return sessionService.getMessages(sessionId, safeOptions);
}); });
// v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。 // v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。
@@ -129,6 +157,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
try { try {
sessionService.clearMessages(sessionId); sessionService.clearMessages(sessionId);
// v0.8.1 P0-2: 清空会话 = 会话内容重置 —— 工作记忆一并清理
ctx.memoryManager.clearWorkingMemory(sessionId);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
+58 -14
View File
@@ -10,11 +10,15 @@ 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.1 P2-1: 工具自定义策略解析
import { parseToolPolicy } from '../harness/sandbox/permissions';
// v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过) // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
import { import {
assertSafeConfigTargetDeep, assertSafeConfigTargetDeep,
DeepCheckSoftFailure, DeepCheckSoftFailure,
} from '../harness/tools/built-in/ssrf-guard'; } from '../harness/tools/built-in/ssrf-guard';
// v0.8.1 P0-4: 主进程文案双语
import { setMainLocale, mt } from '../utils/main-locale';
/** /**
* v0.7.4 P2-9-C: URL assertSafeConfigTarget * v0.7.4 P2-9-C: URL assertSafeConfigTarget
@@ -65,16 +69,12 @@ export const LLM_CONFIG_KEYS = [
'llm.model', 'llm.model',
'llm.apiKey', 'llm.apiKey',
'llm.baseURL', 'llm.baseURL',
// v0.8.1: 全局单一「上下文长度」—— 重建 adapter(携带新窗口)+ 引擎配置同步
'llm.contextWindow',
'llm.fallbackProvider', 'llm.fallbackProvider',
'llm.fallbackModel', 'llm.fallbackModel',
'llm.fallbackApiKey', 'llm.fallbackApiKey',
'llm.fallbackBaseURL', 'llm.fallbackBaseURL',
'ollama.numCtx',
'deepseek.contextWindow',
'agnes.contextWindow',
'mimo.contextWindow',
'openai.contextWindow',
'anthropic.contextWindow',
]; ];
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */ /** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
@@ -129,7 +129,7 @@ export function clearApiKeyOnProviderChange(
* /Engine/Orchestrator/ConfirmationHook * /Engine/Orchestrator/ConfirmationHook
*/ */
export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void { export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void {
const { agentEngineManager, orchestrator, confirmationHook } = ctx; const { agentEngineManager, orchestrator, confirmationHook, configService } = ctx;
switch (key) { switch (key) {
case 'agent.maxIterations': case 'agent.maxIterations':
agentEngineManager.updateConfigAll({ maxIterations: value as number }); agentEngineManager.updateConfigAll({ maxIterations: value as number });
@@ -162,8 +162,15 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
orchestrator.updateDefaultConfig({ temperature: Number(value) }); orchestrator.updateDefaultConfig({ temperature: Number(value) });
break; break;
case 'llm.maxTokens': case 'llm.maxTokens':
agentEngineManager.updateConfigAll({ maxTokens: Number(value) }); // v0.8.1 review 修复: null/空串 = 用户清空「最大输出上限」→ 未配置语义
orchestrator.updateDefaultConfig({ maxTokens: Number(value) }); //undefined 下发,由 Provider 服务端默认值决定);此前 Number(null)=0
// 会把引擎预算清零。
agentEngineManager.updateConfigAll({
maxTokens: value == null || value === '' ? undefined : Number(value) || undefined,
});
orchestrator.updateDefaultConfig({
maxTokens: value == null || value === '' ? undefined : Number(value) || undefined,
});
break; break;
case 'agent.toolExecutionTimeoutMs': case 'agent.toolExecutionTimeoutMs':
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number }); agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
@@ -172,17 +179,34 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
confirmationHook.setConfirmationTimeout(value as number); confirmationHook.setConfirmationTimeout(value as number);
break; break;
case 'ollama.numCtx': case 'ollama.numCtx':
// v0.8.1: ollama.numCtx 已废除 —— 与「上下文长度」合并为 llm.contextWindow。
// 保留此分支仅为兼容存量库中的遗留键写入(迁移 12 已清理),行为同 llm.contextWindow。
agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined }); agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
break; break;
// v0.8.1: 全局单一「上下文长度」—— 替代旧的分 Provider contextWindow 键与
// ollama.numCtx。Ollama Provider 下同时作为 num_ctxcontextLength)下发。
case 'llm.contextWindow': {
const ctx = (value as number) || undefined;
const isOllama = (configService.get<string>('llm.provider') ?? '') === 'ollama';
agentEngineManager.updateConfigAll({
contextWindow: ctx,
contextLength: isOllama ? ctx : undefined,
});
orchestrator.updateDefaultConfig({
contextWindow: ctx,
contextLength: isOllama ? ctx : undefined,
});
break;
}
case 'deepseek.contextWindow': case 'deepseek.contextWindow':
case 'agnes.contextWindow': case 'agnes.contextWindow':
case 'mimo.contextWindow': case 'mimo.contextWindow':
case 'openai.contextWindow': case 'openai.contextWindow':
case 'anthropic.contextWindow': case 'anthropic.contextWindow':
// reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底) // v0.8.1: 分 Provider 键已废除 —— 兼容分支收敛为空操作(迁移 12 已清理
agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined }); // 存量库;若前端旧版本仍写入,静默忽略以防双源语义复活)。
orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined }); log.warn(`[CONFIG] Deprecated provider contextWindow key ignored: ${key}`);
break; break;
default: default:
break; break;
@@ -208,13 +232,27 @@ export async function applyConfigSideEffects(
if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) { if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) {
if (!ctx.reloadAdapter()) { if (!ctx.reloadAdapter()) {
log.warn('[CONFIG] Adapter reload failed after config save'); log.warn('[CONFIG] Adapter reload failed after config save');
return 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写'; return mt('config.error.configIncomplete');
} }
} }
// 2. Engine/Orchestrator/ConfirmationHook 配置同步 // 2. Engine/Orchestrator/ConfirmationHook 配置同步
for (const { key, value } of entries) { for (const { key, value } of entries) {
applyEngineConfigKey(ctx, key, value); applyEngineConfigKey(ctx, key, value);
// v0.8.1 P2-1: 工具自定义策略热加载(tools.{name}.policyJSON 字符串)
const m = /^tools\.(.+)\.policy$/.exec(key);
if (m) {
const toolName = m[1];
const known = ctx.toolRegistry.listAllTools().some((t) => t.name === toolName);
if (known) {
const parsed =
typeof value === 'string' && value.trim() !== '' ? parseToolPolicy(value) : null;
ctx.policyEngine.setPolicyOverride(toolName, parsed);
if (typeof value === 'string' && value.trim() !== '' && !parsed) {
log.warn(`[Policy] Ignored invalid policy config for tool: ${toolName}`);
}
}
}
} }
// 3. 日志级别即时应用 // 3. 日志级别即时应用
@@ -255,7 +293,7 @@ export async function applyConfigSideEffects(
} catch (err) { } catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err); log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径 // v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径
return `工作空间路径保存失败:${(err as Error).message}`; return mt('config.error.workspaceSaveFailed', { message: (err as Error).message });
} }
} }
@@ -266,6 +304,12 @@ export async function applyConfigSideEffects(
await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null); await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null);
} }
// v0.8.1 P0-4: 界面语言变更 → 主进程 toast/通知语言热切换(无需重启)
const localeEntry = entries.find((e) => e.key === 'ui.locale');
if (localeEntry && typeof localeEntry.value === 'string') {
setMainLocale(localeEntry.value);
}
// v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连) // v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连)
const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect'); const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect');
if (autoReconnectEntry) { if (autoReconnectEntry) {
+132 -48
View File
@@ -36,6 +36,7 @@ import { TitleGenerator } from './services/title-generator.service';
import { ContextBuilder } from './harness/prompts/context-builder'; import { ContextBuilder } from './harness/prompts/context-builder';
import { MemoryManager } from './harness/memory/manager'; import { MemoryManager } from './harness/memory/manager';
import { MemoryConsolidator } from './harness/memory/consolidator'; import { MemoryConsolidator } from './harness/memory/consolidator';
import { MemoryMaintainer } from './harness/memory/maintainer';
import { registerAllIPCHandlers } from './ipc'; import { registerAllIPCHandlers } from './ipc';
import type { ToolsReadyRef } from './ipc'; import type { ToolsReadyRef } from './ipc';
import { ToolRegistry } from './harness/tools/registry'; import { ToolRegistry } from './harness/tools/registry';
@@ -85,12 +86,14 @@ import {
SecurityScanHook, SecurityScanHook,
} from './harness/hooks'; } from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook'; import { ConfirmationHook } from './harness/hooks/confirmation-hook';
import { PolicyEngine } from './harness/sandbox/permissions'; import { PolicyEngine, parseToolPolicy } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox'; import { SandboxManager } from './harness/sandbox/sandbox';
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense'; import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
import { OutputValidator } from './harness/verification/output-validator'; import { OutputValidator } from './harness/verification/output-validator';
import { TaskOrchestrator } from './harness/orchestration/orchestrator'; import { TaskOrchestrator } from './harness/orchestration/orchestrator';
import { HealthChecker, SLOMonitor } from './utils/slo'; import { HealthChecker, SLOMonitor } from './utils/slo';
// v0.8.1 P0-4: 主进程 toast/通知文案双语(ui.locale 驱动)
import { setMainLocale, mt } from './utils/main-locale';
// v0.6.4 P4-5: session 级网络代理应用工具(default + agent-browser 分区) // v0.6.4 P4-5: session 级网络代理应用工具(default + agent-browser 分区)
// ===== 步骤 1: 初始化日志系统(SYS 层)===== // ===== 步骤 1: 初始化日志系统(SYS 层)=====
@@ -117,6 +120,16 @@ app.on('child-process-gone', (_event, details) => {
); );
}); });
// ===== E2E 测试引导(v0.8.1 P2-5=====
// METONA_USER_DATA_DIR:重定向 userDataworkspace-config.json / 全局配置层),
// 使 Playwright 冒烟测试与真实用户数据完全隔离;
// METONA_E2E_SEED_CONFIG:跳过引导向导并写入一组确定性的合法 LLM 配置
//Provider/Model/输出上限/上下文长度 —— 全部为设置面板合法配置项),
// 指向 e2e/mock-llm.ts 启动的本地 OpenAI 兼容服务。仅在显式设置时生效。
if (process.env['METONA_USER_DATA_DIR']) {
app.setPath('userData', process.env['METONA_USER_DATA_DIR']);
}
// ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)===== // ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)=====
const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json'); const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json');
@@ -204,6 +217,21 @@ async function initialize(): Promise<void> {
// 注入全局配置层:读取时回退到全局 JSON,写入时双写 // 注入全局配置层:读取时回退到全局 JSON,写入时双写
configService.setGlobalConfig(globalConfigService); configService.setGlobalConfig(globalConfigService);
// v0.8.1 P0-4: 主进程语言随 ui.locale 注入(变更经 applyConfigSideEffects 热切换)
setMainLocale(configService.get<string>('ui.locale'));
// v0.8.1 P2-5: E2E 引导种子(见文件顶部说明;仅 METONA_E2E_SEED_CONFIG 时生效)
if (process.env['METONA_E2E_SEED_CONFIG'] === '1') {
configService.set('onboarding.completed', true);
configService.set('llm.provider', 'deepseek');
configService.set('llm.model', 'deepseek-v4-flash');
configService.set('llm.baseURL', process.env['METONA_E2E_LLM_URL'] ?? 'http://127.0.0.1:0');
configService.set('llm.apiKey', 'e2e-test-key');
configService.set('llm.maxTokens', 2048);
configService.set('llm.contextWindow', 32768);
log.info('[E2E] Seeded deterministic LLM config for smoke test');
}
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON // v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key // 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{
@@ -265,11 +293,10 @@ async function initialize(): Promise<void> {
return null; return null;
} }
// 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段) // v0.8.1 硬性契约: 上下文窗口唯一合法来源是设置面板「上下文长度」
const contextWindow = // llm.contextWindow,全局单一配置,适用于一切 Provider/模型)。
provider !== 'ollama' // 删除了旧的 deepseek/agnes/mimo/openai/anthropic.contextWindow 分 Provider 键。
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined) const contextWindow = configService.get<number>('llm.contextWindow') ?? undefined;
: undefined;
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) { switch (provider) {
@@ -288,13 +315,14 @@ async function initialize(): Promise<void> {
} }
}; };
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见 // 配置未就绪时的 fallback adapter — send/sendStream 会报错但前端可见
// v0.8.1: 不再携带写死的 contextWindow4096)—— 未配置时 getContextWindow
// 返回 0,压缩判定跳过;该 adapter 仅在 LLM 配置缺失时兜底存在。
const FALLBACK_ADAPTER = new DeepSeekAdapter({ const FALLBACK_ADAPTER = new DeepSeekAdapter({
provider: '', provider: '',
baseURL: '', baseURL: '',
apiKey: '', apiKey: '',
defaultModel: '', defaultModel: '',
contextWindow: 4096,
}); });
// ===== 步骤 5: 工作空间文件 + System Prompt ===== // ===== 步骤 5: 工作空间文件 + System Prompt =====
@@ -429,15 +457,23 @@ async function initialize(): Promise<void> {
} }
// ===== P2-10: Agent Engine Manager(每会话独立引擎,替代全局单引擎) ===== // ===== P2-10: Agent Engine Manager(每会话独立引擎,替代全局单引擎) =====
// v0.8.1: contextWindow / maxTokens 唯一来源是设置面板 llm.contextWindow / llm.maxTokens
// Ollama 的 num_ctxcontextLength)与「上下文长度」同源 —— 同一设置同时驱动
// 压缩预算与 num_ctx 下发,不再存在独立的 ollama.numCtx 配置键。
const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER; const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER;
const ollamaNumCtx = configService.get<number>('ollama.numCtx'); const getContextWindowSetting = (): number | undefined =>
configService.get<number>('llm.contextWindow') ?? undefined;
const getOllamaContextLength = (): number | undefined =>
(configService.get<string>('llm.provider') ?? '') === 'ollama'
? getContextWindowSetting()
: undefined;
const agentEngineManager = new AgentEngineManager({ const agentEngineManager = new AgentEngineManager({
buildAdapter, buildAdapter,
baseConfig: { baseConfig: {
maxIterations: configService.get<number>('agent.maxIterations') ?? 20, maxIterations: configService.get<number>('agent.maxIterations') ?? 20,
totalTimeoutMs: configService.get<number>('agent.totalTimeoutMs') ?? 600_000, totalTimeoutMs: configService.get<number>('agent.totalTimeoutMs') ?? 600_000,
contextLength: ollamaNumCtx ?? undefined, contextLength: getOllamaContextLength(),
contextWindow: buildAdapter().getContextWindow(), contextWindow: getContextWindowSetting(),
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true, thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
thinkingEffort: thinkingEffort:
(configService.get<string>('agent.thinkingEffort') as (configService.get<string>('agent.thinkingEffort') as
@@ -452,7 +488,9 @@ async function initialize(): Promise<void> {
enableReflection: configService.get<boolean>('agent.enableReflection') === true, enableReflection: configService.get<boolean>('agent.enableReflection') === true,
// F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488) // F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488)
temperature: configService.get<number>('llm.temperature') ?? 0.0, temperature: configService.get<number>('llm.temperature') ?? 0.0,
maxTokens: configService.get<number>('llm.maxTokens') ?? 63488, // v0.8.1 硬性契约: 直接读设置面板值(种子默认由 CONFIG_DEFAULTS 保证),
// 不再在代码侧携带 63488 写死兜底
maxTokens: configService.get<number>('llm.maxTokens') ?? undefined,
}, },
toolRegistry, toolRegistry,
preToolHooks, preToolHooks,
@@ -483,10 +521,8 @@ async function initialize(): Promise<void> {
); );
return null; return null;
} }
const contextWindow = // v0.8.1: 上下文窗口全局单一配置(llm.contextWindow),不再按 Provider 读取
provider !== 'ollama' const contextWindow = configService.get<number>('llm.contextWindow') ?? undefined;
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
: undefined;
const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) { switch (provider) {
case 'agnes': case 'agnes':
@@ -505,6 +541,27 @@ async function initialize(): Promise<void> {
}; };
agentEngineManager.setFallbackAdapter(buildFallbackAdapter()); agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
// ===== v0.8.1 P1-1: 本地向量记忆嵌入器装配 =====
// 仅在 Provider=Ollama(本地推理、数据不出设备、零 token 成本)且用户在设置面板
// 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回
// nullMemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。
// adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。
memoryManager.setEmbedder({
embed: async (text) => {
const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof OllamaAdapter)) return null;
const model = configService.get<string>('memory.embeddingModel') ?? '';
if (!model.trim()) return null;
try {
const r = await adapter.embed({ model: model.trim(), input: text });
return r.embeddings?.[0] ?? null;
} catch (err) {
log.warn('[Memory] Embedding failed (falling back to TF-IDF):', (err as Error).message);
return null;
}
},
});
// ===== P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) ===== // ===== P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) =====
// v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态) // v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
// v0.5.3: MCP 工具集合运行中变更(添加/断开/启停 server)→ 同步所有已存在引擎。 // v0.5.3: MCP 工具集合运行中变更(添加/断开/启停 server)→ 同步所有已存在引擎。
@@ -544,6 +601,13 @@ async function initialize(): Promise<void> {
// v0.3.18: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉 // v0.3.18: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉
memoryConsolidator.setMemoryManager(memoryManager); memoryConsolidator.setMemoryManager(memoryManager);
// ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段,应用前需用户确认) =====
const memoryMaintainer = new MemoryMaintainer(
() => agentEngineManager.getAdapter(),
workspaceService,
() => db,
);
// ===== P2-11: 会话摘要分层上下文服务 ===== // ===== P2-11: 会话摘要分层上下文服务 =====
const sessionSummaryService = new SessionSummaryService( const sessionSummaryService = new SessionSummaryService(
() => db, () => db,
@@ -566,8 +630,8 @@ async function initialize(): Promise<void> {
| 'high' | 'high'
| 'max' | 'max'
| null) ?? 'high', | null) ?? 'high',
contextLength: ollamaNumCtx ?? undefined, contextLength: getOllamaContextLength(),
contextWindow: buildAdapter().getContextWindow(), contextWindow: getContextWindowSetting(),
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection
enableReflection: configService.get<boolean>('agent.enableReflection') === true, enableReflection: configService.get<boolean>('agent.enableReflection') === true,
}, },
@@ -577,6 +641,17 @@ async function initialize(): Promise<void> {
agentEngineManager.setToolsAll(toolRegistry.listTools()); agentEngineManager.setToolsAll(toolRegistry.listTools());
// 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存 // 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
confirmationHook.setToolDefs(toolRegistry.listAllTools()); confirmationHook.setToolDefs(toolRegistry.listAllTools());
// ===== v0.8.1 P2-1: 加载用户自定义工具策略(tools.{name}.policy=====
// 设置面板保存经 shared.ts 热加载;此处覆盖应用启动的冷加载。
for (const toolDef of toolRegistry.listAllTools()) {
const raw = configService.get<string>(`tools.${toolDef.name}.policy`);
if (raw != null && raw !== '') {
const parsed = parseToolPolicy(raw);
policyEngine.setPolicyOverride(toolDef.name, parsed);
if (!parsed) log.warn(`[Policy] Ignored invalid policy config for tool: ${toolDef.name}`);
}
}
log.info(`Registered ${toolRegistry.size} built-in tools`); log.info(`Registered ${toolRegistry.size} built-in tools`);
// ===== 热重载 Adapter 回调(设置变更时触发) ===== // ===== 热重载 Adapter 回调(设置变更时触发) =====
@@ -593,10 +668,8 @@ async function initialize(): Promise<void> {
fallbackModel: configService.get<string>('llm.fallbackModel') ?? '', fallbackModel: configService.get<string>('llm.fallbackModel') ?? '',
fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '', fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '',
fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '', fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '',
contextWindow: // v0.8.1: 全局单一「上下文长度」配置进入签名(替代旧的分 Provider 键)
provider === 'ollama' contextWindow: configService.get<number>('llm.contextWindow') ?? null,
? configService.get<number>('ollama.numCtx')
: configService.get<number>(`${provider}.contextWindow`),
}); });
}; };
let lastConfigSig = buildConfigSig(); let lastConfigSig = buildConfigSig();
@@ -619,7 +692,7 @@ async function initialize(): Promise<void> {
for (const win of wins) { for (const win of wins) {
win.webContents.send('toast:show', { win.webContents.send('toast:show', {
type: 'warning', type: 'warning',
message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model', message: mt('config.toast.configIncomplete'),
}); });
} }
return false; return false;
@@ -630,20 +703,17 @@ async function initialize(): Promise<void> {
agentEngineManager.setFallbackAdapter(buildFallbackAdapter()); agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
memoryConsolidator.setAdapter(agentEngineManager.getAdapter()); memoryConsolidator.setAdapter(agentEngineManager.getAdapter());
// Provider 切换时同步 contextLength 和 contextWindow // v0.8.1: 同步「上下文长度」到所有引擎(Ollama 同时作为 num_ctx/contextLength
const provider = configService.get<string>('llm.provider') ?? ''; const provider = configService.get<string>('llm.provider') ?? '';
if (provider === 'ollama') { const ctxSetting = getContextWindowSetting();
const numCtx = configService.get<number>('ollama.numCtx'); agentEngineManager.updateConfigAll({
agentEngineManager.updateConfigAll({ contextLength: provider === 'ollama' ? ctxSetting : undefined,
contextLength: numCtx ?? undefined, contextWindow: ctxSetting,
contextWindow: agentEngineManager.getAdapter().getContextWindow(), });
}); orchestrator.updateDefaultConfig({
} else { contextLength: provider === 'ollama' ? ctxSetting : undefined,
agentEngineManager.updateConfigAll({ contextWindow: ctxSetting,
contextLength: undefined, });
contextWindow: agentEngineManager.getAdapter().getContextWindow(),
});
}
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`); log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
// 仅在 Provider 真正变化时通知渲染进程 // 仅在 Provider 真正变化时通知渲染进程
if (lastProvider && lastProvider !== provider) { if (lastProvider && lastProvider !== provider) {
@@ -656,7 +726,7 @@ async function initialize(): Promise<void> {
}); });
win.webContents.send('toast:show', { win.webContents.send('toast:show', {
type: 'success', type: 'success',
message: `Provider 已切换: ${lastProvider}${provider}`, message: mt('config.toast.providerSwitched', { from: lastProvider, to: provider }),
}); });
} }
} }
@@ -668,7 +738,7 @@ async function initialize(): Promise<void> {
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('toast:show', { win.webContents.send('toast:show', {
type: 'error', type: 'error',
message: `Provider 切换失败: ${(err as Error).message}`, message: mt('config.toast.providerSwitchFailed', { message: (err as Error).message }),
}); });
} }
return false; return false;
@@ -749,6 +819,7 @@ async function initialize(): Promise<void> {
contextBuilder, contextBuilder,
agentEngineManager, agentEngineManager,
toolRegistry, toolRegistry,
policyEngine,
auditService, auditService,
sessionRecorder, sessionRecorder,
memoryManager, memoryManager,
@@ -758,6 +829,7 @@ async function initialize(): Promise<void> {
outputValidator, outputValidator,
confirmationHook, confirmationHook,
memoryConsolidator, memoryConsolidator,
memoryMaintainer,
orchestrator, orchestrator,
sessionSummaryService, sessionSummaryService,
titleGenerator, titleGenerator,
@@ -789,8 +861,8 @@ async function initialize(): Promise<void> {
} }
if (event.status === 'available') { if (event.status === 'available') {
trayManager?.sendNotification( trayManager?.sendNotification(
'MetonaAI 更新可用', mt('notify.updateAvailable.title'),
`新版本 ${event.latestVersion} 已发布,可在 设置 → 日志与数据 中下载安装`, mt('notify.updateAvailable.body', { version: event.latestVersion }),
); );
} }
}); });
@@ -830,8 +902,8 @@ async function initialize(): Promise<void> {
return; return;
} }
trayManager?.sendNotification( trayManager?.sendNotification(
'MetonaAI — 任务完成', mt('notify.taskCompleted.title'),
`Agent 已完成任务 (${(data.durationMs / 1000).toFixed(1)}s)`, mt('notify.taskCompleted.body', { seconds: (data.durationMs / 1000).toFixed(1) }),
() => windowManager?.focusWindow(), () => windowManager?.focusWindow(),
); );
}, },
@@ -998,13 +1070,25 @@ if (!gotSingleInstanceLock) {
// 两条防线均为 deny-by-default 白名单制,任何一条失败都不放大攻击面。 // 两条防线均为 deny-by-default 白名单制,任何一条失败都不放大攻击面。
{ {
// 防线一:权限请求白名单(deny-by-default // 防线一:权限请求白名单(deny-by-default
// v0.8.1 根治: session.defaultSession 必须在 app ready 后才能访问 —— 此前在
// 模块加载期(ready 前)直接调用,每次启动都抛 "Session can only be received
// when app is ready",权限白名单静默失效(FATAL 日志为证)。现延迟到 ready 后
// 注册,防线真正生效。
const ALLOWED_PERMISSIONS = new Set<string>(['clipboard-sanitized-write', 'fullscreen']); const ALLOWED_PERMISSIONS = new Set<string>(['clipboard-sanitized-write', 'fullscreen']);
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { void app
callback(ALLOWED_PERMISSIONS.has(permission)); .whenReady()
}); .then(() => {
session.defaultSession.setPermissionCheckHandler((_wc, permission) => session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
ALLOWED_PERMISSIONS.has(permission), callback(ALLOWED_PERMISSIONS.has(permission));
); });
session.defaultSession.setPermissionCheckHandler((_wc, permission) =>
ALLOWED_PERMISSIONS.has(permission),
);
log.info('[Security] Permission whitelist registered (app ready)');
})
.catch((err) => {
log.error('[Security] Failed to register permission whitelist:', err);
});
// 防线二:生产环境 CSP 注入(仅 mainFrame,不触碰 dev server 的 HMR)。 // 防线二:生产环境 CSP 注入(仅 mainFrame,不触碰 dev server 的 HMR)。
// MUI/emotion 需要 style-src 'unsafe-inline'(运行时注入 <style> 标签与 style 属性); // MUI/emotion 需要 style-src 'unsafe-inline'(运行时注入 <style> 标签与 style 属性);
+29 -1
View File
@@ -59,7 +59,8 @@ const metonaAPI = {
rename: (sessionId: string, title: string) => rename: (sessionId: string, title: string) =>
ipcRenderer.invoke('sessions:rename', sessionId, title), ipcRenderer.invoke('sessions:rename', sessionId, title),
delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId), delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId),
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId), getMessages: (sessionId: string, options?: { limit?: number; beforeRowid?: number }) =>
ipcRenderer.invoke('sessions:getMessages', sessionId, options),
// v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库) // v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)
updateMessageContent: (sessionId: string, messageId: string, content: string) => updateMessageContent: (sessionId: string, messageId: string, content: string) =>
ipcRenderer.invoke('sessions:updateMessageContent', sessionId, messageId, content), ipcRenderer.invoke('sessions:updateMessageContent', sessionId, messageId, content),
@@ -110,6 +111,11 @@ const metonaAPI = {
ipcRenderer.invoke('mcp:toggleServer', name, enabled), ipcRenderer.invoke('mcp:toggleServer', name, enabled),
// v0.8.0 P2-5: Resources / Prompts 发现 // v0.8.0 P2-5: Resources / Prompts 发现
listServerContents: (name: string) => ipcRenderer.invoke('mcp:listServerContents', name), listServerContents: (name: string) => ipcRenderer.invoke('mcp:listServerContents', name),
// v0.8.1 P1-4: Prompt 渲染 + Resource 读取(对话内可用化)
getPrompt: (serverName: string, promptName: string, args?: Record<string, string>) =>
ipcRenderer.invoke('mcp:getPrompt', serverName, promptName, args),
readResource: (serverName: string, uri: string) =>
ipcRenderer.invoke('mcp:readResource', serverName, uri),
}, },
// ===== 记忆系统 ===== // ===== 记忆系统 =====
@@ -119,6 +125,9 @@ const metonaAPI = {
listAll: (options?: { type?: string; limit?: number }) => listAll: (options?: { type?: string; limit?: number }) =>
ipcRenderer.invoke('memory:listAll', options), ipcRenderer.invoke('memory:listAll', options),
delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id), delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id),
// v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段)
analyzeMaintenance: () => ipcRenderer.invoke('memory:analyzeMaintenance'),
applyMaintenance: (actions: unknown) => ipcRenderer.invoke('memory:applyMaintenance', actions),
}, },
// ===== v0.2.0: 任务管理 ===== // ===== v0.2.0: 任务管理 =====
@@ -174,6 +183,12 @@ const metonaAPI = {
ipcRenderer.on('tool:confirmationRequest', listener); ipcRenderer.on('tool:confirmationRequest', listener);
return () => ipcRenderer.removeListener('tool:confirmationRequest', listener); return () => ipcRenderer.removeListener('tool:confirmationRequest', listener);
}, },
// v0.8.1 P2-2: 连续同类工具批量确认(单事件携带完整请求列表)
onConfirmationRequestBatch: (callback: (requests: unknown[]) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: unknown[]) => callback(data);
ipcRenderer.on('tool:confirmationRequestBatch', listener);
return () => ipcRenderer.removeListener('tool:confirmationRequestBatch', listener);
},
sendConfirmationResponse: (response: { sendConfirmationResponse: (response: {
toolCallId: string; toolCallId: string;
approved: boolean; approved: boolean;
@@ -243,6 +258,19 @@ const metonaAPI = {
showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path), showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path),
selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath), selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath),
restart: () => ipcRenderer.invoke('app:restart'), restart: () => ipcRenderer.invoke('app:restart'),
// v0.8.1 P2-4: 开机自启(setLoginItemSettings 封装;读回真实生效状态)
setLoginItem: (enabled: boolean) =>
ipcRenderer.invoke('app:setLoginItem', enabled) as Promise<{
success: boolean;
error?: string;
data?: { openAtLogin: boolean };
}>,
getLoginItem: () =>
ipcRenderer.invoke('app:getLoginItem') as Promise<{
success: boolean;
error?: string;
data?: { openAtLogin: boolean };
}>,
// v0.6.4 P4-2: 检查更新(feed 比对式;electron-updater 接入时仅替换实现) // v0.6.4 P4-2: 检查更新(feed 比对式;electron-updater 接入时仅替换实现)
updateCheck: () => updateCheck: () =>
ipcRenderer.invoke('app:updateCheck') as Promise< ipcRenderer.invoke('app:updateCheck') as Promise<
@@ -285,9 +285,11 @@ describe.skipIf(!dbAvailable)('isUnconfiguredGlobalKey — 边界值矩阵', ()
expect(isUnconfiguredGlobalKey(key, value)).toBe(expected); expect(isUnconfiguredGlobalKey(key, value)).toBe(expected);
}); });
it('isGlobalKey 边界:完整前缀匹配而非子串(deepseek.contextWindow 是全局 key', () => { it('isGlobalKey 边界:完整前缀匹配而非子串(v0.8.1: provider 前缀已废除', () => {
expect(isGlobalKey('deepseek.contextWindow')).toBe(true); // v0.8.1: deepseek/agnes/mimo/openai/anthropic/ollama 前缀从全局清单移除
expect(isGlobalKey('deepseek.apiKey')).toBe(true); //(其唯一键 contextWindow/numCtx 已废除,由全局 llm.contextWindow 取代)
expect(isGlobalKey('deepseek.contextWindow')).toBe(false);
expect(isGlobalKey('deepseek.apiKey')).toBe(false);
expect(isGlobalKey('memory.consolidationEnabled')).toBe(true); expect(isGlobalKey('memory.consolidationEnabled')).toBe(true);
expect(isGlobalKey('memory.custom')).toBe(true); expect(isGlobalKey('memory.custom')).toBe(true);
// 非全局前缀 // 非全局前缀
@@ -497,9 +499,9 @@ describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 边界矩阵', () => {
}); });
it('数值边界:默认值 float 精确相等判定', () => { it('数值边界:默认值 float 精确相等判定', () => {
// deepseek.contextWindow 默认 1000000 // v0.8.1: deepseek.contextWindow 键已废除(迁移 12)——改用存续键 llm.maxTokens(默认 63488
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 1000000 })).toBe(0); expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 63488 })).toBe(0);
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 64000 })).toBe(1); expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 64000 })).toBe(1);
}); });
it('迁移后新 ConfigService 读取(跨空间共享生效)', () => { it('迁移后新 ConfigService 读取(跨空间共享生效)', () => {
@@ -0,0 +1,113 @@
/**
* MCPManager getPrompt / readResource v0.8.1 P1-4
*
* prompts/resources
* 1. getPromptmessages text \n\n
* server
* 2. readResourcetext blob
* contents null
*
* server SDK IPC
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { MCPManager } from '../mcp-manager.service';
import type { ToolRegistry } from '../../harness/tools/registry';
function makeManager(): MCPManager {
const fakeDB = {
prepare: () => ({ run: () => ({ changes: 0 }) }),
} as unknown as ConstructorParameters<typeof MCPManager>[0] extends () => infer D ? D : never;
const registry = {
registerMCP: vi.fn(() => true),
unregisterMCPTools: vi.fn(),
} as unknown as ToolRegistry;
return new MCPManager(() => fakeDB, registry);
}
/** 反射注入受控 server 状态(mock client */
function injectServer(manager: MCPManager, client: Record<string, ReturnType<typeof vi.fn>>): void {
const states = (
manager as unknown as {
servers: Map<
string,
{
config: { name: string };
status: string;
client: unknown;
tools: unknown[];
resources: unknown[];
prompts: unknown[];
}
>;
}
).servers;
states.set('test-server', {
config: { name: 'test-server' } as never,
status: 'connected',
client: client as never,
tools: [],
resources: [],
prompts: [],
});
}
describe('MCPManager — getPromptv0.8.1 P1-4', () => {
it('messages 展平为文本,多消息以空行连接', async () => {
const manager = makeManager();
injectServer(manager, {
getPrompt: vi.fn().mockResolvedValue({
description: 'demo prompt',
messages: [
{ role: 'user', content: { type: 'text', text: 'Line A' } },
{ role: 'user', content: { type: 'text', text: 'Line B' } },
],
}),
});
const result = await manager.getPrompt('test-server', 'demo');
expect(result).not.toBeNull();
expect(result!.text).toBe('Line A\n\nLine B');
expect(result!.description).toBe('demo prompt');
});
it('server 未连接 → 显式抛错(调用方以 error 返回)', async () => {
const manager = makeManager();
await expect(manager.getPrompt('nope', 'demo')).rejects.toThrow(/not connected/);
});
});
describe('MCPManager — readResourcev0.8.1 P1-4', () => {
it('text 内容正常返回', async () => {
const manager = makeManager();
injectServer(manager, {
readResource: vi.fn().mockResolvedValue({
contents: [{ uri: 'file:///a.txt', text: 'hello resource', mimeType: 'text/plain' }],
}),
});
const result = await manager.readResource('test-server', 'file:///a.txt');
expect(result).toEqual({ text: 'hello resource', mimeType: 'text/plain' });
});
it('二进制(blob)内容显式拒绝', async () => {
const manager = makeManager();
injectServer(manager, {
readResource: vi.fn().mockResolvedValue({
contents: [{ uri: 'file:///a.png', blob: 'aGVsbG8=' }],
}),
});
await expect(manager.readResource('test-server', 'file:///a.png')).rejects.toThrow(/binary/);
});
it('空 contents → null', async () => {
const manager = makeManager();
injectServer(manager, {
readResource: vi.fn().mockResolvedValue({ contents: [] }),
});
expect(await manager.readResource('test-server', 'file:///none')).toBeNull();
});
});
@@ -0,0 +1,114 @@
/**
* SessionService v0.8.1 P2-3
*
* limit limit = N
* limit + beforeRowid = N
* < limit
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
import { SessionService } from '../session.service';
describe.skipIf(!dbAvailable)('SessionService — 游标分页(v0.8.1 P2-3', () => {
let db: InstanceType<typeof Database>;
let svc: SessionService;
const sessionId = 's_page';
beforeAll(() => {
if (!dbAvailable) return;
db = new Database(':memory:');
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at INTEGER,
updated_at INTEGER,
message_count INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT,
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT,
summarized_until_rowid INTEGER,
updated_at INTEGER
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
session_id TEXT,
title TEXT,
description TEXT,
status TEXT,
priority TEXT,
parent_id TEXT,
assigned_to TEXT,
order_idx INTEGER,
created_at INTEGER,
updated_at INTEGER,
completed_at INTEGER
);
`);
db.prepare('INSERT INTO sessions (id) VALUES (?)').run(sessionId);
svc = new SessionService(() => db);
for (let i = 1; i <= 10; i++) {
svc.saveMessage({ sessionId, role: 'user', content: `msg-${i}` });
}
});
afterAll(() => {
if (db) db.close();
});
it('无 limit → 全量(向后兼容)', () => {
expect(svc.getMessages(sessionId)).toHaveLength(10);
});
it('limit 无游标 → 尾部窗口(最后 N 条,升序返回)', () => {
const tail = svc.getMessages(sessionId, { limit: 3 });
expect(tail.map((m) => m.content)).toEqual(['msg-8', 'msg-9', 'msg-10']);
});
it('limit + beforeRowid → 游标之前 N 条(升序返回)', () => {
const full = svc.getMessages(sessionId);
const cursorRowId = full[6].rowId!; // msg-7 的 rowid
const older = svc.getMessages(sessionId, { limit: 3, beforeRowid: cursorRowId });
expect(older.map((m) => m.content)).toEqual(['msg-4', 'msg-5', 'msg-6']);
});
it('游标翻到开头:返回条数 < limit 表示已完整', () => {
const full = svc.getMessages(sessionId);
const first = full[0].rowId!;
const older = svc.getMessages(sessionId, { limit: 3, beforeRowid: first });
expect(older).toHaveLength(0);
});
});
+106 -12
View File
@@ -21,6 +21,19 @@ function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
/**
* v0.8.1: 已废除的配置键 12
* Provider contextWindow ollama.numCtx llm.contextWindow
*/
export const DEPRECATED_CONFIG_KEYS = [
'ollama.numCtx',
'deepseek.contextWindow',
'agnes.contextWindow',
'mimo.contextWindow',
'openai.contextWindow',
'anthropic.contextWindow',
] as const;
/** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */ /** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */
export interface ConfigDefaultEntry { export interface ConfigDefaultEntry {
key: string; key: string;
@@ -42,7 +55,13 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'llm.baseURL', value: '', category: 'llm' }, { key: 'llm.baseURL', value: '', category: 'llm' },
// F-8 接通: temperature/maxTokens 此前为死配置(引擎硬编码),现由 main.ts 注入引擎 // F-8 接通: temperature/maxTokens 此前为死配置(引擎硬编码),现由 main.ts 注入引擎
{ key: 'llm.temperature', value: 0, category: 'llm' }, { key: 'llm.temperature', value: 0, category: 'llm' },
// v0.8.0 FEAT-1 / v0.8.1: 最大输出上限 —— 唯一合法的输出上限配置(全局,跨 Provider)
{ key: 'llm.maxTokens', value: 63488, category: 'llm' }, { key: 'llm.maxTokens', value: 63488, category: 'llm' },
// v0.8.1: 上下文长度 —— 唯一合法的上下文窗口配置(全局,跨 Provider/模型;
// 驱动引擎压缩预算、前端占用指示,Ollama 场景同时作为 num_ctx 下发)。
// 分 Provider 的 deepseek/agnes/mimo/openai/anthropic.contextWindow 与 ollama.numCtx
// 已废除(迁移 12 清理遗留键)。
{ key: 'llm.contextWindow', value: 131072, category: 'llm' },
// v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭, // v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭,
// 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断) // 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断)
{ key: 'llm.multimodalEnabled', value: false, category: 'llm' }, { key: 'llm.multimodalEnabled', value: false, category: 'llm' },
@@ -74,6 +93,9 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'memory.consolidationEnabled', value: true, category: 'memory' }, { key: 'memory.consolidationEnabled', value: true, category: 'memory' },
{ key: 'memory.consolidationMinChars', value: 200, category: 'memory' }, { key: 'memory.consolidationMinChars', value: 200, category: 'memory' },
{ key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' }, { key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' },
// v0.8.1 P1-1: 本地向量记忆嵌入模型(Ollama embedding 模型名,空 = 向量检索关闭,
// 仅 TF-IDF)。唯一合法来源是设置面板 —— 代码中不存在默认模型名。
{ key: 'memory.embeddingModel', value: '', category: 'memory' },
// v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试) // v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试)
{ key: 'mcp.autoReconnect', value: true, category: 'mcp' }, { key: 'mcp.autoReconnect', value: true, category: 'mcp' },
@@ -88,16 +110,8 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'logging.auditEnabled', value: true, category: 'logging' }, { key: 'logging.auditEnabled', value: true, category: 'logging' },
{ key: 'logging.traceEnabled', value: true, category: 'logging' }, { key: 'logging.traceEnabled', value: true, category: 'logging' },
// Ollama 配置 // v0.8.1: Ollama numCtx 独立配置已废除(与「上下文长度」合并为 llm.contextWindow);
{ key: 'ollama.numCtx', value: null, category: 'ollama' }, // Provider 上下文窗口分键配置已废除 —— 遗留键由迁移 12 从存量库清理。
// Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示)
{ key: 'deepseek.contextWindow', value: 1000000, category: 'deepseek' },
{ key: 'agnes.contextWindow', value: 1000000, category: 'agnes' },
{ key: 'mimo.contextWindow', value: 1000000, category: 'mimo' },
// P3: OpenAI / Anthropic Provider
{ key: 'openai.contextWindow', value: 128000, category: 'openai' },
{ key: 'anthropic.contextWindow', value: 200000, category: 'anthropic' },
// Onboarding // Onboarding
{ key: 'onboarding.completed', value: false, category: 'general' }, { key: 'onboarding.completed', value: false, category: 'general' },
@@ -114,8 +128,12 @@ export class DatabaseService {
* v0.7.4 P4-4: 1 2 9messages_fts trigram * v0.7.4 P4-4: 1 2 9messages_fts trigram
* "失败被吞 + 永久跳过" * "失败被吞 + 永久跳过"
* v0.8.0 P2-1: 2 3 10sessions.deleted_at * v0.8.0 P2-1: 2 3 10sessions.deleted_at
* v0.8.1: 3 4 11 embedding +
* 12 llm.contextWindow Provider ollama.numCtx
* v0.8.1 review: 4 5 13working_memories sessions CASCADE
* schema
*/ */
static readonly SCHEMA_VERSION = 3; static readonly SCHEMA_VERSION = 5;
constructor(workspacePath?: string) { constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
@@ -280,6 +298,7 @@ export class DatabaseService {
); );
-- ===== ===== -- ===== =====
-- v0.8.1 review: sessions CASCADE
CREATE TABLE IF NOT EXISTS working_memories ( CREATE TABLE IF NOT EXISTS working_memories (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
@@ -287,7 +306,9 @@ export class DatabaseService {
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
UNIQUE(session_id, task_id, key) tf_cache TEXT,
UNIQUE(session_id, task_id, key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
); );
-- ===== v0.2.0: 任务表 ===== -- ===== v0.2.0: 任务表 =====
@@ -657,6 +678,79 @@ export class DatabaseService {
} }
} }
// v0.8.1 P1-1 迁移 11: 记忆表 embedding 列(本地向量混合检索)。
// 存量记忆行为 NULL = 未向量化(检索时回退 TF-IDF 路径),新写入由
// MemoryEmbedder 异步回填;向量维度由用户配置的 embedding 模型决定,
// 故用 BLOB 存 Float32Array 而非固定宽度 F32Blob 列。
tryAddColumn('episodic_memories', 'embedding', 'BLOB');
tryAddColumn('semantic_memories', 'embedding', 'BLOB');
// v0.8.1 迁移 12: 清理已废除的上下文窗口配置键(全局 llm.contextWindow 取代)。
// 旧键残留会使设置面板与引擎出现双源语义,启动迁移一次性移除(幂等)。
{
const placeholders = DEPRECATED_CONFIG_KEYS.map(() => '?').join(', ');
const result = db
.prepare(`DELETE FROM app_config WHERE key IN (${placeholders})`)
.run(...DEPRECATED_CONFIG_KEYS);
if (result.changes > 0) {
log.info(
`[DB] Migration: removed ${result.changes} deprecated context-window config key(s) (replaced by llm.contextWindow)`,
);
}
}
// v0.8.1 review 迁移 13: working_memories 补 sessions 外键(ON DELETE CASCADE
// 历史缺陷:该表自 v0.2.0 建表起就没有 sessions 外键 —— 会话删除/清空后
// working 行成为孤儿(行为面已由 0.8.1 终态清理接线覆盖,此处根治 schema)。
// 先清理孤儿行(早于 0.8.1 的历史删除遗留),再以 FK 重建。
{
const hasWorking = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='working_memories'")
.get();
// hasSessions 由迁移 10 块内查询(块作用域),此处独立复查
const hasSessions = !!db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
const fkRows = hasWorking
? (db.prepare('PRAGMA foreign_key_list(working_memories)').all() as Array<{
table: string;
}>)
: [];
const hasSessionsFk = fkRows.some((fk) => fk.table === 'sessions');
if (hasWorking && hasSessions && !hasSessionsFk) {
log.info('[DB] Migration: rebuilding working_memories with sessions FK (CASCADE)');
const rebuildWorking = db.transaction(() => {
db.exec(`
DELETE FROM working_memories
WHERE session_id NOT IN (SELECT id FROM sessions);
CREATE TABLE working_memories_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
tf_cache TEXT,
UNIQUE(session_id, task_id, key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO working_memories_new (id, session_id, task_id, key, value, updated_at, tf_cache)
SELECT id, session_id, task_id, key, value, updated_at, tf_cache
FROM working_memories;
DROP TABLE working_memories;
ALTER TABLE working_memories_new RENAME TO working_memories;
CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id);
`);
});
rebuildWorking();
log.info('[DB] Migration: working_memories rebuilt with sessions FK');
}
}
// 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 的
+17 -8
View File
@@ -11,8 +11,10 @@
* DB ///"工作空间级数据" * DB ///"工作空间级数据"
* *
* key * key
* - llm.* / agent.* / ollama.* / {provider}.contextWindow / security.* / ui.* / logging.* * - llm.* / agent.* / security.* / ui.* / logging.* / memory.*
* - onboarding.completed * - onboarding.completed
* v0.8.1: Provider contextWindow ollama.numCtx
* initialize DB 12
* DB JSON * DB JSON
* DBmiss 退 JSON * DBmiss 退 JSON
* *
@@ -23,7 +25,7 @@ import { app } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import log from 'electron-log'; import log from 'electron-log';
import { CONFIG_DEFAULTS } from './database.service'; import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service';
import { import {
decryptConfigValue, decryptConfigValue,
encryptConfigValue, encryptConfigValue,
@@ -37,15 +39,9 @@ const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
const GLOBAL_KEY_PREFIXES = [ const GLOBAL_KEY_PREFIXES = [
'llm.', 'llm.',
'agent.', 'agent.',
'ollama.',
'security.', 'security.',
'ui.', 'ui.',
'logging.', 'logging.',
'deepseek.',
'agnes.',
'mimo.',
'openai.',
'anthropic.',
'onboarding.', 'onboarding.',
// v0.7.3 P1-5: 记忆固化节流(机器级策略,跨工作空间一致) // v0.7.3 P1-5: 记忆固化节流(机器级策略,跨工作空间一致)
'memory.', 'memory.',
@@ -110,6 +106,19 @@ export class GlobalConfigService {
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; this.data = JSON.parse(raw) as GlobalConfigData;
// v0.8.1 review: 清除已废除的配置键(分 Provider contextWindow / ollama.numCtx),
// 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活
let purged = 0;
for (const key of DEPRECATED_CONFIG_KEYS) {
if (key in this.data) {
delete this.data[key];
purged++;
}
}
if (purged > 0) {
this.flush();
log.info(`[GlobalConfig] Purged ${purged} deprecated config key(s)`);
}
log.info( log.info(
`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`, `[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`,
); );
+53
View File
@@ -871,6 +871,59 @@ export class MCPManager {
return { resources: state.resources ?? [], prompts: state.prompts ?? [] }; return { resources: state.resources ?? [], prompts: state.prompts ?? [] };
} }
/**
* v0.8.1 P1-4: 获取 MCP Prompt prompts/get ChatInput
* messages text role
*/
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>,
): Promise<{ text: string; description?: string } | null> {
const state = this.servers.get(serverName);
if (!state || state.status !== 'connected' || !state.client) {
throw new Error(`MCP server '${serverName}' is not connected`);
}
const res = await state.client.getPrompt({
name: promptName,
arguments: args,
});
const parts: string[] = [];
for (const m of res.messages ?? []) {
// MCP PromptMessage.content 是单个 ContentBlocktext/image/audio/resource_link
// 联合)—— 仅提取文本块;经 unknown 中转以匹配 SDK 联合类型
const content = m.content as unknown as { type?: string; text?: string } | undefined;
const text = content?.type === 'text' ? (content.text ?? '') : '';
parts.push(text);
}
return { text: parts.filter((t) => t.length > 0).join('\n\n'), description: res.description };
}
/**
* v0.8.1 P1-4: 读取 MCP Resource resources/read @mcp
* blob线"二进制拒绝"
*/
async readResource(
serverName: string,
uri: string,
): Promise<{ text: string; mimeType?: string } | null> {
const state = this.servers.get(serverName);
if (!state || state.status !== 'connected' || !state.client) {
throw new Error(`MCP server '${serverName}' is not connected`);
}
const res = await state.client.readResource({ uri });
const contents = res.contents ?? [];
const first = contents[0];
if (!first) return null;
if ('blob' in first && typeof first.blob === 'string') {
throw new Error(`Resource '${uri}' is binary content — inline injection is not supported`);
}
return {
text: ('text' in first ? first.text : '') ?? '',
mimeType: first.mimeType,
};
}
/** /**
* *
*/ */
+30 -2
View File
@@ -283,12 +283,40 @@ export class SessionService {
* P2-11: 新增 afterRowid rowid * P2-11: 新增 afterRowid rowid
* rowid/ * rowid/
*/ */
/**
* v0.8.1 P2-3
* - limit /
* - limit + beforeRowid** N **
* - limit + beforeRowid N
* rowid
*/
getMessages( getMessages(
sessionId: string, sessionId: string,
options: { limit?: number; offset?: number; afterRowid?: number } = {}, options: { limit?: number; offset?: number; afterRowid?: number; beforeRowid?: number } = {},
): MessageInfo[] { ): MessageInfo[] {
const db = this.getDBFn(); const db = this.getDBFn();
const { limit = 0, offset = 0, afterRowid = 0 } = options; const { limit = 0, offset = 0, afterRowid = 0, beforeRowid = 0 } = options;
// 游标向上翻页:rowid < beforeRowidDESC 取 N 条后反转为升序
if (limit > 0 && beforeRowid > 0) {
const rows = db
.prepare(
`SELECT rowid AS row_id, * FROM messages WHERE session_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?`,
)
.all(sessionId, beforeRowid, limit) as MessageRow[];
return rows.reverse().map((row) => this.toMessageInfo(row));
}
// 尾部窗口:无游标且无 offset 时指定 limit → 最后 N 条(DESC 取后反转为升序)。
// offset>0 保留旧版 ASC limit/offset 分页语义(向后兼容既有调用方)。
if (limit > 0 && afterRowid === 0 && offset === 0) {
const rows = db
.prepare(
`SELECT rowid AS row_id, * FROM messages WHERE session_id = ? ORDER BY rowid DESC LIMIT ? OFFSET ?`,
)
.all(sessionId, limit, offset) as MessageRow[];
return rows.reverse().map((row) => this.toMessageInfo(row));
}
let sql = 'SELECT rowid AS row_id, * FROM messages WHERE session_id = ?'; let sql = 'SELECT rowid AS row_id, * FROM messages WHERE session_id = ?';
const params: unknown[] = [sessionId]; const params: unknown[] = [sessionId];
+39 -1
View File
@@ -13,7 +13,16 @@
*/ */
import { join, resolve, sep } from 'path'; import { join, resolve, sep } from 'path';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs'; import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
statSync,
renameSync,
unlinkSync,
} from 'fs';
import { app } from 'electron'; import { app } from 'electron';
import log from 'electron-log'; import log from 'electron-log';
// v0.8.0 P2-4: @ 提及文本片段的编码检测(与 read_file 同源) // v0.8.0 P2-4: @ 提及文本片段的编码检测(与 read_file 同源)
@@ -306,6 +315,35 @@ export class WorkspaceService {
log.info(`MEMORY.md: appended to section "${section}"`); log.info(`MEMORY.md: appended to section "${section}"`);
} }
/**
* v0.8.1 P1-2: 整体改写 MEMORY.md
*
* MemoryMaintainer "删除/合并" file-guard
* MEMORY.md
* Agent Agent
* tmp + rename write_file files.memory
*
*/
rewriteMemory(content: string): void {
const memoryPath = join(this.workspacePath, 'MEMORY.md');
// 时间戳随写随更
const stamped = content.replace(/> 最后更新: .*/, `> 最后更新: ${new Date().toISOString()}`);
const tmpPath = `${memoryPath}.tmp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
writeFileSync(tmpPath, stamped, 'utf-8');
try {
renameSync(tmpPath, memoryPath);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
/* 忽略清理失败 */
}
throw err;
}
this.files.memory = stamped;
log.info('MEMORY.md rewritten (memory maintenance)');
}
/** /**
* MEMORY.md * MEMORY.md
* *
@@ -59,6 +59,14 @@ vi.mock('undici', () => ({
undiciMocks.proxyAgentCalls.push(opts.uri); undiciMocks.proxyAgentCalls.push(opts.uri);
} }
}, },
// v0.8.1 P2-5: 组合 dispatcher 基类(回环直连 + 其余走代理)
Dispatcher: class {
dispatch(): boolean {
return true;
}
async close(): Promise<void> {}
async destroy(): Promise<void> {}
},
setGlobalDispatcher: (...args: unknown[]) => undiciMocks.setGlobalDispatcher(...args), setGlobalDispatcher: (...args: unknown[]) => undiciMocks.setGlobalDispatcher(...args),
})); }));
@@ -89,6 +97,7 @@ describe('applySessionProxy — 双通道应用', () => {
expect(sessionMocks.defaultSetProxy).toHaveBeenCalledWith(expected); expect(sessionMocks.defaultSetProxy).toHaveBeenCalledWith(expected);
expect(sessionMocks.partitionSetProxy).toHaveBeenCalledWith(expected); expect(sessionMocks.partitionSetProxy).toHaveBeenCalledWith(expected);
expect(undiciMocks.proxyAgentCalls).toEqual(['http://127.0.0.1:7890']); expect(undiciMocks.proxyAgentCalls).toEqual(['http://127.0.0.1:7890']);
expect(undiciMocks.agentCalls).toBeGreaterThanOrEqual(1); // 回环直连 Agent 同步构建
expect(undiciMocks.setGlobalDispatcher).toHaveBeenCalledTimes(1); expect(undiciMocks.setGlobalDispatcher).toHaveBeenCalledTimes(1);
}); });
+14 -3
View File
@@ -40,9 +40,11 @@ import {
isEncryptedValue, isEncryptedValue,
encryptConfigValue, encryptConfigValue,
decryptConfigValue, decryptConfigValue,
resetEncryptionUsableForTests,
} from '../secure-config'; } from '../secure-config';
beforeEach(() => { beforeEach(() => {
resetEncryptionUsableForTests();
mockState.encryptionAvailable = true; mockState.encryptionAvailable = true;
mockState.failDecrypt = false; mockState.failDecrypt = false;
}); });
@@ -113,13 +115,22 @@ describe('加密降级与失败语义', () => {
expect(isEncryptedValue(value)).toBe(false); expect(isEncryptedValue(value)).toBe(false);
}); });
it('加密过程抛错 → 回退明文存储(不阻断配置保存', () => { it('解密失败(跨机器/重装)→ 返回空串(不阻断,引导重录', () => {
// decryptString 抛错不影响 encrypt;此处验证 decrypt 失败语义 // v0.8.1: roundtrip probe 需要一次可用加解密 —— 先完成加密,再注入解密失败
mockState.failDecrypt = true;
const encrypted = encryptConfigValue('sk-x') as string; const encrypted = encryptConfigValue('sk-x') as string;
expect(isEncryptedValue(encrypted)).toBe(true);
mockState.failDecrypt = true;
expect(decryptConfigValue(encrypted)).toBe(''); // 失败 → 空串(createAdapter 判定未配置,引导重录) expect(decryptConfigValue(encrypted)).toBe(''); // 失败 → 空串(createAdapter 判定未配置,引导重录)
}); });
it('roundtrip 探测失败 → 会话级降级明文存储(v0.8.1 新增)', () => {
// failDecrypt 令 probe 失败 → usable=false → 加密直接降级明文
mockState.failDecrypt = true;
const value = encryptConfigValue('sk-probe-fail') as string;
expect(value).toBe('sk-probe-fail');
expect(isEncryptedValue(value)).toBe(false);
});
it('safeStorage 不可用时已加密值仍可识别且不被二次"加密"', () => { it('safeStorage 不可用时已加密值仍可识别且不被二次"加密"', () => {
const once = encryptConfigValue('sk-again') as string; const once = encryptConfigValue('sk-again') as string;
mockState.encryptionAvailable = false; mockState.encryptionAvailable = false;
+125
View File
@@ -0,0 +1,125 @@
/**
* Main-process Locale v0.8.1 P0-4 i18n
*
* v0.7.2 i18next broadcast
* toast / sendMessage
* en-US i18n
*
*
* - `ui.locale`zh-CN / en-US
* - main.ts config:set/setBatch ui.locale
* applyConfigSideEffects
* - mt(key, params) key 退 zh 退 key
* t() 退
* - "主进程主动产生" i18n-strings.ts
*/
export type MainLocale = 'zh-CN' | 'en-US';
let currentLocale: MainLocale = 'zh-CN';
/** 设置主进程语言(非法值保持不变;null/undefined 视为未配置 → zh-CN 默认) */
export function setMainLocale(locale: string | null | undefined): void {
if (locale === 'en-US' || locale === 'zh-CN') {
currentLocale = locale;
}
}
export function getMainLocale(): MainLocale {
return currentLocale;
}
const zh: Record<string, string> = {
// ===== sendMessage 错误路径 =====
'agent.error.invalidSessionId': '无效的会话 ID',
'agent.error.invalidMessage': '无效的消息格式',
'agent.error.sessionBusy': '该会话正在执行任务,请等待完成或先中断后再发送',
'agent.error.sessionMissing': '会话不存在或已被删除,请刷新后重试',
'agent.error.adapterLoadFailed':
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)',
// ===== SOUL.md 降级提示 =====
'agent.soul.fallbackToast':
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
// ===== 引擎事件 toast =====
'agent.toast.compressed': '上下文压缩: {{original}} → {{compressed}} tokens(节省 {{saved}}',
'agent.toast.deadLoop':
'检测到死循环(第 {{iteration}} 轮):连续3轮重复相同工具调用,已自动终止',
'agent.toast.providerSwitched': 'Provider 故障转移: {{from}} → {{to}}(主 Provider 请求失败)',
'agent.toast.consolidated': 'AI 已将 {{count}} 条重要记忆写入 MEMORY.md',
'agent.toast.memoryOversize': 'MEMORY.md 已超过 50KB,建议在「记忆」面板整理记忆',
// ===== LLM 运行时查询 =====
'llm.balance.queryFailed': '余额查询失败(API Key 无效或网络错误)',
'llm.listModels.notConfigured': 'LLM 未配置(Provider/Model 为空),无法获取模型列表',
'llm.listModels.noApiKey': 'API Key 未配置,无法获取模型列表',
'llm.listModels.configInvalid': 'LLM 配置校验失败,请先在设置中修正配置',
'llm.listModels.unsupported': '当前 Provider 不支持模型列表查询',
'llm.pull.ollamaOnly': '仅 Ollama Provider 支持模型下载',
'llm.pull.inProgress': '已有模型下载任务进行中,请先取消',
'llm.pull.none': '没有进行中的下载任务',
// ===== main.ts 适配器/Provider =====
'config.toast.configIncomplete':
'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model',
'config.toast.providerSwitched': 'Provider 已切换: {{from}} → {{to}}',
'config.toast.providerSwitchFailed': 'Provider 切换失败: {{message}}',
// ===== 系统通知 =====
'notify.updateAvailable.title': 'MetonaAI 更新可用',
'notify.updateAvailable.body': '新版本 {{version}} 已发布,可在 设置 → 日志与数据 中下载安装',
'notify.taskCompleted.title': 'MetonaAI — 任务完成',
'notify.taskCompleted.body': 'Agent 已完成任务 ({{seconds}}s)',
// ===== shared.ts 配置副作用 =====
'config.error.configIncomplete':
'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写',
'config.error.workspaceSaveFailed': '工作空间路径保存失败:{{message}}',
// ===== 记忆维护(v0.8.1 P1-2 =====
'memory.maintain.auditTitle': 'MEMORY.md 记忆整理',
};
const en: Record<string, string> = {
'agent.error.invalidSessionId': 'Invalid session ID',
'agent.error.invalidMessage': 'Invalid message format',
'agent.error.sessionBusy':
'This session is already running. Please wait for it to finish or abort it first.',
'agent.error.sessionMissing': 'Session does not exist or has been deleted. Please refresh.',
'agent.error.adapterLoadFailed':
'Adapter load failed. Check your LLM config (Provider, API Key, Base URL, Model).',
'agent.soul.fallbackToast':
'SOUL.md not found or empty — using the default Metona identity. Create SOUL.md in the workspace root to customize the agent persona.',
'agent.toast.compressed':
'Context compressed: {{original}} → {{compressed}} tokens (saved {{saved}})',
'agent.toast.deadLoop':
'Dead loop detected (iteration {{iteration}}): the same tool call repeated 3 times — aborted automatically',
'agent.toast.providerSwitched': 'Provider failover: {{from}} → {{to}} (primary provider failed)',
'agent.toast.consolidated': '{{count}} important memories were written to MEMORY.md',
'agent.toast.memoryOversize':
'MEMORY.md exceeds 50KB — consider tidying it up in the Memory panel',
'llm.balance.queryFailed': 'Balance query failed (invalid API key or network error)',
'llm.listModels.notConfigured': 'LLM not configured (Provider/Model empty) — cannot list models',
'llm.listModels.noApiKey': 'API Key not configured — cannot list models',
'llm.listModels.configInvalid': 'LLM config validation failed — fix it in Settings first',
'llm.listModels.unsupported': 'The current provider does not support model listing',
'llm.pull.ollamaOnly': 'Only the Ollama provider supports model download',
'llm.pull.inProgress': 'A model download is already in progress — cancel it first',
'llm.pull.none': 'No download in progress',
'config.toast.configIncomplete':
'LLM config incomplete. Please set Provider, API Key, Base URL and Model in Settings.',
'config.toast.providerSwitched': 'Provider switched: {{from}} → {{to}}',
'config.toast.providerSwitchFailed': 'Provider switch failed: {{message}}',
'notify.updateAvailable.title': 'MetonaAI update available',
'notify.updateAvailable.body':
'Version {{version}} has been released. Install it in Settings → Logs & Data.',
'notify.taskCompleted.title': 'MetonaAI — task completed',
'notify.taskCompleted.body': 'The agent finished the task ({{seconds}}s)',
'config.error.configIncomplete':
'LLM config incomplete. Check that Provider, API Key, Base URL and Model are all filled in.',
'config.error.workspaceSaveFailed': 'Failed to save workspace path: {{message}}',
'memory.maintain.auditTitle': 'MEMORY.md maintenance',
};
const DICTS: Record<MainLocale, Record<string, string>> = { 'zh-CN': zh, 'en-US': en };
/** 主进程文案取词:缺 key 回退 zh 表;参数以 {{name}} 占位替换 */
export function mt(key: string, params?: Record<string, string | number>): string {
const raw = DICTS[currentLocale][key] ?? zh[key] ?? key;
if (!params) return raw;
return raw.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(params[name] ?? ''));
}
+37 -3
View File
@@ -80,12 +80,46 @@ export async function applySessionProxy(proxyUrl: string | null | undefined): Pr
// ===== 通道二:主进程 Node fetchundici 全局 dispatcher===== // ===== 通道二:主进程 Node fetchundici 全局 dispatcher=====
// 动态 import:主进程所有 fetch 出口共享该调度器;setGlobalDispatcher 写入的 // 动态 import:主进程所有 fetch 出口共享该调度器;setGlobalDispatcher 写入的
// 全局符号对所有引用同一 undici registry 的 fetch 实例生效。失败不阻断主流程。 // 全局符号对所有引用同一 undici registry 的 fetch 实例生效。失败不阻断主流程。
//
// v0.8.1 根治: 代理 dispatcher 必须放行回环目标(127.0.0.1 / ::1 / localhost)。
// 此前全局 ProxyAgent 会把发往本机的请求(本地 Ollama / SearXNG / E2E mock LLM
// / 指向 localhost 的任意 Provider)也交给系统代理 —— 代理不可用或拒绝回环时
// 这类请求全部 "fetch failed"。现用组合 dispatcher:回环直连、其余走代理
// (与 NO_PROXY=127.0.0.1,localhost 的通用语义一致)。
await (async () => { await (async () => {
try { try {
const { Agent, ProxyAgent, setGlobalDispatcher } = await import('undici'); const undici = await import('undici');
const { Agent, ProxyAgent, Dispatcher, setGlobalDispatcher } = undici;
if (rules !== '') { if (rules !== '') {
setGlobalDispatcher(new ProxyAgent({ uri: rules, connectTimeout: 15_000 })); const direct = new Agent({ connectTimeout: 15_000 });
log.info(`[Network] Node fetch dispatcher → ProxyAgent(${rules})`); const proxy = new ProxyAgent({ uri: rules, connectTimeout: 15_000 });
type DispatchArgs = Parameters<InstanceType<typeof Dispatcher>['dispatch']>;
class LoopbackBypassDispatcher extends Dispatcher {
override dispatch(...args: DispatchArgs): boolean {
const opts = args[0];
let host = '';
if (typeof opts.origin === 'string') {
host = new URL(opts.origin).hostname;
} else if (opts.origin instanceof URL) {
host = opts.origin.hostname;
}
const isLoopback =
host === '127.0.0.1' ||
host === '::1' ||
host === '[::1]' ||
host === 'localhost' ||
host.endsWith('.localhost');
return (isLoopback ? direct : proxy).dispatch(...args);
}
override async close(): Promise<void> {
await Promise.all([direct.close(), proxy.close()]);
}
override async destroy(): Promise<void> {
await Promise.all([direct.destroy(), proxy.destroy()]);
}
}
setGlobalDispatcher(new LoopbackBypassDispatcher());
log.info(`[Network] Node fetch dispatcher → ProxyAgent(${rules}) + loopback bypass`);
} else { } else {
setGlobalDispatcher(new Agent()); setGlobalDispatcher(new Agent());
log.info('[Network] Node fetch dispatcher → direct Agent'); log.info('[Network] Node fetch dispatcher → direct Agent');
+44 -1
View File
@@ -17,6 +17,49 @@ import log from 'electron-log';
/** 加密值前缀标记(版本化,便于未来算法升级) */ /** 加密值前缀标记(版本化,便于未来算法升级) */
const ENCRYPTION_PREFIX = 'metona-enc:v1:'; const ENCRYPTION_PREFIX = 'metona-enc:v1:';
/**
* v0.8.1 根治: 加密可用性探测roundtrip probe
*
* isEncryptionAvailable()=true /
* DPAPI/keyring API Key
* 使 encryptdecrypt
* -
* - isEncryptionAvailable=false
* WARN "本会话明文"
*/
let encryptionUsable: boolean | null = null;
function isEncryptionUsable(): boolean {
if (encryptionUsable !== null) return encryptionUsable;
try {
if (!safeStorage.isEncryptionAvailable()) {
encryptionUsable = false;
return false;
}
const probe = 'metona-probe-0123456789abcdef';
const cipher = safeStorage.encryptString(probe);
const roundtrip = safeStorage.decryptString(cipher);
encryptionUsable = roundtrip === probe;
if (!encryptionUsable) {
log.warn(
'[SecureConfig] safeStorage roundtrip probe failed — falling back to plaintext storage for this session',
);
}
} catch (err) {
log.warn(
'[SecureConfig] safeStorage probe threw — falling back to plaintext:',
(err as Error).message,
);
encryptionUsable = false;
}
return encryptionUsable;
}
/** 测试专用:清除 roundtrip 探测缓存(生产代码不得调用) */
export function resetEncryptionUsableForTests(): void {
encryptionUsable = null;
}
/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) */ /** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) */
const SENSITIVE_KEY_PATTERNS = [ const SENSITIVE_KEY_PATTERNS = [
'apikey', 'apikey',
@@ -57,7 +100,7 @@ export function encryptConfigValue(value: unknown): unknown {
if (typeof value !== 'string' || value.length === 0) return value; if (typeof value !== 'string' || value.length === 0) return value;
if (isEncryptedValue(value)) return value; // 已加密,幂等 if (isEncryptedValue(value)) return value; // 已加密,幂等
try { try {
if (!safeStorage.isEncryptionAvailable()) { if (!isEncryptionUsable()) {
log.warn('[SecureConfig] safeStorage 不可用,敏感配置将以明文存储'); log.warn('[SecureConfig] safeStorage 不可用,敏感配置将以明文存储');
return value; return value;
} }
+64
View File
@@ -40,6 +40,7 @@
"devDependencies": { "devDependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"@playwright/test": "^1.62.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^7.0.1", "@testing-library/jest-dom": "^7.0.1",
@@ -2448,6 +2449,22 @@
"node": ">=14.18.0" "node": ">=14.18.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz",
@@ -10160,6 +10177,53 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.0", "version": "0.8.1",
"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",
@@ -20,6 +20,7 @@
"typecheck:node": "tsc -p tsconfig.node.json --noEmit", "typecheck:node": "tsc -p tsconfig.node.json --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "electron-vite build && playwright test",
"test:electron": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs run", "test:electron": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs run",
"prepare": "husky" "prepare": "husky"
}, },
@@ -68,6 +69,7 @@
"devDependencies": { "devDependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"@playwright/test": "^1.62.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^7.0.1", "@testing-library/jest-dom": "^7.0.1",
+19
View File
@@ -0,0 +1,19 @@
/**
* Playwright E2E v0.8.1 P2-5
*
* Electron `npm run test:e2e`
* build main.cjs / preload.cjs / dist/index.html
*/
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 120_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
// Electron 冒烟不需要 webServer / 浏览器 projects
});
+3 -1
View File
@@ -28,6 +28,8 @@ import { useUIStore } from '@renderer/stores/ui-store';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { metonaDarkTheme, metonaLightTheme } from '@renderer/lib/theme'; import { metonaDarkTheme, metonaLightTheme } from '@renderer/lib/theme';
// v0.8.1 P0-4: 文案出层
import { t } from '@renderer/lib/i18n';
// v0.7.2 P4-15: 界面语言偏好(ui.locale)启动恢复 // v0.7.2 P4-15: 界面语言偏好(ui.locale)启动恢复
import { setLocale, type Locale } from '@renderer/lib/i18n'; import { setLocale, type Locale } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
@@ -50,7 +52,7 @@ function useTrayActions(): void {
.catch((err) => { .catch((err) => {
console.error('[Tray] create session failed:', err); console.error('[Tray] create session failed:', err);
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error('创建会话失败')) .then((mod) => mod.default.error(t('agent.error.createFailedToast')))
.catch(() => {}); .catch(() => {});
}); });
}); });
+22
View File
@@ -173,6 +173,28 @@ export function ConfirmationDialog(): React.JSX.Element | null {
return cleanup; return cleanup;
}, [refreshPending]); }, [refreshPending]);
// ===== v0.8.1 P2-2: 批量确认事件 —— 主进程聚合(>=3 同类并行请求)时单事件
// 携带全部请求;复用 refreshPending 拉取完整列表(倒计时按最新请求重置) =====
useEffect(() => {
if (!window.metona?.tool?.onConfirmationRequestBatch) return;
const cleanup = window.metona.tool.onConfirmationRequestBatch((requests: unknown[]) => {
if (requests.length === 0) return;
const req = requests[0] as ConfirmationRequest;
refreshPending(req);
setRemember(false);
setAutoExecute(false);
const expires = Math.min(
...requests.map((r) => (r as ConfirmationRequest).expiresAt ?? 0).filter((e) => e > 0),
);
if (expires > 0) {
const remain = Math.max(0, expires - Date.now());
setRemainingMs(remain);
setInitialMs(remain);
}
});
return cleanup;
}, [refreshPending]);
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATEDrun 结束/abort)时清空该会话的前端 state ===== // ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATEDrun 结束/abort)时清空该会话的前端 state =====
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步, // 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。 // 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
+197 -9
View File
@@ -26,6 +26,7 @@ import { Send, Paperclip, Square, X, FileText, Image as ImageIcon, AtSign } from
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTokens } from '@renderer/lib/formatters';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
import { useUIStore } from '@renderer/stores/ui-store'; import { useUIStore } from '@renderer/stores/ui-store';
import { formatFileSize } from '@renderer/lib/formatters'; import { formatFileSize } from '@renderer/lib/formatters';
@@ -109,6 +110,16 @@ export function ChatInput(): React.JSX.Element {
const [mentionIndex, setMentionIndex] = useState(0); const [mentionIndex, setMentionIndex] = useState(0);
const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]); const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]);
const workspaceFilesLoadedRef = useRef(false); const workspaceFilesLoadedRef = useRef(false);
// ===== v0.8.1 P1-4: MCP Prompts / Resources 对话内可用化 =====
/** 已连接 server 的 prompts(斜杠菜单数据源) */
const [mcpPrompts, setMcpPrompts] = useState<
Array<{ server: string; name: string; description?: string }>
>([]);
/** 已连接 server 的 resources@ 提及数据源) */
const [mcpResources, setMcpResources] = useState<
Array<{ server: string; uri: string; name: string }>
>([]);
const mcpContentsLoadedRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const sendMessage = useAgentStore((s) => s.sendMessage); const sendMessage = useAgentStore((s) => s.sendMessage);
@@ -123,6 +134,10 @@ export function ChatInput(): React.JSX.Element {
// v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置) // v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置)
const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled); const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled);
const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps); const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps);
// v0.8.1 P1-3: 输入框上下文占用指示条(UI/UX 设计预留项落地 —— 单次占用 /
// 设置面板「上下文长度」,60%/80% 变色;窗口未配置或无占用数据时隐藏)
const contextWindow = useAgentStore((s) => s.contextWindow);
const lastInputTokens = useAgentStore((s) => s.tokenUsage.lastInputTokens);
const imageGate = supportsImageUpload({ const imageGate = supportsImageUpload({
multimodalEnabled, multimodalEnabled,
provider, provider,
@@ -321,12 +336,18 @@ export function ChatInput(): React.JSX.Element {
if (i > 0 && /\w/.test(text[i - 1])) continue; if (i > 0 && /\w/.test(text[i - 1])) continue;
let j = i + 1; let j = i + 1;
let path = ''; let path = '';
while (j < text.length && /[A-Za-z0-9._\-/]/.test(text[j])) { // v0.8.1 P1-4: token 集合扩展 ':' 以承载 @mcp:{server}:{uri} 引用
while (j < text.length && /[A-Za-z0-9._\-/:]/.test(text[j])) {
path += text[j]; path += text[j];
j++; j++;
} }
const isMcpRef = path.startsWith('mcp:');
path = path.replace(/\.+$/, ''); path = path.replace(/\.+$/, '');
if (path && (path.includes('/') || path.includes('.'))) out.push(path); if (isMcpRef && path.length > 'mcp:'.length + 1) {
out.push(path);
} else if (path && (path.includes('/') || path.includes('.'))) {
out.push(path);
}
i = j - 1; i = j - 1;
} }
return [...new Set(out)]; return [...new Set(out)];
@@ -375,6 +396,35 @@ export function ChatInput(): React.JSX.Element {
setShowSlashMenu(false); setShowSlashMenu(false);
return; return;
} }
// v0.8.1 P1-4: /mcp:{server}:{prompt} — MCP Prompt 渲染填充输入框。
// v0.8.1 review 修复: cmd 已被 toLowerCase() —— server 名含大写时直接取
// 子串会与 MCPManager 的原始名 Map key 失配("not connected")。改为对
// mcpPrompts 清单做大小写不敏感匹配,反查原始 server/prompt 名。
if (cmd.startsWith('/mcp:')) {
const wanted = cmd.slice('/mcp:'.length); // 小写化后的 "server:prompt"
const match = mcpPrompts.find((p) => `${p.server}:${p.name}`.toLowerCase() === wanted);
if (match) {
const res = await window.metona?.mcp?.getPrompt?.(match.server, match.name);
if (res?.success && res.data?.text) {
setInput(res.data.text);
} else {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.slash.mcpPromptFailed', {
prompt: `${match.server}:${match.name}`,
reason: res?.error ?? '',
}),
),
)
.catch(() => {});
setInput('');
}
setAttachments([]);
setShowSlashMenu(false);
return;
}
}
// v0.3.0: /memory — 切换到详情面板的 Memory 标签 // v0.3.0: /memory — 切换到详情面板的 Memory 标签
// v0.3.0 修复:确保详情面板可见,否则切换标签用户看不到 // v0.3.0 修复:确保详情面板可见,否则切换标签用户看不到
if (cmd === '/memory') { if (cmd === '/memory') {
@@ -430,6 +480,51 @@ export function ChatInput(): React.JSX.Element {
}> = []; }> = [];
const mentionedPaths = extractMentions(messageContent).slice(0, 5); const mentionedPaths = extractMentions(messageContent).slice(0, 5);
for (const p of mentionedPaths) { for (const p of mentionedPaths) {
// v0.8.1 P1-4: MCP resource 引用分流 —— mcp:{server}:{uri} → resources/read
//(512KB 上限由主进程统一截断,二进制拒绝);其余走工作空间文件片段
if (p.startsWith('mcp:')) {
const rest = p.slice('mcp:'.length);
const sepIdx = rest.indexOf(':');
const mcpServer = sepIdx > 0 ? rest.slice(0, sepIdx) : '';
const mcpUri = sepIdx > 0 ? rest.slice(sepIdx + 1) : '';
try {
const res = await window.metona?.mcp?.readResource?.(mcpServer, mcpUri);
if (res?.success && res.data) {
let textContent = res.data.text;
if (res.data.truncated) {
textContent += t('input.text.truncatedNote', {
size: MAX_TEXT_ATTACHMENT_BYTES,
limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES),
});
}
mentionInfos.push({
id: `mcp_${nanoid(6)}`,
name: p,
type: 'text',
size: res.data.text.length,
textContent,
truncated: res.data.truncated,
});
} else {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.mention.failed', { path: p, reason: res?.error ?? '' }),
),
)
.catch(() => {});
}
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.mention.failed', { path: p, reason: (err as Error).message }),
),
)
.catch(() => {});
}
continue;
}
try { try {
const clip = await window.metona?.workspace?.readFileClip?.(p); const clip = await window.metona?.workspace?.readFileClip?.(p);
if (clip?.success && typeof clip.content === 'string') { if (clip?.success && typeof clip.content === 'string') {
@@ -487,15 +582,18 @@ export function ChatInput(): React.JSX.Element {
}, [abort]); }, [abort]);
// v0.8.0 P2-4: @ 联想候选(Fuse 模糊匹配;query 为空时按路径字典序取前 8 条) // v0.8.0 P2-4: @ 联想候选(Fuse 模糊匹配;query 为空时按路径字典序取前 8 条)
// v0.8.1 P1-4: 候选池合并 MCP resourcestoken 形态 @mcp:{server}:{uri}
const mentionCandidates = useMemo(() => { const mentionCandidates = useMemo(() => {
if (workspaceFiles.length === 0) return []; const mcpTokens = mcpResources.map((r) => `mcp:${r.server}:${r.uri}`);
if (!mentionQuery) return [...workspaceFiles].sort().slice(0, 8); const pool = [...workspaceFiles, ...mcpTokens];
const fuse = new Fuse(workspaceFiles, { includeScore: true, threshold: 0.4 }); if (pool.length === 0) return [];
if (!mentionQuery) return [...workspaceFiles].sort().slice(0, 8).concat(mcpTokens.slice(0, 4));
const fuse = new Fuse(pool, { includeScore: true, threshold: 0.4 });
return fuse return fuse
.search(mentionQuery) .search(mentionQuery)
.map((r) => r.item) .map((r) => r.item)
.slice(0, 8); .slice(0, 8);
}, [workspaceFiles, mentionQuery]); }, [workspaceFiles, mcpResources, mentionQuery]);
// v0.8.0 P2-4: 选中候选 —— 把 "@query" 片段替换为 "@path" // v0.8.0 P2-4: 选中候选 —— 把 "@query" 片段替换为 "@path"
const applyMention = useCallback( const applyMention = useCallback(
@@ -628,6 +726,37 @@ export function ChatInput(): React.JSX.Element {
}) })
.catch((err) => console.error('[ChatInput] listFiles failed:', err)); .catch((err) => console.error('[ChatInput] listFiles failed:', err));
} }
// v0.8.1 P1-4: 首次触发提及/斜杠时懒加载 MCP prompts/resources(失败静默
// —— server 不支持该能力时菜单自然不出现,不影响文件提及)
if (!mcpContentsLoadedRef.current) {
mcpContentsLoadedRef.current = true;
void window.metona?.mcp
?.listServers?.()
.then(async (servers) => {
const states = (servers ?? []) as Array<{ name: string; status: string }>;
const connected = states.filter((sv) => sv.status === 'connected');
const prompts: Array<{ server: string; name: string; description?: string }> = [];
const resources: Array<{ server: string; uri: string; name: string }> = [];
for (const sv of connected) {
try {
const contents = await window.metona?.mcp?.listServerContents?.(sv.name);
if (contents?.success && contents.data) {
for (const pr of contents.data.prompts ?? []) {
prompts.push({ server: sv.name, name: pr.name, description: pr.description });
}
for (const rs of contents.data.resources ?? []) {
resources.push({ server: sv.name, uri: rs.uri, name: rs.name || rs.uri });
}
}
} catch {
/* 单个 server 失败不影响其他 */
}
}
setMcpPrompts(prompts);
setMcpResources(resources);
})
.catch(() => {});
}
} else { } else {
setMentionOpen(false); setMentionOpen(false);
setMentionQuery(''); setMentionQuery('');
@@ -636,7 +765,14 @@ export function ChatInput(): React.JSX.Element {
[], [],
); );
const filteredCommands = SLASH_COMMANDS.filter((c) => // v0.8.1 P1-4: MCP prompt 动态斜杠命令(/mcp:{server}:{prompt})——
// 与内置命令合并展示;server 未提供 prompts 时自然为空
const mcpCommands = mcpPrompts.map((p) => ({
id: `mcp:${p.server}:${p.name}`,
label: `/mcp:${p.server}:${p.name}`,
description: () => p.description ?? t('input.slash.mcpPrompt'),
}));
const allCommands = [...SLASH_COMMANDS, ...mcpCommands].filter((c) =>
c.label.toLowerCase().includes(`/${slashFilter}`), c.label.toLowerCase().includes(`/${slashFilter}`),
); );
@@ -656,6 +792,58 @@ export function ChatInput(): React.JSX.Element {
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
> >
{/* v0.8.1 P1-3: 上下文占用指示条(最近一次 LLM 输入占用 / 设置面板「上下文长度」) */}
{contextWindow > 0 && lastInputTokens > 0 && (
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center', px: 1.5, pt: 0.75, pb: 0.25 }}
>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
fontSize: 10,
color:
lastInputTokens / contextWindow > 0.8
? 'error.main'
: lastInputTokens / contextWindow > 0.6
? 'warning.main'
: 'text.disabled',
}}
>
{t('input.contextIndicator', {
used: formatTokens(lastInputTokens),
total: formatTokens(contextWindow),
percent: ((lastInputTokens / contextWindow) * 100).toFixed(0),
})}
</Typography>
<Box
sx={{
flex: 1,
height: 3,
borderRadius: 2,
bgcolor: 'action.hover',
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
width: `${Math.min((lastInputTokens / contextWindow) * 100, 100)}%`,
bgcolor:
lastInputTokens / contextWindow > 0.8
? 'error.main'
: lastInputTokens / contextWindow > 0.6
? 'warning.main'
: 'info.main',
transition: 'width 300ms',
}}
/>
</Box>
</Stack>
)}
{/* 附件预览区 */} {/* 附件预览区 */}
{attachments.length > 0 && ( {attachments.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}> <Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}>
@@ -671,7 +859,7 @@ export function ChatInput(): React.JSX.Element {
{/* / v0.7.4 P3-7: 手写 div MUI Paper+List MUI {/* / v0.7.4 P3-7: 手写 div MUI Paper+List MUI
UI // MUI */} UI // MUI */}
{showSlashMenu && filteredCommands.length > 0 && ( {showSlashMenu && allCommands.length > 0 && (
<Paper <Paper
elevation={8} elevation={8}
sx={{ sx={{
@@ -690,7 +878,7 @@ export function ChatInput(): React.JSX.Element {
}} }}
> >
<List dense disablePadding> <List dense disablePadding>
{filteredCommands.map((cmd) => ( {allCommands.map((cmd) => (
<ListItemButton <ListItemButton
key={cmd.id} key={cmd.id}
onClick={() => { onClick={() => {
+6
View File
@@ -37,6 +37,8 @@ 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);
const currentSessionId = useAgentStore((s) => s.currentSessionId); const currentSessionId = useAgentStore((s) => s.currentSessionId);
// v0.8.1 P2-3: 游标分页 —— 滚动到顶部触发向上加载
const loadOlderMessages = useAgentStore((s) => s.loadOlderMessages);
const virtuosoRef = useRef<VirtuosoHandle>(null); const virtuosoRef = useRef<VirtuosoHandle>(null);
// 用户是否处于列表底部(离开底部=上翻查看历史,此时流式输出不强制拉底) // 用户是否处于列表底部(离开底部=上翻查看历史,此时流式输出不强制拉底)
@@ -104,6 +106,10 @@ export function MessageList(): React.JSX.Element {
atBottomStateChange={(atBottom) => { atBottomStateChange={(atBottom) => {
atBottomRef.current = atBottom; atBottomRef.current = atBottom;
}} }}
// v0.8.1 P2-3: 滚动到顶部 → 游标加载更早消息(store 内部防重入 + 完整性判定)
startReached={() => {
void loadOlderMessages();
}}
itemContent={(index, msg) => ( itemContent={(index, msg) => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}> <Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
<MessageItem <MessageItem
+3 -1
View File
@@ -59,7 +59,9 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
} catch (err) { } catch (err) {
console.error('[UserMessage] save failed:', err); console.error('[UserMessage] save failed:', err);
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)) .then((mod) =>
mod.default.error(t('agent.error.saveFailed', { message: (err as Error).message })),
)
.catch(() => {}); .catch(() => {});
// 保留编辑态,让用户重试或复制内容 // 保留编辑态,让用户重试或复制内容
} }
+218 -2
View File
@@ -18,8 +18,16 @@ import {
Chip, Chip,
Alert, Alert,
InputAdornment, InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Checkbox,
FormControlLabel,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react'; import { Brain, Search, Trash2, ChevronDown, Wand2 } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { useUIStore } from '@renderer/stores/ui-store'; import { useUIStore } from '@renderer/stores/ui-store';
import { formatTime, truncate } from '@renderer/lib/formatters'; import { formatTime, truncate } from '@renderer/lib/formatters';
@@ -41,6 +49,15 @@ interface MemoryItem {
created_at?: number; created_at?: number;
} }
/** v0.8.1 P1-2: 维护动作(与主进程 MemoryMaintainer 的类型对齐) */
interface MaintenanceAction {
action: 'delete' | 'update';
section: string;
entry: string;
newEntry?: string;
reason?: string;
}
interface SearchResult { interface SearchResult {
id: string; id: string;
type: MemoryType; type: MemoryType;
@@ -103,6 +120,16 @@ export function MemoryViewer(): React.JSX.Element {
() => new Set<MemoryType>(MEMORY_TYPES), () => new Set<MemoryType>(MEMORY_TYPES),
); );
// ===== v0.8.1 P1-2: 记忆整理(MEMORY.md 维护闭环) =====
const [maintaining, setMaintaining] = useState(false);
const [maintOpen, setMaintOpen] = useState(false);
const [maintActions, setMaintActions] = useState<MaintenanceAction[]>([]);
const [maintSelected, setMaintSelected] = useState<Set<number>>(new Set());
const [maintTotal, setMaintTotal] = useState(0);
/** v0.8.1 review (O2): 各分区条目数(应用选中动作后计算将变空的分区) */
const [maintSectionCounts, setMaintSectionCounts] = useState<Record<string, number>>({});
const [maintError, setMaintError] = useState<string | null>(null);
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle // Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus); const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus); const prevStatus = useRef(agentStatus);
@@ -212,6 +239,87 @@ export function MemoryViewer(): React.JSX.Element {
} }
}; };
// ===== v0.8.1 P1-2: 记忆整理流程(阶段一分析 / 阶段二应用) =====
const handleAnalyze = async () => {
if (!window.metona?.memory?.analyzeMaintenance || maintaining) return;
setMaintaining(true);
setMaintError(null);
try {
const res = await window.metona.memory.analyzeMaintenance();
if (!res.success || !res.data) {
setMaintError(res.error ?? t('memory.maintain.failed'));
return;
}
setMaintActions(res.data.actions);
setMaintTotal(res.data.totalEntries);
setMaintSectionCounts(res.data.sectionEntryCounts ?? {});
// 默认全选(用户可取消勾选)
setMaintSelected(new Set(res.data.actions.map((_, i) => i)));
setMaintOpen(true);
} catch (err) {
setMaintError((err as Error).message ?? t('memory.maintain.failed'));
} finally {
setMaintaining(false);
}
};
const handleApply = async () => {
if (!window.metona?.memory?.applyMaintenance) return;
const chosen = maintActions.filter((_, i) => maintSelected.has(i));
if (chosen.length === 0) {
setMaintOpen(false);
return;
}
setMaintaining(true);
try {
const res = await window.metona.memory.applyMaintenance(chosen);
if (!res.success || !res.data) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(res.error ?? t('memory.maintain.failed')))
.catch(() => {});
return;
}
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.success(
t('memory.maintain.applied', {
applied: res.data!.applied,
skipped: res.data!.skipped,
}),
),
)
.catch(() => {});
setMaintOpen(false);
await loadMemories();
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error((err as Error).message ?? t('memory.maintain.failed')))
.catch(() => {});
} finally {
setMaintaining(false);
}
};
// v0.8.1 review (O2): 应用选中动作后将变空的分区(全部条目被删除且无 update 补充)
const emptyAfterApply = (() => {
const remaining: Record<string, number> = { ...maintSectionCounts };
for (const a of maintActions.filter((_, i) => maintSelected.has(i))) {
if (a.action === 'delete') remaining[a.section] = (remaining[a.section] ?? 1) - 1;
}
return Object.entries(remaining)
.filter(([, n]) => n <= 0)
.map(([sec]) => sec);
})();
const toggleMaintAction = (idx: number): void => {
setMaintSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length; const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length;
// 搜索结果视图 // 搜索结果视图
@@ -332,7 +440,19 @@ export function MemoryViewer(): React.JSX.Element {
> >
{t('memory.list.title')} {t('memory.list.title')}
</Typography> </Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}> <Button
size="small"
variant="text"
onClick={handleAnalyze}
disabled={maintaining}
startIcon={
maintaining ? <CircularProgress size={11} color="inherit" /> : <Wand2 size={12} />
}
sx={{ ml: 'auto', fontSize: 10, minWidth: 0, px: 0.75, flexShrink: 0 }}
>
{t('memory.maintain.button')}
</Button>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, flexShrink: 0 }}>
{t('memory.total', { count: totalCount })} {t('memory.total', { count: totalCount })}
</Typography> </Typography>
</Stack> </Stack>
@@ -447,6 +567,102 @@ export function MemoryViewer(): React.JSX.Element {
}) })
)} )}
</Box> </Box>
{/* ===== v0.8.1 P1-2: 记忆整理建议弹框(两阶段:用户勾选后应用) ===== */}
<Dialog open={maintOpen} onClose={() => setMaintOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}>{t('memory.maintain.title')}</DialogTitle>
<DialogContent dividers>
{maintError && (
<Alert severity="error" sx={{ mb: 1, fontSize: 11 }}>
{maintError}
</Alert>
)}
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1 }}>
{t('memory.maintain.summary', { total: maintTotal, actions: maintActions.length })}
</Typography>
{emptyAfterApply.length > 0 && (
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block', mb: 1 }}>
{t('memory.maintain.emptySectionHint', { sections: emptyAfterApply.join('、') })}
</Typography>
)}
{maintActions.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.disabled', py: 2, display: 'block' }}>
{t('memory.maintain.empty')}
</Typography>
) : (
<Stack spacing={0.5}>
{maintActions.map((a, i) => (
<Box
key={`${a.action}-${i}`}
sx={{
px: 1,
py: 0.5,
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<FormControlLabel
control={
<Checkbox
size="small"
checked={maintSelected.has(i)}
onChange={() => toggleMaintAction(i)}
/>
}
label={
<Stack>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Chip
label={
a.action === 'delete'
? t('memory.maintain.delete')
: t('memory.maintain.merge')
}
size="small"
sx={{
height: 14,
fontSize: 9,
mr: 0.5,
bgcolor: (a.action === 'delete' ? '#ef4444' : '#22d3ee') + '22',
color: a.action === 'delete' ? '#ef4444' : '#22d3ee',
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Typography variant="caption" sx={{ fontSize: 11 }}>
[{a.section}] {truncate(a.entry, 60)}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
{a.action === 'update' && a.newEntry
? `${truncate(a.newEntry, 60)}`
: ''}
{a.reason ? ` · ${a.reason}` : ''}
</Typography>
</Stack>
}
/>
</Box>
))}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setMaintOpen(false)}>
{t('common.cancel')}
</Button>
<Button
size="small"
variant="contained"
onClick={handleApply}
disabled={maintaining || maintSelected.size === 0}
>
{maintaining
? t('memory.maintain.applying')
: t('memory.maintain.apply', { count: maintSelected.size })}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }
+14 -35
View File
@@ -56,7 +56,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [workspacePath, setWorkspacePath] = useState(''); const [workspacePath, setWorkspacePath] = useState('');
// 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX // 上下文长度(v0.8.1: 全局单一配置 llm.contextWindow,跨 Provider 生效,不预填默认值
const [contextWindow, setContextWindow] = useState<number | null>(null); const [contextWindow, setContextWindow] = useState<number | null>(null);
// #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写 // #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写
@@ -112,18 +112,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (onboardingCompleted) return null; if (onboardingCompleted) return null;
// 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致) // v0.8.1 硬性契约: 上下文长度是全局单一配置(llm.contextWindow),不再存在
// ollama 返回 null(由模型决定),其他 provider 返回正整数 // 分 Provider 默认值 —— 删除了旧的 DEFAULT_CTX 写死表(1M/128K/200K/4096)。
const DEFAULT_CTX: Record<string, number | null> = { // 引导页不再预填任何窗口数值,留空时由设置面板种子默认值(131072)兜底。
deepseek: 1_000_000, const ctxMin = 512;
agnes: 1_000_000,
mimo: 1_000_000,
ollama: null,
openai: 128_000,
anthropic: 200_000,
};
// 上下文窗口校验:ollama 允许空,最小 512;其他 provider 最小 4096
const ctxMin = provider === 'ollama' ? 512 : 4096;
const ctxError = const ctxError =
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin); contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
@@ -149,13 +141,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
entries.push({ key: 'llm.multimodalEnabled', value: multimodalEnabled }); entries.push({ key: 'llm.multimodalEnabled', value: multimodalEnabled });
if (workspacePath.trim()) if (workspacePath.trim())
entries.push({ key: 'workspace.path', value: workspacePath.trim() }); entries.push({ key: 'workspace.path', value: workspacePath.trim() });
// 上下文窗口:根据 Provider 落库到对应 key // v0.8.1: 上下文长度全局单一配置(llm.contextWindow,跨 Provider 生效;
// - ollama: ollama.numCtx(允许 null=由模型决定) // Ollama 场景由主进程同步为 num_ctx)—— 分 Provider 键已废除
// - deepseek/agnes/mimo: {provider}.contextWindow(必须有值且 >= 4096 if (contextWindow != null && contextWindow >= ctxMin) {
if (provider === 'ollama') { entries.push({ key: 'llm.contextWindow', value: contextWindow });
entries.push({ key: 'ollama.numCtx', value: contextWindow });
} else if (provider && contextWindow != null && contextWindow >= 4096) {
entries.push({ key: `${provider}.contextWindow`, value: contextWindow });
} }
entries.push({ key: 'onboarding.completed', value: true }); entries.push({ key: 'onboarding.completed', value: true });
@@ -259,8 +248,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
setProvider(v); setProvider(v);
// 联动默认上下文窗口(与 SettingsModal 默认值一致) // v0.8.1: 上下文长度为全局单一配置,切换 Provider 不再预填
setContextWindow(DEFAULT_CTX[v] ?? null); // 任何分 Provider 默认值(DEFAULT_CTX 写死表已删除)
// D-3 修复: 自动填充默认 URL(与 LLMSettings 行为一致 — // D-3 修复: 自动填充默认 URL(与 LLMSettings 行为一致 —
// 仅在 URL 为空或仍是某 Provider 的默认值时覆盖,用户自定义 URL 不动) // 仅在 URL 为空或仍是某 Provider 的默认值时覆盖,用户自定义 URL 不动)
const currentUrl = baseURL.trim(); const currentUrl = baseURL.trim();
@@ -313,30 +302,20 @@ export function OnboardingWizard(): React.JSX.Element | null {
/> />
<TextField <TextField
size="small" size="small"
label={ label={t('onboarding.llm.ctx.window')}
provider === 'ollama'
? t('onboarding.llm.ctx.ollama')
: t('onboarding.llm.ctx.window')
}
type="number" type="number"
value={contextWindow ?? ''} value={contextWindow ?? ''}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
setContextWindow(v === '' ? null : Number(v)); setContextWindow(v === '' ? null : Number(v));
}} }}
placeholder={ placeholder={t('onboarding.llm.ctx.placeholder')}
provider === 'ollama'
? t('onboarding.llm.ctx.ollamaPlaceholder')
: t('onboarding.llm.ctx.placeholder')
}
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }} slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
error={ctxError} error={ctxError}
helperText={ helperText={
ctxError ctxError
? t('onboarding.llm.ctx.minError', { min: ctxMin }) ? t('onboarding.llm.ctx.minError', { min: ctxMin })
: provider === 'ollama' : t('onboarding.llm.ctx.helper')
? ' '
: t('onboarding.llm.ctx.helper')
} }
/> />
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} {/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
+15
View File
@@ -30,6 +30,8 @@ export function AgentSettings() {
const [reflection, setReflection] = useConfig('agent.enableReflection', false); const [reflection, setReflection] = useConfig('agent.enableReflection', false);
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000); const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000); const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000);
// v0.8.1 P1-1: 本地向量记忆嵌入模型(memory.embeddingModel;空 = 向量检索关闭)
const [embeddingModel, setEmbeddingModel] = useConfig('memory.embeddingModel', '');
const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512]; const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512];
@@ -133,6 +135,19 @@ export function AgentSettings() {
</Typography> </Typography>
} }
/> />
{/* ===== v0.8.1 P1-1: 本地向量记忆(Ollama embedding 模型,空 = 关闭) ===== */}
<Typography variant="subtitle2" sx={{ fontWeight: 600, mt: 1 }}>
{t('settings.agent.memory.title')}
</Typography>
<TextField
size="small"
label={t('settings.agent.memory.embeddingModel')}
value={embeddingModel}
onChange={(e) => setEmbeddingModel(e.target.value)}
placeholder={t('settings.agent.memory.embeddingPlaceholder')}
helperText={t('settings.agent.memory.embeddingHelper')}
/>
</Stack> </Stack>
); );
} }
+52 -1
View File
@@ -10,7 +10,8 @@
* - App.tsx ui.locale zh-CN * - App.tsx ui.locale zh-CN
*/ */
import { Button, Stack, Typography, Box } from '@mui/material'; import { Button, Stack, Typography, Box, Switch, FormControlLabel } from '@mui/material';
import { useEffect, useState } from 'react';
import type { ThemeMode } from '@renderer/stores/ui-store'; import type { ThemeMode } from '@renderer/stores/ui-store';
import { useConfig } from './useConfig'; import { useConfig } from './useConfig';
import { setLocale, getLocale, type Locale } from '@renderer/lib/i18n'; import { setLocale, getLocale, type Locale } from '@renderer/lib/i18n';
@@ -35,6 +36,8 @@ export function AppearanceSettings({
}) { }) {
// v0.7.2 P4-15: 界面语言(ui.locale 全局配置;useConfig 已含失败回滚与竞态保护) // v0.7.2 P4-15: 界面语言(ui.locale 全局配置;useConfig 已含失败回滚与竞态保护)
const [locale, setLocaleConfig] = useConfig<Locale>('ui.locale', 'zh-CN'); const [locale, setLocaleConfig] = useConfig<Locale>('ui.locale', 'zh-CN');
// ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings 封装;读回真实生效状态) =====
const [openAtLogin, setOpenAtLogin] = useState(false);
const handleLocaleChange = (next: Locale): void => { const handleLocaleChange = (next: Locale): void => {
if (next === getLocale()) return; if (next === getLocale()) return;
@@ -42,6 +45,35 @@ export function AppearanceSettings({
void setLocale(next); void setLocale(next);
}; };
useEffect(() => {
window.metona?.app
?.getLoginItem?.()
.then((r) => {
if (r.success && r.data) setOpenAtLogin(r.data.openAtLogin);
})
.catch(() => {});
}, []);
const handleLoginItem = (enabled: boolean): void => {
setOpenAtLogin(enabled); // 乐观更新
window.metona?.app
?.setLoginItem?.(enabled)
.then((r) => {
if (r.success && r.data) {
// 以平台真实生效状态为准(Linux 不可用时保持 false
setOpenAtLogin(r.data.openAtLogin);
if (enabled && !r.data.openAtLogin) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.warning(t('settings.appearance.autoStart.unsupported')))
.catch(() => {});
}
} else {
setOpenAtLogin(!enabled); // 回滚
}
})
.catch(() => setOpenAtLogin(!enabled));
};
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
@@ -88,6 +120,25 @@ export function AppearanceSettings({
{t('settings.appearance.localeHelper')} {t('settings.appearance.localeHelper')}
</Typography> </Typography>
</Box> </Box>
{/* ===== v0.8.1 P2-4: 开机自启 ===== */}
<FormControlLabel
control={
<Switch
size="small"
checked={openAtLogin}
onChange={(e) => handleLoginItem(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2">{t('settings.appearance.autoStart')}</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{t('settings.appearance.autoStartHelper')}
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Stack> </Stack>
); );
} }
+97 -199
View File
@@ -32,8 +32,6 @@ import { PROVIDER_LABELS } from '@renderer/lib/constants';
import { t } from '@renderer/lib/i18n'; import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
import { PROVIDER_URLS } from './useConfig'; import { PROVIDER_URLS } from './useConfig';
// v0.8.0 FEAT-1: 输出上限配置可见性门控(Provider 支持矩阵)
import { supportsOutputLimitConfig } from '@renderer/lib/model-capabilities';
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */ /** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
interface ModelOption { interface ModelOption {
@@ -41,8 +39,6 @@ interface ModelOption {
name?: string; name?: string;
supportsToolCalling?: boolean; supportsToolCalling?: boolean;
supportsThinking?: boolean; supportsThinking?: boolean;
/** v0.8.0 FEAT-1: 模型输出上限(llm:listModels 元数据,用于钳制提示) */
maxOutputTokens?: number;
description?: string; description?: string;
} }
@@ -55,18 +51,15 @@ export function LLMSettings() {
const [model, setModel] = useState<string>(''); const [model, setModel] = useState<string>('');
const [apiKey, setApiKey] = useState<string>(''); const [apiKey, setApiKey] = useState<string>('');
const [baseURL, setBaseURL] = useState<string>(''); const [baseURL, setBaseURL] = useState<string>('');
const [numCtx, setNumCtx] = useState<number | null>(null); // v0.8.1 硬性契约: 上下文长度与最大输出上限是全局唯一合法配置
// v0.8.0 FEAT-1: 最大输出上限(llm.maxTokens,按请求传给 APInull = 使用引擎默认 //llm.contextWindow / llm.maxTokens,跨 Provider/模型生效;代码中不存在任何写死值
const [contextWindow, setContextWindow] = useState<number | null>(131072);
const [maxOutputTokens, setMaxOutputTokens] = useState<number | null>(null); const [maxOutputTokens, setMaxOutputTokens] = useState<number | null>(null);
// v0.8.1 P1-3: 成本估算单价(可选,每百万 tokens;未配置时成本显示隐藏)
const [priceInput, setPriceInput] = useState<number | null>(null);
const [priceOutput, setPriceOutput] = useState<number | null>(null);
// v0.8.0 P0-3: agent.enableThinking(模型不支持思考时的联动提示) // v0.8.0 P0-3: agent.enableThinking(模型不支持思考时的联动提示)
const [agentThinkingEnabled, setAgentThinkingEnabled] = useState(true); const [agentThinkingEnabled, setAgentThinkingEnabled] = useState(true);
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
// P3: OpenAI/Anthropic contextWindow
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
// P1: 故障转移 Provider 配置 // P1: 故障转移 Provider 配置
const [fbProvider, setFbProvider] = useState<string>(''); const [fbProvider, setFbProvider] = useState<string>('');
const [fbModel, setFbModel] = useState<string>(''); const [fbModel, setFbModel] = useState<string>('');
@@ -230,12 +223,11 @@ export function LLMSettings() {
window.metona.config.get('llm.model'), window.metona.config.get('llm.model'),
window.metona.config.get('llm.apiKey'), window.metona.config.get('llm.apiKey'),
window.metona.config.get('llm.baseURL'), window.metona.config.get('llm.baseURL'),
window.metona.config.get('ollama.numCtx'), // v0.8.1: 全局唯一「上下文长度」/「最大输出上限」/ 可选单价
window.metona.config.get('deepseek.contextWindow'), window.metona.config.get('llm.contextWindow'),
window.metona.config.get('agnes.contextWindow'), window.metona.config.get('llm.maxTokens'),
window.metona.config.get('mimo.contextWindow'), window.metona.config.get('llm.priceInput'),
window.metona.config.get('openai.contextWindow'), window.metona.config.get('llm.priceOutput'),
window.metona.config.get('anthropic.contextWindow'),
// P1: 故障转移配置 // P1: 故障转移配置
window.metona.config.get('llm.fallbackProvider'), window.metona.config.get('llm.fallbackProvider'),
window.metona.config.get('llm.fallbackModel'), window.metona.config.get('llm.fallbackModel'),
@@ -243,23 +235,20 @@ export function LLMSettings() {
window.metona.config.get('llm.fallbackBaseURL'), window.metona.config.get('llm.fallbackBaseURL'),
// v0.5.4: 多模态开关 // v0.5.4: 多模态开关
window.metona.config.get('llm.multimodalEnabled'), window.metona.config.get('llm.multimodalEnabled'),
// v0.8.0 FEAT-1: 最大输出上限 + 思考开关(P0-3 模型能力联动提示) // v0.8.0 P0-3: 思考开关(模型能力联动提示)
window.metona.config.get('llm.maxTokens'),
window.metona.config.get('agent.enableThinking'), window.metona.config.get('agent.enableThinking'),
]); ]);
if (cancelled) return; if (cancelled) return;
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm, mt, et] = results; const [p, m, k, u, cw, mt, pi, po, fbp, fbm, fbk, fbu, mm, et] = results;
setProvider((p as string) ?? ''); setProvider((p as string) ?? '');
setModel((m as string) ?? ''); setModel((m as string) ?? '');
setApiKey((k as string) ?? ''); setApiKey((k as string) ?? '');
setBaseURL((u as string) ?? ''); setBaseURL((u as string) ?? '');
setNumCtx((nc as number | null) ?? null); // v0.8.1 review: 用户清空(null)= 未配置语义,尊重之(不回填种子值)
setContextWindow(typeof cw === 'number' && cw > 0 ? cw : null);
if (typeof mt === 'number' && mt > 0) setMaxOutputTokens(mt); if (typeof mt === 'number' && mt > 0) setMaxOutputTokens(mt);
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds); if (typeof pi === 'number' && pi > 0) setPriceInput(pi);
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag); if (typeof po === 'number' && po > 0) setPriceOutput(po);
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa);
if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an);
setFbProvider((fbp as string) ?? ''); setFbProvider((fbp as string) ?? '');
setFbModel((fbm as string) ?? ''); setFbModel((fbm as string) ?? '');
setFbApiKey((fbk as string) ?? ''); setFbApiKey((fbk as string) ?? '');
@@ -284,74 +273,36 @@ export function LLMSettings() {
useAgentStore.getState().setProvider(provider, model); useAgentStore.getState().setProvider(provider, model);
}, [provider, model]); }, [provider, model]);
// v0.3.1: contextWindow 变化时同步到 Agent Store支持所有 Provider // v0.8.1: 全局「上下文长度」变化时同步到 Agent Store输入框指示条/详情面板即时刷新
useEffect(() => { useEffect(() => {
if (provider === 'ollama') { if (contextWindow != null && contextWindow > 0) {
if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx }); useAgentStore.getState().setContextWindow(contextWindow);
} else if (provider === 'deepseek') {
if (dsCtxWindow != null && dsCtxWindow > 0)
useAgentStore.setState({ contextWindow: dsCtxWindow });
} else if (provider === 'agnes') {
if (agnesCtxWindow != null && agnesCtxWindow > 0)
useAgentStore.setState({ contextWindow: agnesCtxWindow });
} else if (provider === 'mimo') {
if (mimoCtxWindow != null && mimoCtxWindow > 0)
useAgentStore.setState({ contextWindow: mimoCtxWindow });
} else if (provider === 'openai') {
if (oaCtxWindow != null && oaCtxWindow > 0)
useAgentStore.setState({ contextWindow: oaCtxWindow });
} else if (provider === 'anthropic') {
if (anthropicCtxWindow != null && anthropicCtxWindow > 0)
useAgentStore.setState({ contextWindow: anthropicCtxWindow });
} }
}, [ }, [contextWindow]);
provider,
numCtx,
dsCtxWindow,
agnesCtxWindow,
mimoCtxWindow,
oaCtxWindow,
anthropicCtxWindow,
]);
// ===== 字段级 inline 校验 ===== // ===== 字段级 inline 校验 =====
// Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL // Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL
const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL); const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL);
// Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数) // Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数)
const modelHasSpace = !!model && /\s/.test(model); const modelHasSpace = !!model && /\s/.test(model);
// contextWindow / numCtx:必须为有限正数且不低于最小值 // v0.8.1: 全局上下文长度 / 最大输出上限校验(有限正数,下限 512 / 256)
const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512); const ctxWindowError =
const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096; contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < 512);
const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096;
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
// v0.8.0 FEAT-1: 最大输出上限校验(≥256;上限钳制由 adapter 按模型元信息执行)
const maxTokensError = const maxTokensError =
maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 256); maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 256);
// v0.8.1 P1-3: 单价校验(可选,非负数)
const priceError =
(priceInput != null && (!Number.isFinite(priceInput) || priceInput < 0)) ||
(priceOutput != null && (!Number.isFinite(priceOutput) || priceOutput < 0));
// v0.8.0 FEAT-1: 当前模型的输出上限元信息(模型列表已加载时展示钳制提示)
const modelMeta = model ? modelOptions.find((o) => o.id === model) : undefined;
const modelMaxOutput = modelMeta?.maxOutputTokens;
const exceedsModelCap =
maxOutputTokens != null &&
typeof modelMaxOutput === 'number' &&
maxOutputTokens > modelMaxOutput;
// v0.8.0 P0-3: 模型不支持思考 + 全局思考开关开启 → 联动提示 // v0.8.0 P0-3: 模型不支持思考 + 全局思考开关开启 → 联动提示
const modelMeta = model ? modelOptions.find((o) => o.id === model) : undefined;
const thinkingUnsupportedByModel = const thinkingUnsupportedByModel =
model != null && modelMeta?.supportsThinking === false && agentThinkingEnabled; model != null && modelMeta?.supportsThinking === false && agentThinkingEnabled;
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
const hasBlockingError = const hasBlockingError =
urlError || urlError || modelHasSpace || ctxWindowError || maxTokensError || priceError;
modelHasSpace ||
numCtxError ||
maxTokensError ||
(provider === 'deepseek' && dsCtxError) ||
(provider === 'agnes' && agnesCtxError) ||
(provider === 'mimo' && mimoCtxError) ||
(provider === 'openai' && oaCtxError) ||
(provider === 'anthropic' && anthropicCtxError);
// 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL // 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL
// 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found // 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found
@@ -398,17 +349,19 @@ export function LLMSettings() {
{ key: 'llm.baseURL', value: baseURL }, { key: 'llm.baseURL', value: baseURL },
// v0.5.4: 多模态总开关 // v0.5.4: 多模态总开关
{ key: 'llm.multimodalEnabled', value: multimodalEnabled }, { key: 'llm.multimodalEnabled', value: multimodalEnabled },
// v0.8.0 FEAT-1: 最大输出上限(按请求传给 API;清空 = 引擎默认 63488 // v0.8.1 review 修复: 清空输入 = 写入 null(未配置语义 —— 引擎跳过压缩
// 预算 / 输出上限参数不下发),不再静默回写种子默认值
{
key: 'llm.contextWindow',
value: contextWindow != null && contextWindow >= 512 ? contextWindow : null,
},
{ {
key: 'llm.maxTokens', key: 'llm.maxTokens',
value: maxOutputTokens != null && maxOutputTokens >= 256 ? maxOutputTokens : 63488, value: maxOutputTokens != null && maxOutputTokens >= 256 ? maxOutputTokens : null,
}, },
{ key: 'ollama.numCtx', value: numCtx }, // v0.8.1 P1-3: 成本估算单价(可选,null = 未配置 → 成本显示隐藏)
{ key: 'deepseek.contextWindow', value: dsCtxWindow }, { key: 'llm.priceInput', value: priceInput },
{ key: 'agnes.contextWindow', value: agnesCtxWindow }, { key: 'llm.priceOutput', value: priceOutput },
{ key: 'mimo.contextWindow', value: mimoCtxWindow },
{ key: 'openai.contextWindow', value: oaCtxWindow },
{ key: 'anthropic.contextWindow', value: anthropicCtxWindow },
// P1: 故障转移 Provider(主 Provider 失败时切换) // P1: 故障转移 Provider(主 Provider 失败时切换)
{ key: 'llm.fallbackProvider', value: fbProvider }, { key: 'llm.fallbackProvider', value: fbProvider },
{ key: 'llm.fallbackModel', value: fbModel }, { key: 'llm.fallbackModel', value: fbModel },
@@ -423,16 +376,6 @@ export function LLMSettings() {
} else { } else {
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口) // v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled); useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
// v0.8.0 FEAT-1: 配置值超过模型上限 → 明示"将按模型上限生效"
//adapter 侧按 MODEL_INFO.maxOutputTokens 钳制,llm.maxTokens 经
// applyEngineConfigKey 热更新引擎,无需重启)
if (exceedsModelCap && typeof modelMaxOutput === 'number') {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })),
)
.catch(() => {});
}
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.success(t('llm.save.success'))) .then((mod) => mod.default.success(t('llm.save.success')))
.catch(() => {}); .catch(() => {});
@@ -573,37 +516,58 @@ export function LLMSettings() {
{t('llm.models.hint')} {t('llm.models.hint')}
</Typography> </Typography>
</Stack> </Stack>
{/* v0.8.0 P0-3: 模型不支持思考 × 全局思考开关 → 联动提示(adapter 侧已硬门控 */} {/* v0.8.0 P0-3: 模型不支持思考 × 全局思考开关 → 联动提示(按用户配置发送,降级重试兜底 */}
{thinkingUnsupportedByModel && ( {thinkingUnsupportedByModel && (
<Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}> <Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}>
{t('llm.thinking.unsupported')} {t('llm.thinking.unsupported')}
</Typography> </Typography>
)} )}
{/* v0.8.0 FEAT-1: Provider {/* v0.8.1 : llm.maxTokens Provider
PROVIDER_OUTPUT_LIMIT_SUPPORT llm.maxTokens */} */}
{supportsOutputLimitConfig(provider) && ( <TextField
size="small"
label={t('llm.maxTokens.label')}
type="number"
value={maxOutputTokens ?? ''}
onChange={(e) => {
const v = e.target.value;
setMaxOutputTokens(v === '' ? null : Number(v));
}}
slotProps={{ htmlInput: { min: 256, step: 256 } }}
error={maxTokensError}
helperText={maxTokensError ? t('llm.maxTokens.minError') : t('llm.maxTokens.helper')}
/>
{/* v0.8.1 P1-3: 成本估算单价(可选,每百万 tokens;两栏都留空则隐藏成本显示) */}
<Stack direction="row" spacing={1}>
<TextField <TextField
size="small" size="small"
label={t('llm.maxTokens.label')} fullWidth
label={t('llm.price.input')}
type="number" type="number"
value={maxOutputTokens ?? ''} value={priceInput ?? ''}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
setMaxOutputTokens(v === '' ? null : Number(v)); setPriceInput(v === '' ? null : Number(v));
}} }}
slotProps={{ htmlInput: { min: 256, step: 256 } }} slotProps={{ htmlInput: { min: 0, step: 'any' } }}
error={maxTokensError} error={priceError}
helperText={ helperText={t('llm.price.helper')}
maxTokensError
? t('llm.maxTokens.minError')
: exceedsModelCap && typeof modelMaxOutput === 'number'
? t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })
: typeof modelMaxOutput === 'number'
? `${t('llm.maxTokens.helper')} · ${t('llm.maxTokens.capInfo', { cap: modelMaxOutput })}`
: t('llm.maxTokens.helper')
}
/> />
)} <TextField
size="small"
fullWidth
label={t('llm.price.output')}
type="number"
value={priceOutput ?? ''}
onChange={(e) => {
const v = e.target.value;
setPriceOutput(v === '' ? null : Number(v));
}}
slotProps={{ htmlInput: { min: 0, step: 'any' } }}
error={priceError}
helperText={t('llm.price.helper')}
/>
</Stack>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} {/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel <FormControlLabel
control={ control={
@@ -651,20 +615,9 @@ export function LLMSettings() {
</> </>
)} )}
{provider === 'ollama' && ( {provider === 'ollama' && (
<TextField <Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
size="small" {t('llm.ctxWindow.ollamaNote')}
label={t('llm.numCtx.label')} </Typography>
type="number"
value={numCtx ?? ''}
onChange={(e) => {
const v = e.target.value;
setNumCtx(v === '' ? null : Number(v));
}}
placeholder={t('llm.numCtx.placeholder')}
slotProps={{ htmlInput: { min: 512, step: 512 } }}
error={numCtxError}
helperText={numCtxError ? t('llm.numCtx.error') : ' '}
/>
)} )}
{/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI ===== */} {/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI ===== */}
{provider === 'ollama' && ( {provider === 'ollama' && (
@@ -735,20 +688,20 @@ export function LLMSettings() {
</Typography> </Typography>
</Stack> </Stack>
)} )}
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} {/* v0.8.1 : llm.contextWindow
{provider === 'deepseek' && ( Provider/Ollama
<TextField num_ctx Provider */}
size="small" <TextField
label={t('llm.ctxWindow.label')} size="small"
type="number" label={t('llm.ctxWindow.label')}
value={dsCtxWindow} type="number"
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)} value={contextWindow}
placeholder="如 64000、128000、1000000" onChange={(e) => setContextWindow(Number(e.target.value) || 0)}
slotProps={{ htmlInput: { min: 4096, step: 4096 } }} placeholder="如 32768、131072、1000000"
error={dsCtxError} slotProps={{ htmlInput: { min: 512, step: 512 } }}
helperText={dsCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')} error={ctxWindowError}
/> helperText={ctxWindowError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
)} />
{/* v0.5.0: DeepSeek 账户余额显示 */} {/* v0.5.0: DeepSeek 账户余额显示 */}
{provider === 'deepseek' && ( {provider === 'deepseek' && (
@@ -801,61 +754,6 @@ export function LLMSettings() {
</Button> </Button>
</Stack> </Stack>
)} )}
{provider === 'agnes' && (
<TextField
size="small"
label={t('llm.ctxWindow.label')}
type="number"
value={agnesCtxWindow}
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 64000、128000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={agnesCtxError}
helperText={agnesCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
/>
)}
{provider === 'mimo' && (
<TextField
size="small"
label={t('llm.ctxWindow.label')}
type="number"
value={mimoCtxWindow}
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 65536、131072、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={mimoCtxError}
helperText={mimoCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.mimoHelper')}
/>
)}
{provider === 'openai' && (
<TextField
size="small"
label={t('llm.ctxWindow.label')}
type="number"
value={oaCtxWindow}
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
placeholder="如 128000、200000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={oaCtxError}
helperText={oaCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.oaHelper')}
/>
)}
{provider === 'anthropic' && (
<TextField
size="small"
label={t('llm.ctxWindow.label')}
type="number"
value={anthropicCtxWindow}
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
placeholder="如 200000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={anthropicCtxError}
helperText={
anthropicCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.anthropicHelper')
}
/>
)}
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */} {/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
<Divider /> <Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
+275 -35
View File
@@ -6,13 +6,219 @@
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box, TextField } from '@mui/material'; import {
Button,
Stack,
Typography,
Checkbox,
Divider,
Chip,
Box,
TextField,
Switch,
FormControlLabel,
} from '@mui/material';
import { alpha } from '@mui/material/styles'; import { alpha } from '@mui/material/styles';
import { useConfig } from './useConfig'; import { useConfig } from './useConfig';
// 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';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
// ===== v0.8.1 P2-1: 工具自定义策略编辑器 =====
/** 单个工具的策略编辑状态(正则以字符串源编辑,保存时编译校验由主进程承担) */
interface ToolPolicyDraft {
denied: string;
allowed: string;
maxFrequency: string;
requireConfirmation: boolean;
}
/** 校验正则源列表(返回首个非法正则;用于保存前 inline 提示) */
function firstInvalidRegex(src: string): string | null {
if (!src.trim()) return null;
for (const item of src.split(',')) {
const pattern = item.trim();
if (!pattern) continue;
try {
new RegExp(pattern);
} catch {
return pattern;
}
}
return null;
}
function ToolPolicyEditor({
toolName,
onClose,
}: {
toolName: string;
onClose: () => void;
}): React.JSX.Element {
const [draft, setDraft] = useState<ToolPolicyDraft>({
denied: '',
allowed: '',
maxFrequency: '',
requireConfirmation: false,
});
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
window.metona?.config
?.get(`tools.${toolName}.policy`)
.then((raw) => {
if (cancelled) return;
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
setDraft({
denied: Array.isArray(parsed.deniedPatterns)
? (parsed.deniedPatterns as string[]).join(', ')
: '',
allowed: Array.isArray(parsed.allowedPatterns)
? (parsed.allowedPatterns as string[]).join(', ')
: '',
maxFrequency:
typeof parsed.maxFrequency === 'number' ? String(parsed.maxFrequency) : '',
requireConfirmation: parsed.requireConfirmation === true,
});
} catch {
/* 损坏配置按空编辑 */
}
}
setLoaded(true);
})
.catch(() => setLoaded(true));
return () => {
cancelled = true;
};
}, [toolName]);
const deniedInvalid = firstInvalidRegex(draft.denied);
const allowedInvalid = firstInvalidRegex(draft.allowed);
const handleSave = async (): Promise<void> => {
if (deniedInvalid || allowedInvalid) return;
setSaving(true);
try {
// 空策略 = 清除覆盖(回退默认策略)
const empty =
!draft.denied.trim() &&
!draft.allowed.trim() &&
!draft.maxFrequency.trim() &&
!draft.requireConfirmation;
const policy = empty
? null
: JSON.stringify({
...(draft.denied.trim()
? {
deniedPatterns: draft.denied
.split(',')
.map((x) => x.trim())
.filter(Boolean),
}
: {}),
...(draft.allowed.trim()
? {
allowedPatterns: draft.allowed
.split(',')
.map((x) => x.trim())
.filter(Boolean),
}
: {}),
...(draft.maxFrequency.trim() ? { maxFrequency: Number(draft.maxFrequency) } : {}),
...(draft.requireConfirmation ? { requireConfirmation: true } : {}),
});
await window.metona?.config?.set(`tools.${toolName}.policy`, policy);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success(t('settings.tools.policy.saved')))
.catch(() => {});
onClose();
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error((err as Error).message))
.catch(() => {});
} finally {
setSaving(false);
}
};
if (!loaded) return <Typography variant="caption">{t('common.loading')}</Typography>;
return (
<Stack spacing={1} sx={{ px: 1.5, py: 1.5, bgcolor: 'background.default', borderRadius: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t('settings.tools.policy.title', { name: toolName })}
</Typography>
<TextField
size="small"
label={t('settings.tools.policy.denied')}
value={draft.denied}
onChange={(e) => setDraft((d) => ({ ...d, denied: e.target.value }))}
placeholder={t('settings.tools.policy.regexHint')}
error={deniedInvalid != null}
helperText={
deniedInvalid != null
? t('settings.tools.policy.badRegex', { pattern: deniedInvalid })
: ' '
}
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
<TextField
size="small"
label={t('settings.tools.policy.allowed')}
value={draft.allowed}
onChange={(e) => setDraft((d) => ({ ...d, allowed: e.target.value }))}
placeholder={t('settings.tools.policy.regexHint')}
error={allowedInvalid != null}
helperText={
allowedInvalid != null
? t('settings.tools.policy.badRegex', { pattern: allowedInvalid })
: ' '
}
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TextField
size="small"
fullWidth
type="number"
label={t('settings.tools.policy.frequency')}
value={draft.maxFrequency}
onChange={(e) => setDraft((d) => ({ ...d, maxFrequency: e.target.value }))}
slotProps={{ htmlInput: { min: 1 } }}
/>
<FormControlLabel
control={
<Switch
size="small"
checked={draft.requireConfirmation}
onChange={(e) => setDraft((d) => ({ ...d, requireConfirmation: e.target.checked }))}
/>
}
label={<Typography variant="caption">{t('settings.tools.policy.confirm')}</Typography>}
/>
</Stack>
<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-end' }}>
<Button size="small" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={saving || deniedInvalid != null || allowedInvalid != null}
>
{t('settings.tools.policy.save')}
</Button>
</Stack>
</Stack>
);
}
export function ToolsSettings() { export function ToolsSettings() {
const [tools, setTools] = useState< const [tools, setTools] = useState<
Array<{ Array<{
@@ -24,6 +230,8 @@ export function ToolsSettings() {
}> }>
>([]); >([]);
const [autoExecList, setAutoExecList] = useState<string[]>([]); const [autoExecList, setAutoExecList] = useState<string[]>([]);
// v0.8.1 P2-1: 展开策略编辑器的工具集合
const [policyEditors, setPolicyEditors] = useState<Set<string>>(new Set());
const loadTools = () => { const loadTools = () => {
if (window.metona?.tools?.list) { if (window.metona?.tools?.list) {
@@ -139,43 +347,75 @@ export function ToolsSettings() {
</Typography> </Typography>
) : ( ) : (
tools.map((tool) => ( tools.map((tool) => (
<Stack <Stack key={tool.name} spacing={0}>
key={tool.name} <Stack
direction="row" direction="row"
sx={{ sx={{
py: 1, py: 1,
px: 1.5, px: 1.5,
borderRadius: 1.5, borderRadius: 1.5,
bgcolor: 'secondary.main', bgcolor: 'secondary.main',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
}} }}
> >
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}> <Stack
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}> direction="row"
{tool.name} spacing={1}
</Typography> sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}
<Typography
variant="caption"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
> >
{tool.description.slice(0, 40)} <Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
</Typography> {tool.name}
</Typography>
<Typography
variant="caption"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{tool.description.slice(0, 40)}
</Typography>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Chip
label={tool.riskLevel.toUpperCase()}
size="small"
color={riskColors[tool.riskLevel]}
variant="outlined"
sx={{ height: 18, fontSize: 9 }}
/>
<Button
size="small"
variant={policyEditors.has(tool.name) ? 'contained' : 'text'}
onClick={() =>
setPolicyEditors((prev) => {
const next = new Set(prev);
if (next.has(tool.name)) next.delete(tool.name);
else next.add(tool.name);
return next;
})
}
sx={{ minWidth: 0, fontSize: 10, px: 0.75 }}
>
{t('settings.tools.policy.button')}
</Button>
<Checkbox
checked={tool.enabled}
onChange={(e) => handleToggle(tool.name, e.target.checked)}
size="small"
/>
</Stack>
</Stack> </Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}> {policyEditors.has(tool.name) && (
<Chip <ToolPolicyEditor
label={tool.riskLevel.toUpperCase()} toolName={tool.name}
size="small" onClose={() =>
color={riskColors[tool.riskLevel]} setPolicyEditors((prev) => {
variant="outlined" const next = new Set(prev);
sx={{ height: 18, fontSize: 9 }} next.delete(tool.name);
return next;
})
}
/> />
<Checkbox )}
checked={tool.enabled}
onChange={(e) => handleToggle(tool.name, e.target.checked)}
size="small"
/>
</Stack>
</Stack> </Stack>
)) ))
)} )}
+91
View File
@@ -6,6 +6,7 @@
*/ */
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material'; import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material';
import { useEffect, useState } from 'react';
import { Zap } from 'lucide-react'; import { Zap } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTokens } from '@renderer/lib/formatters'; import { formatTokens } from '@renderer/lib/formatters';
@@ -19,6 +20,47 @@ export function TokenUsage(): React.JSX.Element {
const currentIteration = useAgentStore((s) => s.currentIteration); const currentIteration = useAgentStore((s) => s.currentIteration);
const contextWindow = useAgentStore((s) => s.contextWindow); const contextWindow = useAgentStore((s) => s.contextWindow);
// ===== v0.8.1 P1-3: 成本估算(设置面板可选单价 llm.priceInput/llm.priceOutput
// 每百万 tokens;未配置时隐藏成本行 —— 单价是用户可编辑配置,代码零写死) =====
const [prices, setPrices] = useState<{ input: number | null; output: number | null }>({
input: null,
output: null,
});
useEffect(() => {
if (!window.metona?.config?.get) return;
let cancelled = false;
Promise.all([
window.metona.config.get('llm.priceInput'),
window.metona.config.get('llm.priceOutput'),
])
.then(([pi, po]) => {
if (cancelled) return;
setPrices({
input: typeof pi === 'number' && pi > 0 ? pi : null,
output: typeof po === 'number' && po > 0 ? po : null,
});
})
.catch(() => {});
const off = window.metona.config.onChanged?.((data) => {
if (data.key === 'llm.priceInput') {
setPrices((p) => ({
...p,
input: typeof data.value === 'number' && data.value > 0 ? data.value : null,
}));
}
if (data.key === 'llm.priceOutput') {
setPrices((p) => ({
...p,
output: typeof data.value === 'number' && data.value > 0 ? data.value : null,
}));
}
});
return () => {
cancelled = true;
off?.();
};
}, []);
const maxTokens = contextWindow; const maxTokens = contextWindow;
// v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens // v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens
// 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比 // 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比
@@ -29,6 +71,17 @@ export function TokenUsage(): React.JSX.Element {
// 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成) // 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成)
const inputPercent = const inputPercent =
tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0; tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0;
// v0.8.1 P1-3: 缓存命中率(Provider 上报 cache 字段时才有意义)
const cacheHitPercent =
tokenUsage.cacheHitTokens != null && tokenUsage.inputTokens > 0
? (tokenUsage.cacheHitTokens / tokenUsage.inputTokens) * 100
: null;
// v0.8.1 P1-3: 估算成本(单价配置齐全时才展示;/1e6 = 每百万 tokens 单价)
const estimatedCost =
prices.input != null && prices.output != null
? (tokenUsage.inputTokens / 1e6) * prices.input +
(tokenUsage.outputTokens / 1e6) * prices.output
: null;
return ( return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}> <Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
@@ -136,6 +189,44 @@ export function TokenUsage(): React.JSX.Element {
: '-'} : '-'}
</TableCell> </TableCell>
</TableRow> </TableRow>
{cacheHitPercent != null && (
<TableRow>
<TableCell sx={{ color: 'info.main', fontSize: 11 }}>
{t('trace.token.cacheHit')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'info.main',
textAlign: 'right',
}}
>
{`${formatTokens(tokenUsage.cacheHitTokens ?? 0)} (${cacheHitPercent.toFixed(1)}%)`}
</TableCell>
</TableRow>
)}
{estimatedCost != null && (
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('trace.token.cost')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{estimatedCost < 0.01 && estimatedCost > 0
? `<$0.01`
: `$${estimatedCost.toFixed(2)}`}
</TableCell>
</TableRow>
)}
{tokenUsage.lastCompressedSaved > 0 && ( {tokenUsage.lastCompressedSaved > 0 && (
<TableRow> <TableRow>
<TableCell sx={{ color: 'success.main', fontSize: 11 }}> <TableCell sx={{ color: 'success.main', fontSize: 11 }}>
+29 -3
View File
@@ -192,7 +192,14 @@ export function useAgentStream(): void {
error?: string; error?: string;
durationMs?: number; durationMs?: number;
}; };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
/** v0.8.1 P1-3: Prompt Cache 命中/未命中(Provider 上报时携带) */
cacheHitTokens?: number;
cacheMissTokens?: number;
};
/** v0.3.18 修复: 上下文压缩事件数据 */ /** v0.3.18 修复: 上下文压缩事件数据 */
savedTokens?: number; savedTokens?: number;
originalTokens?: number; originalTokens?: number;
@@ -490,6 +497,14 @@ export function useAgentStream(): void {
// v0.3.18 修复: 单次上下文占用 — 替换不累加 // v0.3.18 修复: 单次上下文占用 — 替换不累加
// 反映当前对话上下文的真实大小,压缩后会大幅下降 // 反映当前对话上下文的真实大小,压缩后会大幅下降
lastInputTokens: data.usage.inputTokens ?? 0, lastInputTokens: data.usage.inputTokens ?? 0,
// v0.8.1 P1-3: 缓存命中/未命中透传(undefined = Provider 不上报,
// 保持 undefined 而非污染为 0 —— UI 以此判定是否显示命中率行)
...(data.usage.cacheHitTokens != null || data.usage.cacheMissTokens != null
? {
cacheHitTokens: (cur.cacheHitTokens ?? 0) + (data.usage.cacheHitTokens ?? 0),
cacheMissTokens: (cur.cacheMissTokens ?? 0) + (data.usage.cacheMissTokens ?? 0),
}
: {}),
}); });
// 同步更新当前 Trace 步骤的 token 用量 // 同步更新当前 Trace 步骤的 token 用量
@@ -519,7 +534,10 @@ export function useAgentStream(): void {
getStore().addMessage({ getStore().addMessage({
id: genMsgId('system'), id: genMsgId('system'),
role: 'system', role: 'system',
content: `🔍 输出验证:发现 ${issues.length} 个疑似问题(含幻觉/事实一致性检测,仅供参考)\n${lines.join('\n')}`, content: t('agent.system.validationFound', {
count: issues.length,
lines: lines.join('\n'),
}),
timestamp: Date.now(), timestamp: Date.now(),
}); });
} }
@@ -876,7 +894,11 @@ export function useAgentStream(): void {
store.addMessage({ store.addMessage({
id: genMsgId('system'), id: genMsgId('system'),
role: 'system', role: 'system',
content: `Provider 已切换: ${from ?? '未知'}${to ?? '未知'}${reason ? ` (${reason})` : ''}`, content: t('agent.system.providerSwitched', {
from: from ?? '?',
to: to ?? '?',
reason: reason ? ` (${reason})` : '',
}),
timestamp: Date.now(), timestamp: Date.now(),
}); });
}); });
@@ -897,6 +919,10 @@ export function useAgentStream(): void {
if (key === 'agent.maxIterations' && typeof value === 'number') { if (key === 'agent.maxIterations' && typeof value === 'number') {
store.setMaxIterations(value); store.setMaxIterations(value);
} }
// v0.8.1: 全局「上下文长度」热更新(设置面板保存后立即刷新占用指示)
if (key === 'llm.contextWindow' && typeof value === 'number' && value > 0) {
store.setContextWindow(value);
}
// 其他 agent.* 配置项若 store 有对应字段,可在此扩展 // 其他 agent.* 配置项若 store 有对应字段,可在此扩展
}); });
+23 -12
View File
@@ -9,6 +9,9 @@ import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
import { toggleTheme } from './useTheme'; import { toggleTheme } from './useTheme';
// v0.8.1 P0-4: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
/** /**
* macOS * macOS
@@ -16,7 +19,8 @@ import { toggleTheme } from './useTheme';
*/ */
function isMac(): boolean { function isMac(): boolean {
// 优先使用 userAgentData.platform(现代 API // 优先使用 userAgentData.platform(现代 API
const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData; const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } })
.userAgentData;
if (userAgentData?.platform) { if (userAgentData?.platform) {
return userAgentData.platform.toUpperCase().includes('MAC'); return userAgentData.platform.toUpperCase().includes('MAC');
} }
@@ -75,16 +79,21 @@ export function useKeyboardShortcuts(): void {
// Ctrl+N — 新建会话 // Ctrl+N — 新建会话
if (key === 'n' && matchModifier(e, true)) { if (key === 'n' && matchModifier(e, true)) {
if (window.metona?.sessions?.create) { if (window.metona?.sessions?.create) {
window.metona.sessions.create().then((result: MetonaSessionInfo) => { window.metona.sessions
useSessionStore.getState().addSession(result); .create()
useSessionStore.getState().setCurrentSession(result.id); .then((result: MetonaSessionInfo) => {
// v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话 useSessionStore.getState().addSession(result);
useAgentStore.getState().setCurrentSession(result.id); useSessionStore.getState().setCurrentSession(result.id);
}).catch((err) => { // v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话
console.error('[KeyboardShortcuts]', err); useAgentStore.getState().setCurrentSession(result.id);
// 与 Sidebar/CommandPalette 入口对齐:失败时弹 toast 提示用户 })
import('@metona-team/metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {}); .catch((err) => {
}); console.error('[KeyboardShortcuts]', err);
// 与 Sidebar/CommandPalette 入口对齐:失败时弹 toast 提示用户
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(t('agent.error.createFailedToast')))
.catch(() => {});
});
} }
e.preventDefault(); e.preventDefault();
return; return;
@@ -171,7 +180,9 @@ export function useKeyboardShortcuts(): void {
navigator.clipboard.writeText(lastAssistant.content).catch((err) => { navigator.clipboard.writeText(lastAssistant.content).catch((err) => {
console.error('[KeyboardShortcuts]', err); console.error('[KeyboardShortcuts]', err);
// 复制是用户主动操作,失败时必须有反馈 // 复制是用户主动操作,失败时必须有反馈
import('@metona-team/metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {}); import('@metona-team/metona-toast')
.then((mod) => mod.default.error(t('agent.error.copyFailed')))
.catch(() => {});
}); });
} }
e.preventDefault(); e.preventDefault();
+147 -37
View File
@@ -146,6 +146,7 @@ registerTranslations('zh-CN', {
// ===== 输入框(ChatInput)—— v0.7.3 P4-3 ===== // ===== 输入框(ChatInput)—— v0.7.3 P4-3 =====
'input.placeholder.ready': '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)', 'input.placeholder.ready': '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)',
'input.contextIndicator': '上下文 {{used}} / {{total}} ({{percent}}%)',
'input.placeholder.toolsLoading': '工具加载中...', 'input.placeholder.toolsLoading': '工具加载中...',
'input.placeholder.configLoading': '正在加载配置...', 'input.placeholder.configLoading': '正在加载配置...',
'input.send': '发送', 'input.send': '发送',
@@ -167,6 +168,8 @@ registerTranslations('zh-CN', {
'input.slash.memory': '搜索记忆', 'input.slash.memory': '搜索记忆',
'input.slash.clear': '清空会话', 'input.slash.clear': '清空会话',
'input.slash.export': '导出 Markdown', 'input.slash.export': '导出 Markdown',
'input.slash.mcpPrompt': 'MCP Prompt 模板',
'input.slash.mcpPromptFailed': 'MCP Prompt {{prompt}} 获取失败: {{reason}}',
// ===== LLM 配置(LLMSettings)—— v0.7.3 P4-3 ===== // ===== LLM 配置(LLMSettings)—— v0.7.3 P4-3 =====
'llm.title': 'LLM 配置', 'llm.title': 'LLM 配置',
@@ -186,22 +189,21 @@ registerTranslations('zh-CN', {
'开启后可在输入框上传图片;DeepSeek 需 vision 系列模型。历史会话图片会随上下文回传(最近 10 张)', '开启后可在输入框上传图片;DeepSeek 需 vision 系列模型。历史会话图片会随上下文回传(最近 10 张)',
'llm.apiKey.label': 'API Key', 'llm.apiKey.label': 'API Key',
'llm.apiKey.required': '必填,未填 {{provider}} 的 API Key 会 401', 'llm.apiKey.required': '必填,未填 {{provider}} 的 API Key 会 401',
'llm.numCtx.label': '上下文长度 (num_ctx)', // v0.8.1: 全局唯一「上下文长度」(llm.contextWindow,跨 Provider/模型生效)。
'llm.numCtx.placeholder': '默认由模型决定(如 2048、4096、128000', // 分 Provider 键与 ollama.numCtx 已废除 —— 旧键文案一并移除,杜绝双源语义。
'llm.numCtx.error': '最小值为 512', 'llm.ctxWindow.label': '上下文长度 (tokens)',
'llm.ctxWindow.label': '上下文窗口 (contextWindow)', 'llm.ctxWindow.placeholder': '如 32768、131072、1000000',
'llm.ctxWindow.placeholder': '如 64000、128000、1000000', 'llm.ctxWindow.minError': '最小值为 512',
'llm.ctxWindow.minError': '最小值为 4096', 'llm.ctxWindow.helper': '驱动上下文压缩预算与占用指示;Ollama 场景同时作为 num_ctx 下发',
'llm.ctxWindow.helper': '用于上下文压缩判断,不传给 API', 'llm.ctxWindow.ollamaNote': 'Ollama:上方「上下文长度」将作为 num_ctx 传给本地服务端',
'llm.ctxWindow.mimoHelper': '默认 10000001M),用于上下文压缩判断', // v0.8.1: 全局唯一「最大输出上限」(llm.maxTokens,原样透传请求参数,无模型钳制)
'llm.ctxWindow.oaHelper': 'gpt-4o 默认 128Kgpt-4.1 默认 1M',
'llm.ctxWindow.anthropicHelper': 'Claude 默认 200K',
// v0.8.0 FEAT-1: 最大输出上限(llm.maxTokens,按请求传给 API
'llm.maxTokens.label': '最大输出上限 (tokens)', 'llm.maxTokens.label': '最大输出上限 (tokens)',
'llm.maxTokens.helper': '单次回复(含思考内容)的最大 token 数;思考会占用此预算', 'llm.maxTokens.helper': '单次回复(含思考内容)的最大 token 数;思考会占用此预算',
'llm.maxTokens.minError': '最小值为 256', 'llm.maxTokens.minError': '最小值为 256',
'llm.maxTokens.exceedCap': '当前模型输出上限为 {{cap}},保存后将按模型上限生效(配置值被钳制)', // v0.8.1 P1-3: 成本估算单价(可选,每百万 tokens)
'llm.maxTokens.capInfo': '当前模型输出上限:{{cap}} tokens', 'llm.price.input': '输入单价 / 每百万 tokens(可选)',
'llm.price.output': '输出单价 / 每百万 tokens(可选)',
'llm.price.helper': '用于 Token 面板的成本估算;留空则不显示成本',
'llm.thinking.unsupported': 'llm.thinking.unsupported':
'当前模型元信息标注不支持思考:仍按你的配置发送思考参数;若思考耗尽输出预算,会自动关闭思考重试一次', '当前模型元信息标注不支持思考:仍按你的配置发送思考参数;若思考耗尽输出预算,会自动关闭思考重试一次',
'llm.pull.title': '下载 Ollama 模型', 'llm.pull.title': '下载 Ollama 模型',
@@ -244,6 +246,19 @@ registerTranslations('zh-CN', {
'memory.searching': '搜索中...', 'memory.searching': '搜索中...',
'memory.search.empty': '无匹配结果', 'memory.search.empty': '无匹配结果',
'memory.empty': '暂无记忆数据', 'memory.empty': '暂无记忆数据',
// v0.8.1 P1-2: 记忆整理(MEMORY.md 维护闭环)
'memory.maintain.button': '整理记忆',
'memory.maintain.title': '记忆整理建议',
'memory.maintain.summary':
'当前 MEMORY.md 共 {{total}} 条记忆,AI 提出以下 {{actions}} 条建议(勾选后应用)',
'memory.maintain.empty': 'AI 未发现需要整理的条目',
'memory.maintain.delete': '删除',
'memory.maintain.merge': '合并',
'memory.maintain.apply': '应用({{count}}',
'memory.maintain.applying': '应用中...',
'memory.maintain.applied': '记忆整理完成:应用 {{applied}} 条,跳过 {{skipped}} 条',
'memory.maintain.failed': '记忆整理失败,请重试',
'memory.maintain.emptySectionHint': '应用后以下分区将没有条目(保留分区头):{{sections}}',
'memory.loadFailed': '加载记忆失败', 'memory.loadFailed': '加载记忆失败',
'memory.deleteFailed': '删除失败', 'memory.deleteFailed': '删除失败',
'memory.type.episodic': '情景记忆', 'memory.type.episodic': '情景记忆',
@@ -268,11 +283,9 @@ registerTranslations('zh-CN', {
'onboarding.llm.multimodal.label': '启用多模态(图片输入)', 'onboarding.llm.multimodal.label': '启用多模态(图片输入)',
'onboarding.llm.multimodal.helper': '开启后可在输入框上传图片;DeepSeek 需 vision 系列模型', 'onboarding.llm.multimodal.helper': '开启后可在输入框上传图片;DeepSeek 需 vision 系列模型',
'onboarding.llm.apiKey.placeholder': 'sk-...(本地模型可留空)', 'onboarding.llm.apiKey.placeholder': 'sk-...(本地模型可留空)',
'onboarding.llm.ctx.ollama': '上下文长度 (num_ctx)', 'onboarding.llm.ctx.window': '上下文长度 (tokens)',
'onboarding.llm.ctx.window': '上下文窗口 (contextWindow)', 'onboarding.llm.ctx.placeholder': '如 32768、131072、1000000',
'onboarding.llm.ctx.ollamaPlaceholder': '默认由模型决定(如 2048、4096、128000', 'onboarding.llm.ctx.helper': '全局唯一配置:驱动压缩预算与占用指示,Ollama 场景作为 num_ctx 下发',
'onboarding.llm.ctx.placeholder': '如 64000、128000、1000000',
'onboarding.llm.ctx.helper': '用于上下文压缩判断,不传给 API',
'onboarding.llm.ctx.minError': '最小值为 {{min}}', 'onboarding.llm.ctx.minError': '最小值为 {{min}}',
'onboarding.agent.title': '自定义 Agent', 'onboarding.agent.title': '自定义 Agent',
'onboarding.agent.body': '编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。', 'onboarding.agent.body': '编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。',
@@ -395,6 +408,30 @@ registerTranslations('zh-CN', {
'settings.agent.enableReflection': '启用反思阶段(REFLECTING', 'settings.agent.enableReflection': '启用反思阶段(REFLECTING',
'settings.agent.enableReflectionHelper': 'settings.agent.enableReflectionHelper':
'每轮工具执行后进入反思状态,存在失败结果时记录告警(不阻断执行)', '每轮工具执行后进入反思状态,存在失败结果时记录告警(不阻断执行)',
// v0.8.1 P0-4: 渲染层残留硬编码文案出层
'agent.busy.loadingTools': '工具正在加载中,请稍候...',
'agent.busy.streamingClear': 'Agent 正在回复中,请先中断再清空会话',
'agent.busy.streamingEdit': 'Agent 正在回复中,请先中断再编辑重发',
'agent.busy.streamingRegenerate': 'Agent 正在回复中,无法重新生成',
'agent.error.sessionCreateFailed': '无法创建会话:{{message}}',
'agent.error.createFailedToast': '创建会话失败',
'agent.error.sendFailed': '消息发送失败:{{message}}',
'agent.error.clearFailedRetry': '清空会话失败,请重试',
'agent.error.clearFailed': '清空会话失败:{{message}}',
'agent.error.truncateFailedResend': '消息截断失败,无法重发',
'agent.error.truncateFailedRegenerate': '消息截断失败,无法重新生成',
'agent.info.nothingToRegenerate': '没有可重新生成的用户消息',
'agent.error.saveFailed': '保存失败:{{message}}',
'agent.error.copyFailed': '复制失败',
'agent.system.providerSwitched': 'Provider 已切换: {{from}} → {{to}}{{reason}}',
'agent.system.validationFound':
'🔍 输出验证:发现 {{count}} 个疑似问题(含幻觉/事实一致性检测,仅供参考)\n{{lines}}',
// v0.8.1 P1-1: 本地向量记忆
'settings.agent.memory.title': '记忆增强(可选)',
'settings.agent.memory.embeddingModel': '向量嵌入模型(Ollama',
'settings.agent.memory.embeddingPlaceholder': '如 nomic-embed-text(留空 = 关闭)',
'settings.agent.memory.embeddingHelper':
'配置后记忆检索启用本地向量混合召回(需 Ollama Provider 与已 pull 的 embedding 模型);留空则使用 TF-IDF 检索',
// ===== 外观设置(AppearanceSettings===== // ===== 外观设置(AppearanceSettings=====
'settings.appearance.title': '外观', 'settings.appearance.title': '外观',
@@ -407,6 +444,10 @@ registerTranslations('zh-CN', {
'settings.appearance.localeEnUS': 'English', 'settings.appearance.localeEnUS': 'English',
'settings.appearance.localeHelper': 'settings.appearance.localeHelper':
'切换即时生效;已迁移的界面(系统消息 / 工具确认弹框 / 侧边栏 / 状态栏 / Agent 状态区)将跟随所选语言显示', '切换即时生效;已迁移的界面(系统消息 / 工具确认弹框 / 侧边栏 / 状态栏 / Agent 状态区)将跟随所选语言显示',
// v0.8.1 P2-4: 开机自启
'settings.appearance.autoStart': '开机自启',
'settings.appearance.autoStartHelper': '系统启动时自动运行 MetonaAI(当前平台不支持时保持关闭)',
'settings.appearance.autoStart.unsupported': '当前平台不支持开机自启',
// ===== 日志与数据(LogsSettings===== // ===== 日志与数据(LogsSettings=====
'settings.logs.title': '日志与数据', 'settings.logs.title': '日志与数据',
@@ -593,6 +634,17 @@ registerTranslations('zh-CN', {
// ===== 工具管理(ToolsSettings===== // ===== 工具管理(ToolsSettings=====
'settings.tools.title': '工具管理', 'settings.tools.title': '工具管理',
// v0.8.1 P2-1: 工具自定义策略
'settings.tools.policy.button': '策略',
'settings.tools.policy.title': '自定义策略({{name}})—— 与默认策略按字段合并',
'settings.tools.policy.denied': '拒绝参数(正则,逗号分隔)',
'settings.tools.policy.allowed': '允许参数白名单(正则,逗号分隔)',
'settings.tools.policy.regexHint': '如 /etc(?:/|$), C:\\Windows\\',
'settings.tools.policy.badRegex': '非法正则: {{pattern}}',
'settings.tools.policy.frequency': '频率上限(次/分钟)',
'settings.tools.policy.confirm': '强制确认',
'settings.tools.policy.save': '保存策略',
'settings.tools.policy.saved': '工具策略已保存并热生效',
'settings.tools.toggleFailed': '切换工具失败', 'settings.tools.toggleFailed': '切换工具失败',
'settings.tools.autoExecFailed': '设置自动执行失败', 'settings.tools.autoExecFailed': '设置自动执行失败',
'settings.tools.autoExecTitle': '自动执行工具', 'settings.tools.autoExecTitle': '自动执行工具',
@@ -684,6 +736,8 @@ registerTranslations('zh-CN', {
'trace.token.total': '累计消耗', 'trace.token.total': '累计消耗',
'trace.token.context': '上下文占用', 'trace.token.context': '上下文占用',
'trace.token.compressedSaved': '压缩节省', 'trace.token.compressedSaved': '压缩节省',
'trace.token.cacheHit': 'Prompt 缓存命中',
'trace.token.cost': '估算成本',
// ===== Trace 步骤(TraceStep / constants===== // ===== Trace 步骤(TraceStep / constants=====
'trace.step.toolCalls': '工具调用', 'trace.step.toolCalls': '工具调用',
@@ -854,6 +908,7 @@ registerTranslations('en-US', {
// ===== 输入框(ChatInput===== // ===== 输入框(ChatInput=====
'input.placeholder.ready': 'input.placeholder.ready':
'Type a message... (Cmd/Ctrl+Enter to send, Cmd/Ctrl+Shift+Enter for newline, / for commands)', 'Type a message... (Cmd/Ctrl+Enter to send, Cmd/Ctrl+Shift+Enter for newline, / for commands)',
'input.contextIndicator': 'Context {{used}} / {{total}} ({{percent}}%)',
'input.placeholder.toolsLoading': 'Loading tools...', 'input.placeholder.toolsLoading': 'Loading tools...',
'input.placeholder.configLoading': 'Loading configuration...', 'input.placeholder.configLoading': 'Loading configuration...',
'input.send': 'Send', 'input.send': 'Send',
@@ -878,6 +933,8 @@ registerTranslations('en-US', {
'input.slash.memory': 'Search memory', 'input.slash.memory': 'Search memory',
'input.slash.clear': 'Clear session', 'input.slash.clear': 'Clear session',
'input.slash.export': 'Export Markdown', 'input.slash.export': 'Export Markdown',
'input.slash.mcpPrompt': 'MCP prompt template',
'input.slash.mcpPromptFailed': 'Failed to fetch MCP prompt {{prompt}}: {{reason}}',
// ===== LLM 配置(LLMSettings===== // ===== LLM 配置(LLMSettings=====
'llm.title': 'LLM Configuration', 'llm.title': 'LLM Configuration',
@@ -897,24 +954,22 @@ registerTranslations('en-US', {
'Allows uploading images; DeepSeek requires a vision model. Images from history are replayed (last 10)', 'Allows uploading images; DeepSeek requires a vision model. Images from history are replayed (last 10)',
'llm.apiKey.label': 'API Key', 'llm.apiKey.label': 'API Key',
'llm.apiKey.required': 'Required — missing {{provider}} API Key will result in 401', 'llm.apiKey.required': 'Required — missing {{provider}} API Key will result in 401',
'llm.numCtx.label': 'Context length (num_ctx)', // v0.8.1: global single context length (llm.contextWindow, applies to all providers/models)
'llm.numCtx.placeholder': 'Default decided by the model (e.g. 2048, 4096, 128000)', 'llm.ctxWindow.label': 'Context length (tokens)',
'llm.numCtx.error': 'Minimum is 512', 'llm.ctxWindow.placeholder': 'e.g. 32768, 131072, 1000000',
'llm.ctxWindow.label': 'Context window (contextWindow)', 'llm.ctxWindow.minError': 'Minimum is 512',
'llm.ctxWindow.placeholder': 'e.g. 64000, 128000, 1000000', 'llm.ctxWindow.helper':
'llm.ctxWindow.minError': 'Minimum is 4096', 'Drives the compression budget and usage indicator; also sent as num_ctx for Ollama',
'llm.ctxWindow.helper': 'Used for context-compression decisions; not sent to the API', 'llm.ctxWindow.ollamaNote': 'Ollama: the context length above is sent to the server as num_ctx',
'llm.ctxWindow.mimoHelper': 'Default 1000000 (1M), used for compression decisions', // v0.8.1: global single max output tokens (llm.maxTokens, passed through as-is)
'llm.ctxWindow.oaHelper': 'gpt-4o defaults to 128K, gpt-4.1 to 1M',
'llm.ctxWindow.anthropicHelper': 'Claude defaults to 200K',
// v0.8.0 FEAT-1: max output tokens (llm.maxTokens, sent per request)
'llm.maxTokens.label': 'Max output tokens', 'llm.maxTokens.label': 'Max output tokens',
'llm.maxTokens.helper': 'llm.maxTokens.helper':
'Maximum tokens per response (reasoning included); thinking consumes this budget', 'Maximum tokens per response (reasoning included); thinking consumes this budget',
'llm.maxTokens.minError': 'Minimum is 256', 'llm.maxTokens.minError': 'Minimum is 256',
'llm.maxTokens.exceedCap': // v0.8.1 P1-3: cost estimation unit prices (optional, per million tokens)
'This model caps output at {{cap}} — the saved value will be clamped to the model limit', 'llm.price.input': 'Input price / 1M tokens (optional)',
'llm.maxTokens.capInfo': 'Model output cap: {{cap}} tokens', 'llm.price.output': 'Output price / 1M tokens (optional)',
'llm.price.helper': 'Used for the cost estimate in the Token panel; leave empty to hide it',
'llm.thinking.unsupported': 'llm.thinking.unsupported':
'Model metadata marks thinking as unsupported — your setting is still sent; if reasoning exhausts the output budget, one degraded retry (thinking off) runs automatically', 'Model metadata marks thinking as unsupported — your setting is still sent; if reasoning exhausts the output budget, one degraded retry (thinking off) runs automatically',
'llm.pull.title': 'Download Ollama model', 'llm.pull.title': 'Download Ollama model',
@@ -957,6 +1012,20 @@ registerTranslations('en-US', {
'memory.searching': 'Searching...', 'memory.searching': 'Searching...',
'memory.search.empty': 'No matches', 'memory.search.empty': 'No matches',
'memory.empty': 'No memories yet', 'memory.empty': 'No memories yet',
// v0.8.1 P1-2: memory maintenance (MEMORY.md loop)
'memory.maintain.button': 'Tidy up',
'memory.maintain.title': 'Memory maintenance suggestions',
'memory.maintain.summary':
'MEMORY.md has {{total}} entries; the AI proposed {{actions}} action(s) below (select and apply)',
'memory.maintain.empty': 'No entries need maintenance',
'memory.maintain.delete': 'Delete',
'memory.maintain.merge': 'Merge',
'memory.maintain.apply': 'Apply ({{count}})',
'memory.maintain.applying': 'Applying...',
'memory.maintain.applied': 'Maintenance done: {{applied}} applied, {{skipped}} skipped',
'memory.maintain.failed': 'Memory maintenance failed. Please retry.',
'memory.maintain.emptySectionHint':
'These sections will have no entries after applying (headers are kept): {{sections}}',
'memory.loadFailed': 'Failed to load memories', 'memory.loadFailed': 'Failed to load memories',
'memory.deleteFailed': 'Delete failed', 'memory.deleteFailed': 'Delete failed',
'memory.type.episodic': 'Episodic', 'memory.type.episodic': 'Episodic',
@@ -982,11 +1051,10 @@ registerTranslations('en-US', {
'onboarding.llm.multimodal.label': 'Enable multimodal (image input)', 'onboarding.llm.multimodal.label': 'Enable multimodal (image input)',
'onboarding.llm.multimodal.helper': 'Allows uploading images; DeepSeek requires a vision model', 'onboarding.llm.multimodal.helper': 'Allows uploading images; DeepSeek requires a vision model',
'onboarding.llm.apiKey.placeholder': 'sk-... (leave empty for local models)', 'onboarding.llm.apiKey.placeholder': 'sk-... (leave empty for local models)',
'onboarding.llm.ctx.ollama': 'Context length (num_ctx)', 'onboarding.llm.ctx.window': 'Context length (tokens)',
'onboarding.llm.ctx.window': 'Context window (contextWindow)', 'onboarding.llm.ctx.placeholder': 'e.g. 32768, 131072, 1000000',
'onboarding.llm.ctx.ollamaPlaceholder': 'Default decided by the model (e.g. 2048, 4096, 128000)', 'onboarding.llm.ctx.helper':
'onboarding.llm.ctx.placeholder': 'e.g. 64000, 128000, 1000000', 'Single global setting: drives the compression budget and usage indicator; sent as num_ctx for Ollama',
'onboarding.llm.ctx.helper': 'Used for context-compression decisions; not sent to the API',
'onboarding.llm.ctx.minError': 'Minimum is {{min}}', 'onboarding.llm.ctx.minError': 'Minimum is {{min}}',
'onboarding.agent.title': 'Customize Agent', 'onboarding.agent.title': 'Customize Agent',
'onboarding.agent.body': 'onboarding.agent.body':
@@ -1112,6 +1180,30 @@ registerTranslations('en-US', {
'settings.agent.enableReflection': 'Enable reflection phase (REFLECTING)', 'settings.agent.enableReflection': 'Enable reflection phase (REFLECTING)',
'settings.agent.enableReflectionHelper': 'settings.agent.enableReflectionHelper':
'Enters a reflection state after each round of tool execution; logs warnings on failed results (does not block execution)', 'Enters a reflection state after each round of tool execution; logs warnings on failed results (does not block execution)',
// v0.8.1 P0-4: renderer residual hard-coded strings
'agent.busy.loadingTools': 'Tools are still loading. Please wait a moment...',
'agent.busy.streamingClear': 'The agent is replying — abort it before clearing the session',
'agent.busy.streamingEdit': 'The agent is replying — abort it before editing and resending',
'agent.busy.streamingRegenerate': 'The agent is replying — cannot regenerate now',
'agent.error.sessionCreateFailed': 'Failed to create session: {{message}}',
'agent.error.createFailedToast': 'Failed to create session',
'agent.error.sendFailed': 'Failed to send message: {{message}}',
'agent.error.clearFailedRetry': 'Failed to clear the session. Please retry.',
'agent.error.clearFailed': 'Failed to clear the session: {{message}}',
'agent.error.truncateFailedResend': 'Message truncation failed — cannot resend',
'agent.error.truncateFailedRegenerate': 'Message truncation failed — cannot regenerate',
'agent.info.nothingToRegenerate': 'No user message to regenerate',
'agent.error.saveFailed': 'Save failed: {{message}}',
'agent.error.copyFailed': 'Copy failed',
'agent.system.providerSwitched': 'Provider switched: {{from}} → {{to}}{{reason}}',
'agent.system.validationFound':
'🔍 Output validation: {{count}} suspected issue(s) found (hallucination / fact-consistency checks, for reference only)\n{{lines}}',
// v0.8.1 P1-1: local vector memory
'settings.agent.memory.title': 'Memory enhancement (optional)',
'settings.agent.memory.embeddingModel': 'Embedding model (Ollama)',
'settings.agent.memory.embeddingPlaceholder': 'e.g. nomic-embed-text (empty = off)',
'settings.agent.memory.embeddingHelper':
'When set, memory retrieval uses local vector hybrid recall (requires the Ollama provider and a pulled embedding model); leave empty for TF-IDF retrieval',
// ===== Appearance Settings ===== // ===== Appearance Settings =====
'settings.appearance.title': 'Appearance', 'settings.appearance.title': 'Appearance',
@@ -1124,6 +1216,11 @@ registerTranslations('en-US', {
'settings.appearance.localeEnUS': 'English', 'settings.appearance.localeEnUS': 'English',
'settings.appearance.localeHelper': 'settings.appearance.localeHelper':
'Takes effect immediately; migrated UI (system messages / tool confirmation dialog / sidebar / status bar / agent status) follows the selected language', 'Takes effect immediately; migrated UI (system messages / tool confirmation dialog / sidebar / status bar / agent status) follows the selected language',
// v0.8.1 P2-4: launch at login
'settings.appearance.autoStart': 'Launch at login',
'settings.appearance.autoStartHelper':
'Start MetonaAI automatically when the system boots (stays off if the platform does not support it)',
'settings.appearance.autoStart.unsupported': 'Launch at login is not supported on this platform',
// ===== Logs & Data Settings ===== // ===== Logs & Data Settings =====
'settings.logs.title': 'Logs & Data', 'settings.logs.title': 'Logs & Data',
@@ -1312,6 +1409,17 @@ registerTranslations('en-US', {
// ===== Tools Settings ===== // ===== Tools Settings =====
'settings.tools.title': 'Tools', 'settings.tools.title': 'Tools',
// v0.8.1 P2-1: per-tool custom policy
'settings.tools.policy.button': 'Policy',
'settings.tools.policy.title': 'Custom policy ({{name}}) — merged over defaults by field',
'settings.tools.policy.denied': 'Denied arguments (regex, comma separated)',
'settings.tools.policy.allowed': 'Allowed arguments whitelist (regex, comma separated)',
'settings.tools.policy.regexHint': 'e.g. /etc(?:/|$), C:\\Windows\\',
'settings.tools.policy.badRegex': 'Invalid regex: {{pattern}}',
'settings.tools.policy.frequency': 'Frequency cap (calls/min)',
'settings.tools.policy.confirm': 'Force confirmation',
'settings.tools.policy.save': 'Save policy',
'settings.tools.policy.saved': 'Tool policy saved and applied hot',
'settings.tools.toggleFailed': 'Failed to toggle tool', 'settings.tools.toggleFailed': 'Failed to toggle tool',
'settings.tools.autoExecFailed': 'Failed to set auto-execute', 'settings.tools.autoExecFailed': 'Failed to set auto-execute',
'settings.tools.autoExecTitle': 'Auto-execute tools', 'settings.tools.autoExecTitle': 'Auto-execute tools',
@@ -1409,6 +1517,8 @@ registerTranslations('en-US', {
'trace.token.total': 'Total usage', 'trace.token.total': 'Total usage',
'trace.token.context': 'Context usage', 'trace.token.context': 'Context usage',
'trace.token.compressedSaved': 'Saved by compression', 'trace.token.compressedSaved': 'Saved by compression',
'trace.token.cacheHit': 'Prompt cache hit',
'trace.token.cost': 'Estimated cost',
// ===== Trace Step (TraceStep / constants) ===== // ===== Trace Step (TraceStep / constants) =====
'trace.step.toolCalls': 'Tool calls', 'trace.step.toolCalls': 'Tool calls',
-31
View File
@@ -60,34 +60,3 @@ export function supportsImageUpload(input: ImageUploadGateInput): ImageUploadGat
return { allowed: true }; return { allowed: true };
} }
// ===== v0.8.0 FEAT-1: Provider 输出上限参数支持矩阵 =====
/**
* Provider "最大输出上限"LLM
* Provider
*
* API + adapter
* - deepseek: max_tokens384K / vision 8192
* - agnes: max_tokens 65.5K
* - mimo: max_completion_tokens 131072 / 32768 token
* - openai: max_tokenschat / max_completion_tokenso
* - anthropic: max_tokens
* - ollama: options.num_predict
*
* Provider
* false UI
*/
export const PROVIDER_OUTPUT_LIMIT_SUPPORT: Record<string, boolean> = {
deepseek: true,
agnes: true,
mimo: true,
openai: true,
anthropic: true,
ollama: true,
};
/** 查询当前 Provider 是否支持输出上限配置(未知 Provider 保守返回 false → 隐藏) */
export function supportsOutputLimitConfig(provider: string): boolean {
return PROVIDER_OUTPUT_LIMIT_SUPPORT[provider] === true;
}
+148 -49
View File
@@ -17,6 +17,12 @@ import { emitStreamBus } from '../lib/stream-event-bus';
import { t } from '../lib/i18n'; import { t } from '../lib/i18n';
import '../lib/i18n-strings'; import '../lib/i18n-strings';
/**
* v0.8.1 P2-3: 会话消息分页窗口大小/
* MessageList startReached
*/
const MESSAGE_PAGE_SIZE = 200;
// L-1: 消息 ID 防碰撞计数器 // L-1: 消息 ID 防碰撞计数器
let _msgIdCounter = 0; let _msgIdCounter = 0;
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`; export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`;
@@ -37,15 +43,6 @@ export function trimTraceStepsByAnchor<T extends { startedAt?: number }>(
return steps.filter((s) => typeof s.startedAt !== 'number' || s.startedAt < anchorTimestamp); return steps.filter((s) => typeof s.startedAt !== 'number' || s.startedAt < anchorTimestamp);
} }
/**
* L-16 修复: 提取上下文窗口默认值为命名常量
* - Ollama 4096 OllamaAdapter.DEFAULT_CONTEXT_WINDOW
* - ProviderDeepSeek/Agnes 1M adapter MODEL_INFO
* getContextWindow() listModels() UI
*/
const DEFAULT_OLLAMA_CONTEXT_WINDOW = 4096;
const DEFAULT_CLOUD_CONTEXT_WINDOW = 1_000_000;
// ===== 消息类型 ===== // ===== 消息类型 =====
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool'; export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
@@ -81,6 +78,10 @@ export interface ChatMessage {
content: string; content: string;
timestamp: number; timestamp: number;
iteration?: number; iteration?: number;
/**
* v0.8.1 P2-3: DB rowid rowid
*/
rowId?: number;
toolCalls?: ToolCallInfo[]; toolCalls?: ToolCallInfo[];
reasoningContent?: string; reasoningContent?: string;
attachments?: AttachmentInfo[]; attachments?: AttachmentInfo[];
@@ -88,9 +89,7 @@ export interface ChatMessage {
// ===== Agent 状态 ===== // ===== Agent 状态 =====
export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error'; export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error'; // ===== Token 统计 =====
// ===== Token 统计 =====
export interface TokenUsage { export interface TokenUsage {
/** 累计输入 token(所有轮次累加) */ /** 累计输入 token(所有轮次累加) */
@@ -111,6 +110,12 @@ export interface TokenUsage {
* UI "压缩节省了 X tokens" * UI "压缩节省了 X tokens"
*/ */
lastCompressedSaved: number; lastCompressedSaved: number;
/**
* v0.8.1 P1-3: Prompt Cache /Provider
* undefined = Provider UI
*/
cacheHitTokens?: number;
cacheMissTokens?: number;
} }
// ===== Trace 步骤 ===== // ===== Trace 步骤 =====
@@ -178,6 +183,13 @@ interface AgentState {
*/ */
sessionRunStates: Record<string, AgentStatus>; sessionRunStates: Record<string, AgentStatus>;
/**
* v0.8.1 P2-3: 分页状态 messagesComplete=false
* PAGE_SIZE loadingOlder
*/
messagesComplete: boolean;
loadingOlder: boolean;
/** /**
* v0.7.3 P1-4: 模型视觉能力探测缓存modelId supportsVision * v0.7.3 P1-4: 模型视觉能力探测缓存modelId supportsVision
* false /undefined = * false /undefined =
@@ -216,6 +228,11 @@ interface AgentState {
// v0.5.4: 多模态开关 setter(App 初始化与设置保存时同步) // v0.5.4: 多模态开关 setter(App 初始化与设置保存时同步)
setMultimodalEnabled: (enabled: boolean) => void; setMultimodalEnabled: (enabled: boolean) => void;
setMaxIterations: (max: number) => void; setMaxIterations: (max: number) => void;
/**
* v0.8.1: 全局config:changed llm.contextWindow
* LLM store
*/
setContextWindow: (contextWindow: number) => void;
setCurrentIteration: (n: number) => void; setCurrentIteration: (n: number) => void;
/** /**
* v0.7.2 P3-11: 维护后台会话运行状态图 * v0.7.2 P3-11: 维护后台会话运行状态图
@@ -241,6 +258,11 @@ interface AgentState {
*/ */
resetSessionState: () => void; resetSessionState: () => void;
abort: () => void; abort: () => void;
/**
* v0.8.1 P2-3: 向上加载更早消息 = rowId
* messagesComplete no-op
*/
loadOlderMessages: () => Promise<void>;
/** /**
* P2-11: 编辑重发 * P2-11: 编辑重发
* @param messageId ID * @param messageId ID
@@ -281,6 +303,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
sessionRunStates: {}, sessionRunStates: {},
// v0.7.3 P1-4: 模型视觉能力缓存(初始为空 = 全部未知 → 门控保守放行) // v0.7.3 P1-4: 模型视觉能力缓存(初始为空 = 全部未知 → 门控保守放行)
modelVisionCaps: {}, modelVisionCaps: {},
// v0.8.1 P2-3: 分页状态初始值
messagesComplete: true,
loadingOlder: false,
// ===== Actions ===== // ===== Actions =====
@@ -300,18 +325,23 @@ export const useAgentStore = create<AgentState>((set, get) => ({
agentStatus: 'idle', agentStatus: 'idle',
isStreaming: false, isStreaming: false,
currentRunId: null, currentRunId: null,
// v0.8.1 P2-3: 切换会话重置分页状态(加载完成后由 setCurrentSession 更新)
messagesComplete: false,
loadingOlder: false,
}); });
// 从数据库加载该会话的消息 // 从数据库加载该会话的消息v0.8.1 P2-3: 首屏只加载尾部窗口,防超长会话
// 一次性全量入内存;向上翻页由 loadOlderMessages 游标拉取)
if (id && window.metona?.sessions?.getMessages) { if (id && window.metona?.sessions?.getMessages) {
window.metona.sessions window.metona.sessions
.getMessages(id) .getMessages(id, { limit: MESSAGE_PAGE_SIZE })
.then((msgs) => { .then((msgs) => {
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息 // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息
if (get().currentSessionId !== id) return; if (get().currentSessionId !== id) return;
const messages = ( const messages = (
msgs as Array<{ msgs as Array<{
id: string; id: string;
rowId?: number;
role: string; role: string;
content: string; content: string;
reasoningContent?: string; reasoningContent?: string;
@@ -334,6 +364,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
.filter((m) => m.role !== 'tool') .filter((m) => m.role !== 'tool')
.map((m) => ({ .map((m) => ({
id: m.id, id: m.id,
rowId: m.rowId,
role: m.role as ChatMessage['role'], role: m.role as ChatMessage['role'],
content: m.content, content: m.content,
reasoningContent: m.reasoningContent, reasoningContent: m.reasoningContent,
@@ -342,7 +373,12 @@ export const useAgentStore = create<AgentState>((set, get) => ({
iteration: m.iteration, iteration: m.iteration,
timestamp: m.timestamp, timestamp: m.timestamp,
})); }));
set({ messages }); // v0.8.1 P2-3: 首屏尾部窗口加载 —— 返回条数 < limit 说明已到会话开头
set({
messages,
messagesComplete: msgs.length < MESSAGE_PAGE_SIZE,
loadingOlder: false,
});
}) })
.catch((err) => { .catch((err) => {
console.error('[AgentStore]', err); console.error('[AgentStore]', err);
@@ -446,7 +482,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用 // v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用
if (!get().toolsReady) { if (!get().toolsReady) {
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.warning('工具正在加载中,请稍候...')) .then((mod) => mod.default.warning(t('agent.busy.loadingTools')))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -487,7 +523,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
}); });
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)) .then((mod) =>
mod.default.error(
t('agent.error.sessionCreateFailed', { message: (err as Error).message }),
),
)
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -633,7 +673,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
}); });
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)) .then((mod) =>
mod.default.error(t('agent.error.sendFailed', { message: (err as Error).message })),
)
.catch(() => {}); .catch(() => {});
}, },
); );
@@ -695,11 +737,10 @@ export const useAgentStore = create<AgentState>((set, get) => ({
set((s) => ({ tokenUsage: { ...s.tokenUsage, lastCompressedSaved: savedTokens } })), set((s) => ({ tokenUsage: { ...s.tokenUsage, lastCompressedSaved: savedTokens } })),
setProvider: (provider, model) => { setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字 // v0.8.1 硬性契约: 上下文窗口唯一来源是设置面板「上下文长度」(llm.contextWindow
// v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取 // 全局单一配置,跨 Provider 生效)—— 删除了旧的 Ollama 4096 / 云端 1M 写死占位
const initialCtx = // 与分 Provider 配置键派生逻辑。未加载到配置前占位 0(指示条按"未知"隐藏)。
provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW; set({ provider, model, contextWindow: 0 });
set({ provider, model, contextWindow: initialCtx });
// v0.5.4: 多模态开关随 provider/model 状态一并加载(App 初始化/设置保存共用此路径) // v0.5.4: 多模态开关随 provider/model 状态一并加载(App 初始化/设置保存共用此路径)
if (window.metona?.config?.get) { if (window.metona?.config?.get) {
window.metona.config window.metona.config
@@ -711,26 +752,25 @@ export const useAgentStore = create<AgentState>((set, get) => ({
/* 读取失败保持默认 false */ /* 读取失败保持默认 false */
}); });
} }
// v0.3.1: 异步读取实际配置(所有 Provider // v0.8.1: 异步读取全局「上下文长度」配置(竞态保护与 multimodal 同口径
if (window.metona?.config?.get) { const expectedProvider = provider;
// Ollama 用 ollama.numCtxDeepSeek 用 deepseek.contextWindowAgnes 用 agnes.contextWindow window.metona?.config
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`; ?.get('llm.contextWindow')
// P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值 .then((v) => {
const expectedProvider = provider; if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果
window.metona.config if (v != null && typeof v === 'number' && v > 0) {
.get(configKey) set({ contextWindow: v });
.then((v) => { }
if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果 })
if (v != null && typeof v === 'number' && v > 0) { .catch((err) => {
set({ contextWindow: v }); console.error('[AgentStore]', err);
} });
})
.catch((err) => {
console.error('[AgentStore]', err);
});
}
}, },
// v0.8.1: 全局「上下文长度」热更新入口(config:changed llm.contextWindow 时调用;
// 与引擎 updateConfigAll 同步生效,详情面板占用百分比与输入框指示条即时刷新)
setContextWindow: (contextWindow) => set({ contextWindow }),
// v0.5.4: 多模态开关 setter — 设置保存/开关切换时同步(立即生效,ChatInput 消费) // v0.5.4: 多模态开关 setter — 设置保存/开关切换时同步(立即生效,ChatInput 消费)
setMultimodalEnabled: (enabled) => set({ multimodalEnabled: enabled }), setMultimodalEnabled: (enabled) => set({ multimodalEnabled: enabled }),
@@ -815,7 +855,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const { currentSessionId, isStreaming } = get(); const { currentSessionId, isStreaming } = get();
if (isStreaming) { if (isStreaming) {
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.warning('Agent 正在回复中,请先中断再清空会话')) .then((mod) => mod.default.warning(t('agent.busy.streamingClear')))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -824,14 +864,16 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const r = await window.metona.sessions.clearMessages(currentSessionId); const r = await window.metona.sessions.clearMessages(currentSessionId);
if (r && r.success === false) { if (r && r.success === false) {
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '清空会话失败,请重试')) .then((mod) => mod.default.error(r.error ?? t('agent.error.clearFailedRetry')))
.catch(() => {}); .catch(() => {});
return; return;
} }
} catch (err) { } catch (err) {
console.error('[AgentStore] clearMessages failed:', err); console.error('[AgentStore] clearMessages failed:', err);
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`清空会话失败:${(err as Error).message}`)) .then((mod) =>
mod.default.error(t('agent.error.clearFailed', { message: (err as Error).message })),
)
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -855,6 +897,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
isStreaming: false, isStreaming: false,
currentRunId: null, currentRunId: null,
sessionRunStates: currentSessionId ? restRunStates : s.sessionRunStates, sessionRunStates: currentSessionId ? restRunStates : s.sessionRunStates,
// v0.8.1 P2-3 review 修复: 分页状态同步复位(空会话即完整,防悬空游标)
messagesComplete: true,
loadingOlder: false,
}; };
}); });
}, },
@@ -880,10 +925,64 @@ export const useAgentStore = create<AgentState>((set, get) => ({
agentStatus: 'idle', agentStatus: 'idle',
isStreaming: false, isStreaming: false,
sessionRunStates: currentSessionId ? restRunStates : s.sessionRunStates, sessionRunStates: currentSessionId ? restRunStates : s.sessionRunStates,
// v0.8.1 P2-3 review 修复: 分页状态同步复位
messagesComplete: true,
loadingOlder: false,
}; };
}); });
}, },
// ===== v0.8.1 P2-3: 向上翻页加载 =====
loadOlderMessages: async () => {
const { currentSessionId, messages, loadingOlder, messagesComplete } = get();
if (!currentSessionId || loadingOlder || messagesComplete) return;
const oldestRowId = messages.find((m) => m.rowId != null)?.rowId;
if (oldestRowId == null) {
// 无游标可依(全量数据或空会话)→ 视为已完整
set({ messagesComplete: true });
return;
}
set({ loadingOlder: true });
try {
const msgs = (await window.metona.sessions.getMessages(currentSessionId, {
limit: MESSAGE_PAGE_SIZE,
beforeRowid: oldestRowId,
})) as Array<{
id: string;
rowId?: number;
role: string;
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
attachments?: Array<{ id: string; name: string; type: string; size: number }>;
iteration?: number;
timestamp: number;
}>;
const older = msgs
.filter((m) => m.role !== 'tool')
.map((m) => ({
id: m.id,
rowId: m.rowId,
role: m.role as ChatMessage['role'],
content: m.content,
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
attachments: m.attachments as AttachmentInfo[] | undefined,
iteration: m.iteration,
timestamp: m.timestamp,
}));
set((s) => ({
// 竞态保护:会话已切换时丢弃
messages: s.currentSessionId === currentSessionId ? [...older, ...s.messages] : s.messages,
messagesComplete: msgs.length < MESSAGE_PAGE_SIZE,
loadingOlder: false,
}));
} catch (err) {
console.error('[AgentStore] loadOlderMessages failed:', err);
set({ loadingOlder: false });
}
},
abort: () => { abort: () => {
const sessionId = get().currentSessionId; const sessionId = get().currentSessionId;
// 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED // 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED
@@ -915,7 +1014,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const { messages, currentSessionId, isStreaming } = get(); const { messages, currentSessionId, isStreaming } = get();
if (isStreaming) { if (isStreaming) {
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.warning('Agent 正在回复中,请先中断再编辑重发')) .then((mod) => mod.default.warning(t('agent.busy.streamingEdit')))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -931,7 +1030,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
} catch (err) { } catch (err) {
console.error('[AgentStore] truncateAfter failed:', err); console.error('[AgentStore] truncateAfter failed:', err);
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error('消息截断失败,无法重发')) .then((mod) => mod.default.error(t('agent.error.truncateFailedResend')))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -966,7 +1065,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const { messages, currentSessionId, isStreaming } = get(); const { messages, currentSessionId, isStreaming } = get();
if (isStreaming) { if (isStreaming) {
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.warning('Agent 正在回复中,无法重新生成')) .then((mod) => mod.default.warning(t('agent.busy.streamingRegenerate')))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -979,7 +1078,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
messages[lastUserIdx].role !== 'user' messages[lastUserIdx].role !== 'user'
) { ) {
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.info('没有可重新生成的用户消息')) .then((mod) => mod.default.info(t('agent.info.nothingToRegenerate')))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -992,7 +1091,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
} catch (err) { } catch (err) {
console.error('[AgentStore] truncateAfter failed:', err); console.error('[AgentStore] truncateAfter failed:', err);
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error('消息截断失败,无法重新生成')) .then((mod) => mod.default.error(t('agent.error.truncateFailedRegenerate')))
.catch(() => {}); .catch(() => {});
return; return;
} }
+67 -1
View File
@@ -126,7 +126,10 @@ interface MetonaSessionsAPI {
create: (title?: string) => Promise<MetonaSessionInfo>; create: (title?: string) => Promise<MetonaSessionInfo>;
rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>; rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>; delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
getMessages: (sessionId: string) => Promise< getMessages: (
sessionId: string,
options?: { limit?: number; beforeRowid?: number },
) => Promise<
Array<{ Array<{
id: string; id: string;
role: string; role: string;
@@ -252,6 +255,25 @@ interface MetonaMCPAPI {
}>; }>;
}; };
}>; }>;
/** v0.8.1 P1-4: Prompt 渲染(prompts/get)—— ChatInput 斜杠菜单填充输入框 */
getPrompt: (
serverName: string,
promptName: string,
args?: Record<string, string>,
) => Promise<{
success: boolean;
error?: string;
data?: { text: string; description?: string };
}>;
/** v0.8.1 P1-4: Resource 读取(resources/read)—— @mcp 提及注入(512KB 上限) */
readResource: (
serverName: string,
uri: string,
) => Promise<{
success: boolean;
error?: string;
data?: { text: string; truncated: boolean };
}>;
} }
// ===== Memory API ===== // ===== Memory API =====
@@ -281,6 +303,37 @@ interface MetonaMemoryAPI {
error?: string; error?: string;
}>; }>;
delete: (type: string, id: string) => Promise<{ success: boolean; error?: string }>; delete: (type: string, id: string) => Promise<{ success: boolean; error?: string }>;
/** v0.8.1 P1-2: MEMORY.md 维护建议(LLM 分析结果) */
analyzeMaintenance: () => Promise<{
success: boolean;
error?: string;
data?: {
actions: Array<{
action: 'delete' | 'update';
section: string;
entry: string;
newEntry?: string;
reason?: string;
}>;
totalEntries: number;
/** v0.8.1 review (O2): 各分区当前条目数(空分区提示数据源) */
sectionEntryCounts: Record<string, number>;
};
}>;
/** v0.8.1 P1-2: 应用用户确认的维护动作 */
applyMaintenance: (
actions: Array<{
action: 'delete' | 'update';
section: string;
entry: string;
newEntry?: string;
reason?: string;
}>,
) => Promise<{
success: boolean;
error?: string;
data?: { applied: number; skipped: number };
}>;
} }
// ===== Config API ===== // ===== Config API =====
@@ -305,6 +358,15 @@ interface MetonaAppAPI {
showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>; showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>; selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
restart: () => Promise<{ success: boolean }>; restart: () => Promise<{ success: boolean }>;
/** v0.8.1 P2-4: 开机自启开关(平台不支持时 openAtLogin 恒为 false */
setLoginItem: (
enabled: boolean,
) => Promise<{ success: boolean; error?: string; data?: { openAtLogin: boolean } }>;
getLoginItem: () => Promise<{
success: boolean;
error?: string;
data?: { openAtLogin: boolean };
}>;
reportError: (payload: unknown) => void; reportError: (payload: unknown) => void;
/** v0.6.4 P4-2: 手动检查更新 */ /** v0.6.4 P4-2: 手动检查更新 */
updateCheck: () => Promise< updateCheck: () => Promise<
@@ -650,6 +712,10 @@ interface MetonaConfirmationRequest {
interface MetonaToolAPI { interface MetonaToolAPI {
onConfirmationRequest: (callback: (request: MetonaConfirmationRequest) => void) => () => void; onConfirmationRequest: (callback: (request: MetonaConfirmationRequest) => void) => () => void;
/** v0.8.1 P2-2: 连续同类工具批量确认事件(>=3 条同类并行请求合并为单事件) */
onConfirmationRequestBatch: (
callback: (requests: MetonaConfirmationRequest[]) => void,
) => () => void;
sendConfirmationResponse: (response: { sendConfirmationResponse: (response: {
toolCallId: string; toolCallId: string;
approved: boolean; approved: boolean;
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'], include: ['**/*.{test,spec}.{ts,tsx}'],
// v0.8.1 P2-5: E2E 目录归 Playwrightplaywright.config.ts)—— vitest 不收集
exclude: ['**/node_modules/**', 'e2e/**'],
// v0.7.4: 组件测试 setup —— jest-dom 断言 + window.metona mock + i18n 字典。 // v0.7.4: 组件测试 setup —— jest-dom 断言 + window.metona mock + i18n 字典。
// 全局环境保持 node(大量 electron 逻辑测试依赖);组件测试文件用 // 全局环境保持 node(大量 electron 逻辑测试依赖);组件测试文件用
// `// @vitest-environment jsdom` 注释切换。 // `// @vitest-environment jsdom` 注释切换。