feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 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 双模式)
This commit is contained in:
@@ -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
|
||||||
@@ -44,6 +44,9 @@ yarn-error.log*
|
|||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
|
# Husky (internal shims)
|
||||||
|
.husky/_/
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.4.0-blue?style=flat-square" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.4.1-blue?style=flat-square" alt="Version" />
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
||||||
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
||||||
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
||||||
@@ -844,9 +844,8 @@ npm run format # Prettier 格式化
|
|||||||
|
|
||||||
# ─── 测试 ─────────────────────────────────
|
# ─── 测试 ─────────────────────────────────
|
||||||
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过)
|
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:watch # 测试监听模式
|
||||||
npm run test:e2e # E2E 测试 (Playwright)
|
|
||||||
|
|
||||||
# ─── 构建 ─────────────────────────────────
|
# ─── 构建 ─────────────────────────────────
|
||||||
npm run build # 构建生产包 (Windows NSIS + 便携版)
|
npm run build # 构建生产包 (Windows NSIS + 便携版)
|
||||||
|
|||||||
@@ -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<MetonaResponse> {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async *sendStream(_request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||||
|
// 空实现
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试辅助: 暴露 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<AdapterConfig> = {}): 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<Response>((_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<Response>((_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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Uint8Array> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const c of chunks) controller.enqueue(encoder.encode(c));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
): Promise<Array<{ type: string; [k: string]: unknown }>> {
|
||||||
|
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<string, unknown> };
|
||||||
|
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<string, number>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,7 +124,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
|||||||
* @param init fetch init(不含 signal,由本方法内部管理)
|
* @param init fetch init(不含 signal,由本方法内部管理)
|
||||||
* @param timeoutMs 超时时间(毫秒)
|
* @param timeoutMs 超时时间(毫秒)
|
||||||
*/
|
*/
|
||||||
protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
protected async fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
@@ -181,7 +185,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
|||||||
*/
|
*/
|
||||||
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||||||
let errorBody = '';
|
let errorBody = '';
|
||||||
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
try {
|
||||||
|
errorBody = await response.text();
|
||||||
|
} catch {
|
||||||
|
/* body 可能已消费或为 null */
|
||||||
|
}
|
||||||
|
|
||||||
// v0.3.17: 解析 JSON 错误体,识别 content_filter
|
// v0.3.17: 解析 JSON 错误体,识别 content_filter
|
||||||
if (errorBody) {
|
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;
|
(error as Error & { status: number }).status = response.status;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -226,7 +236,10 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
|||||||
|
|
||||||
const msg = error.message.toLowerCase();
|
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 {
|
return {
|
||||||
code: MetonaErrorCode.NETWORK_TIMEOUT,
|
code: MetonaErrorCode.NETWORK_TIMEOUT,
|
||||||
message: error.message,
|
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 {
|
return {
|
||||||
code: MetonaErrorCode.NETWORK_ERROR,
|
code: MetonaErrorCode.NETWORK_ERROR,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,23 +40,35 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
/** 工具定义缓存(由外部设置) */
|
/** 工具定义缓存(由外部设置) */
|
||||||
private toolDefs = new Map<string, MetonaToolDef>();
|
private toolDefs = new Map<string, MetonaToolDef>();
|
||||||
|
|
||||||
/** 用户选择记忆(同一会话内不再重复询问) */
|
/** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */
|
||||||
private rememberedDecisions = new Map<string, boolean>();
|
private rememberedDecisions = new Map<string, { approved: boolean; at: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.4.1: 会话内拒绝记忆的 TTL(10 分钟)
|
||||||
|
*
|
||||||
|
* 历史问题:用户勾选"记住拒绝"后,该工具在本会话永久被拒且无恢复入口,
|
||||||
|
* 用户只能重启会话。现给拒绝记忆加 TTL——过期后恢复询问;
|
||||||
|
* 批准记忆不受 TTL 影响(记住批准是低风险决定,保留原语义)。
|
||||||
|
*/
|
||||||
|
private static readonly DENIAL_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
|
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
|
||||||
private autoExecuteTools = new Set<string>();
|
private autoExecuteTools = new Set<string>();
|
||||||
|
|
||||||
/** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
|
/** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
|
||||||
private pendingConfirmations = new Map<string, {
|
private pendingConfirmations = new Map<
|
||||||
resolve: (v: boolean) => void;
|
string,
|
||||||
timer: NodeJS.Timeout;
|
{
|
||||||
toolName: string;
|
resolve: (v: boolean) => void;
|
||||||
expiresAt: number;
|
timer: NodeJS.Timeout;
|
||||||
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
|
toolName: string;
|
||||||
args?: Record<string, unknown>;
|
expiresAt: number;
|
||||||
riskLevel?: string;
|
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
|
||||||
reason?: string;
|
args?: Record<string, unknown>;
|
||||||
}>();
|
riskLevel?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
/** 确认超时时间(可从配置读取,默认 120 秒) */
|
/** 确认超时时间(可从配置读取,默认 120 秒) */
|
||||||
private confirmationTimeoutMs = 120_000;
|
private confirmationTimeoutMs = 120_000;
|
||||||
@@ -168,7 +180,12 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
* @param remember 会话内记住决定
|
* @param remember 会话内记住决定
|
||||||
* @param autoExecute 永久自动执行(持久化)
|
* @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);
|
const pending = this.pendingConfirmations.get(toolCallId);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
@@ -177,9 +194,9 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
if (autoExecute && approved) {
|
if (autoExecute && approved) {
|
||||||
this.setAutoExecute(pending.toolName, true);
|
this.setAutoExecute(pending.toolName, true);
|
||||||
}
|
}
|
||||||
// 会话内记忆
|
// 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期)
|
||||||
if (remember) {
|
if (remember) {
|
||||||
this.rememberedDecisions.set(pending.toolName, approved);
|
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
|
||||||
}
|
}
|
||||||
this.pendingConfirmations.delete(toolCallId);
|
this.pendingConfirmations.delete(toolCallId);
|
||||||
}
|
}
|
||||||
@@ -225,7 +242,7 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
this.setAutoExecute(pending.toolName, true);
|
this.setAutoExecute(pending.toolName, true);
|
||||||
}
|
}
|
||||||
if (remember) {
|
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;
|
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<HookResult> {
|
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
|
||||||
const def = this.toolDefs.get(toolCall.name);
|
const def = this.toolDefs.get(toolCall.name);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -265,8 +320,8 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否需要确认
|
// 检查是否需要确认
|
||||||
const needsConfirmation = def.requiresPermission ||
|
const needsConfirmation =
|
||||||
ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
|
def.requiresPermission || ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
|
||||||
|
|
||||||
if (!needsConfirmation) {
|
if (!needsConfirmation) {
|
||||||
return { blocked: false };
|
return { blocked: false };
|
||||||
@@ -277,11 +332,21 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
return { blocked: false };
|
return { blocked: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有记住的决策
|
// 检查是否有记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问)
|
||||||
const remembered = this.rememberedDecisions.get(toolCall.name);
|
const remembered = this.rememberedDecisions.get(toolCall.name);
|
||||||
if (remembered !== undefined) {
|
if (remembered !== undefined) {
|
||||||
if (remembered) return { blocked: false };
|
const isExpiredDenial =
|
||||||
return { blocked: true, reason: `User previously denied tool "${toolCall.name}"` };
|
!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;
|
this.lastTimeoutToastAt = now;
|
||||||
// 统计当前还有多少 pending(含本次刚超时的)
|
// 统计当前还有多少 pending(含本次刚超时的)
|
||||||
const pendingCount = this.pendingConfirmations.size + 1;
|
const pendingCount = this.pendingConfirmations.size + 1;
|
||||||
const message = pendingCount > 1
|
const message =
|
||||||
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
|
pendingCount > 1
|
||||||
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
|
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
|
||||||
|
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
|
||||||
this.mainWindow.webContents.send('toast:show', {
|
this.mainWindow.webContents.send('toast:show', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -21,15 +21,16 @@ export interface PreToolHook {
|
|||||||
export class PermissionCheckHook implements PreToolHook {
|
export class PermissionCheckHook implements PreToolHook {
|
||||||
constructor(private policyEngine: PolicyEngine) {}
|
constructor(private policyEngine: PolicyEngine) {}
|
||||||
|
|
||||||
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
|
async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise<HookResult> {
|
||||||
const result = this.policyEngine.checkAuthorization(toolCall.name, toolCall.args);
|
// v0.4.1: 透传 sessionId 使频率限制按会话隔离(多会话并发时各自独立配额)
|
||||||
|
const result = this.policyEngine.checkAuthorization(toolCall.name, toolCall.args, sessionId);
|
||||||
if (!result.authorized) {
|
if (!result.authorized) {
|
||||||
return { blocked: true, reason: result.reason };
|
return { blocked: true, reason: result.reason };
|
||||||
}
|
}
|
||||||
// v0.3.0 修复: 授权成功后记录调用,使频率限制功能生效
|
// v0.3.0 修复: 授权成功后记录调用,使频率限制功能生效
|
||||||
// 在授权检查通过后立即记录,即使后续工具执行失败也计入频率
|
// 在授权检查通过后立即记录,即使后续工具执行失败也计入频率
|
||||||
// 这样可以防止通过故意制造错误来绕过频率限制
|
// 这样可以防止通过故意制造错误来绕过频率限制
|
||||||
this.policyEngine.recordCall(toolCall.name);
|
this.policyEngine.recordCall(toolCall.name, sessionId);
|
||||||
return { blocked: false };
|
return { blocked: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,34 @@ import { PolicyEngine, DEFAULT_POLICIES } from '../permissions';
|
|||||||
describe('PolicyEngine 默认策略', () => {
|
describe('PolicyEngine 默认策略', () => {
|
||||||
it('所有内置工具均有策略配置', () => {
|
it('所有内置工具均有策略配置', () => {
|
||||||
const knownTools = [
|
const knownTools = [
|
||||||
'read_file', 'write_file', 'list_directory', 'search_files', 'delete_file',
|
'read_file',
|
||||||
'file_move', 'file_info', 'file_editor', 'code_search', 'diff_viewer',
|
'write_file',
|
||||||
'web_search', 'web_fetch', 'web_browser', 'http_request',
|
'list_directory',
|
||||||
'memory_store', 'memory_search', 'run_command', 'task_manager',
|
'search_files',
|
||||||
'delegate_task', 'git_status', 'git_diff', 'git_log', 'git_commit',
|
'delete_file',
|
||||||
'lint_code', 'run_tests', 'project_info', 'think', 'view_image',
|
'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) {
|
for (const tool of knownTools) {
|
||||||
expect(DEFAULT_POLICIES.some((p) => p.toolName === tool)).toBe(true);
|
expect(DEFAULT_POLICIES.some((p) => p.toolName === tool)).toBe(true);
|
||||||
@@ -57,7 +79,9 @@ describe('deniedPatterns 深度扫描', () => {
|
|||||||
|
|
||||||
it('正常路径不误判', () => {
|
it('正常路径不误判', () => {
|
||||||
const engine = new PolicyEngine();
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,21 +32,65 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
// H-5 修复: 移除 /MEMORY\.md/i 粗粒度正则 — 之前会误拦子目录的 MEMORY.md
|
// H-5 修复: 移除 /MEMORY\.md/i 粗粒度正则 — 之前会误拦子目录的 MEMORY.md
|
||||||
// 改为在 engine.ts executeToolSafely 中进行精确的根目录校验(仅保护 workspacePath/MEMORY.md)
|
// 改为在 engine.ts executeToolSafely 中进行精确的根目录校验(仅保护 workspacePath/MEMORY.md)
|
||||||
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected
|
// @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: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
|
||||||
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
|
||||||
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
|
||||||
{ toolName: 'memory_search', 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: '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 },
|
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
|
||||||
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
|
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
|
||||||
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
|
// 由于该工具可执行 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 拦截
|
// v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截
|
||||||
// file_editor — 精准文件编辑(WRITE),与 write_file 同级安全约束
|
// 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)
|
// code_search — 基于 ripgrep 的只读搜索(READ)
|
||||||
{ toolName: 'code_search', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'code_search', requiredLevel: PermissionLevel.READ },
|
||||||
// diff_viewer — 文件/文本差异对比(只读,READ)
|
// diff_viewer — 文件/文本差异对比(只读,READ)
|
||||||
@@ -54,17 +98,32 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
// task_manager — 任务管理(数据库读写,低风险 WRITE)
|
// task_manager — 任务管理(数据库读写,低风险 WRITE)
|
||||||
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
|
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
|
||||||
// delegate_task — 子任务委派(启动 SubAgent,EXTERNAL_ACTION)
|
// 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})
|
// C-7 修复: MCP 工具通配符策略 — MCP 工具名称动态生成(mcp_{serverName}_{toolName})
|
||||||
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
|
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
|
||||||
// @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES
|
// @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 个)
|
// v0.3.1: Git 工具集(4 个)
|
||||||
{ toolName: 'git_status', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'git_status', requiredLevel: PermissionLevel.READ },
|
||||||
{ toolName: 'git_diff', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'git_diff', requiredLevel: PermissionLevel.READ },
|
||||||
{ toolName: 'git_log', 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 个)
|
// v0.3.1: 开发工具集(3 个)
|
||||||
{ toolName: 'lint_code', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'lint_code', requiredLevel: PermissionLevel.READ },
|
||||||
@@ -81,10 +140,20 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
{ toolName: 'view_image', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'view_image', requiredLevel: PermissionLevel.READ },
|
||||||
|
|
||||||
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
|
// 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 个)— 可能覆盖目标,需确认
|
// 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 个)— 只读
|
// v0.3.3: 文件信息查询工具(1 个)— 只读
|
||||||
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
|
||||||
@@ -93,12 +162,22 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
export class PolicyEngine {
|
export class PolicyEngine {
|
||||||
private policies: Map<string, PermissionPolicy> = new Map();
|
private policies: Map<string, PermissionPolicy> = 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<string, number[]> = new Map();
|
private callFrequency: Map<string, number[]> = new Map();
|
||||||
|
|
||||||
/** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */
|
/** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */
|
||||||
private readonly FREQ_WINDOW_MS = 60_000;
|
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 合并而非完全覆盖
|
* v0.3.0 修复:customPolicies 与 DEFAULT_POLICIES 合并而非完全覆盖
|
||||||
*
|
*
|
||||||
@@ -120,7 +199,20 @@ export class PolicyEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAuthorization(toolName: string, args: Record<string, unknown>): {
|
/**
|
||||||
|
* 权限校验
|
||||||
|
*
|
||||||
|
* v0.4.1: 新增可选 sessionId 参数 — 频率限制按会话隔离(多会话并发时各自独立配额)
|
||||||
|
*
|
||||||
|
* @param toolName 工具名
|
||||||
|
* @param args 工具参数
|
||||||
|
* @param sessionId 会话 ID(可选;缺失时频率配额计入 'global' 桶保持向后兼容)
|
||||||
|
*/
|
||||||
|
checkAuthorization(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
sessionId?: string,
|
||||||
|
): {
|
||||||
authorized: boolean;
|
authorized: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
level: PermissionLevel;
|
level: PermissionLevel;
|
||||||
@@ -223,9 +315,9 @@ export class PolicyEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.3.0: 频率限制检查
|
// v0.3.0: 频率限制检查(v0.4.1: 按会话隔离)
|
||||||
if (policy.maxFrequency !== undefined) {
|
if (policy.maxFrequency !== undefined) {
|
||||||
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency);
|
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency, sessionId);
|
||||||
if (!freqCheck.allowed) {
|
if (!freqCheck.allowed) {
|
||||||
return {
|
return {
|
||||||
authorized: false,
|
authorized: false,
|
||||||
@@ -252,23 +344,31 @@ export class PolicyEngine {
|
|||||||
* v0.3.0 修复:
|
* v0.3.0 修复:
|
||||||
* - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏)
|
* - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏)
|
||||||
*
|
*
|
||||||
|
* v0.4.1: 新增可选 sessionId 参数 — 频率配额按会话隔离
|
||||||
|
*
|
||||||
* @param toolName 工具名称
|
* @param toolName 工具名称
|
||||||
* @param maxFreq 最大频率(每分钟)
|
* @param maxFreq 最大频率(每分钟)
|
||||||
|
* @param sessionId 会话 ID(可选;缺失时计入 'global' 桶)
|
||||||
* @returns 检查结果
|
* @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 policy = this.policies.get(toolName);
|
||||||
const limit = maxFreq ?? policy?.maxFrequency;
|
const limit = maxFreq ?? policy?.maxFrequency;
|
||||||
if (limit === undefined) return { allowed: true };
|
if (limit === undefined) return { allowed: true };
|
||||||
|
|
||||||
|
const key = this.freqKey(toolName, sessionId);
|
||||||
const now = Date.now();
|
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);
|
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
|
||||||
|
|
||||||
// v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长
|
// v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长
|
||||||
if (validCalls.length !== calls.length) {
|
if (validCalls.length !== calls.length) {
|
||||||
this.callFrequency.set(toolName, validCalls);
|
this.callFrequency.set(key, validCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validCalls.length >= limit) {
|
if (validCalls.length >= limit) {
|
||||||
@@ -284,16 +384,19 @@ export class PolicyEngine {
|
|||||||
* v0.3.0: 记录工具调用(工具成功执行后调用)
|
* v0.3.0: 记录工具调用(工具成功执行后调用)
|
||||||
*
|
*
|
||||||
* v0.3.0 修复:同时清理过期记录,防止数组无限增长
|
* v0.3.0 修复:同时清理过期记录,防止数组无限增长
|
||||||
|
* v0.4.1: 新增可选 sessionId 参数 — 与 checkFrequency 的会话隔离配对使用
|
||||||
*
|
*
|
||||||
* @param toolName 工具名称
|
* @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 now = Date.now();
|
||||||
const calls = this.callFrequency.get(toolName) ?? [];
|
const calls = this.callFrequency.get(key) ?? [];
|
||||||
// v0.3.0 修复:记录新调用时同时清理过期记录
|
// v0.3.0 修复:记录新调用时同时清理过期记录
|
||||||
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
|
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
|
||||||
validCalls.push(now);
|
validCalls.push(now);
|
||||||
this.callFrequency.set(toolName, validCalls);
|
this.callFrequency.set(key, validCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
|
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ describe('RunCommandTool.validateCommand', () => {
|
|||||||
const tool = new RunCommandTool();
|
const tool = new RunCommandTool();
|
||||||
// 访问私有方法
|
// 访问私有方法
|
||||||
const validate = (cmd: string) =>
|
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 blocked = (cmd: string) => {
|
||||||
const result = validate(cmd);
|
const result = validate(cmd);
|
||||||
@@ -87,3 +89,28 @@ describe('RunCommandTool.validateCommand', () => {
|
|||||||
allowed('rm -rf node_modules');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<html><body>
|
||||||
|
<ol id="b_results">
|
||||||
|
<li class="b_algo">
|
||||||
|
<h2><a href="https://example.com/article-1">第一篇 TypeScript 文章</a></h2>
|
||||||
|
<p>这是第一条结果的摘要内容,讲述 TypeScript 高级用法。</p>
|
||||||
|
</li>
|
||||||
|
<li class="b_algo">
|
||||||
|
<h2><a href="https://example.com/article-2">第二篇 Node.js 文章</a></h2>
|
||||||
|
<div class="b_caption"><p>第二条结果的摘要。</p></div>
|
||||||
|
</li>
|
||||||
|
<li class="b_algo">
|
||||||
|
<!-- 自身域名链接应被过滤 -->
|
||||||
|
<h2><a href="https://www.bing.com/video?q=x">Bing 内部视频链接</a></h2>
|
||||||
|
<p>不应出现在结果中。</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</body></html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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('<html><body></body></html>')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBaidu — 结构化解析', () => {
|
||||||
|
const BAIDU_HTML = `
|
||||||
|
<html><body>
|
||||||
|
<div id="content_left">
|
||||||
|
<div class="result c-container new-pmd" srcid="1">
|
||||||
|
<h3 class="t"><a data-url="https://example.com/real-url-1" href="https://www.baidu.com/link?url=xyz">百度结果一</a></h3>
|
||||||
|
<span class="content-right_8Zs40">第一条摘要内容。</span>
|
||||||
|
</div>
|
||||||
|
<div class="result c-container" srcid="2">
|
||||||
|
<h3><a href="https://www.baidu.com/link?url=abc">跳转链接结果</a></h3>
|
||||||
|
<span class="content-right_8Zs40">无 data-url 的结果(跳转链接被过滤)。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<html><body>
|
||||||
|
<div class="results">
|
||||||
|
<div class="vrwrap">
|
||||||
|
<h3><a href="/link?url=sogou-internal-1">搜狗结果一</a></h3>
|
||||||
|
<div class="str_info">搜狗结果一的摘要文本。</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb">
|
||||||
|
<h3><a href="https://example.com/direct">搜狗直链结果</a></h3>
|
||||||
|
<p class="space-txt">直链结果的摘要。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></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 = `
|
||||||
|
<html><body>
|
||||||
|
<div class="res-list">
|
||||||
|
<ul>
|
||||||
|
<li class="res-list">
|
||||||
|
<h3 class="res-title"><a href="https://example.com/360-result">360 结果一</a></h3>
|
||||||
|
<p class="res-desc">360 搜索结果的摘要描述。</p>
|
||||||
|
</li>
|
||||||
|
<li class="res-list">
|
||||||
|
<h3><a href="https://www.so.com/internal">360 内部链接</a></h3>
|
||||||
|
<p class="res-desc">不应出现。</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body></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 = '<html><body><div class="captcha">请输入验证码</div></body></html>';
|
||||||
|
expect(parseBing(notSearchPage)).toHaveLength(0);
|
||||||
|
expect(parseBaidu(notSearchPage)).toHaveLength(0);
|
||||||
|
expect(parseSogou(notSearchPage)).toHaveLength(0);
|
||||||
|
expect(parse360(notSearchPage)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,13 +58,22 @@ function decodeBuffer(buf: Buffer): string {
|
|||||||
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
||||||
// 敏感变量后缀黑名单
|
// 敏感变量后缀黑名单
|
||||||
const SENSITIVE_SUFFIXES = [
|
const SENSITIVE_SUFFIXES = [
|
||||||
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
|
'_API_KEY',
|
||||||
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
|
'_TOKEN',
|
||||||
|
'_SECRET',
|
||||||
|
'_PASSWORD',
|
||||||
|
'_PASSWD',
|
||||||
|
'_CREDENTIAL',
|
||||||
|
'_CREDENTIALS',
|
||||||
|
'_PRIVATE_KEY',
|
||||||
];
|
];
|
||||||
// 敏感变量名黑名单(精确匹配)
|
// 敏感变量名黑名单(精确匹配)
|
||||||
const SENSITIVE_KEYS = new Set([
|
const SENSITIVE_KEYS = new Set([
|
||||||
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
|
'DEEPSEEK_API_KEY',
|
||||||
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
|
'AGNES_API_KEY',
|
||||||
|
'MIMO_API_KEY',
|
||||||
|
'GITEA_PASSWORD',
|
||||||
|
'DATABASE_PASSWORD',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
@@ -86,12 +95,31 @@ function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
|||||||
return env;
|
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 =====
|
// ===== 9. run_command =====
|
||||||
|
|
||||||
export class RunCommandTool implements IMetonaTool {
|
export class RunCommandTool implements IMetonaTool {
|
||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'run_command',
|
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: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -123,7 +151,11 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
// 安全校验:workdir 必须在工作空间内
|
// 安全校验:workdir 必须在工作空间内
|
||||||
const resolvedWorkdir = resolve(context.workspacePath, workdir);
|
const resolvedWorkdir = resolve(context.workspacePath, workdir);
|
||||||
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
|
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 设计
|
// v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计
|
||||||
@@ -183,19 +215,32 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
let stderr: Buffer;
|
let stderr: Buffer;
|
||||||
|
|
||||||
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
|
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
|
||||||
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT)
|
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法直接执行(ENOENT/EINVAL)
|
||||||
// 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
|
// v0.4.1: Windows 上白名单工具(git/node/npm/npx/pnpm/yarn/tsc)的简单命令改用
|
||||||
// 非 Windows 上对简单命令用 execFile
|
// execFile('cmd.exe', ['/c', ...args]) — 参数显式分离传递,不经 shell 字符串解析,
|
||||||
|
// 相比 exec() 的整串拼接显著收窄注入面
|
||||||
|
// 非 Windows 上对简单命令直接 execFile
|
||||||
if (simpleCmd && !isWindows) {
|
if (simpleCmd && !isWindows) {
|
||||||
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
|
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
|
||||||
stdout = result.stdout;
|
stdout = result.stdout;
|
||||||
stderr = result.stderr;
|
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 {
|
} else {
|
||||||
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
|
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
|
||||||
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
|
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
|
||||||
const finalCommand = isWindows
|
const finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${command}` : command;
|
||||||
? `chcp 65001 >nul 2>&1 && ${command}`
|
|
||||||
: command;
|
|
||||||
const result = await execAsync(finalCommand, execOpts);
|
const result = await execAsync(finalCommand, execOpts);
|
||||||
stdout = result.stdout;
|
stdout = result.stdout;
|
||||||
stderr = result.stderr;
|
stderr = result.stderr;
|
||||||
@@ -236,7 +281,11 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
|
|
||||||
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md
|
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md
|
||||||
if (commandTouchesProtectedFile(command)) {
|
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
|
// P0-5: 剥离 Windows chcp 前缀("chcp 65001 >nul 2>&1 &&" 会破坏 shell-quote
|
||||||
@@ -256,33 +305,61 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
const hardBlocks = [
|
const hardBlocks = [
|
||||||
// 文件系统破坏
|
// 文件系统破坏
|
||||||
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
|
{ 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(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: /\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: /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: /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: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
|
||||||
// 磁盘格式化
|
// 磁盘格式化
|
||||||
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
||||||
// 权限滥用
|
// 权限滥用
|
||||||
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is 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
|
// 反向 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' },
|
{ pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' },
|
||||||
// Windows 危险命令
|
// Windows 危险命令
|
||||||
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
||||||
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown 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: /\breg\s+(add|delete|import|restore)/i,
|
||||||
{ pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' },
|
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: 'Background subshell execution is forbidden' },
|
||||||
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
|
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
|
||||||
@@ -342,20 +419,29 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
prevWasPipe = false;
|
prevWasPipe = false;
|
||||||
} else if (typeof obj.op === 'string') {
|
} else if (typeof obj.op === 'string') {
|
||||||
// 跟踪管道运算符,用于下一轮检测 `| sh`
|
// 跟踪管道运算符,用于下一轮检测 `| sh`
|
||||||
prevWasPipe = (obj.op === '|');
|
prevWasPipe = obj.op === '|';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 危险命令名 token(精确匹配,大小写不敏感)
|
// 危险命令名 token(精确匹配,大小写不敏感)
|
||||||
const dangerousCommands = new Set([
|
const dangerousCommands = new Set([
|
||||||
'sudo', 'su', 'doas',
|
'sudo',
|
||||||
'shutdown', 'reboot', 'halt', 'poweroff',
|
'su',
|
||||||
'mkfs', 'fdisk', 'format', 'diskpart',
|
'doas',
|
||||||
|
'shutdown',
|
||||||
|
'reboot',
|
||||||
|
'halt',
|
||||||
|
'poweroff',
|
||||||
|
'mkfs',
|
||||||
|
'fdisk',
|
||||||
|
'format',
|
||||||
|
'diskpart',
|
||||||
]);
|
]);
|
||||||
// 危险参数 token
|
// 危险参数 token
|
||||||
const dangerousArgs = new Set([
|
const dangerousArgs = new Set([
|
||||||
'-enc', '-encodedcommand', // PowerShell 编码执行
|
'-enc',
|
||||||
|
'-encodedcommand', // PowerShell 编码执行
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
|
|||||||
@@ -6,9 +6,15 @@
|
|||||||
* 智能排序:引擎权重(50%) + 可达性(30%) + 摘要质量(20%)
|
* 智能排序:引擎权重(50%) + 可达性(30%) + 摘要质量(20%)
|
||||||
* 自动抓取:对前 N 条结果调用 web_fetch 获取完整正文
|
* 自动抓取:对前 N 条结果调用 web_fetch 获取完整正文
|
||||||
*
|
*
|
||||||
|
* v0.4.1: HTML 解析迁移至 node-html-parser(结构化解析)
|
||||||
|
* 主层使用 DOM 结构解析(引擎改版时选择器更精确、可维护性远优于正则),
|
||||||
|
* 正则解析保留为降级路径(结构化解析无结果时兜底)。
|
||||||
|
* 此前纯正则方案违反项目开发规范第一铁律(HTML 解析应使用成熟库)。
|
||||||
|
*
|
||||||
* @see docs/Agent网络工具通用设计-v2.md — 第 2 章 web_search 搜索设计
|
* @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 { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
@@ -64,7 +70,9 @@ const ENGINES: EngineDef[] = [
|
|||||||
name: 'bing',
|
name: 'bing',
|
||||||
weight: 90,
|
weight: 90,
|
||||||
searchUrl: (q, tr) => {
|
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`;
|
return `https://www.bing.com/search?q=${encodeURIComponent(q)}${freshness}&count=20`;
|
||||||
},
|
},
|
||||||
parse: parseBing,
|
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 results: SearchResult[] = [];
|
||||||
const blocks = html.split(/<li[^>]*class="b_algo"/i).slice(1);
|
const blocks = html.split(/<li[^>]*class="b_algo"/i).slice(1);
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
@@ -99,7 +248,9 @@ function parseBing(html: string): SearchResult[] {
|
|||||||
if (!titleMatch) continue;
|
if (!titleMatch) continue;
|
||||||
const url = titleMatch[1];
|
const url = titleMatch[1];
|
||||||
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
||||||
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
|
const snippetMatch =
|
||||||
|
block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) ||
|
||||||
|
block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
|
||||||
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
||||||
if (title && url && !url.includes('bing.com')) {
|
if (title && url && !url.includes('bing.com')) {
|
||||||
results.push({ title, url, snippet, engine: 'bing', weight: 90 });
|
results.push({ title, url, snippet, engine: 'bing', weight: 90 });
|
||||||
@@ -108,17 +259,19 @@ function parseBing(html: string): SearchResult[] {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBaidu(html: string): SearchResult[] {
|
function parseBaiduRegex(html: string): SearchResult[] {
|
||||||
const results: SearchResult[] = [];
|
const results: SearchResult[] = [];
|
||||||
const blocks = html.split(/<div[^>]*class="result[^"]*"/i).slice(1);
|
const blocks = html.split(/<div[^>]*class="result[^"]*"/i).slice(1);
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const titleMatch = block.match(/<a[^>]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
|
const titleMatch =
|
||||||
|| block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
block.match(/<a[^>]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i) ||
|
||||||
|
block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
||||||
if (!titleMatch) continue;
|
if (!titleMatch) continue;
|
||||||
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://${titleMatch[1]}`;
|
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://${titleMatch[1]}`;
|
||||||
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
||||||
const snippetMatch = block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i)
|
const snippetMatch =
|
||||||
|| block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
|
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() : '';
|
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
||||||
if (title && url && !url.includes('baidu.com/link')) {
|
if (title && url && !url.includes('baidu.com/link')) {
|
||||||
results.push({ title, url, snippet, engine: '百度', weight: 80 });
|
results.push({ title, url, snippet, engine: '百度', weight: 80 });
|
||||||
@@ -127,18 +280,23 @@ function parseBaidu(html: string): SearchResult[] {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSogou(html: string): SearchResult[] {
|
function parseSogouRegex(html: string): SearchResult[] {
|
||||||
const results: SearchResult[] = [];
|
const results: SearchResult[] = [];
|
||||||
const blocks = html.split(/<div[^>]*class="vrwrap"/i).slice(1)
|
const blocks = html
|
||||||
|
.split(/<div[^>]*class="vrwrap"/i)
|
||||||
|
.slice(1)
|
||||||
.concat(html.split(/<div[^>]*class="rb"/i).slice(1));
|
.concat(html.split(/<div[^>]*class="rb"/i).slice(1));
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
||||||
if (!titleMatch) continue;
|
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 title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
||||||
const snippetMatch = block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|
const snippetMatch =
|
||||||
|| block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|
block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
|
||||||
|| block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/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() : '';
|
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
||||||
if (title && url && !url.includes('sogou.com')) {
|
if (title && url && !url.includes('sogou.com')) {
|
||||||
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
|
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
|
||||||
@@ -147,19 +305,22 @@ function parseSogou(html: string): SearchResult[] {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse360(html: string): SearchResult[] {
|
function parse360Regex(html: string): SearchResult[] {
|
||||||
const results: SearchResult[] = [];
|
const results: SearchResult[] = [];
|
||||||
const blocks = html.split(/<li[^>]*class="res-list"/i).slice(1)
|
const blocks = html
|
||||||
|
.split(/<li[^>]*class="res-list"/i)
|
||||||
|
.slice(1)
|
||||||
.concat(html.split(/<div[^>]*class="result"/i).slice(1));
|
.concat(html.split(/<div[^>]*class="result"/i).slice(1));
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
||||||
if (!titleMatch) continue;
|
if (!titleMatch) continue;
|
||||||
const url = titleMatch[1];
|
const url = titleMatch[1];
|
||||||
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
||||||
const snippetMatch = block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|
const snippetMatch =
|
||||||
|| block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|
block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
|
||||||
|| block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|
block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
|
||||||
|| block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
|
block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
|
||||||
|
block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
|
||||||
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
||||||
if (title && url && !url.includes('so.com')) {
|
if (title && url && !url.includes('so.com')) {
|
||||||
results.push({ title, url, snippet, engine: '360搜索', weight: 75 });
|
results.push({ title, url, snippet, engine: '360搜索', weight: 75 });
|
||||||
@@ -192,7 +353,7 @@ async function checkReachability(urls: string[], concurrency = 5): Promise<Map<s
|
|||||||
function smartSort(results: SearchResult[]): SearchResult[] {
|
function smartSort(results: SearchResult[]): SearchResult[] {
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const reachability = r.reachable ? 30 : -20;
|
const reachability = r.reachable ? 30 : -20;
|
||||||
const snippetQuality = Math.min(r.snippet.length, 100) / 100 * 20;
|
const snippetQuality = (Math.min(r.snippet.length, 100) / 100) * 20;
|
||||||
const weightScore = (r.weight / 100) * 50;
|
const weightScore = (r.weight / 100) * 50;
|
||||||
r._score = weightScore + reachability + snippetQuality;
|
r._score = weightScore + reachability + snippetQuality;
|
||||||
}
|
}
|
||||||
@@ -223,13 +384,20 @@ function computeRelevance(query: string, result: SearchResult): number {
|
|||||||
export class WebSearchTool implements IMetonaTool {
|
export class WebSearchTool implements IMetonaTool {
|
||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'web_search',
|
name: 'web_search',
|
||||||
description: 'Search the web for information. Returns titles, snippets, and URLs. When SearXNG is enabled, uses the configured SearXNG instance; otherwise uses built-in engines (Bing, Baidu, Sogou, 360). Automatically fetches full content for top results.',
|
description:
|
||||||
|
'Search the web for information. Returns titles, snippets, and URLs. When SearXNG is enabled, uses the configured SearXNG instance; otherwise uses built-in engines (Bing, Baidu, Sogou, 360). Automatically fetches full content for top results.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'Search query keywords' },
|
query: { type: 'string', description: 'Search query keywords' },
|
||||||
time_range: { type: 'string', description: 'Time filter: day, week, month, year (optional)' },
|
time_range: {
|
||||||
enhance_snippets: { type: 'boolean', description: 'Auto-enhance short snippets (default true)' },
|
type: 'string',
|
||||||
|
description: 'Time filter: day, week, month, year (optional)',
|
||||||
|
},
|
||||||
|
enhance_snippets: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Auto-enhance short snippets (default true)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
@@ -265,7 +433,10 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
? Math.min(8, Math.max(3, searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : 5))
|
? Math.min(8, Math.max(3, searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : 5))
|
||||||
: 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,避免配置变更后返回旧缓存)
|
// 缓存检查(key 含模式 + maxResults + fetchTop,避免配置变更后返回旧缓存)
|
||||||
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${normalizeUrl(query).toLowerCase()}`;
|
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${normalizeUrl(query).toLowerCase()}`;
|
||||||
@@ -338,7 +509,10 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
|
|
||||||
// 写入缓存
|
// 写入缓存
|
||||||
searchCache.set(cacheKey, output);
|
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;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -394,7 +568,7 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json() as { results?: Array<Record<string, unknown>> };
|
const data = (await response.json()) as { results?: Array<Record<string, unknown>> };
|
||||||
for (const item of data.results ?? []) {
|
for (const item of data.results ?? []) {
|
||||||
const url = item.url as string;
|
const url = item.url as string;
|
||||||
const title = item.title as string;
|
const title = item.title as string;
|
||||||
@@ -418,7 +592,10 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results.push(...pageResults);
|
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++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,12 +617,17 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
const searchPromises = ENGINES.map(async (engine) => {
|
const searchPromises = ENGINES.map(async (engine) => {
|
||||||
try {
|
try {
|
||||||
const url = engine.searchUrl(query, timeRange);
|
const url = engine.searchUrl(query, timeRange);
|
||||||
const response = await fetchWithTimeout(url, {
|
const response = await fetchWithTimeout(
|
||||||
headers: {
|
url,
|
||||||
'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',
|
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) {
|
if (!response.ok) {
|
||||||
logTool('web_search', `[内置] ${engine.name} HTTP ${response.status}`);
|
logTool('web_search', `[内置] ${engine.name} HTTP ${response.status}`);
|
||||||
@@ -501,10 +683,10 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
if (enhanced >= maxEnhance) break;
|
if (enhanced >= maxEnhance) break;
|
||||||
if (r.snippet.length < 30 && r.reachable) {
|
if (r.snippet.length < 30 && r.reachable) {
|
||||||
try {
|
try {
|
||||||
const fetchResult = await this.webFetchTool.execute(
|
const fetchResult = (await this.webFetchTool.execute(
|
||||||
{ url: r.url },
|
{ url: r.url },
|
||||||
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
||||||
) as { success: boolean; content?: string };
|
)) as { success: boolean; content?: string };
|
||||||
|
|
||||||
if (fetchResult.success && fetchResult.content) {
|
if (fetchResult.success && fetchResult.content) {
|
||||||
const text = fetchResult.content.slice(0, 200);
|
const text = fetchResult.content.slice(0, 200);
|
||||||
@@ -537,7 +719,10 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
fetchTop: number,
|
fetchTop: number,
|
||||||
): Promise<Array<{ url: string; title: string; content: string }>> {
|
): Promise<Array<{ url: string; title: string; content: string }>> {
|
||||||
// 相关性评分(不过滤,relevance=0 的结果也参与抓取候选)
|
// 相关性评分(不过滤,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 : [];
|
const filtered = withRelevance.length > 0 ? withRelevance : [];
|
||||||
|
|
||||||
// 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数
|
// 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数
|
||||||
@@ -554,27 +739,30 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
toFetch = shuffled.slice(0, topN);
|
toFetch = shuffled.slice(0, topN);
|
||||||
} else {
|
} else {
|
||||||
toFetch = filtered
|
toFetch = filtered.sort((a, b) => b.relevance - a.relevance).slice(0, topN);
|
||||||
.sort((a, b) => b.relevance - a.relevance)
|
|
||||||
.slice(0, topN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetched: Array<{ url: string; title: string; content: string }> = [];
|
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 {
|
try {
|
||||||
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
|
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
|
||||||
const fetchResult = await this.webFetchTool.execute(
|
const fetchResult = (await this.webFetchTool.execute(
|
||||||
{ url: item.result.url },
|
{ url: item.result.url },
|
||||||
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
||||||
) as { success: boolean; content?: string };
|
)) as { success: boolean; content?: string };
|
||||||
|
|
||||||
if (fetchResult.success && fetchResult.content) {
|
if (fetchResult.success && fetchResult.content) {
|
||||||
return { url: item.result.url, title: item.result.title, content: fetchResult.content };
|
return { url: item.result.url, title: item.result.title, content: fetchResult.content };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -601,7 +789,9 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
lines.push(`${i + 1}. ${r.title}`);
|
lines.push(`${i + 1}. ${r.title}`);
|
||||||
lines.push(` URL: ${r.url}`);
|
lines.push(` URL: ${r.url}`);
|
||||||
if (r.snippet) lines.push(` 摘要: ${r.snippet.slice(0, 150)}`);
|
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');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,12 @@ export type {
|
|||||||
MetonaResponseMeta,
|
MetonaResponseMeta,
|
||||||
MetonaTokenUsage,
|
MetonaTokenUsage,
|
||||||
MetonaStreamEvent,
|
MetonaStreamEvent,
|
||||||
|
MetonaValidationPayload,
|
||||||
MetonaThinking,
|
MetonaThinking,
|
||||||
MetonaError,
|
MetonaError,
|
||||||
} from './metona-response';
|
} from './metona-response';
|
||||||
|
|
||||||
export {
|
export { MetonaFinishReason, MetonaStreamEventType, MetonaErrorCode } from './metona-response';
|
||||||
MetonaFinishReason,
|
|
||||||
MetonaStreamEventType,
|
|
||||||
MetonaErrorCode,
|
|
||||||
} from './metona-response';
|
|
||||||
|
|
||||||
// ===== 上下文与记忆 =====
|
// ===== 上下文与记忆 =====
|
||||||
export type { MetonaContext, MetonaMemoryItem } from './metona-context';
|
export type { MetonaContext, MetonaMemoryItem } from './metona-context';
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export enum MetonaStreamEventType {
|
|||||||
ERROR = 'error',
|
ERROR = 'error',
|
||||||
DONE = 'done',
|
DONE = 'done',
|
||||||
USAGE = 'usage',
|
USAGE = 'usage',
|
||||||
|
/** v0.4.1: 输出验证结果(OutputValidator 检出的疑似幻觉/事实矛盾/敏感信息,不阻断输出) */
|
||||||
|
VALIDATION = 'validation',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetonaStreamEvent {
|
export interface MetonaStreamEvent {
|
||||||
@@ -131,10 +133,27 @@ export interface MetonaStreamEvent {
|
|||||||
/** ERROR */
|
/** ERROR */
|
||||||
error?: MetonaError;
|
error?: MetonaError;
|
||||||
|
|
||||||
|
/** VALIDATION — 输出验证结果(v0.4.1: 疑似问题提示,不阻断输出) */
|
||||||
|
validation?: MetonaValidationPayload;
|
||||||
|
|
||||||
/** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */
|
/** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */
|
||||||
terminationReason?: string;
|
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 {
|
export interface MetonaThinking {
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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<unknown> {
|
||||||
|
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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>): {
|
||||||
|
roleDefinition: string;
|
||||||
|
dynamicReminders?: string;
|
||||||
|
} {
|
||||||
|
const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as {
|
||||||
|
runStream: Mock;
|
||||||
|
};
|
||||||
|
return engine.runStream.mock.calls[0][3];
|
||||||
|
}
|
||||||
+462
-345
@@ -22,7 +22,10 @@ import log from 'electron-log';
|
|||||||
/** 单会话的 text_delta 节流状态 */
|
/** 单会话的 text_delta 节流状态 */
|
||||||
interface ThrottleState {
|
interface ThrottleState {
|
||||||
buffer: string;
|
buffer: string;
|
||||||
lastEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null;
|
lastEventMeta: Pick<
|
||||||
|
MetonaStreamEvent,
|
||||||
|
'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'
|
||||||
|
> | null;
|
||||||
flushTimer: ReturnType<typeof setTimeout> | null;
|
flushTimer: ReturnType<typeof setTimeout> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,10 +40,20 @@ interface IterationTrace {
|
|||||||
|
|
||||||
export function registerAgentHandlers(ctx: IPCContext): void {
|
export function registerAgentHandlers(ctx: IPCContext): void {
|
||||||
const {
|
const {
|
||||||
agentEngineManager, sessionRecorder, configService, sessionService,
|
agentEngineManager,
|
||||||
workspaceService, contextBuilder, auditService, memoryManager,
|
sessionRecorder,
|
||||||
promptInjectionDefender, outputValidator, memoryConsolidator,
|
configService,
|
||||||
sessionSummaryService, orchestrator, confirmationHook,
|
sessionService,
|
||||||
|
workspaceService,
|
||||||
|
contextBuilder,
|
||||||
|
auditService,
|
||||||
|
memoryManager,
|
||||||
|
promptInjectionDefender,
|
||||||
|
outputValidator,
|
||||||
|
memoryConsolidator,
|
||||||
|
sessionSummaryService,
|
||||||
|
orchestrator,
|
||||||
|
confirmationHook,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||||
@@ -88,7 +101,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
|
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
|
||||||
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
|
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
|
||||||
const st = throttleStates.get(sessionId) ?? {
|
const st = throttleStates.get(sessionId) ?? {
|
||||||
buffer: '', lastEventMeta: null, flushTimer: null,
|
buffer: '',
|
||||||
|
lastEventMeta: null,
|
||||||
|
flushTimer: null,
|
||||||
};
|
};
|
||||||
throttleStates.set(sessionId, st);
|
throttleStates.set(sessionId, st);
|
||||||
if (st.buffer === '') {
|
if (st.buffer === '') {
|
||||||
@@ -101,7 +116,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
runId: event.runId,
|
runId: event.runId,
|
||||||
};
|
};
|
||||||
} else if (st.lastEventMeta) {
|
} 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;
|
st.buffer += event.delta;
|
||||||
if (st.flushTimer === null) {
|
if (st.flushTimer === null) {
|
||||||
@@ -133,9 +153,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
break;
|
break;
|
||||||
case MetonaStreamEventType.TOOL_RESULT:
|
case MetonaStreamEventType.TOOL_RESULT:
|
||||||
if (event.toolResult) {
|
if (event.toolResult) {
|
||||||
const resultPreview = typeof event.toolResult.result === 'string'
|
const resultPreview =
|
||||||
? event.toolResult.result
|
typeof event.toolResult.result === 'string'
|
||||||
: JSON.stringify(event.toolResult.result);
|
? event.toolResult.result
|
||||||
|
: JSON.stringify(event.toolResult.result);
|
||||||
sessionRecorder.recordToolResult({
|
sessionRecorder.recordToolResult({
|
||||||
sessionId,
|
sessionId,
|
||||||
iteration: event.iteration,
|
iteration: event.iteration,
|
||||||
@@ -167,21 +188,67 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 常驻监听:状态变化(广播 + TRACE 迭代录制) =====
|
// ===== 常驻监听:状态变化(广播 + TRACE 迭代录制) =====
|
||||||
agentEngineManager.on('stateChange', (data: {
|
agentEngineManager.on(
|
||||||
previous?: string; current?: string; state?: string;
|
'stateChange',
|
||||||
sessionId?: string; iteration?: number; runId?: string;
|
(data: {
|
||||||
}) => {
|
previous?: string;
|
||||||
if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`);
|
current?: string;
|
||||||
broadcast('agent:stateChange', data);
|
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;
|
const sessionId = data.sessionId;
|
||||||
if (!sessionId || data.iteration == null) return;
|
if (!sessionId || data.iteration == null) return;
|
||||||
const stateValue = data.state ?? data.current ?? '';
|
const stateValue = data.state ?? data.current ?? '';
|
||||||
const trace = iterationTraces.get(sessionId);
|
const trace = iterationTraces.get(sessionId);
|
||||||
|
|
||||||
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
||||||
if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) {
|
if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) {
|
||||||
if (trace && !trace.responded) {
|
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<string>('llm.provider') ?? '';
|
||||||
|
const model = configService.get<string>('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({
|
sessionRecorder.recordLLMResponse({
|
||||||
sessionId,
|
sessionId,
|
||||||
iteration: trace.iteration,
|
iteration: trace.iteration,
|
||||||
@@ -190,76 +257,48 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
|
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<string>('llm.provider') ?? '';
|
|
||||||
const model = configService.get<string>('llm.model') ?? '';
|
|
||||||
sessionRecorder.recordLLMRequest({
|
|
||||||
sessionId,
|
|
||||||
iteration: data.iteration,
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
messageCount: data.iteration + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// PARSING → 本轮流式结束,记录 llm_response
|
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
|
||||||
if (stateValue === 'PARSING' && trace && trace.iteration === data.iteration && !trace.responded) {
|
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
|
||||||
trace.responded = true;
|
if (stateValue === 'TERMINATED') {
|
||||||
sessionRecorder.recordLLMResponse({
|
if (trace) {
|
||||||
sessionId,
|
sessionRecorder.recordIterationEnd(sessionId, {
|
||||||
iteration: trace.iteration,
|
iteration: trace.iteration,
|
||||||
content: trace.text,
|
durationMs: Date.now() - trace.startedAt,
|
||||||
finishReason: 'stop',
|
});
|
||||||
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
|
}
|
||||||
});
|
cleanupSessionState(sessionId);
|
||||||
}
|
|
||||||
|
|
||||||
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
|
|
||||||
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
|
|
||||||
if (stateValue === 'TERMINATED') {
|
|
||||||
if (trace) {
|
|
||||||
sessionRecorder.recordIterationEnd(sessionId, {
|
|
||||||
iteration: trace.iteration,
|
|
||||||
durationMs: Date.now() - trace.startedAt,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
cleanupSessionState(sessionId);
|
},
|
||||||
}
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 常驻监听:上下文压缩(toast + streamEvent 通知) =====
|
// ===== 常驻监听:上下文压缩(toast + streamEvent 通知) =====
|
||||||
agentEngineManager.on('compressed', (data: {
|
agentEngineManager.on(
|
||||||
sessionId?: string; iteration?: number; originalTokens?: number; compressedTokens?: number;
|
'compressed',
|
||||||
}) => {
|
(data: {
|
||||||
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
|
sessionId?: string;
|
||||||
// toast 通知用户压缩已发生
|
iteration?: number;
|
||||||
broadcast('toast:show', {
|
originalTokens?: number;
|
||||||
type: 'info',
|
compressedTokens?: number;
|
||||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`,
|
}) => {
|
||||||
});
|
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
|
||||||
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
// toast 通知用户压缩已发生
|
||||||
broadcast('agent:streamEvent', {
|
broadcast('toast:show', {
|
||||||
type: 'compressed',
|
type: 'info',
|
||||||
sessionId: data.sessionId ?? '',
|
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`,
|
||||||
iteration: data.iteration ?? 0,
|
});
|
||||||
originalTokens: data.originalTokens,
|
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
||||||
compressedTokens: data.compressedTokens,
|
broadcast('agent:streamEvent', {
|
||||||
savedTokens,
|
type: 'compressed',
|
||||||
timestamp: Date.now(),
|
sessionId: data.sessionId ?? '',
|
||||||
});
|
iteration: data.iteration ?? 0,
|
||||||
});
|
originalTokens: data.originalTokens,
|
||||||
|
compressedTokens: data.compressedTokens,
|
||||||
|
savedTokens,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ===== 常驻监听:死循环检测(toast 警告) =====
|
// ===== 常驻监听:死循环检测(toast 警告) =====
|
||||||
agentEngineManager.on('deadLoop', (data: { iteration?: number; sessionId?: string }) => {
|
agentEngineManager.on('deadLoop', (data: { iteration?: number; sessionId?: string }) => {
|
||||||
@@ -271,300 +310,378 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 常驻监听:Provider 故障转移(P1,通知前端 + toast) =====
|
// ===== 常驻监听:Provider 故障转移(P1,通知前端 + toast) =====
|
||||||
agentEngineManager.on('providerSwitched', (data: {
|
agentEngineManager.on(
|
||||||
from?: string; to?: string; reason?: string; sessionId?: string;
|
'providerSwitched',
|
||||||
}) => {
|
(data: { from?: string; to?: string; reason?: string; sessionId?: string }) => {
|
||||||
broadcast('agent:providerSwitched', {
|
broadcast('agent:providerSwitched', {
|
||||||
from: data.from,
|
from: data.from,
|
||||||
to: data.to,
|
to: data.to,
|
||||||
reason: data.reason ?? 'failover',
|
reason: data.reason ?? 'failover',
|
||||||
sessionId: data.sessionId ?? '',
|
sessionId: data.sessionId ?? '',
|
||||||
});
|
});
|
||||||
broadcast('toast:show', {
|
broadcast('toast:show', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`,
|
message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`,
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Agent 消息发送 =====
|
// ===== Agent 消息发送 =====
|
||||||
|
|
||||||
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
ipcMain.handle(
|
||||||
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
'agent:sendMessage',
|
||||||
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
|
async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
||||||
const sendErrorEvent = (message: string, sid: string): void => {
|
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
||||||
const errorEvent: MetonaStreamEvent = {
|
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
|
||||||
type: MetonaStreamEventType.ERROR,
|
const sendErrorEvent = (message: string, sid: string): void => {
|
||||||
requestId: '', sessionId: sid, iteration: 0, seq: 0, timestamp: Date.now(),
|
const errorEvent: MetonaStreamEvent = {
|
||||||
error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false },
|
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') {
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||||
sendErrorEvent('无效的会话 ID', sessionId ?? '');
|
sendErrorEvent('无效的会话 ID', sessionId ?? '');
|
||||||
return { success: false, error: 'Invalid 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`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
if (
|
||||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
!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 误以为需要在工作空间查找
|
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||||||
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
|
if (!ctx.reloadAdapter()) {
|
||||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
const errorMsg =
|
||||||
const attachmentList = attachments.map((att, i) => {
|
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||||||
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
log.error('[AGENT]', errorMsg);
|
||||||
const note = att.type === 'image'
|
sendErrorEvent(errorMsg, sessionId);
|
||||||
? '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);
|
|
||||||
sessionRecorder.stopRecording(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' };
|
return { success: false, error: errorMsg };
|
||||||
}
|
|
||||||
if (injectionResult.riskScore >= 4) {
|
|
||||||
log.warn('[PromptInjectionDefender] Suspicious patterns detected:', injectionResult.findings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACE 层:记录上下文构建
|
// TRACE 层:开始录制 / TOOL 层:记录会话开始
|
||||||
sessionRecorder.recordContextBuilt(sessionId, {
|
sessionRecorder.startRecording(sessionId);
|
||||||
tokenCount: estimateMessagesTokens(history),
|
auditService.logSessionStart(sessionId);
|
||||||
usageRatio: 0,
|
|
||||||
|
// 保存用户消息到数据库
|
||||||
|
sessionService.saveMessage({
|
||||||
|
sessionId,
|
||||||
|
role: 'user',
|
||||||
|
content: userMessage.content,
|
||||||
|
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||||||
const engine = agentEngineManager.getEngine(sessionId);
|
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||||||
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
|
|
||||||
|
|
||||||
// 输出验证(不阻塞响应,仅记录警告)
|
// 从工作空间文件构建 System Prompt
|
||||||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
const workspaceFiles = workspaceService.getFiles();
|
||||||
try {
|
const systemPrompt = contextBuilder.buildSystemPrompt(
|
||||||
const toolResults = output.iterations
|
workspaceFiles,
|
||||||
.flatMap((step) => step.toolResults ?? [])
|
workspaceService.getPath(),
|
||||||
.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');
|
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||||||
const validation = await outputValidator.validate(output.finalAnswer, {
|
if (contextBuilder.isUsingFallbackRole()) {
|
||||||
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
broadcast('toast:show', {
|
||||||
context,
|
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 消息到数据库(含思考内容和工具调用)
|
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
||||||
for (const step of output.iterations) {
|
try {
|
||||||
if (!step.thought) continue;
|
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) => {
|
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
|
||||||
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
|
const attachments = (
|
||||||
return {
|
userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }
|
||||||
id: tc.id,
|
).attachments;
|
||||||
name: tc.name,
|
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||||
args: tc.args,
|
const attachmentList = attachments
|
||||||
status: result?.success ? 'success' as const : 'error' as const,
|
.map((att, i) => {
|
||||||
result: result?.result,
|
const typeLabel =
|
||||||
durationMs: result?.durationMs,
|
att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
||||||
error: result?.error,
|
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).`;
|
||||||
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
|
|
||||||
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
|
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||||
const assistantContent = (toolCallsWithResults?.length && !step.thought.content)
|
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
||||||
? null
|
: attachmentBlock;
|
||||||
: step.thought.content;
|
|
||||||
sessionService.saveMessage({
|
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,
|
sessionId,
|
||||||
role: 'assistant',
|
);
|
||||||
content: assistantContent,
|
sessionRecorder.stopRecording(sessionId, {
|
||||||
reasoningContent: step.thought.reasoningContent || undefined,
|
totalIterations: 0,
|
||||||
toolCalls: toolCallsWithResults,
|
totalTokens: 0,
|
||||||
iteration: step.iteration,
|
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 结果消息到数据库
|
// TRACE 层:记录上下文构建
|
||||||
// OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
|
sessionRecorder.recordContextBuilt(sessionId, {
|
||||||
if (step.toolResults) {
|
tokenCount: estimateMessagesTokens(history),
|
||||||
for (const result of step.toolResults) {
|
usageRatio: 0,
|
||||||
const resultContent = typeof result.result === 'string'
|
});
|
||||||
? result.result
|
|
||||||
: JSON.stringify(result.result);
|
// 启动 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({
|
sessionService.saveMessage({
|
||||||
sessionId,
|
sessionId,
|
||||||
role: 'tool',
|
role: 'assistant',
|
||||||
content: result.error ?? resultContent,
|
content: assistantContent,
|
||||||
toolResult: result,
|
reasoningContent: step.thought.reasoningContent || undefined,
|
||||||
|
toolCalls: toolCallsWithResults,
|
||||||
iteration: step.iteration,
|
iteration: step.iteration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 Token 统计
|
// v0.3.0 修复: 保存 tool 结果消息到数据库
|
||||||
if (output.totalTokenUsage.totalTokens > 0) {
|
// OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
|
||||||
sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens);
|
if (step.toolResults) {
|
||||||
}
|
for (const result of step.toolResults) {
|
||||||
|
const resultContent =
|
||||||
// 更新 MEMORY.md 时间戳
|
typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
||||||
workspaceService.updateMemoryTimestamp();
|
sessionService.saveMessage({
|
||||||
|
sessionId,
|
||||||
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
|
role: 'tool',
|
||||||
// 异步执行,不阻塞主流程返回;失败仅记录日志
|
content: result.error ?? resultContent,
|
||||||
memoryConsolidator
|
toolResult: result,
|
||||||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
iteration: step.iteration,
|
||||||
.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);
|
// 更新 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 层:停止录制
|
// P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录)
|
||||||
auditService.logSessionEnd({
|
sessionSummaryService.maybeSummarize(sessionId).catch((err) => {
|
||||||
sessionId,
|
log.warn('[AGENT] Session summary generation failed:', err);
|
||||||
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,失败仅记录)
|
log.info(
|
||||||
sessionSummaryService.maybeSummarize(sessionId).catch((err) => {
|
`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`,
|
||||||
log.warn('[AGENT] Session summary generation failed:', err);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
// TOOL 层:记录错误
|
||||||
} catch (error) {
|
auditService.log({
|
||||||
log.error('[AGENT] Error:', error);
|
sessionId,
|
||||||
|
eventType: 'error',
|
||||||
|
actor: 'agent',
|
||||||
|
target: 'agent_loop',
|
||||||
|
details: { error: (error as Error).message },
|
||||||
|
outcome: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
// TOOL 层:记录错误
|
// TRACE 层:停止录制
|
||||||
auditService.log({
|
sessionRecorder.stopRecording(sessionId, {
|
||||||
sessionId,
|
totalIterations: 0,
|
||||||
eventType: 'error',
|
totalTokens: 0,
|
||||||
actor: 'agent',
|
durationMs: 0,
|
||||||
target: 'agent_loop',
|
terminationReason: 'error',
|
||||||
details: { error: (error as Error).message },
|
});
|
||||||
outcome: 'error',
|
|
||||||
});
|
|
||||||
|
|
||||||
// TRACE 层:停止录制
|
// 发送错误事件到 UI
|
||||||
sessionRecorder.stopRecording(sessionId, {
|
const metonaError: MetonaError = {
|
||||||
totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error',
|
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
|
return { success: false, error: (error as Error).message };
|
||||||
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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 中断会话 =====
|
// ===== 中断会话 =====
|
||||||
|
|
||||||
|
|||||||
+59
-40
@@ -13,46 +13,65 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
|||||||
return mcpManager.getServerStates();
|
return mcpManager.getServerStates();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => {
|
ipcMain.handle(
|
||||||
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
|
'mcp:addServer',
|
||||||
if (!config || typeof config !== 'object') {
|
async (
|
||||||
return { success: false, error: 'Invalid config' };
|
_event,
|
||||||
}
|
config: { name: string; transport: string; command?: string; args?: string[]; url?: string },
|
||||||
if (typeof config.name !== 'string' || !config.name.trim()) {
|
) => {
|
||||||
return { success: false, error: 'Server name is required' };
|
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
|
||||||
}
|
if (!config || typeof config !== 'object') {
|
||||||
// M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言)
|
return { success: false, error: 'Invalid config' };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
try { new URL(config.url); } catch {
|
if (typeof config.name !== 'string' || !config.name.trim()) {
|
||||||
return { success: false, error: 'Invalid url format' };
|
return { success: false, error: 'Server name is required' };
|
||||||
}
|
}
|
||||||
}
|
// M-5 修复: transport 运行时校验(替代 as 断言)
|
||||||
try {
|
// v0.4.1: 新增 'streamable-http' 传输方式
|
||||||
await mcpManager.addServer({
|
if (
|
||||||
name: config.name,
|
config.transport !== 'stdio' &&
|
||||||
transport: config.transport, // 已校验,无需断言
|
config.transport !== 'sse' &&
|
||||||
command: config.command,
|
config.transport !== 'streamable-http'
|
||||||
args: config.args,
|
) {
|
||||||
url: config.url,
|
return {
|
||||||
enabled: true,
|
success: false,
|
||||||
});
|
error: `Invalid transport: ${config.transport}. Must be 'stdio', 'sse', or 'streamable-http'`,
|
||||||
log.info(`MCP server added: ${config.name}`);
|
};
|
||||||
return { success: true };
|
}
|
||||||
} catch (error) {
|
// stdio 类型必须有 command
|
||||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
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) => {
|
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
|
||||||
// M-38 修复: name 校验
|
// M-38 修复: name 校验
|
||||||
@@ -63,7 +82,7 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
|||||||
await mcpManager.removeServer(name);
|
await mcpManager.removeServer(name);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} 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);
|
await mcpManager.toggleServer(name, enabled);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-4
@@ -57,7 +57,12 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
|||||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
|
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
|
||||||
return;
|
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) {
|
if (typeof req.toolCallId !== 'string' || !req.toolCallId) {
|
||||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
|
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
|
||||||
return;
|
return;
|
||||||
@@ -69,7 +74,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
|||||||
const remember = typeof req.remember === 'boolean' ? req.remember : false;
|
const remember = typeof req.remember === 'boolean' ? req.remember : false;
|
||||||
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
|
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
|
||||||
confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute);
|
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: 批量工具确认响应(并行工具调用一次性审批) =====
|
// ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批) =====
|
||||||
@@ -86,7 +93,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
|||||||
};
|
};
|
||||||
// 严格校验 toolCallIds 数组
|
// 严格校验 toolCallIds 数组
|
||||||
if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// 每个元素必须是字符串
|
// 每个元素必须是字符串
|
||||||
@@ -108,7 +117,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
|||||||
remember,
|
remember,
|
||||||
autoExecute,
|
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 确认 =====
|
// ===== v0.3.2: 拉取当前所有 pending 确认 =====
|
||||||
@@ -116,6 +127,22 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
|||||||
return { success: true, data: confirmationHook.getPendingConfirmations() };
|
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: 持久化自动执行设置 =====
|
// ===== v0.2.0: 持久化自动执行设置 =====
|
||||||
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
|
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
|
||||||
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
|
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
|
||||||
|
|||||||
+65
-20
@@ -15,7 +15,8 @@ const metonaAPI = {
|
|||||||
// ===== Agent 交互 =====
|
// ===== Agent 交互 =====
|
||||||
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) => {
|
onStreamEvent: (callback: (event: unknown) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
|
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
|
||||||
@@ -42,17 +43,21 @@ const metonaAPI = {
|
|||||||
sessions: {
|
sessions: {
|
||||||
list: () => ipcRenderer.invoke('sessions:list'),
|
list: () => ipcRenderer.invoke('sessions:list'),
|
||||||
create: (title?: string) => ipcRenderer.invoke('sessions:create', title),
|
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),
|
delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId),
|
||||||
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId),
|
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId),
|
||||||
pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned),
|
pin: (sessionId: string, pinned: boolean) =>
|
||||||
archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived),
|
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),
|
deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId),
|
||||||
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
|
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
|
||||||
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
|
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
|
||||||
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
|
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
|
||||||
ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive),
|
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),
|
getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -61,12 +66,14 @@ const metonaAPI = {
|
|||||||
listServers: () => ipcRenderer.invoke('mcp:listServers'),
|
listServers: () => ipcRenderer.invoke('mcp:listServers'),
|
||||||
addServer: (config: unknown) => ipcRenderer.invoke('mcp:addServer', config),
|
addServer: (config: unknown) => ipcRenderer.invoke('mcp:addServer', config),
|
||||||
removeServer: (name: string) => ipcRenderer.invoke('mcp:removeServer', name),
|
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: {
|
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 }) =>
|
listAll: (options?: { type?: string; limit?: number }) =>
|
||||||
ipcRenderer.invoke('memory:listAll', options),
|
ipcRenderer.invoke('memory:listAll', options),
|
||||||
delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id),
|
delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id),
|
||||||
@@ -75,10 +82,24 @@ const metonaAPI = {
|
|||||||
// ===== v0.2.0: 任务管理 =====
|
// ===== v0.2.0: 任务管理 =====
|
||||||
tasks: {
|
tasks: {
|
||||||
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
|
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
|
||||||
create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) =>
|
create: (data: {
|
||||||
ipcRenderer.invoke('tasks:create', data),
|
sessionId: string;
|
||||||
update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }, sessionId: string) =>
|
title: string;
|
||||||
ipcRenderer.invoke('tasks:update', id, updates, sessionId),
|
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),
|
delete: (id: string, sessionId: string) => ipcRenderer.invoke('tasks:delete', id, sessionId),
|
||||||
// P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 写入后触发)
|
// P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 写入后触发)
|
||||||
onTaskChanged: (callback: (sessionId: string) => void) => {
|
onTaskChanged: (callback: (sessionId: string) => void) => {
|
||||||
@@ -102,11 +123,19 @@ const metonaAPI = {
|
|||||||
ipcRenderer.on('tool:confirmationRequest', listener);
|
ipcRenderer.on('tool:confirmationRequest', listener);
|
||||||
return () => ipcRenderer.removeListener('tool:confirmationRequest', listener);
|
return () => ipcRenderer.removeListener('tool:confirmationRequest', listener);
|
||||||
},
|
},
|
||||||
sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
|
sendConfirmationResponse: (response: {
|
||||||
ipcRenderer.send('tool:confirmationResponse', response),
|
toolCallId: string;
|
||||||
|
approved: boolean;
|
||||||
|
remember: boolean;
|
||||||
|
autoExecute?: boolean;
|
||||||
|
}) => ipcRenderer.send('tool:confirmationResponse', response),
|
||||||
// v0.3.2: 批量确认响应(并行工具一次性审批)
|
// v0.3.2: 批量确认响应(并行工具一次性审批)
|
||||||
sendConfirmationResponseBatch: (response: { toolCallIds: string[]; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
|
sendConfirmationResponseBatch: (response: {
|
||||||
ipcRenderer.send('tool:confirmationResponseBatch', response),
|
toolCallIds: string[];
|
||||||
|
approved: boolean;
|
||||||
|
remember: boolean;
|
||||||
|
autoExecute?: boolean;
|
||||||
|
}) => ipcRenderer.send('tool:confirmationResponseBatch', response),
|
||||||
// v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失)
|
// v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失)
|
||||||
getPendingConfirmations: () =>
|
getPendingConfirmations: () =>
|
||||||
ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{
|
ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{
|
||||||
@@ -120,6 +149,17 @@ const metonaAPI = {
|
|||||||
expiresAt?: number;
|
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: 持久化自动执行设置
|
// v0.2.0: 持久化自动执行设置
|
||||||
setAutoExecute: (toolName: string, enabled: boolean) =>
|
setAutoExecute: (toolName: string, enabled: boolean) =>
|
||||||
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
|
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
|
||||||
@@ -169,15 +209,18 @@ const metonaAPI = {
|
|||||||
// ===== 工具管理 =====
|
// ===== 工具管理 =====
|
||||||
tools: {
|
tools: {
|
||||||
list: () => ipcRenderer.invoke('tools:list'),
|
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 初始化完成后触发) */
|
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
|
||||||
onReady: (callback: (data: { toolCount: number }) => void) => {
|
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);
|
ipcRenderer.on('tools:ready', listener);
|
||||||
return () => ipcRenderer.removeListener('tools:ready', listener);
|
return () => ipcRenderer.removeListener('tools:ready', listener);
|
||||||
},
|
},
|
||||||
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */
|
/** 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 通知桥接 =====
|
||||||
toast: {
|
toast: {
|
||||||
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
|
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: { type: string; message: string; options?: unknown }) =>
|
const listener = (
|
||||||
callback(data);
|
_event: Electron.IpcRendererEvent,
|
||||||
|
data: { type: string; message: string; options?: unknown },
|
||||||
|
) => callback(data);
|
||||||
ipcRenderer.on('toast:show', listener);
|
ipcRenderer.on('toast:show', listener);
|
||||||
return () => ipcRenderer.removeListener('toast:show', listener);
|
return () => ipcRenderer.removeListener('toast:show', listener);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -365,12 +365,64 @@ export class DatabaseService {
|
|||||||
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
|
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
|
||||||
tryAddColumn('working_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
|
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
|
||||||
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
||||||
// SQLite 不支持 ALTER COLUMN,需要重建表
|
// SQLite 不支持 ALTER COLUMN,需要重建表
|
||||||
try {
|
try {
|
||||||
// 检测 content 列是否有 NOT NULL 约束
|
// 检测 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');
|
const contentCol = columns.find((c) => c.name === 'content');
|
||||||
if (contentCol && contentCol.notnull === 1) {
|
if (contentCol && contentCol.notnull === 1) {
|
||||||
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
|
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.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 type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
@@ -41,9 +43,15 @@ function safeParseArgs(raw: string): string[] {
|
|||||||
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
|
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
|
||||||
*/
|
*/
|
||||||
const ALLOWED_MCP_COMMANDS = new Set([
|
const ALLOWED_MCP_COMMANDS = new Set([
|
||||||
'npx', 'node', 'npm',
|
'npx',
|
||||||
'python', 'python3', 'uv', 'uvx',
|
'node',
|
||||||
'bun', 'deno',
|
'npm',
|
||||||
|
'python',
|
||||||
|
'python3',
|
||||||
|
'uv',
|
||||||
|
'uvx',
|
||||||
|
'bun',
|
||||||
|
'deno',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,12 +66,16 @@ const ALLOWED_MCP_COMMANDS = new Set([
|
|||||||
*/
|
*/
|
||||||
function validateMcpCommand(command: string, args: string[]): void {
|
function validateMcpCommand(command: string, args: string[]): void {
|
||||||
// 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径)
|
// 提取命令 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)) {
|
if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` +
|
`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<string, string> {
|
function buildSafeEnv(): Record<string, string> {
|
||||||
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
|
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
|
||||||
const SENSITIVE_SUFFIXES = [
|
const SENSITIVE_SUFFIXES = [
|
||||||
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
|
'_API_KEY',
|
||||||
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
|
'_TOKEN',
|
||||||
|
'_SECRET',
|
||||||
|
'_PASSWORD',
|
||||||
|
'_PASSWD',
|
||||||
|
'_CREDENTIAL',
|
||||||
|
'_CREDENTIALS',
|
||||||
|
'_PRIVATE_KEY',
|
||||||
];
|
];
|
||||||
// 敏感变量名黑名单(精确匹配)
|
// 敏感变量名黑名单(精确匹配)
|
||||||
const SENSITIVE_KEYS = new Set([
|
const SENSITIVE_KEYS = new Set([
|
||||||
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
|
'DEEPSEEK_API_KEY',
|
||||||
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
|
'AGNES_API_KEY',
|
||||||
|
'MIMO_API_KEY',
|
||||||
|
'GITEA_PASSWORD',
|
||||||
|
'DATABASE_PASSWORD',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
@@ -120,7 +141,8 @@ export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'err
|
|||||||
export interface MCPServerConfig {
|
export interface MCPServerConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
transport: 'stdio' | 'sse';
|
/** v0.4.1: 新增 'streamable-http'(MCP 当前主流远程传输);'sse' 保留向后兼容 */
|
||||||
|
transport: 'stdio' | 'sse' | 'streamable-http';
|
||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -172,7 +194,10 @@ class MCPToolAdapter implements IMetonaTool {
|
|||||||
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
||||||
*/
|
*/
|
||||||
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
||||||
const properties: Record<string, { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }> = {};
|
const properties: Record<
|
||||||
|
string,
|
||||||
|
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
|
||||||
|
> = {};
|
||||||
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(schemaProps)) {
|
for (const [key, prop] of Object.entries(schemaProps)) {
|
||||||
@@ -212,11 +237,19 @@ export class MCPManager {
|
|||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
const rows = db.prepare(`
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
SELECT * FROM mcp_servers WHERE enabled = 1
|
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<unknown> =>
|
const connectWithTimeout = (config: MCPServerConfig): Promise<unknown> =>
|
||||||
@@ -230,7 +263,8 @@ export class MCPManager {
|
|||||||
const config: MCPServerConfig = {
|
const config: MCPServerConfig = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
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,
|
command: row.command ?? undefined,
|
||||||
args: row.args ? safeParseArgs(row.args) : undefined,
|
args: row.args ? safeParseArgs(row.args) : undefined,
|
||||||
url: row.url ?? undefined,
|
url: row.url ?? undefined,
|
||||||
@@ -279,12 +313,15 @@ export class MCPManager {
|
|||||||
env: buildSafeEnv(),
|
env: buildSafeEnv(),
|
||||||
});
|
});
|
||||||
} else if (config.transport === 'sse' && config.url) {
|
} else if (config.transport === 'sse' && config.url) {
|
||||||
// SSE 模式(远程 HTTP)
|
// SSE 模式(远程 HTTP,旧式传输,保留向后兼容)
|
||||||
transport = new SSEClientTransport(new URL(config.url));
|
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 {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported transport "${config.transport}". ` +
|
`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();
|
const db = this.getDB();
|
||||||
db.prepare(`
|
db.prepare(
|
||||||
|
`
|
||||||
UPDATE mcp_servers SET last_connected = ?, error_message = NULL WHERE name = ?
|
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)`);
|
log.info(`MCP server "${name}" connected: ${tools.length} tool(s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -329,9 +368,11 @@ export class MCPManager {
|
|||||||
|
|
||||||
// 更新数据库
|
// 更新数据库
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
db.prepare(`
|
db.prepare(
|
||||||
|
`
|
||||||
UPDATE mcp_servers SET error_message = ? WHERE name = ?
|
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);
|
log.error(`MCP server "${name}" connection failed:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -369,19 +410,28 @@ export class MCPManager {
|
|||||||
*/
|
*/
|
||||||
async toggleServer(name: string, enabled: boolean): Promise<void> {
|
async toggleServer(name: string, enabled: boolean): Promise<void> {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
db.prepare(`
|
db.prepare(
|
||||||
|
`
|
||||||
UPDATE mcp_servers SET enabled = ?, updated_at = ? WHERE name = ?
|
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) {
|
if (enabled) {
|
||||||
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as {
|
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;
|
id: string;
|
||||||
} | undefined;
|
name: string;
|
||||||
|
transport: string;
|
||||||
|
command: string | null;
|
||||||
|
args: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
if (row) {
|
if (row) {
|
||||||
await this.connectServer({
|
await this.connectServer({
|
||||||
id: row.id, name: row.name,
|
id: row.id,
|
||||||
transport: row.transport as 'stdio' | 'sse',
|
name: row.name,
|
||||||
|
transport: row.transport as MCPServerConfig['transport'],
|
||||||
command: row.command ?? undefined,
|
command: row.command ?? undefined,
|
||||||
args: row.args ? safeParseArgs(row.args) : undefined,
|
args: row.args ? safeParseArgs(row.args) : undefined,
|
||||||
url: row.url ?? undefined,
|
url: row.url ?? undefined,
|
||||||
@@ -395,16 +445,30 @@ export class MCPManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加新的 MCP Server
|
* 添加新的 MCP Server
|
||||||
|
*
|
||||||
|
* v0.4.1: 校验 transport 与对应字段匹配(stdio→command,sse/streamable-http→url)
|
||||||
*/
|
*/
|
||||||
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
|
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
const id = `mcp_${nanoid(8)}`;
|
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)
|
INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||||
`).run(
|
`,
|
||||||
id, config.name, config.transport,
|
).run(
|
||||||
|
id,
|
||||||
|
config.name,
|
||||||
|
config.transport,
|
||||||
config.command ?? null,
|
config.command ?? null,
|
||||||
config.args ? JSON.stringify(config.args) : null,
|
config.args ? JSON.stringify(config.args) : null,
|
||||||
config.url ?? null,
|
config.url ?? null,
|
||||||
|
|||||||
Generated
+233
-80
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"@mui/icons-material": "^9.1.1",
|
"@mui/icons-material": "^9.1.1",
|
||||||
"@mui/material": "^9.1.2",
|
"@mui/material": "^9.1.2",
|
||||||
"@types/shell-quote": "^1.7.5",
|
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
@@ -24,14 +23,15 @@
|
|||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"lru-cache": "^11.1.0",
|
"lru-cache": "^11.1.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"node-html-parser": "^6.1.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-virtuoso": "^4.18.12",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shell-quote": "^1.10.0",
|
"shell-quote": "^1.10.0",
|
||||||
"sql.js": "^1.12.0",
|
|
||||||
"zod": "^3.25.67",
|
"zod": "^3.25.67",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@@ -39,13 +39,13 @@
|
|||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
"@playwright/test": "^1.52.0",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
@@ -53,6 +53,8 @@
|
|||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"electron-vite": "^3.1.0",
|
"electron-vite": "^3.1.0",
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^9.28.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.3.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"postcss": "^8.5.4",
|
"postcss": "^8.5.4",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
@@ -2225,22 +2227,6 @@
|
|||||||
"node": ">=14.18.0"
|
"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": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -3202,6 +3188,7 @@
|
|||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/shell-quote/-/shell-quote-1.7.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/shell-quote/-/shell-quote-1.7.5.tgz",
|
||||||
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
|
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
@@ -4201,6 +4188,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/boolean": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
|
||||||
@@ -4911,6 +4904,34 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -5247,6 +5268,73 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"node_modules/dot-prop": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-9.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-9.0.0.tgz",
|
||||||
@@ -6500,9 +6588,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -7017,6 +7105,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/highlight.js": {
|
||||||
"version": "11.11.1",
|
"version": "11.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||||
@@ -7172,6 +7269,22 @@
|
|||||||
"node": ">= 14"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -7885,6 +7998,40 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
|
"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": "^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": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -9230,6 +9387,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -9519,9 +9688,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9571,38 +9740,6 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz",
|
||||||
@@ -10087,6 +10224,16 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"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": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"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",
|
"license": "BSD-3-Clause",
|
||||||
"optional": true
|
"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": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -10829,6 +10970,16 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -11787,21 +11938,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/vitest": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.6.tgz",
|
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.6.tgz",
|
||||||
@@ -12001,6 +12137,23 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.3",
|
"version": "17.7.3",
|
||||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.3.tgz",
|
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.3.tgz",
|
||||||
|
|||||||
+21
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run prebuild && electron-vite build && electron-builder",
|
"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:renderer": "electron-vite build --rendererOnly",
|
||||||
"build:electron": "tsc -p tsconfig.node.json",
|
"build:electron": "tsc -p tsconfig.node.json",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -21,7 +21,20 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:electron": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs run",
|
"test:electron": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs run",
|
||||||
"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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -30,7 +43,6 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"@mui/icons-material": "^9.1.1",
|
"@mui/icons-material": "^9.1.1",
|
||||||
"@mui/material": "^9.1.2",
|
"@mui/material": "^9.1.2",
|
||||||
"@types/shell-quote": "^1.7.5",
|
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
@@ -39,14 +51,15 @@
|
|||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"lru-cache": "^11.1.0",
|
"lru-cache": "^11.1.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"node-html-parser": "^6.1.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-virtuoso": "^4.18.12",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shell-quote": "^1.10.0",
|
"shell-quote": "^1.10.0",
|
||||||
"sql.js": "^1.12.0",
|
|
||||||
"zod": "^3.25.67",
|
"zod": "^3.25.67",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@@ -54,13 +67,13 @@
|
|||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
"@playwright/test": "^1.52.0",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
@@ -68,6 +81,8 @@
|
|||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"electron-vite": "^3.1.0",
|
"electron-vite": "^3.1.0",
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^9.28.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.3.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"postcss": "^8.5.4",
|
"postcss": "^8.5.4",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
|||||||
@@ -18,11 +18,28 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
Dialog,
|
||||||
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
|
DialogTitle,
|
||||||
Accordion, AccordionSummary, AccordionDetails,
|
DialogContent,
|
||||||
LinearProgress, List, ListItem, ListItemIcon, ListItemText,
|
DialogActions,
|
||||||
IconButton, Tooltip, Divider,
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Alert,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
LinearProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
|
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
@@ -64,36 +81,65 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
const [initialMs, setInitialMs] = useState<number>(0);
|
const [initialMs, setInitialMs] = useState<number>(0);
|
||||||
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
||||||
const [confirmAutoExecute, setConfirmAutoExecute] = useState(false);
|
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 请求 =====
|
// ===== 弹框打开时主动拉取已积压的 pending 请求 =====
|
||||||
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
|
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
|
||||||
// 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。
|
// 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。
|
||||||
const refreshPending = useCallback(async (mergeNew?: ConfirmationRequest) => {
|
const refreshPending = useCallback(
|
||||||
try {
|
async (mergeNew?: ConfirmationRequest) => {
|
||||||
const result = await window.metona?.tool?.getPendingConfirmations();
|
try {
|
||||||
const pendingList: ConfirmationRequest[] = result?.success ? result.data : [];
|
const result = await window.metona?.tool?.getPendingConfirmations();
|
||||||
// 合并新到的 IPC 请求(若 pending 快照已包含则去重)
|
const pendingList: ConfirmationRequest[] = result?.success ? result.data : [];
|
||||||
const merged = [...pendingList];
|
// 合并新到的 IPC 请求(若 pending 快照已包含则去重)
|
||||||
if (mergeNew) {
|
const merged = [...pendingList];
|
||||||
const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId);
|
if (mergeNew) {
|
||||||
if (!exists) merged.push(mergeNew);
|
const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId);
|
||||||
}
|
if (!exists) merged.push(mergeNew);
|
||||||
// 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加)
|
}
|
||||||
const dedupedMap = new Map<string, ConfirmationRequest>();
|
// 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加)
|
||||||
for (const r of merged) dedupedMap.set(r.toolCallId, r);
|
const dedupedMap = new Map<string, ConfirmationRequest>();
|
||||||
const deduped = Array.from(dedupedMap.values());
|
for (const r of merged) dedupedMap.set(r.toolCallId, r);
|
||||||
|
const deduped = Array.from(dedupedMap.values());
|
||||||
|
|
||||||
setRequests(deduped);
|
setRequests(deduped);
|
||||||
// 默认全选
|
// 默认全选
|
||||||
setSelectedIds(new Set(deduped.map((r) => r.toolCallId)));
|
setSelectedIds(new Set(deduped.map((r) => r.toolCallId)));
|
||||||
} catch {
|
} catch {
|
||||||
// 拉取失败时回退到只显示新到的请求
|
// 拉取失败时回退到只显示新到的请求
|
||||||
if (mergeNew) {
|
if (mergeNew) {
|
||||||
setRequests([mergeNew]);
|
setRequests([mergeNew]);
|
||||||
setSelectedIds(new Set([mergeNew.toolCallId]));
|
setSelectedIds(new Set([mergeNew.toolCallId]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// v0.4.1: 弹框打开时同步拉取被拒工具列表(展示恢复询问入口)
|
||||||
}, []);
|
void refreshRememberedDenials();
|
||||||
|
},
|
||||||
|
[refreshRememberedDenials],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API)
|
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API)
|
||||||
@@ -207,44 +253,45 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRespond = useCallback((approved: boolean, onlySelected: boolean = true) => {
|
const handleRespond = useCallback(
|
||||||
// 决定要处理的 toolCallId 列表
|
(approved: boolean, onlySelected: boolean = true) => {
|
||||||
// onlySelected=true: 仅处理勾选项(用于"批准选中")
|
// 决定要处理的 toolCallId 列表
|
||||||
// onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部")
|
// onlySelected=true: 仅处理勾选项(用于"批准选中")
|
||||||
const targetIds = onlySelected
|
// onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部")
|
||||||
? Array.from(selectedIds)
|
const targetIds = onlySelected ? Array.from(selectedIds) : requests.map((r) => r.toolCallId);
|
||||||
: requests.map((r) => r.toolCallId);
|
|
||||||
|
|
||||||
if (targetIds.length === 0) return;
|
if (targetIds.length === 0) return;
|
||||||
|
|
||||||
// 通过批量 IPC 通道发送响应
|
// 通过批量 IPC 通道发送响应
|
||||||
// autoExecute 仅在批准时生效(拒绝时无需持久化)
|
// autoExecute 仅在批准时生效(拒绝时无需持久化)
|
||||||
window.metona?.tool?.sendConfirmationResponseBatch({
|
window.metona?.tool?.sendConfirmationResponseBatch({
|
||||||
toolCallIds: targetIds,
|
toolCallIds: targetIds,
|
||||||
approved,
|
approved,
|
||||||
remember,
|
remember,
|
||||||
autoExecute: approved && autoExecute,
|
autoExecute: approved && autoExecute,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onlySelected) {
|
if (onlySelected) {
|
||||||
// "批准选中":只移除已处理的,保留未选中的 pending
|
// "批准选中":只移除已处理的,保留未选中的 pending
|
||||||
// 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败)
|
// 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败)
|
||||||
const targetSet = new Set(targetIds);
|
const targetSet = new Set(targetIds);
|
||||||
const remaining = requests.filter((r) => !targetSet.has(r.toolCallId));
|
const remaining = requests.filter((r) => !targetSet.has(r.toolCallId));
|
||||||
setRequests(remaining);
|
setRequests(remaining);
|
||||||
// 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中)
|
// 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中)
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
// 主动刷新后端 pending 列表,拉取可能新到达的请求
|
// 主动刷新后端 pending 列表,拉取可能新到达的请求
|
||||||
// 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应
|
// 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应
|
||||||
if (remaining.length === 0) {
|
if (remaining.length === 0) {
|
||||||
setTimeout(() => refreshPending(), 50);
|
setTimeout(() => refreshPending(), 50);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// "拒绝全部 / 批准全部":清空所有
|
||||||
|
setRequests([]);
|
||||||
|
setSelectedIds(new Set());
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
// "拒绝全部 / 批准全部":清空所有
|
[requests, selectedIds, remember, autoExecute, refreshPending],
|
||||||
setRequests([]);
|
);
|
||||||
setSelectedIds(new Set());
|
|
||||||
}
|
|
||||||
}, [requests, selectedIds, remember, autoExecute, refreshPending]);
|
|
||||||
|
|
||||||
if (requests.length === 0) return null;
|
if (requests.length === 0) return null;
|
||||||
|
|
||||||
@@ -269,9 +316,8 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
const isUrgent = remainingSec <= 10 && remainingSec > 0;
|
const isUrgent = remainingSec <= 10 && remainingSec > 0;
|
||||||
const isExpired = remainingMs <= 0 && earliestExpires != null;
|
const isExpired = remainingMs <= 0 && earliestExpires != null;
|
||||||
const totalMs = initialMs || remainingMs;
|
const totalMs = initialMs || remainingMs;
|
||||||
const progressPercent = totalMs > 0
|
const progressPercent =
|
||||||
? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100))
|
totalMs > 0 ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) : 100;
|
||||||
: 100;
|
|
||||||
|
|
||||||
// 格式化参数显示
|
// 格式化参数显示
|
||||||
const formatArg = (key: string, value: unknown): string => {
|
const formatArg = (key: string, value: unknown): string => {
|
||||||
@@ -288,303 +334,340 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 综合风险文案
|
// 综合风险文案
|
||||||
const batchReason = totalCount > 1
|
const batchReason =
|
||||||
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
|
totalCount > 1
|
||||||
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
|
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
|
||||||
|
: (requests[0]?.reason ?? 'Agent 正在请求执行工具');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={requests.length > 0}
|
open={requests.length > 0}
|
||||||
onClose={(_, reason) => {
|
onClose={(_, reason) => {
|
||||||
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
|
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
|
||||||
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
|
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
|
||||||
if (isExpired) return;
|
if (isExpired) return;
|
||||||
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
|
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
|
||||||
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
|
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
|
||||||
if (confirmAutoExecute) return;
|
if (confirmAutoExecute) return;
|
||||||
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
|
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
|
||||||
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
|
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
|
||||||
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
|
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
|
||||||
handleRespond(false, false);
|
handleRespond(false, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
slotProps={{
|
||||||
paper: {
|
paper: {
|
||||||
sx: { bgcolor: 'background.paper' },
|
sx: { bgcolor: 'background.paper' },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
|
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
|
||||||
<Typography variant="h6" component="span">
|
<Typography variant="h6" component="span">
|
||||||
工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`}
|
工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`}
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flex: 1 }} />
|
|
||||||
<Tooltip title="刷新 pending 列表">
|
|
||||||
<IconButton size="small" onClick={() => refreshPending()}>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
|
|
||||||
{batchReason}
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{earliestExpires && !isExpired && (
|
|
||||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Timer
|
|
||||||
size={16}
|
|
||||||
color={isUrgent ? 'var(--mui-palette-error-main)' : 'var(--mui-palette-text-secondary)'}
|
|
||||||
style={isUrgent ? { animation: 'metona-pulse 1s ease-in-out infinite' } : undefined}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: isUrgent ? 'error.main' : 'text.secondary',
|
|
||||||
fontWeight: isUrgent ? 700 : 500,
|
|
||||||
minWidth: 80,
|
|
||||||
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
|
|
||||||
'@keyframes metona-pulse': {
|
|
||||||
'0%, 100%': { opacity: 1 },
|
|
||||||
'50%': { opacity: 0.4 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s
|
|
||||||
</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={progressPercent}
|
|
||||||
color={isUrgent ? 'error' : 'primary'}
|
|
||||||
sx={{ flex: 1, height: 6, borderRadius: 3 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 选择控制条 */}
|
|
||||||
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Button size="small" onClick={handleSelectAll} disabled={allSelected}>
|
|
||||||
全选
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
|
|
||||||
全不选
|
|
||||||
</Button>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
|
||||||
已选 {selectedCount} / {totalCount}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<Tooltip title="刷新 pending 列表">
|
||||||
|
<IconButton size="small" onClick={() => refreshPending()}>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Divider sx={{ mb: 1 }} />
|
<DialogContent>
|
||||||
|
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
|
||||||
|
{batchReason}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
{/* 分组列表 */}
|
{/* v0.4.1: 本会话内被记住拒绝的工具 — 提供恢复询问入口(拒绝记忆 10 分钟后自动过期) */}
|
||||||
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
|
{rememberedDenials.length > 0 && (
|
||||||
{grouped.map((group) => {
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
const groupIds = group.requests.map((r) => r.toolCallId);
|
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||||
const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
|
本会话中已记住拒绝的工具({Math.ceil(rememberedDenials[0].expiresInSeconds / 60)}{' '}
|
||||||
const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
|
分钟后自动恢复询问):
|
||||||
const isMulti = group.requests.length > 1;
|
</Typography>
|
||||||
|
{rememberedDenials.map((d) => (
|
||||||
|
<Chip
|
||||||
|
key={d.toolName}
|
||||||
|
label={`${d.toolName} — 重新询问`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleResetDenial(d.toolName)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{earliestExpires && !isExpired && (
|
||||||
<Accordion
|
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
key={group.toolName}
|
<Timer
|
||||||
defaultExpanded
|
size={16}
|
||||||
sx={{ bgcolor: 'background.default', mb: 0.5 }}
|
color={
|
||||||
|
isUrgent ? 'var(--mui-palette-error-main)' : 'var(--mui-palette-text-secondary)'
|
||||||
|
}
|
||||||
|
style={isUrgent ? { animation: 'metona-pulse 1s ease-in-out infinite' } : undefined}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: isUrgent ? 'error.main' : 'text.secondary',
|
||||||
|
fontWeight: isUrgent ? 700 : 500,
|
||||||
|
minWidth: 80,
|
||||||
|
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
|
||||||
|
'@keyframes metona-pulse': {
|
||||||
|
'0%, 100%': { opacity: 1 },
|
||||||
|
'50%': { opacity: 0.4 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
{totalCount > 1 ? '最早过期 ' : '剩余 '}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
|
{remainingSec}s
|
||||||
<Checkbox
|
</Typography>
|
||||||
size="small"
|
<LinearProgress
|
||||||
checked={groupAllSelected}
|
variant="determinate"
|
||||||
indeterminate={!groupAllSelected && groupSomeSelected}
|
value={progressPercent}
|
||||||
onChange={(e) => {
|
color={isUrgent ? 'error' : 'primary'}
|
||||||
e.stopPropagation();
|
sx={{ flex: 1, height: 6, borderRadius: 3 }}
|
||||||
handleToggleGroupSelect(group);
|
/>
|
||||||
}}
|
</Box>
|
||||||
onClick={(e) => e.stopPropagation()}
|
)}
|
||||||
/>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
{/* 选择控制条 */}
|
||||||
{group.toolName}
|
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
</Typography>
|
<Button size="small" onClick={handleSelectAll} disabled={allSelected}>
|
||||||
{isMulti && (
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
|
||||||
|
全不选
|
||||||
|
</Button>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||||
|
已选 {selectedCount} / {totalCount}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 1 }} />
|
||||||
|
|
||||||
|
{/* 分组列表 */}
|
||||||
|
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
|
||||||
|
{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 (
|
||||||
|
<Accordion
|
||||||
|
key={group.toolName}
|
||||||
|
defaultExpanded
|
||||||
|
sx={{ bgcolor: 'background.default', mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={groupAllSelected}
|
||||||
|
indeterminate={!groupAllSelected && groupSomeSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleGroupSelect(group);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{group.toolName}
|
||||||
|
</Typography>
|
||||||
|
{isMulti && (
|
||||||
|
<Chip
|
||||||
|
label={`×${group.requests.length}`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
label={`×${group.requests.length}`}
|
label={group.riskLevel}
|
||||||
|
color={groupRiskColor}
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ height: 20, fontSize: 11 }}
|
sx={{ height: 20, fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
)}
|
</Box>
|
||||||
<Chip
|
</AccordionSummary>
|
||||||
label={group.riskLevel}
|
<AccordionDetails sx={{ pt: 0 }}>
|
||||||
color={groupRiskColor}
|
{group.requests.map((req, idx) => {
|
||||||
size="small"
|
const isSelected = selectedIds.has(req.toolCallId);
|
||||||
sx={{ height: 20, fontSize: 11 }}
|
return (
|
||||||
/>
|
<ListItem
|
||||||
</Box>
|
key={req.toolCallId}
|
||||||
</AccordionSummary>
|
sx={{
|
||||||
<AccordionDetails sx={{ pt: 0 }}>
|
py: 0.5,
|
||||||
{group.requests.map((req, idx) => {
|
bgcolor: isSelected ? 'action.selected' : 'transparent',
|
||||||
const isSelected = selectedIds.has(req.toolCallId);
|
borderRadius: 1,
|
||||||
return (
|
}}
|
||||||
<ListItem
|
secondaryAction={
|
||||||
key={req.toolCallId}
|
isMulti ? (
|
||||||
sx={{
|
<Typography variant="caption" color="text.secondary">
|
||||||
py: 0.5,
|
#{idx + 1}
|
||||||
bgcolor: isSelected ? 'action.selected' : 'transparent',
|
</Typography>
|
||||||
borderRadius: 1,
|
) : undefined
|
||||||
}}
|
|
||||||
secondaryAction={
|
|
||||||
isMulti ? (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
#{idx + 1}
|
|
||||||
</Typography>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => handleToggleSelect(req.toolCallId)}
|
|
||||||
/>
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box
|
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'text.primary',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
margin: 0,
|
|
||||||
maxHeight: 200,
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(req.args).map(([key, value]) => (
|
|
||||||
<Box key={key} component="div" sx={{ mb: 0.5 }}>
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'info.main', fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
{key}:
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.primary', ml: 1 }}
|
|
||||||
>
|
|
||||||
{formatArg(key, value)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{Object.keys(req.args).length === 0 && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
(无参数)
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
</ListItem>
|
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||||
);
|
<Checkbox
|
||||||
})}
|
size="small"
|
||||||
</AccordionDetails>
|
checked={isSelected}
|
||||||
</Accordion>
|
onChange={() => handleToggleSelect(req.toolCallId)}
|
||||||
);
|
/>
|
||||||
})}
|
</ListItemIcon>
|
||||||
</List>
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'text.primary',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(req.args).map(([key, value]) => (
|
||||||
|
<Box key={key} component="div" sx={{ mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'info.main', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{key}:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'text.primary', ml: 1 }}
|
||||||
|
>
|
||||||
|
{formatArg(key, value)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{Object.keys(req.args).length === 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
(无参数)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
|
||||||
<Divider sx={{ my: 1 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
在本次会话中记住此决定(同工具不再询问)
|
在本次会话中记住此决定(同工具不再询问)
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={autoExecute}
|
checked={autoExecute}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setConfirmAutoExecute(true);
|
setConfirmAutoExecute(true);
|
||||||
} else {
|
} else {
|
||||||
setAutoExecute(false);
|
setAutoExecute(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
永久自动执行选中工具(跨会话不再询问,可在设置中关闭)
|
永久自动执行选中工具(跨会话不再询问,可在设置中关闭)
|
||||||
{selectedCount > 0 && autoExecute && (
|
{selectedCount > 0 && autoExecute && (
|
||||||
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
|
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
|
||||||
· 将应用到 {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size} 个工具
|
· 将应用到{' '}
|
||||||
</Box>
|
{
|
||||||
)}
|
new Set(
|
||||||
</Typography>
|
Array.from(selectedIds)
|
||||||
}
|
.map((id) => requests.find((r) => r.toolCallId === id)?.toolName)
|
||||||
/>
|
.filter(Boolean) as string[],
|
||||||
</DialogContent>
|
).size
|
||||||
|
}{' '}
|
||||||
|
个工具
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRespond(false, false)}
|
onClick={() => handleRespond(false, false)}
|
||||||
color="error"
|
color="error"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
disabled={isExpired}
|
disabled={isExpired}
|
||||||
>
|
>
|
||||||
拒绝全部 ({totalCount})
|
拒绝全部 ({totalCount})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRespond(true, false)}
|
onClick={() => handleRespond(true, false)}
|
||||||
color="success"
|
color="success"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
disabled={isExpired || allSelected}
|
disabled={isExpired || allSelected}
|
||||||
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
|
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
|
||||||
>
|
>
|
||||||
批准全部
|
批准全部
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRespond(true, true)}
|
onClick={() => handleRespond(true, true)}
|
||||||
color="success"
|
color="success"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={isExpired || selectedCount === 0}
|
disabled={isExpired || selectedCount === 0}
|
||||||
>
|
>
|
||||||
{isExpired
|
{isExpired
|
||||||
? '已超时'
|
? '已超时'
|
||||||
: selectedCount === totalCount
|
: selectedCount === totalCount
|
||||||
? `确认执行 (${selectedCount})`
|
? `确认执行 (${selectedCount})`
|
||||||
: `批准选中 (${selectedCount})`}
|
: `批准选中 (${selectedCount})`}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
|
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
|
||||||
{/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown;
|
{/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown;
|
||||||
@@ -603,14 +686,17 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
<DialogTitle sx={{ fontSize: 14 }}>确认永久自动执行?</DialogTitle>
|
<DialogTitle sx={{ fontSize: 14 }}>确认永久自动执行?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置 → 工具管理」中关闭。
|
勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置
|
||||||
|
→ 工具管理」中关闭。
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
|
||||||
确定要启用吗?
|
确定要启用吗?
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">取消</Button>
|
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAutoExecute(true);
|
setAutoExecute(true);
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* MessageList — 消息列表容器
|
* MessageList — 消息列表容器(v0.4.1: react-virtuoso 真虚拟滚动)
|
||||||
|
*
|
||||||
|
* v0.4.1 重构:content-visibility 准虚拟滚动升级为 react-virtuoso 真虚拟滚动。
|
||||||
|
* - 千条消息会话:屏幕外 DOM 节点不再挂载(此前仅跳过渲染,节点仍全量存在)
|
||||||
|
* - 新消息滚动:followOutput(数组追加时生效)
|
||||||
|
* - 流式跟随(复查补充): followOutput 只响应"数组长度变化",流式 delta 更新
|
||||||
|
* 最后一条消息内容时高度增长不会自动滚底 — 用 atBottomStateChange 跟踪底部
|
||||||
|
* 状态 + 流式期间 200ms 定时滚底兜底,仅当用户处于底部时才跟随
|
||||||
|
* (用户上翻查看历史时不打断)
|
||||||
|
* - 动态高度:Markdown/代码块/工具卡片高度变化由 Virtuoso 自动测量
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { Box, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
import { MessageItem } from './MessageItem';
|
import { MessageItem } from './MessageItem';
|
||||||
@@ -11,114 +21,81 @@ import { StreamingIndicator } from './StreamingIndicator';
|
|||||||
export function MessageList(): React.JSX.Element {
|
export function MessageList(): React.JSX.Element {
|
||||||
const messages = useAgentStore((s) => s.messages);
|
const messages = useAgentStore((s) => s.messages);
|
||||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const currentSessionId = useAgentStore((s) => s.currentSessionId);
|
||||||
|
|
||||||
// F2: 滚动节流优化
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
// 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发,
|
// 用户是否处于列表底部(离开底部=上翻查看历史,此时流式输出不强制拉底)
|
||||||
// 且 'smooth' 在长列表上触发主线程布局动画,加剧卡顿。
|
const atBottomRef = useRef(true);
|
||||||
// 方案:
|
|
||||||
// - 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程)
|
|
||||||
// - 非流式时:立即 'smooth' 滚动(新消息发送/接收完成)
|
|
||||||
// - 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局
|
|
||||||
const lastScrollRef = useRef(0);
|
|
||||||
const trailingTimerRef = useRef<number | null>(null);
|
|
||||||
const rafRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
|
// 流式期间的滚动跟随兜底(复查修复)
|
||||||
|
// followOutput 只在 data 数组追加时触发;流式 delta 增高最后一条消息时需主动跟随。
|
||||||
|
// 仅当用户在底部时执行 scrollToIndex,保证上翻浏览不被打断。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = performance.now();
|
if (!isStreaming) return;
|
||||||
const elapsed = now - lastScrollRef.current;
|
const timer = window.setInterval(() => {
|
||||||
|
if (atBottomRef.current) {
|
||||||
// 调度一次 rAF 滚动(自动取消上一次挂起的 rAF)
|
virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior: 'auto' });
|
||||||
const doScroll = (behavior: ScrollBehavior) => {
|
|
||||||
lastScrollRef.current = performance.now();
|
|
||||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
|
||||||
rafRef.current = null;
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 非流式或首次进入:立即平滑滚动(新消息出现)
|
|
||||||
if (!isStreaming || lastScrollRef.current === 0) {
|
|
||||||
if (trailingTimerRef.current !== null) {
|
|
||||||
clearTimeout(trailingTimerRef.current);
|
|
||||||
trailingTimerRef.current = null;
|
|
||||||
}
|
}
|
||||||
doScroll('smooth');
|
}, 200);
|
||||||
return;
|
return () => window.clearInterval(timer);
|
||||||
}
|
}, [isStreaming]);
|
||||||
|
|
||||||
// 流式中:100ms 节流 + trailing 兜底
|
|
||||||
if (elapsed >= 100) {
|
|
||||||
// 已过节流窗口,立即执行
|
|
||||||
if (trailingTimerRef.current !== null) {
|
|
||||||
clearTimeout(trailingTimerRef.current);
|
|
||||||
trailingTimerRef.current = null;
|
|
||||||
}
|
|
||||||
doScroll('auto');
|
|
||||||
} else if (trailingTimerRef.current === null) {
|
|
||||||
// 在节流窗口内,安排 trailing 滚动(保证最后一次 delta 后到底)
|
|
||||||
const remaining = 100 - elapsed;
|
|
||||||
trailingTimerRef.current = window.setTimeout(() => {
|
|
||||||
trailingTimerRef.current = null;
|
|
||||||
doScroll('auto');
|
|
||||||
}, remaining);
|
|
||||||
}
|
|
||||||
}, [messages, isStreaming]);
|
|
||||||
|
|
||||||
// 组件卸载时清理所有挂起的 timer 和 rAF
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (trailingTimerRef.current !== null) {
|
|
||||||
clearTimeout(trailingTimerRef.current);
|
|
||||||
trailingTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (rafRef.current !== null) {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
rafRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (messages.length === 0 && !isStreaming) {
|
if (messages.length === 0 && !isStreaming) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
|
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
|
||||||
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }} />
|
<Box
|
||||||
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>MetonaAI Desktop</Typography>
|
component="img"
|
||||||
<Typography variant="body1" sx={{ color: 'text.secondary' }}>生产级通用 AI Agent 智能体桌面应用</Typography>
|
src="./logo.png"
|
||||||
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent 就绪 · 选择一个 Provider 开始对话</Typography>
|
alt="Metona"
|
||||||
|
sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>
|
||||||
|
MetonaAI Desktop
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
||||||
|
生产级通用 AI Agent 智能体桌面应用
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>
|
||||||
|
Agent 就绪 · 选择一个 Provider 开始对话
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// F12: GPU 加速 — transform: translateZ(0) 将滚动容器提升为合成层
|
<Virtuoso
|
||||||
// 滚动时由合成器线程处理,避免主线程重绘叠加卡顿
|
ref={virtuosoRef}
|
||||||
// 风险评估:已确认 chat 目录内无 position: fixed 元素;
|
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
|
||||||
// ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响
|
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount)
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3, transform: 'translateZ(0)' }}>
|
key={currentSessionId ?? 'no-session'}
|
||||||
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
{messages.map((msg, i) => (
|
data={messages}
|
||||||
// F4: content-visibility 准虚拟滚动
|
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
|
||||||
// 浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。
|
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
|
||||||
// 配合 F1 memo(跳过 re-render)+ F3 流式纯文本,历史消息开销降至最低。
|
// 新消息追加时跟随(流式中同步,非流式平滑)
|
||||||
// contain-intrinsic-size 提供估算高度,避免滚动时高度抖动。
|
followOutput={isStreaming ? 'auto' : 'smooth'}
|
||||||
// 注:如后续需真正虚拟滚动(移除 DOM 节点),可升级到 react-virtuoso。
|
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
|
||||||
<Box
|
atBottomStateChange={(atBottom) => {
|
||||||
key={msg.id}
|
atBottomRef.current = atBottom;
|
||||||
sx={{
|
}}
|
||||||
'content-visibility': 'auto',
|
itemContent={(index, msg) => (
|
||||||
'contain-intrinsic-size': 'auto 300px',
|
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
|
||||||
}}
|
<MessageItem
|
||||||
>
|
message={msg}
|
||||||
<MessageItem message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
|
isLast={index === messages.length - 1}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
components={{
|
||||||
|
Footer: () => (
|
||||||
|
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pb: 3 }}>
|
||||||
|
<StreamingIndicator />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
),
|
||||||
<StreamingIndicator />
|
}}
|
||||||
<div ref={messagesEndRef} />
|
/>
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* AgentSettings — Agent 配置 Tab
|
||||||
|
*
|
||||||
|
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||||
|
* 功能:迭代次数 / 总超时 / 工具超时 / 确认超时 / 思考模式配置。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
FormControl,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useConfig } from './useConfig';
|
||||||
|
|
||||||
|
export 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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
Agent 配置
|
||||||
|
</Typography>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>最大迭代次数</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={maxIter}
|
||||||
|
label="最大迭代次数"
|
||||||
|
onChange={(e) => setMaxIter(e.target.value as number)}
|
||||||
|
>
|
||||||
|
{MAX_ITER_OPTIONS.map((n) => (
|
||||||
|
<MenuItem key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="总超时(秒)"
|
||||||
|
type="number"
|
||||||
|
value={timeout / 1000}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
if (v >= 120 && v <= 3600) setTimeout_(v * 1000);
|
||||||
|
}}
|
||||||
|
slotProps={{ htmlInput: { min: 120, max: 3600, step: 30 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="工具执行超时(秒)"
|
||||||
|
type="number"
|
||||||
|
value={toolExecTimeout / 1000}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 秒)"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="工具确认超时(秒)"
|
||||||
|
type="number"
|
||||||
|
value={confirmTimeout / 1000}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 秒)"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={thinking}
|
||||||
|
onChange={(e) => setThinking(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">启用思考模式</Typography>}
|
||||||
|
/>
|
||||||
|
{thinking && (
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>思考强度</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={thinkingEffort}
|
||||||
|
label="思考强度"
|
||||||
|
onChange={(e) => setThinkingEffort(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="low">Low</MenuItem>
|
||||||
|
<MenuItem value="medium">Medium</MenuItem>
|
||||||
|
<MenuItem value="high">High</MenuItem>
|
||||||
|
<MenuItem value="max">Max</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* AppearanceSettings — 外观设置 Tab
|
||||||
|
*
|
||||||
|
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||||
|
* 功能:主题切换(深色/浅色/跟随系统)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button, Stack, Typography, Box } from '@mui/material';
|
||||||
|
import type { ThemeMode } from '@renderer/stores/ui-store';
|
||||||
|
|
||||||
|
export function AppearanceSettings({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
}: {
|
||||||
|
theme: ThemeMode;
|
||||||
|
setTheme: (t: ThemeMode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
外观
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||||
|
主题
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t}
|
||||||
|
variant={theme === t ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
>
|
||||||
|
{t === 'dark' ? '深色' : t === 'light' ? '浅色' : '跟随系统'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>('');
|
||||||
|
const [model, setModel] = useState<string>('');
|
||||||
|
const [apiKey, setApiKey] = useState<string>('');
|
||||||
|
const [baseURL, setBaseURL] = useState<string>('');
|
||||||
|
const [numCtx, setNumCtx] = useState<number | null>(null);
|
||||||
|
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
|
||||||
|
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
|
||||||
|
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
|
||||||
|
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
|
||||||
|
// P3: OpenAI/Anthropic contextWindow
|
||||||
|
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
|
||||||
|
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
|
||||||
|
// P1: 故障转移 Provider 配置
|
||||||
|
const [fbProvider, setFbProvider] = useState<string>('');
|
||||||
|
const [fbModel, setFbModel] = useState<string>('');
|
||||||
|
const [fbApiKey, setFbApiKey] = useState<string>('');
|
||||||
|
const [fbBaseURL, setFbBaseURL] = useState<string>('');
|
||||||
|
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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
LLM 配置
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
加载中...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
LLM 配置
|
||||||
|
</Typography>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Provider</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={provider}
|
||||||
|
label="Provider"
|
||||||
|
onChange={(e) => handleProviderChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||||
|
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||||
|
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||||
|
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||||
|
<MenuItem value="openai">OpenAI</MenuItem>
|
||||||
|
<MenuItem value="anthropic">Anthropic</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="API Base URL"
|
||||||
|
value={baseURL}
|
||||||
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
|
placeholder="如 https://api.deepseek.com"
|
||||||
|
error={urlError}
|
||||||
|
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="模型名称"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
||||||
|
error={modelHasSpace}
|
||||||
|
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
|
||||||
|
/>
|
||||||
|
{provider !== 'ollama' && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="API Key"
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
error={apiKeyEmpty}
|
||||||
|
helperText={
|
||||||
|
apiKeyEmpty
|
||||||
|
? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401`
|
||||||
|
: ' '
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
|
||||||
|
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{provider === 'ollama' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文长度 (num_ctx)"
|
||||||
|
type="number"
|
||||||
|
value={numCtx ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
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' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={dsCtxWindow}
|
||||||
|
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
|
placeholder="如 64000、128000、1000000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
error={dsCtxError}
|
||||||
|
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{provider === 'agnes' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={agnesCtxWindow}
|
||||||
|
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
|
placeholder="如 64000、128000、1000000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
error={agnesCtxError}
|
||||||
|
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{provider === 'mimo' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={mimoCtxWindow}
|
||||||
|
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
|
placeholder="如 65536、131072、1000000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
error={mimoCtxError}
|
||||||
|
helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{provider === 'openai' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={oaCtxWindow}
|
||||||
|
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
|
||||||
|
placeholder="如 128000、200000、1000000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
error={oaCtxError}
|
||||||
|
helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{provider === 'anthropic' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={anthropicCtxWindow}
|
||||||
|
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
|
||||||
|
placeholder="如 200000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
error={anthropicCtxError}
|
||||||
|
helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
故障转移(可选)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。
|
||||||
|
</Typography>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>备用 Provider</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={fbProvider}
|
||||||
|
label="备用 Provider"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setFbProvider(v);
|
||||||
|
setFbModel('');
|
||||||
|
setFbApiKey('');
|
||||||
|
setFbBaseURL(PROVIDER_URLS[v] ?? '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>禁用</em>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||||
|
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||||
|
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||||
|
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||||
|
<MenuItem value="openai">OpenAI</MenuItem>
|
||||||
|
<MenuItem value="anthropic">Anthropic</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{fbProvider && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="备用 Base URL"
|
||||||
|
value={fbBaseURL}
|
||||||
|
onChange={(e) => setFbBaseURL(e.target.value)}
|
||||||
|
placeholder="如 https://api.deepseek.com"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="备用模型名称"
|
||||||
|
value={fbModel}
|
||||||
|
onChange={(e) => setFbModel(e.target.value)}
|
||||||
|
placeholder="如 deepseek-v4-flash"
|
||||||
|
/>
|
||||||
|
{fbProvider !== 'ollama' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="备用 API Key"
|
||||||
|
type={showFbKey ? 'text' : 'password'}
|
||||||
|
value={fbApiKey}
|
||||||
|
onChange={(e) => setFbApiKey(e.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton size="small" onClick={() => setShowFbKey(!showFbKey)}>
|
||||||
|
{showFbKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save 按钮:批量提交,取消 onChange 实时落库 */}
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || hasBlockingError}
|
||||||
|
startIcon={saving ? <CircularProgress size={12} /> : undefined}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存配置'}
|
||||||
|
</Button>
|
||||||
|
{hasBlockingError && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
|
||||||
|
请修正表单错误后再保存
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<string>('');
|
||||||
|
const [logPathLoading, setLogPathLoading] = useState<boolean>(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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
日志与数据
|
||||||
|
</Typography>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>日志级别</InputLabel>
|
||||||
|
<Select value={logLevel} label="日志级别" onChange={(e) => setLogLevel(e.target.value)}>
|
||||||
|
<MenuItem value="debug">DEBUG</MenuItem>
|
||||||
|
<MenuItem value="info">INFO</MenuItem>
|
||||||
|
<MenuItem value="warn">WARN</MenuItem>
|
||||||
|
<MenuItem value="error">ERROR</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* P3-13: 日志文件路径展示与打开按钮 */}
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.5, display: 'block' }}
|
||||||
|
>
|
||||||
|
日志文件
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={logFilePath}
|
||||||
|
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
|
||||||
|
slotProps={{ input: { readOnly: true, sx: { fontSize: 11, fontFamily: 'monospace' } } }}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Folder size={14} />}
|
||||||
|
onClick={handleOpenLogFolder}
|
||||||
|
disabled={!logFilePath}
|
||||||
|
>
|
||||||
|
打开日志文件夹
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Copy size={14} />}
|
||||||
|
onClick={handleCopyLogPath}
|
||||||
|
disabled={!logFilePath}
|
||||||
|
color={
|
||||||
|
copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'inherit'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败' : '复制路径'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 0.5, display: 'block' }}>
|
||||||
|
日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
|
数据管理
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>
|
||||||
|
📦 导出全部数据(JSON)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setConfirmClear('sessions')}
|
||||||
|
disabled={clearing !== null}
|
||||||
|
>
|
||||||
|
{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setConfirmClear('memories')}
|
||||||
|
disabled={clearing !== null}
|
||||||
|
>
|
||||||
|
{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setConfirmClear('auditLogs')}
|
||||||
|
disabled={clearing !== null}
|
||||||
|
>
|
||||||
|
{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */}
|
||||||
|
<Dialog
|
||||||
|
open={confirmClear !== null}
|
||||||
|
onClose={() => setConfirmClear(null)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>确认清理</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2">
|
||||||
|
确定清理{confirmClear ? labels[confirmClear] : ''}?此操作不可撤销。
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmClear(null)} color="inherit">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClearConfirm} color="error" variant="contained">
|
||||||
|
清理
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<string, string> = {
|
||||||
|
connected: 'success.main',
|
||||||
|
connecting: 'warning.main',
|
||||||
|
disconnected: 'text.secondary',
|
||||||
|
error: 'error.main',
|
||||||
|
};
|
||||||
|
|
||||||
|
// L-11 修复: 确认移除 MCP 服务
|
||||||
|
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
|
||||||
|
const [removeError, setRemoveError] = useState<string | null>(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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
MCP 服务
|
||||||
|
</Typography>
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||||
|
暂无 MCP 服务
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
servers.map((s) => (
|
||||||
|
<Stack
|
||||||
|
key={s.name}
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
py: 1,
|
||||||
|
px: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'secondary.main',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{s.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
||||||
|
{s.status}
|
||||||
|
</Typography>
|
||||||
|
{s.toolCount > 0 && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{s.toolCount} 工具
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ fontSize: 10, minWidth: 40 }}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.mcp?.toggleServer(
|
||||||
|
s.name,
|
||||||
|
s.status !== 'connected',
|
||||||
|
);
|
||||||
|
if (r?.success) {
|
||||||
|
loadServers();
|
||||||
|
} else {
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(r?.error ?? '操作失败'))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MCPSettings]', err);
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(`操作失败:${(err as Error).message}`))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.status === 'connected' ? '断开' : '连接'}
|
||||||
|
</Button>
|
||||||
|
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
sx={{ fontSize: 10, minWidth: 40 }}
|
||||||
|
onClick={() => setConfirmRemove(s.name)}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
|
||||||
|
<Dialog
|
||||||
|
open={confirmRemove !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setConfirmRemove(null);
|
||||||
|
setRemoveError(null);
|
||||||
|
}}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>确认移除</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2">
|
||||||
|
确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。
|
||||||
|
</Typography>
|
||||||
|
{removeError && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>
|
||||||
|
移除失败:{removeError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmRemove(null);
|
||||||
|
setRemoveError(null);
|
||||||
|
}}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirmRemove} color="error" variant="contained">
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
{showAdd ? (
|
||||||
|
<Stack
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'secondary.main',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="服务名称"
|
||||||
|
/>
|
||||||
|
{/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */}
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant={newTransport === 'stdio' ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setNewTransport('stdio')}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
本地 (stdio)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={newTransport === 'streamable-http' ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setNewTransport('streamable-http')}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
远程 (HTTP)
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{newTransport === 'stdio' ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={newCommand}
|
||||||
|
onChange={(e) => setNewCommand(e.target.value)}
|
||||||
|
placeholder="命令路径(如 npx / node / python)"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={newArgs}
|
||||||
|
onChange={(e) => setNewArgs(e.target.value)}
|
||||||
|
placeholder="参数 (空格分隔)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={newUrl}
|
||||||
|
onChange={(e) => setNewUrl(e.target.value)}
|
||||||
|
placeholder="Streamable HTTP URL(如 https://example.com/mcp)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={
|
||||||
|
!newName.trim() || (newTransport === 'stdio' ? !newCommand.trim() : !newUrl.trim())
|
||||||
|
}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowAdd(false)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>
|
||||||
|
+ 添加 MCP 服务
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* 标题 + 状态徽章 */}
|
||||||
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
SearXNG 元搜索
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={enabled ? '已启用' : '未启用'}
|
||||||
|
size="small"
|
||||||
|
color={enabled ? 'success' : 'default'}
|
||||||
|
variant={enabled ? 'filled' : 'outlined'}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
SearXNG 是开源元搜索引擎,支持 70+
|
||||||
|
搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 启用开关 */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">启用 SearXNG</Typography>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* API 地址 + 连接测试 */}
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="API 地址"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="如 https://searxng.example.com"
|
||||||
|
error={urlError}
|
||||||
|
helperText={
|
||||||
|
urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'
|
||||||
|
}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={!url.trim() || urlError || testing}
|
||||||
|
sx={{ mt: 0.5, minWidth: 90, height: 40 }}
|
||||||
|
>
|
||||||
|
{testing ? <CircularProgress size={14} /> : '测试连接'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 测试结果 */}
|
||||||
|
{testResult && (
|
||||||
|
<Alert
|
||||||
|
severity={testResult.success ? 'success' : 'error'}
|
||||||
|
sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}
|
||||||
|
>
|
||||||
|
{testResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索引擎 */}
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="搜索引擎(逗号分隔)"
|
||||||
|
value={engines}
|
||||||
|
onChange={(e) => setEngines(e.target.value)}
|
||||||
|
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 语言 + 安全搜索 */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>语言</InputLabel>
|
||||||
|
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
|
||||||
|
<MenuItem value="zh-CN">简体中文</MenuItem>
|
||||||
|
<MenuItem value="zh-TW">繁體中文</MenuItem>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="ja">日本語</MenuItem>
|
||||||
|
<MenuItem value="ko">한국어</MenuItem>
|
||||||
|
<MenuItem value="auto">自动检测</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>安全搜索</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={safesearch}
|
||||||
|
label="安全搜索"
|
||||||
|
onChange={(e) => setSafesearch(e.target.value as number)}
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>关闭</MenuItem>
|
||||||
|
<MenuItem value={1}>中等</MenuItem>
|
||||||
|
<MenuItem value={2}>严格</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 时间范围 + 返回格式 */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>时间范围</InputLabel>
|
||||||
|
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
|
||||||
|
<MenuItem value="">不限</MenuItem>
|
||||||
|
<MenuItem value="day">一天</MenuItem>
|
||||||
|
<MenuItem value="week">一周</MenuItem>
|
||||||
|
<MenuItem value="month">一月</MenuItem>
|
||||||
|
<MenuItem value="year">一年</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>返回格式</InputLabel>
|
||||||
|
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
|
||||||
|
<MenuItem value="json">JSON(结构化解析)</MenuItem>
|
||||||
|
<MenuItem value="html">HTML(原始网页)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 最大结果数 + 自动抓取条数 */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="最大结果数"
|
||||||
|
type="number"
|
||||||
|
value={maxResults}
|
||||||
|
onChange={(e) => setMaxResults(Number(e.target.value))}
|
||||||
|
placeholder="0 表示使用默认"
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 50 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="自动抓取条数"
|
||||||
|
type="number"
|
||||||
|
value={fetchCount}
|
||||||
|
onChange={(e) => setFetchCount(Number(e.target.value))}
|
||||||
|
placeholder="0 表示由 AI 决定"
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 8 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 抓取类型 */}
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>抓取类型</InputLabel>
|
||||||
|
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
|
||||||
|
<MenuItem value="sequential">顺序抓取</MenuItem>
|
||||||
|
<MenuItem value="random">随机抓取</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 认证设置 */}
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
|
认证设置
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>认证类型</InputLabel>
|
||||||
|
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
|
||||||
|
<MenuItem value="bearer">Bearer Token</MenuItem>
|
||||||
|
<MenuItem value="basic">Basic Auth</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
value={authKey}
|
||||||
|
onChange={(e) => setAuthKey(e.target.value)}
|
||||||
|
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
|
||||||
|
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
{authType === 'bearer'
|
||||||
|
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
|
||||||
|
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<string[]>([]);
|
||||||
|
|
||||||
|
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<string, 'success' | 'info' | 'warning' | 'error'> = {
|
||||||
|
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<typeof t> => t !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
工具管理
|
||||||
|
</Typography>
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||||
|
加载中...
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
tools.map((t) => (
|
||||||
|
<Stack
|
||||||
|
key={t.name}
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
py: 1,
|
||||||
|
px: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'secondary.main',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{t.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{t.description.slice(0, 40)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<Chip
|
||||||
|
label={t.riskLevel.toUpperCase()}
|
||||||
|
size="small"
|
||||||
|
color={riskColors[t.riskLevel]}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 18, fontSize: 9 }}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={t.enabled}
|
||||||
|
onChange={(e) => handleToggle(t.name, e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{/* ===== 自动执行工具管理 ===== */}
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
自动执行工具
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${autoExecList.length} 个`}
|
||||||
|
size="small"
|
||||||
|
color={autoExecList.length > 0 ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 18, fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
|
||||||
|
已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 已自动执行的工具列表 */}
|
||||||
|
{autoExecToolDetails.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}
|
||||||
|
>
|
||||||
|
暂无自动执行工具
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
autoExecToolDetails.map((t) => (
|
||||||
|
<Stack
|
||||||
|
key={t.name}
|
||||||
|
direction="row"
|
||||||
|
sx={(theme) => ({
|
||||||
|
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',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'success.main',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'text.primary',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
|
||||||
|
onClick={() => handleSetAutoExec(t.name, false)}
|
||||||
|
>
|
||||||
|
取消自动
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 可设为自动执行的工具(需要确认但未设置) */}
|
||||||
|
{pendingConfirmTools.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
|
||||||
|
可设为自动执行(当前需要确认)
|
||||||
|
</Typography>
|
||||||
|
{pendingConfirmTools.map((t) => (
|
||||||
|
<Stack
|
||||||
|
key={t.name}
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
py: 1,
|
||||||
|
px: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'secondary.main',
|
||||||
|
border: '1px dashed',
|
||||||
|
borderColor: 'divider',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.primary' }}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={t.riskLevel.toUpperCase()}
|
||||||
|
size="small"
|
||||||
|
color={riskColors[t.riskLevel]}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 16, fontSize: 9 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="contained"
|
||||||
|
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
|
||||||
|
onClick={() => handleSetAutoExec(t.name, true)}
|
||||||
|
>
|
||||||
|
设为自动
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
工作空间
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
工作空间是 Metona 的组织核心,包含 SOUL.md、MEMORY.md 两个必需文件。
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={workspacePath}
|
||||||
|
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||||
|
placeholder="~/MetonaWorkspaces/default/"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button variant="outlined" size="small" onClick={handleSelect}>
|
||||||
|
选择文件夹
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{workspacePath && (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={handleOpen}
|
||||||
|
sx={{ alignSelf: 'flex-start', fontSize: 11, color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
📂 在文件管理器中打开
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 校验中状态 */}
|
||||||
|
{checking && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
|
||||||
|
<CircularProgress size={14} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
正在校验工作空间...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 校验失败 */}
|
||||||
|
{pendingPath && checkResult && !checkResult.valid && (
|
||||||
|
<Alert severity="error" sx={{ py: 0.5 }}>
|
||||||
|
<Typography variant="caption">路径无效:{checkResult.reason}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button size="small" onClick={handleCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 校验成功 — 显示工作空间状态 + 继承选项 */}
|
||||||
|
{pendingPath && checkResult?.valid && (
|
||||||
|
<Stack
|
||||||
|
spacing={1.5}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||||
|
目标工作空间:{checkResult.path}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{checkResult.isNewWorkspace ? (
|
||||||
|
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
新工作空间 — 切换后将自动创建 2 个必需文件(SOUL.md、MEMORY.md)
|
||||||
|
</Alert>
|
||||||
|
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
|
||||||
|
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
已有目录但缺少 {checkResult.missingFiles.length} 个文件:
|
||||||
|
{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
已有工作空间 — 2 个必需文件均已就绪,将直接加载现有配置。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */}
|
||||||
|
{currentPath &&
|
||||||
|
currentPath !== pendingPath &&
|
||||||
|
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
|
||||||
|
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
|
从当前工作空间继承:
|
||||||
|
</Typography>
|
||||||
|
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
|
||||||
|
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={inheritSoul}
|
||||||
|
onChange={(e) => setInheritSoul(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
|
||||||
|
{!checkResult.dbExists && (
|
||||||
|
<>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={inheritDatabase}
|
||||||
|
onChange={(e) => setInheritDatabase(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption">
|
||||||
|
数据库 agent.db(会话/消息/记忆/Trace 历史记录)
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}
|
||||||
|
>
|
||||||
|
勾选后将复制当前工作空间的全部历史数据到新工作空间(通过 SQLite backup API
|
||||||
|
原子性导出)
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
|
||||||
|
MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying}
|
||||||
|
startIcon={applying ? <CircularProgress size={12} /> : undefined}
|
||||||
|
>
|
||||||
|
{applying ? '应用中...' : '确认切换'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
修改工作空间路径后需重启应用生效。
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 重启确认对话框 */}
|
||||||
|
<Dialog
|
||||||
|
open={showRestartDialog}
|
||||||
|
onClose={() => setShowRestartDialog(false)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ fontSize: 14 }}>工作空间已切换</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
工作空间已更新为:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
mt: 0.5,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workspacePath}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
|
||||||
|
需要重启应用以加载新工作空间的配置和文件。是否立即重启?
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button size="small" onClick={() => setShowRestartDialog(false)}>
|
||||||
|
稍后手动重启
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => window.metona?.app?.restart()}
|
||||||
|
>
|
||||||
|
立即重启
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
||||||
|
const [value, setValue] = useState<T>(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<string, string> = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
+63
-14
@@ -11,7 +11,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
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
|
* Agent 流式事件监听 Hook
|
||||||
@@ -102,12 +107,23 @@ export function useAgentStream(): void {
|
|||||||
/** 工具调用增量(流式参数拼接) */
|
/** 工具调用增量(流式参数拼接) */
|
||||||
toolCallDelta?: { index: number; name?: string; argsDelta?: string };
|
toolCallDelta?: { index: number; name?: string; argsDelta?: string };
|
||||||
toolCall?: { id: string; name: string; args: Record<string, unknown> };
|
toolCall?: { id: string; name: string; args: Record<string, unknown> };
|
||||||
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 };
|
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
||||||
/** v0.3.18 修复: 上下文压缩事件数据 */
|
/** v0.3.18 修复: 上下文压缩事件数据 */
|
||||||
savedTokens?: number;
|
savedTokens?: number;
|
||||||
originalTokens?: number;
|
originalTokens?: number;
|
||||||
compressedTokens?: number;
|
compressedTokens?: number;
|
||||||
|
/** v0.4.1: 输出验证结果(OutputValidator 检出的疑似问题,不阻断输出) */
|
||||||
|
validation?: {
|
||||||
|
score: number;
|
||||||
|
issues: Array<{ severity: string; type: string; message: string }>;
|
||||||
|
};
|
||||||
error?: { code: string; message: string };
|
error?: { code: string; message: string };
|
||||||
state?: string;
|
state?: string;
|
||||||
};
|
};
|
||||||
@@ -143,7 +159,9 @@ export function useAgentStream(): void {
|
|||||||
if (data.iteration != null) {
|
if (data.iteration != null) {
|
||||||
const msgs = getStore().messages;
|
const msgs = getStore().messages;
|
||||||
const last = msgs[msgs.length - 1];
|
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);
|
(last.iteration != null && last.iteration !== data.iteration);
|
||||||
if (needsNewCard) {
|
if (needsNewCard) {
|
||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
@@ -201,7 +219,9 @@ export function useAgentStream(): void {
|
|||||||
if (data.iteration != null) {
|
if (data.iteration != null) {
|
||||||
const msgs = getStore().messages;
|
const msgs = getStore().messages;
|
||||||
const last = msgs[msgs.length - 1];
|
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);
|
(last.iteration != null && last.iteration !== data.iteration);
|
||||||
if (needsNewCard) {
|
if (needsNewCard) {
|
||||||
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
|
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
|
||||||
@@ -307,7 +327,7 @@ export function useAgentStream(): void {
|
|||||||
tc.id === data.toolResult!.toolCallId
|
tc.id === data.toolResult!.toolCallId
|
||||||
? {
|
? {
|
||||||
...tc,
|
...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,
|
result: data.toolResult!.result,
|
||||||
error: data.toolResult!.error,
|
error: data.toolResult!.error,
|
||||||
durationMs: data.toolResult!.durationMs,
|
durationMs: data.toolResult!.durationMs,
|
||||||
@@ -328,7 +348,9 @@ export function useAgentStream(): void {
|
|||||||
tc.id === data.toolResult!.toolCallId
|
tc.id === data.toolResult!.toolCallId
|
||||||
? {
|
? {
|
||||||
...tc,
|
...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,
|
result: data.toolResult!.result,
|
||||||
error: data.toolResult!.error,
|
error: data.toolResult!.error,
|
||||||
durationMs: data.toolResult!.durationMs,
|
durationMs: data.toolResult!.durationMs,
|
||||||
@@ -375,6 +397,23 @@ export function useAgentStream(): void {
|
|||||||
break;
|
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':
|
case 'done':
|
||||||
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
|
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
|
||||||
@@ -420,9 +459,8 @@ export function useAgentStream(): void {
|
|||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
id: genMsgId('error'),
|
id: genMsgId('error'),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: errorCode === 'content_filtered'
|
content:
|
||||||
? `⚠️ ${errorMessage}`
|
errorCode === 'content_filtered' ? `⚠️ ${errorMessage}` : `错误: ${errorMessage}`,
|
||||||
: `错误: ${errorMessage}`,
|
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -494,12 +532,14 @@ export function useAgentStream(): void {
|
|||||||
if (data.iteration > prevIteration) {
|
if (data.iteration > prevIteration) {
|
||||||
const messages = store.messages;
|
const messages = store.messages;
|
||||||
const lastMsg = messages[messages.length - 1];
|
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) {
|
if (!isAlreadyCurrentIteration) {
|
||||||
// 首轮不要求上一轮有内容(上一轮是用户消息)
|
// 首轮不要求上一轮有内容(上一轮是用户消息)
|
||||||
const isFirstIteration = prevIteration === 0;
|
const isFirstIteration = prevIteration === 0;
|
||||||
const prevHasContent = lastMsg?.role === 'assistant' &&
|
const prevHasContent =
|
||||||
|
lastMsg?.role === 'assistant' &&
|
||||||
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent);
|
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent);
|
||||||
|
|
||||||
if (isFirstIteration || prevHasContent) {
|
if (isFirstIteration || prevHasContent) {
|
||||||
@@ -527,7 +567,8 @@ export function useAgentStream(): void {
|
|||||||
// 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤
|
// 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤
|
||||||
// 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组
|
// 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组
|
||||||
// runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step)
|
// 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) {
|
if (isSameIteration) {
|
||||||
// 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state
|
// 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state
|
||||||
@@ -555,7 +596,10 @@ export function useAgentStream(): void {
|
|||||||
completedAt: Date.now(),
|
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 可更新
|
// 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新
|
||||||
// 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态
|
// 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态
|
||||||
} else {
|
} else {
|
||||||
@@ -601,7 +645,12 @@ export function useAgentStream(): void {
|
|||||||
if (!window.metona?.agent?.onProviderSwitched) return;
|
if (!window.metona?.agent?.onProviderSwitched) return;
|
||||||
|
|
||||||
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
|
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 切换消息
|
// L-3: 仅在当前会话中显示 Provider 切换消息
|
||||||
const store = useAgentStore.getState();
|
const store = useAgentStore.getState();
|
||||||
if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return;
|
if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return;
|
||||||
|
|||||||
Vendored
+90
-40
@@ -59,15 +59,19 @@ interface MetonaAgentAPI {
|
|||||||
/** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */
|
/** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */
|
||||||
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
|
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
|
||||||
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
|
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
|
||||||
onStateChange: (callback: (state: {
|
onStateChange: (
|
||||||
sessionId: string | null;
|
callback: (state: {
|
||||||
iteration: number;
|
sessionId: string | null;
|
||||||
state: string;
|
iteration: number;
|
||||||
previous: string;
|
state: string;
|
||||||
current: string;
|
previous: string;
|
||||||
}) => void) => () => void;
|
current: string;
|
||||||
|
}) => void,
|
||||||
|
) => () => void;
|
||||||
abortSession: (sessionId: string) => Promise<{ success: boolean }>;
|
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 =====
|
// ===== Sessions API =====
|
||||||
@@ -87,25 +91,40 @@ interface MetonaSessionsAPI {
|
|||||||
create: (title?: string) => Promise<MetonaSessionInfo>;
|
create: (title?: string) => Promise<MetonaSessionInfo>;
|
||||||
rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
|
rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getMessages: (sessionId: string) => Promise<Array<{
|
getMessages: (sessionId: string) => Promise<
|
||||||
id: string;
|
Array<{
|
||||||
role: string;
|
id: string;
|
||||||
content: string;
|
role: string;
|
||||||
reasoningContent?: string;
|
content: string;
|
||||||
toolCalls?: unknown[];
|
reasoningContent?: string;
|
||||||
toolResult?: unknown;
|
toolCalls?: unknown[];
|
||||||
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
toolResult?: unknown;
|
||||||
iteration?: number;
|
attachments?: Array<{
|
||||||
timestamp: number;
|
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 }>;
|
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
|
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
||||||
/** P2-11: 截断消息(编辑重发/重新生成) */
|
/** P2-11: 截断消息(编辑重发/重新生成) */
|
||||||
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
|
truncateAfter: (
|
||||||
Promise<{ success: boolean; truncated?: number; error?: string }>;
|
sessionId: string,
|
||||||
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
|
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>;
|
getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +132,8 @@ interface MetonaSessionsAPI {
|
|||||||
|
|
||||||
interface MetonaMCPServerConfig {
|
interface MetonaMCPServerConfig {
|
||||||
name: string;
|
name: string;
|
||||||
transport: 'stdio' | 'sse';
|
/** v0.4.1: 新增 'streamable-http'(MCP 当前主流远程传输) */
|
||||||
|
transport: 'stdio' | 'sse' | 'streamable-http';
|
||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -147,11 +167,14 @@ interface MetonaMemorySearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MetonaMemoryAPI {
|
interface MetonaMemoryAPI {
|
||||||
search: (query: string, options?: {
|
search: (
|
||||||
topK?: number;
|
query: string,
|
||||||
type?: 'episodic' | 'semantic' | 'working';
|
options?: {
|
||||||
minImportance?: number;
|
topK?: number;
|
||||||
}) => Promise<MetonaMemorySearchResult[]>;
|
type?: 'episodic' | 'semantic' | 'working';
|
||||||
|
minImportance?: number;
|
||||||
|
},
|
||||||
|
) => Promise<MetonaMemorySearchResult[]>;
|
||||||
listAll: (options?: { type?: string; limit?: number }) => Promise<{
|
listAll: (options?: { type?: string; limit?: number }) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: { episodic?: unknown[]; semantic?: unknown[]; working?: unknown[] };
|
data?: { episodic?: unknown[]; semantic?: unknown[]; working?: unknown[] };
|
||||||
@@ -166,7 +189,9 @@ interface MetonaConfigAPI {
|
|||||||
get: (key: string) => Promise<unknown>;
|
get: (key: string) => Promise<unknown>;
|
||||||
set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
|
set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||||
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
|
// 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 实时更新)
|
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
|
||||||
onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void;
|
onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void;
|
||||||
}
|
}
|
||||||
@@ -227,8 +252,11 @@ interface MetonaWorkspaceInfo {
|
|||||||
|
|
||||||
interface MetonaWorkspaceAPI {
|
interface MetonaWorkspaceAPI {
|
||||||
check: (targetPath: string) => Promise<MetonaWorkspaceCheckResult>;
|
check: (targetPath: string) => Promise<MetonaWorkspaceCheckResult>;
|
||||||
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
|
inheritFiles: (params: {
|
||||||
Promise<MetonaWorkspaceInheritResult>;
|
targetPath: string;
|
||||||
|
sourcePath: string;
|
||||||
|
files: string[];
|
||||||
|
}) => Promise<MetonaWorkspaceInheritResult>;
|
||||||
getInfo: () => Promise<MetonaWorkspaceInfo>;
|
getInfo: () => Promise<MetonaWorkspaceInfo>;
|
||||||
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check)
|
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check)
|
||||||
checkDatabaseIntegrity: (sourcePath: string) => Promise<{
|
checkDatabaseIntegrity: (sourcePath: string) => Promise<{
|
||||||
@@ -242,11 +270,13 @@ interface MetonaWorkspaceAPI {
|
|||||||
// ===== Toast API =====
|
// ===== Toast API =====
|
||||||
|
|
||||||
interface MetonaToastAPI {
|
interface MetonaToastAPI {
|
||||||
onShow: (callback: (data: {
|
onShow: (
|
||||||
type: 'success' | 'error' | 'warning' | 'info';
|
callback: (data: {
|
||||||
message: string;
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
options?: Record<string, unknown>;
|
message: string;
|
||||||
}) => void) => () => void;
|
options?: Record<string, unknown>;
|
||||||
|
}) => void,
|
||||||
|
) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Tools API =====
|
// ===== Tools API =====
|
||||||
@@ -280,7 +310,11 @@ interface MetonaSearXNGTestResult {
|
|||||||
|
|
||||||
interface MetonaSearXNGAPI {
|
interface MetonaSearXNGAPI {
|
||||||
/** 测试 SearXNG 实例连接可达性与认证有效性 */
|
/** 测试 SearXNG 实例连接可达性与认证有效性 */
|
||||||
testConnection: (url: string, authKey: string, authType: string) => Promise<MetonaSearXNGTestResult>;
|
testConnection: (
|
||||||
|
url: string,
|
||||||
|
authKey: string,
|
||||||
|
authType: string,
|
||||||
|
) => Promise<MetonaSearXNGTestResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Data API =====
|
// ===== Data API =====
|
||||||
@@ -347,8 +381,11 @@ interface MetonaAuditVerifyResult {
|
|||||||
|
|
||||||
interface MetonaAuditAPI {
|
interface MetonaAuditAPI {
|
||||||
verifyChain: () => Promise<MetonaAuditVerifyResult>;
|
verifyChain: () => Promise<MetonaAuditVerifyResult>;
|
||||||
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
|
query: (filters?: {
|
||||||
Promise<{ success: boolean; data?: unknown[]; error?: string }>;
|
sessionId?: string;
|
||||||
|
eventType?: string;
|
||||||
|
limit?: number;
|
||||||
|
}) => Promise<{ success: boolean; data?: unknown[]; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== v0.2.0: Tool Confirmation API =====
|
// ===== v0.2.0: Tool Confirmation API =====
|
||||||
@@ -386,8 +423,21 @@ interface MetonaToolAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
data: MetonaConfirmationRequest[];
|
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[] }>;
|
getAutoExecuteList: () => Promise<{ success: boolean; data: string[] }>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user