diff --git a/README.md b/README.md index 6a97dc9..1278325 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -657,6 +657,11 @@ OLLAMA_BASE_URL=http://localhost:11434 | `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 | | `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) | | `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) | +| `agent.enableReflection` | `false` | 反思阶段开关 — 开启后每轮工具执行经过 REFLECTING 状态(失败结果告警,不阻断) | +| `memory.consolidationEnabled` | `true` | 会话结束记忆固化总开关(v0.7.3 节流策略) | +| `memory.consolidationMinChars` | `200` | 固化内容门控:回答字符数阈值(或存在成功工具调用) | +| `memory.consolidationIntervalMs` | `600000` | 固化频率窗口(同会话两次固化的最小间隔) | +| `mcp.autoReconnect` | `true` | MCP 断连自动重连(指数退避 5s/15s/60s,最多 3 次) | | `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 | | `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 | | `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 | @@ -873,8 +878,8 @@ npm run lint:fix # ESLint 自动修复 npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── -npm test # 运行单元测试 (Vitest, 系统 Node — 473 通过, 34 个 SQLite 依赖用例因 better-sqlite3 ABI 自动跳过) -npm run test:electron # 运行全量单元测试 (Electron Node ABI, 507 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) +npm test # 运行单元测试 (Vitest, 系统 Node — SQLite 依赖用例因 better-sqlite3 ABI 自动跳过) +npm run test:electron # 运行全量单元测试 (Electron Node ABI, 全部用例执行, 含 SQLite 审计链哈希 + 引擎工具链集成) npm run test:watch # 测试监听模式 # ─── 构建 ───────────────────────────────── diff --git a/electron/harness/adapters/__tests__/anthropic-cache-control.test.ts b/electron/harness/adapters/__tests__/anthropic-cache-control.test.ts new file mode 100644 index 0000000..f5e7a92 --- /dev/null +++ b/electron/harness/adapters/__tests__/anthropic-cache-control.test.ts @@ -0,0 +1,133 @@ +/** + * Anthropic system cache_control 断言测试(v0.7.3 P1-1) + * + * Anthropic 缓存按"内容块前缀"命中:system 必须以块数组传递并在块上打 + * cache_control 才可缓存。本文件锁定: + * C1 非空 system → 块数组 + {type:'ephemeral'}; + * C2 空 system → 保持空字符串(不发空块); + * C3 thinking 模式下断言仍然存在(cache 与 thinking 不互斥); + * C4 system 块文本为四分区完整拼接(roleDefinition/outputConstraints/ + * safetyGuidelines/dynamicReminders)。 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { AnthropicAdapter } from '../anthropic.adapter'; +import type { MetonaRequest } from '../../types'; + +function captureFetch(): { bodies: Array> } { + const bodies: Array> = []; + const genericBody = { + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 3, output_tokens: 2 }, + stop_reason: 'end_turn', + }; + const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}')) as Record); + return new Response(JSON.stringify(genericBody), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + return { bodies }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeAdapter(): AnthropicAdapter { + return new AnthropicAdapter({ + provider: 'anthropic', + baseURL: 'http://a.test', + apiKey: 'k', + defaultModel: 'claude-sonnet-4-5', + }); +} + +function makeRequest(overrides?: Partial): MetonaRequest { + return { + meta: { + sessionId: 's1', + iteration: 1, + requestId: 'r1', + timestamp: Date.now(), + agentVersion: 'test', + }, + systemPrompt: { + roleDefinition: 'You are Metona.', + outputConstraints: 'Be concise.', + safetyGuidelines: 'Stay safe.', + dynamicReminders: '## Current Workspace\n`/ws`', + }, + messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }], + params: { maxTokens: 63_488, temperature: 0, stream: false }, + ...overrides, + }; +} + +describe('AnthropicAdapter — system cache_control(P1-1)', () => { + it('C1: 非空 system → 单 text 块 + cache_control ephemeral', async () => { + const adapter = makeAdapter(); + const { bodies } = captureFetch(); + await adapter.send(makeRequest()); + + const system = bodies[0].system as Array<{ + type: string; + text: string; + cache_control: { type: string }; + }>; + expect(Array.isArray(system)).toBe(true); + expect(system).toHaveLength(1); + expect(system[0].type).toBe('text'); + expect(system[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('C2: 空 system → 保持空字符串(不发空块)', async () => { + const adapter = makeAdapter(); + const { bodies } = captureFetch(); + await adapter.send( + makeRequest({ + systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' }, + }), + ); + expect(bodies[0].system).toBe(''); + }); + + it('C3: thinking 模式下 cache_control 断言仍然存在', async () => { + const adapter = makeAdapter(); + const { bodies } = captureFetch(); + await adapter.send( + makeRequest({ + params: { + maxTokens: 8192, + temperature: 0, + stream: false, + thinkingEnabled: true, + thinkingEffort: 'high', + }, + }), + ); + const system = bodies[0].system as Array<{ cache_control: { type: string } }>; + expect(system[0].cache_control).toEqual({ type: 'ephemeral' }); + // thinking 与 cache 共存:thinking 块也在请求体中 + expect(bodies[0].thinking).toMatchObject({ type: 'enabled' }); + }); + + it('C4: system 块文本为四分区完整拼接', async () => { + const adapter = makeAdapter(); + const { bodies } = captureFetch(); + await adapter.send(makeRequest()); + + const system = bodies[0].system as Array<{ text: string }>; + expect(system[0].text).toContain('You are Metona.'); + expect(system[0].text).toContain('Be concise.'); + expect(system[0].text).toContain('Stay safe.'); + expect(system[0].text).toContain('## Current Workspace'); + }); +}); diff --git a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts index 7378ce9..a5331db 100644 --- a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts +++ b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts @@ -109,29 +109,69 @@ describe('AnthropicAdapter — 请求体契约', () => { role: 'assistant', content: null, toolCalls: [ - { id: 'tc_1', name: 'read_file', args: { path: 'a.txt' }, iteration: 1, timestamp: Date.now() }, + { + id: 'tc_1', + name: 'read_file', + args: { path: 'a.txt' }, + iteration: 1, + timestamp: Date.now(), + }, ], timestamp: Date.now(), }, - { role: 'tool', content: null, toolResult: { toolCallId: 'tc_1', toolName: 'read_file', result: 'data', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() }, + { + role: 'tool', + content: null, + toolResult: { + toolCallId: 'tc_1', + toolName: 'read_file', + result: 'data', + success: true, + durationMs: 1, + timestamp: Date.now(), + }, + timestamp: Date.now(), + }, // 孤立 tool_result(前面没有对应 tool_use)应被过滤 - { role: 'tool', content: null, toolResult: { toolCallId: 'tc_orphan', toolName: 'x', result: '', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() }, + { + role: 'tool', + content: null, + toolResult: { + toolCallId: 'tc_orphan', + toolName: 'x', + result: '', + success: true, + durationMs: 1, + timestamp: Date.now(), + }, + timestamp: Date.now(), + }, { role: 'user', content: 'next?', timestamp: Date.now() }, ], }), ); const body = bodies[0]; - expect(body.system).toContain('You are Metona.'); + // v0.7.3 P1-1: system 转为块数组并打 cache_control 断言(稳定前缀 prompt cache) + const system = body.system as Array<{ + type: string; + text: string; + cache_control: { type: string }; + }>; + expect(Array.isArray(system)).toBe(true); + expect(system[0].text).toContain('You are Metona.'); + expect(system[0].cache_control).toEqual({ type: 'ephemeral' }); expect(Array.isArray(body.messages)).toBe(true); const msgs = body.messages as Array<{ role: string; content: Array> }>; // tool_use 的 assistant 消息存在且携带 id/name const assistantToolMsg = msgs.find((m) => m.role === 'assistant'); - expect(assistantToolMsg?.content[0]).toMatchObject({ type: 'tool_use', id: 'tc_1', name: 'read_file' }); + expect(assistantToolMsg?.content[0]).toMatchObject({ + type: 'tool_use', + id: 'tc_1', + name: 'read_file', + }); // tool 结果以 user 角色 tool_result 形态出现且配对 id 正确;孤立者被丢弃 - const toolResultBlocks = msgs.flatMap((m) => - m.content.filter((c) => c.type === 'tool_result'), - ); + const toolResultBlocks = msgs.flatMap((m) => m.content.filter((c) => c.type === 'tool_result')); expect(toolResultBlocks).toHaveLength(1); expect(toolResultBlocks[0].tool_use_id).toBe('tc_1'); }); @@ -166,7 +206,15 @@ describe('AnthropicAdapter — 请求体契约', () => { }); const { bodies } = captureFetch(); await adapter.send( - makeRequest({ params: { maxTokens: 1500, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }), + makeRequest({ + params: { + maxTokens: 1500, + temperature: 0, + stream: false, + thinkingEnabled: true, + thinkingEffort: 'low', + }, + }), ); const body = bodies[0]; const thinking = body.thinking as { type: string; budget_tokens: number }; @@ -185,13 +233,17 @@ describe('AnthropicAdapter — 请求体契约', () => { }); const { bodies } = captureFetch(); await adapter.send( - makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true } }), + makeRequest({ + params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true }, + }), ); expect(bodies[0].temperature).toBeUndefined(); expect(bodies[0].thinking).toBeDefined(); await adapter.send( - makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false } }), + makeRequest({ + params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false }, + }), ); expect(bodies[1].temperature).toBe(0.7); expect(bodies[1].thinking).toBeUndefined(); @@ -236,13 +288,37 @@ describe('OllamaAdapter — 请求体契约', () => { const adapter = makeOllama(); const { bodies } = captureFetch(); - await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } })); + await adapter.send( + makeRequest({ + params: { + maxTokens: 4096, + temperature: 0, + stream: false, + thinkingEnabled: true, + thinkingEffort: 'low', + }, + }), + ); expect(bodies[0].think).toBe('low'); - await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'max' } })); + await adapter.send( + makeRequest({ + params: { + maxTokens: 4096, + temperature: 0, + stream: false, + thinkingEnabled: true, + thinkingEffort: 'max', + }, + }), + ); expect(bodies[1].think).toBe(true); - await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } })); + await adapter.send( + makeRequest({ + params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false }, + }), + ); expect(bodies[2].think).toBeUndefined(); }); @@ -319,12 +395,26 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4)', () => { }); const { bodies } = captureFetch(); - await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'high' } })); + await adapter.send( + makeRequest({ + params: { + maxTokens: 4096, + temperature: 0, + stream: false, + thinkingEnabled: true, + thinkingEffort: 'high', + }, + }), + ); expect( ((bodies[0].chat_template_kwargs as Record) ?? {}).enable_thinking, ).toBe(true); - await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } })); + await adapter.send( + makeRequest({ + params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false }, + }), + ); expect( ((bodies[1].chat_template_kwargs as Record) ?? {}).enable_thinking, ).toBe(false); diff --git a/electron/harness/adapters/anthropic.adapter.ts b/electron/harness/adapters/anthropic.adapter.ts index f7a20a5..8bc3d68 100644 --- a/electron/harness/adapters/anthropic.adapter.ts +++ b/electron/harness/adapters/anthropic.adapter.ts @@ -248,8 +248,7 @@ export class AnthropicAdapter extends BaseAdapter { usage: { inputTokens: messageStartInputTokens, outputTokens: (usage.output_tokens as number) ?? 0, - totalTokens: - messageStartInputTokens + ((usage.output_tokens as number) ?? 0), + totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0), // v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集, // cache_read/creation_input_tokens 与 output_tokens 同在 usage 内) cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined, @@ -343,10 +342,7 @@ export class AnthropicAdapter extends BaseAdapter { try { args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; } catch (err) { - args = truncatedArgumentsPayload( - (err as Error).message, - block.argsBuffer.slice(-120), - ); + args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120)); } yield { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, @@ -506,11 +502,22 @@ export class AnthropicAdapter extends BaseAdapter { const body: Record = { model: this.config.defaultModel, max_tokens: maxTokensForRequest, - system, messages: merged, stream, }; + // v0.7.3 P1-1: system 稳定前缀打 prompt cache 断言。 + // Anthropic 缓存按"内容块前缀"命中 —— system 以字符串传递时无法附加 + // cache_control,必须转为块数组并在最后一个块上打 {type:'ephemeral'}。 + // 缓存前缀覆盖 tools + system(请求组装顺序 tools 在前):system 稳定后, + // 多轮对话/多轮迭代复用同一前缀,输入 token 计费按缓存读价(约 1/10)。 + // 前缀稳定性由 P1-1 保证:易变内容(日期/记忆/附件提示)已迁入用户消息。 + if (system) { + body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }]; + } else { + body.system = system; + } + // 工具定义(input_schema 命名) if (request.tools?.length) { body.tools = request.tools.map((t) => ({ diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index d5b17cc..b31f5fe 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -59,17 +59,21 @@ export class OllamaAdapter extends BaseAdapter { const nativeRequest = await this.toNativeRequest(request); // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 - const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...nativeRequest, stream: false }), - }, this.config.timeoutMs ?? 300_000); + const response = await this.fetchWithTimeout( + `${this.baseURL}/api/chat`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...nativeRequest, stream: false }), + }, + this.config.timeoutMs ?? 300_000, + ); if (!response.ok) { await this.throwHttpError(response, 'Ollama API error'); } - const data = await response.json() as Record; + const data = (await response.json()) as Record; return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration); } @@ -79,11 +83,15 @@ export class OllamaAdapter extends BaseAdapter { const nativeRequest = await this.toNativeRequest(request); // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 - const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...nativeRequest, stream: true }), - }, this.config.timeoutMs ?? 300_000); + const response = await this.fetchWithTimeout( + `${this.baseURL}/api/chat`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...nativeRequest, stream: true }), + }, + this.config.timeoutMs ?? 300_000, + ); if (!response.ok || !response.body) { await this.throwHttpError(response, 'Ollama stream error'); @@ -209,7 +217,10 @@ export class OllamaAdapter extends BaseAdapter { } } catch (parseErr) { // P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断 - log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200)); + log.warn( + `[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, + trimmed.slice(0, 200), + ); } } } @@ -239,7 +250,13 @@ export class OllamaAdapter extends BaseAdapter { format?: string | object; images?: string[]; options?: Record; - }): Promise<{ response: string; thinking?: string; done: boolean; totalDuration: number; evalCount: number }> { + }): Promise<{ + response: string; + thinking?: string; + done: boolean; + totalDuration: number; + evalCount: number; + }> { const response = await fetch(`${this.baseURL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -248,9 +265,12 @@ export class OllamaAdapter extends BaseAdapter { }); if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`); - const data = await response.json() as { - response?: string; thinking?: string; done?: boolean; - total_duration?: number; eval_count?: number; + const data = (await response.json()) as { + response?: string; + thinking?: string; + done?: boolean; + total_duration?: number; + eval_count?: number; }; return { @@ -277,7 +297,7 @@ export class OllamaAdapter extends BaseAdapter { }); if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`); - const data = await response.json() as { embeddings?: number[][]; total_duration?: number }; + const data = (await response.json()) as { embeddings?: number[][]; total_duration?: number }; return { embeddings: data.embeddings ?? [], @@ -299,7 +319,7 @@ export class OllamaAdapter extends BaseAdapter { signal: AbortSignal.timeout(10_000), }); if (response.ok) { - const data = await response.json() as { + const data = (await response.json()) as { models?: Array<{ name: string; size?: number; @@ -309,6 +329,8 @@ export class OllamaAdapter extends BaseAdapter { if (data.models?.length) { // v0.6.4 P4-1: 能力标志改为逐模型 /api/show 实测探测;单个探测失败 // 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性) + // v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行), + // 供上传入口拒绝不支持图片的本地语言模型 const enriched = await Promise.all( data.models.map(async (m) => { const caps = await this.probeCapabilities(m.name); @@ -319,6 +341,7 @@ export class OllamaAdapter extends BaseAdapter { contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW, supportsToolCalling: caps ? caps.supportsTools : true, supportsThinking: caps ? caps.supportsThinking : true, + supportsVision: caps ? caps.supportsVision : undefined, description: m.details ? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}` : undefined, @@ -403,7 +426,9 @@ export class OllamaAdapter extends BaseAdapter { // ===== POST /api/show ===== - async showModel(model: string): Promise<{ parameters: string; template: string; capabilities: string[] } | null> { + async showModel( + model: string, + ): Promise<{ parameters: string; template: string; capabilities: string[] } | null> { try { const response = await fetch(`${this.baseURL}/api/show`, { method: 'POST', @@ -412,7 +437,11 @@ export class OllamaAdapter extends BaseAdapter { signal: AbortSignal.timeout(10_000), }); if (!response.ok) return null; - const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] }; + const data = (await response.json()) as { + parameters?: string; + template?: string; + capabilities?: string[]; + }; return { parameters: data.parameters ?? '', template: data.template ?? '', @@ -470,13 +499,22 @@ export class OllamaAdapter extends BaseAdapter { // ===== GET /api/ps ===== - async listRunning(): Promise> { + async listRunning(): Promise< + Array<{ name: string; size: number; sizeVram: number; contextLength: number }> + > { try { const response = await fetch(`${this.baseURL}/api/ps`, { signal: AbortSignal.timeout(10_000), }); if (!response.ok) return []; - const data = await response.json() as { models?: Array<{ name: string; size?: number; size_vram?: number; context_length?: number }> }; + const data = (await response.json()) as { + models?: Array<{ + name: string; + size?: number; + size_vram?: number; + context_length?: number; + }>; + }; return (data.models ?? []).map((m) => ({ name: m.name ?? '', size: m.size ?? 0, @@ -496,7 +534,7 @@ export class OllamaAdapter extends BaseAdapter { signal: AbortSignal.timeout(5_000), }); if (!response.ok) return 'unknown'; - const data = await response.json() as { version?: string }; + const data = (await response.json()) as { version?: string }; return data.version ?? 'unknown'; } catch { return 'unknown'; @@ -525,7 +563,9 @@ export class OllamaAdapter extends BaseAdapter { const buf = Buffer.from(await res.arrayBuffer()); return buf.toString('base64'); } catch (error) { - log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`); + log.warn( + `[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`, + ); return ''; } } @@ -539,58 +579,60 @@ export class OllamaAdapter extends BaseAdapter { request.systemPrompt.outputConstraints, request.systemPrompt.safetyGuidelines, request.systemPrompt.dynamicReminders, - ].filter(Boolean).join('\n\n'), + ] + .filter(Boolean) + .join('\n\n'), }, ]; // #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await) for (const m of request.messages) { if (m.role === 'system') continue; - // C-6 修复: Ollama API 不支持 null content,assistant 仅有 tool_calls 时转为空字符串 - const msg: Record = { role: m.role, content: m.content ?? '' }; - // Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀) - if (m.images?.length) { - // #2 修复: 支持公网 URL 图片,下载后转为纯 base64 - // 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误 - const resolvedImages: string[] = []; - for (const img of m.images) { - const url = img.url; - if (url.startsWith('data:')) { - // data:image/png;base64,iVBOR... → iVBOR... - const base64Part = url.split(',')[1]; - resolvedImages.push(base64Part ?? url); - } else if (url.startsWith('http://') || url.startsWith('https://')) { - // #2 修复: 公网 URL → 下载 → 纯 base64 - const base64 = await this.resolveImageToBase64(url); - if (base64) resolvedImages.push(base64); - } else { - // 已是纯 base64 字符串(无 data: 前缀) - resolvedImages.push(url); - } + // C-6 修复: Ollama API 不支持 null content,assistant 仅有 tool_calls 时转为空字符串 + const msg: Record = { role: m.role, content: m.content ?? '' }; + // Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀) + if (m.images?.length) { + // #2 修复: 支持公网 URL 图片,下载后转为纯 base64 + // 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误 + const resolvedImages: string[] = []; + for (const img of m.images) { + const url = img.url; + if (url.startsWith('data:')) { + // data:image/png;base64,iVBOR... → iVBOR... + const base64Part = url.split(',')[1]; + resolvedImages.push(base64Part ?? url); + } else if (url.startsWith('http://') || url.startsWith('https://')) { + // #2 修复: 公网 URL → 下载 → 纯 base64 + const base64 = await this.resolveImageToBase64(url); + if (base64) resolvedImages.push(base64); + } else { + // 已是纯 base64 字符串(无 data: 前缀) + resolvedImages.push(url); } - msg.images = resolvedImages; } - // 工具结果 - if (m.role === 'tool' && m.toolResult) { - msg.tool_call_id = m.toolResult.toolCallId; - // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content - msg.content = m.toolResult.error - ? m.toolResult.error - : (typeof m.toolResult.result === 'string' - ? m.toolResult.result - : JSON.stringify(m.toolResult.result)); - } - // assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串) - if (m.role === 'assistant' && m.toolCalls?.length) { - msg.tool_calls = m.toolCalls.map((tc) => ({ - function: { name: tc.name, arguments: JSON.stringify(tc.args) }, - })); - } - // 推理内容回传(保持多轮推理链完整) - if (m.role === 'assistant' && m.reasoningContent) { - (msg as Record).reasoning_content = m.reasoningContent; - } - messages.push(msg); + msg.images = resolvedImages; + } + // 工具结果 + if (m.role === 'tool' && m.toolResult) { + msg.tool_call_id = m.toolResult.toolCallId; + // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content + msg.content = m.toolResult.error + ? m.toolResult.error + : typeof m.toolResult.result === 'string' + ? m.toolResult.result + : JSON.stringify(m.toolResult.result); + } + // assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串) + if (m.role === 'assistant' && m.toolCalls?.length) { + msg.tool_calls = m.toolCalls.map((tc) => ({ + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })); + } + // 推理内容回传(保持多轮推理链完整) + if (m.role === 'assistant' && m.reasoningContent) { + (msg as Record).reasoning_content = m.reasoningContent; + } + messages.push(msg); } const body: Record = { @@ -619,14 +661,23 @@ export class OllamaAdapter extends BaseAdapter { // Thinking 模式 if (request.params.thinkingEnabled) { - const effortMap: Record = { low: 'low', medium: 'medium', high: 'high', max: true }; + const effortMap: Record = { + low: 'low', + medium: 'medium', + high: 'high', + max: true, + }; body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true; } return body; } - private toMetonaResponse(data: Record, requestId: string, iteration: number = 0): MetonaResponse { + private toMetonaResponse( + data: Record, + requestId: string, + iteration: number = 0, + ): MetonaResponse { const message = data.message as Record | undefined; const toolCalls = message?.tool_calls as Array> | undefined; return { @@ -638,11 +689,14 @@ export class OllamaAdapter extends BaseAdapter { timestamp: Date.now(), perfStats: { loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined, - promptEvalDurationMs: data.prompt_eval_duration ? (data.prompt_eval_duration as number) / 1e6 : undefined, - evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined, - tokensPerSecond: data.eval_count && data.eval_duration - ? ((data.eval_count as number) / ((data.eval_duration as number) / 1e9)) + promptEvalDurationMs: data.prompt_eval_duration + ? (data.prompt_eval_duration as number) / 1e6 : undefined, + evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined, + tokensPerSecond: + data.eval_count && data.eval_duration + ? (data.eval_count as number) / ((data.eval_duration as number) / 1e9) + : undefined, }, }, content: (message?.content as string) ?? '', @@ -652,7 +706,10 @@ export class OllamaAdapter extends BaseAdapter { const rawArgs = fn?.arguments; let args: Record = {}; try { - args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record) ?? {}; + args = + typeof rawArgs === 'string' + ? JSON.parse(rawArgs) + : ((rawArgs as Record) ?? {}); } catch (parseErr) { // v0.6.4: 非流式路径截断自愈对齐 —— 原 catch 静默降级 {},与流式修复后的 // 行为不一致。统一转为 _truncatedArguments 错误参数。 @@ -677,7 +734,10 @@ export class OllamaAdapter extends BaseAdapter { outputTokens: (data.eval_count as number) ?? 0, totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0), }, - finishReason: mapOllamaDoneReason(data.done_reason as string | undefined, !!message?.tool_calls), + finishReason: mapOllamaDoneReason( + data.done_reason as string | undefined, + !!message?.tool_calls, + ), }; } } @@ -693,10 +753,15 @@ function mapOllamaDoneReason( ): MetonaFinishReason { if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS; switch (reason) { - case 'stop': return MetonaFinishReason.STOP; - case 'length': return MetonaFinishReason.LENGTH; - case 'load': return MetonaFinishReason.STOP; // 冷启动加载完成,非错误 - case 'unload': return MetonaFinishReason.STOP; - default: return MetonaFinishReason.STOP; + case 'stop': + return MetonaFinishReason.STOP; + case 'length': + return MetonaFinishReason.LENGTH; + case 'load': + return MetonaFinishReason.STOP; // 冷启动加载完成,非错误 + case 'unload': + return MetonaFinishReason.STOP; + default: + return MetonaFinishReason.STOP; } } diff --git a/electron/harness/agent-loop/__tests__/engine.test.ts b/electron/harness/agent-loop/__tests__/engine.test.ts index 9f9aa43..1aec24e 100644 --- a/electron/harness/agent-loop/__tests__/engine.test.ts +++ b/electron/harness/agent-loop/__tests__/engine.test.ts @@ -15,7 +15,10 @@ import type { IMetonaProviderAdapter, MetonaResponse, MetonaStreamEvent } from ' import { MetonaStreamEventType } from '../../types'; /** 构造 Mock Adapter:sendStream 按脚本产出事件 */ -function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: Error }): IMetonaProviderAdapter { +function createMockAdapter( + scripts: MetonaStreamEvent[][], + opts?: { failWith?: Error }, +): IMetonaProviderAdapter { let call = 0; return { providerId: 'mock', @@ -23,12 +26,20 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, - send: vi.fn(async (): Promise => ({ - meta: { requestId: 'r_test', provider: 'mock', model: 'mock-model', latencyMs: 1, timestamp: Date.now() }, - content: 'ok', - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - finishReason: 'stop' as never, - })), + send: vi.fn( + async (): Promise => ({ + meta: { + requestId: 'r_test', + provider: 'mock', + model: 'mock-model', + latencyMs: 1, + timestamp: Date.now(), + }, + content: 'ok', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + finishReason: 'stop' as never, + }), + ), sendStream: vi.fn(async function* (): AsyncIterable { if (opts?.failWith) throw opts.failWith; const script = scripts[call % scripts.length]; @@ -42,8 +53,23 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E function textDoneEvent(text: string): MetonaStreamEvent[] { return [ - { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: text }, - { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() }, + { + type: MetonaStreamEventType.TEXT_DELTA, + requestId: 'r1', + sessionId: 's1', + iteration: 1, + seq: 0, + timestamp: Date.now(), + delta: text, + }, + { + type: MetonaStreamEventType.DONE, + requestId: 'r1', + sessionId: 's1', + iteration: 1, + seq: 1, + timestamp: Date.now(), + }, ]; } @@ -51,10 +77,21 @@ function toolCallEvent(name: string, args: Record): MetonaStrea return [ { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, - requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), + requestId: 'r1', + sessionId: 's1', + iteration: 1, + seq: 0, + timestamp: Date.now(), toolCall: { id: 'tc_test', name, args, iteration: 1, timestamp: Date.now() }, }, - { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() }, + { + type: MetonaStreamEventType.DONE, + requestId: 'r1', + sessionId: 's1', + iteration: 1, + seq: 1, + timestamp: Date.now(), + }, ]; } @@ -121,7 +158,9 @@ describe('AgentLoopEngine', () => { }); it('不可重试错误直接 ERROR(无 fallback 时)', async () => { - const adapter = createMockAdapter([], { failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }) }); + const adapter = createMockAdapter([], { + failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }), + }); const engine = new AgentLoopEngine({ retryCount: 0 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.ERROR); @@ -129,7 +168,9 @@ describe('AgentLoopEngine', () => { it('P1 故障转移:主 Provider 失败后切换到 fallback Provider', async () => { // 主 adapter 每次都失败(401 不可重试) - const primary = createMockAdapter([], { failWith: Object.assign(new Error('401 invalid key'), { status: 401 }) }); + const primary = createMockAdapter([], { + failWith: Object.assign(new Error('401 invalid key'), { status: 401 }), + }); // fallback 正常返回 const fallback = createMockAdapter([textDoneEvent('fallback answer')]); @@ -151,8 +192,12 @@ describe('AgentLoopEngine', () => { }); it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => { - const primary = createMockAdapter([], { failWith: Object.assign(new Error('401'), { status: 401 }) }); - const fallback = createMockAdapter([], { failWith: Object.assign(new Error('500'), { status: 500 }) }); + const primary = createMockAdapter([], { + failWith: Object.assign(new Error('401'), { status: 401 }), + }); + const fallback = createMockAdapter([], { + failWith: Object.assign(new Error('500'), { status: 500 }), + }); const engine = new AgentLoopEngine({ retryCount: 0 }, primary); engine.setFallbackAdapter(fallback); @@ -162,3 +207,72 @@ describe('AgentLoopEngine', () => { expect(output.terminationReason).toBe(TerminationReason.ERROR); }); }); + +// ===== v0.7.3 P4-4 / P3-1: 死循环乒乓检测 + REFLECTING 状态接线 ===== + +describe('AgentLoopEngine — 死循环乒乓检测(ABAB,P4-4)', () => { + it('最近 4 轮 A→B→A→B 交替(A≠B)触发 DEAD_LOOP(驻留模式抓不住的乒乓)', async () => { + const readScript = toolCallEvent('read_file', { file_path: 'x.ts' }); + const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' }); + // 1:read 2:write 3:read 4:write ← 第 4 轮 PARSING 时滑窗构成 ABAB + const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]); + const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter); + const deadLoopEvents: unknown[] = []; + engine.on('deadLoop', (d) => deadLoopEvents.push(d)); + + const output = await engine.runStream(userMessage, 's1', [], systemPrompt); + expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP); + expect(deadLoopEvents.length).toBe(1); + }); + + it('A→B→C 交替(无重复模式)不误报,按 MAX_ITERATIONS 终止', async () => { + const scripts = [ + toolCallEvent('read_file', { file_path: 'a.ts' }), + toolCallEvent('write_file', { file_path: 'a.ts' }), + toolCallEvent('lint_code', {}), + ]; + const adapter = createMockAdapter(scripts); + const engine = new AgentLoopEngine({ maxIterations: 4 }, adapter); + const output = await engine.runStream(userMessage, 's1', [], systemPrompt); + expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS); + }); + + it('A→B→B→B 前缀不构成 ABAB(A≠B 约束),由驻留模式在 3 连 B 时接管', async () => { + const readScript = toolCallEvent('read_file', { file_path: 'x.ts' }); + const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' }); + // 1:read 2:write 3:write 4:write —— 第 4 轮时 ABAB 不成立,但 3 连 write 命中驻留模式 + const adapter = createMockAdapter([readScript, writeScript, writeScript, writeScript]); + const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter); + const output = await engine.runStream(userMessage, 's1', [], systemPrompt); + expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP); + }); +}); + +describe('AgentLoopEngine — REFLECTING 状态接线(P3-1 enableReflection)', () => { + const collectStates = async (config: Record): Promise => { + const adapter = createMockAdapter([ + toolCallEvent('read_file', { file_path: 'a.ts' }), + textDoneEvent('done'), + ]); + const engine = new AgentLoopEngine(config as never, adapter); + const states: string[] = []; + engine.on('stateChange', (d: { state?: string; current?: string }) => { + const s = d.state ?? d.current ?? ''; + if (!states.includes(s)) states.push(s); + }); + await engine.runStream(userMessage, 's1', [], systemPrompt); + return states; + }; + + it('enableReflection=true 时工具执行后进入 REFLECTING 状态', async () => { + const states = await collectStates({ maxIterations: 2, enableReflection: true }); + expect(states).toContain('REFLECTING'); + expect(states).toContain('EXECUTING'); + expect(states).toContain('OBSERVING'); + }); + + it('enableReflection=false(默认)时不进入 REFLECTING', async () => { + const states = await collectStates({ maxIterations: 2, enableReflection: false }); + expect(states).not.toContain('REFLECTING'); + }); +}); diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 62ef773..0919469 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -50,7 +50,6 @@ class DeadLoopError extends Error { const DEFAULT_CONFIG: AgentLoopConfig = { maxIterations: 20, - timeoutMs: 120_000, totalTimeoutMs: 600_000, enableReflection: false, compressionThreshold: 0.8, @@ -563,7 +562,7 @@ export class AgentLoopEngine extends EventEmitter { }); // 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止 throw new DeadLoopError( - `Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`, + `Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations, or two alternating call patterns kept cycling (A→B→A→B) without progress. Please refine the approach or provide more specific instructions.`, ); } } @@ -620,7 +619,11 @@ export class AgentLoopEngine extends EventEmitter { await this.transitionTo(AgentLoopState.OBSERVING); // === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 === - // 如果有工具调用且需要后续推理,进入 REFLECTING 状态 + // v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置 + // 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置 + // 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮 + // 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志, + // 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。 if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) { await this.transitionTo(AgentLoopState.REFLECTING); // 检查工具执行是否有错误,如果有严重错误可以提前终止 @@ -1087,9 +1090,13 @@ export class AgentLoopEngine extends EventEmitter { /** * v0.3.0: 死循环检测 * - * 检测策略: - * 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。 - * 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。 + * 检测策略(v0.7.3 起双模式): + * 1. 驻留模式 — 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。 + * 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。 + * 2. 乒乓模式(v0.7.3 新增)— 最近4轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)。 + * 典型场景:模型在"读文件 A → 写文件 B"两步之间无限往返(每次读完又改回), + * 单步签名各不相同,驻留模式永不命中;docs/Agentic-Loop详解.md 第五章将 + * "两种状态间反复来回切换、毫无进展"列为必须检测的停滞模式。 * * v0.3.0 修复: * - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报 @@ -1123,21 +1130,31 @@ export class AgentLoopEngine extends EventEmitter { this.toolCallHistory.push(signature); - // 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长) + // 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长) if (this.toolCallHistory.length > 5) { this.toolCallHistory.shift(); } - // 需要至少3轮数据才能检测 - if (this.toolCallHistory.length < 3) return false; - const len = this.toolCallHistory.length; - const r1 = this.toolCallHistory[len - 1]; // 当前轮 - const r2 = this.toolCallHistory[len - 2]; // 上一轮 - const r3 = this.toolCallHistory[len - 3]; // 上上一轮 - // 连续3轮完全相同 → 死循环 - return r1 === r2 && r2 === r3; + // 模式 1:连续3轮完全相同 → 死循环 + if (len >= 3) { + const r1 = this.toolCallHistory[len - 1]; // 当前轮 + const r2 = this.toolCallHistory[len - 2]; // 上一轮 + const r3 = this.toolCallHistory[len - 3]; // 上上一轮 + if (r1 === r2 && r2 === r3) return true; + } + + // 模式 2(v0.7.3):最近4轮 ABAB 交替(A≠B)→ 乒乓死循环 + if (len >= 4) { + const a1 = this.toolCallHistory[len - 4]; + const b1 = this.toolCallHistory[len - 3]; + const a2 = this.toolCallHistory[len - 2]; + const b2 = this.toolCallHistory[len - 1]; + if (a1 === a2 && b1 === b2 && a1 !== b1) return true; + } + + return false; } /** diff --git a/electron/harness/agent-loop/types.ts b/electron/harness/agent-loop/types.ts index f7f4bfb..c44da6b 100644 --- a/electron/harness/agent-loop/types.ts +++ b/electron/harness/agent-loop/types.ts @@ -60,7 +60,6 @@ export interface TokenUsage { export interface AgentLoopConfig { maxIterations: number; - timeoutMs: number; totalTimeoutMs: number; enableReflection: boolean; compressionThreshold: number; diff --git a/electron/harness/hooks/__tests__/forget-session.test.ts b/electron/harness/hooks/__tests__/forget-session.test.ts new file mode 100644 index 0000000..4f6c1af --- /dev/null +++ b/electron/harness/hooks/__tests__/forget-session.test.ts @@ -0,0 +1,88 @@ +/** + * ConfirmationHook forgetSession 测试(v0.7.3 P2-3) + * + * 锁定会话终态清理契约: + * F1 forgetSession 清空该会话的决策记忆(拒绝记忆不再残留); + * F2 forgetSession 同时拒绝该会话等待中的确认(clearPending 语义); + * F3 会话隔离:清理 A 不影响 B; + * F4 空/未知 sessionId 幂等无副作用。 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// 模拟一个存活窗口 —— beforeExecute 的 hasAvailableWindow 守卫需要它, +// 否则确认请求在创建 pending 之前即被短路(测不到记忆/pending 路径) +const fakeWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() }, +}; +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: vi.fn(() => [fakeWindow]) }, +})); + +import { ConfirmationHook } from '../confirmation-hook'; +import type { MetonaToolCall, MetonaToolDef } from '../../../harness/types'; +import { MetonaRiskLevel, MetonaToolCategory } from '../../../harness/types'; + +function makeToolCall(name: string, id = `tc_${name}`): MetonaToolCall { + return { id, name, args: {}, iteration: 1, timestamp: Date.now() }; +} + +const NEEDS_CONFIRM_DEF: MetonaToolDef = { + name: 'run_command', + description: 'test', + parameters: { type: 'object', properties: {}, required: [] }, + category: MetonaToolCategory.CODE_EXECUTION, + riskLevel: MetonaRiskLevel.HIGH, + requiresPermission: true, + timeoutMs: 1000, +}; + +describe('ConfirmationHook — forgetSession(P2-3)', () => { + let hook: ConfirmationHook; + + beforeEach(() => { + vi.useFakeTimers(); + hook = new ConfirmationHook(null, null); + hook.setToolDefs([NEEDS_CONFIRM_DEF]); + }); + + it('F1: 会话删除后决策记忆被清空(拒绝记忆不再跨生命周期残留)', async () => { + const pending = hook.beforeExecute(makeToolCall('run_command'), 'sess-A'); + hook.resolveConfirmation(`tc_run_command`, false, true); // 记住拒绝 + await pending; + + expect(hook.getRememberedDenials('sess-A')).toHaveLength(1); + hook.forgetSession('sess-A'); + expect(hook.getRememberedDenials('sess-A')).toHaveLength(0); + }); + + it('F2: forgetSession 拒绝该会话等待中的确认(clearPending 语义)', async () => { + const p1 = hook.beforeExecute(makeToolCall('run_command'), 'sess-A'); + // resolve pending(拒绝)后走记忆清理路径 + hook.forgetSession('sess-A'); + await expect(p1).resolves.toMatchObject({ blocked: true }); + // pending 已清空 —— getPendingConfirmations 无残留 + expect(hook.getPendingConfirmations()).toHaveLength(0); + }); + + it('F3: 会话隔离 —— 清理 A 不影响 B 的决策记忆', async () => { + const p1 = hook.beforeExecute(makeToolCall('run_command', 'tc_A'), 'sess-A'); + hook.resolveConfirmation('tc_A', false, true); + await p1; + + const p2 = hook.beforeExecute(makeToolCall('run_command', 'tc_B'), 'sess-B'); + hook.resolveConfirmation('tc_B', false, true); + await p2; + + hook.forgetSession('sess-A'); + expect(hook.getRememberedDenials('sess-A')).toHaveLength(0); + expect(hook.getRememberedDenials('sess-B')).toHaveLength(1); + }); + + it('F4: 空 sessionId 幂等无副作用;未知会话不抛错', () => { + expect(() => hook.forgetSession('')).not.toThrow(); + expect(() => hook.forgetSession('nonexistent')).not.toThrow(); + expect(hook.getRememberedDenials()).toHaveLength(0); + }); +}); diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index eed5649..a0595cf 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -550,6 +550,22 @@ export class ConfirmationHook implements PreToolHook { } } + /** + * v0.7.3 P2-3: 会话生命周期终态清理(会话删除 / SubAgent 终结时调用)。 + * + * 此前 rememberedDecisions 两级 Map 只增不减 —— 会话删除/子任务终结后其 + * 决策记忆永久残留,长期运行实例随会话数缓慢泄漏。本方法与 clearPending + * 的区别:clearPending 只处理等待中的确认(会话中断时用,会话本身仍存活), + * 本方法面向"会话已终结"的终态,同时清空 pending 与决策记忆。 + * + * @param sessionId 会话 ID(主会话或 SubAgent taskId) + */ + forgetSession(sessionId: string): void { + if (!sessionId) return; + this.clearPending(sessionId); + this.rememberedDecisions.delete(sessionId); + } + // ===== 私有辅助(v0.5.0: 会话隔离) ===== /** 获取(或创建)指定会话的决策记忆表 */ diff --git a/electron/harness/memory/__tests__/consolidation-policy.test.ts b/electron/harness/memory/__tests__/consolidation-policy.test.ts new file mode 100644 index 0000000..6783cb7 --- /dev/null +++ b/electron/harness/memory/__tests__/consolidation-policy.test.ts @@ -0,0 +1,97 @@ +/** + * 记忆固化触发决策测试(v0.7.3 P1-5) + * + * 锁定 shouldConsolidate 的四类判定:总开关 / 内容门控(回答长度 ∨ 成功工具调用)/ + * 频率窗口(含首次不限)/ 配置兜底(非法数值回退安全下限)。 + */ + +import { describe, it, expect } from 'vitest'; +import { + shouldConsolidate, + MIN_CONSOLIDATION_INTERVAL_MS, + MIN_CONSOLIDATION_MIN_CHARS, +} from '../consolidation-policy'; + +const BASE = { + enabled: true, + answerChars: 500, + minChars: 200, + hadSuccessfulToolCall: false, + lastConsolidationAt: 0, + now: 1_000_000, + intervalMs: 600_000, +}; + +describe('shouldConsolidate', () => { + it('总开关显式关闭 → disabled', () => { + expect(shouldConsolidate({ ...BASE, enabled: false })).toEqual({ + consolidate: false, + reason: 'disabled', + }); + }); + + it('回答够长且首次固化 → 允许(lastConsolidationAt=0 不受频率限制)', () => { + expect(shouldConsolidate(BASE)).toEqual({ + consolidate: true, + reason: 'content-and-frequency-pass', + }); + }); + + it('回答过短且无成功工具调用 → below-threshold(短寒暄不触发固化)', () => { + expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: false })).toEqual({ + consolidate: false, + reason: 'below-threshold', + }); + }); + + it('回答过短但存在成功工具调用 → 允许(事实性上下文可沉淀)', () => { + expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: true })).toEqual({ + consolidate: true, + reason: 'content-and-frequency-pass', + }); + }); + + it('频率窗口内重复触发 → throttled', () => { + expect( + shouldConsolidate({ + ...BASE, + lastConsolidationAt: BASE.now - 60_000, // 1 分钟前刚固化过 + }), + ).toEqual({ consolidate: false, reason: 'throttled' }); + }); + + it('频率窗口已过 → 允许', () => { + expect( + shouldConsolidate({ + ...BASE, + lastConsolidationAt: BASE.now - 600_001, + }), + ).toEqual({ consolidate: true, reason: 'content-and-frequency-pass' }); + }); + + it('配置兜底:minChars=0 不会让纯寒暄触发(安全下限生效)', () => { + expect(shouldConsolidate({ ...BASE, minChars: 0, answerChars: 10 })).toEqual({ + consolidate: false, + reason: 'below-threshold', + }); + expect(MIN_CONSOLIDATION_MIN_CHARS).toBeGreaterThan(0); + }); + + it('配置兜底:intervalMs=0 不会退化为每条消息固化(安全下限生效)', () => { + expect( + shouldConsolidate({ + ...BASE, + intervalMs: 0, + lastConsolidationAt: BASE.now - 30_000, // 30 秒前刚固化 + }), + ).toEqual({ consolidate: false, reason: 'throttled' }); + expect(MIN_CONSOLIDATION_INTERVAL_MS).toBeGreaterThanOrEqual(60_000); + }); + + it('配置兜底:minChars/intervalMs 为 NaN 时按默认值处理', () => { + expect(shouldConsolidate({ ...BASE, minChars: Number.NaN, intervalMs: Number.NaN })).toEqual({ + consolidate: true, + reason: 'content-and-frequency-pass', + }); + }); +}); diff --git a/electron/harness/memory/consolidation-policy.ts b/electron/harness/memory/consolidation-policy.ts new file mode 100644 index 0000000..1299fd8 --- /dev/null +++ b/electron/harness/memory/consolidation-policy.ts @@ -0,0 +1,74 @@ +/** + * Consolidation Policy — 记忆固化触发决策(v0.7.3 P1-5) + * + * 背景:MemoryConsolidator 在每次 run 完成后无条件发起一次非流式 LLM 请求 + * (30s 超时)判断本次对话是否有值得持久化的记忆。短寒暄/单轮问答同样触发, + * 纯成本浪费且对 Provider 构成无意义请求压力。 + * + * 本模块把触发决策收敛为纯函数(可表测),决策输入: + * - 总开关 memory.consolidationEnabled(fail-secure:仅显式 false 才关闭) + * - 内容门控:本次回答 ≥ minChars 字符 **或** 本次 run 存在成功的工具调用 + * (工具调用意味着产生了可沉淀的事实性上下文) + * - 频率门控:距该会话上次固化 ≥ intervalMs(首次不设限,但仍受内容门控约束) + * + * 决策与执行解耦:本模块不做 IO,调用方(ipc/agent.ts)持有会话级 + * lastConsolidationAt 状态并执行 consolidate。 + */ + +export interface ConsolidationDecisionInput { + /** 总开关(memory.consolidationEnabled;undefined/null 视为开启) */ + enabled: boolean | null | undefined; + /** 本次 Agent 最终回答的字符数 */ + answerChars: number; + /** 内容门控阈值(memory.consolidationMinChars,默认 200) */ + minChars: number; + /** 本次 run 是否存在成功的工具调用 */ + hadSuccessfulToolCall: boolean; + /** 该会话上次固化的时间戳(0 = 从未固化) */ + lastConsolidationAt: number; + /** 当前时间戳 */ + now: number; + /** 频率门控窗口(memory.consolidationIntervalMs,默认 600000) */ + intervalMs: number; +} + +export type ConsolidationDecision = + | { consolidate: true; reason: 'content-and-frequency-pass' } + | { consolidate: false; reason: 'disabled' | 'below-threshold' | 'throttled' }; + +/** 频率窗口合法下限(防误配 0/负值导致门控失效——0 等价于每条消息都固化) */ +export const MIN_CONSOLIDATION_INTERVAL_MS = 60_000; + +/** 内容门控合法下限(防误配 0 导致纯寒暄也固化) */ +export const MIN_CONSOLIDATION_MIN_CHARS = 20; + +/** + * 判定本次 run 是否应触发记忆固化。 + */ +export function shouldConsolidate(input: ConsolidationDecisionInput): ConsolidationDecision { + // 1. 总开关 —— fail-secure 语义由调用方负责(!== false 才视为开启后传入布尔) + if (input.enabled === false) { + return { consolidate: false, reason: 'disabled' }; + } + + // 2. 内容门控:回答够长 或 有成功的工具调用(事实性上下文) + const minChars = Math.max( + MIN_CONSOLIDATION_MIN_CHARS, + Number.isFinite(input.minChars) ? input.minChars : 200, + ); + const contentWorthy = input.answerChars >= minChars || input.hadSuccessfulToolCall; + if (!contentWorthy) { + return { consolidate: false, reason: 'below-threshold' }; + } + + // 3. 频率门控:上次固化距今不足窗口 → 跳过(首次 lastConsolidationAt=0 不受限) + const intervalMs = Math.max( + MIN_CONSOLIDATION_INTERVAL_MS, + Number.isFinite(input.intervalMs) ? input.intervalMs : 600_000, + ); + if (input.lastConsolidationAt > 0 && input.now - input.lastConsolidationAt < intervalMs) { + return { consolidate: false, reason: 'throttled' }; + } + + return { consolidate: true, reason: 'content-and-frequency-pass' }; +} diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index 58d7f3f..7fa260f 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -167,6 +167,8 @@ export class TaskOrchestrator extends EventEmitter { thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium', contextLength: this.defaultConfig?.contextLength, contextWindow: this.defaultConfig?.contextWindow ?? 128_000, + // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection(REFLECTING 状态开关) + enableReflection: this.defaultConfig?.enableReflection ?? false, }, this.engines.createAdapter(), this.toolRegistry, diff --git a/electron/harness/prompts/__tests__/context-builder.test.ts b/electron/harness/prompts/__tests__/context-builder.test.ts index 3fe52f6..4c87b75 100644 --- a/electron/harness/prompts/__tests__/context-builder.test.ts +++ b/electron/harness/prompts/__tests__/context-builder.test.ts @@ -64,11 +64,23 @@ describe('ContextBuilder — isUsingFallbackRole 首次降级通知语义', () = }); describe('ContextBuilder — 动态区注入', () => { - it('注入当前日期时间(含本地时区)', () => { + it('v0.7.3 P1-1: 不再注入日期时间(prompt cache 前缀稳定性)', () => { const cb = new ContextBuilder(); const prompt = cb.buildSystemPrompt({ soul: 'x', memory: '' }); - expect(prompt.dynamicReminders).toContain('## Current Date & Time'); - expect(prompt.dynamicReminders).toMatch(/UTC[+-]/); + expect(prompt.dynamicReminders).not.toContain('## Current Date & Time'); + expect(prompt.dynamicReminders).not.toMatch(/UTC[+-]/); + }); + + it('v0.7.3 P1-1: system 输出跨调用字节级稳定(同输入 → 同字节)', () => { + const cb = new ContextBuilder(); + const files = { soul: 'x', memory: '## 用户偏好\n- 偏好深色主题' }; + const a = cb.buildSystemPrompt(files, '/tmp/ws-demo'); + const b = cb.buildSystemPrompt(files, '/tmp/ws-demo'); + // 跨 run 缓存命中的前提:四分区逐字节一致(日期时间已移入用户消息前置块) + expect(a.roleDefinition).toBe(b.roleDefinition); + expect(a.outputConstraints).toBe(b.outputConstraints); + expect(a.safetyGuidelines).toBe(b.safetyGuidelines); + expect(a.dynamicReminders).toBe(b.dynamicReminders); }); it('注入工作空间路径(动态区,路径可切换)', () => { diff --git a/electron/harness/prompts/__tests__/user-context.test.ts b/electron/harness/prompts/__tests__/user-context.test.ts new file mode 100644 index 0000000..67eb94f --- /dev/null +++ b/electron/harness/prompts/__tests__/user-context.test.ts @@ -0,0 +1,94 @@ +/** + * 用户上下文前置块测试(v0.7.3 P1-1) + * + * 锁定三类动态内容(日期时间 / 记忆 / 附件提示)在用户消息前置块的 + * 分组结构与空值收缩行为 —— 它们从 system prompt 迁出的契约面。 + */ + +import { describe, it, expect } from 'vitest'; +import { buildUserContextPrefix, withUserContextPrefix } from '../user-context'; + +describe('buildUserContextPrefix', () => { + it('恒含头部说明与日期时间分区(唯一无条件分区)', () => { + const prefix = buildUserContextPrefix({ now: Date.UTC(2026, 7, 30, 6, 30) }); + expect(prefix).toContain('[Contextual information for this message'); + expect(prefix).toContain('## Current Date & Time'); + }); + + it('无记忆/附件时不产出对应分区(空值收缩)', () => { + const prefix = buildUserContextPrefix({ now: Date.now() }); + expect(prefix).not.toContain('## Relevant Memories (Retrieved)'); + expect(prefix).not.toContain('## User Attachments (Direct Upload)'); + }); + + it('记忆分区:条目格式与截断口径(沿用原 system 注入契约)', () => { + const prefix = buildUserContextPrefix({ + now: Date.now(), + memories: [ + { + id: 'm1', + type: 'semantic', + content: 'x'.repeat(500), + source: 'agent_thought', + importance: 0.9, + score: 0.8, + createdAt: Date.now(), + }, + ], + }); + expect(prefix).toContain('## Relevant Memories (Retrieved)'); + expect(prefix).toMatch(/\[1\] \(semantic, 重要度: 0\.9\)/); + // 内容截断到 200 字符 + expect(prefix).toContain('x'.repeat(200)); + expect(prefix).not.toContain('x'.repeat(201)); + }); + + it('附件分区:图片提示禁止重复读图;文本截断标记透传', () => { + const prefix = buildUserContextPrefix({ + now: Date.now(), + attachments: [ + { name: 'shot.png', type: 'image' }, + { name: 'big.log', type: 'text', truncated: true }, + ], + }); + expect(prefix).toContain('## User Attachments (Direct Upload)'); + expect(prefix).toContain('1. [image] shot.png'); + expect(prefix).toContain('do NOT call view_image'); + expect(prefix).toContain('2. [text file] big.log'); + expect(prefix).toContain('TRUNCATED — only the first 512KB is included'); + }); + + it('分区以 --- 分隔且以前缀分隔符收尾(调用方可直接拼接用户内容)', () => { + const prefix = buildUserContextPrefix({ + now: Date.now(), + memories: [ + { + id: 'm', + type: 'episodic', + content: 'c', + source: 'user_input', + importance: 0.5, + score: 0.5, + createdAt: Date.now(), + }, + ], + attachments: [{ name: 'a.txt', type: 'text' }], + }); + expect(prefix).toMatch(/---\s*$/); + // 三个分区恰好两个内部 --- + 收尾 1 个 ---(共 3 个独立行) + expect(prefix.match(/^---$/gm)?.length ?? 0).toBe(3); + }); +}); + +describe('withUserContextPrefix', () => { + it('前置块与用户内容拼接(前置块自带收尾分隔符)', () => { + const prefix = buildUserContextPrefix({ now: Date.now() }); + const out = withUserContextPrefix(prefix, '你好,帮我写个脚本'); + expect(out.startsWith(prefix)).toBe(true); + expect(out.endsWith('你好,帮我写个脚本')).toBe(true); + }); + + it('空前缀原样返回(契约防御)', () => { + expect(withUserContextPrefix('', 'hello')).toBe('hello'); + }); +}); diff --git a/electron/harness/prompts/context-builder.ts b/electron/harness/prompts/context-builder.ts index 77afc36..73d9cd1 100644 --- a/electron/harness/prompts/context-builder.ts +++ b/electron/harness/prompts/context-builder.ts @@ -58,7 +58,10 @@ export class ContextBuilder { * * v0.3.14: 移除 AGENTS.md 和 USERS.md 的读取,SOUL.md 仅做角色定义 */ - buildSystemPrompt(workspaceFiles?: WorkspaceFiles, workspacePath?: string): { + buildSystemPrompt( + workspaceFiles?: WorkspaceFiles, + workspacePath?: string, + ): { roleDefinition: string; outputConstraints: string; safetyGuidelines: string; @@ -76,23 +79,19 @@ export class ContextBuilder { // ===== 动态区:记忆 ===== const dynamicParts: string[] = []; - // v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间) - // 用于让 AI 准确理解"今天"、"昨天"等相对时间表达 - // #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确 - const now = new Date(); - const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai'; - // 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息 - const offset = -now.getTimezoneOffset() / 60; - const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`; - const dateTimeStr = now.toLocaleString('zh-CN', { - timeZone: localTimezone, - hour12: false, - }); - dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`); + // v0.7.3 P1-1 根治: 当前日期时间不再注入 system prompt —— 此前每次构建都 + // 产生不同字节(秒级时间戳 + 时区),导致跨 run 的 system 前缀永不一致, + // DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。现移入用户消息 + // 前置块(@see user-context.ts),system 保持跨 run 字节级稳定。 + // MEMORY.md 的 `> 创建时间/最后更新` 元数据行由 extractContent 剥离, + // 正文提取不受时间戳更新影响 —— 此处无需额外处理。 - // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区) + // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区; + // 会话期间路径恒定,不破坏缓存) if (workspacePath) { - dynamicParts.push(`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`); + dynamicParts.push( + `## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`, + ); } if (workspaceFiles?.memory) { @@ -138,7 +137,9 @@ export class ContextBuilder { } else { // v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 IPC 层读取后发 toast this.lastUsedFallbackRole = true; - log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity'); + log.warn( + '[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity', + ); // 兜底身份定义(Metona 灵魂定义) parts.push(`# Metona — 灵魂定义 > "想清楚再动手,做对比做快重要" diff --git a/electron/harness/prompts/user-context.ts b/electron/harness/prompts/user-context.ts new file mode 100644 index 0000000..74b0e0e --- /dev/null +++ b/electron/harness/prompts/user-context.ts @@ -0,0 +1,140 @@ +/** + * User Context Prefix — 每条用户消息的系统上下文前置块(v0.7.3 P1-1 根治) + * + * 背景(Prompt Cache 被打穿的根因): + * 此前「当前日期时间」「检索到的相关记忆」「附件提示」三类**每条消息都在变** + * 的内容被追加进 systemPrompt.dynamicReminders —— OpenAI 兼容系将其拼进首条 + * system 消息、Anthropic 写入顶层 system 字段。任何一次变化都会使整个 system + * 前缀失配,DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。长 system + * (SOUL + 安全准则 + MEMORY.md)× 每 run 最多 20 轮迭代 × 全量重算输入 token, + * 成本与首字延迟被系统性放大。 + * + * 现契约(单一事实来源): + * - system prompt 只保留跨 run 字节级稳定的内容(SOUL / 约束 / 安全准则 / + * 工作空间路径 / MEMORY.md 正文——其易变的 `> 最后更新` 元数据行本就被 + * extractContent 剥离);Anthropic 侧对该稳定前缀打 cache_control 断言; + * - 易变内容(日期时间 / 记忆 / 附件提示)由本模块构建为**用户消息前置块**, + * 随当次请求注入首条 user 消息(LLM 语义等价:Claude Code 同款上下文注入位); + * - DB 持久化 / 前端展示 / 记忆固化 / 注入检测均使用**原始干净内容**, + * 前置块只存在于发给引擎的副本上。 + * + * 纯函数、零副作用:可在 node vitest 下直接表测(稳定性/分组/空值收缩)。 + */ + +import type { SearchResult } from '../memory/manager'; + +/** 附件提示所需的元信息子集(与 agent-store AttachmentInfo 对齐的渲染端子集) */ +export interface AttachmentHint { + name: string; + type: string; + truncated?: boolean; +} + +export interface UserContextPrefixInput { + /** 当前时间戳(前置块内降精度到分钟,减少无意义抖动) */ + now?: number; + /** 检索到的相关记忆(空数组时不产出记忆分区) */ + memories?: SearchResult[]; + /** 用户附件元信息(空数组/undefined 时不产出附件分区) */ + attachments?: AttachmentHint[]; + /** + * 时区标签(如 "Asia/Shanghai (UTC+8)")。 + * 由调用方计算(Intl.DateTimeFormat().resolvedOptions().timeZone)—— + * 本模块保持纯函数语义,不做 Electron/Intl 环境依赖。 + */ + timezoneLabel?: string; +} + +/** 记忆注入条目的内容截断(与原 dynamicReminders 注入口径一致) */ +const MEMORY_EXCERPT_CHARS = 200; +/** 附件提示上限(与输入侧 5 个附件的硬上限对齐) */ +const MAX_ATTACHMENT_HINTS = 8; + +/** + * 构建用户消息上下文前置块。 + * + * 输出形态(各分区以 `\n\n---\n\n` 分隔,整体以分隔符结尾, + * 调用方直接 `${prefix}${userContent}` 拼接): + * ``` + * [Contextual information for this message — system-generated metadata, not part of the user's request.] + * + * ## Current Date & Time + * 2026/8/30 14:30:00 (Asia/Shanghai, UTC+8) + * + * --- + * + * ## Relevant Memories (Retrieved) + * [1] (semantic, 重要度: 0.9) ... + * + * --- + * + * ## User Attachments (Direct Upload) + * ... + * ``` + */ +export function buildUserContextPrefix(input: UserContextPrefixInput): string { + const parts: string[] = []; + + // ===== 分区 1:当前日期时间(降精度到分钟) ===== + const now = input.now ?? Date.now(); + const timezoneLabel = input.timezoneLabel ?? 'UTC'; + const dateStr = new Date(now).toLocaleString('sv-SE', { + timeZone: undefined, + hour12: false, + }); // sv-SE 给出 ISO 形态 "2026-08-30 14:30:00" + parts.push(`## Current Date & Time\n${dateStr} (${timezoneLabel})`); + + // ===== 分区 2:相关记忆注入(沿用原 system 注入的展示口径) ===== + const memories = (input.memories ?? []).slice(0, 5); + if (memories.length > 0) { + const memorySection = memories + .map( + (m, i) => + `[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, MEMORY_EXCERPT_CHARS)}`, + ) + .join('\n'); + parts.push(`## Relevant Memories (Retrieved)\n${memorySection}`); + } + + // ===== 分区 3:附件提示(沿用原 system 注入的语义与文案契约) ===== + const attachments = (input.attachments ?? []).slice(0, MAX_ATTACHMENT_HINTS); + if (attachments.length > 0) { + const attachmentList = attachments + .map((att, i) => { + const typeLabel = + att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file'; + // 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整, + // 防止模型把残缺内容当作完整文件事实(v0.7.2 A5 契约延续) + const truncatedNote = + att.truncated === true + ? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)' + : ''; + const note = + att.type === 'image' + ? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again' + : att.type === 'text' + ? `content${truncatedNote} already inlined in the user message, do NOT search in workspace or read it again` + : 'uploaded directly by user, do NOT search in workspace'; + return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`; + }) + .join('\n'); + + parts.push( + `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`, + ); + } + + const header = + "[Contextual information for this message — system-generated metadata, not part of the user's request.]"; + + return `${header}\n\n${parts.join('\n\n---\n\n')}\n\n---\n\n`; +} + +/** + * 将前置块与用户原始内容拼装为发送给引擎的消息内容。 + * 空前缀(理论上不会发生——日期分区恒存在,但契约上防御)时原样返回。 + */ +export function withUserContextPrefix(prefix: string, userContent: string): string { + if (!prefix) return userContent; + return `${prefix}${userContent}`; +} diff --git a/electron/harness/tools/built-in/__tests__/ssrf-dispatcher.test.ts b/electron/harness/tools/built-in/__tests__/ssrf-dispatcher.test.ts new file mode 100644 index 0000000..4b92519 --- /dev/null +++ b/electron/harness/tools/built-in/__tests__/ssrf-dispatcher.test.ts @@ -0,0 +1,103 @@ +/** + * SSRF DNS Pinning 测试(v0.7.3 P2-1) + * + * 锁定三个单元: + * D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family), + * 空集合返回 ENOTFOUND(防御)。 + * D2 resolveRedirectTarget —— 重定向状态识别 + 相对 Location 解析 + + * 非法/缺失 Location 返回 null。 + * D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源; + * 域名解析路径由 ssrf-guard 表测覆盖,此处不重复触网)。 + */ + +import { describe, it, expect } from 'vitest'; +import { createPinnedLookup, resolveRedirectTarget, resolvePinnedIps } from '../ssrf-dispatcher'; +import type { LookupCallback } from '../ssrf-dispatcher'; + +describe('createPinnedLookup', () => { + it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => { + const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']); + const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => { + const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!)); + lookup('attacker.example', {}, cb); + }); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 }); + expect(result[1].family).toBe(6); + }); + + it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => { + const lookup = createPinnedLookup(['not-an-ip']); + await expect( + new Promise((resolve, reject) => { + const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses)); + lookup('h', {}, cb as never); + }), + ).rejects.toMatchObject({ code: 'ENOTFOUND' }); + }); + + it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher)', async () => { + const lookup = createPinnedLookup([]); + await expect( + new Promise((resolve, reject) => { + const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses)); + lookup('h', {}, cb as never); + }), + ).rejects.toMatchObject({ code: 'ENOTFOUND' }); + }); +}); + +describe('resolveRedirectTarget', () => { + const makeResponse = (status: number, location?: string) => ({ + status, + headers: { + get: (name: string) => (name.toLowerCase() === 'location' ? (location ?? null) : null), + }, + }); + + it('D2: 301/302/303/307/308 识别并解析绝对 Location', () => { + for (const status of [301, 302, 303, 307, 308]) { + expect( + resolveRedirectTarget(makeResponse(status, 'https://cdn.example.com/x'), 'https://a.test/'), + ).toBe('https://cdn.example.com/x'); + } + }); + + it('D2: 相对 Location 以当前 URL 为基解析(RFC 7231)', () => { + expect(resolveRedirectTarget(makeResponse(302, '/next?a=1'), 'https://a.test/dir/page')).toBe( + 'https://a.test/next?a=1', + ); + }); + + it('D2: 非 3xx 状态 → null(终态)', () => { + expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull(); + expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull(); + }); + + it('D2: 缺失/非法 Location → null', () => { + expect(resolveRedirectTarget(makeResponse(302), 'https://a.test/')).toBeNull(); + expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull(); + expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull(); + }); +}); + +describe('resolvePinnedIps', () => { + it('D3: IP 直连 URL —— 公网 IP 直接返回', async () => { + const ips = await resolvePinnedIps('https://93.184.216.34/x'); + expect(ips).toEqual(['93.184.216.34']); + }); + + it('D3: 私有/回环 IP 直连被拒(单一事实来源 ssrf-guard)', async () => { + for (const host of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '[::1]']) { + await expect(resolvePinnedIps(`http://${host}/latest`)).rejects.toThrow(/Blocked SSRF/); + } + }); + + it('D3: 非 http/https 协议被拒', async () => { + await expect(resolvePinnedIps('ftp://example.com')).rejects.toThrow(/not allowed/); + }); + + it('D3: 非法 URL 被拒', async () => { + await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/); + }); +}); diff --git a/electron/harness/tools/built-in/browser-window-manager.ts b/electron/harness/tools/built-in/browser-window-manager.ts index da29de5..c2ef523 100644 --- a/electron/harness/tools/built-in/browser-window-manager.ts +++ b/electron/harness/tools/built-in/browser-window-manager.ts @@ -9,6 +9,8 @@ import { BrowserWindow, session } from 'electron'; import log from 'electron-log'; +// v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测) +import { corsAllowOrigin, extractOriginHeader } from './network-utils'; /** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */ const AGENT_PARTITION = 'persist:metona-agent-browser'; @@ -128,12 +130,22 @@ export class BrowserWindowManager { // v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false // 仅对 agent session 放行 CORS,不影响主应用 + // v0.7.3 P2-2 收紧: ACAO 从通配 '*' 改为回显请求 Origin —— 通配值让任意 + // 第三方页面都能借该分区跨域读取;回显等价保留截图/页面自身跨域能力, + // 并附加 Vary: Origin 防止共享缓存把定向值串到其他 Origin。 const agentSession = session.fromPartition(AGENT_PARTITION); agentSession.webRequest.onHeadersReceived((details, callback) => { + // Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露 + // requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取) + const requestHeaders = ( + details as unknown as { requestHeaders?: Record } + ).requestHeaders; + const originHeader = extractOriginHeader(requestHeaders); callback({ responseHeaders: { ...details.responseHeaders, - 'Access-Control-Allow-Origin': ['*'], + 'Access-Control-Allow-Origin': corsAllowOrigin(originHeader), + Vary: [...(details.responseHeaders?.Vary ?? []), 'Origin'], }, }); }); diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index 90e1ef7..30f372b 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -25,6 +25,9 @@ import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard'; import type { SandboxManager } from '../../sandbox/sandbox'; +// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源 +// (与 MCP stdio 启动共用同一黑名单,历史双实现已漂移过一次) +import { buildSafeChildEnv } from '../../../utils/safe-env'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -52,47 +55,22 @@ function decodeBuffer(buf: Buffer): string { /** * #9 修复 + 审查修复: 构建安全的子进程环境变量 * - * 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。 - * 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。 + * v0.7.3 P3-2: 实现收敛到 utils/safe-env.ts(buildSafeChildEnv)—— + * 与 MCP stdio 启动共用同一黑名单,本文件仅保留 run_command 的运行时差异注入 + * (Windows 中文编码变量)。黑名单方案的设计原因见 safe-env.ts 模块注释: + * 白名单方案会遗漏 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量导致子进程功能破坏。 */ function buildSafeCommandEnv(isWindows: boolean): Record { - // 敏感变量后缀黑名单 - const SENSITIVE_SUFFIXES = [ - '_API_KEY', - '_TOKEN', - '_SECRET', - '_PASSWORD', - '_PASSWD', - '_CREDENTIAL', - '_CREDENTIALS', - '_PRIVATE_KEY', - ]; - // 敏感变量名黑名单(精确匹配) - const SENSITIVE_KEYS = new Set([ - 'DEEPSEEK_API_KEY', - 'AGNES_API_KEY', - 'MIMO_API_KEY', - 'GITEA_PASSWORD', - 'DATABASE_PASSWORD', - ]); - - const env: Record = {}; - for (const [key, val] of Object.entries(process.env)) { - if (!val) continue; - if (SENSITIVE_KEYS.has(key)) continue; - if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue; - env[key] = val; - } - - // 添加必要的运行时变量 - env.NODE_ENV = 'production'; - if (isWindows) { - env.PYTHONIOENCODING = 'utf-8'; - env.LANG = 'zh_CN.UTF-8'; - env.LC_ALL = 'zh_CN.UTF-8'; - } - - return env; + return buildSafeChildEnv({ + runtime: isWindows + ? { + NODE_ENV: 'production', + PYTHONIOENCODING: 'utf-8', + LANG: 'zh_CN.UTF-8', + LC_ALL: 'zh_CN.UTF-8', + } + : { NODE_ENV: 'production' }, + }); } /** diff --git a/electron/harness/tools/built-in/http-request.ts b/electron/harness/tools/built-in/http-request.ts index 7b7a59a..3a2709b 100644 --- a/electron/harness/tools/built-in/http-request.ts +++ b/electron/harness/tools/built-in/http-request.ts @@ -14,7 +14,11 @@ import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; // v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑, // web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。 +// v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到 +// 连接层(undici connect.lookup),校验与连接共用同一批 IP,DNS rebinding +// 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。 import { validateSSRF } from './ssrf-guard'; +import { ssrfPinnedFetch } from './ssrf-dispatcher'; const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const; const MAX_BODY_BYTES = 50 * 1024; // 50KB @@ -52,7 +56,8 @@ const MAX_BODY_BYTES = 50 * 1024; // 50KB export class HttpRequestTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'http_request', - description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.', + description: + 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.', parameters: { type: 'object', properties: { @@ -64,7 +69,10 @@ export class HttpRequestTool implements IMetonaTool { }, headers: { type: 'object', description: 'Request headers as key-value pairs' }, body: { type: 'string', description: 'Request body (string)' }, - timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default 30000, max 60000)', + }, }, required: ['url'], }, @@ -102,15 +110,12 @@ export class HttpRequestTool implements IMetonaTool { }; } - // 超时控制 - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - - try { + // 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT; + // 工具执行层的 abort signal 经 context 传入 registry 兜底) + { const fetchOptions: RequestInit = { method, headers, - signal: controller.signal, // #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验 // 重定向后的 URL 由用户自行处理(响应中会包含 Location 头) redirect: 'manual', @@ -120,7 +125,9 @@ export class HttpRequestTool implements IMetonaTool { fetchOptions.body = body; } - const response = await fetch(url, fetchOptions); + // v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层, + // 关闭校验-连接之间的 DNS rebinding 窗口 + const response = await ssrfPinnedFetch(url, fetchOptions, timeout); const text = await response.text(); // 截断到 50KB @@ -144,14 +151,14 @@ export class HttpRequestTool implements IMetonaTool { body: safeBody, truncated, ok: response.ok, - success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段 + success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段 }; - } finally { - clearTimeout(timer); } } catch (error) { - // 区分超时(AbortError)与其他网络错误 - if (error instanceof Error && error.name === 'AbortError') { + // 区分超时与其他网络错误:AbortError(外部中断)与 + // ETIMEDOUT(ssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义 + const err = error as Error & { code?: string }; + if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') { return { error: 'Request timeout', success: false }; } const errMsg = error instanceof Error ? error.message : String(error); diff --git a/electron/harness/tools/built-in/network-utils.ts b/electron/harness/tools/built-in/network-utils.ts index 2aed309..01af136 100644 --- a/electron/harness/tools/built-in/network-utils.ts +++ b/electron/harness/tools/built-in/network-utils.ts @@ -34,7 +34,8 @@ export const UA_POOL = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0', ]; -export const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'; +export const MOBILE_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'; export const ACCEPT_LANGUAGE_POOL = [ 'zh-CN,zh;q=0.9,en;q=0.8', @@ -54,21 +55,25 @@ export function buildAntiCrawlHeaders( const userAgent = mobileUA ? MOBILE_UA : UA_POOL[uaIdx]; let origin = ''; - try { origin = new URL(url).origin; } catch { /* ignore */ } + try { + origin = new URL(url).origin; + } catch { + /* ignore */ + } return { 'User-Agent': userAgent, - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language': ACCEPT_LANGUAGE_POOL[langIdx], 'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'no-cache', - 'DNT': '1', - 'Referer': origin || '', + DNT: '1', + Referer: origin || '', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', - 'Pragma': 'no-cache', + Pragma: 'no-cache', }; } @@ -107,7 +112,15 @@ export function normalizeUrl(url: string): string { const u = new URL(url); // 去除追踪参数 - const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid']; + const trackingParams = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'gclid', + 'fbclid', + ]; for (const p of trackingParams) u.searchParams.delete(p); // H-6 增强: 排序查询参数(确保参数顺序一致,便于去重) @@ -125,7 +138,7 @@ export function normalizeUrl(url: string): string { (u.protocol === 'https:' && u.port === '443') || (u.protocol === 'ws:' && u.port === '80') || (u.protocol === 'wss:' && u.port === '443'); - const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : ''); + const portSuffix = isDefaultPort ? '' : u.port ? `:${u.port}` : ''; // 强制小写 host return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`; @@ -158,47 +171,68 @@ export function isInterceptedPage(html: string): boolean { // ===== HTML → 纯文本转换 ===== const HTML_ENTITY_MAP: Record = { - ' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"', - ''': "'", '…': '…', '—': '—', '–': '–', - '«': '«', '»': '»', '×': '×', '÷': '÷', - '©': '©', '®': '®', '™': '™', '€': '€', - '£': '£', '¥': '¥', '¢': '¢', '°': '°', + ' ': ' ', + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'", + '…': '…', + '—': '—', + '–': '–', + '«': '«', + '»': '»', + '×': '×', + '÷': '÷', + '©': '©', + '®': '®', + '™': '™', + '€': '€', + '£': '£', + '¥': '¥', + '¢': '¢', + '°': '°', }; export function htmlToText(html: string): string { - return html - // 移除噪声标签及内容 - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/noscript>/gi, '') - .replace(/]*>[\s\S]*?<\/nav>/gi, '') - .replace(/]*>[\s\S]*?<\/header>/gi, '') - .replace(/]*>[\s\S]*?<\/footer>/gi, '') - .replace(/]*>[\s\S]*?<\/aside>/gi, '') - .replace(/]*>[\s\S]*?<\/iframe>/gi, '') - .replace(/]*>[\s\S]*?<\/svg>/gi, '') - // 移除 HTML 注释 - .replace(//g, '') - // 块级标签转换行 - .replace(/<\/?(p|div|h[1-6]|li|tr|blockquote|section|article|pre|br|hr)[^>]*>/gi, '\n') - // 表格单元格转制表符 - .replace(/<\/?(td|th)[^>]*>/gi, '\t') - // 移除剩余标签 - .replace(/<[^>]+>/g, '') - // 解码 HTML 实体 - .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) - .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16))) - .replace(/&[a-z]+;/gi, (m) => HTML_ENTITY_MAP[m.toLowerCase()] ?? m) - // 清理空白 - .replace(/\n{3,}/g, '\n\n') - .replace(/[ \t]+/g, ' ') - .replace(/^[ \t]+/gm, '') - .trim(); + return ( + html + // 移除噪声标签及内容 + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/noscript>/gi, '') + .replace(/]*>[\s\S]*?<\/nav>/gi, '') + .replace(/]*>[\s\S]*?<\/header>/gi, '') + .replace(/]*>[\s\S]*?<\/footer>/gi, '') + .replace(/]*>[\s\S]*?<\/aside>/gi, '') + .replace(/]*>[\s\S]*?<\/iframe>/gi, '') + .replace(/]*>[\s\S]*?<\/svg>/gi, '') + // 移除 HTML 注释 + .replace(//g, '') + // 块级标签转换行 + .replace(/<\/?(p|div|h[1-6]|li|tr|blockquote|section|article|pre|br|hr)[^>]*>/gi, '\n') + // 表格单元格转制表符 + .replace(/<\/?(td|th)[^>]*>/gi, '\t') + // 移除剩余标签 + .replace(/<[^>]+>/g, '') + // 解码 HTML 实体 + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16))) + .replace(/&[a-z]+;/gi, (m) => HTML_ENTITY_MAP[m.toLowerCase()] ?? m) + // 清理空白 + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]+/g, ' ') + .replace(/^[ \t]+/gm, '') + .trim() + ); } // ===== 流式读取(大文件保护,10MB 上限) ===== -export async function readBodyWithLimit(response: Response, maxBytes = 10 * 1024 * 1024): Promise { +export async function readBodyWithLimit( + response: Response, + maxBytes = 10 * 1024 * 1024, +): Promise { const contentLength = response.headers.get('content-length'); if (contentLength && parseInt(contentLength) > maxBytes) { throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`); @@ -249,6 +283,40 @@ export function logTool(toolName: string, message: string): void { log.info(`[Tool:${toolName}] ${message}`); } +// ===== v0.7.3 P2-2: Agent 浏览器 CORS Origin 回显 ===== + +/** + * 计算响应应携带的 Access-Control-Allow-Origin 值(纯函数,表测锁定)。 + * + * 背景:Agent 浏览器专用 session 此前对所有响应注入 ACAO:* —— 任意被 Agent + * 打开的第三方页面都能借该分区无差别跨域读取。改为回显请求 Origin(等价能力: + * 页面对自己的 Origin 仍可跨域读,如截图所需),无 Origin(同源导航/非浏览器 + * 客户端)回退 '*' 保持既有能力不回退。 + * + * @param requestOrigin 请求头 Origin(可能为 undefined / 任意字符串) + * @returns 应写入响应的 ACAO 值(单元素数组,供 Electron responseHeaders 使用) + */ +export function corsAllowOrigin(requestOrigin: string | undefined | null): string[] { + const origin = requestOrigin?.trim(); + if (origin && /^https?:\/\//i.test(origin)) { + return [origin]; + } + return ['*']; +} + +/** 从请求头集合中大小写不敏感地提取 Origin 值 */ +export function extractOriginHeader( + requestHeaders: Record | undefined, +): string | undefined { + if (!requestHeaders) return undefined; + for (const [key, value] of Object.entries(requestHeaders)) { + if (key.toLowerCase() === 'origin') { + return Array.isArray(value) ? value[0] : value; + } + } + return undefined; +} + // ===== v0.6.4 P4-4: HTML → Markdown 转换(web_fetch extract_mode='markdown') ===== // // v0.6.4 收尾:私有 npm 凭据解锁后,按开发规范第一铁律把第一轮的临时自写实现 @@ -285,11 +353,9 @@ const LIST_LINE = /^\s*(?:- |\d+\. )/; * 2. 仅当"空行两侧都是同一列表的条目行"时移除该空行(绝不吞条目、不影响段落间距)。 */ function collapseListGaps(markdown: string): string { - const lines = markdown.split('\n').map((line) => - line - .replace(/^(\s*)- {2,}/, '$1- ') - .replace(/^(\s*\d+\.)\s{2,}/, '$1 '), - ); + const lines = markdown + .split('\n') + .map((line) => line.replace(/^(\s*)- {2,}/, '$1- ').replace(/^(\s*\d+\.)\s{2,}/, '$1 ')); const isListItem = (l: string | undefined): boolean => (l ?? '').length > 0 && LIST_LINE.test(l!); @@ -321,5 +387,7 @@ export function htmlToMarkdown(html: string): string { return ''; } - return collapseListGaps(md).replace(/\n{3,}/g, '\n\n').trim(); + return collapseListGaps(md) + .replace(/\n{3,}/g, '\n\n') + .trim(); } diff --git a/electron/harness/tools/built-in/ssrf-dispatcher.ts b/electron/harness/tools/built-in/ssrf-dispatcher.ts new file mode 100644 index 0000000..9ec8442 --- /dev/null +++ b/electron/harness/tools/built-in/ssrf-dispatcher.ts @@ -0,0 +1,170 @@ +/** + * SSRF DNS Pinning Dispatcher(v0.7.3 P2-1) + * + * 关闭 M7 审查确认的 DNS rebinding 窗口:此前 validateSSRF 在校验阶段解析一次 + * DNS,fetch 实际连接时 undici 再次解析 —— 两次解析之间攻击者可切换 DNS 记录 + * (TTL=0)把连接导向内网。原注释断言"Node fetch 下无法彻底关闭",该结论只对 + * 全局 fetch 成立;主进程已依赖 undici(network-proxy 的 setGlobalDispatcher), + * undici 的 Agent 支持 connect.lookup 自定义 —— 校验通过的 IP 集合可精确 pin 到 + * 连接层,TLS SNI/证书校验仍基于原始域名(undici 将 servername 保持为 hostname)。 + * + * 契约: + * - resolvePublicAddresses(ssrf-guard)是校验与地址解析的唯一事实来源, + * 本模块 pin 的就是它返回的那批 IP —— 校验与连接同源,无双解析窗口; + * - 代理激活(network-proxy.isProxyActive())时 pinning 不可实现(DNS 在代理端 + * 解析)且 per-request dispatcher 会旁路用户代理 —— 退化为"仅入口校验", + * 走全局 dispatcher(保持既有语义与代理兼容); + * - fetchWithTimeoutPinned 合并外部 abort signal 与超时控制,语义对齐 + * BaseAdapter.fetchWithTimeout(超时 → ETIMEDOUT 可重试;外部中断原样抛出); + * - 每次 pinned 请求构造一次性 Agent 并在 finally 中 close(连接池即用即毁, + * 防止把"上一请求的 pin 集合"泄漏给后续请求)。 + */ + +import { Agent, fetch as undiciFetch } from 'undici'; +import { isIP } from 'node:net'; +import { resolvePublicAddresses } from './ssrf-guard'; +import { fetchWithTimeout } from './network-utils'; +import { isProxyActive } from '../../../utils/network-proxy'; +import log from 'electron-log'; + +/** 标准 dns.lookup 回调签名(undici connect.lookup 消费) */ +export type LookupCallback = ( + err: NodeJS.ErrnoException | null, + addresses?: Array<{ address: string; family: number }>, +) => void; + +/** undici connect.lookup 的函数签名形态 */ +export type PinnedLookup = (hostname: string, options: unknown, callback: LookupCallback) => void; + +/** + * 构造"钉死 IP 集合"的 lookup 函数:无论传入什么 hostname,都只返回校验阶段 + * 锁定的公网地址(过滤非法 family)。集合为空时返回 ENOTFOUND(防御性—— + * 调用方在集合为空时不应构造 dispatcher)。 + */ +export function createPinnedLookup(allowedIps: string[]): PinnedLookup { + return (_hostname, _options, callback) => { + process.nextTick(() => { + const addresses = allowedIps + .map((ip) => ({ address: ip, family: isIP(ip) })) + .filter((a): a is { address: string; family: number } => a.family === 4 || a.family === 6); + if (addresses.length === 0) { + const err: NodeJS.ErrnoException = new Error('pinned lookup: no allowed addresses'); + err.code = 'ENOTFOUND'; + callback(err, undefined); + return; + } + callback(null, addresses); + }); + }; +} + +/** + * 校验 URL 并返回 pinning 用的公网 IP 集合。 + * 校验失败原样抛出(调用方按 SSRF 阻断处理)。 + */ +export async function resolvePinnedIps(url: string): Promise { + return resolvePublicAddresses(url); +} + +/** + * 带 SSRF pinning 的 fetch(http_request / web_fetch Phase1 / 可达性预检共用)。 + * + * 行为: + * 1. 代理激活 → 退化为普通 fetchWithTimeout(仅入口校验语义,见模块注释); + * 2. 否则 → 解析并校验公网 IP → 一次性 undici Agent(pinned lookup)发起请求; + * 3. 超时/外部中断语义与 fetchWithTimeout 对齐; + * 4. 返回 Response 与全局 fetch 兼容(status/ok/headers/text/url/body)。 + * + * @param url 目标 URL(调用方已保证 http/https;本函数再做一次全量 SSRF 校验) + * @param init RequestInit(redirect 等由调用方决定) + * @param timeoutMs 请求超时 + * @param externalSignal 外部 abort 信号(引擎中断透传,可选) + */ +export async function ssrfPinnedFetch( + url: string, + init: RequestInit, + timeoutMs: number, + externalSignal?: AbortSignal, +): Promise { + const ips = await resolvePublicAddresses(url); + + // 代理激活:DNS 在代理端解析,pinning 不可实现;走全局 dispatcher 保持代理语义 + if (isProxyActive()) { + return fetchWithTimeout(url, init, timeoutMs); + } + + // 外部信号已中止 → 直接抛 AbortError(对齐 fetchWithTimeout 行为) + if (externalSignal?.aborted) { + const err = new Error('Aborted'); + err.name = 'AbortError'; + throw err; + } + + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + const onExternalAbort = () => controller.abort(); + if (externalSignal) { + externalSignal.addEventListener('abort', onExternalAbort, { once: true }); + } + + // 一次性 pinned Agent(connect 超时对齐 network-proxy 的 15s 连接上限) + const dispatcher = new Agent({ + connect: { timeout: 15_000, lookup: createPinnedLookup(ips) as never }, + }); + + try { + const response = await undiciFetch(url, { + ...(init as Record), + signal: controller.signal, + dispatcher, + } as never); + return response as unknown as Response; + } catch (err) { + const externalAborted = externalSignal?.aborted === true; + if (timedOut && !externalAborted) { + const timeoutError = new Error( + `Request timed out after ${timeoutMs}ms (url=${String(url).slice(0, 120)})`, + ); + (timeoutError as Error & { code: string }).code = 'ETIMEDOUT'; + throw timeoutError; + } + throw err; + } finally { + clearTimeout(timer); + if (externalSignal) { + externalSignal.removeEventListener('abort', onExternalAbort); + } + // 一次性 dispatcher 用后即毁(连接池不跨请求复用,防止 pin 集合泄漏) + void dispatcher.close().catch((closeErr) => { + log.debug(`[SSRFDispatcher] dispatcher close failed: ${(closeErr as Error).message}`); + }); + } +} + +const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]); + +/** + * 解析重定向目标(纯函数,表测锁定)。 + * + * @returns 下一跳绝对 URL;非重定向状态/缺失/非法 Location 返回 null + * (表示当前响应即终态或无法跟随,由调用方按既有语义处理)。 + * 相对 Location 以 currentUrl 为基解析(RFC 7231)。 + */ +export function resolveRedirectTarget( + response: { status: number; headers: { get(name: string): string | null } }, + currentUrl: string, +): string | null { + if (!REDIRECT_STATUS.has(response.status)) return null; + const location = response.headers.get('location'); + if (!location) return null; + try { + return new URL(location, currentUrl).toString(); + } catch { + return null; + } +} diff --git a/electron/harness/tools/built-in/ssrf-guard.ts b/electron/harness/tools/built-in/ssrf-guard.ts index 6e38748..2773372 100644 --- a/electron/harness/tools/built-in/ssrf-guard.ts +++ b/electron/harness/tools/built-in/ssrf-guard.ts @@ -30,21 +30,21 @@ export function isPrivateIP(ip: string): boolean { // IPv4 直接检测 if (isIP(ip) === 4) { const parts = ip.split('.').map(Number); - if (parts[0] === 127) return true; // 回环 - if (parts[0] === 10) return true; // 内网 + if (parts[0] === 127) return true; // 回环 + if (parts[0] === 10) return true; // 内网 if (parts[0] === 192 && parts[1] === 168) return true; // 内网 if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网 - if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据) - if (parts[0] === 0) return true; // 0.0.0.0/8 - if (parts[0] >= 224) return true; // 组播 + 保留 + if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据) + if (parts[0] === 0) return true; // 0.0.0.0/8 + if (parts[0] >= 224) return true; // 组播 + 保留 return false; } // IPv6 检测 if (isIP(ip) === 6) { const lower = ip.toLowerCase(); - if (lower === '::1') return true; // 回环 - if (lower.startsWith('fe80:')) return true; // 链路本地 + if (lower === '::1') return true; // 回环 + if (lower.startsWith('fe80:')) return true; // 链路本地 if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地 // ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测 const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); @@ -57,16 +57,22 @@ export function isPrivateIP(ip: string): boolean { } /** - * SSRF 校验 — 解析 URL 域名并校验 IP + * SSRF 校验 + 公网地址解析(单一事实来源) + * + * v0.7.3 P2-1 重构:resolvePublicAddresses 承载全部校验逻辑并返回解析出的 + * 公网 IP 集合;validateSSRF 成为它的"只要不抛错"薄包装。这样 pinning 层 + * (ssrf-dispatcher)能拿到与校验完全同一批 IP,避免"校验一次解析、连接 + * 再解析一次"的双解析不一致。 * * 1. 协议白名单:仅允许 http/https * 2. hostname 为 IP 时直接检测 * 3. 域名 — DNS 解析后检测所有 IP;任意一个 IP 为私有即拒绝 * (防止 DNS rebinding 中只校验第一个 IP 的绕过) * + * @returns 校验通过的全部公网 IP(供 DNS pinning 使用) * @throws 如果 URL 指向私有/内网/回环地址或协议不被允许 */ -export async function validateSSRF(url: string): Promise { +export async function resolvePublicAddresses(url: string): Promise { let parsed: URL; try { parsed = new URL(url); @@ -86,7 +92,7 @@ export async function validateSSRF(url: string): Promise { if (isPrivateIP(hostname)) { throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`); } - return; + return [hostname]; } // 域名 — DNS 解析后检测所有 IP @@ -94,22 +100,39 @@ export async function validateSSRF(url: string): Promise { try { addresses = await lookup(hostname, { all: true }); } catch (err) { - throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`); + throw new Error( + `Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`, + ); } if (addresses.length === 0) { throw new Error(`Blocked SSRF: no DNS records for ${hostname}`); } + const publicIps: string[] = []; for (const { address } of addresses) { if (isPrivateIP(address)) { throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`); } + publicIps.push(address); } + return publicIps; +} + +/** + * SSRF 校验 — 解析 URL 域名并校验 IP(v0.7.3 起为 resolvePublicAddresses 的 + * "仅校验不取值"包装,校验逻辑单一来源在后者) + * + * @throws 如果 URL 指向私有/内网/回环地址或协议不被允许 + */ +export async function validateSSRF(url: string): Promise { + await resolvePublicAddresses(url); } /** validateSSRF 的不抛错包装:返回结构化结果供工具 execute 直接 return */ -export async function safeValidateSSRF(url: string): Promise<{ ok: true } | { ok: false; error: string }> { +export async function safeValidateSSRF( + url: string, +): Promise<{ ok: true } | { ok: false; error: string }> { try { await validateSSRF(url); return { ok: true }; diff --git a/electron/harness/tools/built-in/web-fetch.ts b/electron/harness/tools/built-in/web-fetch.ts index e52855e..d53772b 100644 --- a/electron/harness/tools/built-in/web-fetch.ts +++ b/electron/harness/tools/built-in/web-fetch.ts @@ -18,7 +18,6 @@ import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { fetchCache, buildAntiCrawlHeaders, - fetchWithTimeout, htmlToText, isInterceptedPage, readBodyWithLimit, @@ -30,14 +29,21 @@ import { getBrowserManager } from './browser'; // v0.6.4 P2-2 根治安全不对称:web_fetch 此前完全没有 SSRF 校验(仅协议检查)且 // requiresPermission:false —— LLM 可直接抓取 http://127.0.0.1:、 // http://169.254.169.254/latest/meta-data 等内网/云元数据地址,浏览器回退通道 -// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为): -// 入口校验 + HTTP 重定向终态 URL 复检(堵 redirect:'follow' 绕道内网的口子)。 +// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为)。 +// v0.7.3 P2-1 根治: 抓取层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到 +// 连接层(undici connect.lookup),关闭校验-连接之间的 DNS rebinding 窗口; +// 重定向改为逐跳手动跟随,每一跳都先校验后连接(原 redirect:'follow' 下 +// 中间跳转在"终态复检"之前已真实发出,可触达内网)。 import { validateSSRF } from './ssrf-guard'; +import { resolveRedirectTarget, ssrfPinnedFetch } from './ssrf-dispatcher'; // ===== 跳过重试的状态码 ===== const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]); +/** v0.7.3 P2-1: 单次抓取允许的最大重定向跳数(每跳均经校验 + pinning) */ +const MAX_REDIRECT_HOPS = 5; + // ===== WebFetchTool ===== export class WebFetchTool implements IMetonaTool { @@ -188,25 +194,55 @@ export class WebFetchTool implements IMetonaTool { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const headers = buildAntiCrawlHeaders(url, attempt, mobileUA); - const response = await fetchWithTimeout(url, { headers, redirect: 'follow' }, 20_000); - // v0.6.4 P2-2: 重定向终态复检 —— redirect:'follow' 下 fetch 可能跟随跳转 - // 到与入口校验不同的目标;SSRF 校验 initial URL 后再对 response.url(终态) - // 复检,堵住"外网跳内网"绕道。终态指向私有地址时按拦截处理转入浏览器通道 - // 也会被浏览器侧域名校验拒绝。 - if (response.url && response.url !== url) { - try { - await validateSSRF(response.url); - } catch (ssrfErr) { + // ===== v0.7.3 P2-1 根治: 手动逐跳重定向 + 每跳 SSRF 校验 + DNS pinning ===== + // 原 redirect:'follow' 下 undici 在内核自动跟跳:跳转目标仅在"终态复检" + // 时被校验,中间跳转的请求已经真实发出(可触达内网/元数据地址)。 + // 现改为逐跳手动跟随:每跳由 ssrfPinnedFetch 发出(校验通过 IP pin 到 + // 连接层),Location 目标显式校验通过后才允许下一跳;私有地址/非法 + // 协议目标按 blocked 语义立即终止(禁止重试与浏览器回退)。 + let currentUrl = url; + let response: Response | null = null; + let redirectBlocked: string | null = null; + + for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) { + response = await ssrfPinnedFetch(currentUrl, { headers, redirect: 'manual' }, 20_000); + const next = resolveRedirectTarget(response, currentUrl); + if (next === null) break; // 非重定向(或无/非法 Location)—— 当前响应即终态 + if (hop === MAX_REDIRECT_HOPS) { return { success: false, html: '', text: '', intercepted: false, - blocked: true, - reason: `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`, + reason: `Too many redirects (>${MAX_REDIRECT_HOPS})`, }; } + // 下一跳目标显式校验(ssrfPinnedFetch 内部还会再次校验+pinning; + // 这里提前拦截以保证 blocked 语义:不重试、不进浏览器回退) + try { + await validateSSRF(next); + } catch (ssrfErr) { + redirectBlocked = `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`; + break; + } + currentUrl = next; + response = null; // 丢弃中间跳转响应,下一跳重新抓取 + } + + if (redirectBlocked) { + return { + success: false, + html: '', + text: '', + intercepted: false, + blocked: true, + reason: redirectBlocked, + }; + } + if (!response) { + // 防御性:循环正常结束必然携带终态响应 + return { success: false, html: '', text: '', intercepted: false, reason: 'No response' }; } // 跳过重试的状态码 → 直接进入浏览器回退 diff --git a/electron/harness/tools/built-in/web-search.ts b/electron/harness/tools/built-in/web-search.ts index f405d7c..70260cb 100644 --- a/electron/harness/tools/built-in/web-search.ts +++ b/electron/harness/tools/built-in/web-search.ts @@ -26,6 +26,9 @@ import { buildSearXNGAuthHeaders, logTool, } from './network-utils'; +// v0.7.3 P2-1: 可达性预检经 SSRF 校验 + DNS pinning(结果 URL 是不可信外部输入) +import { safeValidateSSRF } from './ssrf-guard'; +import { ssrfPinnedFetch } from './ssrf-dispatcher'; import type { WebFetchTool } from './web-fetch'; // ===== 类型定义 ===== @@ -330,6 +333,10 @@ function parse360Regex(html: string): SearchResult[] { } // ===== 可达性预检 ===== +// v0.7.3 P2-1: 可达性预检的 URL 来自不可信的搜索结果 —— HEAD 探测同样不得 +// 触达内网/元数据地址。校验失败的 URL 直接标记不可达(不发起任何请求); +// 探测经 ssrfPinnedFetch(DNS pinning),重定向不自动跟随(3xx 即视为可达 —— +// 链接活性已证明,且跟跳目标不再绕过校验)。 async function checkReachability(urls: string[], concurrency = 5): Promise> { const result = new Map(); @@ -337,8 +344,13 @@ async function checkReachability(urls: string[], concurrency = 5): Promise { try { - const resp = await fetchWithTimeout(url, { method: 'HEAD', redirect: 'follow' }, 3_000); - result.set(url, resp.ok); + const ssrf = await safeValidateSSRF(url); + if (!ssrf.ok) { + result.set(url, false); + return; + } + const resp = await ssrfPinnedFetch(url, { method: 'HEAD', redirect: 'manual' }, 3_000); + result.set(url, resp.ok || (resp.status >= 300 && resp.status < 400)); } catch { result.set(url, false); } diff --git a/electron/harness/types/metona-adapter.ts b/electron/harness/types/metona-adapter.ts index 1f957c4..8df57d0 100644 --- a/electron/harness/types/metona-adapter.ts +++ b/electron/harness/types/metona-adapter.ts @@ -30,6 +30,13 @@ export interface MetonaModelInfo { supportsToolCalling?: boolean; /** 是否支持 Thinking / Reasoning */ supportsThinking?: boolean; + /** + * 是否支持视觉(图片输入)— v0.7.3 P1-4。 + * undefined 表示未知(调用方保守放行,保持可用性); + * Ollama /api/show capabilities 探测成功时为真实布尔值, + * 供前端上传入口拒绝不支持图片的本地语言模型。 + */ + supportsVision?: boolean; /** 模型描述 */ description?: string; } diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts index 07c7004..4a84da7 100644 --- a/electron/ipc/__tests__/agent.test.ts +++ b/electron/ipc/__tests__/agent.test.ts @@ -37,7 +37,8 @@ import type { MetonaMessage } from '../../harness/types'; function makeEngineMock(overrides: Record = {}) { return { runStream: vi.fn().mockResolvedValue({ - finalAnswer: '这是最终回答', + // v0.7.3 P1-5: 固化门控要求回答 >= minChars(默认 200)—— mock 回答扩到阈值之上 + finalAnswer: '这是最终回答' + '补充细节。'.repeat(60), terminationReason: 'completed', iterations: [ { @@ -140,6 +141,8 @@ function makeCtx(overrides: Record = {}) { // v0.5.1: abortByParent 返回 taskId[](abortSession 据此清理 SubAgent pending 确认) orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }), confirmationHook: { clearPending: vi.fn() }, + // v0.7.3 P4-1: 会话标题生成器接线(sendMessage 完成路径消费) + titleGenerator: { maybeGenerateTitle: vi.fn().mockResolvedValue(null) }, reloadAdapter: vi.fn(() => true), ...overrides, }; @@ -266,10 +269,13 @@ describe('agent:sendMessage — 成功路径', () => { sessionId: 'sess_1', }), ); - // 2. 引擎启动(每会话引擎) + // 2. 引擎启动(每会话引擎)—— v0.7.3 P1-1: 引擎收到带上下文前置块的消息副本 expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1'); expect(engine.runStream).toHaveBeenCalledWith( - VALID_MESSAGE, + expect.objectContaining({ + role: 'user', + content: expect.stringContaining(String(VALID_MESSAGE.content)), + }), 'sess_1', [], expect.objectContaining({ roleDefinition: 'role' }), @@ -292,7 +298,10 @@ describe('agent:sendMessage — 成功路径', () => { ); expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); // 7. 输出验证执行 - expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith('这是最终回答', expect.anything()); + expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith( + expect.stringContaining('这是最终回答'), + expect.anything(), + ); // 8. 摘要评估(异步触发) await vi.waitFor(() => expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'), @@ -301,7 +310,7 @@ describe('agent:sendMessage — 成功路径', () => { await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled()); }); - it('注入相关记忆到 System Prompt 动态区', async () => { + it('注入相关记忆到用户消息上下文前置块(P1-1:system 保持缓存稳定)', async () => { const { ctx, ctxRaw } = makeCtx({ memoryManager: { search: vi.fn(() => [ @@ -321,9 +330,16 @@ describe('agent:sendMessage — 成功路径', () => { await handler(null, VALID_MESSAGE, 'sess_1'); expect(ctxRaw.memoryManager.search).toHaveBeenCalled(); - // 引擎收到的 systemPrompt 应包含记忆块 + // P1-1: 记忆注入迁移到首条 user 消息前置块(system 跨 run 字节稳定 → 缓存命中) const prompt = engine_runStreamPrompt(ctxRaw); - expect(prompt.dynamicReminders).toContain('用户偏好深色主题'); + expect(prompt.dynamicReminders).not.toContain('用户偏好深色主题'); + const userMessage = engine_runStreamUserMessage(ctxRaw); + expect(userMessage.content).toContain('[Contextual information for this message'); + expect(userMessage.content).toContain('用户偏好深色主题'); + // DB 持久化仍使用原始干净内容(前置块只存在于引擎副本) + expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: VALID_MESSAGE.content }), + ); }); it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => { @@ -406,6 +422,14 @@ describe('agent:abortSession — 中断编排', () => { }); }); +/** 从 runStream 调用参数中提取首条用户消息(P1-1 前置块断言用) */ +function engine_runStreamUserMessage(ctxRaw: Record): { content: string } { + const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as { + runStream: Mock; + }; + return engine.runStream.mock.calls[0][0]; +} + /** 从 runStream 调用参数中提取 systemPrompt */ function engine_runStreamPrompt(ctxRaw: Record): { roleDefinition: string; diff --git a/electron/ipc/__tests__/sessions-tools-config.test.ts b/electron/ipc/__tests__/sessions-tools-config.test.ts index 7e346ec..16d4edc 100644 --- a/electron/ipc/__tests__/sessions-tools-config.test.ts +++ b/electron/ipc/__tests__/sessions-tools-config.test.ts @@ -65,7 +65,7 @@ describe('sessions 域 — 参数校验矩阵', () => { getMessages: vi.fn(() => []), pin: vi.fn(() => true), archive: vi.fn(() => true), - deleteMessage: vi.fn(() => true), + // v0.7.3 P1-3: sessions:deleteMessage 死通道已删除,service 方法一并移除 clearMessages: vi.fn(), truncateMessagesAfter: vi.fn(() => true), searchMessages: vi.fn(() => []), diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts index fc11f06..0b7ed25 100644 --- a/electron/ipc/agent.ts +++ b/electron/ipc/agent.ts @@ -19,8 +19,24 @@ import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types'; import { estimateMessagesTokens } from '../harness/utils/token-estimator'; import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter'; import { OllamaAdapter } from '../harness/adapters/ollama.adapter'; +// v0.7.3 P1-1: 用户上下文前置块(动态内容出 system,保 prompt cache 前缀稳定) +import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompts/user-context'; +// v0.7.3 P1-5: 记忆固化触发决策(纯函数) +import { shouldConsolidate } from '../harness/memory/consolidation-policy'; import log from 'electron-log'; +/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */ +function buildTimezoneLabel(): string { + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; + const offset = -new Date().getTimezoneOffset() / 60; + const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`; + return `${tz} (${offsetStr})`; + } catch { + return 'UTC'; + } +} + /** 单会话的 text_delta 节流状态 */ interface ThrottleState { buffer: string; @@ -56,12 +72,16 @@ export function registerAgentHandlers(ctx: IPCContext): void { sessionSummaryService, orchestrator, confirmationHook, + titleGenerator, } = ctx; // ===== 常驻事件管道:text_delta 按会话节流(F8) ===== const throttleStates = new Map(); const iterationTraces = new Map(); + // v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方) + const lastConsolidationBySession = new Map(); + const flushThrottle = (sessionId: string): void => { const st = throttleStates.get(sessionId); if (!st) return; @@ -411,59 +431,46 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); } - // 检索与用户消息相关的记忆,注入到 System Prompt 动态区 + // 检索与用户消息相关的记忆 + 附件元信息 → 构建用户消息上下文前置块。 + // v0.7.3 P1-1 根治: 记忆注入与附件提示此前追加进 systemPrompt.dynamicReminders, + // 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入 + // (LLM 语义等价),system prompt 保持跨 run 字节级稳定。 + let userContextPrefix = ''; try { const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 }); + const attachments = ( + userMessage as MetonaMessage & { + attachments?: Array<{ name: string; type: string; truncated?: boolean }>; + } + ).attachments; + userContextPrefix = buildUserContextPrefix({ + now: Date.now(), + memories, + attachments: Array.isArray(attachments) ? attachments : [], + timezoneLabel: buildTimezoneLabel(), + }); if (memories.length > 0) { - const memorySection = memories - .map( - (m, i) => - `[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`, - ) - .join('\n'); - const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`; - systemPrompt.dynamicReminders = systemPrompt.dynamicReminders - ? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}` - : memoryBlock; - log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`); + log.debug(`[AGENT] Injected ${memories.length} memories into user context prefix`); } } catch (err) { log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err); + // 记忆检索失败时前置块退化为仅含日期时间(附件提示随之丢失可接受—— + // 主进程附件提示是辅助语义,附件内容本体仍在消息中) + userContextPrefix = buildUserContextPrefix({ + now: Date.now(), + memories: [], + attachments: [], + timezoneLabel: buildTimezoneLabel(), + }); } - // 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找 - const attachments = ( - userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> } - ).attachments; - if (Array.isArray(attachments) && attachments.length > 0) { - const attachmentList = attachments - .map((att, i) => { - const typeLabel = - att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file'; - // v0.7.2 A5: 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整, - // 防止模型把残缺内容当作完整文件事实 - const truncatedNote = - (att as { truncated?: boolean }).truncated === true - ? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)' - : ''; - const note = - att.type === 'image' - ? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again' - : att.type === 'text' - ? `content${truncatedNote} already inlined in the user message, do NOT search in workspace or read it again` - : 'uploaded directly by user, do NOT search in workspace'; - return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`; - }) - .join('\n'); - - const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`; - - systemPrompt.dynamicReminders = systemPrompt.dynamicReminders - ? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}` - : attachmentBlock; - - log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`); - } + // 构建发送给引擎的用户消息副本 —— 前置块只存在于该副本: + // DB 持久化(上方 saveMessage 已用原始内容)、前端展示、记忆固化、 + // 注入检测均使用原始干净内容,互不污染。 + const engineUserMessage: MetonaMessage = { + ...userMessage, + content: withUserContextPrefix(userContextPrefix, userMessage.content), + }; try { // 提示注入检测(安全模块) @@ -503,8 +510,9 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); // 启动 Agent Loop(P2-10: 每会话独立引擎) + // v0.7.3 P1-1: 传入带上下文前置块的消息副本 —— 原始 userMessage 保持干净 const engine = agentEngineManager.getEngine(sessionId); - const output = await engine.runStream(userMessage, sessionId, history, systemPrompt); + const output = await engine.runStream(engineUserMessage, sessionId, history, systemPrompt); // 输出验证(不阻塞响应,仅记录警告) // v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测 @@ -624,23 +632,58 @@ export function registerAgentHandlers(ctx: IPCContext): void { workspaceService.updateMemoryTimestamp(); // 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md - // 异步执行,不阻塞主流程返回;失败仅记录日志 - memoryConsolidator - .consolidate(userMessage.content, output.finalAnswer, output.iterations) - .then((result) => { - if (result.appended > 0) { - log.info( - `[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`, - ); - broadcast('toast:show', { - type: 'info', - message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`, - }); - } - }) - .catch((err) => { - log.warn('[AGENT] Memory consolidation failed:', err); - }); + // v0.7.3 P1-5 节流: 此前每次 run 无条件发起固化 LLM 请求,短寒暄同样触发。 + // 现按 consolidation-policy 决策(总开关 + 内容门控 + 会话级频率窗口)触发; + // 异步执行不阻塞主流程返回;失败仅记录日志。 + const lastConsolidationAt = lastConsolidationBySession.get(sessionId) ?? 0; + const hadSuccessfulToolCall = output.iterations.some((step) => + (step.toolResults ?? []).some((r) => r.success), + ); + const decision = shouldConsolidate({ + enabled: configService.get('memory.consolidationEnabled') !== false, + answerChars: output.finalAnswer?.length ?? 0, + minChars: configService.get('memory.consolidationMinChars') ?? 200, + hadSuccessfulToolCall, + lastConsolidationAt, + now: Date.now(), + intervalMs: configService.get('memory.consolidationIntervalMs') ?? 600_000, + }); + if (decision.consolidate) { + memoryConsolidator + .consolidate(userMessage.content, output.finalAnswer, output.iterations) + .then((result) => { + if (result.appended > 0) { + lastConsolidationBySession.set(sessionId, Date.now()); + log.info( + `[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`, + ); + broadcast('toast:show', { + type: 'info', + message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`, + }); + } + }) + .catch((err) => { + log.warn('[AGENT] Memory consolidation failed:', err); + }); + } else { + log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`); + } + + // v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默) + if (output.terminationReason === 'completed') { + titleGenerator + .maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer) + .then((title) => { + if (title) { + // 广播重命名结果,前端 Sidebar 实时刷新标题 + broadcast('config:changed', { key: `session.title.${sessionId}`, value: title }); + } + }) + .catch(() => { + /* 静默 —— 标题失败已有 debug 日志 */ + }); + } // TOOL 层:记录会话结束 / TRACE 层:停止录制 auditService.logSessionEnd({ @@ -832,8 +875,9 @@ export function registerAgentHandlers(ctx: IPCContext): void { // v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认 confirmationHook.clearPending(sessionId); // v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务) + // v0.7.3 P2-3: 被中止的 SubAgent 是会话终态 —— 用 forgetSession 连决策记忆一并清理 for (const taskId of abortedTaskIds) { - confirmationHook.clearPending(taskId); + confirmationHook.forgetSession(taskId); } // TOOL 层:记录中断 @@ -880,6 +924,8 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); subTraces.delete(taskId); subMeta.delete(taskId); + // v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏) + confirmationHook.forgetSession(taskId); }; orchestrator.on( diff --git a/electron/ipc/app.ts b/electron/ipc/app.ts index 7a62cb4..e590b45 100644 --- a/electron/ipc/app.ts +++ b/electron/ipc/app.ts @@ -34,6 +34,34 @@ export function registerAppHandlers(ctx: IPCContext): void { return result; }); + // ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)===== + ipcMain.handle('app:healthSnapshot', async () => { + try { + return { success: true, data: ctx.getHealthSnapshot() }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + // ===== v0.7.3 P3-3: JSONL 录制文件统计与清理(设置页展示 + 手动清理)===== + ipcMain.handle('logs:traceStats', async () => { + try { + return { success: true, data: ctx.sessionRecorder.getRecordingStats() }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('logs:pruneTraceFiles', async () => { + try { + const deleted = ctx.sessionRecorder.pruneOldRecordings(); + log.info(`[LOGS] Manual trace prune: ${deleted} file(s) removed`); + return { success: true, data: { deleted } }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + ipcMain.handle('app:openExternal', async (_event, url: unknown) => { // M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议 if (typeof url !== 'string' || !url) { diff --git a/electron/ipc/context.ts b/electron/ipc/context.ts index 6dffc4a..128be1c 100644 --- a/electron/ipc/context.ts +++ b/electron/ipc/context.ts @@ -23,6 +23,9 @@ import type { ConfirmationHook } from '../harness/hooks/confirmation-hook'; import type { MemoryConsolidator } from '../harness/memory/consolidator'; import type { TaskOrchestrator } from '../harness/orchestration/orchestrator'; import type { SessionSummaryService } from '../services/session-summary.service'; +import type { TitleGenerator } from '../services/title-generator.service'; +import type { HealthChecker } from '../utils/slo'; +import type { SLOMonitor } from '../utils/slo'; /** 工具就绪状态(main.ts 在 MCP 初始化完成后更新,tools.ts 的 isReady 查询读取) */ export interface ToolsReadyRef { @@ -30,6 +33,14 @@ export interface ToolsReadyRef { toolCount: number; } +/** v0.7.3 P3-4: 健康快照载荷(app:healthSnapshot 返回) */ +export interface HealthSnapshot { + slo: ReturnType; + /** 最近一次健康检查报告;null 表示应用启动后尚未执行过检查(周期 60s) */ + health: Awaited> | null; + generatedAt: number; +} + export interface IPCContext { mainWindow: BrowserWindow; sessionService: SessionService; @@ -49,6 +60,10 @@ export interface IPCContext { memoryConsolidator: MemoryConsolidator; orchestrator: TaskOrchestrator; sessionSummaryService: SessionSummaryService; + /** v0.7.3 P4-1: 会话标题生成器 */ + titleGenerator: TitleGenerator; + /** v0.7.3 P3-4: 健康快照读取器(SLO + 最近健康检查报告) */ + getHealthSnapshot: () => HealthSnapshot; toolsReadyRef: ToolsReadyRef; } diff --git a/electron/ipc/sessions.ts b/electron/ipc/sessions.ts index 6d5e15a..1532e8e 100644 --- a/electron/ipc/sessions.ts +++ b/electron/ipc/sessions.ts @@ -13,7 +13,7 @@ const isValidSessionId = (id: unknown): id is string => typeof id === 'string' && id.length > 0 && id.length <= 200; export function registerSessionHandlers(ctx: IPCContext): void { - const { sessionService } = ctx; + const { sessionService, confirmationHook } = ctx; ipcMain.handle('sessions:list', async () => { return sessionService.list(); @@ -34,7 +34,13 @@ export function registerSessionHandlers(ctx: IPCContext): void { ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => { // M-34 修复: 校验 sessionId if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; - return { success: sessionService.delete(sessionId) }; + const result = { success: sessionService.delete(sessionId) }; + // v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理 + // (防 rememberedDecisions 随会话数累积泄漏) + if (result.success) { + confirmationHook.forgetSession(sessionId); + } + return result; }); ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => { @@ -55,11 +61,9 @@ export function registerSessionHandlers(ctx: IPCContext): void { return { success: sessionService.archive(sessionId, archived) }; }); - ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => { - if (typeof messageId !== 'string' || !messageId) - return { success: false, error: 'Invalid messageId' }; - return { success: sessionService.deleteMessage(messageId) }; - }); + // v0.7.3 P1-3: sessions:deleteMessage 已删除 —— 渲染层零调用方(死通道), + // 且原实现不回减 sessions.message_count(计数漂移面)。消息删除语义由 + // sessions:truncateAfter(编辑重发/重新生成)与会话删除级联完整覆盖。 // v0.7.2 A1 根治: 语义修正 —— "清空会话"的成功判定是"操作完成"而非"有行被删除"。 // 原实现透传 DELETE 影响行数(changes > 0),空会话清空会返回 success:false, diff --git a/electron/ipc/shared.ts b/electron/ipc/shared.ts index 9257420..d2ceef0 100644 --- a/electron/ipc/shared.ts +++ b/electron/ipc/shared.ts @@ -101,6 +101,11 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', }); break; + // v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关 + case 'agent.enableReflection': + agentEngineManager.updateConfigAll({ enableReflection: value === true }); + orchestrator.updateDefaultConfig({ enableReflection: value === true }); + break; case 'agent.toolExecutionTimeoutMs': agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number }); break; @@ -202,5 +207,11 @@ export async function applyConfigSideEffects( await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null); } + // v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连) + const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect'); + if (autoReconnectEntry) { + ctx.mcpManager.setAutoReconnect(autoReconnectEntry.value !== false); + } + return null; } diff --git a/electron/main.ts b/electron/main.ts index e04fda0..74c560e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -32,6 +32,7 @@ import { WindowManager } from './services/window-manager.service'; import { MCPManager } from './services/mcp-manager.service'; import { SessionSummaryService } from './services/session-summary.service'; import { AgentEngineManager } from './services/agent-engine-manager.service'; +import { TitleGenerator } from './services/title-generator.service'; import { ContextBuilder } from './harness/prompts/context-builder'; import { MemoryManager } from './harness/memory/manager'; import { MemoryConsolidator } from './harness/memory/consolidator'; @@ -317,6 +318,8 @@ async function initialize(): Promise { const traceEnabled = configService.get('logging.traceEnabled') !== false; // SessionRecorder 录制总开关(false 时不写 TRACE JSONL 文件) sessionRecorder.setEnabled(traceEnabled); + // v0.7.3 P3-3: 启动期清理旧 JSONL 录制文件(按 mtime 保留最近 200 个) + void sessionRecorder.pruneOldRecordings(); // ===== v0.2.0: 安全模块(必须在工具注册之前) ===== const policyEngine = new PolicyEngine(); @@ -426,6 +429,9 @@ async function initialize(): Promise { | 'max' | null) ?? 'high', toolExecutionTimeoutMs: configService.get('agent.toolExecutionTimeoutMs') ?? 120_000, + // v0.7.3 P3-1 接线: agent.enableReflection 此前为死配置(引擎读取但全链路 + // 无置 true 路径)。现注入引擎基线配置,REFLECTING 状态由此开关真实驱动。 + enableReflection: configService.get('agent.enableReflection') === true, // F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488) temperature: configService.get('llm.temperature') ?? 0.0, maxTokens: configService.get('llm.maxTokens') ?? 63488, @@ -490,6 +496,8 @@ async function initialize(): Promise { mcpManager.setOnToolsChanged(() => { agentEngineManager.setToolsAll(toolRegistry.listTools()); }); + // v0.7.3 P4-2: MCP 自动重连开关(默认开启;配置变更经 shared.ts applyConfigSideEffects 联动) + mcpManager.setAutoReconnect(configService.get('mcp.autoReconnect') !== false); const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 }; mcpManager .initialize() @@ -542,6 +550,8 @@ async function initialize(): Promise { | null) ?? 'high', contextLength: ollamaNumCtx ?? undefined, contextWindow: buildAdapter().getContextWindow(), + // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection + enableReflection: configService.get('agent.enableReflection') === true, }, ); toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator)); @@ -647,6 +657,56 @@ async function initialize(): Promise { } }; + // ===== P1-12 + v0.7.3 P3-4: SLO 健康监控接入(提前至窗口创建前, + // 使 IPC 侧 getHealthSnapshot 闭包可安全引用 sloMonitor/healthChecker) ===== + const healthChecker = new HealthChecker( + () => db, + join(workspaceInfo.path, '.metona', 'agent.db'), + ); + const sloMonitor = new SLOMonitor(); + agentEngineManager.on('complete', (data: { durationMs: number; terminationReason?: string }) => { + sloMonitor.recordRequest(data.durationMs, data.terminationReason === 'completed'); + }); + /** 最近一次健康检查报告(供 IPC app:healthSnapshot 展示;初始 null = 尚未检查) */ + let lastHealthReport: Awaited> | null = null; + const healthTimer = setInterval(async () => { + try { + // v0.7.3 P3-1 接线: cleanupExpired 此前零调用方(且 expires_at 无写入方)双重死路。 + // 现挂入健康检查周期,让过期记忆(episodic_memories.expires_at)真正可回收, + // 为后续记忆 TTL 语义预留真实管道;无过期条目时为零开销(一条带索引的 DELETE)。 + const expired = memoryManager.cleanupExpired(); + if (expired > 0) { + log.info(`[Health] Expired episodic memories purged: ${expired}`); + } + const report = await healthChecker.check(); + lastHealthReport = report; + if (!report.healthy) { + const failed = report.checks + .filter((c) => !c.healthy) + .map((c) => c.name) + .join(', '); + log.warn(`[Health] Unhealthy checks: ${failed}`); + trayManager?.setStatus('error'); + } else { + // 恢复 idle(运行中状态会被后续 stateChange 覆盖) + trayManager?.setStatus('idle'); + } + const slo = sloMonitor.getStatus(); + if (slo.violated) { + log.warn( + `[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` + + `P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`, + ); + } + } catch (err) { + log.warn('[Health] Check failed:', err); + } + }, 60_000); + healthTimer.unref?.(); + + // ===== v0.7.3 P4-1: 会话标题生成器(首轮 run 完成后 LLM 生成精炼标题) ===== + const titleGenerator = new TitleGenerator(() => agentEngineManager.getAdapter(), sessionService); + // ===== 窗口管理 ===== windowManager = new WindowManager(); // 局部 const 捕获:闭包内 TS 无法对模块级可空变量做流收窄 @@ -682,6 +742,12 @@ async function initialize(): Promise { memoryConsolidator, orchestrator, sessionSummaryService, + titleGenerator, + getHealthSnapshot: () => ({ + slo: sloMonitor.getStatus(), + health: lastHealthReport, + generatedAt: Date.now(), + }), toolsReadyRef, }); }, @@ -733,42 +799,6 @@ async function initialize(): Promise { }, ); - // ===== P1-12: SLO 健康监控接入(原为死代码,现真实运行) ===== - const healthChecker = new HealthChecker( - () => db, - join(workspaceInfo.path, '.metona', 'agent.db'), - ); - const sloMonitor = new SLOMonitor(); - agentEngineManager.on('complete', (data: { durationMs: number; terminationReason?: string }) => { - sloMonitor.recordRequest(data.durationMs, data.terminationReason === 'completed'); - }); - const healthTimer = setInterval(async () => { - try { - const report = await healthChecker.check(); - if (!report.healthy) { - const failed = report.checks - .filter((c) => !c.healthy) - .map((c) => c.name) - .join(', '); - log.warn(`[Health] Unhealthy checks: ${failed}`); - trayManager?.setStatus('error'); - } else { - // 恢复 idle(运行中状态会被后续 stateChange 覆盖) - trayManager?.setStatus('idle'); - } - const slo = sloMonitor.getStatus(); - if (slo.violated) { - log.warn( - `[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` + - `P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`, - ); - } - } catch (err) { - log.warn('[Health] Check failed:', err); - } - }, 60_000); - healthTimer.unref?.(); - // ===== 应用生命周期 ===== app.on('window-all-closed', () => { // macOS: 保持应用运行(托盘模式) diff --git a/electron/preload.ts b/electron/preload.ts index 62bdc61..566bf4d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -57,7 +57,9 @@ const metonaAPI = { ipcRenderer.invoke('sessions:pin', sessionId, pinned), archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived), - deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId), + // v0.7.3 P1-3: sessions:deleteMessage 死通道已删除 —— 渲染层零调用方, + // 且其实现不回减 message_count(计数漂移面)。消息删除语义由 + // sessions:truncateAfter(编辑重发/重新生成/会话删除级联)完整覆盖。 clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId), /** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */ truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) => @@ -232,6 +234,49 @@ const metonaAPI = { // #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志) // 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject reportError: (payload: unknown) => ipcRenderer.send('error:report', payload), + // v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告) + // 类型以内联形态声明(本文件在 tsconfig.node 域,不引用渲染端 global.d.ts) + getHealthSnapshot: () => + ipcRenderer.invoke('app:healthSnapshot') as Promise<{ + success: boolean; + error?: string; + data?: { + slo: { + errorRate: number; + throughput: number; + avgLatencyMs: number; + percentiles: Record; + burnRate: number; + target: number; + violated: boolean; + totalRequests: number; + errorRequests: number; + timestamp: string; + }; + health: { + healthy: boolean; + checks: Array<{ name: string; healthy: boolean; latencyMs?: number; error?: string }>; + timestamp: string; + } | null; + generatedAt: number; + }; + }>, + }, + + // ===== v0.7.3 P3-3: JSONL 录制文件生命周期(设置页展示/清理) ===== + logs: { + traceStats: () => + ipcRenderer.invoke('logs:traceStats') as Promise<{ + success: boolean; + error?: string; + data?: { count: number; totalBytes: number }; + }>, + pruneTraceFiles: () => + ipcRenderer.invoke('logs:pruneTraceFiles') as Promise<{ + success: boolean; + error?: string; + data?: { deleted: number }; + }>, }, // ===== 工作空间管理 ===== diff --git a/electron/services/__tests__/mcp-reconnect.test.ts b/electron/services/__tests__/mcp-reconnect.test.ts new file mode 100644 index 0000000..7a50fed --- /dev/null +++ b/electron/services/__tests__/mcp-reconnect.test.ts @@ -0,0 +1,137 @@ +/** + * MCP 自动重连测试(v0.7.3 P4-2) + * + * R1 nextRetryDelayMs 退避序列(5s/15s/60s,越界钳制); + * R2 连接失败 → 进入 reconnecting 并按退避排程;fake timers 推进后真实重试; + * R3 重试耗尽(3 次)→ 停留 error 且不再排程; + * R4 disconnectServer(用户显式断开)取消重连排程; + * R5 setAutoReconnect(false) 取消全部排程且后续失败不再排程; + * R6 连接成功清零计数与排程(connectServer 成功路径经 spy 模拟)。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { MCPManager, MAX_RECONNECT_ATTEMPTS, nextRetryDelayMs } from '../mcp-manager.service'; +import type { MCPServerConfig } from '../mcp-manager.service'; +import type { ToolRegistry } from '../../harness/tools/registry'; + +function makeManager(): MCPManager { + const fakeDB = { + prepare: () => ({ run: () => ({ changes: 0 }) }), + } as unknown as ConstructorParameters[0] extends () => infer D ? D : never; + const registry = { + registerMCP: vi.fn(() => true), + unregisterMCPTools: vi.fn(), + } as unknown as ToolRegistry; + return new MCPManager(() => fakeDB, registry); +} + +const FAILING_CONFIG: MCPServerConfig = { + id: 'mcp_test', + name: 'broken-server', + // stdio 但缺 command —— connectServer 在构造 transport 前显式抛错(不触网、不 spawn) + transport: 'stdio', + enabled: true, +}; + +describe('nextRetryDelayMs', () => { + it('R1: 退避序列 5s/15s/60s,越界钳制', () => { + expect(nextRetryDelayMs(1)).toBe(5_000); + expect(nextRetryDelayMs(2)).toBe(15_000); + expect(nextRetryDelayMs(3)).toBe(60_000); + expect(nextRetryDelayMs(4)).toBe(60_000); // 越界钳制到最后一档 + expect(nextRetryDelayMs(0)).toBe(5_000); // 下界钳制 + }); +}); + +describe('MCPManager 自动重连', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('R2: 连接失败 → reconnecting 状态 + 定时排程;推进后真实重试', async () => { + const manager = makeManager(); + const connectSpy = vi.spyOn(manager, 'connectServer'); + + await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); + // 第一次失败后:计数 1,状态 reconnecting,已排程 + expect(manager.getReconnectInfo('broken-server')).toMatchObject({ + attempts: 1, + scheduled: true, + }); + expect(manager.getServerState('broken-server')?.status).toBe('reconnecting'); + + // 推进 5s → 第二次真实重试(同样失败)→ 计数 2 + connectSpy.mockClear(); + await vi.advanceTimersByTimeAsync(5_000); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(manager.getReconnectInfo('broken-server')).toMatchObject({ + attempts: 2, + scheduled: true, + }); + }); + + it('R3: 重试耗尽(3 次)→ 停留 error 且不再排程', async () => { + const manager = makeManager(); + await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); + + // 第 1 次失败后 + 3 次定时重试(每次再失败) + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(15_000); + await vi.advanceTimersByTimeAsync(60_000); + + const info = manager.getReconnectInfo('broken-server'); + // 耗尽后:排程清空(计数清零后 getReconnectInfo 返回 null 或 scheduled=false)、状态停留 error + expect(info?.scheduled ?? false).toBe(false); + expect(manager.getServerState('broken-server')?.status).toBe('error'); + // 再推进也不再有动作 + const spy = vi.spyOn(manager, 'connectServer'); + await vi.advanceTimersByTimeAsync(600_000); + expect(spy).not.toHaveBeenCalled(); + }); + + it('R4: disconnectServer(用户显式断开)取消重连排程', async () => { + const manager = makeManager(); + await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); + expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true); + + await manager.disconnectServer('broken-server'); + // 簿记清空 → getReconnectInfo 返回 null(等价于无排程) + expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false); + // 状态回落 disconnected + expect(manager.getServerState('broken-server')?.status).toBe('disconnected'); + // 推进时间不再触发重试 + const spy = vi.spyOn(manager, 'connectServer'); + await vi.advanceTimersByTimeAsync(600_000); + expect(spy).not.toHaveBeenCalled(); + }); + + it('R5: setAutoReconnect(false) 取消全部排程,后续失败不再排程', async () => { + const manager = makeManager(); + await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); + expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true); + + manager.setAutoReconnect(false); + expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false); + + // 关闭后再次失败 → 不排程(簿记清空 → getReconnectInfo 返回 null) + await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); + expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false); + expect(manager.getServerState('broken-server')?.status).toBe('error'); + }); + + it('R6: setAutoReconnect(true)(默认)开启语义 —— 失败必排程', async () => { + const manager = makeManager(); + expect(manager.getReconnectInfo('x')).toBeNull(); + await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); + expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true); + expect(MAX_RECONNECT_ATTEMPTS).toBe(3); + }); +}); diff --git a/electron/services/__tests__/session-recorder.test.ts b/electron/services/__tests__/session-recorder.test.ts index ad69daf..6edbea6 100644 --- a/electron/services/__tests__/session-recorder.test.ts +++ b/electron/services/__tests__/session-recorder.test.ts @@ -249,3 +249,61 @@ describe('SessionRecorder — 总开关与缓冲上限', () => { expect(recorder.getFilePath('s1')).toBeNull(); }); }); + +// ===== v0.7.3 P3-3: JSONL 录制文件生命周期(stats + prune) ===== + +describe('SessionRecorder — 录制文件统计与清理(P3-3)', () => { + const writeRecording = async (name: string, ageHours: number): Promise => { + const fs = await import('node:fs'); + const logsDir = join(wsRoot, 'logs'); + if (!existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true }); + const filePath = join(logsDir, name); + fs.writeFileSync(filePath, '{"event":"session_start"}\n', 'utf-8'); + const mtime = new Date(Date.now() - ageHours * 3600_000); + fs.utimesSync(filePath, mtime, mtime); + }; + + it('getRecordingStats:统计 count 与 totalBytes;目录不存在返回零值', async () => { + expect(recorder.getRecordingStats()).toEqual({ count: 0, totalBytes: 0 }); + + await writeRecording('session_s1_2026-01-01.jsonl', 1); + await writeRecording('session_s2_2026-01-02.jsonl', 2); + // 非 session_*.jsonl 命名的文件不受治理(用户自放文件) + await writeRecording('user-notes.txt', 3); + + const stats = recorder.getRecordingStats(); + expect(stats.count).toBe(2); + expect(stats.totalBytes).toBeGreaterThan(0); + }); + + it('pruneOldRecordings:按 mtime 保留最近 N 个,删除其余', async () => { + for (let i = 0; i < 6; i++) { + await writeRecording(`session_s${i}_f.jsonl`, i + 1); // s0 最旧 + } + const deleted = recorder.pruneOldRecordings(3); + expect(deleted).toBe(3); + + const remaining = (readdirSync(join(wsRoot, 'logs')) as string[]).filter((f) => + f.startsWith('session_'), + ); + expect(remaining).toHaveLength(3); + // 保留的应是最新的 3 个(s0/s1/s2 —— 年龄 1/2/3 小时,s0 最新) + for (const keep of ['session_s0_f.jsonl', 'session_s1_f.jsonl', 'session_s2_f.jsonl']) { + expect(remaining).toContain(keep); + } + }); + + it('pruneOldRecordings:仅治理 session_*.jsonl 命名,用户文件不受影响', async () => { + await writeRecording('session_a.jsonl', 100); + await writeRecording('my-data.jsonl', 100); + const deleted = recorder.pruneOldRecordings(0); + expect(deleted).toBe(1); + expect(existsSync(join(wsRoot, 'logs', 'my-data.jsonl'))).toBe(true); + }); + + it('pruneOldRecordings:未超限返回 0 且不删除任何文件', async () => { + await writeRecording('session_x.jsonl', 1); + expect(recorder.pruneOldRecordings(200)).toBe(0); + expect(existsSync(join(wsRoot, 'logs', 'session_x.jsonl'))).toBe(true); + }); +}); diff --git a/electron/services/__tests__/session-truncate-trace.test.ts b/electron/services/__tests__/session-truncate-trace.test.ts new file mode 100644 index 0000000..bfcb3fc --- /dev/null +++ b/electron/services/__tests__/session-truncate-trace.test.ts @@ -0,0 +1,146 @@ +/** + * truncateMessagesAfter × TRACE metadata 联动测试(v0.7.3 P1-2) + * + * 根治契约:编辑重发/重新生成截断消息时,sessions.metadata 中的 traceSteps + * 必须按 startedAt <= 锚点消息 created_at 同步过滤,否则 Trace 面板出现 + * "幽灵步骤"(v0.7.2 A1 只修了 /clear 路径的 metadata 残留)。 + * + * 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式执行; + * 系统 Node 下自动跳过。 + */ + +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; +} + +describe.skipIf(!dbAvailable)('truncateMessagesAfter × TRACE metadata 过滤(P1-2)', () => { + let db: import('better-sqlite3').Database; + // 测试域宽松类型:SessionService 依赖 Electron ABI 的 better-sqlite3 实例, + // 直接持有真实实例即可(同 session-summary.test.ts 的既有写法) + // eslint(next-line 无需禁用:测试文件未启用 no-explicit-any) + let sessionService: any; + + beforeAll(async () => { + const { SessionService } = await import('../session.service'); + db = new Database(':memory:'); + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT DEFAULT '新会话', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + pinned INTEGER DEFAULT 0, + archived INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}' + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + reasoning_content TEXT, + tool_calls TEXT, + tool_result TEXT, + attachments TEXT, + iteration INTEGER, + created_at INTEGER NOT NULL + ); + CREATE TABLE session_summaries ( + session_id TEXT PRIMARY KEY, + summary TEXT NOT NULL, + summarized_until_rowid INTEGER NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + `); + db.prepare( + 'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, 0)', + ).run('s_trace', Date.now(), Date.now()); + sessionService = new SessionService(() => db); + }); + + afterAll(() => { + try { + db?.close(); + } catch { + /* ignore */ + } + }); + + const insertMessage = (id: string, content: string, createdAt: number): number => { + db.prepare( + `INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, 'user', ?, ?)`, + ).run(id, 's_trace', content, createdAt); + const row = db.prepare('SELECT rowid AS rid FROM messages WHERE id = ?').get(id) as { + rid: number; + }; + return row.rid; + }; + + const saveMetadata = (steps: Array<{ runId?: string; startedAt: number }>): void => { + db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?').run( + JSON.stringify({ traceSteps: steps, tokenUsage: { totalTokens: 10 } }), + 's_trace', + ); + }; + + const loadMetadataSteps = (): Array<{ runId?: string; startedAt: number }> => { + const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get('s_trace') as { + metadata: string; + }; + const parsed = JSON.parse(row.metadata) as { + traceSteps?: Array<{ runId?: string; startedAt: number }>; + }; + return parsed.traceSteps ?? []; + }; + + it('截断后:晚于锚点消息的 traceSteps 被过滤,早于/等于的保留', () => { + const anchorTs = 1_000_000; + const anchorId = 'm_anchor'; + insertMessage('m_before', '更早的消息', anchorTs - 10_000); + insertMessage(anchorId, '锚点消息', anchorTs); + insertMessage('m_after', '锚点后的消息', anchorTs + 10_000); + + saveMetadata([ + { runId: 'r_before', startedAt: anchorTs - 9_000 }, + { runId: 'r_anchor', startedAt: anchorTs }, // 锚点触发的 run(将被截) + { runId: 'r_after', startedAt: anchorTs + 11_000 }, + ]); + + const truncated = sessionService.truncateMessagesAfter('s_trace', anchorId, true); + expect(truncated).toBe(true); + + const kept = loadMetadataSteps(); + expect(kept.map((s) => s.runId)).toEqual(['r_before']); + }); + + it('metadata 缺失/损坏时不阻断截断主流程', () => { + db.prepare("UPDATE sessions SET metadata = 'not-json' WHERE id = ?").run('s_trace'); + const anchorTs = 2_000_000; + const anchorId = 'm2'; + insertMessage(anchorId, '锚点消息 2', anchorTs); + insertMessage('m2_after', '锚点 2 之后的消息', anchorTs + 1000); + + expect(() => sessionService.truncateMessagesAfter('s_trace', anchorId, true)).not.toThrow(); + expect(sessionService.truncateMessagesAfter('s_trace', anchorId, true)).toBe(false); // 已被上一句删过,无行可删 + }); + + it('锚点不存在时返回 false 且不触碰 metadata', () => { + saveMetadata([{ runId: 'r_keep', startedAt: Date.now() }]); + expect(sessionService.truncateMessagesAfter('s_trace', 'm_missing', true)).toBe(false); + expect(loadMetadataSteps()).toHaveLength(1); + }); +}); diff --git a/electron/services/__tests__/title-generator.test.ts b/electron/services/__tests__/title-generator.test.ts new file mode 100644 index 0000000..20bd849 --- /dev/null +++ b/electron/services/__tests__/title-generator.test.ts @@ -0,0 +1,157 @@ +/** + * TitleGenerator 测试(v0.7.3 P4-1) + * + * T1 sanitizeTitle 清洗规则矩阵(围栏/引号/自述前缀/空白/截断/空值); + * T2 maybeGenerateTitle 幂等(每会话仅一次); + * T3 已有自定义标题的会话不覆盖; + * T4 LLM 失败/超时静默回退(不抛错,返回 null); + * T5 空输入门控。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { TitleGenerator, sanitizeTitle } from '../title-generator.service'; +import type { IMetonaProviderAdapter, MetonaResponse } from '../../harness/types'; + +describe('sanitizeTitle', () => { + it('T1a: 干净文本原样通过', () => { + expect(sanitizeTitle('修复登录超时')).toBe('修复登录超时'); + }); + + it('T1b: markdown 围栏与列表标记剥离', () => { + expect(sanitizeTitle('```json\n"标题"\n```')).toBe('标题'); + expect(sanitizeTitle('- 修复登录超时')).toBe('修复登录超时'); + expect(sanitizeTitle('# 修复登录超时')).toBe('修复登录超时'); + }); + + it('T1c: 成对包裹引号剥离', () => { + expect(sanitizeTitle('"修复登录超时"')).toBe('修复登录超时'); + expect(sanitizeTitle('“修复登录超时”')).toBe('修复登录超时'); + }); + + it('T1d: 自述前缀剥离(标题:/Title:)', () => { + expect(sanitizeTitle('标题: 修复登录超时')).toBe('修复登录超时'); + expect(sanitizeTitle('Title: Fix login timeout')).toBe('Fix login timeout'); + }); + + it('T1e: 换行折叠为空格、首尾空白去除', () => { + expect(sanitizeTitle('修复\n登录 超时\n')).toBe('修复 登录 超时'); + }); + + it('T1f: 超长截断到 maxLen', () => { + const out = sanitizeTitle('a'.repeat(100), 40); + expect(out?.length).toBe(40); + }); + + it('T1g: 空值/纯符号 → null', () => { + expect(sanitizeTitle('')).toBeNull(); + expect(sanitizeTitle(' ')).toBeNull(); + expect(sanitizeTitle('"""')).toBeNull(); + expect(sanitizeTitle('###')).toBeNull(); + }); +}); + +function makeAdapter(content: string | Error): IMetonaProviderAdapter { + return { + providerId: 'mock', + supportedModels: ['m'], + supportsToolCalling: true, + supportsThinking: false, + getContextWindow: () => 128_000, + send: vi.fn(async (): Promise => { + if (content instanceof Error) throw content; + return { + meta: { requestId: 'r', provider: 'mock', model: 'm', latencyMs: 1, timestamp: Date.now() }, + content, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + finishReason: 'stop' as never, + }; + }), + sendStream: vi.fn(), + setAbortSignal: vi.fn(), + healthCheck: async () => true, + } as unknown as IMetonaProviderAdapter; +} + +function makeSessionService(existing: Array<{ id: string; title: string }> = []) { + // 注意:真实 SessionService.list() 是同步方法(better-sqlite3), + // mock 必须保持同步返回,否则 generate 内的 .find() 拿到 Promise + return { + list: vi.fn(() => existing) as unknown as () => Array<{ id: string; title: string }>, + rename: vi.fn(() => true), + }; +} + +describe('TitleGenerator.maybeGenerateTitle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('T2: 首个 run 生成标题并调用 rename;同会话幂等', async () => { + const svc = makeSessionService([{ id: 's1', title: '新会话' }]); + const gen = new TitleGenerator(() => makeAdapter('修复登录超时'), svc as never); + const title = await gen.maybeGenerateTitle( + 's1', + '帮我看看为什么登录会超时', + '已定位为 token 过期…', + ); + expect(title).toBe('修复登录超时'); + expect(svc.rename).toHaveBeenCalledWith('s1', '修复登录超时'); + + // 第二次调用(同会话)幂等 —— 不再调用 LLM + const second = await gen.maybeGenerateTitle('s1', '再问一次', '再答一次'); + expect(second).toBeNull(); + }); + + it('T3: 已有自定义标题的会话不覆盖', async () => { + const svc = makeSessionService([{ id: 's1', title: '用户手动命名' }]); + const adapterSend = vi.fn(); + const adapter = makeAdapter('不应被使用'); + (adapter.send as ReturnType).mockImplementation(async (...args: unknown[]) => { + adapterSend(...args); + throw new Error('should not be called'); + }); + const gen = new TitleGenerator(() => adapter, svc as never); + const title = await gen.maybeGenerateTitle('s1', '内容', '回答'); + expect(title).toBeNull(); + expect(adapterSend).not.toHaveBeenCalled(); + }); + + it('T4: LLM 失败静默回退(返回 null,不抛错),且同会话不重试', async () => { + const svc = makeSessionService([{ id: 's1', title: '新会话' }]); + const gen = new TitleGenerator(() => makeAdapter(new Error('network down')), svc as never); + await expect(gen.maybeGenerateTitle('s1', 'q', 'a')).resolves.toBeNull(); + await expect(gen.maybeGenerateTitle('s1', 'q2', 'a2')).resolves.toBeNull(); + expect(svc.rename).not.toHaveBeenCalled(); + }); + + it('T5: 空 sessionId / 双向空内容 → 不生成', async () => { + const svc = makeSessionService(); + const adapter = makeAdapter('x'); + const gen = new TitleGenerator(() => adapter, svc as never); + expect(await gen.maybeGenerateTitle('', 'q', 'a')).toBeNull(); + expect(await gen.maybeGenerateTitle('s1', ' ', ' ')).toBeNull(); + }); + + it('T6: 并发重入复用同一 Promise(不重复调用 LLM)', async () => { + const svc = makeSessionService([{ id: 's1', title: '新会话' }]); + const gen = new TitleGenerator(() => makeAdapter('并发标题'), svc as never); + // 使用真实定时器场景下并发触发 + vi.useRealTimers(); + const [a, b] = await Promise.all([ + gen.maybeGenerateTitle('s2', 'q', 'a'), + gen.maybeGenerateTitle('s2', 'q', 'a'), + ]); + expect(a).toBe('并发标题'); + expect(b).toBe('并发标题'); + // rename 仅一次(并发重入复用) + expect(svc.rename).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index 6c7373e..d209861 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -70,6 +70,14 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [ // F-8 接通: promptInjectionDefense 由 main.ts(SecurityScanHook)与 ipc/agent.ts(用户消息检测)消费 { key: 'security.promptInjectionDefense', value: true, category: 'security' }, + // v0.7.3 P1-5: 记忆固化节流(consolidation-policy 消费) + { key: 'memory.consolidationEnabled', value: true, category: 'memory' }, + { key: 'memory.consolidationMinChars', value: 200, category: 'memory' }, + { key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' }, + + // v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试) + { key: 'mcp.autoReconnect', value: true, category: 'mcp' }, + // UI 配置 // F-8 清理: 移除死配置 ui.fontSize / ui.animationMode(无消费者;主题走 localStorage) { key: 'ui.theme', value: 'auto', category: 'ui' }, @@ -106,7 +114,6 @@ export class DatabaseService { */ static readonly SCHEMA_VERSION = 1; - constructor(workspacePath?: string) { const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); const metonaDir = join(baseDir, '.metona'); diff --git a/electron/services/global-config.service.ts b/electron/services/global-config.service.ts index dfaedfd..97cb423 100644 --- a/electron/services/global-config.service.ts +++ b/electron/services/global-config.service.ts @@ -24,7 +24,11 @@ import { join } from 'path'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import log from 'electron-log'; import { CONFIG_DEFAULTS } from './database.service'; -import { decryptConfigValue, encryptConfigValue, isSensitiveConfigKey } from '../utils/secure-config'; +import { + decryptConfigValue, + encryptConfigValue, + isSensitiveConfigKey, +} from '../utils/secure-config'; /** 全局配置文件路径(userData 下,与工作空间无关) */ const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json'); @@ -43,6 +47,8 @@ const GLOBAL_KEY_PREFIXES = [ 'openai.', 'anthropic.', 'onboarding.', + // v0.7.3 P1-5: 记忆固化节流(机器级策略,跨工作空间一致) + 'memory.', ]; /** 判断 key 是否属于全局配置 */ @@ -104,7 +110,9 @@ export class GlobalConfigService { if (existsSync(GLOBAL_CONFIG_FILE)) { const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8'); this.data = JSON.parse(raw) as GlobalConfigData; - log.info(`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`); + log.info( + `[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`, + ); } else { // 确保父目录存在 const dir = join(GLOBAL_CONFIG_FILE, '..'); diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index ad9caca..f941640 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -25,6 +25,8 @@ import type { ToolRegistry } from '../harness/tools/registry'; import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool'; import type { MetonaToolDef } from '../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types'; +// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用) +import { buildSafeChildEnv } from '../utils/safe-env'; // v0.3.0 修复: 安全解析 JSON args,防止数据库中存储了非法 JSON 导致初始化崩溃 /** @visibleForTesting 纯函数,供安全表测直接断言 */ @@ -97,44 +99,14 @@ export function validateMcpCommand(command: string, args: string[]): void { /** * #6 修复 + 审查修复: 构建安全的子进程环境变量 * - * 审查修复: 原白名单方案过于激进,剥离了 MCP Server 运行所需的 npm_config_*、代理变量等, - * 导致 MCP Server 无法启动。改为黑名单方案:剔除包含敏感后缀的变量,保留其余。 - * - * 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量也会被过滤。 + * v0.7.3 P3-2: 实现收敛到 utils/safe-env.ts(buildSafeChildEnv)——与 + * run_command 共用同一黑名单(历史双实现已漂移)。MCP 侧无运行时差异注入。 + * 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量会被过滤; * 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。 */ /** @visibleForTesting 纯函数,供安全表测直接断言 */ export function buildSafeEnv(): Record { - // 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程 - const SENSITIVE_SUFFIXES = [ - '_API_KEY', - '_TOKEN', - '_SECRET', - '_PASSWORD', - '_PASSWD', - '_CREDENTIAL', - '_CREDENTIALS', - '_PRIVATE_KEY', - ]; - // 敏感变量名黑名单(精确匹配) - const SENSITIVE_KEYS = new Set([ - 'DEEPSEEK_API_KEY', - 'AGNES_API_KEY', - 'MIMO_API_KEY', - 'GITEA_PASSWORD', - 'DATABASE_PASSWORD', - ]); - - const env: Record = {}; - for (const [key, val] of Object.entries(process.env)) { - if (!val) continue; - // 跳过敏感变量名 - if (SENSITIVE_KEYS.has(key)) continue; - // 跳过敏感后缀变量 - if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue; - env[key] = val; - } - return env; + return buildSafeChildEnv(); } /** @@ -171,7 +143,29 @@ export function safeParseHeaders( // ===== 类型定义 ===== -export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; +export type MCPServerStatus = + | 'connecting' + | 'connected' + | 'disconnected' + | 'error' + | 'reconnecting'; + +// ===== v0.7.3 P4-2: 自动重连策略常量 ===== + +/** 最大自动重连次数(超过后停留 error 态,等待用户手动 toggle) */ +export const MAX_RECONNECT_ATTEMPTS = 3; + +/** + * 重连退避间隔(毫秒):5s / 15s / 60s。 + * 纯函数 nextRetryDelayMs 消费,表测锁定(vitest fake timers 场景)。 + */ +export const RECONNECT_DELAYS_MS = [5_000, 15_000, 60_000] as const; + +/** @visibleForTesting 纯函数 —— 第 attempt 次(1-based)重试前的等待毫秒数 */ +export function nextRetryDelayMs(attempt: number): number { + const idx = Math.min(Math.max(attempt, 1), RECONNECT_DELAYS_MS.length) - 1; + return RECONNECT_DELAYS_MS[idx]; +} export interface MCPServerConfig { id: string; @@ -261,6 +255,17 @@ class MCPToolAdapter implements IMetonaTool { export class MCPManager { private servers = new Map(); + + // ===== v0.7.3 P4-2: 自动重连状态 ===== + /** 总开关(mcp.autoReconnect,默认 true;main.ts 启动时注入,配置变更联动) */ + private autoReconnect = true; + /** 各 server 的重连定时器(disconnect/shutdown 时必须清理) */ + private reconnectTimers = new Map(); + /** 各 server 已尝试的自动重连次数(成功连接后清零) */ + private reconnectAttempts = new Map(); + /** 待重连的配置快照(重连时从原配置重建连接,避免读 DB 中间态) */ + private reconnectConfigs = new Map(); + /** * 工具集合变更回调(v0.5.3) * @@ -277,6 +282,93 @@ export class MCPManager { private toolRegistry: ToolRegistry, ) {} + // ===== v0.7.3 P4-2: 自动重连 ===== + + /** + * 设置自动重连开关(main.ts 启动时按 mcp.autoReconnect 注入; + * 配置变更经 shared.ts applyConfigSideEffects 联动)。 + * 关闭时立即取消所有已排程的重连并清零计数(用户显式意图优先)。 + */ + setAutoReconnect(enabled: boolean): void { + this.autoReconnect = enabled; + if (!enabled) { + this.cancelAllReconnects(); + } + log.debug(`[MCPManager] autoReconnect = ${enabled}`); + } + + /** 查询某 server 的重连状态(测试与诊断用) */ + getReconnectInfo(name: string): { attempts: number; scheduled: boolean } | null { + const attempts = this.reconnectAttempts.get(name); + const scheduled = this.reconnectTimers.has(name); + if (attempts === undefined && !scheduled) return null; + return { attempts: attempts ?? 0, scheduled }; + } + + /** 取消某 server 的重连排程(用户显式断开/移除时调用) */ + private cancelReconnect(name: string): void { + const timer = this.reconnectTimers.get(name); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(name); + } + this.reconnectAttempts.delete(name); + this.reconnectConfigs.delete(name); + } + + /** 取消全部重连排程(shutdown / 开关关闭时调用) */ + private cancelAllReconnects(): void { + for (const timer of this.reconnectTimers.values()) { + clearTimeout(timer); + } + this.reconnectTimers.clear(); + this.reconnectAttempts.clear(); + this.reconnectConfigs.clear(); + } + + /** + * 连接失败后排程指数退避重连(5s/15s/60s,最多 3 次)。 + * 状态机进入 'reconnecting'(设置页可见);重试耗尽停留 'error'。 + * 仅记住传入配置快照 —— 重连时按原配置重建,不读 DB 中间态。 + */ + private scheduleReconnect(name: string, config: MCPServerConfig): void { + if (!this.autoReconnect) return; + + const attempts = (this.reconnectAttempts.get(name) ?? 0) + 1; + if (attempts > MAX_RECONNECT_ATTEMPTS) { + log.warn( + `[MCPManager] "${name}" reconnect exhausted (${MAX_RECONNECT_ATTEMPTS} attempts) — staying in error state`, + ); + this.reconnectAttempts.delete(name); + this.reconnectConfigs.delete(name); + return; + } + + this.reconnectAttempts.set(name, attempts); + this.reconnectConfigs.set(name, config); + const state = this.servers.get(name); + if (state) state.status = 'reconnecting'; + + const delay = nextRetryDelayMs(attempts); + log.info( + `[MCPManager] "${name}" reconnect scheduled in ${delay / 1000}s (attempt ${attempts}/${MAX_RECONNECT_ATTEMPTS})`, + ); + const timer = setTimeout(() => { + this.reconnectTimers.delete(name); + const snapshot = this.reconnectConfigs.get(name); + if (!snapshot) return; + log.info( + `[MCPManager] "${name}" reconnecting (attempt ${attempts}/${MAX_RECONNECT_ATTEMPTS})`, + ); + void this.connectServer(snapshot).catch(() => { + /* connectServer 失败路径已自行 scheduleReconnect / 记录状态 */ + }); + }, delay); + // 定时器不阻塞应用退出 + timer.unref?.(); + this.reconnectTimers.set(name, timer); + } + /** 注册工具集合变更回调(main.ts 在 AgentEngineManager 创建后注入) */ setOnToolsChanged(callback: () => void): void { this.toolsChangedCallback = callback; @@ -356,10 +448,9 @@ export class MCPManager { async connectServer(config: MCPServerConfig): Promise { const { name } = config; - // 断开已有连接 - if (this.servers.has(name)) { - await this.disconnectServer(name); - } + // 断开已有连接(内部拆除 —— 保留重连簿记,否则重试计数被清零、 + // 退避序列永远停在第 1 次;用户显式断开走 disconnectServer) + await this.teardownConnection(name); this.servers.set(name, { config, @@ -441,6 +532,15 @@ export class MCPManager { state.connectedAt = Date.now(); state.error = undefined; + // v0.7.3 P4-2: 连接成功 —— 清零重连计数并取消排程 + this.reconnectAttempts.delete(name); + this.reconnectConfigs.delete(name); + const pendingTimer = this.reconnectTimers.get(name); + if (pendingTimer) { + clearTimeout(pendingTimer); + this.reconnectTimers.delete(name); + } + // 更新数据库 const db = this.getDB(); db.prepare( @@ -468,6 +568,9 @@ export class MCPManager { `, ).run((error as Error).message, name); + // v0.7.3 P4-2: 失败后排程指数退避自动重连(开关关闭时 no-op) + this.scheduleReconnect(name, config); + log.error(`MCP server "${name}" connection failed:`, error); throw error; } @@ -476,14 +579,15 @@ export class MCPManager { /** * 断开 MCP Server */ - async disconnectServer(name: string): Promise { + /** + * 内部连接拆除(保留重连簿记)—— connectServer 重连前的清理动作。 + * 与 disconnectServer 的区别:不清 reconnectAttempts/Timers/Configs, + * 否则自动重连的每次重试都会把自己的计数清零(退避序列永远停在第 1 次)。 + */ + private async teardownConnection(name: string): Promise { const state = this.servers.get(name); if (!state) return; - - // 从 ToolRegistry 注销 this.toolRegistry.unregisterMCPTools(name); - - // 关闭客户端 if (state.client) { try { await state.client.close(); @@ -491,13 +595,24 @@ export class MCPManager { // 忽略关闭错误 } } - - state.status = 'disconnected'; state.client = null; state.tools = []; - - // v0.5.3: 工具集合已变化 — 通知调用方同步引擎(已有引擎需移除失效工具定义) this.notifyToolsChanged(); + } + + /** + * 断开 MCP Server(用户显式语义:取消重连排程 + 拆除连接) + */ + async disconnectServer(name: string): Promise { + // v0.7.3 P4-2: 用户显式断开/移除 —— 取消重连排程(用户意图优先于自动重试)。 + // 无论是否存在连接态(error/reconnecting 态的 server 也可能被移除)都执行。 + this.cancelReconnect(name); + await this.teardownConnection(name); + + const state = this.servers.get(name); + if (state) { + state.status = 'disconnected'; + } log.info(`MCP server "${name}" disconnected`); } @@ -601,12 +716,16 @@ export class MCPManager { status: MCPServerStatus; toolCount: number; error?: string; + /** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */ + reconnectAttempt?: number; }> { return Array.from(this.servers.values()).map((s) => ({ name: s.config.name, status: s.status, toolCount: s.tools.length, error: s.error, + reconnectAttempt: + s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined, })); } @@ -618,6 +737,7 @@ export class MCPManager { status: MCPServerStatus; toolCount: number; error?: string; + reconnectAttempt?: number; } | null { const state = this.servers.get(name); if (!state) return null; @@ -626,6 +746,8 @@ export class MCPManager { status: state.status, toolCount: state.tools.length, error: state.error, + reconnectAttempt: + state.status === 'reconnecting' ? this.reconnectAttempts.get(state.config.name) : undefined, }; } @@ -633,6 +755,8 @@ export class MCPManager { * 关闭所有连接 */ async shutdown(): Promise { + // v0.7.3 P4-2: 退出前取消全部重连排程(timer 已 unref,此处幂等清理) + this.cancelAllReconnects(); const names = Array.from(this.servers.keys()); await Promise.allSettled(names.map((n) => this.disconnectServer(n))); log.info('MCP Manager shut down'); diff --git a/electron/services/session-recorder.service.ts b/electron/services/session-recorder.service.ts index e22136b..48d74f3 100644 --- a/electron/services/session-recorder.service.ts +++ b/electron/services/session-recorder.service.ts @@ -18,7 +18,15 @@ */ import { join } from 'path'; -import { appendFileSync, existsSync, mkdirSync, promises } from 'fs'; +import { + appendFileSync, + existsSync, + mkdirSync, + promises, + readdirSync, + statSync, + unlinkSync, +} from 'fs'; import log from 'electron-log'; // ===== 事件类型 ===== @@ -265,6 +273,82 @@ export class SessionRecorder { return first?.filePath ?? null; } + // ===== v0.7.3 P3-3: JSONL 录制文件生命周期治理 ===== + + /** JSONL 录制文件名模式(仅治理本服务产出的文件) */ + private static readonly RECORDING_NAME = /^session_.+\.jsonl$/; + + /** + * 统计录制目录中的 JSONL 文件(设置页展示 + 清理前置确认用)。 + * 目录不存在 / 统计失败返回零值(不抛错)。 + */ + getRecordingStats(): { count: number; totalBytes: number } { + try { + const logsDir = join(this.workspacePath, 'logs'); + if (!existsSync(logsDir)) return { count: 0, totalBytes: 0 }; + const names = readdirSync(logsDir).filter((n) => SessionRecorder.RECORDING_NAME.test(n)); + let totalBytes = 0; + for (const name of names) { + try { + totalBytes += statSync(join(logsDir, name)).size; + } catch { + /* 单文件统计失败跳过 */ + } + } + return { count: names.length, totalBytes }; + } catch { + return { count: 0, totalBytes: 0 }; + } + } + + /** + * 清理旧录制文件(按修改时间保留最近 maxFiles 个,默认 200)。 + * + * 背景:workspace/logs/session_*.jsonl 随使用无限累积无任何清理路径。 + * 清理策略:mtime 降序保留前 maxFiles 个,其余删除;仅匹配本服务的 + * session_*.jsonl 命名(用户自放文件不受影响)。启动时(main.ts)与 + * 设置页手动清理共用本方法。 + * + * @returns 实际删除的文件数 + */ + pruneOldRecordings(maxFiles = 200): number { + try { + const logsDir = join(this.workspacePath, 'logs'); + if (!existsSync(logsDir)) return 0; + const entries = readdirSync(logsDir) + .filter((n) => SessionRecorder.RECORDING_NAME.test(n)) + .map((name) => { + try { + return { name, mtime: statSync(join(logsDir, name)).mtimeMs }; + } catch { + return { name, mtime: 0 }; + } + }) + .sort((a, b) => b.mtime - a.mtime); + + if (entries.length <= maxFiles) return 0; + const toDelete = entries.slice(maxFiles); + let deleted = 0; + for (const entry of toDelete) { + try { + unlinkSync(join(logsDir, entry.name)); + deleted++; + } catch { + /* 单文件删除失败(占用中)跳过 */ + } + } + if (deleted > 0) { + log.info( + `[SessionRecorder] Pruned ${deleted} old recording file(s) (kept ${Math.min(maxFiles, entries.length)})`, + ); + } + return deleted; + } catch (err) { + log.warn('[SessionRecorder] pruneOldRecordings failed:', err); + return 0; + } + } + // ===== 私有方法 ===== /** diff --git a/electron/services/session.service.ts b/electron/services/session.service.ts index fba5c1d..d01dc9b 100644 --- a/electron/services/session.service.ts +++ b/electron/services/session.service.ts @@ -247,10 +247,12 @@ export class SessionService { const db = this.getDBFn(); const op = inclusive ? '>=' : '>'; - // 先查锚点 rowid(删除后无法再定位) + // 先查锚点 rowid 与时间戳(删除后无法再定位) const anchor = db - .prepare('SELECT rowid AS rid FROM messages WHERE session_id = ? AND id = ?') - .get(sessionId, messageId) as { rid: number } | undefined; + .prepare( + 'SELECT rowid AS rid, created_at AS ts FROM messages WHERE session_id = ? AND id = ?', + ) + .get(sessionId, messageId) as { rid: number; ts: number } | undefined; if (!anchor) return false; const result = db @@ -270,6 +272,40 @@ export class SessionService { 'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?', ).run(sessionId, anchor.rid + (inclusive ? 0 : 1)); + // v0.7.3 P1-2 根治: 同步截断 metadata 中的 TRACE 步骤。 + // 此前编辑重发/重新生成只删消息 —— traceSteps 残留,Trace 面板出现 + // "幽灵步骤"(与 /clear 的 metadata 残留同类,v0.7.2 A1 只修了 clear 路径)。 + // 截断语义:锚点消息(inclusive 时含锚点)触发的 run 及其之后全部作废 —— + // 按 startedAt < 锚点消息 created_at 过滤保留更早 run 的步骤(严格小于: + // 锚点消息触发的 run 与消息同毫秒落库,等值属于"锚点侧"必须丢弃 —— + // 宁可多删一个边界步骤也不留幽灵步骤)。tokenUsage 为最近一次 run 的 + // 累计展示值,紧随其后的重发 run 会重写,无需修正。 + try { + const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as + | { metadata: string } + | undefined; + if (row?.metadata) { + const data = JSON.parse(row.metadata) as { + traceSteps?: Array<{ startedAt?: number }>; + tokenUsage?: unknown; + }; + if (Array.isArray(data.traceSteps)) { + const kept = data.traceSteps.filter( + (s) => typeof s?.startedAt !== 'number' || s.startedAt < anchor.ts, + ); + if (kept.length !== data.traceSteps.length) { + data.traceSteps = kept; + db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?').run( + JSON.stringify(data), + sessionId, + ); + } + } + } + } catch { + // metadata 解析失败不阻断截断主流程(与截断语义无耦合) + } + log.info( `Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`, ); @@ -355,12 +391,11 @@ export class SessionService { /** * 删除一条消息 + * + * v0.7.3 P1-3: 随 sessions:deleteMessage 死通道一并移除 —— 该方法不回减 + * sessions.message_count(saveMessage 加、删除不加的计数漂移面),且渲染层 + * 从未有调用方。消息删除语义由 truncateMessagesAfter(含计数修正)覆盖。 */ - deleteMessage(messageId: string): boolean { - const db = this.getDBFn(); - const result = db.prepare('DELETE FROM messages WHERE id = ?').run(messageId); - return result.changes > 0; - } /** * 清空会话所有消息 diff --git a/electron/services/title-generator.service.ts b/electron/services/title-generator.service.ts new file mode 100644 index 0000000..ef66198 --- /dev/null +++ b/electron/services/title-generator.service.ts @@ -0,0 +1,200 @@ +/** + * Title Generator — 会话标题 LLM 自动生成(v0.7.3 P4-1) + * + * 背景:首轮消息后前端仅以"用户首条消息截前 30 字符"作为会话标题 + * (agent-store.sendMessage),中文长句体验差且语义压缩生硬。 + * + * 本服务在会话首个完成的 run 之后(terminationReason === 'completed')用主 + * Provider adapter 发起一次极小的非流式请求(maxTokens 32 / temperature 0.3 / + * thinking 关闭),生成 ≤16 字的精炼标题并写回 sessions 表。生成失败(网络 / + * 配额 / 解析失败)静默回退——前端首 30 字符截断标题保持有效,无损可用性。 + * + * 契约: + * - 每个会话生命周期内仅生成一次(内存 Set 幂等,进程重启后自然重置—— + * 已有非默认标题的会话通过 hasCustomTitle 判定跳过,不会重复生成); + * - 标题经 sanitizeTitle 清洗:剥离 markdown/引号/换行/前后缀冒号, + * 折叠空白,超长截断,空结果返回 null(调用方保持原标题不变)。 + */ + +import { nanoid } from 'nanoid'; +import log from 'electron-log'; +import type { IMetonaProviderAdapter, MetonaRequest } from '../harness/types'; +import type { SessionService } from './session.service'; + +/** 生成标题的最大长度(字符)——超过即截断 */ +const MAX_TITLE_LENGTH = 40; + +/** 传给 LLM 的用户消息 / 回答摘录长度 */ +const EXCERPT_LENGTH = 600; + +/** LLM 调用超时(标题生成不应阻塞任何主流程) */ +const TITLE_TIMEOUT_MS = 15_000; + +/** + * 清洗 LLM 返回的标题文本。 + * + * 规则(按序应用): + * 1. 剥离 markdown 代码围栏与首尾 `#`/`-`/`*` 列表标记; + * 2. 剥离成对包裹引号(中英文单双引号); + * 3. 剥离 "标题:"/"Title:" 这类自述前缀; + * 4. 折叠全部空白(含换行)为单个空格并 trim; + * 5. 超过 maxLen 截断; + * 6. 空结果返回 null(调用方保持原标题)。 + */ +export function sanitizeTitle(raw: string, maxLen: number = MAX_TITLE_LENGTH): string | null { + if (!raw || typeof raw !== 'string') return null; + + let title = raw.trim(); + // 1/2. markdown 围栏、列表标记与包裹引号 —— 循环应用直至稳定(处理嵌套包装 + // 如 ```"标题"``` / 多层引号;剥除全部首尾引号字符而非仅成对项, + // 使 '"""' 这类纯符号输入收敛为空 → null) + for (let i = 0; i < 3; i++) { + const before = title; + title = title + .replace(/^```(?:[a-z]*)\s*/i, '') + .replace(/\s*```$/i, '') + .replace(/^[#\-*>]+\s*/, '') + .replace(/^["'“”『』「"]+/, '') + .replace(/["'“”『』「"]+$/, ''); + if (title === before) break; + } + // 3. 自述前缀(标题:/Title:/会话标题: 等) + title = title.replace(/^(?:标题|会话标题|题目|title)\s*[::]\s*/i, ''); + // 4. 折叠空白(换行合并——LLM 偶发多行输出时取首行语义) + title = title.replace(/\s+/g, ' ').trim(); + // 5. 截断 + if (title.length > maxLen) title = title.slice(0, maxLen).trimEnd(); + // 6. 空结果 + return title.length > 0 ? title : null; +} + +export class TitleGenerator { + /** 已生成过标题的会话(进程级幂等) */ + private generated = new Set(); + /** 进行中的生成任务(防并发重复调用) */ + private running = new Map>(); + + constructor( + private getAdapter: () => IMetonaProviderAdapter, + private sessionService: SessionService, + ) {} + + /** + * 为会话生成标题(fire-and-forget 调用;失败静默)。 + * + * @param sessionId 会话 ID + * @param userMessage 用户原始消息(干净版本,不含注入前缀) + * @param assistantAnswer Agent 最终回答 + * @returns 生成的标题(未生成/失败返回 null) + */ + async maybeGenerateTitle( + sessionId: string, + userMessage: string, + assistantAnswer: string, + ): Promise { + if (!sessionId || typeof sessionId !== 'string') return null; + // 输入门控:双向内容均为空无生成意义 + if (!userMessage?.trim() && !assistantAnswer?.trim()) return null; + + // 并发去重先于幂等短路 —— 同一会话进行中的生成必须复用同一 Promise, + // 而不能被"已占位"的幂等判断吞掉(否则并发第二调用拿到 null) + const existing = this.running.get(sessionId); + if (existing) return existing; + // 幂等:每会话仅一次(占位同步完成,先于任何 await) + if (this.generated.has(sessionId)) return null; + + this.generated.add(sessionId); + const task = this.generate(sessionId, userMessage, assistantAnswer).finally(() => { + this.running.delete(sessionId); + }); + this.running.set(sessionId, task); + return task; + } + + private async generate( + sessionId: string, + userMessage: string, + assistantAnswer: string, + ): Promise { + try { + const adapter = this.getAdapter(); + if (!adapter) return null; + + // 已有自定义标题(用户手动重命名 / 前端首条截断标题)时不覆盖, + // 避免用户主动命名被 LLM 标题冲掉 + const sessions = this.sessionService.list(); + const current = sessions.find((s) => s.id === sessionId); + if (current && current.title !== '新会话') { + log.debug(`[TitleGenerator] session ${sessionId} already titled "${current.title}" — skip`); + return null; + } + + const request: MetonaRequest = { + meta: { + sessionId: 'title-generation', + iteration: 0, + requestId: `tg_${nanoid(12)}`, + timestamp: Date.now(), + agentVersion: '1.0.0', + }, + systemPrompt: { + roleDefinition: + 'You generate concise conversation titles for an AI assistant desktop app.', + outputConstraints: + 'Given the first user message and the assistant reply, output ONE title of at most 16 characters ' + + 'in the same language as the user message. The title must capture the core topic or task. ' + + 'No quotes, no markdown, no ending punctuation, no explanations — output the title text ONLY.', + safetyGuidelines: + 'Do not include sensitive data (passwords, keys, personal info) in the title.', + }, + messages: [ + { + role: 'user', + content: + `User message: ${userMessage.slice(0, EXCERPT_LENGTH)}\n\n` + + `Assistant reply: ${assistantAnswer.slice(0, EXCERPT_LENGTH)}\n\n` + + `Output the title only.`, + timestamp: Date.now(), + }, + ], + params: { + maxTokens: 32, + temperature: 0.3, + stream: false, + thinkingEnabled: false, + thinkingEffort: 'low', + }, + }; + + // 超时保护:标题生成绝不能拖慢会话收尾 + let timer: ReturnType | undefined; + try { + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('title generation timeout')), TITLE_TIMEOUT_MS); + }); + const response = await Promise.race([adapter.send(request), timeoutPromise]); + const title = sanitizeTitle(response.content); + if (!title) { + log.debug( + `[TitleGenerator] session ${sessionId}: empty/unsanitizable title, keeping fallback`, + ); + return null; + } + const renamed = this.sessionService.rename(sessionId, title); + if (renamed) { + log.info(`[TitleGenerator] session ${sessionId} titled: "${title}"`); + } + return renamed ? title : null; + } finally { + if (timer) clearTimeout(timer); + } + } catch (err) { + // 静默回退:标题失败不影响会话可用性(前端首条截断标题仍在) + log.debug( + `[TitleGenerator] session ${sessionId} title generation failed:`, + (err as Error).message, + ); + return null; + } + } +} diff --git a/electron/utils/__tests__/safe-env.test.ts b/electron/utils/__tests__/safe-env.test.ts new file mode 100644 index 0000000..4ec3257 --- /dev/null +++ b/electron/utils/__tests__/safe-env.test.ts @@ -0,0 +1,70 @@ +/** + * 子进程环境净化单源测试(v0.7.3 P3-2) + * + * buildSafeChildEnv 是 run_command 与 MCP stdio 启动的唯一净化实现: + * E1 敏感后缀剔除;E2 敏感精确名单剔除;E3 非敏感变量保留(黑名单不误伤); + * E4 runtime 注入覆盖;E5 空值剔除。 + */ + +import { describe, it, expect } from 'vitest'; +import { buildSafeChildEnv } from '../safe-env'; + +const SAMPLE_ENV: Record = { + PATH: '/usr/bin', + LANG: 'zh_CN.UTF-8', + DEEPSEEK_API_KEY: 'sk-secret', + MY_SERVICE_TOKEN: 't', + APP_PASSWORD: 'p', + GITEA_PASSWORD: 'g', + GITHUB_TOKEN: 'gh', + HOME: '/home/u', +}; + +describe('buildSafeChildEnv', () => { + it('E1: 敏感后缀变量全部剔除(_API_KEY/_TOKEN/_PASSWORD/_SECRET/...)', () => { + const env = buildSafeChildEnv({ source: SAMPLE_ENV }); + expect(env['DEEPSEEK_API_KEY']).toBeUndefined(); + expect(env['MY_SERVICE_TOKEN']).toBeUndefined(); + expect(env['APP_PASSWORD']).toBeUndefined(); + expect(env['GITHUB_TOKEN']).toBeUndefined(); + }); + + it('E2: 精确名单剔除(大小写不敏感后缀 + 精确 key)', () => { + const env = buildSafeChildEnv({ source: { GITEA_PASSWORD: 'g', DATABASE_PASSWORD: 'd' } }); + expect(env['GITEA_PASSWORD']).toBeUndefined(); + expect(env['DATABASE_PASSWORD']).toBeUndefined(); + }); + + it('E3: 非敏感变量保留(黑名单不误伤 GIT_*/代理/路径类变量)', () => { + const env = buildSafeChildEnv({ + source: { ...SAMPLE_ENV, GIT_AUTHOR_NAME: 'metona', HTTPS_PROXY: 'http://p:7890' }, + }); + expect(env['PATH']).toBe('/usr/bin'); + expect(env['LANG']).toBe('zh_CN.UTF-8'); + expect(env['HOME']).toBe('/home/u'); + expect(env['GIT_AUTHOR_NAME']).toBe('metona'); + expect(env['HTTPS_PROXY']).toBe('http://p:7890'); + }); + + it('E4: runtime 注入覆盖净化结果(调用方运行时差异显式可控)', () => { + const env = buildSafeChildEnv({ + source: SAMPLE_ENV, + runtime: { NODE_ENV: 'production', LANG: 'C.utf8' }, + }); + expect(env['NODE_ENV']).toBe('production'); + // runtime 覆盖同名的净化保留值 + expect(env['LANG']).toBe('C.utf8'); + }); + + it('E5: 空值/undefined 变量剔除', () => { + const env = buildSafeChildEnv({ source: { EMPTY: '', UNSET: undefined, KEEP: 'x' } }); + expect('EMPTY' in env).toBe(false); + expect('UNSET' in env).toBe(false); + expect(env['KEEP']).toBe('x'); + }); + + it('E6: 无 source 时回退 process.env(运行路径契约;至少保留 PATH 类基本变量)', () => { + const env = buildSafeChildEnv({ runtime: { NODE_ENV: 'production' } }); + expect(env['NODE_ENV']).toBe('production'); + }); +}); diff --git a/electron/utils/network-proxy.ts b/electron/utils/network-proxy.ts index 2cc9b10..ea0fce0 100644 --- a/electron/utils/network-proxy.ts +++ b/electron/utils/network-proxy.ts @@ -27,20 +27,37 @@ import log from 'electron-log'; const AGENT_BROWSER_PARTITION = 'persist:metona-agent-browser'; +/** + * v0.7.3 P2-1: 代理激活标志(模块级状态,由 applySessionProxy 维护)。 + * + * SSRF DNS pinning(ssrf-dispatcher)在此标志为 true 时必须退化为"仅入口校验": + * 代理模式下 DNS 解析发生在代理服务端,本地无法感知/约束最终连接的 IP, + * per-request dispatcher 也会旁路用户配置的 ProxyAgent(破坏代理语义)。 + * 已知边界与 M7 结论一致,显式暴露而非隐藏。 + */ +let proxyActive = false; + +export function isProxyActive(): boolean { + return proxyActive; +} + export async function applySessionProxy(proxyUrl: string | null | undefined): Promise { const envProxy = process.env['HTTPS_PROXY'] || process.env['HTTP_PROXY'] || ''; const rules = (typeof proxyUrl === 'string' ? proxyUrl.trim() : '') || envProxy; + proxyActive = rules !== ''; // ===== 通道一:Chromium sessions ===== try { - const config = rules !== '' ? { proxyRules: rules } : ({ mode: 'direct' as const }); + const config = rules !== '' ? { proxyRules: rules } : { mode: 'direct' as const }; await session.defaultSession.setProxy(config); await session.fromPartition(AGENT_BROWSER_PARTITION).setProxy(config); log.info( `[Network] Chromium sessions proxy ${rules === '' ? 'set to direct (no proxy)' : `rules: ${rules}`}`, ); } catch (err) { - log.warn(`[Network] Failed to apply Chromium session proxy "${rules}": ${(err as Error).message}`); + log.warn( + `[Network] Failed to apply Chromium session proxy "${rules}": ${(err as Error).message}`, + ); } // ===== 通道二:主进程 Node fetch(undici 全局 dispatcher)===== @@ -58,7 +75,9 @@ export async function applySessionProxy(proxyUrl: string | null | undefined): Pr } } catch (err) { // 理论仅在 undici 原生绑定异常时发生;fetch 将保持默认直连行为 - log.warn(`[Network] Failed to set undici dispatcher for proxy "${rules}": ${(err as Error).message}`); + log.warn( + `[Network] Failed to set undici dispatcher for proxy "${rules}": ${(err as Error).message}`, + ); } })(); } diff --git a/electron/utils/safe-env.ts b/electron/utils/safe-env.ts new file mode 100644 index 0000000..abbd8e5 --- /dev/null +++ b/electron/utils/safe-env.ts @@ -0,0 +1,72 @@ +/** + * Safe Child Environment — 子进程环境变量净化(v0.7.3 P3-2 单源收敛) + * + * 背景:run_command(command.ts buildSafeCommandEnv)与 MCP stdio 启动 + * (mcp-manager.service.ts buildSafeEnv)各持有一份几乎相同的"敏感变量黑名单 + * + 全量剔除"实现,任何新增敏感 key 都要改两处(历史上已经漂移过一次: + * command 版多注入了 Windows 运行时变量)。本模块是唯一实现,两个调用方 + * 按需声明运行时差异。 + * + * 净化策略(黑名单方案,#9 修复 + 审查修复的延续): + * - 敏感变量名黑名单(精确匹配)一律剔除; + * - 敏感后缀(_API_KEY/_TOKEN/_SECRET/...)一律剔除; + * - 其余变量保留(白名单方案会破坏 GIT_* 与 PYTHONPATH、代理变量等子进程必需项, + * 见 mcp-manager 历史注释); + * - 调用方通过 `runtime` 注入必需的运行时变量(后者覆盖前者,显式可控)。 + */ + +/** 敏感变量后缀黑名单(大小写不敏感后缀匹配) */ +const SENSITIVE_SUFFIXES = [ + '_API_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_PASSWD', + '_CREDENTIAL', + '_CREDENTIALS', + '_PRIVATE_KEY', +] as const; + +/** 敏感变量名黑名单(精确匹配) */ +const SENSITIVE_KEYS = new Set([ + 'DEEPSEEK_API_KEY', + 'AGNES_API_KEY', + 'MIMO_API_KEY', + 'GITEA_PASSWORD', + 'DATABASE_PASSWORD', +]); + +export interface SafeChildEnvOptions { + /** + * 调用方必需的运行时变量(如 NODE_ENV / PYTHONIOENCODING / LANG)。 + * 在净化后的 process.env 之上覆盖写入。 + */ + runtime?: Record; + /** + * 变量来源(默认 process.env;测试可注入受控快照)。 + */ + source?: Record; +} + +/** + * 构建净化后的子进程环境变量。 + * @see 模块注释 —— 黑名单方案的设计原因与历史漂移教训 + */ +export function buildSafeChildEnv(options: SafeChildEnvOptions = {}): Record { + const source = options.source ?? process.env; + const env: Record = {}; + + for (const [key, val] of Object.entries(source)) { + if (!val) continue; + if (SENSITIVE_KEYS.has(key)) continue; + if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue; + env[key] = val; + } + + // 运行时必需变量(显式注入,覆盖净化结果) + if (options.runtime) { + Object.assign(env, options.runtime); + } + + return env; +} diff --git a/package-lock.json b/package-lock.json index 328d8cc..718c2d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.7.2", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.7.2", + "version": "0.7.3", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index de1f70f..37af067 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.7.2", + "version": "0.7.3", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/App.tsx b/src/App.tsx index c914a35..d06528e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,6 +91,8 @@ export default function App(): React.JSX.Element { .catch(() => { /* 读取失败保持默认 zh-CN */ }); + // v0.7.3 P1-4: 启动即拉取模型能力探测缓存(Ollama vision 门控数据源) + useAgentStore.getState().refreshModelCapabilities(); // M-25 修复: 改用 Promise.allSettled,单个配置读取失败不影响另一个 Promise.allSettled([ window.metona.config.get('llm.provider'), @@ -128,7 +130,23 @@ export default function App(): React.JSX.Element { .catch((err) => { console.error('[App] tools.isReady query failed:', err); }); - return unsubscribe; + const offTools = unsubscribe; + + // v0.7.3 P1-4: LLM 配置变更 / Ollama 模型下载完成后刷新能力探测缓存 + const offConfig = window.metona.config?.onChanged?.((data) => { + if (data.key === 'llm.provider' || data.key === 'llm.model') { + void useAgentStore.getState().refreshModelCapabilities(); + } + }); + const offPull = window.metona.llm?.onOllamaPullEnded?.(() => { + void useAgentStore.getState().refreshModelCapabilities(); + }); + + return () => { + offTools(); + offConfig?.(); + offPull?.(); + }; } else { // 无 IPC 可用时直接标记为就绪(避免永久禁用) useAgentStore.getState().setToolsReady(true); diff --git a/src/__tests__/model-capabilities.test.ts b/src/__tests__/model-capabilities.test.ts new file mode 100644 index 0000000..58804eb --- /dev/null +++ b/src/__tests__/model-capabilities.test.ts @@ -0,0 +1,79 @@ +/** + * 模型能力门控测试(v0.7.3 P1-4) + * + * 锁定 supportsImageUpload 三道判定链: + * M1 总开关;M2 DeepSeek 非 vision 命名拒绝;M3 Ollama 能力探测 + * (supportsVision=false 拒绝 / undefined 保守放行 / 探测缓存缺失放行); + * 其余 Provider 默认放行。 + */ + +import { describe, it, expect } from 'vitest'; +import { supportsImageUpload } from '../lib/model-capabilities'; + +const BASE = { multimodalEnabled: true, provider: 'deepseek', model: 'deepseek-v4-pro' }; + +describe('supportsImageUpload', () => { + it('M1: 总开关关闭 → disabled(即使模型支持)', () => { + const r = supportsImageUpload({ + ...BASE, + model: 'deepseek-v4-flash-vision-exp', + multimodalEnabled: false, + }); + expect(r).toEqual({ allowed: false, reason: 'disabled' }); + }); + + it('M2: DeepSeek 非 vision 命名 → provider-vision', () => { + expect(supportsImageUpload(BASE)).toEqual({ allowed: false, reason: 'provider-vision' }); + expect(supportsImageUpload({ ...BASE, model: '' })).toEqual({ + allowed: false, + reason: 'provider-vision', + }); + }); + + it('M2b: DeepSeek vision 命名放行', () => { + const r = supportsImageUpload({ + ...BASE, + model: 'deepseek-v4-flash-vision-exp', + }); + expect(r.allowed).toBe(true); + }); + + it('M3a: Ollama 探测 supportsVision=false → probed-no-vision', () => { + const r = supportsImageUpload({ + multimodalEnabled: true, + provider: 'ollama', + model: 'qwen3:latest', + visionCaps: { 'qwen3:latest': false, 'llava:latest': true }, + }); + expect(r).toEqual({ allowed: false, reason: 'probed-no-vision' }); + }); + + it('M3b: Ollama 探测 true / undefined(未知)→ 保守放行', () => { + const base = { multimodalEnabled: true, provider: 'ollama', model: 'llava:latest' }; + expect(supportsImageUpload({ ...base, visionCaps: { 'llava:latest': true } }).allowed).toBe( + true, + ); + expect(supportsImageUpload({ ...base, visionCaps: { 'other:model': false } }).allowed).toBe( + true, + ); + // 探测支持为 true 的模型放行 + }); + + it('M3c: Ollama 探测缓存缺失(未加载列表)→ 放行(fail-open 保持可用性)', () => { + const r = supportsImageUpload({ + multimodalEnabled: true, + provider: 'ollama', + model: 'qwen3:latest', + visionCaps: {}, + }); + expect(r.allowed).toBe(true); + }); + + it('M4: 其余 Provider(openai/anthropic/agnes/mimo)默认放行', () => { + for (const provider of ['openai', 'anthropic', 'agnes', 'mimo']) { + expect( + supportsImageUpload({ multimodalEnabled: true, provider, model: 'any-model' }).allowed, + ).toBe(true); + } + }); +}); diff --git a/src/__tests__/trace-lifecycle.test.ts b/src/__tests__/trace-lifecycle.test.ts new file mode 100644 index 0000000..8300b52 --- /dev/null +++ b/src/__tests__/trace-lifecycle.test.ts @@ -0,0 +1,79 @@ +/** + * Trace 生命周期测试(v0.7.3 P3-3) + * + * L1 keepRecentRuns —— 按 runId 分组保留最近 N 组、首次出现顺序稳定、 + * 无 runId 的 legacy 步骤逐条参与淘汰、未超限原样返回(引用不变)。 + */ + +import { describe, it, expect } from 'vitest'; +import { keepRecentRuns, MAX_TRACE_RUNS } from '../lib/trace-lifecycle'; + +interface Step { + runId?: string; + iteration: number; +} + +function makeSteps(spec: Array<[runId: string | undefined, count: number]>): Step[] { + const steps: Step[] = []; + for (const [runId, count] of spec) { + for (let i = 0; i < count; i++) { + steps.push( + runId === undefined ? { iteration: steps.length } : { runId, iteration: steps.length }, + ); + } + } + return steps; +} + +describe('keepRecentRuns', () => { + it('L1a: 未超限时原样返回(引用不变,零开销路径)', () => { + const steps = makeSteps([ + ['runA', 2], + ['runB', 3], + ]); + expect(keepRecentRuns(steps)).toBe(steps); + }); + + it('L1b: 超限时丢弃最旧 run 组,保留最近 N 组', () => { + const spec: Array<[string | undefined, number]> = []; + for (let i = 0; i < MAX_TRACE_RUNS + 3; i++) spec.push([`run${i}`, 2]); + const steps = makeSteps(spec); + + const kept = keepRecentRuns(steps); + // 丢掉前 3 个 run 组(6 条),保留后 20 组(40 条) + expect(kept).toHaveLength(MAX_TRACE_RUNS * 2); + expect(kept.every((s) => Number(s.runId?.slice(3)) >= 3)).toBe(true); + // 顺序保持首次出现顺序(run3 → run22) + expect(kept[0].runId).toBe('run3'); + expect(kept[kept.length - 1].runId).toBe(`run${MAX_TRACE_RUNS + 2}`); + }); + + it('L1c: 无 runId 的 legacy 步骤逐条参与淘汰(不整组豁免)', () => { + const spec: Array<[string | undefined, number]> = []; + for (let i = 0; i < MAX_TRACE_RUNS; i++) spec.push([`run${i}`, 1]); + spec.push([undefined, 3]); // 3 条 legacy + const steps = makeSteps(spec); + + const kept = keepRecentRuns(steps, 5); + // 共 23 组(20 run + 3 legacy 伪组,legacy 按插入序排在最后), + // 保留最近 5 组 = run18 + run19 + 3 条 legacy + expect(kept).toHaveLength(5); + const runIds = kept.filter((s) => s.runId).map((s) => s.runId); + expect(runIds).toEqual(['run18', 'run19']); + expect(kept.filter((s) => !s.runId)).toHaveLength(3); + }); + + it('L1d: 空数组安全', () => { + expect(keepRecentRuns([])).toEqual([]); + }); + + it('L1e: maxRuns 自定义生效', () => { + const steps = makeSteps([ + ['a', 1], + ['b', 1], + ['c', 1], + ]); + const kept = keepRecentRuns(steps, 2); + expect(kept.map((s) => s.runId)).toEqual(['b', 'c']); + }); +}); diff --git a/src/__tests__/trace-trim.test.ts b/src/__tests__/trace-trim.test.ts new file mode 100644 index 0000000..de313d0 --- /dev/null +++ b/src/__tests__/trace-trim.test.ts @@ -0,0 +1,41 @@ +/** + * 编辑重发/重新生成的 Trace 截断测试(v0.7.3 P1-2) + * + * 前端侧:trimTraceStepsByAnchor 按 startedAt 过滤(legacy 无时间戳保守保留)。 + * DB 侧(truncateMessagesAfter 过滤 metadata.traceSteps)在 + * electron/services/__tests__/session-truncate-trace.test.ts 以真实 SQLite 覆盖。 + */ + +import { describe, it, expect } from 'vitest'; +import { trimTraceStepsByAnchor } from '../stores/agent-store'; + +describe('trimTraceStepsByAnchor(P1-2)', () => { + const anchorTs = 1_000_000; + + it('仅保留 startedAt < 锚点时间戳的步骤(严格小于:等值属于锚点侧必须丢弃)', () => { + const steps = [ + { runId: 'r1', startedAt: 900_000 }, + { runId: 'r2', startedAt: anchorTs }, // 恰好等于锚点 —— 锚点消息触发的 run,同毫秒落库必须判废 + { runId: 'r3', startedAt: anchorTs + 1 }, + { runId: 'r4', startedAt: anchorTs + 5000 }, + ]; + const kept = trimTraceStepsByAnchor(steps, anchorTs); + expect(kept.map((s) => s.runId)).toEqual(['r1']); + }); + + it('legacy 步骤缺 startedAt → 保守保留(无时间依据可判废)', () => { + const steps = [ + { iteration: 1 } as { runId?: string; startedAt: number; iteration: number }, + { runId: 'r2', startedAt: anchorTs + 1 }, + ]; + const kept = trimTraceStepsByAnchor(steps, anchorTs); + // legacy(无 startedAt)保守保留;r2 startedAt 晚于锚点 → 丢弃 + expect(kept).toHaveLength(1); + expect(kept[0].runId).toBeUndefined(); + expect(kept[0].startedAt).toBeUndefined(); + }); + + it('空数组安全', () => { + expect(trimTraceStepsByAnchor([], anchorTs)).toEqual([]); + }); +}); diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index fd0575b..3193e62 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -26,12 +26,19 @@ import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; import { useUIStore } from '@renderer/stores/ui-store'; import { formatFileSize } from '@renderer/lib/formatters'; +import { PROVIDER_LABELS } from '@renderer/lib/constants'; +// v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测) +import { supportsImageUpload } from '@renderer/lib/model-capabilities'; +// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) +import { t } from '@renderer/lib/i18n'; +import '@renderer/lib/i18n-strings'; -const SLASH_COMMANDS = [ - { id: 'tool', label: '/tool', description: '选择工具' }, - { id: 'memory', label: '/memory', description: '搜索记忆' }, - { id: 'clear', label: '/clear', description: '清空会话' }, - { id: 'export', label: '/export', description: '导出 Markdown' }, +// v0.7.3 P4-3: 描述文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体) +const SLASH_COMMANDS: Array<{ id: string; label: string; description: () => string }> = [ + { id: 'tool', label: '/tool', description: () => t('input.slash.tool') }, + { id: 'memory', label: '/memory', description: () => t('input.slash.memory') }, + { id: 'clear', label: '/clear', description: () => t('input.slash.clear') }, + { id: 'export', label: '/export', description: () => t('input.slash.export') }, ]; const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; @@ -106,15 +113,14 @@ export function ChatInput(): React.JSX.Element { const model = useAgentStore((s) => s.model); // v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置) const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled); - - /** - * v0.5.4: 图片上传双重判断 — 总开关 × 模型能力 - * - 开关关闭:即使模型支持多模态也不能上传(显式控制) - * - 模型能力:DeepSeek 仅 vision 系列支持;其他五家 Provider 均支持 - */ - const modelSupportsImages = - provider !== 'deepseek' || (model.length > 0 && model.includes('vision')); - const supportsImages = multimodalEnabled && modelSupportsImages; + const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps); + const imageGate = supportsImageUpload({ + multimodalEnabled, + provider, + model, + visionCaps: modelVisionCaps, + }); + const supportsImages = imageGate.allowed; // 草稿自动保存 useEffect(() => { @@ -163,11 +169,12 @@ export function ChatInput(): React.JSX.Element { reader.onerror = () => reject(new Error('文本文件读取失败')); reader.readAsText(headBlob); }); - attachment.textContent += `\n\n[... 文件内容已截断:原始大小 ${formatFileSize(file.size)},仅包含前 ${formatFileSize(MAX_TEXT_ATTACHMENT_BYTES)}。如需完整内容请分段处理 ...]`; + attachment.textContent += t('input.text.truncatedNote', { + size: formatFileSize(file.size), + limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES), + }); import('@metona-team/metona-toast') - .then((mod) => - mod.default.warning(`文本附件 "${file.name}" 超过 512KB,已截断为前 512KB`), - ) + .then((mod) => mod.default.warning(t('input.text.truncated', { name: file.name }))) .catch(() => {}); } else { attachment.textContent = await new Promise((resolve, reject) => { @@ -196,9 +203,15 @@ export function ChatInput(): React.JSX.Element { if (filtered.length < fileArray.length && !supportsImages) { const skipped = fileArray.length - filtered.length; // v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持 - const reason = multimodalEnabled - ? `当前模型不支持图片(${provider} 需多模态模型),已跳过 ${skipped} 个图片文件` - : `多模态未开启(设置 → LLM 配置),已跳过 ${skipped} 个图片文件`; + const reason = + imageGate.reason === 'disabled' + ? t('input.image.skipped.toggle', { count: skipped }) + : imageGate.reason === 'probed-no-vision' + ? t('input.image.skipped.probe', { count: skipped }) + : t('input.image.skipped.model', { + provider: PROVIDER_LABELS[provider] ?? provider, + count: skipped, + }); import('@metona-team/metona-toast') .then((mod) => { mod.default.warning(reason); @@ -218,7 +231,7 @@ export function ChatInput(): React.JSX.Element { if (successful.length === 0) { import('@metona-team/metona-toast') .then((mod) => { - mod.default.error('文件读取失败,请检查文件是否损坏或被锁定'); + mod.default.error(t('input.file.readFailed')); }) .catch(() => {}); return; @@ -227,7 +240,7 @@ export function ChatInput(): React.JSX.Element { const failedCount = filtered.length - successful.length; import('@metona-team/metona-toast') .then((mod) => { - mod.default.warning(`${failedCount} 个文件读取失败,已跳过`); + mod.default.warning(t('input.file.partialFailed', { count: failedCount })); }) .catch(() => {}); } @@ -522,7 +535,7 @@ export function ChatInput(): React.JSX.Element { {cmd.label} - {cmd.description} + {cmd.description()} ))} @@ -554,9 +567,9 @@ export function ChatInput(): React.JSX.Element { placeholder={ configLoaded ? toolsReady - ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' - : '工具加载中...' - : '正在加载配置...' + ? t('input.placeholder.ready') + : t('input.placeholder.toolsLoading') + : t('input.placeholder.configLoading') } disabled={isStreaming || !configLoaded || !toolsReady} multiline @@ -608,7 +621,7 @@ export function ChatInput(): React.JSX.Element { onClick={handleAbort} sx={{ height: 28, fontSize: 12 }} > - 中断 + {t('input.abort')} ) : ( )} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index a44c9dc..9750ac1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -96,6 +96,19 @@ export function Sidebar(): React.JSX.Element { }; }, []); + // v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播 + // (合成 key:session.title.),此处消费并刷新侧栏标题 + useEffect(() => { + if (!window.metona?.config?.onChanged) return; + const unsubscribe = window.metona.config.onChanged((data) => { + const match = /^session\.title\.(.+)$/.exec(data.key); + if (match && typeof data.value === 'string' && data.value) { + useSessionStore.getState().updateSession(match[1], { title: data.value }); + } + }); + return unsubscribe; + }, []); + // v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率) useEffect(() => { const q = searchQuery.trim(); diff --git a/src/components/memory/MemoryViewer.tsx b/src/components/memory/MemoryViewer.tsx index 695f0cf..ad947c0 100644 --- a/src/components/memory/MemoryViewer.tsx +++ b/src/components/memory/MemoryViewer.tsx @@ -7,13 +7,25 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { - Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails, - IconButton, TextField, Chip, Alert, InputAdornment, + Box, + Typography, + Stack, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + TextField, + Chip, + Alert, + InputAdornment, } from '@mui/material'; import { Brain, Search, Trash2, ChevronDown } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useUIStore } from '@renderer/stores/ui-store'; import { formatTime, truncate } from '@renderer/lib/formatters'; +// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) +import { t } from '@renderer/lib/i18n'; +import '@renderer/lib/i18n-strings'; // ===== 类型 ===== @@ -43,11 +55,13 @@ interface SearchResult { const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working']; -const MEMORY_TYPE_LABELS: Record = { - episodic: '情景记忆', - semantic: '语义记忆', - working: '工作记忆', +// v0.7.3 P4-3: 类型标签渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体) +const MEMORY_TYPE_LABEL_KEYS: Record = { + episodic: 'memory.type.episodic', + semantic: 'memory.type.semantic', + working: 'memory.type.working', }; +const memoryTypeLabel = (type: MemoryType): string => t(MEMORY_TYPE_LABEL_KEYS[type]); const MEMORY_TYPE_COLORS: Record = { episodic: '#22d3ee', @@ -73,7 +87,9 @@ function getId(item: MemoryItem): string { export function MemoryViewer(): React.JSX.Element { const [memories, setMemories] = useState>({ - episodic: [], semantic: [], working: [], + episodic: [], + semantic: [], + working: [], }); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); @@ -108,7 +124,7 @@ export function MemoryViewer(): React.JSX.Element { // 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果 if (loadReqIdRef.current !== reqId) return; if (!res.success) { - setError(res.error ?? '加载记忆失败'); + setError(res.error ?? t('memory.loadFailed')); return; } const data = res.data ?? {}; @@ -119,7 +135,7 @@ export function MemoryViewer(): React.JSX.Element { }); } catch (err) { if (loadReqIdRef.current !== reqId) return; - setError((err as Error).message ?? '加载记忆失败'); + setError((err as Error).message ?? t('memory.loadFailed')); } }, []); @@ -127,7 +143,9 @@ export function MemoryViewer(): React.JSX.Element { useEffect(() => { loadMemories(); // cleanup: 使当前请求失效(防止卸载后 setState) - return () => { loadReqIdRef.current++; }; + return () => { + loadReqIdRef.current++; + }; }, [loadMemories]); // Agent 完成自动刷新 @@ -161,7 +179,7 @@ export function MemoryViewer(): React.JSX.Element { setSearchResults(results); } catch (err) { if (searchReqIdRef.current !== reqId) return; - setError((err as Error).message ?? '搜索失败'); + setError((err as Error).message ?? t('memory.loadFailed')); setSearchResults([]); } finally { // 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死 @@ -181,29 +199,42 @@ export function MemoryViewer(): React.JSX.Element { const res = await window.metona.memory.delete(type, id); if (!res.success) { // 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert - import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(res.error ?? t('memory.deleteFailed'))) + .catch(() => {}); return; } await loadMemories(); } catch (err) { - import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error((err as Error).message ?? t('memory.deleteFailed'))) + .catch(() => {}); } }; - const totalCount = - memories.episodic.length + memories.semantic.length + memories.working.length; + const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length; // 搜索结果视图 if (searchResults !== null) { return ( - + - - 记忆搜索 + + {t('memory.search.title')} - {searchResults.length} 条结果 + {t('memory.results', { count: searchResults.length })} @@ -212,9 +243,15 @@ export function MemoryViewer(): React.JSX.Element { fullWidth value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} - placeholder="搜索记忆..." - sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearch(); + }} + placeholder={t('memory.search.placeholder')} + sx={{ + mb: 1.5, + flexShrink: 0, + '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 }, + }} slotProps={{ input: { startAdornment: ( @@ -223,24 +260,49 @@ export function MemoryViewer(): React.JSX.Element { ), endAdornment: searchQuery ? ( - - 清除 + + + {t('memory.search.clear')} + ) : null, }, }} /> - {error && {error}} + {error && ( + + {error} + + )} - + {searching ? ( - - 搜索中... + + {t('memory.searching')} ) : searchResults.length === 0 ? ( - - 无匹配结果 + + {t('memory.search.empty')} ) : ( searchResults.map((r) => ( @@ -254,14 +316,24 @@ export function MemoryViewer(): React.JSX.Element { // 全量列表视图 return ( - + - - 记忆库 + + {t('memory.list.title')} - {totalCount} 条 + {t('memory.total', { count: totalCount })} @@ -270,9 +342,15 @@ export function MemoryViewer(): React.JSX.Element { fullWidth value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} - placeholder="搜索记忆..." - sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearch(); + }} + placeholder={t('memory.search.placeholder')} + sx={{ + mb: 1.5, + flexShrink: 0, + '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 }, + }} slotProps={{ input: { startAdornment: ( @@ -284,11 +362,18 @@ export function MemoryViewer(): React.JSX.Element { }} /> - {error && {error}} + {error && ( + + {error} + + )} {totalCount === 0 ? ( - + 暂无记忆数据 ) : ( @@ -320,16 +405,31 @@ export function MemoryViewer(): React.JSX.Element { > }> - - - {MEMORY_TYPE_LABELS[type]} + + + {memoryTypeLabel(type)} @@ -366,17 +466,22 @@ function MemoryItemRow({ return ( onDelete(type, getId(item))} - sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }} + sx={{ + opacity: 0, + transition: 'opacity 150ms', + p: 0.25, + '&:hover': { color: 'error.main' }, + }} > - + {truncate(item.content, 100)} @@ -428,17 +548,22 @@ function MemorySearchItem({ return ( @@ -457,14 +584,18 @@ function MemorySearchItem({ label={`I ${(item.importance ?? 0).toFixed(2)}`} size="small" sx={{ - height: 14, fontSize: 9, + height: 14, + fontSize: 9, bgcolor: importanceColor(item.importance) + '22', color: importanceColor(item.importance), '& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' }, }} /> {item.createdAt > 0 && ( - + {formatTime(item.createdAt)} )} @@ -473,12 +604,26 @@ function MemorySearchItem({ size="small" aria-label="删除该记忆" onClick={() => onDelete(item.type, item.id)} - sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }} + sx={{ + opacity: 0, + transition: 'opacity 150ms', + p: 0.25, + '&:hover': { color: 'error.main' }, + }} > - + {truncate(item.content, 100)} diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index d589642..198febb 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -27,8 +27,19 @@ import { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react'; import { useUIStore } from '@renderer/stores/ui-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { PROVIDER_URLS } from '@renderer/components/settings/useConfig'; +// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) +import { t } from '@renderer/lib/i18n'; +import '@renderer/lib/i18n-strings'; -const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用']; +// v0.7.3 P4-3: 步骤文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体) +const STEP_KEYS = [ + 'onboarding.step.welcome', + 'onboarding.step.llm', + 'onboarding.step.agent', + 'onboarding.step.workspace', + 'onboarding.step.done', +] as const; +const STEPS = STEP_KEYS.map((k) => k); // 供长度/索引判断 // #48 修复: Onboarding 进度持久化 key const ONBOARDING_PROGRESS_KEY = 'onboarding_progress'; @@ -152,7 +163,7 @@ export function OnboardingWizard(): React.JSX.Element | null { if (r && !r.success) { console.error('[OnboardingWizard]', 'Batch config save failed:', r.error); import('@metona-team/metona-toast') - .then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')) + .then((mod) => mod.default.error(r.error ?? t('onboarding.toast.saveFailed'))) .catch(() => {}); return; } @@ -177,7 +188,11 @@ export function OnboardingWizard(): React.JSX.Element | null { console.error('[OnboardingWizard]', 'Failed to save configuration:', err); // 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死 import('@metona-team/metona-toast') - .then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)) + .then((mod) => + mod.default.error( + t('onboarding.toast.saveFailedWithReason', { message: (err as Error).message }), + ), + ) .catch(() => {}); } }; @@ -194,9 +209,9 @@ export function OnboardingWizard(): React.JSX.Element | null { alternativeLabel sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }} > - {STEPS.map((s) => ( - - {s} + {STEP_KEYS.map((key) => ( + + {t(key)} ))} @@ -219,21 +234,21 @@ export function OnboardingWizard(): React.JSX.Element | null { sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} /> - 欢迎使用 MetonaAI Desktop + {t('onboarding.welcome.title')} - 生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。 + {t('onboarding.welcome.body')} - 让我们花 1 分钟完成初始配置。 + {t('onboarding.welcome.hint')} )} {step === 1 && ( - 配置 LLM Provider + {t('onboarding.llm.title')} - 选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。 + {t('onboarding.llm.body')} @@ -279,11 +294,11 @@ export function OnboardingWizard(): React.JSX.Element | null { /> setApiKey(e.target.value)} - placeholder="sk-...(本地模型可留空)" + placeholder={t('onboarding.llm.apiKey.placeholder')} slotProps={{ input: { endAdornment: ( @@ -299,7 +314,9 @@ export function OnboardingWizard(): React.JSX.Element | null { {/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} @@ -333,9 +350,9 @@ export function OnboardingWizard(): React.JSX.Element | null { } label={ - 启用多模态(图片输入) + {t('onboarding.llm.multimodal.label')} - 开启后可在输入框上传图片;DeepSeek 需 vision 系列模型 + {t('onboarding.llm.multimodal.helper')} } @@ -347,10 +364,10 @@ export function OnboardingWizard(): React.JSX.Element | null { {step === 2 && ( - 自定义 Agent + {t('onboarding.agent.title')} - 编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。 + {t('onboarding.agent.body')}

- SOUL.md — 定义 Agent - 的身份、性格、核心价值观 + {t('onboarding.agent.soulLabel')}
- 此步骤可稍后在工作空间目录中完成。 + {t('onboarding.agent.soulHint')}
@@ -377,17 +393,17 @@ export function OnboardingWizard(): React.JSX.Element | null { {step === 3 && ( - 工作空间 + {t('onboarding.workspace.title')} - 选择工作空间目录,或使用默认路径。 + {t('onboarding.workspace.body')} setWorkspacePath(e.target.value)} - placeholder="~/MetonaWorkspaces/default/" + placeholder={t('onboarding.workspace.placeholder')} sx={{ flex: 1 }} /> - 包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。 + {t('onboarding.workspace.helper')} )} @@ -419,12 +435,12 @@ export function OnboardingWizard(): React.JSX.Element | null { - 配置完成! + {t('onboarding.done.title')} - MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧! + {t('onboarding.done.body')} - 按 Ctrl+Enter 发送消息,输入 / 查看命令列表 + {t('onboarding.done.hint')} )} @@ -445,7 +461,7 @@ export function OnboardingWizard(): React.JSX.Element | null { size="small" sx={{ color: 'text.secondary' }} > - 上一步 + {t('onboarding.prev')} diff --git a/src/components/settings/AgentSettings.tsx b/src/components/settings/AgentSettings.tsx index 5e44326..f162634 100644 --- a/src/components/settings/AgentSettings.tsx +++ b/src/components/settings/AgentSettings.tsx @@ -23,6 +23,8 @@ export function AgentSettings() { const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000); const [thinking, setThinking] = useConfig('agent.enableThinking', true); const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high'); + // v0.7.3 P3-1: REFLECTING 状态开关(引擎侧 agent.enableReflection,默认关闭) + const [reflection, setReflection] = useConfig('agent.enableReflection', false); const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000); const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000); @@ -107,6 +109,27 @@ export function AgentSettings() { )} + {/* v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关 */} + setReflection(e.target.checked)} + size="small" + /> + } + label={ + + 启用反思阶段(REFLECTING) + + 每轮工具执行后进入反思状态,存在失败结果时记录告警(不阻断执行) + + + } + /> ); } diff --git a/src/components/settings/LLMSettings.tsx b/src/components/settings/LLMSettings.tsx index af8f3e4..5b628da 100644 --- a/src/components/settings/LLMSettings.tsx +++ b/src/components/settings/LLMSettings.tsx @@ -28,6 +28,9 @@ import { import { Eye, EyeOff } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; +// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) +import { t } from '@renderer/lib/i18n'; +import '@renderer/lib/i18n-strings'; import { PROVIDER_URLS } from './useConfig'; /** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */ @@ -99,9 +102,11 @@ export function LLMSettings() { if (r.success && r.data) { setModelOptions(r.data); setModelsLoaded(true); + // v0.7.3 P1-4: 同步能力探测缓存(Ollama supportsVision → 上传门控数据源) + useAgentStore.getState().applyModelCapabilities(r.data); } else { setModelOptions([]); - setModelsError(r.error ?? '获取模型列表失败'); + setModelsError(r.error ?? t('llm.models.failed')); } } catch (err) { setModelOptions([]); @@ -115,21 +120,21 @@ export function LLMSettings() { const name = pullModelName.trim(); if (!name || pulling || !window.metona?.llm?.pullModel) return; setPulling(true); - setPullStatus({ label: '连接 Ollama 服务…', percent: null }); + setPullStatus({ label: t('llm.pull.title'), percent: null }); try { const r = await window.metona.llm.pullModel(name); if (r.success) { import('@metona-team/metona-toast') - .then((mod) => mod.default.success(`模型 ${name} 下载完成`)) + .then((mod) => mod.default.success(t('llm.pull.done', { name }))) .catch(() => {}); } else if (!r.aborted) { import('@metona-team/metona-toast') - .then((mod) => mod.default.error(`模型下载失败:${r.error ?? '未知错误'}`)) + .then((mod) => mod.default.error(t('llm.pull.failed', { message: r.error ?? 'unknown' }))) .catch(() => {}); } } catch (err) { import('@metona-team/metona-toast') - .then((mod) => mod.default.error(`模型下载失败:${(err as Error).message}`)) + .then((mod) => mod.default.error(t('llm.pull.failed', { message: (err as Error).message }))) .catch(() => {}); } finally { // onOllamaPullEnded 也会复位(时序竞态下双保险),此处兜底同步复位 @@ -182,7 +187,7 @@ export function LLMSettings() { setBalance(r.data); } else { setBalance(null); - setBalanceError(r.error ?? '查询失败'); + setBalanceError(r.error ?? t('llm.balance.queryFailed')); } } catch (err) { setBalance(null); @@ -344,7 +349,7 @@ export function LLMSettings() { const handleSave = async () => { if (hasBlockingError) { import('@metona-team/metona-toast') - .then((mod) => mod.default.error('请修正表单中的错误后再保存')) + .then((mod) => mod.default.error(t('llm.save.blockedToast'))) .catch(() => {}); return; } @@ -353,7 +358,7 @@ export function LLMSettings() { const setBatch = window.metona?.config?.setBatch; if (!setBatch) { import('@metona-team/metona-toast') - .then((mod) => mod.default.error('配置 API 不可用')) + .then((mod) => mod.default.error(t('llm.api.unavailable'))) .catch(() => {}); return; } @@ -380,13 +385,13 @@ export function LLMSettings() { const r = await setBatch(entries); if (r && !r.success) { import('@metona-team/metona-toast') - .then((mod) => mod.default.error(r.error ?? '配置保存失败')) + .then((mod) => mod.default.error(r.error ?? t('llm.save.failed.generic'))) .catch(() => {}); } else { // v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口) useAgentStore.getState().setMultimodalEnabled(multimodalEnabled); import('@metona-team/metona-toast') - .then((mod) => mod.default.success('配置已保存')) + .then((mod) => mod.default.success(t('llm.save.success'))) .catch(() => {}); // v0.7.2 P3-9: 保存成功后配置与引擎 adapter 一致 —— 若列表已加载过则静默刷新, // 保证候选列表与刚保存的 Provider/BaseURL 同源 @@ -405,10 +410,10 @@ export function LLMSettings() { return ( - LLM 配置 + {t('llm.title')} - 加载中... + {t('llm.loading')} ); @@ -419,13 +424,13 @@ export function LLMSettings() { return ( - LLM 配置 + {t('llm.title')} - Provider + {t('llm.provider.label')} { const v = e.target.value; setFbProvider(v); @@ -795,7 +804,7 @@ export function LLMSettings() { }} > - 禁用 + {t('llm.fallback.disabled')} DeepSeek Agnes AI @@ -809,14 +818,14 @@ export function LLMSettings() { <> setFbBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> setFbModel(e.target.value)} placeholder="如 deepseek-v4-flash" @@ -824,7 +833,7 @@ export function LLMSettings() { {fbProvider !== 'ollama' && ( setFbApiKey(e.target.value)} @@ -852,11 +861,11 @@ export function LLMSettings() { disabled={saving || hasBlockingError} startIcon={saving ? : undefined} > - {saving ? '保存中...' : '保存配置'} + {saving ? t('llm.saving') : t('llm.save')} {hasBlockingError && ( - 请修正表单错误后再保存 + {t('llm.save.blocked')} )} diff --git a/src/components/settings/LogsSettings.tsx b/src/components/settings/LogsSettings.tsx index bd4f4de..f4f3c6b 100644 --- a/src/components/settings/LogsSettings.tsx +++ b/src/components/settings/LogsSettings.tsx @@ -22,11 +22,12 @@ import { InputLabel, FormControl, } from '@mui/material'; -import { Folder, Copy } from 'lucide-react'; +import { Folder, Copy, RefreshCw } from 'lucide-react'; import { useConfig } from './useConfig'; import { useUIStore } from '@renderer/stores/ui-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; +import { formatFileSize } from '@renderer/lib/formatters'; export function LogsSettings() { const [logLevel, setLogLevel] = useConfig('logging.level', 'info'); @@ -315,6 +316,12 @@ export function LogsSettings() { {clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'} + {/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */} + + + {/* v0.7.3 P3-4: SLO / 健康快照可视化(只读 + 手动刷新) */} + + {/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */} ); } + +// ===== v0.7.3 P3-3: JSONL 录制文件统计与清理 ===== + +function TraceFilesPanel(): React.JSX.Element { + const [stats, setStats] = useState<{ count: number; totalBytes: number } | null>(null); + const [pruning, setPruning] = useState(false); + + const loadStats = async () => { + try { + const r = await window.metona?.logs?.traceStats(); + if (r?.success && r.data) setStats(r.data); + } catch (err) { + console.error('[LogsSettings] traceStats failed:', err); + } + }; + + useEffect(() => { + void loadStats(); + }, []); + + const handlePrune = async () => { + setPruning(true); + try { + const r = await window.metona?.logs?.pruneTraceFiles(); + if (r?.success) { + import('@metona-team/metona-toast') + .then((mod) => + mod.default.success(`已清理 ${r.data?.deleted ?? 0} 个旧录制文件(保留最近 200 个)`), + ) + .catch(() => {}); + void loadStats(); + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r?.error ?? '清理失败')) + .catch(() => {}); + } + } catch (err) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`清理失败:${(err as Error).message}`)) + .catch(() => {}); + } finally { + setPruning(false); + } + }; + + return ( + <> + + + 会话录制文件(TRACE JSONL) + + + + {stats + ? `${stats.count} 个文件 · 共 ${formatFileSize(stats.totalBytes)}(保留策略:最近 200 个,启动时自动清理)` + : '统计中...'} + + + + + + + + ); +} + +// ===== v0.7.3 P3-4: SLO / 健康快照可视化 ===== + +function HealthSnapshotPanel(): React.JSX.Element { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(false); + const [chainResult, setChainResult] = useState<{ + valid: boolean; + verified: number; + total: number; + } | null>(null); + + const loadSnapshot = async () => { + setLoading(true); + try { + const r = await window.metona?.app?.getHealthSnapshot(); + if (r?.success && r.data) setSnapshot(r.data); + } catch (err) { + console.error('[LogsSettings] healthSnapshot failed:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadSnapshot(); + }, []); + + const handleVerifyChain = async () => { + try { + const r = await window.metona?.audit?.verifyChain(); + if (r?.success) { + setChainResult({ + valid: r.valid, + verified: r.verifiedRecords, + total: r.totalRecords, + }); + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r?.error ?? '校验失败')) + .catch(() => {}); + } + } catch (err) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`校验失败:${(err as Error).message}`)) + .catch(() => {}); + } + }; + + const fmtPct = (v: number): string => `${(v * 100).toFixed(2)}%`; + + return ( + <> + + + + 运行健康(SLO / 健康检查) + + + + {!snapshot ? ( + + {loading ? '加载中...' : '暂无快照'} + + ) : ( + + + + + + !c.healthy) + .map((c) => c.name) + .join(', ')}` + : '启动后尚未执行(周期 60s)' + } + warn={snapshot.health ? !snapshot.health.healthy : false} + /> + {chainResult && ( + + )} + + + + )} + + ); +} + +function HealthRow({ + label, + value, + warn, +}: { + label: string; + value: string; + warn?: boolean; +}): React.JSX.Element { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/src/components/settings/MCPSettings.tsx b/src/components/settings/MCPSettings.tsx index 7d233e0..f3d4cf4 100644 --- a/src/components/settings/MCPSettings.tsx +++ b/src/components/settings/MCPSettings.tsx @@ -131,9 +131,16 @@ export function MCPSettings() { const statusColors: Record = { connected: 'success.main', connecting: 'warning.main', + // v0.7.3 P4-2: reconnecting —— 自动重连排程中(信息态,非故障) + reconnecting: 'info.main', disconnected: 'text.secondary', error: 'error.main', }; + /** v0.7.3 P4-2: reconnecting 状态显示第 N/3 次尝试 */ + const statusLabel = (s: { status: string; reconnectAttempt?: number }): string => + s.status === 'reconnecting' && s.reconnectAttempt + ? `${s.status} (${s.reconnectAttempt}/3)` + : s.status; // L-11 修复: 确认移除 MCP 服务 // 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭 @@ -185,7 +192,7 @@ export function MCPSettings() { {s.name} - {s.status} + {statusLabel(s)} {s.toolCount > 0 && ( diff --git a/src/lib/i18n-strings.ts b/src/lib/i18n-strings.ts index a9909d6..73af09b 100644 --- a/src/lib/i18n-strings.ts +++ b/src/lib/i18n-strings.ts @@ -103,6 +103,144 @@ registerTranslations('zh-CN', { 'monitor.sub.running': '运行中', 'monitor.sub.completed': '已完成', 'monitor.sub.error': '失败', + + // ===== 输入框(ChatInput)—— v0.7.3 P4-3 ===== + 'input.placeholder.ready': '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)', + 'input.placeholder.toolsLoading': '工具加载中...', + 'input.placeholder.configLoading': '正在加载配置...', + 'input.send': '发送', + 'input.abort': '中断', + 'input.attach.image': '附加文件(图片/文本/代码)', + 'input.attach.textOnly.model': '附加文件(文本/代码)— 当前模型不支持图片', + 'input.attach.textOnly.toggle': '附加文件(文本/代码)— 多模态未开启(设置 → LLM 配置)', + 'input.image.skipped.model': + '当前模型不支持图片({{provider}} 需多模态模型),已跳过 {{count}} 个图片文件', + 'input.image.skipped.toggle': '多模态未开启(设置 → LLM 配置),已跳过 {{count}} 个图片文件', + 'input.image.skipped.probe': '当前模型不支持图片(能力探测),已跳过 {{count}} 个图片文件', + 'input.file.readFailed': '文件读取失败,请检查文件是否损坏或被锁定', + 'input.file.partialFailed': '{{count}} 个文件读取失败,已跳过', + 'input.text.truncated': '文本附件 "{{name}}" 超过 512KB,已截断为前 512KB', + 'input.text.truncatedNote': + '\n\n[... 文件内容已截断:原始大小 {{size}},仅包含前 {{limit}}。如需完整内容请分段处理 ...]', + 'input.toast.toolsLoading': '工具正在加载中,请稍候...', + 'input.slash.tool': '选择工具', + 'input.slash.memory': '搜索记忆', + 'input.slash.clear': '清空会话', + 'input.slash.export': '导出 Markdown', + + // ===== LLM 配置(LLMSettings)—— v0.7.3 P4-3 ===== + 'llm.title': 'LLM 配置', + 'llm.loading': '加载中...', + 'llm.provider.label': 'Provider', + 'llm.baseURL': 'API Base URL', + 'llm.baseURL.error': '需以 http:// 或 https:// 开头', + 'llm.model.label': '模型名称', + 'llm.model.placeholder': '如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5', + 'llm.model.space': '模型名称不能包含空格', + 'llm.models.load': '获取模型列表', + 'llm.models.hint': '基于「已保存」的配置查询(修改后请先保存)', + 'llm.models.hint.unloaded': '可手动输入,或点击下方按钮获取模型列表', + 'llm.models.failed': '获取模型列表失败', + 'llm.multimodal.label': '启用多模态(图片输入)', + 'llm.multimodal.helper': + '开启后可在输入框上传图片;DeepSeek 需 vision 系列模型。历史会话图片会随上下文回传(最近 10 张)', + 'llm.apiKey.label': 'API Key', + 'llm.apiKey.required': '必填,未填 {{provider}} 的 API Key 会 401', + 'llm.numCtx.label': '上下文长度 (num_ctx)', + 'llm.numCtx.placeholder': '默认由模型决定(如 2048、4096、128000)', + 'llm.numCtx.error': '最小值为 512', + 'llm.ctxWindow.label': '上下文窗口 (contextWindow)', + 'llm.ctxWindow.placeholder': '如 64000、128000、1000000', + 'llm.ctxWindow.minError': '最小值为 4096', + 'llm.ctxWindow.helper': '用于上下文压缩判断,不传给 API', + 'llm.ctxWindow.mimoHelper': '默认 1000000(1M),用于上下文压缩判断', + 'llm.ctxWindow.oaHelper': 'gpt-4o 默认 128K,gpt-4.1 默认 1M', + 'llm.ctxWindow.anthropicHelper': 'Claude 默认 200K', + 'llm.pull.title': '下载 Ollama 模型', + 'llm.pull.placeholder': '模型名,如 qwen3:8b', + 'llm.pull.download': '下载', + 'llm.pull.cancel': '取消', + 'llm.pull.helper': + '从 Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见', + 'llm.pull.done': '模型 {{name}} 下载完成', + 'llm.pull.failed': '模型下载失败:{{message}}', + 'llm.balance.label': '账户余额', + 'llm.balance.querying': '查询中...', + 'llm.balance.unqueried': '未查询(需已保存 API Key)', + 'llm.balance.granted': '(赠送 {{granted}} + 充值 {{topped}})', + 'llm.balance.queryFailed': '查询失败', + 'llm.fallback.title': '故障转移(可选)', + 'llm.fallback.helper': + '主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。', + 'llm.fallback.provider': '备用 Provider', + 'llm.fallback.disabled': '禁用', + 'llm.fallback.baseURL': '备用 Base URL', + 'llm.fallback.model': '备用模型名称', + 'llm.fallback.apiKey': '备用 API Key', + 'llm.save': '保存配置', + 'llm.saving': '保存中...', + 'llm.save.blocked': '请修正表单错误后再保存', + 'llm.save.blockedToast': '请修正表单中的错误后再保存', + 'llm.save.success': '配置已保存', + 'llm.save.failed': '保存失败:{{message}}', + 'llm.api.unavailable': '配置 API 不可用', + 'llm.save.failed.generic': '配置保存失败', + + // ===== 记忆库(MemoryViewer)—— v0.7.3 P4-3 ===== + 'memory.search.title': '记忆搜索', + 'memory.list.title': '记忆库', + 'memory.results': '{{count}} 条结果', + 'memory.total': '{{count}} 条', + 'memory.search.placeholder': '搜索记忆...', + 'memory.search.clear': '清除', + 'memory.searching': '搜索中...', + 'memory.search.empty': '无匹配结果', + 'memory.empty': '暂无记忆数据', + 'memory.loadFailed': '加载记忆失败', + 'memory.deleteFailed': '删除失败', + 'memory.type.episodic': '情景记忆', + 'memory.type.semantic': '语义记忆', + 'memory.type.working': '工作记忆', + + // ===== 引导向导(OnboardingWizard)—— v0.7.3 P4-3 ===== + 'onboarding.step.welcome': '欢迎', + 'onboarding.step.llm': '配置 LLM', + 'onboarding.step.agent': '自定义 Agent', + 'onboarding.step.workspace': '工作空间', + 'onboarding.step.done': '开始使用', + 'onboarding.prev': '上一步', + 'onboarding.next': '下一步', + 'onboarding.finish': '开始使用', + 'onboarding.welcome.title': '欢迎使用 MetonaAI Desktop', + 'onboarding.welcome.body': + '生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。', + 'onboarding.welcome.hint': '让我们花 1 分钟完成初始配置。', + 'onboarding.llm.title': '配置 LLM Provider', + 'onboarding.llm.body': '选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。', + 'onboarding.llm.multimodal.label': '启用多模态(图片输入)', + 'onboarding.llm.multimodal.helper': '开启后可在输入框上传图片;DeepSeek 需 vision 系列模型', + 'onboarding.llm.apiKey.placeholder': 'sk-...(本地模型可留空)', + 'onboarding.llm.ctx.ollama': '上下文长度 (num_ctx)', + 'onboarding.llm.ctx.window': '上下文窗口 (contextWindow)', + 'onboarding.llm.ctx.ollamaPlaceholder': '默认由模型决定(如 2048、4096、128000)', + 'onboarding.llm.ctx.placeholder': '如 64000、128000、1000000', + 'onboarding.llm.ctx.helper': '用于上下文压缩判断,不传给 API', + 'onboarding.llm.ctx.minError': '最小值为 {{min}}', + 'onboarding.agent.title': '自定义 Agent', + 'onboarding.agent.body': '编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。', + 'onboarding.agent.soulLabel': 'SOUL.md — 定义 Agent 的身份、性格、核心价值观', + 'onboarding.agent.soulHint': '此步骤可稍后在工作空间目录中完成。', + 'onboarding.workspace.title': '工作空间', + 'onboarding.workspace.body': '选择工作空间目录,或使用默认路径。', + 'onboarding.workspace.pick': '选择文件夹', + 'onboarding.workspace.placeholder': '~/MetonaWorkspaces/default/', + 'onboarding.workspace.helper': '包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。', + 'onboarding.done.title': '配置完成!', + 'onboarding.done.body': 'MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧!', + 'onboarding.done.hint': '按 Ctrl+Enter 发送消息,输入 / 查看命令列表', + 'onboarding.toast.saveFailed': '配置保存失败,请重试', + 'onboarding.toast.saveFailedWithReason': '保存配置失败:{{message}}', + 'onboarding.toast.folderFailed': '选择文件夹失败', }); registerTranslations('en-US', { @@ -195,4 +333,149 @@ registerTranslations('en-US', { 'monitor.sub.running': 'Running', 'monitor.sub.completed': 'Completed', 'monitor.sub.error': 'Failed', + + // ===== 输入框(ChatInput)===== + 'input.placeholder.ready': + 'Type a message... (Cmd/Ctrl+Enter to send, Cmd/Ctrl+Shift+Enter for newline, / for commands)', + 'input.placeholder.toolsLoading': 'Loading tools...', + 'input.placeholder.configLoading': 'Loading configuration...', + 'input.send': 'Send', + 'input.abort': 'Stop', + 'input.attach.image': 'Attach files (images/text/code)', + 'input.attach.textOnly.model': 'Attach files (text/code) — current model has no image support', + 'input.attach.textOnly.toggle': 'Attach files (text/code) — multimodal disabled (Settings → LLM)', + 'input.image.skipped.model': + 'Current model has no image support ({{provider}} requires a multimodal model); {{count}} image file(s) skipped', + 'input.image.skipped.toggle': + 'Multimodal is disabled (Settings → LLM); {{count}} image file(s) skipped', + 'input.image.skipped.probe': + 'Current model has no image support (capability probe); {{count}} image file(s) skipped', + 'input.file.readFailed': 'Failed to read files — check whether they are corrupted or locked', + 'input.file.partialFailed': '{{count}} file(s) failed to read and were skipped', + 'input.text.truncated': + 'Text attachment "{{name}}" exceeds 512KB and was truncated to the first 512KB', + 'input.text.truncatedNote': + '\n\n[... File content truncated: original size {{size}}, only the first {{limit}} included. Process in segments if you need the full content ...]', + 'input.toast.toolsLoading': 'Tools are still loading, please wait...', + 'input.slash.tool': 'Pick a tool', + 'input.slash.memory': 'Search memory', + 'input.slash.clear': 'Clear session', + 'input.slash.export': 'Export Markdown', + + // ===== LLM 配置(LLMSettings)===== + 'llm.title': 'LLM Configuration', + 'llm.loading': 'Loading...', + 'llm.provider.label': 'Provider', + 'llm.baseURL': 'API Base URL', + 'llm.baseURL.error': 'Must start with http:// or https://', + 'llm.model.label': 'Model Name', + 'llm.model.placeholder': 'e.g. deepseek-v4-pro, gpt-4o, claude-sonnet-4-5', + 'llm.model.space': 'Model name must not contain spaces', + 'llm.models.load': 'Fetch model list', + 'llm.models.hint': 'Queries the saved configuration (save changes first)', + 'llm.models.hint.unloaded': 'Type manually, or fetch the model list below', + 'llm.models.failed': 'Failed to fetch model list', + 'llm.multimodal.label': 'Enable multimodal (image input)', + 'llm.multimodal.helper': + 'Allows uploading images; DeepSeek requires a vision model. Images from history are replayed (last 10)', + 'llm.apiKey.label': 'API Key', + 'llm.apiKey.required': 'Required — missing {{provider}} API Key will result in 401', + 'llm.numCtx.label': 'Context length (num_ctx)', + 'llm.numCtx.placeholder': 'Default decided by the model (e.g. 2048, 4096, 128000)', + 'llm.numCtx.error': 'Minimum is 512', + 'llm.ctxWindow.label': 'Context window (contextWindow)', + 'llm.ctxWindow.placeholder': 'e.g. 64000, 128000, 1000000', + 'llm.ctxWindow.minError': 'Minimum is 4096', + 'llm.ctxWindow.helper': 'Used for context-compression decisions; not sent to the API', + 'llm.ctxWindow.mimoHelper': 'Default 1000000 (1M), used for compression decisions', + 'llm.ctxWindow.oaHelper': 'gpt-4o defaults to 128K, gpt-4.1 to 1M', + 'llm.ctxWindow.anthropicHelper': 'Claude defaults to 200K', + 'llm.pull.title': 'Download Ollama model', + 'llm.pull.placeholder': 'Model name, e.g. qwen3:8b', + 'llm.pull.download': 'Download', + 'llm.pull.cancel': 'Cancel', + 'llm.pull.helper': + 'Pull a model from the Ollama server (large downloads depend on network; cancellable). Fetch the list afterwards', + 'llm.pull.done': 'Model {{name}} downloaded', + 'llm.pull.failed': 'Model download failed: {{message}}', + 'llm.balance.label': 'Account Balance', + 'llm.balance.querying': 'Querying...', + 'llm.balance.unqueried': 'Not queried (requires a saved API Key)', + 'llm.balance.granted': '(granted {{granted}} + topped up {{topped}})', + 'llm.balance.queryFailed': 'Query failed', + 'llm.fallback.title': 'Failover (optional)', + 'llm.fallback.helper': + 'Switch to the fallback provider when the primary fails (retries exhausted or invalid key). Leave empty to disable.', + 'llm.fallback.provider': 'Fallback Provider', + 'llm.fallback.disabled': 'Disabled', + 'llm.fallback.baseURL': 'Fallback Base URL', + 'llm.fallback.model': 'Fallback Model Name', + 'llm.fallback.apiKey': 'Fallback API Key', + 'llm.save': 'Save Configuration', + 'llm.saving': 'Saving...', + 'llm.save.blocked': 'Fix form errors before saving', + 'llm.save.blockedToast': 'Please fix the form errors before saving', + 'llm.save.success': 'Configuration saved', + 'llm.save.failed': 'Save failed: {{message}}', + 'llm.api.unavailable': 'Configuration API unavailable', + 'llm.save.failed.generic': 'Failed to save configuration', + + // ===== 记忆库(MemoryViewer)===== + 'memory.search.title': 'Memory Search', + 'memory.list.title': 'Memory Store', + 'memory.results': '{{count}} results', + 'memory.total': '{{count}} items', + 'memory.search.placeholder': 'Search memories...', + 'memory.search.clear': 'Clear', + 'memory.searching': 'Searching...', + 'memory.search.empty': 'No matches', + 'memory.empty': 'No memories yet', + 'memory.loadFailed': 'Failed to load memories', + 'memory.deleteFailed': 'Delete failed', + 'memory.type.episodic': 'Episodic', + 'memory.type.semantic': 'Semantic', + 'memory.type.working': 'Working', + + // ===== 引导向导(OnboardingWizard)===== + 'onboarding.step.welcome': 'Welcome', + 'onboarding.step.llm': 'Configure LLM', + 'onboarding.step.agent': 'Customize Agent', + 'onboarding.step.workspace': 'Workspace', + 'onboarding.step.done': 'Get Started', + 'onboarding.prev': 'Back', + 'onboarding.next': 'Next', + 'onboarding.finish': 'Get Started', + 'onboarding.welcome.title': 'Welcome to MetonaAI Desktop', + 'onboarding.welcome.body': + 'A production-grade AI Agent desktop app with multi-turn chat, tool calling, memory, and MCP integration.', + 'onboarding.welcome.hint': "Let's spend 1 minute on initial setup.", + 'onboarding.llm.title': 'Configure LLM Provider', + 'onboarding.llm.body': + 'Pick a provider and fill in API details. Base URL and model accept any input.', + 'onboarding.llm.multimodal.label': 'Enable multimodal (image input)', + 'onboarding.llm.multimodal.helper': 'Allows uploading images; DeepSeek requires a vision model', + 'onboarding.llm.apiKey.placeholder': 'sk-... (leave empty for local models)', + 'onboarding.llm.ctx.ollama': 'Context length (num_ctx)', + 'onboarding.llm.ctx.window': 'Context window (contextWindow)', + 'onboarding.llm.ctx.ollamaPlaceholder': 'Default decided by the model (e.g. 2048, 4096, 128000)', + 'onboarding.llm.ctx.placeholder': 'e.g. 64000, 128000, 1000000', + 'onboarding.llm.ctx.helper': 'Used for context-compression decisions; not sent to the API', + 'onboarding.llm.ctx.minError': 'Minimum is {{min}}', + 'onboarding.agent.title': 'Customize Agent', + 'onboarding.agent.body': + 'Edit SOUL.md in the workspace to define the agent identity and personality.', + 'onboarding.agent.soulLabel': 'SOUL.md — define the agent identity, personality, and core values', + 'onboarding.agent.soulHint': 'You can do this later in the workspace directory.', + 'onboarding.workspace.title': 'Workspace', + 'onboarding.workspace.body': 'Choose a workspace directory, or keep the default.', + 'onboarding.workspace.pick': 'Choose Folder', + 'onboarding.workspace.placeholder': '~/MetonaWorkspaces/default/', + 'onboarding.workspace.helper': + 'Contains the two required files SOUL.md and MEMORY.md, auto-created on first open.', + 'onboarding.done.title': 'All set!', + 'onboarding.done.body': 'MetonaAI Desktop is ready. Start chatting with your AI agent!', + 'onboarding.done.hint': 'Press Ctrl+Enter to send; type / to see commands', + 'onboarding.toast.saveFailed': 'Failed to save configuration, please retry', + 'onboarding.toast.saveFailedWithReason': 'Failed to save configuration: {{message}}', + 'onboarding.toast.folderFailed': 'Failed to choose folder', }); diff --git a/src/lib/model-capabilities.ts b/src/lib/model-capabilities.ts new file mode 100644 index 0000000..e835d38 --- /dev/null +++ b/src/lib/model-capabilities.ts @@ -0,0 +1,62 @@ +/** + * 模型能力门控纯函数(v0.7.3 P1-4) + * + * ChatInput 的图片上传入口此前只有"多模态总开关 × DeepSeek vision 命名"两道 + * 判定 —— Ollama 恒放行,但本地语言模型是否支持 vision 因模型而异(qwen3 纯文本 + * vs llava 视觉)。v0.6.4 P4-1 已实现 /api/show 能力探测,本模块把"是否允许上传 + * 图片"的完整判定收敛为纯函数,供组件消费与表测锁定。 + * + * 判定链(任一不满足即拒绝): + * 1. multimodalEnabled 总开关(设置 → LLM 配置); + * 2. Provider 级:DeepSeek 非 vision 系列命名 → 拒绝(模型级防线,与 adapter 口径一致); + * 3. 模型级:Ollama 消费 llm:listModels 的 supportsVision 探测结果 —— + * false 拒绝;undefined(未知/探测失败/列表未加载)保守放行(fail-open, + * 与 adapter 侧探测失败回退 true 的可用性策略对齐);其余 Provider 无探测数据放行。 + */ + +export type ImageUploadDenialReason = + | 'disabled' // 多模态总开关未开启 + | 'provider-vision' // 当前 Provider 的该模型命名不支持 vision(DeepSeek) + | 'probed-no-vision'; // 能力探测明确返回不支持(Ollama) + +export interface ImageUploadGateInput { + multimodalEnabled: boolean; + provider: string; + model: string; + /** + * 模型能力探测缓存(agent-store.modelVisionCaps;modelId → supportsVision)。 + * 仅记录探测为 false 的条目即可生效(undefined/缺失 = 未知 → 放行)。 + */ + visionCaps?: Record; +} + +export interface ImageUploadGateResult { + allowed: boolean; + reason?: ImageUploadDenialReason; +} + +export function supportsImageUpload(input: ImageUploadGateInput): ImageUploadGateResult { + // 1. 总开关 + if (!input.multimodalEnabled) { + return { allowed: false, reason: 'disabled' }; + } + + const model = input.model ?? ''; + + // 2. DeepSeek 模型级命名防线(非 vision 系列不支持图片,见 DeepSeekAdapter) + if (input.provider === 'deepseek') { + if (model.length === 0 || !model.includes('vision')) { + return { allowed: false, reason: 'provider-vision' }; + } + } + + // 3. Ollama 能力探测(v0.7.3 P1-4 接线 /api/show supportsVision) + if (input.provider === 'ollama' && model) { + const probed = input.visionCaps?.[model]; + if (probed === false) { + return { allowed: false, reason: 'probed-no-vision' }; + } + } + + return { allowed: true }; +} diff --git a/src/lib/trace-lifecycle.ts b/src/lib/trace-lifecycle.ts new file mode 100644 index 0000000..b0eb4e2 --- /dev/null +++ b/src/lib/trace-lifecycle.ts @@ -0,0 +1,53 @@ +/** + * Trace 生命周期(v0.7.3 P3-3) + * + * 背景:traceSteps 跨 run 只增不减(saveTraceData 每次全量重写 sessions.metadata), + * 长会话单行 metadata 可达数十 MB —— v0.7.2 A4 只裁剪了单条 tool_result 里的 + * base64,没有约束 run 条目数。现按"保留最近 N 个 run"截断,历史 trace 的完整 + * 记录仍在 JSONL 录制文件中(TRACE 层),详情面板仅需近程可读性。 + * + * 纯函数、零副作用:可在 node vitest 下直接表测。 + */ + +/** 落库保留的最大 run 数(默认 20) */ +export const MAX_TRACE_RUNS = 20; + +export interface TraceStepLike { + runId?: string; +} + +/** + * 按首次出现顺序对 runId 分组,仅保留最近 maxRuns 个 run 的步骤。 + * 无 runId 的 legacy 步骤各自独立成组(逐条参与淘汰,不整组豁免)。 + * + * @param steps 全量 traceSteps(跨 run 累积) + * @param maxRuns 保留的 run 组数上限 + */ +export function keepRecentRuns(steps: T[], maxRuns = MAX_TRACE_RUNS): T[] { + if (steps.length <= 0) return steps; + + // 分组(保持首次出现顺序);无 runId 的步骤用序号伪分组,避免被误合并 + const order: string[] = []; + const groups = new Map(); + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const key = step.runId ?? `__legacy_${i}`; + const group = groups.get(key); + if (group) { + group.push(step); + } else { + groups.set(key, [step]); + order.push(key); + } + } + + // 超限时丢弃最旧的 run 组 + if (order.length <= maxRuns) return steps; + const keepKeys = new Set(order.slice(order.length - maxRuns)); + const out: T[] = []; + for (const key of order) { + if (!keepKeys.has(key)) continue; + out.push(...groups.get(key)!); + } + return out; +} diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index 261b9b1..7147dcf 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -9,11 +9,29 @@ import { useSessionStore } from './session-store'; // v0.7.2 A4: Trace 落库前复用显示层裁剪(剥离 base64/超长字符串),防止 // view_image 截图等超大工具结果(单张 ~6.7MB base64)原样写入 sessions.metadata import { toDisplayResult } from '../lib/tool-result-display'; +// v0.7.3 P3-3: Trace 生命周期 —— metadata 只保留最近 N 个 run 的步骤 +import { keepRecentRuns } from '../lib/trace-lifecycle'; // L-1: 消息 ID 防碰撞计数器 let _msgIdCounter = 0; export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`; +/** + * v0.7.3 P1-2: 按锚点时间戳截断 traceSteps(编辑重发/重新生成的本地侧镜像)。 + * + * DB 侧 truncateMessagesAfter 已同步过滤 metadata.traceSteps(startedAt < + * 锚点消息 created_at,严格小于 —— 锚点触发的 run 与消息同毫秒落库,等值 + * 属于"锚点侧"必须丢弃);本函数对前端内存态做同一语义的过滤,保证截断后 + * TraceViewer 不再显示已被撤销轮次的"幽灵步骤"(切换会话再切回时 DB 与 + * 内存两侧口径一致)。legacy 步骤缺 startedAt 时保守保留(无时间依据可判废)。 + */ +export function trimTraceStepsByAnchor( + steps: T[], + anchorTimestamp: number, +): T[] { + return steps.filter((s) => typeof s.startedAt !== 'number' || s.startedAt < anchorTimestamp); +} + /** * L-16 修复: 提取上下文窗口默认值为命名常量 * - Ollama 默认 4096(与 OllamaAdapter.DEFAULT_CONTEXT_WINDOW 保持一致) @@ -155,6 +173,13 @@ interface AgentState { */ sessionRunStates: Record; + /** + * v0.7.3 P1-4: 模型视觉能力探测缓存(modelId → supportsVision)。 + * 仅存探测为 false 的条目即可生效(缺失/undefined = 未知 → 上传门控保守放行)。 + * 数据源:window.metona.llm.listModels()(Ollama 侧为 /api/show 能力探测)。 + */ + modelVisionCaps: Record; + // Actions setCurrentSession: (id: string | null) => void; setMessages: (messages: ChatMessage[]) => void; @@ -193,6 +218,16 @@ interface AgentState { * status 传入 null 表示该会话运行已结束(TERMINATED / abort),移除条目。 */ updateSessionRunState: (sessionId: string, status: AgentStatus | null) => void; + /** + * v0.7.3 P1-4: 合并模型能力探测结果(仅记录 supportsVision === false 的模型)。 + * 供 LLMSettings 加载列表后与 store 刷新动作调用。 + */ + applyModelCapabilities: (models: Array<{ id: string; supportsVision?: boolean }>) => void; + /** + * v0.7.3 P1-4: 拉取 llm:listModels 并合并能力缓存(启动 / Provider 切换 / + * Ollama 模型下载完成后调用)。失败静默 —— 门控按未知模型保守放行。 + */ + refreshModelCapabilities: () => Promise; saveTraceData: () => void; clearMessages: () => Promise; abort: () => void; @@ -234,6 +269,8 @@ export const useAgentStore = create((set, get) => ({ multimodalEnabled: false, // v0.7.2 P3-11: 后台会话运行状态图(初始为空) sessionRunStates: {}, + // v0.7.3 P1-4: 模型视觉能力缓存(初始为空 = 全部未知 → 门控保守放行) + modelVisionCaps: {}, // ===== Actions ===== @@ -620,6 +657,29 @@ export const useAgentStore = create((set, get) => ({ return { sessionRunStates: { ...s.sessionRunStates, [sessionId]: status } }; }), + // v0.7.3 P1-4: 合并模型能力探测结果(supportsVision=false 才入缓存) + applyModelCapabilities: (models) => + set((s) => { + const additions: Record = {}; + for (const m of models) { + if (m?.id && m.supportsVision === false) additions[m.id] = false; + } + if (Object.keys(additions).length === 0) return s; + return { modelVisionCaps: { ...s.modelVisionCaps, ...additions } }; + }), + + // v0.7.3 P1-4: 拉取 llm:listModels 并合并能力缓存(失败静默,门控按未知放行) + refreshModelCapabilities: async () => { + try { + const r = await window.metona?.llm?.listModels?.(); + if (r?.success && Array.isArray(r.data)) { + useAgentStore.getState().applyModelCapabilities(r.data); + } + } catch { + /* 静默 —— 探测不可用时门控按未知模型保守放行 */ + } + }, + saveTraceData: () => { const { currentSessionId, traceSteps, tokenUsage } = get(); if (currentSessionId && window.metona?.sessions?.saveTrace) { @@ -630,7 +690,10 @@ export const useAgentStore = create((set, get) => ({ // toDisplayResult(dataUrl 剥离 + 任意 >8KB 字符串截断),内存态 traceSteps // 在入 store 时已同步瘦身(见 useAgentStream),此处对 restore 自 DB 的 // 旧格式数据兜底二次裁剪。 - const sanitizedSteps = traceSteps.map((step) => + // v0.7.3 P3-3: 条目数同样收口 —— 只保留最近 20 个 run 的步骤 + // (历史 run 的完整记录在 JSONL 录制文件中,metadata 仅需近程可读性)。 + const recentSteps = keepRecentRuns(traceSteps); + const sanitizedSteps = recentSteps.map((step) => step.toolCalls?.length ? { ...step, @@ -745,12 +808,15 @@ export const useAgentStore = create((set, get) => ({ } // 本地截断(保留之前的消息,重置运行状态) + // v0.7.3 P1-2: traceSteps 同步按锚点时间戳过滤 —— DB 侧 truncateMessagesAfter + // 已过滤 metadata.traceSteps,此处对内存态做同一语义截断,消除幽灵步骤 set({ messages: messages.slice(0, idx), agentStatus: 'idle', isStreaming: false, currentIteration: 0, currentRunId: null, + traceSteps: trimTraceStepsByAnchor(get().traceSteps, original.timestamp), }); // 从原消息附件重建图片参数(附件随消息保留重发) @@ -802,13 +868,14 @@ export const useAgentStore = create((set, get) => ({ } } - // 本地截断 + // 本地截断(v0.7.3 P1-2: traceSteps 同步过滤,语义与 editAndResend 一致) set({ messages: messages.slice(0, lastUserIdx), agentStatus: 'idle', isStreaming: false, currentIteration: 0, currentRunId: null, + traceSteps: trimTraceStepsByAnchor(get().traceSteps, lastUser.timestamp), }); // 重发原内容(含原附件) diff --git a/src/types/global.d.ts b/src/types/global.d.ts index a819a42..77691a6 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -127,7 +127,7 @@ interface MetonaSessionsAPI { >; pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>; archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>; - deleteMessage: (messageId: string) => Promise<{ success: boolean }>; + /** v0.7.3 P1-3: deleteMessage 死通道已移除(消息删除语义由 truncateAfter 覆盖) */ /** v0.7.2 A1: 语义为"操作完成"(空会话清空同样成功);失败时携带 error */ clearMessages: (sessionId: string) => Promise<{ success: boolean; error?: string }>; /** P2-11: 截断消息(编辑重发/重新生成) */ @@ -173,9 +173,12 @@ interface MetonaMCPServerConfig { interface MetonaMCPServerStatus { name: string; - status: 'connecting' | 'connected' | 'disconnected' | 'error'; + /** v0.7.3 P4-2: 新增 reconnecting(自动重连排程中,含第 N 次尝试) */ + status: 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting'; toolCount: number; error?: string; + /** v0.7.3 P4-2: reconnecting 状态下的已尝试次数 */ + reconnectAttempt?: number; } interface MetonaMCPAPI { @@ -243,6 +246,50 @@ interface MetonaAppAPI { | { status: 'up-to-date'; latestVersion: string } | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } >; + /** v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告) */ + getHealthSnapshot: () => Promise<{ + success: boolean; + error?: string; + data?: MetonaHealthSnapshot; + }>; +} + +// ===== v0.7.3 P3-4: 健康快照载荷 ===== + +interface MetonaHealthSnapshot { + slo: { + errorRate: number; + throughput: number; + avgLatencyMs: number; + percentiles: Record; + burnRate: number; + target: number; + violated: boolean; + totalRequests: number; + errorRequests: number; + timestamp: string; + }; + health: { + healthy: boolean; + checks: Array<{ name: string; healthy: boolean; latencyMs?: number; error?: string }>; + timestamp: string; + } | null; + generatedAt: number; +} + +// ===== v0.7.3 P3-3: JSONL 录制文件生命周期 ===== + +interface MetonaLogsAPI { + traceStats: () => Promise<{ + success: boolean; + error?: string; + data?: { count: number; totalBytes: number }; + }>; + pruneTraceFiles: () => Promise<{ + success: boolean; + error?: string; + data?: { deleted: number }; + }>; } // ===== Workspace API ===== @@ -314,6 +361,8 @@ interface MetonaModelInfoLite { maxOutputTokens?: number; supportsToolCalling?: boolean; supportsThinking?: boolean; + /** v0.7.3 P1-4: 视觉能力(undefined = 未知,前端保守放行;Ollama 探测填充) */ + supportsVision?: boolean; description?: string; } @@ -577,6 +626,8 @@ interface MetonaBridge { tasks: MetonaTasksAPI; audit: MetonaAuditAPI; tool: MetonaToolAPI; + /** v0.7.3 P3-3: JSONL 录制文件生命周期 */ + logs: MetonaLogsAPI; /** v0.6.4: 托盘动作(新建会话死链接线) */ tray?: MetonaTrayAPI; }