feat: v0.8.3 工作空间切换链路根治 · 思考强度扩档 — 启动TDZ连锁/继承丢数/回显滞后三修复 · xhigh+true档位单源 · 安全防线ready后注册 · 2217 用例全量回归 + 生产模式E2E
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m37s
CI / 全量测试 (Electron ABI) (push) Failing after 6m1s
CI / 产物编译验证 (push) Successful in 10m54s

This commit is contained in:
2026-09-08 21:14:59 +08:00
parent 4cd6e997b5
commit 9329df68af
18 changed files with 250 additions and 114 deletions
+7 -12
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.8.2-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.8.3-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" /> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -54,19 +54,14 @@
--- ---
## 🆕 v0.8.2 更新亮点 ## 🆕 v0.8.3 更新亮点
| 特性 | 说明 | | 特性 | 说明 |
|:---|:---| |:---|:---|
| 🛡 **适配器图片下载 SSRF 收口** | Anthropic/Ollama 的图片 URL 下载统一走 DNS Pinning 安全通道(逐跳重定向复检 + 10MB 上限 + 类型白名单),根治"诱导模型回读内网数据"的可回读外泄通道 | | 🔄 **工作空间切换保存修复** | 根治"确认切换报错、重启后仍回旧空间":CSP 注入遗留模块顶层导致生产环境每次启动抛 "Session can only be received when app is ready",主进程模块求值中断,连锁令 `workspace.path` 保存链路的动态 `import('../main')` 抛 TDZ 死区错误。现安全防线(权限白名单 + CSP)统一在 ready 后、窗口创建前注册并首次真正生效,工作空间路径持久化抽离为独立模块(`utils/workspace-path.ts`)消除循环依赖;启动时再以 workspace-config.json(权威源)回写对齐当前空间 DB —— 修复继承复制滞后导致的"切换重启后设置页回显旧路径/空路径" |
| 🔐 **根 MEMORY.md 保护根治** | 闸门从工具名单枚举反转为"任意工具的路径形参数统一精确匹配"—— delete_file / file_move / MCP 文件类工具全部纳入保护 | | 💾 **继承数据库静默丢数修复** | `workspace:inheritFiles` 的 SQLite `backup()` 返回 Promise 未 await,源库被提前 `close()` 掐断备份 —— 目标 agent.db 仅剩空壳单页(切换后历史会话/记忆/Trace 静默丢失,重启后全量重建空表),且备份失败 rejection 无人接住(unhandledRejection)。现等待备份完成再关闭源库,失败仍回退 `copyFileSync` |
| 🔌 **Anthropic 协议保真** | extended thinking 块(含签名)在工具循环多轮回传;pause_turn 长回复自动续传(预算耗尽按截断语义收尾,不再静默丢失 | | 🧠 **思考强度新增 xhigh / true 档** | `agent.thinkingEffort` 扩展为 6 档:low / medium / high / **xhigh** / max / **true**。xhigh 原样下发 Ollama(Qwen3 等新思考模板的原生最高档,旧版只发 high 会触发模板 500 "Unexpected reasoning effort");`true` 不指定档位,交由模型思考模板用自身默认档。仅支持固定档位的 Provider 就近降档映射(OpenAI/DeepSeek → highAnthropic → 24576/16384 token 预算 |
| 📦 **上下文压缩正确性** | 压缩时机移至本轮消息入列后、估算纳入 System Prompt、摘要调用重试 + 纯截断兜底、故障转移后预算校正值重置 | | 🎛 **思考参数档位单源** | `MetonaThinkingEffort` 类型收敛至 IR 层单一来源(引擎/引擎配置注入/UI 共用),消除四处内联 union 的漂移风险 |
| 💾 **全局配置原子写 + 自愈** | global-config.json 改为 tmp+rename 原子落盘并维护备份 —— 崩溃不再丢失全部全局配置(含 LLM 凭据) |
| 🧠 **向量记忆召回修复** | 向量检索与重要度预过滤解耦(独立召回 200 条池)、embedding 模型指纹(更换模型后惰性重算自愈)、语义记忆 access_count 更新后不再归零 |
| 🎨 **工具结果富媒体渲染** | view_image / 浏览器截图在聊天流内联预览;diff_viewer / file_editor dry_run 渲染为增删着色的 unified diff 视图 |
| ⌨️ **交互断链修复** | 引用回复 / Ctrl+L 聚焦输入框(受控输入桥根治)、IPC 桥缺失兜底、根级 ErrorBoundary 逐区隔离、/export 全量导出、会话列表实时刷新、Onboarding 首帧闪烁消除 |
| 🌐 **i18n 与行为对齐** | 托盘菜单/系统对话框/剩余 8 处文案出层;MiMo 思考未配置默认关闭(尊重用户意图)、MiMo 联网搜索引用回填正文、OpenAI 推理模型 stop 参数前置拦截 |
--- ---
@@ -670,7 +665,7 @@ OLLAMA_BASE_URL=http://localhost:11434
| `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) | | `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) |
| `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) | | `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) |
| `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 | | `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 |
| `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) | | `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / xhigh / max / true —— xhigh 为 Qwen3 等模板原生档,true 交由模型默认档,v0.8.3) |
| `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) | | `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) |
| `agent.enableReflection` | `false` | 反思阶段开关 — 开启后每轮工具执行经过 REFLECTING 状态(失败结果告警,不阻断) | | `agent.enableReflection` | `false` | 反思阶段开关 — 开启后每轮工具执行经过 REFLECTING 状态(失败结果告警,不阻断) |
| `memory.consolidationEnabled` | `true` | 会话结束记忆固化总开关(v0.7.3 节流策略) | | `memory.consolidationEnabled` | `true` | 会话结束记忆固化总开关(v0.7.3 节流策略) |
@@ -82,7 +82,9 @@ describe('OpenAIAdapter — 推理模型 reasoning_effort', () => {
['low', 'low'], ['low', 'low'],
['medium', 'medium'], ['medium', 'medium'],
['high', 'high'], ['high', 'high'],
['xhigh', 'high'], // v0.8.3: OpenAI 档位封顶 highxhigh 就近降档
['max', 'high'], // max 归一 high ['max', 'high'], // max 归一 high
['true', 'high'], // v0.8.3: 模型默认档归一 high
] as const)('o3-mini effort=%s → reasoning_effort=%s', async (effort, expected) => { ] as const)('o3-mini effort=%s → reasoning_effort=%s', async (effort, expected) => {
const adapter = makeAdapter('o3-mini'); const adapter = makeAdapter('o3-mini');
mockFetch.mockResolvedValue(okResponse()); mockFetch.mockResolvedValue(okResponse());
@@ -13,7 +13,7 @@
* *
* Ollama: * Ollama:
* O1 options 映射(num_predict=numTokens、num_ctx=contextLength、stop、top_p * O1 options 映射(num_predict=numTokens、num_ctx=contextLength、stop、top_p
* O2 think 参数 effort 映射(low→"low"、max→true)与未配置时缺省 * O2 think 参数 effort 映射(low→"low"、xhigh→"xhigh"、max/true→true)与未配置时缺省
* O3 图片归一化(data URI 剥前缀;无 URL 触发下载分支时零网络请求) * O3 图片归一化(data URI 剥前缀;无 URL 触发下载分支时零网络请求)
* *
* Agnes: * Agnes:
@@ -286,7 +286,7 @@ describe('OllamaAdapter — 请求体契约', () => {
expect(options.stop).toEqual(['STOP']); expect(options.stop).toEqual(['STOP']);
}); });
it('O2: think 参数 effort 映射(low→"low"、max→true);未开启思考时缺省', async () => { it('O2: think 参数 effort 映射(low→"low"、xhigh→"xhigh"、max/true→true);未开启思考时缺省', async () => {
const adapter = makeOllama(); const adapter = makeOllama();
const { bodies } = captureFetch(); const { bodies } = captureFetch();
@@ -316,12 +316,39 @@ describe('OllamaAdapter — 请求体契约', () => {
); );
expect(bodies[1].think).toBe(true); expect(bodies[1].think).toBe(true);
// v0.8.3: xhigh 原样透传(Qwen3 等思考模板原生档);true → 布尔 true(模型默认档)
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'xhigh',
},
}),
);
expect(bodies[2].think).toBe('xhigh');
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'true',
},
}),
);
expect(bodies[3].think).toBe(true);
await adapter.send( await adapter.send(
makeRequest({ makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false }, params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
}), }),
); );
expect(bodies[2].think).toBeUndefined(); expect(bodies[4].think).toBeUndefined();
}); });
it('O3: data URI 图片剥前缀转纯 base64 数组(无网络下载路径触发)', async () => { it('O3: data URI 图片剥前缀转纯 base64 数组(无网络下载路径触发)', async () => {
@@ -516,8 +543,10 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
['low', 1024], ['low', 1024],
['medium', 4096], ['medium', 4096],
['high', 16384], ['high', 16384],
['xhigh', 24576], // v0.8.3: 介于 high 与 max 之间
// v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768 // v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768
['max', 32768], ['max', 32768],
['true', 16384], // v0.8.3: 模型默认档按 high 同档预算
] 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();
@@ -723,9 +752,11 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
['low', 'high'], ['low', 'high'],
['medium', 'high'], ['medium', 'high'],
['high', 'high'], ['high', 'high'],
['xhigh', 'high'], // v0.8.3: DeepSeek 无 xhigh 档,就近映射 high
['max', 'max'], ['max', 'max'],
['true', 'high'], // v0.8.3: 模型默认档映射 high
] as const)( ] as const)(
'effort=%s → reasoning_effort=%slow/medium 归一 high', 'effort=%s → reasoning_effort=%slow/medium/xhigh/true 归一 high',
async (effort, expected) => { async (effort, expected) => {
const adapter = makeAdapter(); const adapter = makeAdapter();
const { bodies } = captureFetch(); const { bodies } = captureFetch();
@@ -730,12 +730,16 @@ export class AnthropicAdapter extends BaseAdapter {
} }
// Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半) // Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半)
// v0.8.3: 新增 xhigh / true 档 —— xhigh 介于 high 与 max 之间取 24576
// true(模型默认档)按 high 同档预算
if (thinkingRequested) { if (thinkingRequested) {
const budgetMap: Record<string, number> = { const budgetMap: Record<string, number> = {
low: 1024, low: 1024,
medium: 4096, medium: 4096,
high: 16384, high: 16384,
xhigh: 24576,
max: 32768, max: 32768,
true: 16384,
}; };
const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384; const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384;
// thinking 路径 maxTokensForRequest 恒为数字(Math.max(2048, …) 兜底) // thinking 路径 maxTokensForRequest 恒为数字(Math.max(2048, …) 兜底)
@@ -206,13 +206,16 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
body.thinking = { type: 'disabled' }; body.thinking = { type: 'disabled' };
} else { } else {
body.thinking = { type: 'enabled' }; body.thinking = { type: 'enabled' };
// v0.8.3: 新增 xhigh / true 档 —— DeepSeek API 仅 high / max 两档,就近映射 high
const effortMap: Record<string, string> = { const effortMap: Record<string, string> = {
low: 'high', low: 'high',
medium: 'high', medium: 'high',
high: 'high', high: 'high',
xhigh: 'high',
max: 'max', max: 'max',
true: 'high',
}; };
// DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high // DeepSeek API 仅支持 high / max 两档,low/medium/xhigh/true 映射为 high
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
// 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截) // 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截)
if (DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]?.supportsThinking === false) { if (DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]?.supportsThinking === false) {
@@ -698,11 +698,17 @@ export class OllamaAdapter extends BaseAdapter {
if (request.params.thinkingEnabled) { if (request.params.thinkingEnabled) {
const modelThinkingSupported = this.cachedThinkingSupport !== false; const modelThinkingSupported = this.cachedThinkingSupport !== false;
if (modelThinkingSupported) { if (modelThinkingSupported) {
// v0.8.3: 新增 xhigh / true 档 —— Qwen3 等新模型思考模板原生支持 xhigh
//(旧版仅下发 low/medium/high 时,模板会 500 "Unexpected reasoning effort");
// true = 不指定档位(think: true),由模型思考模板用自身默认档。
// max 语义为"应用内最高档"Ollama 侧同样以布尔 true 放行给服务端默认。
const effortMap: Record<string, string | boolean> = { const effortMap: Record<string, string | boolean> = {
low: 'low', low: 'low',
medium: 'medium', medium: 'medium',
high: 'high', high: 'high',
xhigh: 'xhigh',
max: true, max: true,
true: true,
}; };
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true; body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
// v0.8.0 P0-3: 思考占用 num_predict 输出预算 —— 预算过小时显式告警 // v0.8.0 P0-3: 思考占用 num_predict 输出预算 —— 预算过小时显式告警
@@ -149,12 +149,15 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
} }
// Thinking 模式:推理模型映射 reasoning_effort;非推理模型忽略 // Thinking 模式:推理模型映射 reasoning_effort;非推理模型忽略
// v0.8.3: 新增 xhigh / true 档 —— OpenAI API 档位封顶 high,二者就近映射
if (request.params.thinkingEnabled && isReasoningModel) { if (request.params.thinkingEnabled && isReasoningModel) {
const effortMap: Record<string, string> = { const effortMap: Record<string, string> = {
low: 'low', low: 'low',
medium: 'medium', medium: 'medium',
high: 'high', high: 'high',
xhigh: 'high',
max: 'high', max: 'high',
true: '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 —— 用户配置的预算过小时告警
+8 -3
View File
@@ -4,7 +4,12 @@
* 用于 Agent Loop 引擎内部的状态管理和迭代记录。 * 用于 Agent Loop 引擎内部的状态管理和迭代记录。
*/ */
import type { MetonaToolCall, MetonaToolResult, MetonaThinkingBlock } from '../types'; import type {
MetonaThinkingEffort,
MetonaToolCall,
MetonaToolResult,
MetonaThinkingBlock,
} from '../types';
// ===== Agent Loop 状态机 ===== // ===== Agent Loop 状态机 =====
@@ -100,8 +105,8 @@ export interface AgentLoopConfig {
maxTokens?: number; maxTokens?: number;
/** 是否启用思考模式(默认 true) */ /** 是否启用思考模式(默认 true) */
thinkingEnabled: boolean; thinkingEnabled: boolean;
/** 思考强度(默认 'high' */ /** 思考强度(默认 'high'v0.8.3 档位单源见 MetonaThinkingEffort */
thinkingEffort: 'low' | 'medium' | 'high' | 'max'; thinkingEffort: MetonaThinkingEffort;
/** Ollama num_ctx(与「上下文长度」同源:llm.contextWindow,仅 Ollama Provider 下发) */ /** Ollama num_ctx(与「上下文长度」同源:llm.contextWindow,仅 Ollama Provider 下发) */
contextLength?: number; contextLength?: number;
/** 工具执行兜底超时(ms,默认 120000),实际取 max(此值, tool.timeoutMs) */ /** 工具执行兜底超时(ms,默认 120000),实际取 max(此值, tool.timeoutMs) */
+1
View File
@@ -11,6 +11,7 @@ export type {
MetonaRequestMeta, MetonaRequestMeta,
MetonaSystemPrompt, MetonaSystemPrompt,
MetonaGenerationParams, MetonaGenerationParams,
MetonaThinkingEffort,
MetonaConstraints, MetonaConstraints,
MetonaMessage, MetonaMessage,
MetonaImageContent, MetonaImageContent,
+12 -1
View File
@@ -38,6 +38,17 @@ export interface MetonaSystemPrompt {
// ===== 生成参数 ===== // ===== 生成参数 =====
/**
* 思考强度档位(v0.8.3 起单一类型源,引擎/引擎配置/UI/各 Adapter 共用)
*
* - low / medium / high / max:原始四档
* - xhighv0.8.3 新增 —— Qwen3 等新模型思考模板原生支持 xhigh 档
* (Ollama 下原样下发;仅支持 low/medium/high 的 Provider 就近降档映射)
* - truev0.8.3 新增 —— 不指定档位,`think: true` 交给模型思考模板用自身默认
* 档(如 Qwen3 默认 xhigh);有显式档位协议的 Provider 就近映射为 high
*/
export type MetonaThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'true';
export interface MetonaGenerationParams { export interface MetonaGenerationParams {
/** 最大生成 token 数 */ /** 最大生成 token 数 */
maxTokens?: number; maxTokens?: number;
@@ -52,7 +63,7 @@ export interface MetonaGenerationParams {
/** 是否启用思考模式 */ /** 是否启用思考模式 */
thinkingEnabled?: boolean; thinkingEnabled?: boolean;
/** 思考强度(替代 thinkingBudget,各 Provider 映射见 Adapter 规范) */ /** 思考强度(替代 thinkingBudget,各 Provider 映射见 Adapter 规范) */
thinkingEffort?: 'low' | 'medium' | 'high' | 'max'; thinkingEffort?: MetonaThinkingEffort;
/** 上下文窗口大小(仅 Ollama 支持 num_ctx */ /** 上下文窗口大小(仅 Ollama 支持 num_ctx */
contextLength?: number; contextLength?: number;
} }
+8 -3
View File
@@ -14,6 +14,8 @@ import { isSensitiveConfigKey } from '../utils/secure-config';
import { maskSensitiveValue } from '../utils/mask'; import { maskSensitiveValue } from '../utils/mask';
// v0.8.1 P2-1: 工具自定义策略解析 // v0.8.1 P2-1: 工具自定义策略解析
import { parseToolPolicy } from '../harness/sandbox/permissions'; import { parseToolPolicy } from '../harness/sandbox/permissions';
// v0.8.3: 思考强度档位单源(新增 xhigh / true 档)
import type { MetonaThinkingEffort } from '../harness/types';
// v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过) // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
import { import {
assertSafeConfigTargetDeep, assertSafeConfigTargetDeep,
@@ -21,6 +23,10 @@ import {
} from '../harness/tools/built-in/ssrf-guard'; } from '../harness/tools/built-in/ssrf-guard';
// v0.8.1 P0-4: 主进程文案双语 // v0.8.1 P0-4: 主进程文案双语
import { setMainLocale, mt } from '../utils/main-locale'; import { setMainLocale, mt } from '../utils/main-locale';
// v0.8.3: 工作空间路径持久化独立成模块 —— 此前动态 import('../main') 与入口模块
// 形成循环依赖,入口求值一旦中断即抛 TDZ "Cannot access 'main' before
// initialization",工作空间切换保存随之失败
import { writeWorkspacePathToFile } from '../utils/workspace-path';
/** /**
* v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。 * v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。
@@ -149,10 +155,10 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
break; break;
case 'agent.thinkingEffort': case 'agent.thinkingEffort':
agentEngineManager.updateConfigAll({ agentEngineManager.updateConfigAll({
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', thinkingEffort: value as MetonaThinkingEffort,
}); });
orchestrator.updateDefaultConfig({ orchestrator.updateDefaultConfig({
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', thinkingEffort: value as MetonaThinkingEffort,
}); });
break; break;
// v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关 // v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关
@@ -293,7 +299,6 @@ export async function applyConfigSideEffects(
); );
if (workspaceEntry) { if (workspaceEntry) {
try { try {
const { writeWorkspacePathToFile } = await import('../main');
writeWorkspacePathToFile(workspaceEntry.value as string); writeWorkspacePathToFile(workspaceEntry.value as string);
log.info(`[CONFIG] Workspace path saved (restart required): ${workspaceEntry.value}`); log.info(`[CONFIG] Workspace path saved (restart required): ${workspaceEntry.value}`);
} catch (err) { } catch (err) {
+4 -1
View File
@@ -161,7 +161,10 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
const Database = (await import('better-sqlite3')).default; const Database = (await import('better-sqlite3')).default;
const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true }); const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true });
try { try {
srcDb.backup(dstFile); // v0.8.3 修复: backup() 返回 Promise,必须 await —— 原实现未等待就
// close() 源库,备份被中途掐断:目标库只剩空壳页(继承数据静默丢失,
// 重启后全量重建表),且 rejection 无人接住(unhandledRejection FATAL)。
await srcDb.backup(dstFile);
inherited.push(fileName); inherited.push(fileName);
log.info( log.info(
`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`, `[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`,
+54 -49
View File
@@ -17,7 +17,6 @@
import 'dotenv/config'; import 'dotenv/config';
import { app, shell, Menu, BrowserWindow, dialog, session } from 'electron'; import { app, shell, Menu, BrowserWindow, dialog, session } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils'; import { electronApp, optimizer } from '@electron-toolkit/utils';
import log from 'electron-log'; import log from 'electron-log';
import { DatabaseService } from './services/database.service'; import { DatabaseService } from './services/database.service';
@@ -47,6 +46,8 @@ import { OllamaAdapter } from './harness/adapters/ollama.adapter';
import { OpenAIAdapter } from './harness/adapters/openai.adapter'; import { OpenAIAdapter } from './harness/adapters/openai.adapter';
import { AnthropicAdapter } from './harness/adapters/anthropic.adapter'; import { AnthropicAdapter } from './harness/adapters/anthropic.adapter';
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter'; import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
// v0.8.3: 思考强度档位单源(新增 xhigh / true 档)
import type { MetonaThinkingEffort } from './harness/types';
import { import {
ReadFileTool, ReadFileTool,
WriteFileTool, WriteFileTool,
@@ -95,6 +96,10 @@ import { HealthChecker, SLOMonitor } from './utils/slo';
// v0.8.1 P0-4: 主进程 toast/通知文案双语(ui.locale 驱动) // v0.8.1 P0-4: 主进程 toast/通知文案双语(ui.locale 驱动)
import { setMainLocale, mt } from './utils/main-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 分区)
// v0.8.3: 工作空间路径持久化独立成模块(消除 main ↔ ipc/shared 循环依赖 ——
// 此前 shared.ts 动态 import('../main') 取 writeWorkspacePathToFile,入口模块
// 求值一旦中断即抛 TDZ,工作空间切换保存随之报错)
import { readWorkspacePathFromFile, writeWorkspacePathToFile } from './utils/workspace-path';
// ===== 步骤 1: 初始化日志系统(SYS 层)===== // ===== 步骤 1: 初始化日志系统(SYS 层)=====
log.transports.file.level = 'info'; log.transports.file.level = 'info';
@@ -130,31 +135,6 @@ if (process.env['METONA_USER_DATA_DIR']) {
app.setPath('userData', process.env['METONA_USER_DATA_DIR']); app.setPath('userData', process.env['METONA_USER_DATA_DIR']);
} }
// ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)=====
const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json');
function readWorkspacePathFromFile(): string | null {
try {
if (existsSync(WORKSPACE_CONFIG_FILE)) {
const data = JSON.parse(readFileSync(WORKSPACE_CONFIG_FILE, 'utf-8'));
return data.workspacePath ?? null;
}
} catch {
// 忽略读取错误
}
return null;
}
function writeWorkspacePathToFile(workspacePath: string): void {
try {
writeFileSync(WORKSPACE_CONFIG_FILE, JSON.stringify({ workspacePath }, null, 2), 'utf-8');
} catch (err) {
log.error('Failed to write workspace config file:', err);
}
}
export { readWorkspacePathFromFile, writeWorkspacePathToFile };
let databaseService: DatabaseService | null = null; let databaseService: DatabaseService | null = null;
let trayManager: TrayManager | null = null; let trayManager: TrayManager | null = null;
let windowManager: WindowManager | null = null; let windowManager: WindowManager | null = null;
@@ -196,8 +176,14 @@ async function initialize(): Promise<void> {
const workspaceService = new WorkspaceService(savedWorkspacePath ?? undefined); const workspaceService = new WorkspaceService(savedWorkspacePath ?? undefined);
const workspaceInfo = workspaceService.initialize(); const workspaceInfo = workspaceService.initialize();
// 持久化工作空间路径(供下次启动读取) // 持久化工作空间路径(供下次启动读取)
// v0.8.3: writeWorkspacePathToFile 改为向上抛错(供切换工作空间场景 toast 告知),
// 启动期回写失败非致命 —— 仅日志留痕,不中断初始化。
if (savedWorkspacePath !== workspaceInfo.path) { if (savedWorkspacePath !== workspaceInfo.path) {
try {
writeWorkspacePathToFile(workspaceInfo.path); writeWorkspacePathToFile(workspaceInfo.path);
} catch {
// 错误已在 writeWorkspacePathToFile 内记录
}
} }
log.info( log.info(
`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`, `Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`,
@@ -220,6 +206,19 @@ async function initialize(): Promise<void> {
// v0.8.1 P0-4: 主进程语言随 ui.locale 注入(变更经 applyConfigSideEffects 热切换) // v0.8.1 P0-4: 主进程语言随 ui.locale 注入(变更经 applyConfigSideEffects 热切换)
setMainLocale(configService.get<string>('ui.locale')); setMainLocale(configService.get<string>('ui.locale'));
// v0.8.3: workspace.path 权威源对齐(设置页回显修复)
// 切换工作空间时,新路径经 config:set 写入的是旧空间的 DB,而"继承数据库"复制
// 发生在该写入之前 —— 新空间的 DB 携带的是滞后一位的旧路径;未继承 DB 的全新
// 空间则根本没有该键。重启后设置页从当前空间 DB 读回显,显示的便不是当前空间。
// workspace-config.json 是启动权威源(readWorkspacePathFromFile 决定本次加载哪个
// 空间),这里把它回写进当前空间 DB,自愈一切来源的漂移(继承复制滞后 / 全新
// 空间缺键 / 引导向导写入),保证 config:get('workspace.path') 与实际加载的空间
// 永远一致。
if (configService.get<string>('workspace.path') !== workspaceInfo.path) {
configService.set('workspace.path', workspaceInfo.path);
log.info(`[CONFIG] workspace.path synced to loaded workspace: ${workspaceInfo.path}`);
}
// v0.8.1 P2-5: E2E 引导种子(见文件顶部说明;仅 METONA_E2E_SEED_CONFIG 时生效) // v0.8.1 P2-5: E2E 引导种子(见文件顶部说明;仅 METONA_E2E_SEED_CONFIG 时生效)
if (process.env['METONA_E2E_SEED_CONFIG'] === '1') { if (process.env['METONA_E2E_SEED_CONFIG'] === '1') {
configService.set('onboarding.completed', true); configService.set('onboarding.completed', true);
@@ -476,12 +475,8 @@ async function initialize(): Promise<void> {
contextWindow: getContextWindowSetting(), 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 MetonaThinkingEffort | null) ??
| 'low' 'high',
| 'medium'
| 'high'
| 'max'
| null) ?? 'high',
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000, toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
// v0.7.3 P3-1 接线: agent.enableReflection 此前为死配置(引擎读取但全链路 // v0.7.3 P3-1 接线: agent.enableReflection 此前为死配置(引擎读取但全链路
// 无置 true 路径)。现注入引擎基线配置,REFLECTING 状态由此开关真实驱动。 // 无置 true 路径)。现注入引擎基线配置,REFLECTING 状态由此开关真实驱动。
@@ -627,12 +622,8 @@ async function initialize(): Promise<void> {
{ {
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 MetonaThinkingEffort | null) ??
| 'low' 'high',
| 'medium'
| 'high'
| 'max'
| null) ?? 'high',
contextLength: getOllamaContextLength(), contextLength: getOllamaContextLength(),
contextWindow: getContextWindowSetting(), contextWindow: getContextWindowSetting(),
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection
@@ -1048,8 +1039,11 @@ if (!gotSingleInstanceLock) {
// v0.5.0: 启动链路异常兜底 — DB 损坏/工作空间不可写等初始化失败时, // v0.5.0: 启动链路异常兜底 — DB 损坏/工作空间不可写等初始化失败时,
// 原实现会静默挂起(渲染进程白屏且无任何用户可见错误)。 // 原实现会静默挂起(渲染进程白屏且无任何用户可见错误)。
// 兜底策略:记录日志 + 弹出系统错误对话框 + 退出(exit 1) // 兜底策略:记录日志 + 弹出系统错误对话框 + 退出(exit 1)
// v0.8.3: 安全防线在同一 ready 链上先于 initialize 注册(回调按挂载顺序执行)
// —— CSP 拦截必须在主窗口发起首个 mainFrame 请求前就位,否则首页响应漏注入。
app app
.whenReady() .whenReady()
.then(registerSecurityHardening)
.then(initialize) .then(initialize)
.catch((err) => { .catch((err) => {
log.error('[Startup] Initialization failed:', err); log.error('[Startup] Initialization failed:', err);
@@ -1072,16 +1066,23 @@ if (!gotSingleInstanceLock) {
// 此前主窗口无 CSP、无 permission handler —— notifications/geo/media/clipboard // 此前主窗口无 CSP、无 permission handler —— notifications/geo/media/clipboard
// 等请求全部走 Chromium 默认放行,且渲染层一旦被注入可静默触达敏感能力。 // 等请求全部走 Chromium 默认放行,且渲染层一旦被注入可静默触达敏感能力。
// 两条防线均为 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
// 后注册,防线真正生效。
//
// v0.8.3 根治收尾: 防线二(CSP)当时漏改,仍留在模块顶层 —— 生产环境每次启动
// 都在此抛 "Session can only be received when app is ready",模块求值中止:
// 权限白名单与 CSP 实际从未注册,且打包产物末尾的模块命名空间永不初始化,
// 连锁导致 ipc/shared.ts 保存 workspace.path 的动态 import 抛 TDZ
// (工作空间切换保存失败,utils/workspace-path.ts 头注有完整因果链)。
// 现两条防线统一收敛为 registerSecurityHardening,挂载在 initialize 之前执行;
// 单防线失败仅记日志,不阻断启动链。
function registerSecurityHardening(): void {
// 防线一:权限请求白名单(deny-by-default // 防线一:权限请求白名单(deny-by-default
// v0.8.1 根治: session.defaultSession 必须在 app ready 后才能访问 —— 此前在 try {
// 模块加载期(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']);
void app
.whenReady()
.then(() => {
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission)); callback(ALLOWED_PERMISSIONS.has(permission));
}); });
@@ -1089,15 +1090,15 @@ if (!gotSingleInstanceLock) {
ALLOWED_PERMISSIONS.has(permission), ALLOWED_PERMISSIONS.has(permission),
); );
log.info('[Security] Permission whitelist registered (app ready)'); log.info('[Security] Permission whitelist registered (app ready)');
}) } catch (err) {
.catch((err) => {
log.error('[Security] Failed to register permission whitelist:', 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 属性);
// 附件预览/截图使用 data:/blob: 图片;渲染进程本身不直接外联(所有 fetch 在主进程)。 // 附件预览/截图使用 data:/blob: 图片;渲染进程本身不直接外联(所有 fetch 在主进程)。
if (!process.env['ELECTRON_RENDERER_URL']) { if (!process.env['ELECTRON_RENDERER_URL']) {
try {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
if (details.resourceType === 'mainFrame') { if (details.resourceType === 'mainFrame') {
callback({ callback({
@@ -1123,6 +1124,10 @@ if (!gotSingleInstanceLock) {
callback({ responseHeaders: details.responseHeaders }); callback({ responseHeaders: details.responseHeaders });
} }
}); });
log.info('[Security] CSP header injection registered (app ready)');
} catch (err) {
log.error('[Security] Failed to register CSP header injection:', err);
}
} }
} }
+54
View File
@@ -0,0 +1,54 @@
/**
* 工作空间路径独立持久化 — userData/workspace-config.json
*
* 为什么独立于数据库:agent.db 存放在工作空间目录内,而"工作空间在哪"必须先于
* 数据库初始化得知(鸡生蛋问题),因此路径单独落一份 JSON。
*
* 为什么独立成模块:此前 read/writeWorkspacePathToFile 定义在 main.ts 并由
* ipc/shared.ts 动态 import('../main') 取用 —— main ↔ shared 循环依赖。一旦主
* 入口模块求值中途崩溃(如 v0.8.2 前的顶层 session 访问),bundle 末尾的模块
* 命名空间永不初始化,该动态 import 抛 TDZ "Cannot access 'main' before
* initialization",工作空间切换保存随之失败。抽出后为单向依赖,与入口模块
* 生命周期解耦。
*
* 注意:文件路径必须在使用时实时计算(app.getPath('userData')),不能在模块
* 顶层缓存 —— E2E 通过 METONA_USER_DATA_DIR 在 main.ts 模块体内重定向 userData
* 而 ESM import 会提升到模块体之前执行,顶层缓存会锁死重定向前的路径。
*/
import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import log from 'electron-log';
function getWorkspaceConfigFile(): string {
return join(app.getPath('userData'), 'workspace-config.json');
}
export function readWorkspacePathFromFile(): string | null {
try {
const file = getWorkspaceConfigFile();
if (existsSync(file)) {
const data = JSON.parse(readFileSync(file, 'utf-8'));
return data.workspacePath ?? null;
}
} catch {
// 忽略读取错误
}
return null;
}
/**
* 写入失败向上抛出(调用方决定是否告知用户):
* - 启动路径回写(main.ts):非致命,仅记日志;
* - 切换工作空间保存(ipc/shared.ts):必须 toast 告知,否则用户以为切换成功,
* 下次启动仍回到旧空间(v0.3.10 契约 —— 此前函数内部吞错,错误上报路径为死代码)。
*/
export function writeWorkspacePathToFile(workspacePath: string): void {
try {
writeFileSync(getWorkspaceConfigFile(), JSON.stringify({ workspacePath }, null, 2), 'utf-8');
} catch (err) {
log.error('Failed to write workspace config file:', err);
throw err;
}
}
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.2", "version": "0.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.2", "version": "0.8.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -3590,9 +3590,9 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.8", "version": "7.0.9",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.8.tgz", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.9.tgz",
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.8.2", "version": "0.8.3",
"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",
@@ -110,7 +110,10 @@ export function AgentSettings() {
<MenuItem value="low">{t('agentSettings.effort.low')}</MenuItem> <MenuItem value="low">{t('agentSettings.effort.low')}</MenuItem>
<MenuItem value="medium">{t('agentSettings.effort.medium')}</MenuItem> <MenuItem value="medium">{t('agentSettings.effort.medium')}</MenuItem>
<MenuItem value="high">{t('agentSettings.effort.high')}</MenuItem> <MenuItem value="high">{t('agentSettings.effort.high')}</MenuItem>
{/* v0.8.3: 新增 xhighQwen3 等模板原生档)与 true(交由模型默认档) */}
<MenuItem value="xhigh">{t('agentSettings.effort.xhigh')}</MenuItem>
<MenuItem value="max">{t('agentSettings.effort.max')}</MenuItem> <MenuItem value="max">{t('agentSettings.effort.max')}</MenuItem>
<MenuItem value="true">{t('agentSettings.effort.true')}</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
)} )}
+5
View File
@@ -771,10 +771,13 @@ registerTranslations('zh-CN', {
'detailPanel.tab.tasks': '任务', 'detailPanel.tab.tasks': '任务',
'detailPanel.tab.workspace': '工作空间', 'detailPanel.tab.workspace': '工作空间',
// v0.8.2 P2-5: 思考强度档位 // v0.8.2 P2-5: 思考强度档位
// v0.8.3: 新增 xhighQwen3 等模型思考模板原生档)与 true(交由模型默认档)
'agentSettings.effort.low': '低', 'agentSettings.effort.low': '低',
'agentSettings.effort.medium': '中', 'agentSettings.effort.medium': '中',
'agentSettings.effort.high': '高', 'agentSettings.effort.high': '高',
'agentSettings.effort.xhigh': '极高(xhigh',
'agentSettings.effort.max': '最大', 'agentSettings.effort.max': '最大',
'agentSettings.effort.true': '模型默认(true',
// v0.8.2 P2-5: 工具风险标签 // v0.8.2 P2-5: 工具风险标签
'sidebar.risk.safe': '安全', 'sidebar.risk.safe': '安全',
'sidebar.risk.low': '低', 'sidebar.risk.low': '低',
@@ -1596,7 +1599,9 @@ registerTranslations('en-US', {
'agentSettings.effort.low': 'Low', 'agentSettings.effort.low': 'Low',
'agentSettings.effort.medium': 'Medium', 'agentSettings.effort.medium': 'Medium',
'agentSettings.effort.high': 'High', 'agentSettings.effort.high': 'High',
'agentSettings.effort.xhigh': 'X-High (xhigh)',
'agentSettings.effort.max': 'Max', 'agentSettings.effort.max': 'Max',
'agentSettings.effort.true': 'Model default (true)',
'sidebar.risk.safe': 'SAFE', 'sidebar.risk.safe': 'SAFE',
'sidebar.risk.low': 'LOW', 'sidebar.risk.low': 'LOW',
'sidebar.risk.medium': 'MEDIUM', 'sidebar.risk.medium': 'MEDIUM',