From 49c9b25538e8ca5cfd2facb506d89cbcdc3ca35d Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Fri, 21 Aug 2026 13:58:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.4.1=20=E8=B4=A8=E9=87=8F=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E7=89=88=20=E2=80=94=20=E5=B7=A5=E7=A8=8B=E5=8C=96?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=20+=20=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=20+=20=E6=B5=8B=E8=AF=95=E8=A1=A5=E9=BD=90=20+=20=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式) --- .gitea/workflows/ci.yml | 95 + .gitignore | 3 + .husky/pre-commit | 8 + README.md | 5 +- .../adapters/__tests__/base-adapter.test.ts | 245 +++ .../adapters/__tests__/sse-stream.test.ts | 238 +++ electron/harness/adapters/base-adapter.ts | 23 +- .../hooks/__tests__/confirmation-hook.test.ts | 317 ++++ electron/harness/hooks/confirmation-hook.ts | 114 +- electron/harness/hooks/pre-tool.ts | 7 +- .../sandbox/__tests__/permissions.test.ts | 80 +- electron/harness/sandbox/permissions.ts | 143 +- .../tools/built-in/__tests__/command.test.ts | 29 +- .../__tests__/web-search-parsers.test.ts | 174 ++ electron/harness/tools/built-in/command.ts | 140 +- electron/harness/tools/built-in/web-search.ts | 280 ++- electron/harness/types/index.ts | 7 +- electron/harness/types/metona-response.ts | 19 + electron/ipc/__tests__/agent.test.ts | 415 +++++ electron/ipc/agent.ts | 807 +++++---- electron/ipc/mcp.ts | 99 +- electron/ipc/tools.ts | 35 +- electron/preload.ts | 85 +- electron/services/database.service.ts | 54 +- electron/services/mcp-manager.service.ts | 130 +- package-lock.json | 313 +++- package.json | 27 +- src/components/ConfirmationDialog.tsx | 782 +++++---- src/components/chat/MessageList.tsx | 165 +- src/components/settings/AgentSettings.tsx | 112 ++ .../settings/AppearanceSettings.tsx | 42 + src/components/settings/LLMSettings.tsx | 494 ++++++ src/components/settings/LogsSettings.tsx | 287 +++ src/components/settings/MCPSettings.tsx | 334 ++++ src/components/settings/SearXNGSettings.tsx | 262 +++ src/components/settings/SettingsModal.tsx | 1540 ++--------------- src/components/settings/ToolsSettings.tsx | 311 ++++ src/components/settings/WorkspaceSettings.tsx | 365 ++++ src/components/settings/useConfig.ts | 69 + src/hooks/useAgentStream.ts | 77 +- src/types/global.d.ts | 130 +- 41 files changed, 6254 insertions(+), 2608 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .husky/pre-commit create mode 100644 electron/harness/adapters/__tests__/base-adapter.test.ts create mode 100644 electron/harness/adapters/__tests__/sse-stream.test.ts create mode 100644 electron/harness/hooks/__tests__/confirmation-hook.test.ts create mode 100644 electron/harness/tools/built-in/__tests__/web-search-parsers.test.ts create mode 100644 electron/ipc/__tests__/agent.test.ts create mode 100644 src/components/settings/AgentSettings.tsx create mode 100644 src/components/settings/AppearanceSettings.tsx create mode 100644 src/components/settings/LLMSettings.tsx create mode 100644 src/components/settings/LogsSettings.tsx create mode 100644 src/components/settings/MCPSettings.tsx create mode 100644 src/components/settings/SearXNGSettings.tsx create mode 100644 src/components/settings/ToolsSettings.tsx create mode 100644 src/components/settings/WorkspaceSettings.tsx create mode 100644 src/components/settings/useConfig.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..428a549 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,95 @@ +# MetonaAI Desktop — Gitea Actions CI +# +# 质量门禁:类型检查 + Lint + 单元测试 + 产物编译验证 +# 触发:push(main/dev 分支)与 Pull Request +# +# 说明: +# - 主测试使用系统 Node(npm test)——better-sqlite3 原生模块按系统 Node ABI 编译, +# audit 套件在 ABI 不匹配时自动跳过(skipIf),保证 CI 稳定绿 +# - electron-test job 尝试按 Electron ABI 重建 better-sqlite3 后跑全量测试(含 audit 链式哈希), +# 该 job 标记为 experimental(continue-on-error),失败不阻塞合并 +# - build 验证只跑 electron-vite build(产物编译),不跑 electron-builder 打包—— +# NSIS 目标需要 wine,Linux runner 上不可用 + +name: CI + +on: + push: + branches: [main, dev, master] + pull_request: + branches: [main, dev, master] + +env: + # Electron 二进制国内镜像(与 .npmrc 注释保持一致,加速 runner 下载) + ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/ + +jobs: + quality: + name: 类型检查 + Lint + 单元测试 + runs-on: debian-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests (system Node) + run: npm test + + electron-test: + name: 全量测试 (Electron ABI, experimental) + runs-on: debian-latest + timeout-minutes: 25 + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Rebuild better-sqlite3 for Electron ABI + run: npx @electron/rebuild -f -w better-sqlite3 + + - name: Full tests (Electron Node ABI) + run: npm run test:electron + + build: + name: 产物编译验证 + runs-on: debian-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build (electron-vite) + run: npx electron-vite build diff --git a/.gitignore b/.gitignore index 48410a7..fa0a3e4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ yarn-error.log* coverage/ .nyc_output/ +# Husky (internal shims) +.husky/_/ + # Playwright test-results/ playwright-report/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8b25858 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,8 @@ +# Metona 预提交钩子 +# 1. lint-staged: 对暂存文件跑 eslint --fix + prettier(含自动修复) +# 2. typecheck: 全量 TypeScript 类型检查(防止类型错误进入仓库) +# +# 注意: 发布前仍需手动执行 `npm run build`(完整打包含 electron-builder, +# 耗时较长,不适合放在每次提交的钩子中) +npx lint-staged +npm run typecheck diff --git a/README.md b/README.md index ab1c7fe..11b9c73 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -844,9 +844,8 @@ npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) -npm run test:electron # 运行全量单元测试 (Electron Node ABI, 113 用例全执行, 含 SQLite 审计链哈希) +npm run test:electron # 运行全量单元测试 (Electron Node ABI, 194 用例全执行, 含 SQLite 审计链哈希) npm run test:watch # 测试监听模式 -npm run test:e2e # E2E 测试 (Playwright) # ─── 构建 ───────────────────────────────── npm run build # 构建生产包 (Windows NSIS + 便携版) diff --git a/electron/harness/adapters/__tests__/base-adapter.test.ts b/electron/harness/adapters/__tests__/base-adapter.test.ts new file mode 100644 index 0000000..d6cbee3 --- /dev/null +++ b/electron/harness/adapters/__tests__/base-adapter.test.ts @@ -0,0 +1,245 @@ +/** + * BaseAdapter 单元测试(v0.4.1 测试补齐) + * 覆盖:错误映射(mapError)、HTTP 错误识别(throwHttpError)、 + * ContentFilterError、上下文窗口读取、fetchWithTimeout 超时与清理 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { BaseAdapter, ContentFilterError } from '../base-adapter'; +import { MetonaErrorCode, MetonaStreamEventType } from '../../types'; +import type { + IMetonaProviderAdapter, + AdapterConfig, + MetonaRequest, + MetonaResponse, + MetonaStreamEvent, +} from '../../types'; + +/** 测试用具体 Adapter 实现(暴露 protected 方法供测试) */ +class TestAdapter extends BaseAdapter { + readonly providerId = 'test'; + readonly supportedModels = ['test-model-a', 'test-model-b']; + readonly supportsToolCalling = true; + readonly supportsThinking = false; + + async send(_request: MetonaRequest): Promise { + throw new Error('not implemented'); + } + + async *sendStream(_request: MetonaRequest): AsyncIterable { + // 空实现 + } + + /** 测试辅助: 暴露 protected mapError */ + mapErrorPublic(error: unknown) { + return this.mapError(error); + } + + /** 测试辅助: 暴露 protected throwHttpError */ + async throwHttpErrorPublic(response: Response, context: string) { + return this.throwHttpError(response, context); + } + + /** 测试辅助: 暴露 protected fetchWithTimeout */ + async fetchWithTimeoutPublic(url: string, init: RequestInit, timeoutMs: number) { + return this.fetchWithTimeout(url, init, timeoutMs); + } +} + +function makeAdapter(config: Partial = {}): TestAdapter { + return new TestAdapter({ + provider: 'test', + baseURL: 'https://api.test.com', + apiKey: 'sk-test', + defaultModel: 'test-model-a', + ...config, + }); +} + +describe('BaseAdapter — mapError 错误映射', () => { + it('timeout 消息映射为 NETWORK_TIMEOUT 且可重试', () => { + const adapter = makeAdapter(); + const err = adapter.mapErrorPublic(new Error('Request timeout after 30s')); + expect(err.code).toBe(MetonaErrorCode.NETWORK_TIMEOUT); + expect(err.retryable).toBe(true); + expect(err.provider).toBe('test'); + }); + + it('ECONNREFUSED 映射为 NETWORK_ERROR 且可重试', () => { + const adapter = makeAdapter(); + const err = adapter.mapErrorPublic(new Error('fetch failed: ECONNREFUSED 127.0.0.1:11434')); + expect(err.code).toBe(MetonaErrorCode.NETWORK_ERROR); + expect(err.retryable).toBe(true); + }); + + it('HTTP 401 优先按 status code 映射为 AUTH_INVALID 且不可重试', () => { + const adapter = makeAdapter(); + const e = new Error('API error: 401 Unauthorized'); + (e as Error & { status: number }).status = 401; + const err = adapter.mapErrorPublic(e); + expect(err.code).toBe(MetonaErrorCode.AUTH_INVALID); + expect(err.retryable).toBe(false); + }); + + it('HTTP 429 映射为 RATE_LIMITED 且可重试', () => { + const adapter = makeAdapter(); + const e = new Error('429 Too Many Requests'); + (e as Error & { status: number }).status = 429; + const err = adapter.mapErrorPublic(e); + expect(err.code).toBe(MetonaErrorCode.RATE_LIMITED); + expect(err.retryable).toBe(true); + expect(err.retryAfterMs).toBe(5000); + }); + + it('ContentFilterError 优先映射为 CONTENT_FILTERED', () => { + const adapter = makeAdapter(); + const cf = new ContentFilterError('high risk content', 'MiMo'); + const err = adapter.mapErrorPublic(cf); + expect(err.code).toBe(MetonaErrorCode.CONTENT_FILTERED); + expect(err.retryable).toBe(false); + }); + + it('普通 Error 映射为 UNKNOWN 且不可重试', () => { + const adapter = makeAdapter(); + const err = adapter.mapErrorPublic(new Error('whatever')); + expect(err.code).toBe(MetonaErrorCode.UNKNOWN); + expect(err.retryable).toBe(false); + }); +}); + +describe('BaseAdapter — throwHttpError', () => { + function makeResponse(status: number, body: string): Response { + return new Response(body, { status, statusText: 'Status' }); + } + + it('content_filter 错误体抛出 ContentFilterError(含 status)', async () => { + const adapter = makeAdapter(); + const body = JSON.stringify({ error: { code: 'content_filter', message: 'high risk' } }); + await expect( + adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo'), + ).rejects.toBeInstanceOf(ContentFilterError); + try { + await adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo'); + } catch (e) { + expect((e as ContentFilterError & { status: number }).status).toBe(400); + expect((e as ContentFilterError).message).toContain('安全审核拦截'); + } + }); + + it('普通错误体抛出带 status 属性的 Error(供 isRetryableError 判断)', async () => { + const adapter = makeAdapter(); + await expect( + adapter.throwHttpErrorPublic(makeResponse(503, 'Service Unavailable'), 'DeepSeek'), + ).rejects.toThrow('DeepSeek: 503'); + try { + await adapter.throwHttpErrorPublic(makeResponse(503, ''), 'DeepSeek'); + } catch (e) { + expect((e as Error & { status: number }).status).toBe(503); + } + }); +}); + +describe('BaseAdapter — getContextWindow', () => { + it('配置了 contextWindow 时返回配置值', () => { + const adapter = makeAdapter({ contextWindow: 128_000 }); + expect(adapter.getContextWindow()).toBe(128_000); + }); + + it('未配置时返回保守默认值 1M', () => { + const adapter = makeAdapter(); + expect(adapter.getContextWindow()).toBe(1_000_000); + }); +}); + +describe('BaseAdapter — listModels / healthCheck', () => { + it('listModels 将 supportedModels 映射为 MetonaModelInfo', async () => { + const adapter = makeAdapter(); + const models = await adapter.listModels(); + expect(models.map((m) => m.id)).toEqual(['test-model-a', 'test-model-b']); + }); + + it('healthCheck 成功返回 true', async () => { + const adapter = makeAdapter(); + expect(await adapter.healthCheck()).toBe(true); + }); +}); + +describe('BaseAdapter — fetchWithTimeout', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('正常请求返回 Response 并清理 timer', async () => { + const adapter = makeAdapter(); + const mockResponse = new Response('{"ok":true}'); + const fetchMock = vi.fn().mockResolvedValue(mockResponse); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000); + expect(result).toBe(mockResponse); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('超时后 abort 请求(AbortError)', async () => { + const adapter = makeAdapter(); + vi.useFakeTimers(); + const fetchMock = vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')), + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100); + const expectation = expect(promise).rejects.toThrow('Aborted'); + vi.advanceTimersByTime(150); + await expectation; + vi.useRealTimers(); + }); + + it('外部 abort 信号触发请求中断', async () => { + const adapter = makeAdapter(); + const controller = new AbortController(); + adapter.setAbortSignal(controller.signal); + + const fetchMock = vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')), + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000); + const expectation = expect(promise).rejects.toThrow('Aborted'); + controller.abort(); + await expectation; + }); +}); + +describe('BaseAdapter — 接口契约', () => { + it('providerId / 能力声明符合 IMetonaProviderAdapter 契约', () => { + const adapter: IMetonaProviderAdapter = makeAdapter(); + expect(adapter.providerId).toBe('test'); + expect(typeof adapter.send).toBe('function'); + expect(typeof adapter.sendStream).toBe('function'); + expect(typeof adapter.getContextWindow).toBe('function'); + expect(typeof adapter.setAbortSignal).toBe('function'); + }); + + it('sendStream 是 AsyncGenerator(可迭代)', async () => { + const adapter = makeAdapter(); + const events: MetonaStreamEvent[] = []; + for await (const ev of adapter.sendStream({} as MetonaRequest)) { + events.push(ev); + } + expect(events).toHaveLength(0); + expect(MetonaStreamEventType.TEXT_DELTA).toBe('text_delta'); + }); +}); diff --git a/electron/harness/adapters/__tests__/sse-stream.test.ts b/electron/harness/adapters/__tests__/sse-stream.test.ts new file mode 100644 index 0000000..f979099 --- /dev/null +++ b/electron/harness/adapters/__tests__/sse-stream.test.ts @@ -0,0 +1,238 @@ +/** + * SSE 流式解析器单元测试(v0.4.1 测试补齐) + * 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE / + * [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过 + */ + +import { describe, it, expect } from 'vitest'; +import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream'; +import { MetonaStreamEventType } from '../../types'; + +/** 构造 SSE 测试流 */ +function makeStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(encoder.encode(c)); + controller.close(); + }, + }); +} + +async function collect( + stream: ReadableStream, +): Promise> { + const events: Array<{ type: string; [k: string]: unknown }> = []; + for await (const ev of parseSSEStream(stream, 'req_test', 'sess_test', 1)) { + events.push(ev as unknown as { type: string; [k: string]: unknown }); + } + return events; +} + +describe('parseSSEStream — 文本与推理增量', () => { + it('TEXT_DELTA 事件按序产出', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":",世界"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA); + expect(deltas).toHaveLength(2); + expect(deltas[0].delta).toBe('你好'); + expect(deltas[1].delta).toBe(',世界'); + expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); + }); + + it('REASONING_DELTA(Thinking 模式)事件产出', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"reasoning_content":"让我想想"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA); + expect(reasoning?.delta).toBe('让我想想'); + }); + + it('同一个 chunk 中 content 和 reasoning_content 同时产出', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"content":"答","reasoning_content":"思考"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1); + expect(events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA)).toHaveLength(1); + }); +}); + +describe('parseSSEStream — 工具调用增量拼接', () => { + it('分段 arguments 拼接为完整 JSON 并在 finish_reason=tool_calls 时 flush', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\\"pa"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"a.ts\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE); + expect(completes).toHaveLength(1); + const tc = completes[0].toolCall as { name: string; args: Record }; + expect(tc.name).toBe('read_file'); + expect(tc.args).toEqual({ path: 'a.ts' }); + }); + + it('多个工具调用按 index 分别拼接', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"tool_a","arguments":"{}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"tool_b","arguments":"{}"}}]}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE); + expect(completes).toHaveLength(2); + const names = completes.map((e) => (e.toolCall as { name: string }).name); + expect(names).toEqual(['tool_a', 'tool_b']); + }); + + it('损坏的 args JSON 跳过该工具调用且不中断流', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"后续文本"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + // 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE) + expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength( + 0, + ); + // 流继续处理后续事件 + expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true); + expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); + }); +}); + +describe('parseSSEStream — USAGE 与容错', () => { + it('USAGE 事件解析 DeepSeek 缓存字段', async () => { + const events = await collect( + makeStream([ + 'data: {"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE); + const usage = usageEvent?.usage as Record; + expect(usage.inputTokens).toBe(100); + expect(usage.outputTokens).toBe(50); + expect(usage.totalTokens).toBe(150); + expect(usage.cacheHitTokens).toBe(80); + expect(usage.cacheMissTokens).toBe(20); + }); + + it('坏 JSON 行不中断后续事件(记录警告后继续)', async () => { + const events = await collect( + makeStream([ + 'data: {broken json\n\n', + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true); + expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); + }); + + it('跨 chunk 分割的 SSE 行正确拼接', async () => { + // "data: {...}\n\n" 被切到两个网络 chunk 中 + const events = await collect( + makeStream([ + 'data: {"choices":[{"delta":{"cont', + 'ent":"拼接成功"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA); + expect(delta?.delta).toBe('拼接成功'); + }); + + it('空行与非 data 行被忽略', async () => { + const events = await collect( + makeStream([ + ': comment line\n\n', + '\n', + 'data: {"choices":[{"delta":{"content":"x"}}]}\n\n', + 'data: [DONE]\n\n', + ]), + ); + expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1); + }); +}); + +describe('parseOpenAICompatibleResponse — 非流式响应', () => { + it('解析普通文本响应', () => { + const result = parseOpenAICompatibleResponse({ + choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + expect(result.content).toBe('hello'); + expect(result.finishReason).toBe('stop'); + expect(result.usage.inputTokens).toBe(10); + expect(result.toolCalls).toBeUndefined(); + }); + + it('解析 reasoning_content(Thinking 模式)', () => { + const result = parseOpenAICompatibleResponse({ + choices: [ + { message: { content: 'answer', reasoning_content: 'thinking...' }, finish_reason: 'stop' }, + ], + usage: {}, + }); + expect(result.reasoningContent).toBe('thinking...'); + }); + + it('解析 tool_calls(字符串 arguments 反序列化)', () => { + const result = parseOpenAICompatibleResponse({ + choices: [ + { + message: { + content: null, + tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{"cmd":"ls"}' } }], + }, + finish_reason: 'tool_calls', + }, + ], + usage: {}, + }); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe('run'); + expect(result.toolCalls![0].args).toEqual({ cmd: 'ls' }); + }); + + it('损坏的 tool_calls arguments 降级为空对象', () => { + const result = parseOpenAICompatibleResponse({ + choices: [ + { + message: { + content: null, + tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }], + }, + finish_reason: 'tool_calls', + }, + ], + usage: {}, + }); + expect(result.toolCalls![0].args).toEqual({}); + }); + + it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => { + const result = parseOpenAICompatibleResponse({ + choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }], + usage: {}, + }); + expect(result.finishReason).toBe('stop'); + }); +}); diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index f6dff6d..64071b4 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -124,7 +124,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { * @param init fetch init(不含 signal,由本方法内部管理) * @param timeoutMs 超时时间(毫秒) */ - protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + protected async fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, + ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -181,7 +185,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { */ protected async throwHttpError(response: Response, context: string): Promise { let errorBody = ''; - try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ } + try { + errorBody = await response.text(); + } catch { + /* body 可能已消费或为 null */ + } // v0.3.17: 解析 JSON 错误体,识别 content_filter if (errorBody) { @@ -204,7 +212,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { } } - const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`); + const error = new Error( + `${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`, + ); (error as Error & { status: number }).status = response.status; throw error; } @@ -226,7 +236,10 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { const msg = error.message.toLowerCase(); - if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) { + // v0.4.1 修复: msg 已 toLowerCase,网络错误码常量必须用小写比较 + //(原 'ETIMEDOUT'/'ECONNREFUSED' 等大写常量在小写消息上永不匹配, + // 导致网络错误全部落入 UNKNOWN,无法触发引擎的重试逻辑) + if (msg.includes('timeout') || msg.includes('etimedout')) { return { code: MetonaErrorCode.NETWORK_TIMEOUT, message: error.message, @@ -236,7 +249,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { }; } - if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) { + if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('econnreset')) { return { code: MetonaErrorCode.NETWORK_ERROR, message: error.message, diff --git a/electron/harness/hooks/__tests__/confirmation-hook.test.ts b/electron/harness/hooks/__tests__/confirmation-hook.test.ts new file mode 100644 index 0000000..35b1d9e --- /dev/null +++ b/electron/harness/hooks/__tests__/confirmation-hook.test.ts @@ -0,0 +1,317 @@ +/** + * ConfirmationHook 单元测试(v0.4.1 测试补齐) + * 覆盖:自动执行放行、会话内记忆(批准/拒绝)、拒绝记忆 TTL 过期、 + * 超时拒绝、用户批准、批量审批、pending 管理、恢复询问接口 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { BrowserWindow } from 'electron'; +import { ConfirmationHook } from '../confirmation-hook'; +import type { MetonaToolCall, MetonaToolDef } from '../../types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../types'; + +/** 需要确认的高风险工具定义 */ +const HIGH_RISK_DEF: MetonaToolDef = { + name: 'run_command', + description: 'Execute shell command (test fixture)', + parameters: { type: 'object', properties: {}, required: [] }, + category: MetonaToolCategory.CODE_EXECUTION, + riskLevel: MetonaRiskLevel.HIGH, + requiresPermission: true, + timeoutMs: 1_000, +}; + +/** 低风险工具定义(无需确认) */ +const SAFE_DEF: MetonaToolDef = { + name: 'read_file', + description: 'Read file (test fixture)', + parameters: { type: 'object', properties: {}, required: [] }, + category: MetonaToolCategory.FILE_SYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 1_000, +}; + +/** 第二个需确认的高风险工具(用于记忆互不干扰的测试) */ +const HIGH_RISK_DEF_2: MetonaToolDef = { + name: 'delete_file', + description: 'Delete file (test fixture)', + parameters: { type: 'object', properties: {}, required: [] }, + category: MetonaToolCategory.FILE_SYSTEM, + riskLevel: MetonaRiskLevel.HIGH, + requiresPermission: true, + timeoutMs: 1_000, +}; + +let idCounter = 0; +function makeToolCall(name = 'run_command'): MetonaToolCall { + return { + id: `tc_${++idCounter}`, + name, + args: { command: 'ls' }, + iteration: 1, + timestamp: Date.now(), + }; +} + +function makeMockWindow(): BrowserWindow { + return { + isDestroyed: () => false, + webContents: { send: vi.fn() }, + } as unknown as BrowserWindow; +} + +describe('ConfirmationHook — 免确认路径', () => { + it('未注册工具定义时放行(由 ToolRegistry 处理未知工具错误)', async () => { + const hook = new ConfirmationHook(null, null); + const result = await hook.beforeExecute(makeToolCall('unknown_tool'), 'sess'); + expect(result.blocked).toBe(false); + }); + + it('无需确认的工具直接放行', async () => { + const hook = new ConfirmationHook(null, null); + hook.setToolDefs([SAFE_DEF]); + const result = await hook.beforeExecute(makeToolCall('read_file'), 'sess'); + expect(result.blocked).toBe(false); + }); + + it('持久化自动执行(autoExecute)的工具放行', async () => { + const hook = new ConfirmationHook(null, null); + hook.setToolDefs([HIGH_RISK_DEF]); + hook.setAutoExecute('run_command', true); + const result = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(result.blocked).toBe(false); + expect(hook.getAutoExecuteList()).toContain('run_command'); + }); + + it('记住批准(remember approved)后同会话放行', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + // 第一次调用 → 等待确认 → 用户批准并记住 + const p1 = hook.beforeExecute(makeToolCall(), 'sess'); + const pending = hook.getPendingConfirmations(); + expect(pending).toHaveLength(1); + hook.resolveConfirmation(pending[0].toolCallId, true, true, false); + expect((await p1).blocked).toBe(false); + + // 第二次调用 — 记住的批准直接放行 + const result = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(result.blocked).toBe(false); + }); +}); + +describe('ConfirmationHook — 拒绝与阻断', () => { + it('记住拒绝后同会话阻断(reason 含 previously denied)', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p1 = hook.beforeExecute(makeToolCall(), 'sess'); + const pending = hook.getPendingConfirmations(); + hook.resolveConfirmation(pending[0].toolCallId, false, true, false); + expect((await p1).blocked).toBe(true); + + const result = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('previously denied'); + }); + + it('拒绝记忆 TTL 过期后恢复询问(v0.4.1)', async () => { + vi.useFakeTimers(); + try { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + // 记住拒绝 + const p1 = hook.beforeExecute(makeToolCall(), 'sess'); + const pending1 = hook.getPendingConfirmations(); + hook.resolveConfirmation(pending1[0].toolCallId, false, true, false); + await p1; + + // 拒绝记忆立即生效 + const blockedNow = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(blockedNow.blocked).toBe(true); + expect(blockedNow.reason).toContain('previously denied'); + + // 快进 11 分钟(TTL 10 分钟)→ 拒绝记忆过期,恢复询问流程 + vi.setSystemTime(Date.now() + 11 * 60 * 1000); + // 撤掉主窗口,使询问流程以 'no main window' 阻断(证明走到了询问分支而非记忆分支) + hook.setMainWindow(null as unknown as BrowserWindow); + const result = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('no main window available'); + // 过期记忆已被清理 + expect(hook.getRememberedDenials()).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it('无主窗口时安全阻断(fail-closed)', async () => { + const hook = new ConfirmationHook(null, null); + hook.setToolDefs([HIGH_RISK_DEF]); + const result = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('no main window available'); + }); + + it('用户拒绝单次调用 → blocked 且 reason 含 User denied', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p = hook.beforeExecute(makeToolCall(), 'sess'); + const pending = hook.getPendingConfirmations(); + hook.resolveConfirmation(pending[0].toolCallId, false, false, false); + const result = await p; + expect(result.blocked).toBe(true); + expect(result.reason).toContain('User denied'); + }); +}); + +describe('ConfirmationHook — 超时行为', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('确认超时视为拒绝(blocked)', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + hook.setConfirmationTimeout(30_000); // 最小值 30s + + const p = hook.beforeExecute(makeToolCall(), 'sess'); + // 快进超过超时时间 + vi.advanceTimersByTime(31_000); + const result = await p; + expect(result.blocked).toBe(true); + expect(result.reason).toContain('User denied'); + // pending 已被超时清理 + expect(hook.getPendingConfirmations()).toHaveLength(0); + }); + + it('确认超时与用户点击的竞态:先到者赢(settled 标志)', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + hook.setConfirmationTimeout(30_000); + + const p = hook.beforeExecute(makeToolCall(), 'sess'); + // timer 回调已入队但未执行时,用户点击批准 + vi.advanceTimersByTime(30_000); + // 超时已 resolve(false) — 后续 resolveConfirmation 无效果 + const pending = hook.getPendingConfirmations(); + expect(pending).toHaveLength(0); + const result = await p; + expect(result.blocked).toBe(true); + }); +}); + +describe('ConfirmationHook — 批量审批(v0.3.2)', () => { + it('批量批准并行工具调用', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p1 = hook.beforeExecute(makeToolCall(), 'sess'); + const p2 = hook.beforeExecute(makeToolCall(), 'sess'); + expect(hook.getPendingConfirmations()).toHaveLength(2); + + const ids = hook.getPendingConfirmations().map((r) => r.toolCallId); + const resolved = hook.resolveConfirmationsBatch(ids, true, false, false); + expect(resolved).toHaveLength(2); + + expect((await p1).blocked).toBe(false); + expect((await p2).blocked).toBe(false); + }); + + it('批量拒绝 + 记住 → 同工具后续调用被记忆阻断', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p1 = hook.beforeExecute(makeToolCall(), 'sess'); + const p2 = hook.beforeExecute(makeToolCall(), 'sess'); + const ids = hook.getPendingConfirmations().map((r) => r.toolCallId); + hook.resolveConfirmationsBatch(ids, false, true, false); + + expect((await p1).blocked).toBe(true); + expect((await p2).blocked).toBe(true); + + const after = await hook.beforeExecute(makeToolCall(), 'sess'); + expect(after.blocked).toBe(true); + expect(after.reason).toContain('previously denied'); + }); + + it('批量批准 + autoExecute → 写入持久化自动执行列表', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p = hook.beforeExecute(makeToolCall(), 'sess'); + const ids = hook.getPendingConfirmations().map((r) => r.toolCallId); + hook.resolveConfirmationsBatch(ids, true, false, true); + + expect((await p).blocked).toBe(false); + expect(hook.getAutoExecuteList()).toContain('run_command'); + }); +}); + +describe('ConfirmationHook — 拒绝记忆管理接口(v0.4.1)', () => { + it('getRememberedDenials 只返回拒绝记忆(含剩余时间)', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF, HIGH_RISK_DEF_2]); + + // 记住一个批准(run_command)、一个拒绝(delete_file) + const pApprove = hook.beforeExecute(makeToolCall('run_command'), 'sess'); + hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false); + await pApprove; + + const pDeny = hook.beforeExecute(makeToolCall('delete_file'), 'sess'); + hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false); + await pDeny; + + const denials = hook.getRememberedDenials(); + expect(denials).toHaveLength(1); + expect(denials[0].toolName).toBe('delete_file'); + expect(denials[0].expiresInSeconds).toBeGreaterThan(0); + expect(denials[0].expiresInSeconds).toBeLessThanOrEqual(600); + }); + + it('resetRememberedDenial 重置后恢复询问', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p = hook.beforeExecute(makeToolCall(), 'sess'); + hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false); + await p; + expect(hook.getRememberedDenials()).toHaveLength(1); + + // 重置 → 拒绝记忆清空 + expect(hook.resetRememberedDenial('run_command')).toBe(true); + expect(hook.getRememberedDenials()).toHaveLength(0); + + // 后续调用恢复询问(有窗口 → 产生新 pending) + const p2 = hook.beforeExecute(makeToolCall(), 'sess'); + expect(hook.getPendingConfirmations()).toHaveLength(1); + hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false); + expect((await p2).blocked).toBe(false); + }); + + it('resetRememberedDenial 对无拒绝记忆的工具返回 false', () => { + const hook = new ConfirmationHook(null, null); + expect(hook.resetRememberedDenial('run_command')).toBe(false); + }); +}); + +describe('ConfirmationHook — clearPending', () => { + it('清空所有等待中的确认(全部视为拒绝)', async () => { + const hook = new ConfirmationHook(makeMockWindow(), null); + hook.setToolDefs([HIGH_RISK_DEF]); + + const p1 = hook.beforeExecute(makeToolCall(), 'sess'); + const p2 = hook.beforeExecute(makeToolCall(), 'sess'); + hook.clearPending(); + + expect((await p1).blocked).toBe(true); + expect((await p2).blocked).toBe(true); + expect(hook.getPendingConfirmations()).toHaveLength(0); + }); +}); diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index af60236..8916bd5 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -40,23 +40,35 @@ export class ConfirmationHook implements PreToolHook { /** 工具定义缓存(由外部设置) */ private toolDefs = new Map(); - /** 用户选择记忆(同一会话内不再重复询问) */ - private rememberedDecisions = new Map(); + /** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */ + private rememberedDecisions = new Map(); + + /** + * v0.4.1: 会话内拒绝记忆的 TTL(10 分钟) + * + * 历史问题:用户勾选"记住拒绝"后,该工具在本会话永久被拒且无恢复入口, + * 用户只能重启会话。现给拒绝记忆加 TTL——过期后恢复询问; + * 批准记忆不受 TTL 影响(记住批准是低风险决定,保留原语义)。 + */ + private static readonly DENIAL_TTL_MS = 10 * 60 * 1000; /** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */ private autoExecuteTools = new Set(); /** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */ - private pendingConfirmations = new Map void; - timer: NodeJS.Timeout; - toolName: string; - expiresAt: number; - /** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */ - args?: Record; - riskLevel?: string; - reason?: string; - }>(); + private pendingConfirmations = new Map< + string, + { + resolve: (v: boolean) => void; + timer: NodeJS.Timeout; + toolName: string; + expiresAt: number; + /** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */ + args?: Record; + riskLevel?: string; + reason?: string; + } + >(); /** 确认超时时间(可从配置读取,默认 120 秒) */ private confirmationTimeoutMs = 120_000; @@ -168,7 +180,12 @@ export class ConfirmationHook implements PreToolHook { * @param remember 会话内记住决定 * @param autoExecute 永久自动执行(持久化) */ - resolveConfirmation(toolCallId: string, approved: boolean, remember: boolean, autoExecute: boolean = false): void { + resolveConfirmation( + toolCallId: string, + approved: boolean, + remember: boolean, + autoExecute: boolean = false, + ): void { const pending = this.pendingConfirmations.get(toolCallId); if (pending) { clearTimeout(pending.timer); @@ -177,9 +194,9 @@ export class ConfirmationHook implements PreToolHook { if (autoExecute && approved) { this.setAutoExecute(pending.toolName, true); } - // 会话内记忆 + // 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期) if (remember) { - this.rememberedDecisions.set(pending.toolName, approved); + this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() }); } this.pendingConfirmations.delete(toolCallId); } @@ -225,7 +242,7 @@ export class ConfirmationHook implements PreToolHook { this.setAutoExecute(pending.toolName, true); } if (remember) { - this.rememberedDecisions.set(pending.toolName, approved); + this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() }); } } } @@ -257,6 +274,44 @@ export class ConfirmationHook implements PreToolHook { return result; } + /** + * v0.4.1: 获取本会话内记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口) + * + * 拒绝记忆有 TTL(默认 10 分钟),到期自动恢复询问; + * 此方法返回未过期的拒绝记忆,前端可提供"重新询问"按钮主动重置。 + */ + getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> { + const now = Date.now(); + const result: Array<{ toolName: string; expiresInSeconds: number }> = []; + for (const [toolName, decision] of this.rememberedDecisions) { + if (decision.approved) continue; + const elapsed = now - decision.at; + if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) { + // 已过期 — 顺手清理,避免列表返回过期条目 + this.rememberedDecisions.delete(toolName); + continue; + } + result.push({ + toolName, + expiresInSeconds: Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000), + }); + } + return result; + } + + /** + * v0.4.1: 重置指定工具的会话内拒绝记忆(恢复询问) + * @returns true 表示重置成功(存在该工具的拒绝记忆);false 表示没有可重置的记忆 + */ + resetRememberedDenial(toolName: string): boolean { + const decision = this.rememberedDecisions.get(toolName); + if (decision && !decision.approved) { + this.rememberedDecisions.delete(toolName); + return true; + } + return false; + } + async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise { const def = this.toolDefs.get(toolCall.name); if (!def) { @@ -265,8 +320,8 @@ export class ConfirmationHook implements PreToolHook { } // 检查是否需要确认 - const needsConfirmation = def.requiresPermission || - ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel); + const needsConfirmation = + def.requiresPermission || ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel); if (!needsConfirmation) { return { blocked: false }; @@ -277,11 +332,21 @@ export class ConfirmationHook implements PreToolHook { return { blocked: false }; } - // 检查是否有记住的决策 + // 检查是否有记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问) const remembered = this.rememberedDecisions.get(toolCall.name); if (remembered !== undefined) { - if (remembered) return { blocked: false }; - return { blocked: true, reason: `User previously denied tool "${toolCall.name}"` }; + const isExpiredDenial = + !remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS; + if (isExpiredDenial) { + // 拒绝记忆已过期 — 移除并继续走正常确认流程 + this.rememberedDecisions.delete(toolCall.name); + } else { + if (remembered.approved) return { blocked: false }; + return { + blocked: true, + reason: `User previously denied tool "${toolCall.name}" (remembered in this session; expires in ${Math.ceil((ConfirmationHook.DENIAL_TTL_MS - (Date.now() - remembered.at)) / 60000)} min)`, + }; + } } // 如果没有主窗口,安全起见阻止执行 @@ -347,9 +412,10 @@ export class ConfirmationHook implements PreToolHook { this.lastTimeoutToastAt = now; // 统计当前还有多少 pending(含本次刚超时的) const pendingCount = this.pendingConfirmations.size + 1; - const message = pendingCount > 1 - ? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行` - : `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`; + const message = + pendingCount > 1 + ? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行` + : `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`; this.mainWindow.webContents.send('toast:show', { type: 'warning', message, diff --git a/electron/harness/hooks/pre-tool.ts b/electron/harness/hooks/pre-tool.ts index 3eca645..6cf79f9 100644 --- a/electron/harness/hooks/pre-tool.ts +++ b/electron/harness/hooks/pre-tool.ts @@ -21,15 +21,16 @@ export interface PreToolHook { export class PermissionCheckHook implements PreToolHook { constructor(private policyEngine: PolicyEngine) {} - async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise { - const result = this.policyEngine.checkAuthorization(toolCall.name, toolCall.args); + async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise { + // v0.4.1: 透传 sessionId 使频率限制按会话隔离(多会话并发时各自独立配额) + const result = this.policyEngine.checkAuthorization(toolCall.name, toolCall.args, sessionId); if (!result.authorized) { return { blocked: true, reason: result.reason }; } // v0.3.0 修复: 授权成功后记录调用,使频率限制功能生效 // 在授权检查通过后立即记录,即使后续工具执行失败也计入频率 // 这样可以防止通过故意制造错误来绕过频率限制 - this.policyEngine.recordCall(toolCall.name); + this.policyEngine.recordCall(toolCall.name, sessionId); return { blocked: false }; } } diff --git a/electron/harness/sandbox/__tests__/permissions.test.ts b/electron/harness/sandbox/__tests__/permissions.test.ts index 4684604..a93aa1c 100644 --- a/electron/harness/sandbox/__tests__/permissions.test.ts +++ b/electron/harness/sandbox/__tests__/permissions.test.ts @@ -9,12 +9,34 @@ import { PolicyEngine, DEFAULT_POLICIES } from '../permissions'; describe('PolicyEngine 默认策略', () => { it('所有内置工具均有策略配置', () => { const knownTools = [ - 'read_file', 'write_file', 'list_directory', 'search_files', 'delete_file', - 'file_move', 'file_info', 'file_editor', 'code_search', 'diff_viewer', - 'web_search', 'web_fetch', 'web_browser', 'http_request', - 'memory_store', 'memory_search', 'run_command', 'task_manager', - 'delegate_task', 'git_status', 'git_diff', 'git_log', 'git_commit', - 'lint_code', 'run_tests', 'project_info', 'think', 'view_image', + 'read_file', + 'write_file', + 'list_directory', + 'search_files', + 'delete_file', + 'file_move', + 'file_info', + 'file_editor', + 'code_search', + 'diff_viewer', + 'web_search', + 'web_fetch', + 'web_browser', + 'http_request', + 'memory_store', + 'memory_search', + 'run_command', + 'task_manager', + 'delegate_task', + 'git_status', + 'git_diff', + 'git_log', + 'git_commit', + 'lint_code', + 'run_tests', + 'project_info', + 'think', + 'view_image', ]; for (const tool of knownTools) { expect(DEFAULT_POLICIES.some((p) => p.toolName === tool)).toBe(true); @@ -57,7 +79,9 @@ describe('deniedPatterns 深度扫描', () => { it('正常路径不误判', () => { const engine = new PolicyEngine(); - expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe(true); + expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe( + true, + ); }); }); @@ -91,3 +115,45 @@ describe('通配符策略(mcp_*)', () => { expect(result.requiresConfirmation).toBe(true); }); }); + +describe('频率限制会话隔离(v0.4.1)', () => { + it('不同会话各自独立配额(一个会话耗尽不影响另一个)', () => { + const engine = new PolicyEngine(); + // web_search 默认 maxFrequency: 10 + // 会话 A 耗尽全部配额 + for (let i = 0; i < 10; i++) { + expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-A').authorized).toBe( + true, + ); + engine.recordCall('web_search', 'session-A'); + } + // 会话 A 已被限流 + expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-A').authorized).toBe( + false, + ); + // 会话 B 配额不受影响 + expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-B').authorized).toBe( + true, + ); + expect(engine.recordCall('web_search', 'session-B') === undefined).toBe(true); + }); + + it('recordCall 与 checkAuthorization 使用相同的会话 key', () => { + const engine = new PolicyEngine(); + // 会话 A 记录 10 次 + for (let i = 0; i < 10; i++) engine.recordCall('web_search', 'session-A'); + // 会话 A 限流,会话 B 不限 + expect(engine.checkAuthorization('web_search', {}, 'session-A').authorized).toBe(false); + expect(engine.checkAuthorization('web_search', {}, 'session-B').authorized).toBe(true); + }); + + it('无 sessionId 时计入 global 桶(向后兼容)', () => { + const engine = new PolicyEngine(); + // 旧式调用(无 sessionId)共享 global 桶 + for (let i = 0; i < 10; i++) { + engine.recordCall('web_search'); + } + expect(engine.checkAuthorization('web_search', {}).authorized).toBe(false); + expect(engine.checkAuthorization('web_search', {}, 'any-session').authorized).toBe(true); + }); +}); diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index b54752c..3ad9924 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -32,21 +32,65 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ // H-5 修复: 移除 /MEMORY\.md/i 粗粒度正则 — 之前会误拦子目录的 MEMORY.md // 改为在 engine.ts executeToolSafely 中进行精确的根目录校验(仅保护 workspacePath/MEMORY.md) // @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected - { toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i] }, + { + toolName: 'read_file', + requiredLevel: PermissionLevel.READ, + deniedPatterns: [ + /\/etc(?:\/|["'\s,}]|$)/, + /\/proc(?:\/|["'\s,}]|$)/, + /C:\\Windows\\/i, + /C:\\System32\\/i, + ], + }, { toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 }, { toolName: 'list_directory', requiredLevel: PermissionLevel.READ }, { toolName: 'search_files', requiredLevel: PermissionLevel.READ }, { toolName: 'memory_search', requiredLevel: PermissionLevel.READ }, - { toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 }, + { + toolName: 'write_file', + requiredLevel: PermissionLevel.WRITE, + deniedPatterns: [ + /\/etc(?:\/|["'\s,}]|$)/, + /\/proc(?:\/|["'\s,}]|$)/, + /\/System(?:\/|["'\s,}]|$)/, + /C:\\Windows\\/i, + /C:\\System32\\/i, + ], + requireConfirmation: true, + maxFrequency: 5, + }, { toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE }, - { toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 10 }, + { + toolName: 'run_command', + requiredLevel: PermissionLevel.EXTERNAL_ACTION, + deniedPatterns: [/MEMORY\.md/i], + requireConfirmation: true, + maxFrequency: 10, + }, { toolName: 'web_fetch', requiredLevel: PermissionLevel.READ }, // web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具) // 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION - { toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 }, + { + toolName: 'web_browser', + requiredLevel: PermissionLevel.EXTERNAL_ACTION, + requireConfirmation: true, + maxFrequency: 20, + }, // v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截 // file_editor — 精准文件编辑(WRITE),与 write_file 同级安全约束 - { toolName: 'file_editor', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 10 }, + { + toolName: 'file_editor', + requiredLevel: PermissionLevel.WRITE, + deniedPatterns: [ + /\/etc(?:\/|["'\s,}]|$)/, + /\/proc(?:\/|["'\s,}]|$)/, + /\/System(?:\/|["'\s,}]|$)/, + /C:\\Windows\\/i, + /C:\\System32\\/i, + ], + requireConfirmation: true, + maxFrequency: 10, + }, // code_search — 基于 ripgrep 的只读搜索(READ) { toolName: 'code_search', requiredLevel: PermissionLevel.READ }, // diff_viewer — 文件/文本差异对比(只读,READ) @@ -54,17 +98,32 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ // task_manager — 任务管理(数据库读写,低风险 WRITE) { toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE }, // delegate_task — 子任务委派(启动 SubAgent,EXTERNAL_ACTION) - { toolName: 'delegate_task', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: false, maxFrequency: 5 }, + { + toolName: 'delegate_task', + requiredLevel: PermissionLevel.EXTERNAL_ACTION, + requireConfirmation: false, + maxFrequency: 5, + }, // C-7 修复: MCP 工具通配符策略 — MCP 工具名称动态生成(mcp_{serverName}_{toolName}) // 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具 // @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES - { toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 }, + { + toolName: 'mcp_*', + requiredLevel: PermissionLevel.EXTERNAL_ACTION, + requireConfirmation: true, + maxFrequency: 20, + }, // v0.3.1: Git 工具集(4 个) { toolName: 'git_status', requiredLevel: PermissionLevel.READ }, { toolName: 'git_diff', requiredLevel: PermissionLevel.READ }, { toolName: 'git_log', requiredLevel: PermissionLevel.READ }, - { toolName: 'git_commit', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 10 }, + { + toolName: 'git_commit', + requiredLevel: PermissionLevel.WRITE, + requireConfirmation: true, + maxFrequency: 10, + }, // v0.3.1: 开发工具集(3 个) { toolName: 'lint_code', requiredLevel: PermissionLevel.READ }, @@ -81,10 +140,20 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ { toolName: 'view_image', requiredLevel: PermissionLevel.READ }, // v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认 - { toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 }, + { + toolName: 'delete_file', + requiredLevel: PermissionLevel.WRITE, + requireConfirmation: true, + maxFrequency: 30, + }, // v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认 - { toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 }, + { + toolName: 'file_move', + requiredLevel: PermissionLevel.WRITE, + requireConfirmation: true, + maxFrequency: 30, + }, // v0.3.3: 文件信息查询工具(1 个)— 只读 { toolName: 'file_info', requiredLevel: PermissionLevel.READ }, @@ -93,12 +162,22 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ export class PolicyEngine { private policies: Map = new Map(); - /** v0.3.0: 工具调用频率追踪 — 工具名 -> 调用时间戳列表 */ + /** + * v0.4.1: 工具调用频率追踪 — 频率 key -> 调用时间戳列表 + * key 格式: `${sessionId}:${toolName}`(会话隔离) + * 历史问题:v0.3.0 以 toolName 为 key,所有会话共享同一配额—— + * P2-10 支持多会话并发后,一个会话可耗尽另一个会话的配额(如 web_search 10 次/分钟) + */ private callFrequency: Map = new Map(); /** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */ private readonly FREQ_WINDOW_MS = 60_000; + /** v0.4.1: 构造会话隔离的频率 key(sessionId 缺失时回退 'global' 保持兼容) */ + private freqKey(toolName: string, sessionId?: string): string { + return `${sessionId || 'global'}:${toolName}`; + } + /** * v0.3.0 修复:customPolicies 与 DEFAULT_POLICIES 合并而非完全覆盖 * @@ -120,7 +199,20 @@ export class PolicyEngine { } } - checkAuthorization(toolName: string, args: Record): { + /** + * 权限校验 + * + * v0.4.1: 新增可选 sessionId 参数 — 频率限制按会话隔离(多会话并发时各自独立配额) + * + * @param toolName 工具名 + * @param args 工具参数 + * @param sessionId 会话 ID(可选;缺失时频率配额计入 'global' 桶保持向后兼容) + */ + checkAuthorization( + toolName: string, + args: Record, + sessionId?: string, + ): { authorized: boolean; reason?: string; level: PermissionLevel; @@ -223,9 +315,9 @@ export class PolicyEngine { } } - // v0.3.0: 频率限制检查 + // v0.3.0: 频率限制检查(v0.4.1: 按会话隔离) if (policy.maxFrequency !== undefined) { - const freqCheck = this.checkFrequency(toolName, policy.maxFrequency); + const freqCheck = this.checkFrequency(toolName, policy.maxFrequency, sessionId); if (!freqCheck.allowed) { return { authorized: false, @@ -252,23 +344,31 @@ export class PolicyEngine { * v0.3.0 修复: * - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏) * + * v0.4.1: 新增可选 sessionId 参数 — 频率配额按会话隔离 + * * @param toolName 工具名称 * @param maxFreq 最大频率(每分钟) + * @param sessionId 会话 ID(可选;缺失时计入 'global' 桶) * @returns 检查结果 */ - checkFrequency(toolName: string, maxFreq?: number): { allowed: boolean; reason?: string } { + checkFrequency( + toolName: string, + maxFreq?: number, + sessionId?: string, + ): { allowed: boolean; reason?: string } { const policy = this.policies.get(toolName); const limit = maxFreq ?? policy?.maxFrequency; if (limit === undefined) return { allowed: true }; + const key = this.freqKey(toolName, sessionId); const now = Date.now(); - const calls = this.callFrequency.get(toolName) ?? []; + const calls = this.callFrequency.get(key) ?? []; // 移除时间窗口外的调用记录 const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS); // v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长 if (validCalls.length !== calls.length) { - this.callFrequency.set(toolName, validCalls); + this.callFrequency.set(key, validCalls); } if (validCalls.length >= limit) { @@ -284,16 +384,19 @@ export class PolicyEngine { * v0.3.0: 记录工具调用(工具成功执行后调用) * * v0.3.0 修复:同时清理过期记录,防止数组无限增长 + * v0.4.1: 新增可选 sessionId 参数 — 与 checkFrequency 的会话隔离配对使用 * * @param toolName 工具名称 + * @param sessionId 会话 ID(可选;缺失时计入 'global' 桶) */ - recordCall(toolName: string): void { + recordCall(toolName: string, sessionId?: string): void { + const key = this.freqKey(toolName, sessionId); const now = Date.now(); - const calls = this.callFrequency.get(toolName) ?? []; + const calls = this.callFrequency.get(key) ?? []; // v0.3.0 修复:记录新调用时同时清理过期记录 const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS); validCalls.push(now); - this.callFrequency.set(toolName, validCalls); + this.callFrequency.set(key, validCalls); } // v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理, diff --git a/electron/harness/tools/built-in/__tests__/command.test.ts b/electron/harness/tools/built-in/__tests__/command.test.ts index cb155ad..b874d6e 100644 --- a/electron/harness/tools/built-in/__tests__/command.test.ts +++ b/electron/harness/tools/built-in/__tests__/command.test.ts @@ -15,7 +15,9 @@ describe('RunCommandTool.validateCommand', () => { const tool = new RunCommandTool(); // 访问私有方法 const validate = (cmd: string) => - (tool as unknown as { validateCommand: (c: string) => { allowed: boolean; reason?: string } }).validateCommand(cmd); + ( + tool as unknown as { validateCommand: (c: string) => { allowed: boolean; reason?: string } } + ).validateCommand(cmd); const blocked = (cmd: string) => { const result = validate(cmd); @@ -87,3 +89,28 @@ describe('RunCommandTool.validateCommand', () => { allowed('rm -rf node_modules'); }); }); + +describe('RunCommandTool — Windows execFile 白名单(v0.4.1)', () => { + const tool = new RunCommandTool(); + const parseSimple = (cmd: string) => + ( + tool as unknown as { + parseCommandSimple: (c: string) => { command: string; args: string[] } | null; + } + ).parseCommandSimple(cmd); + + it('白名单命令解析为简单命令(无 shell 运算符)', () => { + const npm = parseSimple('npm install'); + expect(npm).toEqual({ command: 'npm', args: ['install'] }); + const git = parseSimple('git commit -m "fix: bug"'); + expect(git).toEqual({ command: 'git', args: ['commit', '-m', 'fix: bug'] }); + const node = parseSimple('node dist/main.js'); + expect(node).toEqual({ command: 'node', args: ['dist/main.js'] }); + }); + + it('含 shell 运算符的命令不解析为简单命令(继续走 exec 双层校验)', () => { + expect(parseSimple('npm install && npm test')).toBeNull(); + expect(parseSimple('git log | head -5')).toBeNull(); + expect(parseSimple('echo hi > out.txt')).toBeNull(); + }); +}); diff --git a/electron/harness/tools/built-in/__tests__/web-search-parsers.test.ts b/electron/harness/tools/built-in/__tests__/web-search-parsers.test.ts new file mode 100644 index 0000000..4b37e64 --- /dev/null +++ b/electron/harness/tools/built-in/__tests__/web-search-parsers.test.ts @@ -0,0 +1,174 @@ +/** + * web_search 搜索引擎 HTML 解析器单元测试(v0.4.1 测试补齐) + * 覆盖:node-html-parser 结构化解析(主层)、自域名链接过滤、 + * 相对链接补全、空/异常 HTML 容错 + */ + +import { describe, it, expect } from 'vitest'; +import { parseBing, parseBaidu, parseSogou, parse360 } from '../web-search'; + +describe('parseBing — 结构化解析', () => { + const BING_HTML = ` + +

    +
  1. +

    第一篇 TypeScript 文章

    +

    这是第一条结果的摘要内容,讲述 TypeScript 高级用法。

    +
  2. +
  3. +

    第二篇 Node.js 文章

    +

    第二条结果的摘要。

    +
  4. +
  5. + +

    Bing 内部视频链接

    +

    不应出现在结果中。

    +
  6. +
+ + `; + + it('解析结果块并提取 title/url/snippet', () => { + const results = parseBing(BING_HTML); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + title: '第一篇 TypeScript 文章', + url: 'https://example.com/article-1', + snippet: '这是第一条结果的摘要内容,讲述 TypeScript 高级用法。', + engine: 'bing', + weight: 90, + }); + expect(results[1].url).toBe('https://example.com/article-2'); + }); + + it('过滤指向 bing.com 自身域名的链接', () => { + const results = parseBing(BING_HTML); + expect(results.some((r) => r.url.includes('bing.com'))).toBe(false); + }); + + it('空 HTML 返回空数组', () => { + expect(parseBing('')).toHaveLength(0); + expect(parseBing('')).toHaveLength(0); + }); +}); + +describe('parseBaidu — 结构化解析', () => { + const BAIDU_HTML = ` + +
+
+

百度结果一

+ 第一条摘要内容。 +
+
+

跳转链接结果

+ 无 data-url 的结果(跳转链接被过滤)。 +
+
+ + `; + + it('优先使用 data-url 真实链接', () => { + const results = parseBaidu(BAIDU_HTML); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + title: '百度结果一', + url: 'https://example.com/real-url-1', + engine: '百度', + weight: 80, + }); + }); + + it('baidu.com/link 跳转链接被过滤', () => { + const results = parseBaidu(BAIDU_HTML); + expect(results.some((r) => r.url.includes('baidu.com/link'))).toBe(false); + }); + + it('空 HTML 返回空数组', () => { + expect(parseBaidu('')).toHaveLength(0); + }); +}); + +describe('parseSogou — 结构化解析', () => { + const SOGOU_HTML = ` + +
+
+

搜狗结果一

+
搜狗结果一的摘要文本。
+
+
+

搜狗直链结果

+

直链结果的摘要。

+
+
+ + `; + + it('相对链接补全 sogou.com 前缀', () => { + const results = parseSogou(SOGOU_HTML); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + title: '搜狗结果一', + url: 'https://www.sogou.com/link?url=sogou-internal-1', + engine: '搜狗', + weight: 75, + }); + }); + + it('http 开头的直链不补全前缀', () => { + const results = parseSogou(SOGOU_HTML); + expect(results[1].url).toBe('https://example.com/direct'); + expect(results[1].snippet).toBe('直链结果的摘要。'); + }); + + it('空 HTML 返回空数组', () => { + expect(parseSogou('')).toHaveLength(0); + }); +}); + +describe('parse360 — 结构化解析', () => { + const SO360_HTML = ` + +
+ +
+ + `; + + it('解析结果并过滤 so.com 自身链接', () => { + const results = parse360(SO360_HTML); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + title: '360 结果一', + url: 'https://example.com/360-result', + snippet: '360 搜索结果的摘要描述。', + engine: '360搜索', + weight: 75, + }); + }); + + it('空 HTML 返回空数组', () => { + expect(parse360('')).toHaveLength(0); + }); +}); + +describe('解析器降级路径', () => { + it('结构化解析无结果且正则也无结果时返回空数组(不抛错)', () => { + // 非搜索结果页 HTML(如错误页/验证码页) + const notSearchPage = '
请输入验证码
'; + expect(parseBing(notSearchPage)).toHaveLength(0); + expect(parseBaidu(notSearchPage)).toHaveLength(0); + expect(parseSogou(notSearchPage)).toHaveLength(0); + expect(parse360(notSearchPage)).toHaveLength(0); + }); +}); diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index e796a82..5bf9d20 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -58,13 +58,22 @@ function decodeBuffer(buf: Buffer): string { function buildSafeCommandEnv(isWindows: boolean): Record { // 敏感变量后缀黑名单 const SENSITIVE_SUFFIXES = [ - '_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD', - '_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY', + '_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', + 'DEEPSEEK_API_KEY', + 'AGNES_API_KEY', + 'MIMO_API_KEY', + 'GITEA_PASSWORD', + 'DATABASE_PASSWORD', ]); const env: Record = {}; @@ -86,12 +95,31 @@ function buildSafeCommandEnv(isWindows: boolean): Record { return env; } +/** + * v0.4.1: Windows 白名单命令集合 — 这些工具的简单命令(无 shell 运算符)走 + * execFile('cmd.exe', ['/c', ...words]) 执行:参数以数组形式显式传递,不经过 + * shell 解析,从根本上去掉 exec() 的字符串拼接注入面(无法通过参数注入新命令)。 + * + * 仅收录最常见的开发工具(小步灰度);其余命令仍走 exec + 双层校验的既有路径。 + * Node 18.20+/Electron 35 在 Windows 上直接 spawn .cmd 批处理会被拒绝(EINVAL), + * 因此必须通过 cmd.exe /c 中转,但参数分离已足够收窄注入面。 + */ +const WINDOWS_EXEC_FILE_WHITELIST = new Set(['git', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'tsc']); + +/** v0.4.1: 提取命令 basename(处理 C:\Program Files\nodejs\npm.cmd 等路径形式) */ +function commandBasename(cmd: string): string { + const base = cmd.split(/[\\/]/).pop() ?? cmd; + // 去掉 .exe/.cmd/.bat 扩展名(大小写不敏感) + return base.replace(/\.(exe|cmd|bat)$/i, ''); +} + // ===== 9. run_command ===== export class RunCommandTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'run_command', - description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.', + description: + 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.', parameters: { type: 'object', properties: { @@ -123,7 +151,11 @@ export class RunCommandTool implements IMetonaTool { // 安全校验:workdir 必须在工作空间内 const resolvedWorkdir = resolve(context.workspacePath, workdir); if (!isPathWithinWorkspace(workdir, context.workspacePath)) { - return { success: false, error: `Working directory must be within workspace: ${workdir}`, command }; + return { + success: false, + error: `Working directory must be within workspace: ${workdir}`, + command, + }; } // v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计 @@ -183,19 +215,32 @@ export class RunCommandTool implements IMetonaTool { let stderr: Buffer; // #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入) - // 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT) - // 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验) - // 非 Windows 上对简单命令用 execFile + // 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法直接执行(ENOENT/EINVAL) + // v0.4.1: Windows 上白名单工具(git/node/npm/npx/pnpm/yarn/tsc)的简单命令改用 + // execFile('cmd.exe', ['/c', ...args]) — 参数显式分离传递,不经 shell 字符串解析, + // 相比 exec() 的整串拼接显著收窄注入面 + // 非 Windows 上对简单命令直接 execFile if (simpleCmd && !isWindows) { const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts); stdout = result.stdout; stderr = result.stderr; + } else if ( + simpleCmd && + isWindows && + WINDOWS_EXEC_FILE_WHITELIST.has(commandBasename(simpleCmd.command)) + ) { + // v0.4.1: 白名单工具通过 cmd.exe /c + 参数数组执行(参数不经 shell 解析) + const result = await execFileAsync( + 'cmd.exe', + ['/c', simpleCmd.command, ...simpleCmd.args], + execOpts, + ); + stdout = result.stdout; + stderr = result.stderr; } else { // 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec // 已有 SandboxManager.scanCode + validateCommand 双层安全校验 - const finalCommand = isWindows - ? `chcp 65001 >nul 2>&1 && ${command}` - : command; + const finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${command}` : command; const result = await execAsync(finalCommand, execOpts); stdout = result.stdout; stderr = result.stderr; @@ -236,7 +281,11 @@ export class RunCommandTool implements IMetonaTool { // 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md if (commandTouchesProtectedFile(command)) { - return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' }; + return { + allowed: false, + reason: + 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution', + }; } // P0-5: 剥离 Windows chcp 前缀("chcp 65001 >nul 2>&1 &&" 会破坏 shell-quote @@ -256,33 +305,61 @@ export class RunCommandTool implements IMetonaTool { const hardBlocks = [ // 文件系统破坏 { pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' }, - { pattern: /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, reason: 'rm on system directories is forbidden' }, + { + pattern: + /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, + reason: 'rm on system directories is forbidden', + }, { pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' }, // 系统控制 - { pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' }, + { + pattern: /\b(shutdown|reboot|halt|poweroff)\b/, + reason: 'System shutdown commands are forbidden', + }, { pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' }, // 远程代码执行 { pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' }, { pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' }, - { pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, reason: 'Writing to system directories via curl is forbidden' }, + { + pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, + reason: 'Writing to system directories via curl is forbidden', + }, // 设备文件 { pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' }, // 磁盘格式化 { pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' }, // 权限滥用 { pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' }, - { pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: 'Recursive chown on root is forbidden' }, + { + pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, + reason: 'Recursive chown on root is forbidden', + }, // 环境变量窃取 - { pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, reason: 'Exfiltrating environment variables is forbidden' }, + { + pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, + reason: 'Exfiltrating environment variables is forbidden', + }, // 反向 shell - { pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, reason: 'Reverse shell via /dev/tcp is forbidden' }, + { + pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, + reason: 'Reverse shell via /dev/tcp is forbidden', + }, { pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' }, // Windows 危险命令 { pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' }, { pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' }, - { pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' }, - { pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' }, - { pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' }, + { + pattern: /\breg\s+(add|delete|import|restore)/i, + reason: 'Registry modification commands are forbidden', + }, + { + pattern: /\b(taskkill|kill)\s*\//i, + reason: 'Process termination with system flags is forbidden', + }, + { + pattern: /\bpowershell\s+-enc\s+/i, + reason: 'PowerShell encoded command execution is forbidden', + }, // 后台进程与管道炸弹 { pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' }, { pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' }, @@ -342,20 +419,29 @@ export class RunCommandTool implements IMetonaTool { prevWasPipe = false; } else if (typeof obj.op === 'string') { // 跟踪管道运算符,用于下一轮检测 `| sh` - prevWasPipe = (obj.op === '|'); + prevWasPipe = obj.op === '|'; } } } // 危险命令名 token(精确匹配,大小写不敏感) const dangerousCommands = new Set([ - 'sudo', 'su', 'doas', - 'shutdown', 'reboot', 'halt', 'poweroff', - 'mkfs', 'fdisk', 'format', 'diskpart', + 'sudo', + 'su', + 'doas', + 'shutdown', + 'reboot', + 'halt', + 'poweroff', + 'mkfs', + 'fdisk', + 'format', + 'diskpart', ]); // 危险参数 token const dangerousArgs = new Set([ - '-enc', '-encodedcommand', // PowerShell 编码执行 + '-enc', + '-encodedcommand', // PowerShell 编码执行 ]); for (const word of words) { diff --git a/electron/harness/tools/built-in/web-search.ts b/electron/harness/tools/built-in/web-search.ts index efc343d..f405d7c 100644 --- a/electron/harness/tools/built-in/web-search.ts +++ b/electron/harness/tools/built-in/web-search.ts @@ -6,9 +6,15 @@ * 智能排序:引擎权重(50%) + 可达性(30%) + 摘要质量(20%) * 自动抓取:对前 N 条结果调用 web_fetch 获取完整正文 * + * v0.4.1: HTML 解析迁移至 node-html-parser(结构化解析) + * 主层使用 DOM 结构解析(引擎改版时选择器更精确、可维护性远优于正则), + * 正则解析保留为降级路径(结构化解析无结果时兜底)。 + * 此前纯正则方案违反项目开发规范第一铁律(HTML 解析应使用成熟库)。 + * * @see docs/Agent网络工具通用设计-v2.md — 第 2 章 web_search 搜索设计 */ +import { parse as parseHtmlDom, type HTMLElement } from 'node-html-parser'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; @@ -64,7 +70,9 @@ const ENGINES: EngineDef[] = [ name: 'bing', weight: 90, searchUrl: (q, tr) => { - const freshness = tr ? `&filters=ex1:"ez${tr === 'day' ? '1' : tr === 'week' ? '2' : tr === 'month' ? '3' : '4'}"` : ''; + const freshness = tr + ? `&filters=ex1:"ez${tr === 'day' ? '1' : tr === 'week' ? '2' : tr === 'month' ? '3' : '4'}"` + : ''; return `https://www.bing.com/search?q=${encodeURIComponent(q)}${freshness}&count=20`; }, parse: parseBing, @@ -89,9 +97,150 @@ const ENGINES: EngineDef[] = [ }, ]; -// ===== HTML 解析器(正则实现,后续可迁移至 cheerio) ===== +// ===== HTML 解析器(v0.4.1: node-html-parser 结构化解析为主层,正则为降级层) ===== -function parseBing(html: string): SearchResult[] { +/** + * v0.4.1: 从结果块中提取标题链接 — 跳过指向搜索引擎自身域名的链接(favicon/子导航等) + */ +function extractTitleLink( + block: HTMLElement, + selfDomain: string, +): { url: string; title: string } | null { + for (const a of block.querySelectorAll('a[href]')) { + const url = a.getAttribute('href') ?? ''; + const title = a.text.trim(); + if (title && url && !url.includes(selfDomain) && url.startsWith('http')) { + return { url, title }; + } + } + return null; +} + +/** v0.4.1: 提取第一个非空文本的选择器(按优先级尝试多个候选选择器) */ +function extractText(block: HTMLElement, selectors: string[]): string { + for (const sel of selectors) { + const el = block.querySelector(sel); + if (el) { + const text = el.text.trim(); + if (text) return text; + } + } + return ''; +} + +/** v0.4.1: Bing 结构化解析 — li.b_algo 结果块 */ +function parseBingStructured(html: string): SearchResult[] { + const results: SearchResult[] = []; + const root = parseHtmlDom(html); + for (const block of root.querySelectorAll('li.b_algo')) { + const link = extractTitleLink(block, 'bing.com'); + if (!link) continue; + const snippet = extractText(block, ['p', '.b_caption']); + results.push({ title: link.title, url: link.url, snippet, engine: 'bing', weight: 90 }); + } + return results; +} + +/** v0.4.1: 百度结构化解析 — div.result / div.c-container 结果块,优先 a[data-url] 真实链接 */ +function parseBaiduStructured(html: string): SearchResult[] { + const results: SearchResult[] = []; + const root = parseHtmlDom(html); + // 复合选择器去重:class="result c-container" 的元素同时命中两个类名, + // 分别查询再拼接会重复收录同一结果块 + const blocks = root.querySelectorAll('div.result, div.c-container'); + for (const block of blocks) { + // 百度标题链接: 优先 data-url 属性(真实目标 URL),href 通常是 baidu.com/link 跳转 + const dataUrlLink = block.querySelector('a[data-url]'); + let url = dataUrlLink?.getAttribute('data-url') ?? ''; + let title = dataUrlLink?.text.trim() ?? ''; + if (!url || !title) { + const fallback = block.querySelector('h3 a[href]') ?? block.querySelector('a[href]'); + if (fallback) { + const href = fallback.getAttribute('href') ?? ''; + url = href.startsWith('http') ? href : href ? `https://${href}` : ''; + title = fallback.text.trim(); + } + } + const snippet = extractText(block, ['.c-abstract', '[class^="content-right"]']); + if (title && url && !url.includes('baidu.com/link')) { + results.push({ title, url, snippet, engine: '百度', weight: 80 }); + } + } + return results; +} + +/** + * v0.4.1: 搜狗结构化解析 — div.vrwrap / div.rb 结果块(相对链接补全 sogou.com 前缀) + * + * v0.4.1 修复(原正则实现遗留缺陷): 搜狗结果链接是 sogou.com/link?url=... 跳转形式, + * 原 `!url.includes('sogou.com')` 过滤条件把所有跳转结果一并丢弃(相对链接补全后必含 sogou.com), + * 导致搜狗引擎基本无法返回结果。现仅过滤 sogou 自身页面链接,保留 /link 跳转结果 + * (可达性预检会跟随重定向验证)。 + */ +function parseSogouStructured(html: string): SearchResult[] { + const results: SearchResult[] = []; + const root = parseHtmlDom(html); + // 复合选择器避免同一元素命中两个类名时重复收录 + const blocks = root.querySelectorAll('div.vrwrap, div.rb'); + for (const block of blocks) { + const a = block.querySelector('h3 a[href]') ?? block.querySelector('a[href]'); + if (!a) continue; + const href = a.getAttribute('href') ?? ''; + const url = href.startsWith('http') ? href : `https://www.sogou.com${href}`; + const title = a.text.trim(); + const snippet = extractText(block, ['.star-wiki', '.space-txt', '.str_info']); + // 过滤搜狗自身页面(保留 /link 跳转结果) + const isSelfPage = url.includes('sogou.com') && !url.includes('/link'); + if (title && url && !isSelfPage) { + results.push({ title, url, snippet, engine: '搜狗', weight: 75 }); + } + } + return results; +} + +/** v0.4.1: 360 结构化解析 — li.res-list / div.result 结果块 */ +function parse360Structured(html: string): SearchResult[] { + const results: SearchResult[] = []; + const root = parseHtmlDom(html); + // 复合选择器避免同一元素命中多个类名时重复收录 + const blocks = root.querySelectorAll('li.res-list, div.result'); + for (const block of blocks) { + const link = extractTitleLink(block, 'so.com'); + if (!link) continue; + const snippet = extractText(block, ['.res-desc', '.res-rich', '.res-summary', 'dd']); + results.push({ title: link.title, url: link.url, snippet, engine: '360搜索', weight: 75 }); + } + return results; +} + +/** v0.4.1: 结构化解析 + 正则降级的组合入口(供 ENGINES 引用,测试导出) */ +export function parseBing(html: string): SearchResult[] { + const structured = parseBingStructured(html); + if (structured.length > 0) return structured; + return parseBingRegex(html); +} + +export function parseBaidu(html: string): SearchResult[] { + const structured = parseBaiduStructured(html); + if (structured.length > 0) return structured; + return parseBaiduRegex(html); +} + +export function parseSogou(html: string): SearchResult[] { + const structured = parseSogouStructured(html); + if (structured.length > 0) return structured; + return parseSogouRegex(html); +} + +export function parse360(html: string): SearchResult[] { + const structured = parse360Structured(html); + if (structured.length > 0) return structured; + return parse360Regex(html); +} + +// ===== 正则降级解析器(v0.4.1 前的主实现,结构化解析无结果时兜底) ===== + +function parseBingRegex(html: string): SearchResult[] { const results: SearchResult[] = []; const blocks = html.split(/]*class="b_algo"/i).slice(1); for (const block of blocks) { @@ -99,7 +248,9 @@ function parseBing(html: string): SearchResult[] { if (!titleMatch) continue; const url = titleMatch[1]; const title = titleMatch[2].replace(/<[^>]+>/g, '').trim(); - const snippetMatch = block.match(/]*>([\s\S]*?)<\/p>/i) || block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i); + const snippetMatch = + block.match(/]*>([\s\S]*?)<\/p>/i) || + block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i); const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : ''; if (title && url && !url.includes('bing.com')) { results.push({ title, url, snippet, engine: 'bing', weight: 90 }); @@ -108,17 +259,19 @@ function parseBing(html: string): SearchResult[] { return results; } -function parseBaidu(html: string): SearchResult[] { +function parseBaiduRegex(html: string): SearchResult[] { const results: SearchResult[] = []; const blocks = html.split(/]*class="result[^"]*"/i).slice(1); for (const block of blocks) { - const titleMatch = block.match(/]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i) - || block.match(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i); + const titleMatch = + block.match(/]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i) || + block.match(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i); if (!titleMatch) continue; const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://${titleMatch[1]}`; const title = titleMatch[2].replace(/<[^>]+>/g, '').trim(); - const snippetMatch = block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i) - || block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i); + const snippetMatch = + block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i) || + block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i); const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : ''; if (title && url && !url.includes('baidu.com/link')) { results.push({ title, url, snippet, engine: '百度', weight: 80 }); @@ -127,18 +280,23 @@ function parseBaidu(html: string): SearchResult[] { return results; } -function parseSogou(html: string): SearchResult[] { +function parseSogouRegex(html: string): SearchResult[] { const results: SearchResult[] = []; - const blocks = html.split(/]*class="vrwrap"/i).slice(1) + const blocks = html + .split(/]*class="vrwrap"/i) + .slice(1) .concat(html.split(/]*class="rb"/i).slice(1)); for (const block of blocks) { const titleMatch = block.match(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i); if (!titleMatch) continue; - const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://www.sogou.com${titleMatch[1]}`; + const url = titleMatch[1].startsWith('http') + ? titleMatch[1] + : `https://www.sogou.com${titleMatch[1]}`; const title = titleMatch[2].replace(/<[^>]+>/g, '').trim(); - const snippetMatch = block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i) - || block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i) - || block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i); + const snippetMatch = + block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i) || + block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i) || + block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i); const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : ''; if (title && url && !url.includes('sogou.com')) { results.push({ title, url, snippet, engine: '搜狗', weight: 75 }); @@ -147,19 +305,22 @@ function parseSogou(html: string): SearchResult[] { return results; } -function parse360(html: string): SearchResult[] { +function parse360Regex(html: string): SearchResult[] { const results: SearchResult[] = []; - const blocks = html.split(/]*class="res-list"/i).slice(1) + const blocks = html + .split(/]*class="res-list"/i) + .slice(1) .concat(html.split(/]*class="result"/i).slice(1)); for (const block of blocks) { const titleMatch = block.match(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i); if (!titleMatch) continue; const url = titleMatch[1]; const title = titleMatch[2].replace(/<[^>]+>/g, '').trim(); - const snippetMatch = block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i) - || block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i) - || block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i) - || block.match(/]*>([\s\S]*?)<\/dd>/i); + const snippetMatch = + block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i) || + block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i) || + block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i) || + block.match(/]*>([\s\S]*?)<\/dd>/i); const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : ''; if (title && url && !url.includes('so.com')) { results.push({ title, url, snippet, engine: '360搜索', weight: 75 }); @@ -192,7 +353,7 @@ async function checkReachability(urls: string[], concurrency = 5): Promise 0 ? searxngConfig.fetch_count : 5)) : 5; - logTool('web_search', `Mode=${useSearXNG ? 'searxng' : 'builtin'}, maxResults=${maxResults}, fetchTop=${fetchTop}`); + logTool( + 'web_search', + `Mode=${useSearXNG ? 'searxng' : 'builtin'}, maxResults=${maxResults}, fetchTop=${fetchTop}`, + ); // 缓存检查(key 含模式 + maxResults + fetchTop,避免配置变更后返回旧缓存) const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${normalizeUrl(query).toLowerCase()}`; @@ -338,7 +509,10 @@ export class WebSearchTool implements IMetonaTool { // 写入缓存 searchCache.set(cacheKey, output); - logTool('web_search', `Completed: ${sorted.length} results, ${fetchedContent.length} fetched, mode=${mode}`); + logTool( + 'web_search', + `Completed: ${sorted.length} results, ${fetchedContent.length} fetched, mode=${mode}`, + ); return output; } @@ -394,7 +568,7 @@ export class WebSearchTool implements IMetonaTool { } } } else { - const data = await response.json() as { results?: Array> }; + const data = (await response.json()) as { results?: Array> }; for (const item of data.results ?? []) { const url = item.url as string; const title = item.title as string; @@ -418,7 +592,10 @@ export class WebSearchTool implements IMetonaTool { } results.push(...pageResults); - logTool('web_search', `[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`); + logTool( + 'web_search', + `[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`, + ); page++; } @@ -440,12 +617,17 @@ export class WebSearchTool implements IMetonaTool { const searchPromises = ENGINES.map(async (engine) => { try { const url = engine.searchUrl(query, timeRange); - const response = await fetchWithTimeout(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + const response = await fetchWithTimeout( + url, + { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, }, - }, 8_000); + 8_000, + ); if (!response.ok) { logTool('web_search', `[内置] ${engine.name} HTTP ${response.status}`); @@ -501,10 +683,10 @@ export class WebSearchTool implements IMetonaTool { if (enhanced >= maxEnhance) break; if (r.snippet.length < 30 && r.reachable) { try { - const fetchResult = await this.webFetchTool.execute( + const fetchResult = (await this.webFetchTool.execute( { url: r.url }, { sessionId: '', workspacePath: '', iteration: 0, requestId: '' }, - ) as { success: boolean; content?: string }; + )) as { success: boolean; content?: string }; if (fetchResult.success && fetchResult.content) { const text = fetchResult.content.slice(0, 200); @@ -537,7 +719,10 @@ export class WebSearchTool implements IMetonaTool { fetchTop: number, ): Promise> { // 相关性评分(不过滤,relevance=0 的结果也参与抓取候选) - const withRelevance = results.map((r) => ({ result: r, relevance: computeRelevance(query, r) })); + const withRelevance = results.map((r) => ({ + result: r, + relevance: computeRelevance(query, r), + })); const filtered = withRelevance.length > 0 ? withRelevance : []; // 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数 @@ -554,27 +739,30 @@ export class WebSearchTool implements IMetonaTool { } toFetch = shuffled.slice(0, topN); } else { - toFetch = filtered - .sort((a, b) => b.relevance - a.relevance) - .slice(0, topN); + toFetch = filtered.sort((a, b) => b.relevance - a.relevance).slice(0, topN); } const fetched: Array<{ url: string; title: string; content: string }> = []; - const fetchOne = async (item: { result: SearchResult }): Promise<{ url: string; title: string; content: string } | null> => { + const fetchOne = async (item: { + result: SearchResult; + }): Promise<{ url: string; title: string; content: string } | null> => { try { // 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染) - const fetchResult = await this.webFetchTool.execute( + const fetchResult = (await this.webFetchTool.execute( { url: item.result.url }, { sessionId: '', workspacePath: '', iteration: 0, requestId: '' }, - ) as { success: boolean; content?: string }; + )) as { success: boolean; content?: string }; if (fetchResult.success && fetchResult.content) { return { url: item.result.url, title: item.result.title, content: fetchResult.content }; } return null; } catch (err) { - logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`); + logTool( + 'web_search', + `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`, + ); return null; } }; @@ -601,7 +789,9 @@ export class WebSearchTool implements IMetonaTool { lines.push(`${i + 1}. ${r.title}`); lines.push(` URL: ${r.url}`); if (r.snippet) lines.push(` 摘要: ${r.snippet.slice(0, 150)}`); - lines.push(` 来源: ${r.engine}${r.reachable === false ? ' (不可达)' : ''}${r._enhanced ? ' [已增强]' : ''}\n`); + lines.push( + ` 来源: ${r.engine}${r.reachable === false ? ' (不可达)' : ''}${r._enhanced ? ' [已增强]' : ''}\n`, + ); }); return lines.join('\n'); } diff --git a/electron/harness/types/index.ts b/electron/harness/types/index.ts index be863ac..3dd29d5 100644 --- a/electron/harness/types/index.ts +++ b/electron/harness/types/index.ts @@ -29,15 +29,12 @@ export type { MetonaResponseMeta, MetonaTokenUsage, MetonaStreamEvent, + MetonaValidationPayload, MetonaThinking, MetonaError, } from './metona-response'; -export { - MetonaFinishReason, - MetonaStreamEventType, - MetonaErrorCode, -} from './metona-response'; +export { MetonaFinishReason, MetonaStreamEventType, MetonaErrorCode } from './metona-response'; // ===== 上下文与记忆 ===== export type { MetonaContext, MetonaMemoryItem } from './metona-context'; diff --git a/electron/harness/types/metona-response.ts b/electron/harness/types/metona-response.ts index 50c72c1..ab810d6 100644 --- a/electron/harness/types/metona-response.ts +++ b/electron/harness/types/metona-response.ts @@ -92,6 +92,8 @@ export enum MetonaStreamEventType { ERROR = 'error', DONE = 'done', USAGE = 'usage', + /** v0.4.1: 输出验证结果(OutputValidator 检出的疑似幻觉/事实矛盾/敏感信息,不阻断输出) */ + VALIDATION = 'validation', } export interface MetonaStreamEvent { @@ -131,10 +133,27 @@ export interface MetonaStreamEvent { /** ERROR */ error?: MetonaError; + /** VALIDATION — 输出验证结果(v0.4.1: 疑似问题提示,不阻断输出) */ + validation?: MetonaValidationPayload; + /** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */ terminationReason?: string; } +/** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */ +export interface MetonaValidationPayload { + /** 质量分数 0-1(越高越好) */ + score: number; + /** 检出的问题列表(仅 warning 及以上级别才推送前端,info 级噪声不推送) */ + issues: Array<{ + severity: 'error' | 'warning'; + /** 问题类型(fact_inconsistency / hallucination / sensitive_* / unsafe / format) */ + type: string; + /** 人类可读描述 */ + message: string; + }>; +} + // ===== 思考内容 ===== export interface MetonaThinking { diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts new file mode 100644 index 0000000..f884bfa --- /dev/null +++ b/electron/ipc/__tests__/agent.test.ts @@ -0,0 +1,415 @@ +/** + * IPC Agent Handlers — sendMessage 编排链路测试(v0.4.1 测试补齐) + * + * 覆盖 sendMessage 的主编排逻辑: + * 1. 参数校验(无效 sessionId / userMessage → ERROR+DONE 流事件,防止前端 isStreaming 卡死) + * 2. Adapter 加载失败中止 + * 3. Prompt 注入阻断(riskScore >= 7) + * 4. 成功路径(消息持久化 / 审计 / 记忆固化 / 摘要评估 / Token 统计) + * 5. 引擎异常路径(审计错误 + ERROR 流事件) + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { EventEmitter } from 'events'; + +// ===== Mock electron(ipcMain) ===== +const ipcMainHandleMock = vi.fn(); +const ipcMainOnMock = vi.fn(); +vi.mock('electron', () => ({ + ipcMain: { + handle: (...args: unknown[]) => ipcMainHandleMock(...args), + on: (...args: unknown[]) => ipcMainOnMock(...args), + }, +})); + +// ===== Mock broadcast(ipc/context) ===== +const broadcastMock = vi.fn(); +vi.mock('../context', () => ({ + broadcast: (...args: unknown[]) => broadcastMock(...args), +})); + +import { registerAgentHandlers } from '../agent'; +import type { IPCContext } from '../context'; +import type { MetonaMessage } from '../../harness/types'; + +// ===== Mock 依赖工厂 ===== + +function makeEngineMock(overrides: Record = {}) { + return { + runStream: vi.fn().mockResolvedValue({ + finalAnswer: '这是最终回答', + terminationReason: 'completed', + iterations: [ + { + iteration: 1, + state: 'OBSERVING', + startedAt: 1, + completedAt: 2, + thought: { + id: 'thought-1', + content: '本轮思考文本', + reasoningContent: '推理过程', + timestamp: 1, + iteration: 1, + }, + toolCalls: [], + toolResults: [], + }, + ], + totalTokenUsage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, + durationMs: 1234, + metadata: {}, + }), + ...overrides, + }; +} + +function makeCtx(overrides: Record = {}) { + const engine = makeEngineMock(); + const engineManager = new EventEmitter() as EventEmitter & { + getEngine: Mock; + abort: Mock; + waitForAbort: Mock; + }; + (engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine); + (engineManager as unknown as { abort: Mock }).abort = vi.fn(); + (engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi + .fn() + .mockResolvedValue(true); + + const ctx = { + agentEngineManager: engineManager, + sessionRecorder: { + startRecording: vi.fn(), + stopRecording: vi.fn(), + recordContextBuilt: vi.fn(), + recordToolCall: vi.fn(), + recordToolResult: vi.fn(), + recordLLMResponse: vi.fn(), + recordIterationStart: vi.fn(), + recordIterationEnd: vi.fn(), + recordLLMRequest: vi.fn(), + }, + configService: { get: vi.fn(() => '') }, + sessionService: { + saveMessage: vi.fn(), + getMessages: vi.fn(() => []), + updateTokenUsage: vi.fn(), + }, + workspaceService: { + getFiles: vi.fn(() => ({ soul: '# Metona', memory: '# Memory' })), + getPath: vi.fn(() => '/workspace'), + updateMemoryTimestamp: vi.fn(), + }, + contextBuilder: { + buildSystemPrompt: vi.fn(() => ({ + roleDefinition: 'role', + outputConstraints: 'constraints', + safetyGuidelines: 'safety', + dynamicReminders: 'reminders', + })), + isUsingFallbackRole: vi.fn(() => false), + }, + auditService: { + logSessionStart: vi.fn(), + logSessionEnd: vi.fn(), + log: vi.fn(), + }, + memoryManager: { search: vi.fn(() => []) }, + promptInjectionDefender: { + detect: vi.fn(() => ({ + isInjection: false, + riskScore: 0, + findings: [], + recommendation: 'PASS: ok', + })), + }, + outputValidator: { + validate: vi.fn().mockResolvedValue({ valid: true, issues: [], score: 1 }), + }, + memoryConsolidator: { + consolidate: vi.fn().mockResolvedValue({ appended: 0, entries: [], skipped: 0 }), + isRunning: vi.fn(() => false), + }, + sessionSummaryService: { + buildHistoryMessages: vi.fn(() => []), + maybeSummarize: vi.fn().mockResolvedValue(undefined), + }, + orchestrator: { abortByParent: vi.fn() }, + confirmationHook: { clearPending: vi.fn() }, + reloadAdapter: vi.fn(() => true), + ...overrides, + }; + return { ctx: ctx as unknown as IPCContext, engine, engineManager, ctxRaw: ctx }; +} + +function getHandler(channel: string): (...args: unknown[]) => Promise { + const call = ipcMainHandleMock.mock.calls.find(([ch]) => ch === channel); + if (!call) throw new Error(`IPC handler not registered: ${channel}`); + return call[1] as (...args: unknown[]) => Promise; +} + +const VALID_MESSAGE: MetonaMessage = { + role: 'user', + content: '你好,请帮我分析这个项目', + timestamp: Date.now(), +}; + +beforeEach(() => { + ipcMainHandleMock.mockClear(); + ipcMainOnMock.mockClear(); + broadcastMock.mockClear(); +}); + +describe('agent:sendMessage — 参数校验', () => { + it('无效 sessionId 拒绝并发送 ERROR + DONE 流事件(防止前端 isStreaming 卡死)', async () => { + const { ctx } = makeCtx(); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + const result = await handler(null, VALID_MESSAGE, ''); + expect(result).toEqual({ success: false, error: 'Invalid sessionId' }); + + // ERROR + DONE 两个流事件都应广播 + const eventTypes = broadcastMock.mock.calls.map(([, ev]) => (ev as { type: string }).type); + expect(eventTypes).toContain('error'); + expect(eventTypes).toContain('done'); + }); + + it('无效 userMessage(非对象 / content 非字符串)拒绝', async () => { + const { ctx } = makeCtx(); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + const result = await handler(null, { content: 123 }, 'sess_1'); + expect(result).toEqual({ success: false, error: 'Invalid message format' }); + expect(broadcastMock).toHaveBeenCalled(); + }); +}); + +describe('agent:sendMessage — 前置检查', () => { + it('Adapter 加载失败时中止并停止录制', async () => { + const { ctx, ctxRaw } = makeCtx({ reloadAdapter: vi.fn(() => false) }); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + const result = await handler(null, VALID_MESSAGE, 'sess_1'); + expect((result as { success: boolean }).success).toBe(false); + expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); + // 不应调用引擎 + expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); + }); + + it('注入风险 riskScore >= 7 时阻断消息', async () => { + const { ctx, ctxRaw } = makeCtx({ + promptInjectionDefender: { + detect: vi.fn(() => ({ + isInjection: true, + riskScore: 8, + findings: [{ pattern: 'x', matched: 'ignore previous instructions', severity: 'high' }], + recommendation: 'BLOCK: High-risk injection detected', + })), + }, + }); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + const result = await handler(null, VALID_MESSAGE, 'sess_1'); + expect((result as { success: boolean }).success).toBe(false); + expect((result as { error: string }).error).toContain('blocked by prompt injection defense'); + // 用户消息不保存(在注入检测前已保存?—— 现实现:先保存再检测,验证已保存) + expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( + expect.objectContaining({ role: 'user', sessionId: 'sess_1' }), + ); + // 引擎不启动 + expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); + }); + + it('SOUL.md 缺失降级时发送 toast 提示', async () => { + const { ctx } = makeCtx({ + contextBuilder: { + buildSystemPrompt: vi.fn(() => ({ + roleDefinition: 'fallback', + outputConstraints: 'c', + safetyGuidelines: 's', + })), + isUsingFallbackRole: vi.fn(() => true), + }, + }); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + await handler(null, VALID_MESSAGE, 'sess_1'); + const toastCall = broadcastMock.mock.calls.find(([ch]) => ch === 'toast:show'); + expect(toastCall).toBeDefined(); + expect((toastCall![1] as { message: string }).message).toContain('SOUL.md'); + }); +}); + +describe('agent:sendMessage — 成功路径', () => { + it('完整编排:保存消息 → 运行引擎 → 持久化 assistant 消息 → 审计 → 异步固化', async () => { + const { ctx, ctxRaw, engine } = makeCtx(); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + const result = await handler(null, VALID_MESSAGE, 'sess_1'); + expect(result).toEqual({ success: true }); + + // 1. 用户消息保存 + expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( + expect.objectContaining({ + role: 'user', + content: VALID_MESSAGE.content, + sessionId: 'sess_1', + }), + ); + // 2. 引擎启动(每会话引擎) + expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1'); + expect(engine.runStream).toHaveBeenCalledWith( + VALID_MESSAGE, + 'sess_1', + [], + expect.objectContaining({ roleDefinition: 'role' }), + ); + // 3. assistant 消息保存(含思考内容) + expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( + expect.objectContaining({ + role: 'assistant', + content: '本轮思考文本', + reasoningContent: '推理过程', + }), + ); + // 4. Token 统计更新 + expect(ctxRaw.sessionService.updateTokenUsage).toHaveBeenCalledWith('sess_1', 150); + // 5. MEMORY.md 时间戳更新 + expect(ctxRaw.workspaceService.updateMemoryTimestamp).toHaveBeenCalled(); + // 6. 审计 + 录制结束 + expect(ctxRaw.auditService.logSessionEnd).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'sess_1', terminationReason: 'completed' }), + ); + expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); + // 7. 输出验证执行 + expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith('这是最终回答', expect.anything()); + // 8. 摘要评估(异步触发) + await vi.waitFor(() => + expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'), + ); + // 9. 记忆固化(异步触发) + await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled()); + }); + + it('注入相关记忆到 System Prompt 动态区', async () => { + const { ctx, ctxRaw } = makeCtx({ + memoryManager: { + search: vi.fn(() => [ + { + id: 'm1', + type: 'semantic', + content: '用户偏好深色主题', + importance: 0.9, + createdAt: Date.now(), + score: 0.8, + }, + ]), + }, + }); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + await handler(null, VALID_MESSAGE, 'sess_1'); + expect(ctxRaw.memoryManager.search).toHaveBeenCalled(); + // 引擎收到的 systemPrompt 应包含记忆块 + const prompt = engine_runStreamPrompt(ctxRaw); + expect(prompt.dynamicReminders).toContain('用户偏好深色主题'); + }); + + it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => { + const { ctx, ctxRaw } = makeCtx({ + outputValidator: { + validate: vi.fn().mockResolvedValue({ + valid: false, + score: 0.7, + issues: [ + { severity: 'warning', type: 'hallucination', message: 'Path not found in context' }, + { severity: 'info', type: 'format', message: 'noise' }, + ], + }), + }, + }); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + await handler(null, VALID_MESSAGE, 'sess_1'); + const validationCall = broadcastMock.mock.calls.find( + ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'validation', + ); + expect(validationCall).toBeDefined(); + const payload = (validationCall![1] as { validation: { issues: unknown[] } }).validation; + // info 级噪声不推送 + expect(payload.issues).toHaveLength(1); + expect(ctxRaw.outputValidator.validate).toHaveBeenCalled(); + }); +}); + +describe('agent:sendMessage — 异常路径', () => { + it('引擎抛错时返回失败并记录审计错误', async () => { + const engine = makeEngineMock({ + runStream: vi.fn().mockRejectedValue(new Error('LLM connection failed')), + }); + const engineManager = new EventEmitter() as EventEmitter & { + getEngine: Mock; + abort: Mock; + waitForAbort: Mock; + }; + (engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine); + (engineManager as unknown as { abort: Mock }).abort = vi.fn(); + (engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi + .fn() + .mockResolvedValue(true); + const { ctx, ctxRaw } = makeCtx({ + agentEngineManager: engineManager, + }); + registerAgentHandlers(ctx); + const handler = getHandler('agent:sendMessage'); + + const result = await handler(null, VALID_MESSAGE, 'sess_1'); + expect((result as { success: boolean }).success).toBe(false); + expect((result as { error: string }).error).toBe('LLM connection failed'); + // 审计记录错误 + expect(ctxRaw.auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'sess_1', eventType: 'error', outcome: 'error' }), + ); + // ERROR 流事件广播 + const errorCall = broadcastMock.mock.calls.find( + ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error', + ); + expect(errorCall).toBeDefined(); + }); +}); + +describe('agent:abortSession — 中断编排', () => { + it('联动 SubAgent 中断 + 引擎中断 + 清理确认', async () => { + const { ctx, ctxRaw } = makeCtx(); + registerAgentHandlers(ctx); + const handler = getHandler('agent:abortSession'); + + const result = await handler(null, 'sess_1'); + expect(result).toEqual({ success: true }); + expect(ctxRaw.orchestrator.abortByParent).toHaveBeenCalledWith('sess_1'); + expect(ctxRaw.confirmationHook.clearPending).toHaveBeenCalled(); + expect(ctxRaw.auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'session_end', outcome: 'denied' }), + ); + }); +}); + +/** 从 runStream 调用参数中提取 systemPrompt */ +function engine_runStreamPrompt(ctxRaw: Record): { + roleDefinition: string; + dynamicReminders?: string; +} { + const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as { + runStream: Mock; + }; + return engine.runStream.mock.calls[0][3]; +} diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts index c14a79b..2113e4f 100644 --- a/electron/ipc/agent.ts +++ b/electron/ipc/agent.ts @@ -22,7 +22,10 @@ import log from 'electron-log'; /** 单会话的 text_delta 节流状态 */ interface ThrottleState { buffer: string; - lastEventMeta: Pick | null; + lastEventMeta: Pick< + MetonaStreamEvent, + 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId' + > | null; flushTimer: ReturnType | null; } @@ -37,10 +40,20 @@ interface IterationTrace { export function registerAgentHandlers(ctx: IPCContext): void { const { - agentEngineManager, sessionRecorder, configService, sessionService, - workspaceService, contextBuilder, auditService, memoryManager, - promptInjectionDefender, outputValidator, memoryConsolidator, - sessionSummaryService, orchestrator, confirmationHook, + agentEngineManager, + sessionRecorder, + configService, + sessionService, + workspaceService, + contextBuilder, + auditService, + memoryManager, + promptInjectionDefender, + outputValidator, + memoryConsolidator, + sessionSummaryService, + orchestrator, + confirmationHook, } = ctx; // ===== 常驻事件管道:text_delta 按会话节流(F8) ===== @@ -88,7 +101,9 @@ export function registerAgentHandlers(ctx: IPCContext): void { // F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序) if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) { const st = throttleStates.get(sessionId) ?? { - buffer: '', lastEventMeta: null, flushTimer: null, + buffer: '', + lastEventMeta: null, + flushTimer: null, }; throttleStates.set(sessionId, st); if (st.buffer === '') { @@ -101,7 +116,12 @@ export function registerAgentHandlers(ctx: IPCContext): void { runId: event.runId, }; } else if (st.lastEventMeta) { - st.lastEventMeta = { ...st.lastEventMeta, seq: event.seq, timestamp: event.timestamp, runId: event.runId }; + st.lastEventMeta = { + ...st.lastEventMeta, + seq: event.seq, + timestamp: event.timestamp, + runId: event.runId, + }; } st.buffer += event.delta; if (st.flushTimer === null) { @@ -133,9 +153,10 @@ export function registerAgentHandlers(ctx: IPCContext): void { break; case MetonaStreamEventType.TOOL_RESULT: if (event.toolResult) { - const resultPreview = typeof event.toolResult.result === 'string' - ? event.toolResult.result - : JSON.stringify(event.toolResult.result); + const resultPreview = + typeof event.toolResult.result === 'string' + ? event.toolResult.result + : JSON.stringify(event.toolResult.result); sessionRecorder.recordToolResult({ sessionId, iteration: event.iteration, @@ -167,21 +188,67 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); // ===== 常驻监听:状态变化(广播 + TRACE 迭代录制) ===== - agentEngineManager.on('stateChange', (data: { - previous?: string; current?: string; state?: string; - sessionId?: string; iteration?: number; runId?: string; - }) => { - if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`); - broadcast('agent:stateChange', data); + agentEngineManager.on( + 'stateChange', + (data: { + previous?: string; + current?: string; + state?: string; + sessionId?: string; + iteration?: number; + runId?: string; + }) => { + if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`); + broadcast('agent:stateChange', data); - const sessionId = data.sessionId; - if (!sessionId || data.iteration == null) return; - const stateValue = data.state ?? data.current ?? ''; - const trace = iterationTraces.get(sessionId); + const sessionId = data.sessionId; + if (!sessionId || data.iteration == null) return; + const stateValue = data.state ?? data.current ?? ''; + const trace = iterationTraces.get(sessionId); - // THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代) - if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) { - if (trace && !trace.responded) { + // THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代) + if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) { + if (trace && !trace.responded) { + sessionRecorder.recordLLMResponse({ + sessionId, + iteration: trace.iteration, + content: trace.text, + finishReason: 'stop', + tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 }, + }); + } + if (trace) { + sessionRecorder.recordIterationEnd(sessionId, { + iteration: trace.iteration, + durationMs: Date.now() - trace.startedAt, + }); + } + iterationTraces.set(sessionId, { + iteration: data.iteration, + startedAt: Date.now(), + text: '', + responded: false, + }); + sessionRecorder.recordIterationStart(sessionId, data.iteration); + const provider = configService.get('llm.provider') ?? ''; + const model = configService.get('llm.model') ?? ''; + sessionRecorder.recordLLMRequest({ + sessionId, + iteration: data.iteration, + provider, + model, + messageCount: data.iteration + 1, + }); + } + + // PARSING → 本轮流式结束,记录 llm_response + if ( + stateValue === 'PARSING' && + trace && + trace.iteration === data.iteration && + !trace.responded + ) { + trace.responded = true; sessionRecorder.recordLLMResponse({ sessionId, iteration: trace.iteration, @@ -190,76 +257,48 @@ export function registerAgentHandlers(ctx: IPCContext): void { tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 }, }); } - if (trace) { - sessionRecorder.recordIterationEnd(sessionId, { - iteration: trace.iteration, - durationMs: Date.now() - trace.startedAt, - }); - } - iterationTraces.set(sessionId, { - iteration: data.iteration, - startedAt: Date.now(), - text: '', - responded: false, - }); - sessionRecorder.recordIterationStart(sessionId, data.iteration); - const provider = configService.get('llm.provider') ?? ''; - const model = configService.get('llm.model') ?? ''; - sessionRecorder.recordLLMRequest({ - sessionId, - iteration: data.iteration, - provider, - model, - messageCount: data.iteration + 1, - }); - } - // PARSING → 本轮流式结束,记录 llm_response - if (stateValue === 'PARSING' && trace && trace.iteration === data.iteration && !trace.responded) { - trace.responded = true; - sessionRecorder.recordLLMResponse({ - sessionId, - iteration: trace.iteration, - content: trace.text, - finishReason: 'stop', - tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 }, - }); - } - - // TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记, - // 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态 - if (stateValue === 'TERMINATED') { - if (trace) { - sessionRecorder.recordIterationEnd(sessionId, { - iteration: trace.iteration, - durationMs: Date.now() - trace.startedAt, - }); + // TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记, + // 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态 + if (stateValue === 'TERMINATED') { + if (trace) { + sessionRecorder.recordIterationEnd(sessionId, { + iteration: trace.iteration, + durationMs: Date.now() - trace.startedAt, + }); + } + cleanupSessionState(sessionId); } - cleanupSessionState(sessionId); - } - }); + }, + ); // ===== 常驻监听:上下文压缩(toast + streamEvent 通知) ===== - agentEngineManager.on('compressed', (data: { - sessionId?: string; iteration?: number; originalTokens?: number; compressedTokens?: number; - }) => { - const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0)); - // toast 通知用户压缩已发生 - broadcast('toast:show', { - type: 'info', - message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`, - }); - // 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store - broadcast('agent:streamEvent', { - type: 'compressed', - sessionId: data.sessionId ?? '', - iteration: data.iteration ?? 0, - originalTokens: data.originalTokens, - compressedTokens: data.compressedTokens, - savedTokens, - timestamp: Date.now(), - }); - }); + agentEngineManager.on( + 'compressed', + (data: { + sessionId?: string; + iteration?: number; + originalTokens?: number; + compressedTokens?: number; + }) => { + const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0)); + // toast 通知用户压缩已发生 + broadcast('toast:show', { + type: 'info', + message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`, + }); + // 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store + broadcast('agent:streamEvent', { + type: 'compressed', + sessionId: data.sessionId ?? '', + iteration: data.iteration ?? 0, + originalTokens: data.originalTokens, + compressedTokens: data.compressedTokens, + savedTokens, + timestamp: Date.now(), + }); + }, + ); // ===== 常驻监听:死循环检测(toast 警告) ===== agentEngineManager.on('deadLoop', (data: { iteration?: number; sessionId?: string }) => { @@ -271,300 +310,378 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); // ===== 常驻监听:Provider 故障转移(P1,通知前端 + toast) ===== - agentEngineManager.on('providerSwitched', (data: { - from?: string; to?: string; reason?: string; sessionId?: string; - }) => { - broadcast('agent:providerSwitched', { - from: data.from, - to: data.to, - reason: data.reason ?? 'failover', - sessionId: data.sessionId ?? '', - }); - broadcast('toast:show', { - type: 'warning', - message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`, - }); - }); + agentEngineManager.on( + 'providerSwitched', + (data: { from?: string; to?: string; reason?: string; sessionId?: string }) => { + broadcast('agent:providerSwitched', { + from: data.from, + to: data.to, + reason: data.reason ?? 'failover', + sessionId: data.sessionId ?? '', + }); + broadcast('toast:show', { + type: 'warning', + message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`, + }); + }, + ); // ===== Agent 消息发送 ===== - ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => { - // M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常 - // P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死 - const sendErrorEvent = (message: string, sid: string): void => { - const errorEvent: MetonaStreamEvent = { - type: MetonaStreamEventType.ERROR, - requestId: '', sessionId: sid, iteration: 0, seq: 0, timestamp: Date.now(), - error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false }, + ipcMain.handle( + 'agent:sendMessage', + async (_event, userMessage: MetonaMessage, sessionId: string) => { + // M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常 + // P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死 + const sendErrorEvent = (message: string, sid: string): void => { + const errorEvent: MetonaStreamEvent = { + type: MetonaStreamEventType.ERROR, + requestId: '', + sessionId: sid, + iteration: 0, + seq: 0, + timestamp: Date.now(), + error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false }, + }; + broadcast('agent:streamEvent', errorEvent); + broadcast('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE }); }; - broadcast('agent:streamEvent', errorEvent); - broadcast('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE }); - }; - if (!sessionId || typeof sessionId !== 'string') { - log.warn('[AGENT] sendMessage rejected: invalid sessionId'); - sendErrorEvent('无效的会话 ID', sessionId ?? ''); - return { success: false, error: 'Invalid sessionId' }; - } - if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') { - log.warn('[AGENT] sendMessage rejected: invalid userMessage'); - sendErrorEvent('无效的消息格式', sessionId); - return { success: false, error: 'Invalid message format' }; - } - log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80)); - - // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送) - if (!ctx.reloadAdapter()) { - const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)'; - log.error('[AGENT]', errorMsg); - sendErrorEvent(errorMsg, sessionId); - sessionRecorder.stopRecording(sessionId, { totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' }); - return { success: false, error: errorMsg }; - } - - // TRACE 层:开始录制 / TOOL 层:记录会话开始 - sessionRecorder.startRecording(sessionId); - auditService.logSessionStart(sessionId); - - // 保存用户消息到数据库 - sessionService.saveMessage({ - sessionId, - role: 'user', - content: userMessage.content, - attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments, - }); - - // P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文] - const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1); - - // 从工作空间文件构建 System Prompt - const workspaceFiles = workspaceService.getFiles(); - const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath()); - - // v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户 - if (contextBuilder.isUsingFallbackRole()) { - broadcast('toast:show', { - type: 'info', - message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格', - }); - } - - // 检索与用户消息相关的记忆,注入到 System Prompt 动态区 - try { - const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 }); - 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`); + if (!sessionId || typeof sessionId !== 'string') { + log.warn('[AGENT] sendMessage rejected: invalid sessionId'); + sendErrorEvent('无效的会话 ID', sessionId ?? ''); + return { success: false, error: 'Invalid sessionId' }; } - } catch (err) { - log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err); - } + if ( + !userMessage || + typeof userMessage !== 'object' || + typeof userMessage.content !== 'string' + ) { + log.warn('[AGENT] sendMessage rejected: invalid userMessage'); + sendErrorEvent('无效的消息格式', sessionId); + return { success: false, error: 'Invalid message format' }; + } + log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80)); - // 附件提示注入:用户直接上传的文件/图片,避免 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'; - 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 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`); - } - - try { - // 提示注入检测(安全模块) - const injectionResult = promptInjectionDefender.detect(userMessage.content); - if (injectionResult.riskScore >= 7) { - log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings); - sendErrorEvent(`Message blocked by prompt injection defense: ${injectionResult.recommendation}`, sessionId); + // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送) + if (!ctx.reloadAdapter()) { + const errorMsg = + 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)'; + log.error('[AGENT]', errorMsg); + sendErrorEvent(errorMsg, sessionId); sessionRecorder.stopRecording(sessionId, { - totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error', + totalIterations: 0, + totalTokens: 0, + durationMs: 0, + terminationReason: 'error', }); - return { success: false, error: 'Message blocked by prompt injection defense' }; - } - if (injectionResult.riskScore >= 4) { - log.warn('[PromptInjectionDefender] Suspicious patterns detected:', injectionResult.findings); + return { success: false, error: errorMsg }; } - // TRACE 层:记录上下文构建 - sessionRecorder.recordContextBuilt(sessionId, { - tokenCount: estimateMessagesTokens(history), - usageRatio: 0, + // TRACE 层:开始录制 / TOOL 层:记录会话开始 + sessionRecorder.startRecording(sessionId); + auditService.logSessionStart(sessionId); + + // 保存用户消息到数据库 + sessionService.saveMessage({ + sessionId, + role: 'user', + content: userMessage.content, + attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments, }); - // 启动 Agent Loop(P2-10: 每会话独立引擎) - const engine = agentEngineManager.getEngine(sessionId); - const output = await engine.runStream(userMessage, sessionId, history, systemPrompt); + // P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文] + const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1); - // 输出验证(不阻塞响应,仅记录警告) - // v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测 - try { - const toolResults = output.iterations - .flatMap((step) => step.toolResults ?? []) - .map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result))); - const context = [...history, { role: 'user', content: userMessage.content }] - .map((m) => `${m.role}: ${m.content}`).join('\n'); - const validation = await outputValidator.validate(output.finalAnswer, { - toolResults: toolResults.length > 0 ? toolResults : undefined, - context, + // 从工作空间文件构建 System Prompt + const workspaceFiles = workspaceService.getFiles(); + const systemPrompt = contextBuilder.buildSystemPrompt( + workspaceFiles, + workspaceService.getPath(), + ); + + // v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户 + if (contextBuilder.isUsingFallbackRole()) { + broadcast('toast:show', { + type: 'info', + message: + '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格', }); - if (!validation.valid || validation.issues.length > 0) { - log.warn('[OutputValidator] Validation issues:', validation.issues); - } - log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`); - } catch (err) { - log.error('[OutputValidator] Validation failed:', err); } - // 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用) - for (const step of output.iterations) { - if (!step.thought) continue; + // 检索与用户消息相关的记忆,注入到 System Prompt 动态区 + try { + const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 }); + 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`); + } + } catch (err) { + log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err); + } - const toolCallsWithResults = step.toolCalls?.map((tc) => { - const result = step.toolResults?.find((r) => r.toolCallId === tc.id); - return { - id: tc.id, - name: tc.name, - args: tc.args, - status: result?.success ? 'success' as const : 'error' as const, - result: result?.result, - durationMs: result?.durationMs, - error: result?.error, - }; - }); + // 附件提示注入:用户直接上传的文件/图片,避免 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'; + 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 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'); - // 只有当有内容、思考内容或工具调用时才保存 - if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) { - // C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串) - const assistantContent = (toolCallsWithResults?.length && !step.thought.content) - ? null - : step.thought.content; - sessionService.saveMessage({ + 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`); + } + + try { + // 提示注入检测(安全模块) + const injectionResult = promptInjectionDefender.detect(userMessage.content); + if (injectionResult.riskScore >= 7) { + log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings); + sendErrorEvent( + `Message blocked by prompt injection defense: ${injectionResult.recommendation}`, sessionId, - role: 'assistant', - content: assistantContent, - reasoningContent: step.thought.reasoningContent || undefined, - toolCalls: toolCallsWithResults, - iteration: step.iteration, + ); + sessionRecorder.stopRecording(sessionId, { + totalIterations: 0, + totalTokens: 0, + durationMs: 0, + terminationReason: 'error', }); + return { success: false, error: 'Message blocked by prompt injection defense' }; + } + if (injectionResult.riskScore >= 4) { + log.warn( + '[PromptInjectionDefender] Suspicious patterns detected:', + injectionResult.findings, + ); } - // v0.3.0 修复: 保存 tool 结果消息到数据库 - // OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息 - if (step.toolResults) { - for (const result of step.toolResults) { - const resultContent = typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); + // TRACE 层:记录上下文构建 + sessionRecorder.recordContextBuilt(sessionId, { + tokenCount: estimateMessagesTokens(history), + usageRatio: 0, + }); + + // 启动 Agent Loop(P2-10: 每会话独立引擎) + const engine = agentEngineManager.getEngine(sessionId); + const output = await engine.runStream(userMessage, sessionId, history, systemPrompt); + + // 输出验证(不阻塞响应,仅记录警告) + // v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测 + // v0.4.1: warning 及以上级别的 issue 通过 VALIDATION 流事件推送前端展示(此前仅写日志,用户不可感知) + try { + const toolResults = output.iterations + .flatMap((step) => step.toolResults ?? []) + .map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result))); + const context = [...history, { role: 'user', content: userMessage.content }] + .map((m) => `${m.role}: ${m.content}`) + .join('\n'); + const validation = await outputValidator.validate(output.finalAnswer, { + toolResults: toolResults.length > 0 ? toolResults : undefined, + context, + }); + if (!validation.valid || validation.issues.length > 0) { + log.warn('[OutputValidator] Validation issues:', validation.issues); + } + log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`); + + // v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声) + const visibleIssues = validation.issues + .filter((i) => i.severity === 'warning' || i.severity === 'error') + .slice(0, 5); + if (visibleIssues.length > 0) { + broadcast('agent:streamEvent', { + type: MetonaStreamEventType.VALIDATION, + requestId: '', + sessionId, + iteration: output.iterations.length, + seq: 0, + timestamp: Date.now(), + validation: { + score: validation.score, + issues: visibleIssues.map((i) => ({ + severity: i.severity, + type: i.type, + message: i.message, + })), + }, + } satisfies MetonaStreamEvent); + } + } catch (err) { + log.error('[OutputValidator] Validation failed:', err); + } + + // 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用) + for (const step of output.iterations) { + if (!step.thought) continue; + + const toolCallsWithResults = step.toolCalls?.map((tc) => { + const result = step.toolResults?.find((r) => r.toolCallId === tc.id); + return { + id: tc.id, + name: tc.name, + args: tc.args, + status: result?.success ? ('success' as const) : ('error' as const), + result: result?.result, + durationMs: result?.durationMs, + error: result?.error, + }; + }); + + // 只有当有内容、思考内容或工具调用时才保存 + if ( + step.thought.content || + step.thought.reasoningContent || + toolCallsWithResults?.length + ) { + // C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串) + const assistantContent = + toolCallsWithResults?.length && !step.thought.content ? null : step.thought.content; sessionService.saveMessage({ sessionId, - role: 'tool', - content: result.error ?? resultContent, - toolResult: result, + role: 'assistant', + content: assistantContent, + reasoningContent: step.thought.reasoningContent || undefined, + toolCalls: toolCallsWithResults, iteration: step.iteration, }); } - } - } - // 更新 Token 统计 - if (output.totalTokenUsage.totalTokens > 0) { - sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens); - } - - // 更新 MEMORY.md 时间戳 - 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`, - }); + // v0.3.0 修复: 保存 tool 结果消息到数据库 + // OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息 + if (step.toolResults) { + for (const result of step.toolResults) { + const resultContent = + typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + sessionService.saveMessage({ + sessionId, + role: 'tool', + content: result.error ?? resultContent, + toolResult: result, + iteration: step.iteration, + }); + } } - }) - .catch((err) => { - log.warn('[AGENT] Memory consolidation failed:', err); + } + + // 更新 Token 统计 + if (output.totalTokenUsage.totalTokens > 0) { + sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens); + } + + // 更新 MEMORY.md 时间戳 + 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); + }); + + // TOOL 层:记录会话结束 / TRACE 层:停止录制 + auditService.logSessionEnd({ + sessionId, + totalIterations: output.iterations.length, + totalTokens: output.totalTokenUsage.totalTokens, + durationMs: output.durationMs, + terminationReason: output.terminationReason, + }); + sessionRecorder.stopRecording(sessionId, { + totalIterations: output.iterations.length, + totalTokens: output.totalTokenUsage.totalTokens, + durationMs: output.durationMs, + terminationReason: output.terminationReason, }); - // TOOL 层:记录会话结束 / TRACE 层:停止录制 - auditService.logSessionEnd({ - sessionId, - totalIterations: output.iterations.length, - totalTokens: output.totalTokenUsage.totalTokens, - durationMs: output.durationMs, - terminationReason: output.terminationReason, - }); - sessionRecorder.stopRecording(sessionId, { - totalIterations: output.iterations.length, - totalTokens: output.totalTokenUsage.totalTokens, - durationMs: output.durationMs, - terminationReason: output.terminationReason, - }); + // P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录) + sessionSummaryService.maybeSummarize(sessionId).catch((err) => { + log.warn('[AGENT] Session summary generation failed:', err); + }); - // P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录) - sessionSummaryService.maybeSummarize(sessionId).catch((err) => { - log.warn('[AGENT] Session summary generation failed:', err); - }); + log.info( + `[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`, + ); - log.info(`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`); + return { success: true }; + } catch (error) { + log.error('[AGENT] Error:', error); - return { success: true }; - } catch (error) { - log.error('[AGENT] Error:', error); + // TOOL 层:记录错误 + auditService.log({ + sessionId, + eventType: 'error', + actor: 'agent', + target: 'agent_loop', + details: { error: (error as Error).message }, + outcome: 'error', + }); - // TOOL 层:记录错误 - auditService.log({ - sessionId, - eventType: 'error', - actor: 'agent', - target: 'agent_loop', - details: { error: (error as Error).message }, - outcome: 'error', - }); + // TRACE 层:停止录制 + sessionRecorder.stopRecording(sessionId, { + totalIterations: 0, + totalTokens: 0, + durationMs: 0, + terminationReason: 'error', + }); - // TRACE 层:停止录制 - sessionRecorder.stopRecording(sessionId, { - totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error', - }); + // 发送错误事件到 UI + const metonaError: MetonaError = { + code: MetonaErrorCode.UNKNOWN, + message: (error as Error).message, + retryable: false, + }; + broadcast('agent:streamEvent', { + type: MetonaStreamEventType.ERROR, + requestId: '', + sessionId, + iteration: 0, + seq: 0, + timestamp: Date.now(), + error: metonaError, + } satisfies MetonaStreamEvent); - // 发送错误事件到 UI - const metonaError: MetonaError = { - code: MetonaErrorCode.UNKNOWN, - message: (error as Error).message, - retryable: false, - }; - broadcast('agent:streamEvent', { - type: MetonaStreamEventType.ERROR, - requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(), - error: metonaError, - } satisfies MetonaStreamEvent); - - return { success: false, error: (error as Error).message }; - } - }); + return { success: false, error: (error as Error).message }; + } + }, + ); // ===== 中断会话 ===== diff --git a/electron/ipc/mcp.ts b/electron/ipc/mcp.ts index 3192239..20b99df 100644 --- a/electron/ipc/mcp.ts +++ b/electron/ipc/mcp.ts @@ -13,46 +13,65 @@ export function registerMCPHandlers(ctx: IPCContext): void { return mcpManager.getServerStates(); }); - ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => { - // M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为 - if (!config || typeof config !== 'object') { - return { success: false, error: 'Invalid config' }; - } - if (typeof config.name !== 'string' || !config.name.trim()) { - return { success: false, error: 'Server name is required' }; - } - // M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言) - if (config.transport !== 'stdio' && config.transport !== 'sse') { - return { success: false, error: `Invalid transport: ${config.transport}. Must be 'stdio' or 'sse'` }; - } - // stdio 类型必须有 command - if (config.transport === 'stdio' && (typeof config.command !== 'string' || !config.command.trim())) { - return { success: false, error: 'command is required for stdio transport' }; - } - // sse 类型必须有合法 url - if (config.transport === 'sse') { - if (typeof config.url !== 'string' || !config.url.trim()) { - return { success: false, error: 'url is required for sse transport' }; + ipcMain.handle( + 'mcp:addServer', + async ( + _event, + config: { name: string; transport: string; command?: string; args?: string[]; url?: string }, + ) => { + // M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为 + if (!config || typeof config !== 'object') { + return { success: false, error: 'Invalid config' }; } - try { new URL(config.url); } catch { - return { success: false, error: 'Invalid url format' }; + if (typeof config.name !== 'string' || !config.name.trim()) { + return { success: false, error: 'Server name is required' }; } - } - try { - await mcpManager.addServer({ - name: config.name, - transport: config.transport, // 已校验,无需断言 - command: config.command, - args: config.args, - url: config.url, - enabled: true, - }); - log.info(`MCP server added: ${config.name}`); - return { success: true }; - } catch (error) { - return { success: false, error: (error instanceof Error ? error.message : String(error)) }; - } - }); + // M-5 修复: transport 运行时校验(替代 as 断言) + // v0.4.1: 新增 'streamable-http' 传输方式 + if ( + config.transport !== 'stdio' && + config.transport !== 'sse' && + config.transport !== 'streamable-http' + ) { + return { + success: false, + error: `Invalid transport: ${config.transport}. Must be 'stdio', 'sse', or 'streamable-http'`, + }; + } + // stdio 类型必须有 command + if ( + config.transport === 'stdio' && + (typeof config.command !== 'string' || !config.command.trim()) + ) { + return { success: false, error: 'command is required for stdio transport' }; + } + // sse / streamable-http 类型必须有合法 url + if (config.transport === 'sse' || config.transport === 'streamable-http') { + if (typeof config.url !== 'string' || !config.url.trim()) { + return { success: false, error: `url is required for ${config.transport} transport` }; + } + try { + new URL(config.url); + } catch { + return { success: false, error: 'Invalid url format' }; + } + } + try { + await mcpManager.addServer({ + name: config.name, + transport: config.transport, // 已校验,无需断言 + command: config.command, + args: config.args, + url: config.url, + enabled: true, + }); + log.info(`MCP server added: ${config.name}`); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + ); ipcMain.handle('mcp:removeServer', async (_event, name: string) => { // M-38 修复: name 校验 @@ -63,7 +82,7 @@ export function registerMCPHandlers(ctx: IPCContext): void { await mcpManager.removeServer(name); return { success: true }; } catch (error) { - return { success: false, error: (error instanceof Error ? error.message : String(error)) }; + return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); @@ -79,7 +98,7 @@ export function registerMCPHandlers(ctx: IPCContext): void { await mcpManager.toggleServer(name, enabled); return { success: true }; } catch (error) { - return { success: false, error: (error instanceof Error ? error.message : String(error)) }; + return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); } diff --git a/electron/ipc/tools.ts b/electron/ipc/tools.ts index 6bca42d..aa020d4 100644 --- a/electron/ipc/tools.ts +++ b/electron/ipc/tools.ts @@ -57,7 +57,12 @@ export function registerToolHandlers(ctx: IPCContext): void { log.warn('[IPC] tool:confirmationResponse rejected: invalid data'); return; } - const req = data as { toolCallId?: unknown; approved?: unknown; remember?: unknown; autoExecute?: unknown }; + const req = data as { + toolCallId?: unknown; + approved?: unknown; + remember?: unknown; + autoExecute?: unknown; + }; if (typeof req.toolCallId !== 'string' || !req.toolCallId) { log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId'); return; @@ -69,7 +74,9 @@ export function registerToolHandlers(ctx: IPCContext): void { const remember = typeof req.remember === 'boolean' ? req.remember : false; const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false; confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute); - log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`); + log.info( + `[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`, + ); }); // ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批) ===== @@ -86,7 +93,9 @@ export function registerToolHandlers(ctx: IPCContext): void { }; // 严格校验 toolCallIds 数组 if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) { - log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array'); + log.warn( + '[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array', + ); return; } // 每个元素必须是字符串 @@ -108,7 +117,9 @@ export function registerToolHandlers(ctx: IPCContext): void { remember, autoExecute, ); - log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`); + log.info( + `[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`, + ); }); // ===== v0.3.2: 拉取当前所有 pending 确认 ===== @@ -116,6 +127,22 @@ export function registerToolHandlers(ctx: IPCContext): void { return { success: true, data: confirmationHook.getPendingConfirmations() }; }); + // ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) ===== + ipcMain.handle('tool:getRememberedDenials', async () => { + return { success: true, data: confirmationHook.getRememberedDenials() }; + }); + + ipcMain.handle('tool:resetRememberedDenial', async (_event, toolName: unknown) => { + if (typeof toolName !== 'string' || !toolName) { + return { success: false, error: 'Invalid toolName' }; + } + const reset = confirmationHook.resetRememberedDenial(toolName); + if (reset) { + log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`); + } + return { success: reset }; + }); + // ===== v0.2.0: 持久化自动执行设置 ===== ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => { // M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染 diff --git a/electron/preload.ts b/electron/preload.ts index b683823..c4489b1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -15,7 +15,8 @@ const metonaAPI = { // ===== Agent 交互 ===== agent: { /** 发送用户消息 */ - sendMessage: (message: unknown, sessionId: string) => ipcRenderer.invoke('agent:sendMessage', message, sessionId), + sendMessage: (message: unknown, sessionId: string) => + ipcRenderer.invoke('agent:sendMessage', message, sessionId), /** 监听流式事件 */ onStreamEvent: (callback: (event: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); @@ -42,17 +43,21 @@ const metonaAPI = { sessions: { list: () => ipcRenderer.invoke('sessions:list'), create: (title?: string) => ipcRenderer.invoke('sessions:create', title), - rename: (sessionId: string, title: string) => ipcRenderer.invoke('sessions:rename', sessionId, title), + rename: (sessionId: string, title: string) => + ipcRenderer.invoke('sessions:rename', sessionId, title), delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId), getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId), - pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned), - archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived), + pin: (sessionId: string, pinned: boolean) => + 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), clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId), /** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */ truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) => ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive), - saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data), + saveTrace: (sessionId: string, data: unknown) => + ipcRenderer.invoke('sessions:saveTrace', sessionId, data), getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId), }, @@ -61,12 +66,14 @@ const metonaAPI = { listServers: () => ipcRenderer.invoke('mcp:listServers'), addServer: (config: unknown) => ipcRenderer.invoke('mcp:addServer', config), removeServer: (name: string) => ipcRenderer.invoke('mcp:removeServer', name), - toggleServer: (name: string, enabled: boolean) => ipcRenderer.invoke('mcp:toggleServer', name, enabled), + toggleServer: (name: string, enabled: boolean) => + ipcRenderer.invoke('mcp:toggleServer', name, enabled), }, // ===== 记忆系统 ===== memory: { - search: (query: string, options?: unknown) => ipcRenderer.invoke('db:searchMemories', query, options), + search: (query: string, options?: unknown) => + ipcRenderer.invoke('db:searchMemories', query, options), listAll: (options?: { type?: string; limit?: number }) => ipcRenderer.invoke('memory:listAll', options), delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id), @@ -75,10 +82,24 @@ const metonaAPI = { // ===== v0.2.0: 任务管理 ===== tasks: { list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId), - create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => - ipcRenderer.invoke('tasks:create', data), - update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }, sessionId: string) => - ipcRenderer.invoke('tasks:update', id, updates, sessionId), + create: (data: { + sessionId: string; + title: string; + description?: string; + priority?: string; + parentId?: string; + }) => ipcRenderer.invoke('tasks:create', data), + update: ( + id: string, + updates: { + title?: string; + description?: string; + status?: string; + priority?: string; + assignedTo?: string; + }, + sessionId: string, + ) => ipcRenderer.invoke('tasks:update', id, updates, sessionId), delete: (id: string, sessionId: string) => ipcRenderer.invoke('tasks:delete', id, sessionId), // P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 写入后触发) onTaskChanged: (callback: (sessionId: string) => void) => { @@ -102,11 +123,19 @@ const metonaAPI = { ipcRenderer.on('tool:confirmationRequest', listener); return () => ipcRenderer.removeListener('tool:confirmationRequest', listener); }, - sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => - ipcRenderer.send('tool:confirmationResponse', response), + sendConfirmationResponse: (response: { + toolCallId: string; + approved: boolean; + remember: boolean; + autoExecute?: boolean; + }) => ipcRenderer.send('tool:confirmationResponse', response), // v0.3.2: 批量确认响应(并行工具一次性审批) - sendConfirmationResponseBatch: (response: { toolCallIds: string[]; approved: boolean; remember: boolean; autoExecute?: boolean }) => - ipcRenderer.send('tool:confirmationResponseBatch', response), + sendConfirmationResponseBatch: (response: { + toolCallIds: string[]; + approved: boolean; + remember: boolean; + autoExecute?: boolean; + }) => ipcRenderer.send('tool:confirmationResponseBatch', response), // v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失) getPendingConfirmations: () => ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{ @@ -120,6 +149,17 @@ const metonaAPI = { expiresAt?: number; }>; }>, + // v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问) + getRememberedDenials: () => + ipcRenderer.invoke('tool:getRememberedDenials') as Promise<{ + success: boolean; + data: Array<{ toolName: string; expiresInSeconds: number }>; + }>, + resetRememberedDenial: (toolName: string) => + ipcRenderer.invoke('tool:resetRememberedDenial', toolName) as Promise<{ + success: boolean; + error?: string; + }>, // v0.2.0: 持久化自动执行设置 setAutoExecute: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled), @@ -169,15 +209,18 @@ const metonaAPI = { // ===== 工具管理 ===== tools: { list: () => ipcRenderer.invoke('tools:list'), - toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled), + toggle: (toolName: string, enabled: boolean) => + ipcRenderer.invoke('tools:toggle', toolName, enabled), /** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */ onReady: (callback: (data: { toolCount: number }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data); + const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => + callback(data); ipcRenderer.on('tools:ready', listener); return () => ipcRenderer.removeListener('tools:ready', listener); }, /** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */ - isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>, + isReady: () => + ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>, }, // ===== 数据管理 ===== @@ -197,8 +240,10 @@ const metonaAPI = { // ===== Toast 通知桥接 ===== toast: { onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { type: string; message: string; options?: unknown }) => - callback(data); + const listener = ( + _event: Electron.IpcRendererEvent, + data: { type: string; message: string; options?: unknown }, + ) => callback(data); ipcRenderer.on('toast:show', listener); return () => ipcRenderer.removeListener('toast:show', listener); }, diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index 0725837..f887cc4 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -365,12 +365,64 @@ export class DatabaseService { tryAddColumn('semantic_memories', 'tf_cache', 'TEXT'); tryAddColumn('working_memories', 'tf_cache', 'TEXT'); + // v0.4.1 迁移 6: 重建 mcp_servers 表,transport CHECK 约束放宽以支持 'streamable-http' + // 旧约束 CHECK(transport IN ('stdio','sse')) 会拒绝新传输方式写入 + try { + const schemaRow = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'mcp_servers'") + .get() as { sql: string } | undefined; + // 检测现有 CHECK 约束是否已包含 streamable-http(新表跳过重建) + if (schemaRow && schemaRow.sql && !schemaRow.sql.includes('streamable-http')) { + log.info( + '[DB] Migration: rebuilding mcp_servers table to support streamable-http transport', + ); + + const rebuildMcpServers = db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS mcp_servers_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')), + command TEXT, + args TEXT, + url TEXT, + headers TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + last_connected INTEGER, + error_message TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + + INSERT INTO mcp_servers_new (id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at) + SELECT id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at + FROM mcp_servers; + + DROP TABLE mcp_servers; + ALTER TABLE mcp_servers_new RENAME TO mcp_servers; + `); + }); + rebuildMcpServers(); + + log.info( + '[DB] Migration: mcp_servers table rebuilt successfully (transport now supports streamable-http)', + ); + } + } catch (error) { + const msg = toErrorMessage(error); + log.warn(`[DB] Migration 6 (mcp_servers transport CHECK) skipped: ${msg}`); + // 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响 + } + // C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL // @see project_memory.md — Assistant messages with tool_calls must set content to null // SQLite 不支持 ALTER COLUMN,需要重建表 try { // 检测 content 列是否有 NOT NULL 约束 - const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>; + const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ + name: string; + notnull: number; + }>; const contentCol = columns.find((c) => c.name === 'content'); if (contentCol && contentCol.notnull === 1) { log.info('[DB] Migration: rebuilding messages table to allow NULL content'); diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index 4ada0c7..fbbed18 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -15,6 +15,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +// v0.4.1: streamable HTTP 传输(MCP 当前主流远程传输方式) +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { nanoid } from 'nanoid'; import type Database from 'better-sqlite3'; @@ -41,9 +43,15 @@ function safeParseArgs(raw: string): string[] { * 仅允许常见的 MCP Server 运行时,防止任意命令执行。 */ const ALLOWED_MCP_COMMANDS = new Set([ - 'npx', 'node', 'npm', - 'python', 'python3', 'uv', 'uvx', - 'bun', 'deno', + 'npx', + 'node', + 'npm', + 'python', + 'python3', + 'uv', + 'uvx', + 'bun', + 'deno', ]); /** @@ -58,12 +66,16 @@ const ALLOWED_MCP_COMMANDS = new Set([ */ function validateMcpCommand(command: string, args: string[]): void { // 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径) - const baseCmd = command.split(/[\\/]/).pop()?.replace(/\.exe$/i, '') ?? command; + const baseCmd = + command + .split(/[\\/]/) + .pop() + ?.replace(/\.exe$/i, '') ?? command; if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) { throw new Error( `MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` + - `For security reasons, only standard MCP runtimes are permitted.`, + `For security reasons, only standard MCP runtimes are permitted.`, ); } @@ -92,13 +104,22 @@ function validateMcpCommand(command: string, args: string[]): void { function buildSafeEnv(): Record { // 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程 const SENSITIVE_SUFFIXES = [ - '_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD', - '_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY', + '_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', + 'DEEPSEEK_API_KEY', + 'AGNES_API_KEY', + 'MIMO_API_KEY', + 'GITEA_PASSWORD', + 'DATABASE_PASSWORD', ]); const env: Record = {}; @@ -120,7 +141,8 @@ export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'err export interface MCPServerConfig { id: string; name: string; - transport: 'stdio' | 'sse'; + /** v0.4.1: 新增 'streamable-http'(MCP 当前主流远程传输);'sse' 保留向后兼容 */ + transport: 'stdio' | 'sse' | 'streamable-http'; command?: string; args?: string[]; url?: string; @@ -172,7 +194,10 @@ class MCPToolAdapter implements IMetonaTool { * 将 MCP JSON Schema 转换为 MetonaToolParams */ private convertSchema(schema: Record): MetonaToolDef['parameters'] { - const properties: Record = {}; + const properties: Record< + string, + { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string } + > = {}; const schemaProps = (schema.properties ?? {}) as Record>; for (const [key, prop] of Object.entries(schemaProps)) { @@ -212,11 +237,19 @@ export class MCPManager { */ async initialize(): Promise { const db = this.getDB(); - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM mcp_servers WHERE enabled = 1 - `).all() as Array<{ - id: string; name: string; transport: string; - command: string | null; args: string | null; url: string | null; + `, + ) + .all() as Array<{ + id: string; + name: string; + transport: string; + command: string | null; + args: string | null; + url: string | null; }>; const connectWithTimeout = (config: MCPServerConfig): Promise => @@ -230,7 +263,8 @@ export class MCPManager { const config: MCPServerConfig = { id: row.id, name: row.name, - transport: row.transport as 'stdio' | 'sse', + // v0.4.1: 支持三种传输方式(stdio / sse / streamable-http) + transport: row.transport as MCPServerConfig['transport'], command: row.command ?? undefined, args: row.args ? safeParseArgs(row.args) : undefined, url: row.url ?? undefined, @@ -279,12 +313,15 @@ export class MCPManager { env: buildSafeEnv(), }); } else if (config.transport === 'sse' && config.url) { - // SSE 模式(远程 HTTP) + // SSE 模式(远程 HTTP,旧式传输,保留向后兼容) transport = new SSEClientTransport(new URL(config.url)); + } else if (config.transport === 'streamable-http' && config.url) { + // v0.4.1: streamable HTTP 模式(MCP 当前主流远程传输) + transport = new StreamableHTTPClientTransport(new URL(config.url)); } else { throw new Error( `Unsupported transport "${config.transport}". ` + - `'stdio' requires 'command', 'sse' requires 'url'.`, + `'stdio' requires 'command', 'sse'/'streamable-http' requires 'url'.`, ); } @@ -315,9 +352,11 @@ export class MCPManager { // 更新数据库 const db = this.getDB(); - db.prepare(` + db.prepare( + ` UPDATE mcp_servers SET last_connected = ?, error_message = NULL WHERE name = ? - `).run(Date.now(), name); + `, + ).run(Date.now(), name); log.info(`MCP server "${name}" connected: ${tools.length} tool(s)`); } catch (error) { @@ -329,9 +368,11 @@ export class MCPManager { // 更新数据库 const db = this.getDB(); - db.prepare(` + db.prepare( + ` UPDATE mcp_servers SET error_message = ? WHERE name = ? - `).run((error as Error).message, name); + `, + ).run((error as Error).message, name); log.error(`MCP server "${name}" connection failed:`, error); throw error; @@ -369,19 +410,28 @@ export class MCPManager { */ async toggleServer(name: string, enabled: boolean): Promise { const db = this.getDB(); - db.prepare(` + db.prepare( + ` UPDATE mcp_servers SET enabled = ?, updated_at = ? WHERE name = ? - `).run(enabled ? 1 : 0, Date.now(), name); + `, + ).run(enabled ? 1 : 0, Date.now(), name); if (enabled) { - const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as { - id: string; name: string; transport: string; - command: string | null; args: string | null; url: string | null; - } | undefined; + const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as + | { + id: string; + name: string; + transport: string; + command: string | null; + args: string | null; + url: string | null; + } + | undefined; if (row) { await this.connectServer({ - id: row.id, name: row.name, - transport: row.transport as 'stdio' | 'sse', + id: row.id, + name: row.name, + transport: row.transport as MCPServerConfig['transport'], command: row.command ?? undefined, args: row.args ? safeParseArgs(row.args) : undefined, url: row.url ?? undefined, @@ -395,16 +445,30 @@ export class MCPManager { /** * 添加新的 MCP Server + * + * v0.4.1: 校验 transport 与对应字段匹配(stdio→command,sse/streamable-http→url) */ async addServer(config: Omit): Promise { const db = this.getDB(); const id = `mcp_${nanoid(8)}`; - db.prepare(` + // 校验传输方式与必填字段 + if (config.transport === 'stdio' && !config.command) { + throw new Error('stdio transport requires "command"'); + } + if ((config.transport === 'sse' || config.transport === 'streamable-http') && !config.url) { + throw new Error(`${config.transport} transport requires "url"`); + } + + db.prepare( + ` INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled) VALUES (?, ?, ?, ?, ?, ?, 1) - `).run( - id, config.name, config.transport, + `, + ).run( + id, + config.name, + config.transport, config.command ?? null, config.args ? JSON.stringify(config.args) : null, config.url ?? null, diff --git a/package-lock.json b/package-lock.json index 985a47e..b403124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -15,7 +15,6 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@mui/icons-material": "^9.1.1", "@mui/material": "^9.1.2", - "@types/shell-quote": "^1.7.5", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", "dotenv": "^17.4.2", @@ -24,14 +23,15 @@ "fuse.js": "^7.1.0", "lru-cache": "^11.1.0", "nanoid": "^5.1.5", + "node-html-parser": "^6.1.13", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.12", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "shell-quote": "^1.10.0", - "sql.js": "^1.12.0", "zod": "^3.25.67", "zustand": "^5.0.5" }, @@ -39,13 +39,13 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@eslint/js": "^9.39.5", - "@playwright/test": "^1.52.0", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.1.7", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.15.29", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", + "@types/shell-quote": "^1.7.5", "@vitejs/plugin-react": "^4.5.2", "autoprefixer": "^10.4.21", "cross-env": "^10.1.0", @@ -53,6 +53,8 @@ "electron-builder": "^26.0.12", "electron-vite": "^3.1.0", "eslint": "^9.28.0", + "husky": "^9.1.7", + "lint-staged": "^17.3.0", "lucide-react": "^0.511.0", "postcss": "^8.5.4", "prettier": "^3.5.3", @@ -2225,22 +2227,6 @@ "node": ">=14.18.0" } }, - "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", @@ -3202,6 +3188,7 @@ "version": "1.7.5", "resolved": "https://registry.npmmirror.com/@types/shell-quote/-/shell-quote-1.7.5.tgz", "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", + "dev": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -4201,6 +4188,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", @@ -4911,6 +4904,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", @@ -5247,6 +5268,73 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-9.0.0.tgz", @@ -6500,9 +6588,9 @@ "license": "ISC" }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7017,6 +7105,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", @@ -7172,6 +7269,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -7885,6 +7998,40 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "17.3.0", + "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.3.0.tgz", + "integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.5", + "string-argv": "^0.3.2", + "tinyexec": "^1.2.4" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/lint-staged/node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -9184,6 +9331,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmmirror.com/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", @@ -9230,6 +9387,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", @@ -9519,9 +9688,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -9571,38 +9740,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", @@ -10087,6 +10224,16 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-virtuoso": { + "version": "4.18.12", + "resolved": "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.12.tgz", + "integrity": "sha512-6c1SnRicSBfG+WnbhcyJUxzDHvvxD3vsux/EpcfVbgB7clyP1Od2r81TAGTgwbL7U2qP889RR4u+CH6WjgpW0g==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -10781,12 +10928,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/sql.js": { - "version": "1.14.1", - "resolved": "https://registry.npmmirror.com/sql.js/-/sql.js-1.14.1.tgz", - "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", - "license": "MIT" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", @@ -10829,6 +10970,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", @@ -11787,21 +11938,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { "version": "3.2.6", "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.6.tgz", @@ -12001,6 +12137,23 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 375eb9d..01e5ef7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.4.0", + "version": "0.4.1", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", @@ -9,7 +9,7 @@ "scripts": { "dev": "electron-vite dev", "build": "npm run prebuild && electron-vite build && electron-builder", - "prebuild": "npm dedupe && rimraf release", + "prebuild": "npm dedupe && node -e \"require('fs').rmSync('release',{recursive:true,force:true})\"", "build:renderer": "electron-vite build --rendererOnly", "build:electron": "tsc -p tsconfig.node.json", "preview": "vite preview", @@ -21,7 +21,20 @@ "test": "vitest run", "test:watch": "vitest", "test:electron": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs run", - "test:e2e": "playwright test" + "prepare": "husky" + }, + "lint-staged": { + "electron/**/*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "src/**/*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "src/**/*.css": [ + "prettier --write" + ] }, "dependencies": { "@emotion/react": "^11.14.0", @@ -30,7 +43,6 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@mui/icons-material": "^9.1.1", "@mui/material": "^9.1.2", - "@types/shell-quote": "^1.7.5", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", "dotenv": "^17.4.2", @@ -39,14 +51,15 @@ "fuse.js": "^7.1.0", "lru-cache": "^11.1.0", "nanoid": "^5.1.5", + "node-html-parser": "^6.1.13", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.12", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "shell-quote": "^1.10.0", - "sql.js": "^1.12.0", "zod": "^3.25.67", "zustand": "^5.0.5" }, @@ -54,13 +67,13 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@eslint/js": "^9.39.5", - "@playwright/test": "^1.52.0", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.1.7", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.15.29", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", + "@types/shell-quote": "^1.7.5", "@vitejs/plugin-react": "^4.5.2", "autoprefixer": "^10.4.21", "cross-env": "^10.1.0", @@ -68,6 +81,8 @@ "electron-builder": "^26.0.12", "electron-vite": "^3.1.0", "eslint": "^9.28.0", + "husky": "^9.1.7", + "lint-staged": "^17.3.0", "lucide-react": "^0.511.0", "postcss": "^8.5.4", "prettier": "^3.5.3", diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 05ff17b..2bae594 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -18,11 +18,28 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { - Dialog, DialogTitle, DialogContent, DialogActions, - Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox, - Accordion, AccordionSummary, AccordionDetails, - LinearProgress, List, ListItem, ListItemIcon, ListItemText, - IconButton, Tooltip, Divider, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Chip, + Alert, + FormControlLabel, + Checkbox, + Accordion, + AccordionSummary, + AccordionDetails, + LinearProgress, + List, + ListItem, + ListItemIcon, + ListItemText, + IconButton, + Tooltip, + Divider, } from '@mui/material'; import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react'; @@ -64,36 +81,65 @@ export function ConfirmationDialog(): React.JSX.Element | null { const [initialMs, setInitialMs] = useState(0); // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 const [confirmAutoExecute, setConfirmAutoExecute] = useState(false); + // v0.4.1: 本会话内记住"拒绝"的工具(带 TTL,可手动恢复询问) + const [rememberedDenials, setRememberedDenials] = useState< + Array<{ toolName: string; expiresInSeconds: number }> + >([]); + + // v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步) + const refreshRememberedDenials = useCallback(async () => { + try { + const result = await window.metona?.tool?.getRememberedDenials(); + setRememberedDenials(result?.success ? (result.data ?? []) : []); + } catch { + setRememberedDenials([]); + } + }, []); + + // v0.4.1: 重置某个工具的拒绝记忆(恢复询问) + const handleResetDenial = useCallback(async (toolName: string) => { + try { + await window.metona?.tool?.resetRememberedDenial(toolName); + setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName)); + } catch { + // 重置失败保持现状,TTL 到期后仍会自动恢复 + } + }, []); // ===== 弹框打开时主动拉取已积压的 pending 请求 ===== // 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达, // 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。 - const refreshPending = useCallback(async (mergeNew?: ConfirmationRequest) => { - try { - const result = await window.metona?.tool?.getPendingConfirmations(); - const pendingList: ConfirmationRequest[] = result?.success ? result.data : []; - // 合并新到的 IPC 请求(若 pending 快照已包含则去重) - const merged = [...pendingList]; - if (mergeNew) { - const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId); - if (!exists) merged.push(mergeNew); - } - // 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加) - const dedupedMap = new Map(); - for (const r of merged) dedupedMap.set(r.toolCallId, r); - const deduped = Array.from(dedupedMap.values()); + const refreshPending = useCallback( + async (mergeNew?: ConfirmationRequest) => { + try { + const result = await window.metona?.tool?.getPendingConfirmations(); + const pendingList: ConfirmationRequest[] = result?.success ? result.data : []; + // 合并新到的 IPC 请求(若 pending 快照已包含则去重) + const merged = [...pendingList]; + if (mergeNew) { + const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId); + if (!exists) merged.push(mergeNew); + } + // 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加) + const dedupedMap = new Map(); + for (const r of merged) dedupedMap.set(r.toolCallId, r); + const deduped = Array.from(dedupedMap.values()); - setRequests(deduped); - // 默认全选 - setSelectedIds(new Set(deduped.map((r) => r.toolCallId))); - } catch { - // 拉取失败时回退到只显示新到的请求 - if (mergeNew) { - setRequests([mergeNew]); - setSelectedIds(new Set([mergeNew.toolCallId])); + setRequests(deduped); + // 默认全选 + setSelectedIds(new Set(deduped.map((r) => r.toolCallId))); + } catch { + // 拉取失败时回退到只显示新到的请求 + if (mergeNew) { + setRequests([mergeNew]); + setSelectedIds(new Set([mergeNew.toolCallId])); + } } - } - }, []); + // v0.4.1: 弹框打开时同步拉取被拒工具列表(展示恢复询问入口) + void refreshRememberedDenials(); + }, + [refreshRememberedDenials], + ); useEffect(() => { // 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API) @@ -207,44 +253,45 @@ export function ConfirmationDialog(): React.JSX.Element | null { setSelectedIds(new Set()); }, []); - const handleRespond = useCallback((approved: boolean, onlySelected: boolean = true) => { - // 决定要处理的 toolCallId 列表 - // onlySelected=true: 仅处理勾选项(用于"批准选中") - // onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部") - const targetIds = onlySelected - ? Array.from(selectedIds) - : requests.map((r) => r.toolCallId); + const handleRespond = useCallback( + (approved: boolean, onlySelected: boolean = true) => { + // 决定要处理的 toolCallId 列表 + // onlySelected=true: 仅处理勾选项(用于"批准选中") + // onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部") + const targetIds = onlySelected ? Array.from(selectedIds) : requests.map((r) => r.toolCallId); - if (targetIds.length === 0) return; + if (targetIds.length === 0) return; - // 通过批量 IPC 通道发送响应 - // autoExecute 仅在批准时生效(拒绝时无需持久化) - window.metona?.tool?.sendConfirmationResponseBatch({ - toolCallIds: targetIds, - approved, - remember, - autoExecute: approved && autoExecute, - }); + // 通过批量 IPC 通道发送响应 + // autoExecute 仅在批准时生效(拒绝时无需持久化) + window.metona?.tool?.sendConfirmationResponseBatch({ + toolCallIds: targetIds, + approved, + remember, + autoExecute: approved && autoExecute, + }); - if (onlySelected) { - // "批准选中":只移除已处理的,保留未选中的 pending - // 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败) - const targetSet = new Set(targetIds); - const remaining = requests.filter((r) => !targetSet.has(r.toolCallId)); - setRequests(remaining); - // 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中) - setSelectedIds(new Set()); - // 主动刷新后端 pending 列表,拉取可能新到达的请求 - // 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应 - if (remaining.length === 0) { - setTimeout(() => refreshPending(), 50); + if (onlySelected) { + // "批准选中":只移除已处理的,保留未选中的 pending + // 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败) + const targetSet = new Set(targetIds); + const remaining = requests.filter((r) => !targetSet.has(r.toolCallId)); + setRequests(remaining); + // 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中) + setSelectedIds(new Set()); + // 主动刷新后端 pending 列表,拉取可能新到达的请求 + // 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应 + if (remaining.length === 0) { + setTimeout(() => refreshPending(), 50); + } + } else { + // "拒绝全部 / 批准全部":清空所有 + setRequests([]); + setSelectedIds(new Set()); } - } else { - // "拒绝全部 / 批准全部":清空所有 - setRequests([]); - setSelectedIds(new Set()); - } - }, [requests, selectedIds, remember, autoExecute, refreshPending]); + }, + [requests, selectedIds, remember, autoExecute, refreshPending], + ); if (requests.length === 0) return null; @@ -269,9 +316,8 @@ export function ConfirmationDialog(): React.JSX.Element | null { const isUrgent = remainingSec <= 10 && remainingSec > 0; const isExpired = remainingMs <= 0 && earliestExpires != null; const totalMs = initialMs || remainingMs; - const progressPercent = totalMs > 0 - ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) - : 100; + const progressPercent = + totalMs > 0 ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) : 100; // 格式化参数显示 const formatArg = (key: string, value: unknown): string => { @@ -288,303 +334,340 @@ export function ConfirmationDialog(): React.JSX.Element | null { }; // 综合风险文案 - const batchReason = totalCount > 1 - ? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}` - : requests[0]?.reason ?? 'Agent 正在请求执行工具'; + const batchReason = + totalCount > 1 + ? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}` + : (requests[0]?.reason ?? 'Agent 正在请求执行工具'); return ( <> - 0} - onClose={(_, reason) => { - // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致) - // 防止超时后用户误触关闭,导致后端 pending 状态不一致 - if (isExpired) return; - // 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭 - // 防止 ESC 穿透到外层导致意外拒绝所有工具执行 - if (confirmAutoExecute) return; - // 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC) - // reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick' - if (reason === 'backdropClick' || reason === 'escapeKeyDown') { - handleRespond(false, false); - } - }} - maxWidth="md" - fullWidth - slotProps={{ - paper: { - sx: { bgcolor: 'background.paper' }, - }, - }} - > - - - - 工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`} - - - - refreshPending()}> - - - - - - - - {batchReason} - - - {earliestExpires && !isExpired && ( - - - - {totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s - - - - )} - - {/* 选择控制条 */} - - - - - 已选 {selectedCount} / {totalCount} + 0} + onClose={(_, reason) => { + // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致) + // 防止超时后用户误触关闭,导致后端 pending 状态不一致 + if (isExpired) return; + // 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭 + // 防止 ESC 穿透到外层导致意外拒绝所有工具执行 + if (confirmAutoExecute) return; + // 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC) + // reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick' + if (reason === 'backdropClick' || reason === 'escapeKeyDown') { + handleRespond(false, false); + } + }} + maxWidth="md" + fullWidth + slotProps={{ + paper: { + sx: { bgcolor: 'background.paper' }, + }, + }} + > + + + + 工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`} - + + + refreshPending()}> + + + + - + + + {batchReason} + - {/* 分组列表 */} - - {grouped.map((group) => { - const groupIds = group.requests.map((r) => r.toolCallId); - const groupAllSelected = groupIds.every((id) => selectedIds.has(id)); - const groupSomeSelected = groupIds.some((id) => selectedIds.has(id)); - const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default'; - const isMulti = group.requests.length > 1; + {/* v0.4.1: 本会话内被记住拒绝的工具 — 提供恢复询问入口(拒绝记忆 10 分钟后自动过期) */} + {rememberedDenials.length > 0 && ( + + + + 本会话中已记住拒绝的工具({Math.ceil(rememberedDenials[0].expiresInSeconds / 60)}{' '} + 分钟后自动恢复询问): + + {rememberedDenials.map((d) => ( + handleResetDenial(d.toolName)} + sx={{ cursor: 'pointer' }} + /> + ))} + + + )} - return ( - + + - }> - - { - e.stopPropagation(); - handleToggleGroupSelect(group); - }} - onClick={(e) => e.stopPropagation()} - /> - - {group.toolName} - - {isMulti && ( + {totalCount > 1 ? '最早过期 ' : '剩余 '} + {remainingSec}s + + + + )} + + {/* 选择控制条 */} + + + + + 已选 {selectedCount} / {totalCount} + + + + + + {/* 分组列表 */} + + {grouped.map((group) => { + const groupIds = group.requests.map((r) => r.toolCallId); + const groupAllSelected = groupIds.every((id) => selectedIds.has(id)); + const groupSomeSelected = groupIds.some((id) => selectedIds.has(id)); + const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default'; + const isMulti = group.requests.length > 1; + + return ( + + }> + + { + e.stopPropagation(); + handleToggleGroupSelect(group); + }} + onClick={(e) => e.stopPropagation()} + /> + + {group.toolName} + + {isMulti && ( + + )} - )} - - - - - {group.requests.map((req, idx) => { - const isSelected = selectedIds.has(req.toolCallId); - return ( - - #{idx + 1} - - ) : undefined - } - > - - handleToggleSelect(req.toolCallId)} - /> - - - {Object.entries(req.args).map(([key, value]) => ( - - - {key}: - - - {formatArg(key, value)} - - - ))} - {Object.keys(req.args).length === 0 && ( - - (无参数) - - )} - + + + + {group.requests.map((req, idx) => { + const isSelected = selectedIds.has(req.toolCallId); + return ( + + #{idx + 1} + + ) : undefined } - /> - - ); - })} - - - ); - })} - + > + + handleToggleSelect(req.toolCallId)} + /> + + + {Object.entries(req.args).map(([key, value]) => ( + + + {key}: + + + {formatArg(key, value)} + + + ))} + {Object.keys(req.args).length === 0 && ( + + (无参数) + + )} + + } + /> + + ); + })} + + + ); + })} + - + - setRemember(e.target.checked)} - size="small" - /> - } - label={ - - 在本次会话中记住此决定(同工具不再询问) - - } - /> + setRemember(e.target.checked)} + size="small" + /> + } + label={ + + 在本次会话中记住此决定(同工具不再询问) + + } + /> - { - // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 - if (e.target.checked) { - setConfirmAutoExecute(true); - } else { - setAutoExecute(false); - } - }} - size="small" - /> - } - label={ - - 永久自动执行选中工具(跨会话不再询问,可在设置中关闭) - {selectedCount > 0 && autoExecute && ( - - · 将应用到 {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size} 个工具 - - )} - - } - /> - + { + // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 + if (e.target.checked) { + setConfirmAutoExecute(true); + } else { + setAutoExecute(false); + } + }} + size="small" + /> + } + label={ + + 永久自动执行选中工具(跨会话不再询问,可在设置中关闭) + {selectedCount > 0 && autoExecute && ( + + · 将应用到{' '} + { + new Set( + Array.from(selectedIds) + .map((id) => requests.find((r) => r.toolCallId === id)?.toolName) + .filter(Boolean) as string[], + ).size + }{' '} + 个工具 + + )} + + } + /> + - - - - - - + + + + + + {/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */} {/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown; @@ -603,14 +686,17 @@ export function ConfirmationDialog(): React.JSX.Element | null { 确认永久自动执行? - 勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置 → 工具管理」中关闭。 + 勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置 + → 工具管理」中关闭。 确定要启用吗? - + + ))} + + + + ); +} diff --git a/src/components/settings/LLMSettings.tsx b/src/components/settings/LLMSettings.tsx new file mode 100644 index 0000000..9732666 --- /dev/null +++ b/src/components/settings/LLMSettings.tsx @@ -0,0 +1,494 @@ +/** + * LLMSettings — LLM 配置 Tab + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 + * 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。 + */ + +import { useState, useEffect } from 'react'; +import { + Button, + TextField, + Select, + MenuItem, + Stack, + Typography, + Divider, + InputLabel, + FormControl, + IconButton, + CircularProgress, +} from '@mui/material'; +import { Eye, EyeOff } from 'lucide-react'; +import { useAgentStore } from '@renderer/stores/agent-store'; +import { PROVIDER_LABELS } from '@renderer/lib/constants'; +import { PROVIDER_URLS } from './useConfig'; + +export function LLMSettings() { + // 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致: + // 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突) + // 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入 + // 3. 错误提示笼统(不指向具体字段) + const [provider, setProvider] = useState(''); + const [model, setModel] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [baseURL, setBaseURL] = useState(''); + const [numCtx, setNumCtx] = useState(null); + // v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死) + const [dsCtxWindow, setDsCtxWindow] = useState(1000000); + const [agnesCtxWindow, setAgnesCtxWindow] = useState(1000000); + const [mimoCtxWindow, setMimoCtxWindow] = useState(1000000); + // P3: OpenAI/Anthropic contextWindow + const [oaCtxWindow, setOaCtxWindow] = useState(128000); + const [anthropicCtxWindow, setAnthropicCtxWindow] = useState(200000); + // P1: 故障转移 Provider 配置 + const [fbProvider, setFbProvider] = useState(''); + const [fbModel, setFbModel] = useState(''); + const [fbApiKey, setFbApiKey] = useState(''); + const [fbBaseURL, setFbBaseURL] = useState(''); + const [showKey, setShowKey] = useState(false); + const [showFbKey, setShowFbKey] = useState(false); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + + // 初始化:一次性加载所有 LLM 配置字段 + useEffect(() => { + let cancelled = false; + const load = async () => { + if (!window.metona?.config?.get) { + setLoaded(true); + return; + } + try { + const results = await Promise.all([ + window.metona.config.get('llm.provider'), + window.metona.config.get('llm.model'), + window.metona.config.get('llm.apiKey'), + window.metona.config.get('llm.baseURL'), + window.metona.config.get('ollama.numCtx'), + window.metona.config.get('deepseek.contextWindow'), + window.metona.config.get('agnes.contextWindow'), + window.metona.config.get('mimo.contextWindow'), + window.metona.config.get('openai.contextWindow'), + window.metona.config.get('anthropic.contextWindow'), + // P1: 故障转移配置 + window.metona.config.get('llm.fallbackProvider'), + window.metona.config.get('llm.fallbackModel'), + window.metona.config.get('llm.fallbackApiKey'), + window.metona.config.get('llm.fallbackBaseURL'), + ]); + if (cancelled) return; + const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = results; + setProvider((p as string) ?? ''); + setModel((m as string) ?? ''); + setApiKey((k as string) ?? ''); + setBaseURL((u as string) ?? ''); + setNumCtx((nc as number | null) ?? null); + if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds); + if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag); + if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi); + if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa); + if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an); + setFbProvider((fbp as string) ?? ''); + setFbModel((fbm as string) ?? ''); + setFbApiKey((fbk as string) ?? ''); + setFbBaseURL((fbu as string) ?? ''); + } catch (err) { + console.error('[LLMSettings]', err); + } finally { + if (!cancelled) setLoaded(true); + } + }; + load(); + return () => { + cancelled = true; + }; + }, []); + + // 同步 Provider/Model 到 Agent Store(含 contextWindow) + // 注意:仅同步运行时状态,不落库 + useEffect(() => { + useAgentStore.getState().setProvider(provider, model); + }, [provider, model]); + + // v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider) + useEffect(() => { + if (provider === 'ollama') { + if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx }); + } else if (provider === 'deepseek') { + if (dsCtxWindow != null && dsCtxWindow > 0) + useAgentStore.setState({ contextWindow: dsCtxWindow }); + } else if (provider === 'agnes') { + if (agnesCtxWindow != null && agnesCtxWindow > 0) + useAgentStore.setState({ contextWindow: agnesCtxWindow }); + } else if (provider === 'mimo') { + if (mimoCtxWindow != null && mimoCtxWindow > 0) + useAgentStore.setState({ contextWindow: mimoCtxWindow }); + } else if (provider === 'openai') { + if (oaCtxWindow != null && oaCtxWindow > 0) + useAgentStore.setState({ contextWindow: oaCtxWindow }); + } else if (provider === 'anthropic') { + if (anthropicCtxWindow != null && anthropicCtxWindow > 0) + useAgentStore.setState({ contextWindow: anthropicCtxWindow }); + } + }, [ + provider, + numCtx, + dsCtxWindow, + agnesCtxWindow, + mimoCtxWindow, + oaCtxWindow, + anthropicCtxWindow, + ]); + + // ===== 字段级 inline 校验 ===== + // Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL) + const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL); + // Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数) + const modelHasSpace = !!model && /\s/.test(model); + // contextWindow / numCtx:必须为有限正数且不低于最小值 + const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512); + const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096; + const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096; + const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096; + const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096; + const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096; + + // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) + const hasBlockingError = + urlError || + modelHasSpace || + numCtxError || + (provider === 'deepseek' && dsCtxError) || + (provider === 'agnes' && agnesCtxError) || + (provider === 'mimo' && mimoCtxError) || + (provider === 'openai' && oaCtxError) || + (provider === 'anthropic' && anthropicCtxError); + + // 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL + // 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found + const handleProviderChange = (newProvider: string) => { + const oldProvider = provider; + setProvider(newProvider); + // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同) + if (oldProvider !== newProvider && apiKey) { + setApiKey(''); + } + // 切换 Provider 时清空 model(不同 Provider 支持的模型名不同,如 deepseek-v4-pro 不适用于 ollama) + if (oldProvider !== newProvider && model) { + setModel(''); + } + // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时覆盖) + const currentUrl = baseURL.trim(); + const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl); + if (isDefaultUrl || !currentUrl) { + setBaseURL(PROVIDER_URLS[newProvider] ?? ''); + } + }; + + const handleSave = async () => { + if (hasBlockingError) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('请修正表单中的错误后再保存')) + .catch(() => {}); + return; + } + setSaving(true); + try { + const setBatch = window.metona?.config?.setBatch; + if (!setBatch) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('配置 API 不可用')) + .catch(() => {}); + return; + } + // v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败 + const entries: Array<{ key: string; value: unknown }> = [ + { key: 'llm.provider', value: provider }, + { key: 'llm.model', value: model }, + { key: 'llm.apiKey', value: apiKey }, + { key: 'llm.baseURL', value: baseURL }, + { key: 'ollama.numCtx', value: numCtx }, + { key: 'deepseek.contextWindow', value: dsCtxWindow }, + { key: 'agnes.contextWindow', value: agnesCtxWindow }, + { key: 'mimo.contextWindow', value: mimoCtxWindow }, + { key: 'openai.contextWindow', value: oaCtxWindow }, + { key: 'anthropic.contextWindow', value: anthropicCtxWindow }, + // P1: 故障转移 Provider(主 Provider 失败时切换) + { key: 'llm.fallbackProvider', value: fbProvider }, + { key: 'llm.fallbackModel', value: fbModel }, + { key: 'llm.fallbackApiKey', value: fbApiKey }, + { key: 'llm.fallbackBaseURL', value: fbBaseURL }, + ]; + const r = await setBatch(entries); + if (r && !r.success) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r.error ?? '配置保存失败')) + .catch(() => {}); + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.success('配置已保存')) + .catch(() => {}); + } + } catch (err) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)) + .catch(() => {}); + } finally { + setSaving(false); + } + }; + + if (!loaded) { + return ( + + + LLM 配置 + + + 加载中... + + + ); + } + + const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim(); + + return ( + + + LLM 配置 + + + Provider + + + setBaseURL(e.target.value)} + placeholder="如 https://api.deepseek.com" + error={urlError} + helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '} + /> + setModel(e.target.value)} + placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5" + error={modelHasSpace} + helperText={modelHasSpace ? '模型名称不能包含空格' : ' '} + /> + {provider !== 'ollama' && ( + <> + setApiKey(e.target.value)} + placeholder="sk-..." + error={apiKeyEmpty} + helperText={ + apiKeyEmpty + ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` + : ' ' + } + slotProps={{ + input: { + endAdornment: ( + setShowKey(!showKey)}> + {showKey ? : } + + ), + }, + }} + /> + + )} + {provider === 'ollama' && ( + { + const v = e.target.value; + setNumCtx(v === '' ? null : Number(v)); + }} + placeholder="默认由模型决定(如 2048、4096、128000)" + slotProps={{ htmlInput: { min: 512, step: 512 } }} + error={numCtxError} + helperText={numCtxError ? '最小值为 512' : ' '} + /> + )} + {/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} + {provider === 'deepseek' && ( + setDsCtxWindow(Number(e.target.value) || 1000000)} + placeholder="如 64000、128000、1000000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + error={dsCtxError} + helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} + /> + )} + {provider === 'agnes' && ( + setAgnesCtxWindow(Number(e.target.value) || 1000000)} + placeholder="如 64000、128000、1000000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + error={agnesCtxError} + helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} + /> + )} + {provider === 'mimo' && ( + setMimoCtxWindow(Number(e.target.value) || 1000000)} + placeholder="如 65536、131072、1000000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + error={mimoCtxError} + helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'} + /> + )} + {provider === 'openai' && ( + setOaCtxWindow(Number(e.target.value) || 128000)} + placeholder="如 128000、200000、1000000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + error={oaCtxError} + helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'} + /> + )} + {provider === 'anthropic' && ( + setAnthropicCtxWindow(Number(e.target.value) || 200000)} + placeholder="如 200000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + error={anthropicCtxError} + helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'} + /> + )} + + {/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */} + + + 故障转移(可选) + + + 主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。 + + + 备用 Provider + + + {fbProvider && ( + <> + setFbBaseURL(e.target.value)} + placeholder="如 https://api.deepseek.com" + /> + setFbModel(e.target.value)} + placeholder="如 deepseek-v4-flash" + /> + {fbProvider !== 'ollama' && ( + setFbApiKey(e.target.value)} + placeholder="sk-..." + slotProps={{ + input: { + endAdornment: ( + setShowFbKey(!showFbKey)}> + {showFbKey ? : } + + ), + }, + }} + /> + )} + + )} + + {/* Save 按钮:批量提交,取消 onChange 实时落库 */} + + + {hasBlockingError && ( + + 请修正表单错误后再保存 + + )} + + + ); +} diff --git a/src/components/settings/LogsSettings.tsx b/src/components/settings/LogsSettings.tsx new file mode 100644 index 0000000..54f64c0 --- /dev/null +++ b/src/components/settings/LogsSettings.tsx @@ -0,0 +1,287 @@ +/** + * LogsSettings — 日志与数据 Tab + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 + * 功能:日志级别、日志文件路径(打开/复制)、数据导出与清理(Dialog 确认)。 + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogActions, + Button, + TextField, + Select, + MenuItem, + Stack, + Typography, + Divider, + Box, + InputLabel, + FormControl, +} from '@mui/material'; +import { Folder, Copy } 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'; + +export function LogsSettings() { + const [logLevel, setLogLevel] = useConfig('logging.level', 'info'); + const [clearing, setClearing] = useState(null); + // L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm(),保持 UI 一致性 + const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>( + null, + ); + // P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮 + // electron-log 默认写入路径为 ${userData}/logs/main.log + const [logFilePath, setLogFilePath] = useState(''); + const [logPathLoading, setLogPathLoading] = useState(true); + const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle'); + + // P3-13: 组件挂载时获取日志文件路径 + useEffect(() => { + let cancelled = false; + (async () => { + try { + const appData = await window.metona?.app?.getAppDataPath?.(); + if (cancelled) return; + if (appData) { + // electron-log 默认日志路径: ${userData}/logs/main.log + // 路径分隔符由系统决定,直接拼接避免引入 path 模块 + const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\'; + setLogFilePath(`${appData}${sep}logs${sep}main.log`); + } + } catch (e) { + // 获取失败不阻塞 UI + console.warn('[LogsSettings] Failed to get app data path:', e); + } finally { + if (!cancelled) setLogPathLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const handleOpenLogFolder = async () => { + if (!logFilePath) return; + try { + const r = await window.metona?.app?.showItemInFolder?.(logFilePath); + if (r && !r.success) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)) + .catch(() => {}); + } + } catch (e) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)) + .catch(() => {}); + } + }; + + const handleCopyLogPath = async () => { + if (!logFilePath) return; + try { + await navigator.clipboard.writeText(logFilePath); + setCopyState('success'); + setTimeout(() => setCopyState('idle'), 1500); + } catch (e) { + setCopyState('error'); + setTimeout(() => setCopyState('idle'), 1500); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)) + .catch(() => {}); + } + }; + + const handleExport = async () => { + if (!window.metona?.data?.exportData) return; + try { + const r = await window.metona.data.exportData(); + if (r.success && r.data) { + const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(b); + a.download = `metona-export-${Date.now()}.json`; + a.click(); + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)) + .catch(() => {}); + } + } catch (err) { + console.error('[LogsSettings]', err); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)) + .catch(() => {}); + } + }; + const labels: Record = { + sessions: '所有会话', + memories: '所有记忆', + auditLogs: '审计日志', + }; + + // L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈 + const handleClearConfirm = async () => { + if (!confirmClear) return; + const type = confirmClear; + setClearing(type); + setConfirmClear(null); + try { + let r; + if (type === 'sessions') r = await window.metona?.data?.clearSessions(); + else if (type === 'memories') r = await window.metona?.data?.clearMemories(); + else r = await window.metona?.data?.clearAuditLogs(); + if (r?.success) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.success(`${labels[type]}已清理`)) + .catch(() => {}); + // 修复: 清理会话后同步清空前端状态,无需重启应用 + if (type === 'sessions') { + useSessionStore.getState().setSessions([]); + useSessionStore.getState().setCurrentSession(null); + // 同时清空当前消息列表,防止聊天面板显示已删除的会话内容 + useAgentStore.getState().setMessages([]); + } + // v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果) + if (type === 'memories') { + useUIStore.getState().bumpMemoryVersion(); + } + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)) + .catch(() => {}); + } + } catch (e) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`失败: ${(e as Error).message}`)) + .catch(() => {}); + } + setClearing(null); + }; + + return ( + + + 日志与数据 + + + 日志级别 + + + + {/* P3-13: 日志文件路径展示与打开按钮 */} + + + 日志文件 + + + + + + + + 日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。 + + + + + + 数据管理 + + + + + + + {/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */} + setConfirmClear(null)} + maxWidth="xs" + fullWidth + > + 确认清理 + + + 确定清理{confirmClear ? labels[confirmClear] : ''}?此操作不可撤销。 + + + + + + + + + ); +} diff --git a/src/components/settings/MCPSettings.tsx b/src/components/settings/MCPSettings.tsx new file mode 100644 index 0000000..f795720 --- /dev/null +++ b/src/components/settings/MCPSettings.tsx @@ -0,0 +1,334 @@ +/** + * MCPSettings — MCP 服务管理 Tab + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 + * 功能:MCP Server 列表/连接/断开/移除(Dialog 二次确认)/添加。 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogActions, + Button, + TextField, + Stack, + Typography, + Box, + Alert, +} from '@mui/material'; + +export function MCPSettings() { + const [servers, setServers] = useState< + Array<{ name: string; status: string; toolCount: number; error?: string }> + >([]); + const [showAdd, setShowAdd] = useState(false); + const [newName, setNewName] = useState(''); + const [newCommand, setNewCommand] = useState(''); + const [newArgs, setNewArgs] = useState(''); + // v0.4.1: 新增传输方式选择(stdio / streamable-http)与 URL 字段 + const [newTransport, setNewTransport] = useState<'stdio' | 'streamable-http'>('stdio'); + const [newUrl, setNewUrl] = useState(''); + // L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性 + const [confirmRemove, setConfirmRemove] = useState(null); + + // L-20 修复: loadServers 改为多行 async/await 写法,提升可读性 + const loadServers = useCallback(async () => { + if (!window.metona?.mcp?.listServers) return; + try { + const list = await window.metona.mcp.listServers(); + setServers(list as MetonaMCPServerStatus[]); + } catch (err) { + console.error('[MCPSettings]', err); + } + }, []); + // 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState + useEffect(() => { + let cancelled = false; + (async () => { + if (!window.metona?.mcp?.listServers) return; + try { + const list = await window.metona.mcp.listServers(); + if (!cancelled) setServers(list as MetonaMCPServerStatus[]); + } catch (err) { + if (!cancelled) console.error('[MCPSettings]', err); + } + })(); + return () => { + cancelled = true; + }; + }, []); + const handleAdd = async () => { + // v0.4.1: 按传输方式校验必填字段(stdio→命令,streamable-http→URL) + if (!newName.trim()) return; + if (newTransport === 'stdio' && !newCommand.trim()) return; + if (newTransport === 'streamable-http' && !newUrl.trim()) return; + try { + const config = + newTransport === 'stdio' + ? { + name: newName.trim(), + transport: 'stdio' as const, + command: newCommand.trim(), + args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], + enabled: true, + } + : { + name: newName.trim(), + transport: 'streamable-http' as const, + url: newUrl.trim(), + enabled: true, + }; + const r = await window.metona?.mcp?.addServer(config); + if (r?.success) { + setNewName(''); + setNewCommand(''); + setNewArgs(''); + setNewUrl(''); + setNewTransport('stdio'); + setShowAdd(false); + loadServers(); + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')) + .catch(() => {}); + } + } catch (err) { + console.error('[MCPSettings]', err); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`)) + .catch(() => {}); + } + }; + const statusColors: Record = { + connected: 'success.main', + connecting: 'warning.main', + disconnected: 'text.secondary', + error: 'error.main', + }; + + // L-11 修复: 确认移除 MCP 服务 + // 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭 + const [removeError, setRemoveError] = useState(null); + const handleConfirmRemove = async () => { + if (!confirmRemove) return; + try { + setRemoveError(null); + const r = await window.metona?.mcp?.removeServer(confirmRemove); + if (r?.success) { + setConfirmRemove(null); + loadServers(); + } else { + setRemoveError(r?.error ?? '移除失败'); + } + } catch (err) { + setRemoveError((err as Error).message ?? '移除失败'); + } + }; + + return ( + + + MCP 服务 + + {servers.length === 0 ? ( + + 暂无 MCP 服务 + + ) : ( + servers.map((s) => ( + + + + + {s.name} + + + {s.status} + + {s.toolCount > 0 && ( + + {s.toolCount} 工具 + + )} + + + + {/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */} + + + + )) + )} + + {/* L-11 修复: MUI Dialog 替代原生 confirm() */} + { + setConfirmRemove(null); + setRemoveError(null); + }} + maxWidth="xs" + fullWidth + > + 确认移除 + + + 确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。 + + {removeError && ( + + 移除失败:{removeError} + + )} + + + + + + + {showAdd ? ( + + setNewName(e.target.value)} + placeholder="服务名称" + /> + {/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */} + + + + + {newTransport === 'stdio' ? ( + <> + setNewCommand(e.target.value)} + placeholder="命令路径(如 npx / node / python)" + /> + setNewArgs(e.target.value)} + placeholder="参数 (空格分隔)" + /> + + ) : ( + setNewUrl(e.target.value)} + placeholder="Streamable HTTP URL(如 https://example.com/mcp)" + /> + )} + + + + + + ) : ( + + )} + + ); +} diff --git a/src/components/settings/SearXNGSettings.tsx b/src/components/settings/SearXNGSettings.tsx new file mode 100644 index 0000000..f91e526 --- /dev/null +++ b/src/components/settings/SearXNGSettings.tsx @@ -0,0 +1,262 @@ +/** + * SearXNGSettings — SearXNG 元搜索配置 Tab + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 + * 功能:SearXNG 实例配置(12 项)+ 连接测试。 + */ + +import { useState } from 'react'; +import { + Button, + TextField, + Select, + MenuItem, + Stack, + Typography, + Divider, + Chip, + Switch, + Alert, + Box, + FormControlLabel, + InputLabel, + FormControl, + IconButton, + CircularProgress, +} from '@mui/material'; +import { Eye, EyeOff } from 'lucide-react'; +import { useConfig } from './useConfig'; + +export function SearXNGSettings() { + // ===== 12 项配置(useConfig 实时持久化) ===== + const [enabled, setEnabled] = useConfig('searxng.enabled', false); + const [url, setUrl] = useConfig('searxng.url', ''); + const [engines, setEngines] = useConfig('searxng.engines', ''); + const [language, setLanguage] = useConfig('searxng.language', 'zh-CN'); + const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1); + const [timeRange, setTimeRange] = useConfig('searxng.time_range', ''); + const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0); + const [authKey, setAuthKey] = useConfig('searxng.auth_key', ''); + const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer'); + const [format, setFormat] = useConfig('searxng.format', 'json'); + const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0); + const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential'); + + const [showKey, setShowKey] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + const urlError = !!url && !/^https?:\/\//.test(url); + + const handleTest = async () => { + if (!url.trim() || urlError) return; + setTesting(true); + setTestResult(null); + try { + const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType); + if (result?.success) { + setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms)` }); + } else { + setTestResult({ + success: false, + message: result?.error || `连接失败(HTTP ${result?.statusCode})`, + }); + } + } catch (e) { + setTestResult({ success: false, message: (e as Error).message }); + } + setTesting(false); + }; + + return ( + + {/* 标题 + 状态徽章 */} + + + SearXNG 元搜索 + + + + + SearXNG 是开源元搜索引擎,支持 70+ + 搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。 + + + {/* 启用开关 */} + setEnabled(e.target.checked)} size="small" /> + } + label={启用 SearXNG} + /> + + + + {/* API 地址 + 连接测试 */} + + setUrl(e.target.value)} + placeholder="如 https://searxng.example.com" + error={urlError} + helperText={ + urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)' + } + sx={{ flex: 1 }} + /> + + + + {/* 测试结果 */} + {testResult && ( + + {testResult.message} + + )} + + {/* 搜索引擎 */} + setEngines(e.target.value)} + placeholder="如 google,bing,duckduckgo(留空使用实例默认)" + /> + + {/* 语言 + 安全搜索 */} + + + 语言 + + + + 安全搜索 + + + + + {/* 时间范围 + 返回格式 */} + + + 时间范围 + + + + 返回格式 + + + + + {/* 最大结果数 + 自动抓取条数 */} + + setMaxResults(Number(e.target.value))} + placeholder="0 表示使用默认" + slotProps={{ htmlInput: { min: 0, max: 50 } }} + /> + setFetchCount(Number(e.target.value))} + placeholder="0 表示由 AI 决定" + slotProps={{ htmlInput: { min: 0, max: 8 } }} + /> + + + {/* 抓取类型 */} + + 抓取类型 + + + + + + {/* 认证设置 */} + + 认证设置 + + + + 认证类型 + + + setAuthKey(e.target.value)} + placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'} + slotProps={{ + input: { + endAdornment: ( + setShowKey(!showKey)}> + {showKey ? : } + + ), + }, + }} + /> + + + {authType === 'bearer' + ? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。' + : 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'} + + + ); +} diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 6adeb1d..200b6ac 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,24 +1,55 @@ /** - * SettingsModal — 设置弹窗 + * SettingsModal — 设置弹窗(主框架) + * + * v0.4.1 拆分: 各 Tab 内容组件已迁移至独立文件(settings/ 目录), + * 本文件仅负责弹窗布局、垂直 Tab 导航与各 Tab 的挂载/隔离(ErrorBoundary)。 + * + * @see ./useConfig.ts — 共享配置读写 Hook + * @see ./WorkspaceSettings.tsx 等 — 各 Tab 组件 */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material'; -import { alpha } from '@mui/material/styles'; -import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe, Folder, Copy } from 'lucide-react'; -import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store'; -import { useAgentStore } from '@renderer/stores/agent-store'; -import { useSessionStore } from '@renderer/stores/session-store'; -import { PROVIDER_LABELS } from '@renderer/lib/constants'; +import { useState } from 'react'; +import { Dialog, Tabs, Tab, Box, Typography, Stack, IconButton } from '@mui/material'; +import { + X, + Settings, + Bot, + Wrench, + Server, + Palette, + FileText, + FolderOpen, + Globe, +} from 'lucide-react'; +import { useUIStore } from '@renderer/stores/ui-store'; import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary'; +import { WorkspaceSettings } from './WorkspaceSettings'; +import { LLMSettings } from './LLMSettings'; +import { AgentSettings } from './AgentSettings'; +import { ToolsSettings } from './ToolsSettings'; +import { MCPSettings } from './MCPSettings'; +import { SearXNGSettings } from './SearXNGSettings'; +import { AppearanceSettings } from './AppearanceSettings'; +import { LogsSettings } from './LogsSettings'; -type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | 'appearance' | 'logs'; +type SettingsTab = + | 'workspace' + | 'llm' + | 'agent' + | 'tools' + | 'mcp' + | 'searxng' + | 'appearance' + | 'logs'; const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [ { id: 'workspace', label: '工作空间', icon: FolderOpen }, - { id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings }, - { id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server }, + { id: 'llm', label: 'LLM 配置', icon: Bot }, + { id: 'agent', label: 'Agent 配置', icon: Settings }, + { id: 'tools', label: '工具管理', icon: Wrench }, + { id: 'mcp', label: 'MCP 服务', icon: Server }, { id: 'searxng', label: 'SearXNG', icon: Globe }, - { id: 'appearance', label: '外观', icon: Palette }, { id: 'logs', label: '日志与数据', icon: FileText }, + { id: 'appearance', label: '外观', icon: Palette }, + { id: 'logs', label: '日志与数据', icon: FileText }, ]; export function SettingsModal(): React.JSX.Element | null { @@ -30,24 +61,69 @@ export function SettingsModal(): React.JSX.Element | null { if (!settingsOpen) return null; return ( - - + + 设置 - + + + - setTab(v)} + setTab(v)} sx={{ - borderRight: 1, borderColor: 'divider', minWidth: 130, + borderRight: 1, + borderColor: 'divider', + minWidth: 130, '& .MuiTab-root': { - alignItems: 'center', fontSize: 12, - textTransform: 'none', gap: '2px', px: 2, justifyContent: 'flex-start', + alignItems: 'center', + fontSize: 12, + textTransform: 'none', + gap: '2px', + px: 2, + justifyContent: 'flex-start', minHeight: 40, }, '& .MuiTab-iconWrapper': { display: 'flex', alignItems: 'center', marginRight: 0 }, }} > - {TABS.map((t) => } iconPosition="start" />)} + {TABS.map((t) => ( + } + iconPosition="start" + /> + ))} @@ -79,1425 +155,3 @@ export function SettingsModal(): React.JSX.Element | null { ); } - -function useConfig(key: string, defaultValue: T): [T, (v: T) => void] { - const [value, setValue] = useState(defaultValue); - const valueRef = useRef(value); - valueRef.current = value; - // v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致 - // seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值 - const seqRef = useRef(0); - useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]); - const set = useCallback((v: T) => { - const seq = ++seqRef.current; - const prev = valueRef.current; - setValue(v); - window.metona?.config?.set(key, v).then((r: { success?: boolean; error?: string } | undefined) => { - if (r && !r.success) { - // 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改 - if (seqRef.current === seq) setValue(prev); - import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {}); - } - }).catch((err: unknown) => { - console.error('[SettingsModal]', err); - if (seqRef.current === seq) setValue(prev); - import('@metona-team/metona-toast').then((mod) => mod.default.error('配置保存失败')).catch(() => {}); - }); - }, [key]); - return [value, set]; -} - -const PROVIDER_URLS: Record = { deepseek: 'https://api.deepseek.com', agnes: 'https://apihub.agnes-ai.com/v1', mimo: 'https://api.xiaomimimo.com/v1', ollama: 'http://localhost:11434', openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com' }; - -function WorkspaceSettings() { - const [workspacePath, setWorkspacePath] = useConfig('workspace.path', ''); - // 切换工作空间的中间状态 - const [pendingPath, setPendingPath] = useState(null); - const [checkResult, setCheckResult] = useState(null); - const [checking, setChecking] = useState(false); - const [inheritSoul, setInheritSoul] = useState(true); - // 数据库继承:默认勾选(历史会话/消息/记忆/Trace 丢失不可逆,默认带过去更安全) - const [inheritDatabase, setInheritDatabase] = useState(true); - const [applying, setApplying] = useState(false); - const [showRestartDialog, setShowRestartDialog] = useState(false); - - const currentPath = workspacePath; - - const handleSelect = async () => { - if (!window.metona?.app?.selectFolder) return; - const r = await window.metona.app.selectFolder(currentPath || undefined); - if (!r.canceled && r.path) { - // 立即校验新路径 - setPendingPath(r.path); - setChecking(true); - setCheckResult(null); - try { - const result = await window.metona.workspace.check(r.path); - setCheckResult(result); - } catch (err) { - console.error('[SettingsModal]', err); - setCheckResult({ valid: false, reason: (err as Error).message }); - } finally { - setChecking(false); - } - } - }; - - const handleApply = async () => { - if (!pendingPath || !checkResult?.valid) return; - setApplying(true); - try { - // 如果勾选了继承文件,从旧工作空间复制到新工作空间 - // - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义) - // - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据) - const filesToInherit: string[] = []; - if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md')) filesToInherit.push('SOUL.md'); - if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db'); - - // #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失 - if (inheritDatabase && currentPath) { - try { - const integrityResult = await window.metona?.workspace?.checkDatabaseIntegrity(currentPath); - if (!integrityResult?.success) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {}); - setApplying(false); - return; - } - if (!integrityResult.ok) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {}); - setApplying(false); - return; - } - } catch (err) { - console.error('[SettingsModal] Database integrity check failed:', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {}); - setApplying(false); - return; - } - } - - if (filesToInherit.length > 0 && currentPath) { - try { - await window.metona.workspace.inheritFiles({ - targetPath: pendingPath, - sourcePath: currentPath, - files: filesToInherit, - }); - } catch (err) { - // 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险 - console.error('[SettingsModal] Inherit failed:', err); - import('@metona-team/metona-toast').then((mod) => mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`)).catch(() => {}); - } - } - - // 保存新路径到配置 - setWorkspacePath(pendingPath); - // 弹出重启确认对话框 - setShowRestartDialog(true); - // 清理中间状态 - setPendingPath(null); - setCheckResult(null); - } catch (err) { - console.error('[SettingsModal]', err); - // 用户主动操作(切换工作空间)失败必须有反馈 - import('@metona-team/metona-toast').then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)).catch(() => {}); - } finally { - setApplying(false); - } - }; - - const handleCancel = () => { - setPendingPath(null); - setCheckResult(null); - }; - - const handleOpen = async () => { - if (!workspacePath) return; - try { - const r = await window.metona?.app?.showItemInFolder(workspacePath); - if (r && !r.success) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '打开文件夹失败')).catch(() => {}); - } - } catch (err) { - console.error('[SettingsModal]', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {}); - } - }; - - return ( - - 工作空间 - 工作空间是 Metona 的组织核心,包含 SOUL.md、MEMORY.md 两个必需文件。 - - setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} /> - - - {workspacePath && ( - - )} - - {/* 校验中状态 */} - {checking && ( - - - 正在校验工作空间... - - )} - - {/* 校验失败 */} - {pendingPath && checkResult && !checkResult.valid && ( - - 路径无效:{checkResult.reason} - - - - - )} - - {/* 校验成功 — 显示工作空间状态 + 继承选项 */} - {pendingPath && checkResult?.valid && ( - - - 目标工作空间:{checkResult.path} - - - {checkResult.isNewWorkspace ? ( - - 新工作空间 — 切换后将自动创建 2 个必需文件(SOUL.md、MEMORY.md) - - ) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? ( - - 已有目录但缺少 {checkResult.missingFiles.length} 个文件:{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。 - - ) : ( - - 已有工作空间 — 2 个必需文件均已就绪,将直接加载现有配置。 - - )} - - {/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */} - {currentPath && currentPath !== pendingPath && - (checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && ( - - - 从当前工作空间继承: - - {/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */} - {checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && ( - setInheritSoul(e.target.checked)} />} - label={SOUL.md(身份与角色定义)} - /> - )} - {/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */} - {!checkResult.dbExists && ( - <> - setInheritDatabase(e.target.checked)} />} - label={数据库 agent.db(会话/消息/记忆/Trace 历史记录)} - /> - - 勾选后将复制当前工作空间的全部历史数据到新工作空间(通过 SQLite backup API 原子性导出) - - - )} - - MEMORY.md 不继承(记忆与工作空间项目上下文绑定) - - - )} - - - - - - - )} - - 修改工作空间路径后需重启应用生效。 - - {/* 重启确认对话框 */} - setShowRestartDialog(false)} maxWidth="xs" fullWidth> - 工作空间已切换 - - - 工作空间已更新为: - - - {workspacePath} - - - 需要重启应用以加载新工作空间的配置和文件。是否立即重启? - - - - - - - - - ); -} - -function LLMSettings() { - // 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致: - // 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突) - // 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入 - // 3. 错误提示笼统(不指向具体字段) - const [provider, setProvider] = useState(''); - const [model, setModel] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [baseURL, setBaseURL] = useState(''); - const [numCtx, setNumCtx] = useState(null); - // v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死) - const [dsCtxWindow, setDsCtxWindow] = useState(1000000); - const [agnesCtxWindow, setAgnesCtxWindow] = useState(1000000); - const [mimoCtxWindow, setMimoCtxWindow] = useState(1000000); - // P3: OpenAI/Anthropic contextWindow - const [oaCtxWindow, setOaCtxWindow] = useState(128000); - const [anthropicCtxWindow, setAnthropicCtxWindow] = useState(200000); - // P1: 故障转移 Provider 配置 - const [fbProvider, setFbProvider] = useState(''); - const [fbModel, setFbModel] = useState(''); - const [fbApiKey, setFbApiKey] = useState(''); - const [fbBaseURL, setFbBaseURL] = useState(''); - const [showKey, setShowKey] = useState(false); - const [showFbKey, setShowFbKey] = useState(false); - const [loaded, setLoaded] = useState(false); - const [saving, setSaving] = useState(false); - - // 初始化:一次性加载所有 LLM 配置字段 - useEffect(() => { - let cancelled = false; - const load = async () => { - if (!window.metona?.config?.get) { - setLoaded(true); - return; - } - try { - const results = await Promise.all([ - window.metona.config.get('llm.provider'), - window.metona.config.get('llm.model'), - window.metona.config.get('llm.apiKey'), - window.metona.config.get('llm.baseURL'), - window.metona.config.get('ollama.numCtx'), - window.metona.config.get('deepseek.contextWindow'), - window.metona.config.get('agnes.contextWindow'), - window.metona.config.get('mimo.contextWindow'), - window.metona.config.get('openai.contextWindow'), - window.metona.config.get('anthropic.contextWindow'), - // P1: 故障转移配置 - window.metona.config.get('llm.fallbackProvider'), - window.metona.config.get('llm.fallbackModel'), - window.metona.config.get('llm.fallbackApiKey'), - window.metona.config.get('llm.fallbackBaseURL'), - ]); - if (cancelled) return; - const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = results; - setProvider((p as string) ?? ''); - setModel((m as string) ?? ''); - setApiKey((k as string) ?? ''); - setBaseURL((u as string) ?? ''); - setNumCtx((nc as number | null) ?? null); - if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds); - if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag); - if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi); - if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa); - if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an); - setFbProvider((fbp as string) ?? ''); - setFbModel((fbm as string) ?? ''); - setFbApiKey((fbk as string) ?? ''); - setFbBaseURL((fbu as string) ?? ''); - } catch (err) { - console.error('[SettingsModal]', err); - } finally { - if (!cancelled) setLoaded(true); - } - }; - load(); - return () => { cancelled = true; }; - }, []); - - // 同步 Provider/Model 到 Agent Store(含 contextWindow) - // 注意:仅同步运行时状态,不落库 - useEffect(() => { - useAgentStore.getState().setProvider(provider, model); - }, [provider, model]); - - // v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider) - useEffect(() => { - if (provider === 'ollama') { - if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx }); - } else if (provider === 'deepseek') { - if (dsCtxWindow != null && dsCtxWindow > 0) useAgentStore.setState({ contextWindow: dsCtxWindow }); - } else if (provider === 'agnes') { - if (agnesCtxWindow != null && agnesCtxWindow > 0) useAgentStore.setState({ contextWindow: agnesCtxWindow }); - } else if (provider === 'mimo') { - if (mimoCtxWindow != null && mimoCtxWindow > 0) useAgentStore.setState({ contextWindow: mimoCtxWindow }); - } else if (provider === 'openai') { - if (oaCtxWindow != null && oaCtxWindow > 0) useAgentStore.setState({ contextWindow: oaCtxWindow }); - } else if (provider === 'anthropic') { - if (anthropicCtxWindow != null && anthropicCtxWindow > 0) useAgentStore.setState({ contextWindow: anthropicCtxWindow }); - } - }, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow, oaCtxWindow, anthropicCtxWindow]); - - // ===== 字段级 inline 校验 ===== - // Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL) - const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL); - // Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数) - const modelHasSpace = !!model && /\s/.test(model); - // contextWindow / numCtx:必须为有限正数且不低于最小值 - const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512); - const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096; - const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096; - const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096; - const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096; - const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096; - - // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) - const hasBlockingError = urlError || modelHasSpace || numCtxError || - (provider === 'deepseek' && dsCtxError) || - (provider === 'agnes' && agnesCtxError) || - (provider === 'mimo' && mimoCtxError) || - (provider === 'openai' && oaCtxError) || - (provider === 'anthropic' && anthropicCtxError); - - // 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL - // 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found - const handleProviderChange = (newProvider: string) => { - const oldProvider = provider; - setProvider(newProvider); - // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同) - if (oldProvider !== newProvider && apiKey) { - setApiKey(''); - } - // 切换 Provider 时清空 model(不同 Provider 支持的模型名不同,如 deepseek-v4-pro 不适用于 ollama) - if (oldProvider !== newProvider && model) { - setModel(''); - } - // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时覆盖) - const currentUrl = baseURL.trim(); - const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl); - if (isDefaultUrl || !currentUrl) { - setBaseURL(PROVIDER_URLS[newProvider] ?? ''); - } - }; - - const handleSave = async () => { - if (hasBlockingError) { - import('@metona-team/metona-toast').then((mod) => mod.default.error('请修正表单中的错误后再保存')).catch(() => {}); - return; - } - setSaving(true); - try { - const setBatch = window.metona?.config?.setBatch; - if (!setBatch) { - import('@metona-team/metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {}); - return; - } - // v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败 - const entries: Array<{ key: string; value: unknown }> = [ - { key: 'llm.provider', value: provider }, - { key: 'llm.model', value: model }, - { key: 'llm.apiKey', value: apiKey }, - { key: 'llm.baseURL', value: baseURL }, - { key: 'ollama.numCtx', value: numCtx }, - { key: 'deepseek.contextWindow', value: dsCtxWindow }, - { key: 'agnes.contextWindow', value: agnesCtxWindow }, - { key: 'mimo.contextWindow', value: mimoCtxWindow }, - { key: 'openai.contextWindow', value: oaCtxWindow }, - { key: 'anthropic.contextWindow', value: anthropicCtxWindow }, - // P1: 故障转移 Provider(主 Provider 失败时切换) - { key: 'llm.fallbackProvider', value: fbProvider }, - { key: 'llm.fallbackModel', value: fbModel }, - { key: 'llm.fallbackApiKey', value: fbApiKey }, - { key: 'llm.fallbackBaseURL', value: fbBaseURL }, - ]; - const r = await setBatch(entries); - if (r && !r.success) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {}); - } else { - import('@metona-team/metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {}); - } - } catch (err) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)).catch(() => {}); - } finally { - setSaving(false); - } - }; - - if (!loaded) { - return ( - - LLM 配置 - 加载中... - - ); - } - - const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim(); - - return ( - - LLM 配置 - Provider - - - setBaseURL(e.target.value)} - placeholder="如 https://api.deepseek.com" - error={urlError} - helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '} - /> - setModel(e.target.value)} - placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5" - error={modelHasSpace} - helperText={modelHasSpace ? '模型名称不能包含空格' : ' '} - /> - {provider !== 'ollama' && ( - <> - setApiKey(e.target.value)} - placeholder="sk-..." - error={apiKeyEmpty} - helperText={apiKeyEmpty ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` : ' '} - slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} - /> - - )} - {provider === 'ollama' && ( - { - const v = e.target.value; - setNumCtx(v === '' ? null : Number(v)); - }} - placeholder="默认由模型决定(如 2048、4096、128000)" - slotProps={{ htmlInput: { min: 512, step: 512 } }} - error={numCtxError} - helperText={numCtxError ? '最小值为 512' : ' '} - /> - )} - {/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} - {provider === 'deepseek' && ( - setDsCtxWindow(Number(e.target.value) || 1000000)} - placeholder="如 64000、128000、1000000" - slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - error={dsCtxError} - helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} - /> - )} - {provider === 'agnes' && ( - setAgnesCtxWindow(Number(e.target.value) || 1000000)} - placeholder="如 64000、128000、1000000" - slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - error={agnesCtxError} - helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} - /> - )} - {provider === 'mimo' && ( - setMimoCtxWindow(Number(e.target.value) || 1000000)} - placeholder="如 65536、131072、1000000" - slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - error={mimoCtxError} - helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'} - /> - )} - {provider === 'openai' && ( - setOaCtxWindow(Number(e.target.value) || 128000)} - placeholder="如 128000、200000、1000000" - slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - error={oaCtxError} - helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'} - /> - )} - {provider === 'anthropic' && ( - setAnthropicCtxWindow(Number(e.target.value) || 200000)} - placeholder="如 200000" - slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - error={anthropicCtxError} - helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'} - /> - )} - - {/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */} - - 故障转移(可选) - - 主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。 - - 备用 Provider - - - {fbProvider && ( - <> - setFbBaseURL(e.target.value)} - placeholder="如 https://api.deepseek.com" - /> - setFbModel(e.target.value)} - placeholder="如 deepseek-v4-flash" - /> - {fbProvider !== 'ollama' && ( - setFbApiKey(e.target.value)} - placeholder="sk-..." - slotProps={{ input: { endAdornment: setShowFbKey(!showFbKey)}>{showFbKey ? : } } }} - /> - )} - - )} - - {/* Save 按钮:批量提交,取消 onChange 实时落库 */} - - - {hasBlockingError && ( - - 请修正表单错误后再保存 - - )} - - - ); -} - -function AgentSettings() { - const [maxIter, setMaxIter] = useConfig('agent.maxIterations', 20); - const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000); - const [thinking, setThinking] = useConfig('agent.enableThinking', true); - const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high'); - const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000); - const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000); - - const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512]; - - return ( - - Agent 配置 - - 最大迭代次数 - - - { - const v = Number(e.target.value); - if (v >= 120 && v <= 3600) setTimeout_(v * 1000); - }} - slotProps={{ htmlInput: { min: 120, max: 3600, step: 30 } }} - /> - { - const v = Number(e.target.value); - if (v >= 10 && v <= 600) setToolExecTimeout(v * 1000); - }} - slotProps={{ htmlInput: { min: 10, max: 600, step: 10 } }} - helperText="单个工具执行的最大时长,超时自动终止(10~600 秒)" - /> - { - const v = Number(e.target.value); - if (v >= 30 && v <= 600) setConfirmTimeout(v * 1000); - }} - slotProps={{ htmlInput: { min: 30, max: 600, step: 10 } }} - helperText="用户未响应工具确认时,超时自动视为拒绝(30~600 秒)" - /> - setThinking(e.target.checked)} size="small" />} label={启用思考模式} /> - {thinking && ( - 思考强度 - - - )} - - ); -} - -function ToolsSettings() { - const [tools, setTools] = useState>([]); - const [autoExecList, setAutoExecList] = useState([]); - - const loadTools = () => { - if (window.metona?.tools?.list) { - window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ - name: t.name, description: t.description, riskLevel: t.riskLevel, - requiresPermission: t.requiresPermission, enabled: t.enabled, - })))).catch((err) => { console.error('[SettingsModal]', err); }); - } - }; - const loadAutoExec = () => { - if (window.metona?.tool?.getAutoExecuteList) { - window.metona.tool.getAutoExecuteList().then((r) => { - if (r.success) setAutoExecList(r.data); - }).catch((err) => { console.error('[SettingsModal]', err); }); - } - }; - useEffect(() => { loadTools(); loadAutoExec(); }, []); - - const handleToggle = async (name: string, enabled: boolean) => { - // v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致 - // 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚, - // 否则会覆盖 await 期间用户对其他工具的并发修改 - setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); - try { - const r = await window.metona?.tools?.toggle(name, enabled); - if (r && !r.success) { - setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t)); - import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '切换工具失败')).catch(() => {}); - } - } catch (err) { - console.error('[SettingsModal]', err); - setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t)); - import('@metona-team/metona-toast').then((mod) => mod.default.error('切换工具失败')).catch(() => {}); - } - }; - - // 设置/取消自动执行 - const handleSetAutoExec = async (name: string, enabled: boolean) => { - if (!window.metona?.tool?.setAutoExecute) return; - try { - const r = await window.metona.tool.setAutoExecute(name, enabled); - if (r.success) { - setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name)); - } else { - import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '设置自动执行失败')).catch(() => {}); - } - } catch (err) { - console.error('[SettingsModal]', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error('设置自动执行失败')).catch(() => {}); - } - }; - - // v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color - const riskColors: Record = { safe: 'success', low: 'info', medium: 'warning', high: 'error', critical: 'error' }; - - // 需要确认的工具(high/critical 或 requiresPermission) - const needsConfirmTools = tools.filter((t) => - t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission, - ); - // 需要确认但未设为自动执行的工具 - const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name)); - // 已设为自动执行的工具详情 - const autoExecToolDetails = autoExecList - .map((name) => tools.find((t) => t.name === name)) - .filter((t): t is NonNullable => t !== undefined); - - return ( - - 工具管理 - {tools.length === 0 ? 加载中... : tools.map((t) => ( - - - {t.name} - {t.description.slice(0, 40)} - - - - handleToggle(t.name, e.target.checked)} size="small" /> - - - ))} - - - - {/* ===== 自动执行工具管理 ===== */} - - 自动执行工具 - 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} /> - - - 已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。 - - - {/* 已自动执行的工具列表 */} - {autoExecToolDetails.length === 0 ? ( - - 暂无自动执行工具 - - ) : ( - autoExecToolDetails.map((t) => ( - ({ - py: 1, px: 1.5, borderRadius: 1.5, - // 用主题 success 色的 12% 透明度做底,文字和按钮保持不透明 - bgcolor: alpha(theme.palette.success.main, 0.12), - // 左侧绿色状态条,强化"已自动执行"视觉 - boxShadow: `inset 3px 0 0 ${theme.palette.success.main}`, - justifyContent: 'space-between', alignItems: 'center', - })} - > - - - - {t.name} - - - - - - )) - )} - - {/* 可设为自动执行的工具(需要确认但未设置) */} - {pendingConfirmTools.length > 0 && ( - <> - - 可设为自动执行(当前需要确认) - - {pendingConfirmTools.map((t) => ( - - - {t.name} - - - - - ))} - - )} - - ); -} - -function MCPSettings() { - const [servers, setServers] = useState>([]); - const [showAdd, setShowAdd] = useState(false); - const [newName, setNewName] = useState(''); - const [newCommand, setNewCommand] = useState(''); - const [newArgs, setNewArgs] = useState(''); - // L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性 - const [confirmRemove, setConfirmRemove] = useState(null); - - // L-20 修复: loadServers 改为多行 async/await 写法,提升可读性 - const loadServers = useCallback(async () => { - if (!window.metona?.mcp?.listServers) return; - try { - const list = await window.metona.mcp.listServers(); - setServers(list as MetonaMCPServerStatus[]); - } catch (err) { - console.error('[SettingsModal]', err); - } - }, []); - // 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState - useEffect(() => { - let cancelled = false; - (async () => { - if (!window.metona?.mcp?.listServers) return; - try { - const list = await window.metona.mcp.listServers(); - if (!cancelled) setServers(list as MetonaMCPServerStatus[]); - } catch (err) { - if (!cancelled) console.error('[SettingsModal]', err); - } - })(); - return () => { cancelled = true; }; - }, []); - const handleAdd = async () => { - if (!newName.trim() || !newCommand.trim()) return; - try { - const r = await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); - if (r?.success) { - setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); - } else { - import('@metona-team/metona-toast').then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')).catch(() => {}); - } - } catch (err) { - console.error('[SettingsModal]', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`)).catch(() => {}); - } - }; - const statusColors: Record = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' }; - - // L-11 修复: 确认移除 MCP 服务 - // 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭 - const [removeError, setRemoveError] = useState(null); - const handleConfirmRemove = async () => { - if (!confirmRemove) return; - try { - setRemoveError(null); - const r = await window.metona?.mcp?.removeServer(confirmRemove); - if (r?.success) { - setConfirmRemove(null); - loadServers(); - } else { - setRemoveError(r?.error ?? '移除失败'); - } - } catch (err) { - setRemoveError((err as Error).message ?? '移除失败'); - } - }; - - return ( - - MCP 服务 - {servers.length === 0 ? 暂无 MCP 服务 : servers.map((s) => ( - - - - {s.name} - {s.status} - {s.toolCount > 0 && {s.toolCount} 工具} - - - - {/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */} - - - - ))} - - {/* L-11 修复: MUI Dialog 替代原生 confirm() */} - { setConfirmRemove(null); setRemoveError(null); }} - maxWidth="xs" - fullWidth - > - 确认移除 - - - 确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。 - - {removeError && ( - 移除失败:{removeError} - )} - - - - - - - {showAdd ? ( - - setNewName(e.target.value)} placeholder="服务名称" /> - setNewCommand(e.target.value)} placeholder="命令路径" /> - setNewArgs(e.target.value)} placeholder="参数 (空格分隔)" /> - - - - - - ) : } - - ); -} - -function SearXNGSettings() { - // ===== 12 项配置(useConfig 实时持久化) ===== - const [enabled, setEnabled] = useConfig('searxng.enabled', false); - const [url, setUrl] = useConfig('searxng.url', ''); - const [engines, setEngines] = useConfig('searxng.engines', ''); - const [language, setLanguage] = useConfig('searxng.language', 'zh-CN'); - const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1); - const [timeRange, setTimeRange] = useConfig('searxng.time_range', ''); - const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0); - const [authKey, setAuthKey] = useConfig('searxng.auth_key', ''); - const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer'); - const [format, setFormat] = useConfig('searxng.format', 'json'); - const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0); - const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential'); - - const [showKey, setShowKey] = useState(false); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); - - const urlError = !!url && !/^https?:\/\//.test(url); - - const handleTest = async () => { - if (!url.trim() || urlError) return; - setTesting(true); - setTestResult(null); - try { - const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType); - if (result?.success) { - setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms)` }); - } else { - setTestResult({ success: false, message: result?.error || `连接失败(HTTP ${result?.statusCode})` }); - } - } catch (e) { - setTestResult({ success: false, message: (e as Error).message }); - } - setTesting(false); - }; - - return ( - - {/* 标题 + 状态徽章 */} - - SearXNG 元搜索 - - - - SearXNG 是开源元搜索引擎,支持 70+ 搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。 - - - {/* 启用开关 */} - setEnabled(e.target.checked)} size="small" />} label={启用 SearXNG} /> - - - - {/* API 地址 + 连接测试 */} - - setUrl(e.target.value)} - placeholder="如 https://searxng.example.com" - error={urlError} - helperText={urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'} - sx={{ flex: 1 }} - /> - - - - {/* 测试结果 */} - {testResult && ( - - {testResult.message} - - )} - - {/* 搜索引擎 */} - setEngines(e.target.value)} - placeholder="如 google,bing,duckduckgo(留空使用实例默认)" - /> - - {/* 语言 + 安全搜索 */} - - 语言 - - - 安全搜索 - - - - - {/* 时间范围 + 返回格式 */} - - 时间范围 - - - 返回格式 - - - - - {/* 最大结果数 + 自动抓取条数 */} - - setMaxResults(Number(e.target.value))} - placeholder="0 表示使用默认" - slotProps={{ htmlInput: { min: 0, max: 50 } }} - /> - setFetchCount(Number(e.target.value))} - placeholder="0 表示由 AI 决定" - slotProps={{ htmlInput: { min: 0, max: 8 } }} - /> - - - {/* 抓取类型 */} - 抓取类型 - - - - - - {/* 认证设置 */} - 认证设置 - - 认证类型 - - - setAuthKey(e.target.value)} - placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'} - slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} - /> - - - {authType === 'bearer' - ? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。' - : 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'} - - - ); -} - -function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) { - return ( - - 外观 - 主题 - - {(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => )} - - - - ); -} - -function LogsSettings() { - const [logLevel, setLogLevel] = useConfig('logging.level', 'info'); - const [clearing, setClearing] = useState(null); - // L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm(),保持 UI 一致性 - const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null); - // P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮 - // electron-log 默认写入路径为 ${userData}/logs/main.log - const [logFilePath, setLogFilePath] = useState(''); - const [logPathLoading, setLogPathLoading] = useState(true); - const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle'); - - // P3-13: 组件挂载时获取日志文件路径 - useEffect(() => { - let cancelled = false; - (async () => { - try { - const appData = await window.metona?.app?.getAppDataPath?.(); - if (cancelled) return; - if (appData) { - // electron-log 默认日志路径: ${userData}/logs/main.log - // 路径分隔符由系统决定,直接拼接避免引入 path 模块 - const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\'; - setLogFilePath(`${appData}${sep}logs${sep}main.log`); - } - } catch (e) { - // 获取失败不阻塞 UI - console.warn('[LogsSettings] Failed to get app data path:', e); - } finally { - if (!cancelled) setLogPathLoading(false); - } - })(); - return () => { cancelled = true; }; - }, []); - - const handleOpenLogFolder = async () => { - if (!logFilePath) return; - try { - const r = await window.metona?.app?.showItemInFolder?.(logFilePath); - if (r && !r.success) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)).catch(() => {}); - } - } catch (e) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)).catch(() => {}); - } - }; - - const handleCopyLogPath = async () => { - if (!logFilePath) return; - try { - await navigator.clipboard.writeText(logFilePath); - setCopyState('success'); - setTimeout(() => setCopyState('idle'), 1500); - } catch (e) { - setCopyState('error'); - setTimeout(() => setCopyState('idle'), 1500); - import('@metona-team/metona-toast').then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)).catch(() => {}); - } - }; - - const handleExport = async () => { - if (!window.metona?.data?.exportData) return; - try { - const r = await window.metona.data.exportData(); - if (r.success && r.data) { - const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); - } else { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)).catch(() => {}); - } - } catch (err) { - console.error('[LogsSettings]', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)).catch(() => {}); - } - }; - const labels: Record = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; - - // L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈 - const handleClearConfirm = async () => { - if (!confirmClear) return; - const type = confirmClear; - setClearing(type); - setConfirmClear(null); - try { - let r; - if (type === 'sessions') r = await window.metona?.data?.clearSessions(); - else if (type === 'memories') r = await window.metona?.data?.clearMemories(); - else r = await window.metona?.data?.clearAuditLogs(); - if (r?.success) { - import('@metona-team/metona-toast').then((mod) => mod.default.success(`${labels[type]}已清理`)).catch(() => {}); - // 修复: 清理会话后同步清空前端状态,无需重启应用 - if (type === 'sessions') { - useSessionStore.getState().setSessions([]); - useSessionStore.getState().setCurrentSession(null); - // 同时清空当前消息列表,防止聊天面板显示已删除的会话内容 - useAgentStore.getState().setMessages([]); - } - // v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果) - if (type === 'memories') { - useUIStore.getState().bumpMemoryVersion(); - } - } else { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)).catch(() => {}); - } - } catch (e) { - import('@metona-team/metona-toast').then((mod) => mod.default.error(`失败: ${(e as Error).message}`)).catch(() => {}); - } - setClearing(null); - }; - - return ( - - 日志与数据 - 日志级别 - - - - {/* P3-13: 日志文件路径展示与打开按钮 */} - - 日志文件 - - - - - - - 日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。 - - - - - 数据管理 - - - - - - {/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */} - setConfirmClear(null)} - maxWidth="xs" - fullWidth - > - 确认清理 - - - 确定清理{confirmClear ? labels[confirmClear] : ''}?此操作不可撤销。 - - - - - - - - - ); -} diff --git a/src/components/settings/ToolsSettings.tsx b/src/components/settings/ToolsSettings.tsx new file mode 100644 index 0000000..f710085 --- /dev/null +++ b/src/components/settings/ToolsSettings.tsx @@ -0,0 +1,311 @@ +/** + * ToolsSettings — 工具管理 Tab + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 + * 功能:工具启用/禁用开关、自动执行工具管理(乐观更新 + 失败回滚)。 + */ + +import { useState, useEffect } from 'react'; +import { Button, Stack, Typography, Checkbox, Divider, Chip, Box } from '@mui/material'; +import { alpha } from '@mui/material/styles'; + +export function ToolsSettings() { + const [tools, setTools] = useState< + Array<{ + name: string; + description: string; + riskLevel: string; + requiresPermission: boolean; + enabled: boolean; + }> + >([]); + const [autoExecList, setAutoExecList] = useState([]); + + const loadTools = () => { + if (window.metona?.tools?.list) { + window.metona.tools + .list() + .then((l) => + setTools( + (l as MetonaToolInfo[]).map((t) => ({ + name: t.name, + description: t.description, + riskLevel: t.riskLevel, + requiresPermission: t.requiresPermission, + enabled: t.enabled, + })), + ), + ) + .catch((err) => { + console.error('[ToolsSettings]', err); + }); + } + }; + const loadAutoExec = () => { + if (window.metona?.tool?.getAutoExecuteList) { + window.metona.tool + .getAutoExecuteList() + .then((r) => { + if (r.success) setAutoExecList(r.data); + }) + .catch((err) => { + console.error('[ToolsSettings]', err); + }); + } + }; + useEffect(() => { + loadTools(); + loadAutoExec(); + }, []); + + const handleToggle = async (name: string, enabled: boolean) => { + // v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致 + // 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚, + // 否则会覆盖 await 期间用户对其他工具的并发修改 + setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled } : t))); + try { + const r = await window.metona?.tools?.toggle(name, enabled); + if (r && !r.success) { + setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t))); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r.error ?? '切换工具失败')) + .catch(() => {}); + } + } catch (err) { + console.error('[ToolsSettings]', err); + setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t))); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('切换工具失败')) + .catch(() => {}); + } + }; + + // 设置/取消自动执行 + const handleSetAutoExec = async (name: string, enabled: boolean) => { + if (!window.metona?.tool?.setAutoExecute) return; + try { + const r = await window.metona.tool.setAutoExecute(name, enabled); + if (r.success) { + setAutoExecList((p) => (enabled ? [...p, name] : p.filter((n) => n !== name))); + } else { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r.error ?? '设置自动执行失败')) + .catch(() => {}); + } + } catch (err) { + console.error('[ToolsSettings]', err); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('设置自动执行失败')) + .catch(() => {}); + } + }; + + // v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color + const riskColors: Record = { + safe: 'success', + low: 'info', + medium: 'warning', + high: 'error', + critical: 'error', + }; + + // 需要确认的工具(high/critical 或 requiresPermission) + const needsConfirmTools = tools.filter( + (t) => t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission, + ); + // 需要确认但未设为自动执行的工具 + const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name)); + // 已设为自动执行的工具详情 + const autoExecToolDetails = autoExecList + .map((name) => tools.find((t) => t.name === name)) + .filter((t): t is NonNullable => t !== undefined); + + return ( + + + 工具管理 + + {tools.length === 0 ? ( + + 加载中... + + ) : ( + tools.map((t) => ( + + + + {t.name} + + + {t.description.slice(0, 40)} + + + + + handleToggle(t.name, e.target.checked)} + size="small" + /> + + + )) + )} + + + + {/* ===== 自动执行工具管理 ===== */} + + + 自动执行工具 + + 0 ? 'success' : 'default'} + variant="outlined" + sx={{ height: 18, fontSize: 10 }} + /> + + + 已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。 + + + {/* 已自动执行的工具列表 */} + {autoExecToolDetails.length === 0 ? ( + + 暂无自动执行工具 + + ) : ( + autoExecToolDetails.map((t) => ( + ({ + py: 1, + px: 1.5, + borderRadius: 1.5, + // 用主题 success 色的 12% 透明度做底,文字和按钮保持不透明 + bgcolor: alpha(theme.palette.success.main, 0.12), + // 左侧绿色状态条,强化"已自动执行"视觉 + boxShadow: `inset 3px 0 0 ${theme.palette.success.main}`, + justifyContent: 'space-between', + alignItems: 'center', + })} + > + + + + {t.name} + + + + + + )) + )} + + {/* 可设为自动执行的工具(需要确认但未设置) */} + {pendingConfirmTools.length > 0 && ( + <> + + 可设为自动执行(当前需要确认) + + {pendingConfirmTools.map((t) => ( + + + + {t.name} + + + + + + ))} + + )} + + ); +} diff --git a/src/components/settings/WorkspaceSettings.tsx b/src/components/settings/WorkspaceSettings.tsx new file mode 100644 index 0000000..a20a407 --- /dev/null +++ b/src/components/settings/WorkspaceSettings.tsx @@ -0,0 +1,365 @@ +/** + * WorkspaceSettings — 工作空间设置 Tab + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 + * 功能:工作空间路径选择/校验/切换、SOUL.md 与数据库继承、重启确认。 + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogActions, + Button, + TextField, + Stack, + Typography, + Checkbox, + Alert, + CircularProgress, + FormControlLabel, +} from '@mui/material'; +import { useConfig } from './useConfig'; + +export function WorkspaceSettings() { + const [workspacePath, setWorkspacePath] = useConfig('workspace.path', ''); + // 切换工作空间的中间状态 + const [pendingPath, setPendingPath] = useState(null); + const [checkResult, setCheckResult] = useState(null); + const [checking, setChecking] = useState(false); + const [inheritSoul, setInheritSoul] = useState(true); + // 数据库继承:默认勾选(历史会话/消息/记忆/Trace 丢失不可逆,默认带过去更安全) + const [inheritDatabase, setInheritDatabase] = useState(true); + const [applying, setApplying] = useState(false); + const [showRestartDialog, setShowRestartDialog] = useState(false); + + const currentPath = workspacePath; + + const handleSelect = async () => { + if (!window.metona?.app?.selectFolder) return; + const r = await window.metona.app.selectFolder(currentPath || undefined); + if (!r.canceled && r.path) { + // 立即校验新路径 + setPendingPath(r.path); + setChecking(true); + setCheckResult(null); + try { + const result = await window.metona.workspace.check(r.path); + setCheckResult(result); + } catch (err) { + console.error('[WorkspaceSettings]', err); + setCheckResult({ valid: false, reason: (err as Error).message }); + } finally { + setChecking(false); + } + } + }; + + const handleApply = async () => { + if (!pendingPath || !checkResult?.valid) return; + setApplying(true); + try { + // 如果勾选了继承文件,从旧工作空间复制到新工作空间 + // - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义) + // - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据) + const filesToInherit: string[] = []; + if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md')) + filesToInherit.push('SOUL.md'); + if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db'); + + // #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失 + if (inheritDatabase && currentPath) { + try { + const integrityResult = + await window.metona?.workspace?.checkDatabaseIntegrity(currentPath); + if (!integrityResult?.success) { + import('@metona-team/metona-toast') + .then((mod) => + mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`), + ) + .catch(() => {}); + setApplying(false); + return; + } + if (!integrityResult.ok) { + import('@metona-team/metona-toast') + .then((mod) => + mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`), + ) + .catch(() => {}); + setApplying(false); + return; + } + } catch (err) { + console.error('[WorkspaceSettings] Database integrity check failed:', err); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)) + .catch(() => {}); + setApplying(false); + return; + } + } + + if (filesToInherit.length > 0 && currentPath) { + try { + await window.metona.workspace.inheritFiles({ + targetPath: pendingPath, + sourcePath: currentPath, + files: filesToInherit, + }); + } catch (err) { + // 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险 + console.error('[WorkspaceSettings] Inherit failed:', err); + import('@metona-team/metona-toast') + .then((mod) => + mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`), + ) + .catch(() => {}); + } + } + + // 保存新路径到配置 + setWorkspacePath(pendingPath); + // 弹出重启确认对话框 + setShowRestartDialog(true); + // 清理中间状态 + setPendingPath(null); + setCheckResult(null); + } catch (err) { + console.error('[WorkspaceSettings]', err); + // 用户主动操作(切换工作空间)失败必须有反馈 + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)) + .catch(() => {}); + } finally { + setApplying(false); + } + }; + + const handleCancel = () => { + setPendingPath(null); + setCheckResult(null); + }; + + const handleOpen = async () => { + if (!workspacePath) return; + try { + const r = await window.metona?.app?.showItemInFolder(workspacePath); + if (r && !r.success) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r.error ?? '打开文件夹失败')) + .catch(() => {}); + } + } catch (err) { + console.error('[WorkspaceSettings]', err); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('打开文件夹失败')) + .catch(() => {}); + } + }; + + return ( + + + 工作空间 + + + 工作空间是 Metona 的组织核心,包含 SOUL.md、MEMORY.md 两个必需文件。 + + + setWorkspacePath(e.target.value)} + placeholder="~/MetonaWorkspaces/default/" + sx={{ flex: 1 }} + /> + + + {workspacePath && ( + + )} + + {/* 校验中状态 */} + {checking && ( + + + + 正在校验工作空间... + + + )} + + {/* 校验失败 */} + {pendingPath && checkResult && !checkResult.valid && ( + + 路径无效:{checkResult.reason} + + + + + )} + + {/* 校验成功 — 显示工作空间状态 + 继承选项 */} + {pendingPath && checkResult?.valid && ( + + + 目标工作空间:{checkResult.path} + + + {checkResult.isNewWorkspace ? ( + + 新工作空间 — 切换后将自动创建 2 个必需文件(SOUL.md、MEMORY.md) + + ) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? ( + + 已有目录但缺少 {checkResult.missingFiles.length} 个文件: + {checkResult.missingFiles.join(', ')}。缺失文件将自动创建。 + + ) : ( + + 已有工作空间 — 2 个必需文件均已就绪,将直接加载现有配置。 + + )} + + {/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */} + {currentPath && + currentPath !== pendingPath && + (checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && ( + + + 从当前工作空间继承: + + {/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */} + {checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && ( + setInheritSoul(e.target.checked)} + /> + } + label={SOUL.md(身份与角色定义)} + /> + )} + {/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */} + {!checkResult.dbExists && ( + <> + setInheritDatabase(e.target.checked)} + /> + } + label={ + + 数据库 agent.db(会话/消息/记忆/Trace 历史记录) + + } + /> + + 勾选后将复制当前工作空间的全部历史数据到新工作空间(通过 SQLite backup API + 原子性导出) + + + )} + + MEMORY.md 不继承(记忆与工作空间项目上下文绑定) + + + )} + + + + + + + )} + + + 修改工作空间路径后需重启应用生效。 + + + {/* 重启确认对话框 */} + setShowRestartDialog(false)} + maxWidth="xs" + fullWidth + > + 工作空间已切换 + + + 工作空间已更新为: + + + {workspacePath} + + + 需要重启应用以加载新工作空间的配置和文件。是否立即重启? + + + + + + + + + ); +} diff --git a/src/components/settings/useConfig.ts b/src/components/settings/useConfig.ts new file mode 100644 index 0000000..c0f7006 --- /dev/null +++ b/src/components/settings/useConfig.ts @@ -0,0 +1,69 @@ +/** + * useConfig — 设置面板共享配置读写 Hook + * + * 从 SettingsModal.tsx 提取(v0.4.1 拆分),供各设置 Tab 组件复用。 + * + * 特性: + * - 配置读取:挂载时异步加载,null/undefined 保持默认值 + * - 配置写入:失败时回滚 UI 并 toast 提示,避免 UI 与 DB 状态不一致 + * - 竞态保护:seqRef 防止连续修改时旧请求失败回滚覆盖新值 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +export function useConfig(key: string, defaultValue: T): [T, (v: T) => void] { + const [value, setValue] = useState(defaultValue); + const valueRef = useRef(value); + valueRef.current = value; + // v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致 + // seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值 + const seqRef = useRef(0); + useEffect(() => { + if (window.metona?.config?.get) + window.metona.config + .get(key) + .then((v) => { + if (v != null) setValue(v as T); + }) + .catch((err) => { + console.error('[useConfig]', err); + }); + }, [key]); + const set = useCallback( + (v: T) => { + const seq = ++seqRef.current; + const prev = valueRef.current; + setValue(v); + window.metona?.config + ?.set(key, v) + .then((r: { success?: boolean; error?: string } | undefined) => { + if (r && !r.success) { + // 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改 + if (seqRef.current === seq) setValue(prev); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error(r.error ?? '配置保存失败')) + .catch(() => {}); + } + }) + .catch((err: unknown) => { + console.error('[useConfig]', err); + if (seqRef.current === seq) setValue(prev); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('配置保存失败')) + .catch(() => {}); + }); + }, + [key], + ); + return [value, set]; +} + +/** 各 Provider 的默认 Base URL(切换 Provider 时自动填充) */ +export const PROVIDER_URLS: Record = { + deepseek: 'https://api.deepseek.com', + agnes: 'https://apihub.agnes-ai.com/v1', + mimo: 'https://api.xiaomimimo.com/v1', + ollama: 'http://localhost:11434', + openai: 'https://api.openai.com/v1', + anthropic: 'https://api.anthropic.com', +}; diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index f3f1b83..edbde2b 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -11,7 +11,12 @@ */ import { useEffect, useRef } from 'react'; -import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store'; +import { + useAgentStore, + genMsgId, + type ToolCallInfo, + type AgentStatus, +} from '@renderer/stores/agent-store'; /** * Agent 流式事件监听 Hook @@ -102,12 +107,23 @@ export function useAgentStream(): void { /** 工具调用增量(流式参数拼接) */ toolCallDelta?: { index: number; name?: string; argsDelta?: string }; toolCall?: { id: string; name: string; args: Record }; - toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number }; + toolResult?: { + toolCallId: string; + success: boolean; + result?: unknown; + error?: string; + durationMs?: number; + }; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; /** v0.3.18 修复: 上下文压缩事件数据 */ savedTokens?: number; originalTokens?: number; compressedTokens?: number; + /** v0.4.1: 输出验证结果(OutputValidator 检出的疑似问题,不阻断输出) */ + validation?: { + score: number; + issues: Array<{ severity: string; type: string; message: string }>; + }; error?: { code: string; message: string }; state?: string; }; @@ -143,7 +159,9 @@ export function useAgentStream(): void { if (data.iteration != null) { const msgs = getStore().messages; const last = msgs[msgs.length - 1]; - const needsNewCard = !last || last.role !== 'assistant' || + const needsNewCard = + !last || + last.role !== 'assistant' || (last.iteration != null && last.iteration !== data.iteration); if (needsNewCard) { getStore().addMessage({ @@ -201,7 +219,9 @@ export function useAgentStream(): void { if (data.iteration != null) { const msgs = getStore().messages; const last = msgs[msgs.length - 1]; - const needsNewCard = !last || last.role !== 'assistant' || + const needsNewCard = + !last || + last.role !== 'assistant' || (last.iteration != null && last.iteration !== data.iteration); if (needsNewCard) { // F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta) @@ -307,7 +327,7 @@ export function useAgentStream(): void { tc.id === data.toolResult!.toolCallId ? { ...tc, - status: data.toolResult!.success ? 'success' as const : 'error' as const, + status: data.toolResult!.success ? ('success' as const) : ('error' as const), result: data.toolResult!.result, error: data.toolResult!.error, durationMs: data.toolResult!.durationMs, @@ -328,7 +348,9 @@ export function useAgentStream(): void { tc.id === data.toolResult!.toolCallId ? { ...tc, - status: data.toolResult!.success ? 'success' as const : 'error' as const, + status: data.toolResult!.success + ? ('success' as const) + : ('error' as const), result: data.toolResult!.result, error: data.toolResult!.error, durationMs: data.toolResult!.durationMs, @@ -375,6 +397,23 @@ export function useAgentStream(): void { break; } + // v0.4.1: 输出验证结果 — OutputValidator 检出的疑似问题以轻量 system 消息展示(不阻断) + case 'validation': { + const issues = data.validation?.issues ?? []; + if (issues.length > 0) { + const lines = issues.map( + (i) => `${i.severity === 'error' ? '❌' : '⚠️'} [${i.type}] ${i.message}`, + ); + getStore().addMessage({ + id: genMsgId('system'), + role: 'system', + content: `🔍 输出验证:发现 ${issues.length} 个疑似问题(含幻觉/事实一致性检测,仅供参考)\n${lines.join('\n')}`, + timestamp: Date.now(), + }); + } + break; + } + // 流结束 case 'done': // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 @@ -420,9 +459,8 @@ export function useAgentStream(): void { getStore().addMessage({ id: genMsgId('error'), role: 'system', - content: errorCode === 'content_filtered' - ? `⚠️ ${errorMessage}` - : `错误: ${errorMessage}`, + content: + errorCode === 'content_filtered' ? `⚠️ ${errorMessage}` : `错误: ${errorMessage}`, timestamp: Date.now(), }); break; @@ -494,12 +532,14 @@ export function useAgentStream(): void { if (data.iteration > prevIteration) { const messages = store.messages; const lastMsg = messages[messages.length - 1]; - const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration; + const isAlreadyCurrentIteration = + lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration; if (!isAlreadyCurrentIteration) { // 首轮不要求上一轮有内容(上一轮是用户消息) const isFirstIteration = prevIteration === 0; - const prevHasContent = lastMsg?.role === 'assistant' && + const prevHasContent = + lastMsg?.role === 'assistant' && (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent); if (isFirstIteration || prevHasContent) { @@ -527,7 +567,8 @@ export function useAgentStream(): void { // 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤 // 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组 // runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step) - const isSameIteration = lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId; + const isSameIteration = + lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId; if (isSameIteration) { // 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state @@ -555,7 +596,10 @@ export function useAgentStream(): void { completedAt: Date.now(), }); } - } else if (data.state === 'TERMINATED' && (!lastStep || lastStep.runId !== data.runId)) { + } else if ( + data.state === 'TERMINATED' && + (!lastStep || lastStep.runId !== data.runId) + ) { // 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新 // 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态 } else { @@ -601,7 +645,12 @@ export function useAgentStream(): void { if (!window.metona?.agent?.onProviderSwitched) return; const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => { - const { from, to, reason, sessionId } = data as { from?: string; to?: string; reason?: string; sessionId?: string }; + const { from, to, reason, sessionId } = data as { + from?: string; + to?: string; + reason?: string; + sessionId?: string; + }; // L-3: 仅在当前会话中显示 Provider 切换消息 const store = useAgentStore.getState(); if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index d64993b..bdf19ae 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -59,15 +59,19 @@ interface MetonaAgentAPI { /** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */ sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>; onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void; - onStateChange: (callback: (state: { - sessionId: string | null; - iteration: number; - state: string; - previous: string; - current: string; - }) => void) => () => void; + onStateChange: ( + callback: (state: { + sessionId: string | null; + iteration: number; + state: string; + previous: string; + current: string; + }) => void, + ) => () => void; abortSession: (sessionId: string) => Promise<{ success: boolean }>; - onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void; + onProviderSwitched: ( + callback: (data: { from?: string; to?: string; reason?: string }) => void, + ) => () => void; } // ===== Sessions API ===== @@ -87,25 +91,40 @@ interface MetonaSessionsAPI { create: (title?: string) => Promise; rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>; delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>; - getMessages: (sessionId: string) => Promise; - iteration?: number; - timestamp: number; - }>>; + getMessages: (sessionId: string) => Promise< + Array<{ + id: string; + role: string; + content: string; + reasoningContent?: string; + toolCalls?: unknown[]; + toolResult?: unknown; + attachments?: Array<{ + id: string; + name: string; + type: string; + size: number; + preview?: string; + textContent?: string; + }>; + iteration?: number; + timestamp: number; + }> + >; 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 }>; clearMessages: (sessionId: string) => Promise<{ success: boolean }>; /** P2-11: 截断消息(编辑重发/重新生成) */ - truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) => - Promise<{ success: boolean; truncated?: number; error?: string }>; - saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>; + truncateAfter: ( + sessionId: string, + messageId: string, + inclusive?: boolean, + ) => Promise<{ success: boolean; truncated?: number; error?: string }>; + saveTrace: ( + sessionId: string, + data: { traceSteps: unknown[]; tokenUsage: unknown }, + ) => Promise<{ success: boolean }>; getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>; } @@ -113,7 +132,8 @@ interface MetonaSessionsAPI { interface MetonaMCPServerConfig { name: string; - transport: 'stdio' | 'sse'; + /** v0.4.1: 新增 'streamable-http'(MCP 当前主流远程传输) */ + transport: 'stdio' | 'sse' | 'streamable-http'; command?: string; args?: string[]; url?: string; @@ -147,11 +167,14 @@ interface MetonaMemorySearchResult { } interface MetonaMemoryAPI { - search: (query: string, options?: { - topK?: number; - type?: 'episodic' | 'semantic' | 'working'; - minImportance?: number; - }) => Promise; + search: ( + query: string, + options?: { + topK?: number; + type?: 'episodic' | 'semantic' | 'working'; + minImportance?: number; + }, + ) => Promise; listAll: (options?: { type?: string; limit?: number }) => Promise<{ success: boolean; data?: { episodic?: unknown[]; semantic?: unknown[]; working?: unknown[] }; @@ -166,7 +189,9 @@ interface MetonaConfigAPI { get: (key: string) => Promise; set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>; // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 - setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>; + setBatch: ( + entries: Array<{ key: string; value: unknown }>, + ) => Promise<{ success: boolean; error?: string }>; // v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新) onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void; } @@ -227,8 +252,11 @@ interface MetonaWorkspaceInfo { interface MetonaWorkspaceAPI { check: (targetPath: string) => Promise; - inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => - Promise; + inheritFiles: (params: { + targetPath: string; + sourcePath: string; + files: string[]; + }) => Promise; getInfo: () => Promise; // #51 修复: 校验源数据库完整性(PRAGMA integrity_check) checkDatabaseIntegrity: (sourcePath: string) => Promise<{ @@ -242,11 +270,13 @@ interface MetonaWorkspaceAPI { // ===== Toast API ===== interface MetonaToastAPI { - onShow: (callback: (data: { - type: 'success' | 'error' | 'warning' | 'info'; - message: string; - options?: Record; - }) => void) => () => void; + onShow: ( + callback: (data: { + type: 'success' | 'error' | 'warning' | 'info'; + message: string; + options?: Record; + }) => void, + ) => () => void; } // ===== Tools API ===== @@ -280,7 +310,11 @@ interface MetonaSearXNGTestResult { interface MetonaSearXNGAPI { /** 测试 SearXNG 实例连接可达性与认证有效性 */ - testConnection: (url: string, authKey: string, authType: string) => Promise; + testConnection: ( + url: string, + authKey: string, + authType: string, + ) => Promise; } // ===== Data API ===== @@ -347,8 +381,11 @@ interface MetonaAuditVerifyResult { interface MetonaAuditAPI { verifyChain: () => Promise; - query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) => - Promise<{ success: boolean; data?: unknown[]; error?: string }>; + query: (filters?: { + sessionId?: string; + eventType?: string; + limit?: number; + }) => Promise<{ success: boolean; data?: unknown[]; error?: string }>; } // ===== v0.2.0: Tool Confirmation API ===== @@ -386,8 +423,21 @@ interface MetonaToolAPI { success: boolean; data: MetonaConfirmationRequest[]; }>; + /** + * v0.4.1: 获取本会话内记住"拒绝"的工具列表(拒绝记忆 10 分钟 TTL,到期自动恢复询问) + * 供确认弹框展示"重新询问"入口 + */ + getRememberedDenials: () => Promise<{ + success: boolean; + data: Array<{ toolName: string; expiresInSeconds: number }>; + }>; + /** v0.4.1: 重置指定工具的会话内拒绝记忆(立即恢复询问) */ + resetRememberedDenial: (toolName: string) => Promise<{ success: boolean; error?: string }>; /** 设置/取消工具的持久化自动执行(跨会话不再询问) */ - setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>; + setAutoExecute: ( + toolName: string, + enabled: boolean, + ) => Promise<{ success: boolean; error?: string }>; /** 获取已设置为自动执行的工具列表 */ getAutoExecuteList: () => Promise<{ success: boolean; data: string[] }>; }