feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复: - API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容) - 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级) - error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计) - abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止) - run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化) - .env 真实生效(dotenv 回退加载,应用内配置优先) P1 工程基础: - ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线) - 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层) - test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行) - SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end) - Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知) - MCP 真就绪(等待全部连接完成再广播 tools:ready) - SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态) - CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移) P2 架构升级: - handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播) - AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号) - TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent - 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染) - 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI) - Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入 P3 能力扩展: - OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens) - Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机) - 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* AuditService 单元测试(P1-14 测试基线)
|
||||
* 覆盖:链式哈希生成与验证、篡改检测
|
||||
*
|
||||
* 注意:better-sqlite3 为原生模块——若本机安装的构建与系统 Node ABI 不匹配,
|
||||
* 测试自动跳过(skipIf),避免误报失败。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
// better-sqlite3 加载失败时(ABI 不匹配)自动跳过整个套件
|
||||
// 注意:require 成功不代表原生绑定可用(Electron ABI vs 系统 Node ABI),
|
||||
// 需实际构造一次内存库验证
|
||||
let dbAvailable = true;
|
||||
let Database: typeof import('better-sqlite3');
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
Database = require('better-sqlite3');
|
||||
const probe = new Database(':memory:');
|
||||
probe.close();
|
||||
} catch {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
// electron-log 在纯 Node 环境静默(false = 禁用该 transport)
|
||||
log.transports.console.level = false;
|
||||
log.transports.file.level = false;
|
||||
|
||||
describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
let dir: string;
|
||||
// eslint-disable 是失效指令(测试文件 overrides 已关闭 no-explicit-any),已移除
|
||||
let db: any;
|
||||
let service: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { AuditService } = await import('../../services/audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-'));
|
||||
// 手动建库(绕过 DatabaseService 的 electron 依赖)
|
||||
db = new Database(join(dir, 'test.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
iteration INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT 'system',
|
||||
target TEXT NOT NULL,
|
||||
details TEXT,
|
||||
outcome TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
prev_hash TEXT,
|
||||
current_hash TEXT
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_logs
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY.');
|
||||
END;
|
||||
`);
|
||||
service = new AuditService(() => db);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { db?.close(); } catch { /* ignore */ }
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('写入审计日志并生成链式哈希', () => {
|
||||
service.log({
|
||||
sessionId: 's1',
|
||||
eventType: 'tool_call',
|
||||
actor: 'agent',
|
||||
target: 'read_file',
|
||||
details: { file: 'a.ts' },
|
||||
outcome: 'success',
|
||||
});
|
||||
const rows = db.prepare('SELECT * FROM audit_logs').all();
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].current_hash).toBeTruthy();
|
||||
// 首条 prev_hash 为 64 个 0 的创世哨兵(getLastHash 空表回退值)
|
||||
expect(rows[0].prev_hash).toBe('0'.repeat(64));
|
||||
});
|
||||
|
||||
it('第二条记录的 prev_hash 链接前一条', () => {
|
||||
service.log({
|
||||
sessionId: 's1',
|
||||
eventType: 'tool_call',
|
||||
actor: 'agent',
|
||||
target: 'write_file',
|
||||
details: { file: 'b.ts' },
|
||||
outcome: 'success',
|
||||
});
|
||||
const rows = db.prepare('SELECT * FROM audit_logs ORDER BY id').all();
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows[1].prev_hash).toBe(rows[0].current_hash);
|
||||
});
|
||||
|
||||
it('verifyChain 对完整链返回 valid', () => {
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.totalRecords).toBe(2);
|
||||
});
|
||||
|
||||
it('篡改记录后 verifyChain 检测到 tampered', () => {
|
||||
// 需要先 drop 触发器才能 UPDATE(INSERT-ONLY 约束)
|
||||
db.exec('DROP TRIGGER audit_no_delete');
|
||||
db.prepare('UPDATE audit_logs SET details = ? WHERE id = 1').run('{"tampered": true}');
|
||||
db.exec(`CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_logs
|
||||
BEGIN SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY.'); END`);
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* SessionSummaryService + truncateMessagesAfter 交互回归测试
|
||||
*
|
||||
* 覆盖全量复检 #1 修复:消息截断必须同步清理 session_summaries 摘要游标,
|
||||
* 否则 (1) afterRowid 过滤返回空 tail 导致截断点前原文永不加载;
|
||||
* (2) 摘要包含被撤销的"未来"内容(因果污染)。
|
||||
*
|
||||
* 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式
|
||||
* (cross-env ELECTRON_RUN_AS_NODE=1 electron ...)执行;系统 Node 下自动跳过。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
let dbAvailable = true;
|
||||
let Database: typeof import('better-sqlite3');
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
Database = require('better-sqlite3');
|
||||
const probe = new Database(':memory:');
|
||||
probe.close();
|
||||
} catch {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互', () => {
|
||||
let db: any;
|
||||
let dir: string;
|
||||
let sessionService: any;
|
||||
let summaryService: any;
|
||||
|
||||
const insertMessage = (sessionId: string, id: string, role: string, content: string): void => {
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(id, sessionId, role, content, Date.now());
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const { SessionService } = await import('../session.service');
|
||||
const { SessionSummaryService } = await import('../session-summary.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-summary-'));
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT DEFAULT '新会话',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
pinned INTEGER DEFAULT 0,
|
||||
archived INTEGER DEFAULT 0,
|
||||
metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
reasoning_content TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_result TEXT,
|
||||
attachments TEXT,
|
||||
iteration INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE session_summaries (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
summarized_until_rowid INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
||||
);
|
||||
`);
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, ?)',
|
||||
).run('s_test', Date.now(), Date.now(), 0);
|
||||
|
||||
sessionService = new SessionService(() => db);
|
||||
summaryService = new SessionSummaryService(
|
||||
() => db,
|
||||
sessionService,
|
||||
// buildHistoryMessages 不触 adapter(仅 maybeSummarize 用),占位即可
|
||||
() => null as unknown as Parameters<typeof Object>[0],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { db?.close(); } catch { /* ignore */ }
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('分层加载:摘要存在时返回 [摘要消息, 游标之后的原文]', () => {
|
||||
// 5 条消息,摘要覆盖到第 3 条(rowid 3)
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
insertMessage('s_test', `m${i}`, i % 2 === 1 ? 'user' : 'assistant', `消息 ${i}`);
|
||||
}
|
||||
summaryService.saveSummary('s_test', '早期对话的滚动摘要', 3);
|
||||
|
||||
const history = summaryService.buildHistoryMessages('s_test');
|
||||
// 1 条摘要 + rowid 4、5 两条原文
|
||||
expect(history.length).toBe(3);
|
||||
expect((history[0].content as string).startsWith('[Context Summary]')).toBe(true);
|
||||
expect(history[1].content).toBe('消息 4');
|
||||
expect(history[2].content).toBe('消息 5');
|
||||
});
|
||||
|
||||
it('其他会话的摘要不影响本会话(游标按 session_id 隔离)', () => {
|
||||
// s_other 的摘要不应影响 s_test 的分层加载结果
|
||||
summaryService.saveSummary('s_other', 'x', 0);
|
||||
const history = summaryService.buildHistoryMessages('s_test');
|
||||
// s_test 摘要(游标 3)仍生效:摘要 + rowid 4、5
|
||||
expect(history.length).toBe(3);
|
||||
});
|
||||
|
||||
it('截断清理摘要(缺陷 #1 回归):游标落在删除范围内时摘要被删', () => {
|
||||
// 编辑重发第 2 条消息(rowid 2,inclusive)→ 删除 rowid>=2 的 4 条
|
||||
// 摘要游标为 3,落在 [2, ∞) 内 → 摘要必须被清理
|
||||
const truncated = sessionService.truncateMessagesAfter('s_test', 'm2', true);
|
||||
expect(truncated).toBe(true);
|
||||
|
||||
// 摘要已删除
|
||||
expect(summaryService.getSummary('s_test')).toBeNull();
|
||||
// 只剩 rowid 1 的消息
|
||||
const remaining = sessionService.getMessages('s_test');
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0].content).toBe('消息 1');
|
||||
// 分层加载退化为全量原文(无摘要注入)
|
||||
const history = summaryService.buildHistoryMessages('s_test');
|
||||
expect(history.length).toBe(1);
|
||||
expect(history[0].content).toBe('消息 1');
|
||||
});
|
||||
|
||||
it('inclusive=false:游标等于锚点时摘要保留(语义仍一致)', () => {
|
||||
// 重建场景:rowid 1(消息1)+ 新插入 3 条 → rowid 2-4;摘要游标 = 2
|
||||
insertMessage('s_test', 'm2b', 'user', '消息 2b');
|
||||
insertMessage('s_test', 'm3b', 'assistant', '消息 3b');
|
||||
insertMessage('s_test', 'm4b', 'user', '消息 4b');
|
||||
summaryService.saveSummary('s_test', '摘要到 rowid2', 2);
|
||||
|
||||
// 截断 rowid > 3(保留锚点 3):游标 2 < 3+1 → 摘要保留
|
||||
const truncated = sessionService.truncateMessagesAfter('s_test', 'm3b', false);
|
||||
expect(truncated).toBe(true);
|
||||
expect(summaryService.getSummary('s_test')).not.toBeNull();
|
||||
|
||||
// 分层加载:摘要 + rowid 3 原文(rowid 4 已删)
|
||||
const history = summaryService.buildHistoryMessages('s_test');
|
||||
expect(history.length).toBe(2);
|
||||
expect(history[1].content).toBe('消息 3b');
|
||||
});
|
||||
|
||||
it('锚点消息不存在时返回 false 且不产生副作用', () => {
|
||||
const before = sessionService.getMessages('s_test').length;
|
||||
expect(sessionService.truncateMessagesAfter('s_test', 'not_exist', true)).toBe(false);
|
||||
expect(sessionService.getMessages('s_test').length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Agent Engine Manager — 每会话独立引擎管理器(P2-10)
|
||||
*
|
||||
* 解决原"全局单引擎"的两个缺陷:
|
||||
* 1. 全局串行锁:原 AgentLoopEngine.currentRunPromise 使所有会话共享一把锁,
|
||||
* 上一会话未结束时新会话消息需排队等待(最长卡 120s 工具超时)。
|
||||
* 现在每个会话持有独立引擎实例,多会话可并行运行。
|
||||
* 2. adapter abort 信号互踩:原所有引擎/SubAgent 共享一个 adapter 实例,
|
||||
* setAbortSignal 单槽位导致并发时中断信号错乱。
|
||||
* 现在创建引擎时通过 adapter 工厂为每个引擎生成独立 adapter 实例
|
||||
* (adapter 是无状态的配置包装,实例化成本可忽略)。
|
||||
*
|
||||
* 引擎生命周期:
|
||||
* - 按需创建(首次 sendMessage 时),事件统一转发到 manager(附加 sessionId)
|
||||
* - LRU 淘汰:缓存超过 30 个引擎时,淘汰最旧的非运行中引擎
|
||||
*
|
||||
* @see electron/harness/agent-loop/engine.ts — 引擎实现
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { AgentLoopEngine } from '../harness/agent-loop';
|
||||
import type { AgentLoopConfig } from '../harness/agent-loop/types';
|
||||
import type { IMetonaProviderAdapter, MetonaToolDef } from '../harness/types';
|
||||
import type { ToolRegistry } from '../harness/tools/registry';
|
||||
import type { PreToolHook } from '../harness/hooks/pre-tool';
|
||||
import type { PostToolHook } from '../harness/hooks/post-tool';
|
||||
import log from 'electron-log';
|
||||
|
||||
/** 引擎缓存上限(超过后淘汰最旧的非运行中引擎) */
|
||||
const MAX_ENGINES = 30;
|
||||
|
||||
export class AgentEngineManager extends EventEmitter {
|
||||
private engines = new Map<string, AgentLoopEngine>();
|
||||
/** 运行中的会话(stateChange INIT 添加 / TERMINATED 移除),用于 LRU 淘汰保护 */
|
||||
private running = new Set<string>();
|
||||
/** 主 adapter(供 MemoryConsolidator 等共享组件使用) */
|
||||
private primaryAdapter: IMetonaProviderAdapter;
|
||||
private fallbackAdapter: IMetonaProviderAdapter | null = null;
|
||||
private baseConfig: Partial<AgentLoopConfig>;
|
||||
private workspacePath = '';
|
||||
|
||||
constructor(private opts: {
|
||||
/** adapter 工厂(每次调用返回新实例;闭包内读取最新配置) */
|
||||
buildAdapter: () => IMetonaProviderAdapter;
|
||||
baseConfig: Partial<AgentLoopConfig>;
|
||||
toolRegistry?: ToolRegistry;
|
||||
preToolHooks?: PreToolHook[];
|
||||
postToolHooks?: PostToolHook[];
|
||||
}) {
|
||||
super();
|
||||
this.primaryAdapter = opts.buildAdapter();
|
||||
this.baseConfig = { ...opts.baseConfig };
|
||||
}
|
||||
|
||||
/** 主 adapter(供 consolidator / orchestrator 等共享使用) */
|
||||
getAdapter(): IMetonaProviderAdapter {
|
||||
return this.primaryAdapter;
|
||||
}
|
||||
|
||||
/** 创建独立 adapter 实例(每引擎/SubAgent 独享,避免 abort 信号互踩) */
|
||||
createAdapter(): IMetonaProviderAdapter {
|
||||
return this.opts.buildAdapter();
|
||||
}
|
||||
|
||||
getFallbackAdapter(): IMetonaProviderAdapter | null {
|
||||
return this.fallbackAdapter;
|
||||
}
|
||||
|
||||
/** 设置故障转移 Provider(同步到所有引擎) */
|
||||
setFallbackAdapter(adapter: IMetonaProviderAdapter | null): void {
|
||||
this.fallbackAdapter = adapter;
|
||||
for (const engine of this.engines.values()) {
|
||||
engine.setFallbackAdapter(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
getWorkspacePath(): string {
|
||||
return this.workspacePath;
|
||||
}
|
||||
|
||||
setWorkspacePath(path: string): void {
|
||||
this.workspacePath = path;
|
||||
for (const engine of this.engines.values()) {
|
||||
engine.setWorkspacePath(path);
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步工具列表到所有引擎(工具开关变更 / MCP 注册完成时) */
|
||||
setToolsAll(tools: MetonaToolDef[]): void {
|
||||
for (const engine of this.engines.values()) {
|
||||
engine.setTools(tools);
|
||||
}
|
||||
}
|
||||
|
||||
/** 热更新所有引擎配置(设置变更时) */
|
||||
updateConfigAll(partial: Partial<AgentLoopConfig>): void {
|
||||
this.baseConfig = { ...this.baseConfig, ...partial };
|
||||
for (const engine of this.engines.values()) {
|
||||
engine.updateConfig(partial);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建所有 adapter(LLM 配置变更时由 reloadAdapter 调用)
|
||||
* 工厂闭包读取最新配置,重建 primary + 各引擎独立实例。
|
||||
*/
|
||||
refreshAdapters(): void {
|
||||
this.primaryAdapter = this.opts.buildAdapter();
|
||||
for (const engine of this.engines.values()) {
|
||||
engine.setAdapter(this.createAdapter());
|
||||
engine.setFallbackAdapter(this.fallbackAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取(或创建)会话引擎 */
|
||||
getEngine(sessionId: string): AgentLoopEngine {
|
||||
let engine = this.engines.get(sessionId);
|
||||
if (!engine) {
|
||||
engine = this.createEngine(sessionId);
|
||||
this.engines.set(sessionId, engine);
|
||||
this.evictIdleEngines();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** 中断指定会话引擎 */
|
||||
abort(sessionId: string): void {
|
||||
this.engines.get(sessionId)?.abort();
|
||||
}
|
||||
|
||||
/** 等待指定会话当前 run 结束 */
|
||||
async waitForAbort(sessionId: string, timeoutMs = 5_000): Promise<boolean> {
|
||||
return this.engines.get(sessionId)?.waitForAbort(timeoutMs) ?? true;
|
||||
}
|
||||
|
||||
/** 当前引擎数量(测试/诊断用) */
|
||||
get size(): number {
|
||||
return this.engines.size;
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
private createEngine(sessionId: string): AgentLoopEngine {
|
||||
const engine = new AgentLoopEngine(
|
||||
{
|
||||
...this.baseConfig,
|
||||
contextWindow: this.primaryAdapter.getContextWindow(),
|
||||
},
|
||||
this.createAdapter(),
|
||||
this.opts.toolRegistry,
|
||||
this.opts.preToolHooks ?? [],
|
||||
this.opts.postToolHooks ?? [],
|
||||
);
|
||||
engine.setFallbackAdapter(this.fallbackAdapter);
|
||||
if (this.workspacePath) engine.setWorkspacePath(this.workspacePath);
|
||||
this.forwardEngineEvents(engine, sessionId);
|
||||
log.debug(`[EngineManager] engine created for session ${sessionId} (total: ${this.engines.size + 1})`);
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** 将引擎事件转发到 manager(统一附加 sessionId,供常驻监听器消费) */
|
||||
private forwardEngineEvents(engine: AgentLoopEngine, sessionId: string): void {
|
||||
engine.on('streamEvent', (event) => {
|
||||
this.emit('streamEvent', { ...event, sessionId: event.sessionId || sessionId });
|
||||
});
|
||||
engine.on('stateChange', (data) => {
|
||||
const payload = { ...data, sessionId: data.sessionId || sessionId };
|
||||
// 维护运行中集合(LRU 淘汰保护)
|
||||
if (payload.state === 'INIT' || payload.current === 'INIT') this.running.add(sessionId);
|
||||
if (payload.state === 'TERMINATED' || payload.current === 'TERMINATED') this.running.delete(sessionId);
|
||||
this.emit('stateChange', payload);
|
||||
});
|
||||
engine.on('complete', (data) => {
|
||||
this.running.delete(sessionId);
|
||||
this.emit('complete', { ...data, sessionId: data.sessionId || sessionId });
|
||||
});
|
||||
engine.on('compressed', (data) => {
|
||||
this.emit('compressed', { ...data, sessionId });
|
||||
});
|
||||
engine.on('deadLoop', (data) => {
|
||||
this.emit('deadLoop', { ...data, sessionId: data.sessionId || sessionId });
|
||||
});
|
||||
engine.on('providerSwitched', (data) => {
|
||||
this.emit('providerSwitched', { ...data, sessionId: data.sessionId || sessionId });
|
||||
});
|
||||
engine.on('aborted', () => {
|
||||
this.emit('aborted', { sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
/** LRU 淘汰:超过上限时删除最旧的非运行中引擎 */
|
||||
private evictIdleEngines(): void {
|
||||
if (this.engines.size <= MAX_ENGINES) return;
|
||||
let toEvict = this.engines.size - MAX_ENGINES;
|
||||
for (const [sessionId, engine] of this.engines) {
|
||||
if (toEvict <= 0) break;
|
||||
if (this.running.has(sessionId)) continue;
|
||||
engine.destroy();
|
||||
this.engines.delete(sessionId);
|
||||
log.debug(`[EngineManager] evicted idle engine for session ${sessionId}`);
|
||||
toEvict--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
import { GlobalConfigService, isGlobalKey, isUnconfiguredGlobalKey } from './global-config.service';
|
||||
import { decryptConfigValue, encryptConfigValue, isSensitiveConfigKey } from '../utils/secure-config';
|
||||
|
||||
export interface ConfigRow {
|
||||
key: string;
|
||||
@@ -56,6 +57,11 @@ export class ConfigService {
|
||||
parsed = row.value;
|
||||
}
|
||||
|
||||
// P0-1: 敏感 key 落盘为密文,读取时解密(含历史明文的平滑兼容——明文原样返回)
|
||||
if (isSensitiveConfigKey(key)) {
|
||||
parsed = decryptConfigValue(parsed);
|
||||
}
|
||||
|
||||
// 全局 key 的空值回退:若工作空间 DB 存在该 key 但值为"未配置"(如 llm.apiKey=''),
|
||||
// 且全局层有真实值,则返回全局层的值。
|
||||
// 解决场景:新工作空间 DB 被 seedDefaults 灌入 llm.apiKey='' / onboarding.completed=false,
|
||||
@@ -81,10 +87,12 @@ export class ConfigService {
|
||||
/**
|
||||
* 设置配置值(保留已有 category)
|
||||
* 全局 key 同时写入工作空间 DB 和全局 JSON
|
||||
* P0-1: 敏感 key(apiKey 等)加密后再落盘(DB 与全局 JSON 均为密文)
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
const db = this.getDB();
|
||||
const jsonValue = JSON.stringify(value);
|
||||
const storedValue = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
|
||||
const jsonValue = JSON.stringify(storedValue);
|
||||
|
||||
// 查询已有 category,避免 INSERT OR REPLACE 覆盖为默认值
|
||||
const existing = db.prepare('SELECT category FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
|
||||
@@ -95,9 +103,9 @@ export class ConfigService {
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(key, jsonValue, category, Date.now());
|
||||
|
||||
// 全局 key 同步写入全局层(跨工作空间共享)
|
||||
// 全局 key 同步写入全局层(跨工作空间共享;全局层内部同样加密)
|
||||
if (this.globalConfig && isGlobalKey(key)) {
|
||||
this.globalConfig.set(key, value);
|
||||
this.globalConfig.set(key, storedValue);
|
||||
}
|
||||
|
||||
log.debug(`Config set: ${key}`);
|
||||
@@ -118,6 +126,10 @@ export class ConfigService {
|
||||
} catch {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
// P0-1: 敏感 key 解密后再返回(与 get() 行为一致)
|
||||
if (isSensitiveConfigKey(row.key)) {
|
||||
config[row.key] = decryptConfigValue(config[row.key]);
|
||||
}
|
||||
}
|
||||
|
||||
// 全局层合并:与 get() 的回退逻辑保持一致
|
||||
|
||||
@@ -21,6 +21,74 @@ function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */
|
||||
export interface ConfigDefaultEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部配置默认值(唯一维护点)
|
||||
*
|
||||
* 注意:新增/修改配置默认值时只需改这里,全局配置层(SEED_DEFAULTS 判定"未配置"的依据)
|
||||
* 会自动同步,避免双源漂移。
|
||||
*/
|
||||
export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
|
||||
// LLM 配置(无硬编码值,用户必须手动配置)
|
||||
{ key: 'llm.provider', value: '', category: 'llm' },
|
||||
{ key: 'llm.model', value: '', category: 'llm' },
|
||||
{ key: 'llm.apiKey', value: '', category: 'llm' },
|
||||
{ key: 'llm.baseURL', value: '', category: 'llm' },
|
||||
{ key: 'llm.temperature', value: 0, category: 'llm' },
|
||||
{ key: 'llm.maxTokens', value: 63488, category: 'llm' },
|
||||
// P1: Provider 故障转移配置
|
||||
{ key: 'llm.fallbackProvider', value: '', category: 'llm' },
|
||||
{ key: 'llm.fallbackModel', value: '', category: 'llm' },
|
||||
{ key: 'llm.fallbackApiKey', value: '', category: 'llm' },
|
||||
{ key: 'llm.fallbackBaseURL', value: '', category: 'llm' },
|
||||
|
||||
// Agent 配置
|
||||
{ key: 'agent.maxIterations', value: 20, category: 'agent' },
|
||||
{ key: 'agent.totalTimeoutMs', value: 600000, category: 'agent' },
|
||||
{ key: 'agent.enableThinking', value: true, category: 'agent' },
|
||||
{ key: 'agent.thinkingEffort', value: 'high', category: 'agent' },
|
||||
{ key: 'agent.enableReflection', value: false, category: 'agent' },
|
||||
// C-10 修复: 补充缺失的 agent 配置默认值
|
||||
// @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s)
|
||||
{ key: 'agent.confirmationTimeoutMs', value: 120000, category: 'agent' },
|
||||
{ key: 'agent.toolExecutionTimeoutMs', value: 120000, category: 'agent' },
|
||||
|
||||
// 安全配置
|
||||
{ key: 'security.requireWriteConfirmation', value: true, category: 'security' },
|
||||
{ key: 'security.maxFileWriteSizeKB', value: 1024, category: 'security' },
|
||||
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
|
||||
|
||||
// UI 配置
|
||||
{ key: 'ui.theme', value: 'auto', category: 'ui' },
|
||||
{ key: 'ui.animationMode', value: 'auto', category: 'ui' },
|
||||
{ key: 'ui.fontSize', value: 'medium', category: 'ui' },
|
||||
|
||||
// 日志配置
|
||||
{ key: 'logging.level', value: 'info', category: 'logging' },
|
||||
{ key: 'logging.auditEnabled', value: true, category: 'logging' },
|
||||
{ key: 'logging.traceEnabled', value: true, category: 'logging' },
|
||||
|
||||
// Ollama 配置
|
||||
{ key: 'ollama.numCtx', value: null, category: 'ollama' },
|
||||
|
||||
// Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示)
|
||||
{ key: 'deepseek.contextWindow', value: 1000000, category: 'deepseek' },
|
||||
{ key: 'agnes.contextWindow', value: 1000000, category: 'agnes' },
|
||||
{ key: 'mimo.contextWindow', value: 1000000, category: 'mimo' },
|
||||
// P3: OpenAI / Anthropic Provider
|
||||
{ key: 'openai.contextWindow', value: 128000, category: 'openai' },
|
||||
{ key: 'anthropic.contextWindow', value: 200000, category: 'anthropic' },
|
||||
|
||||
// Onboarding
|
||||
{ key: 'onboarding.completed', value: false, category: 'general' },
|
||||
];
|
||||
|
||||
export class DatabaseService {
|
||||
private db: Database.Database | null = null;
|
||||
private dbPath: string;
|
||||
@@ -212,6 +280,14 @@ export class DatabaseService {
|
||||
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ===== P2: 会话摘要表(分层上下文——超长会话早期消息压缩为摘要,LLM 只加载摘要 + 近期原文) =====
|
||||
CREATE TABLE IF NOT EXISTS session_summaries (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
summarized_until_rowid INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
||||
);
|
||||
|
||||
-- ===== 索引 =====
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
||||
@@ -257,7 +333,7 @@ export class DatabaseService {
|
||||
|
||||
// L-6 修复: 提取 tryAddColumn 辅助方法,消除 4 处重复的 try/catch 模式
|
||||
// L-8 修复: 使用 toErrorMessage 替代重复的 error instanceof Error 三元表达式
|
||||
const tryAddColumn = (table: string, column: string, type: string, migrationName: string) => {
|
||||
const tryAddColumn = (table: string, column: string, type: string) => {
|
||||
try {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
||||
log.info(`[DB] Migration: added ${column} column to ${table}`);
|
||||
@@ -276,13 +352,18 @@ export class DatabaseService {
|
||||
// better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。
|
||||
const runAllMigrations = db.transaction(() => {
|
||||
// 迁移 1: messages 表添加 attachments 列
|
||||
tryAddColumn('messages', 'attachments', 'TEXT', 'attachments');
|
||||
tryAddColumn('messages', 'attachments', 'TEXT');
|
||||
// 迁移 2: audit_logs 表添加 iteration 列
|
||||
tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration');
|
||||
tryAddColumn('audit_logs', 'iteration', 'INTEGER');
|
||||
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
|
||||
tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash');
|
||||
tryAddColumn('audit_logs', 'prev_hash', 'TEXT');
|
||||
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
|
||||
tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash');
|
||||
tryAddColumn('audit_logs', 'current_hash', 'TEXT');
|
||||
|
||||
// P2: 记忆 TF 缓存列(存储 tokenize 结果,避免每次检索重复分词)
|
||||
tryAddColumn('episodic_memories', 'tf_cache', 'TEXT');
|
||||
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
|
||||
tryAddColumn('working_memories', 'tf_cache', 'TEXT');
|
||||
|
||||
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
|
||||
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
||||
@@ -345,73 +426,22 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入默认配置
|
||||
* 插入默认配置(P1-13: 从 CONFIG_DEFAULTS 单一来源派生)
|
||||
*/
|
||||
private seedDefaults(): void {
|
||||
const db = this.db!;
|
||||
|
||||
const defaults: Array<{ key: string; value: string; category: string }> = [
|
||||
// LLM 配置(无硬编码值,用户必须手动配置)
|
||||
{ key: 'llm.provider', value: '""', category: 'llm' },
|
||||
{ key: 'llm.model', value: '""', category: 'llm' },
|
||||
{ key: 'llm.apiKey', value: '""', category: 'llm' },
|
||||
{ key: 'llm.baseURL', value: '""', category: 'llm' },
|
||||
{ key: 'llm.temperature', value: '0', category: 'llm' },
|
||||
{ key: 'llm.maxTokens', value: '63488', category: 'llm' },
|
||||
{ key: 'llm.fallbackProvider', value: '""', category: 'llm' },
|
||||
{ key: 'llm.fallbackModel', value: '""', category: 'llm' },
|
||||
|
||||
// Agent 配置
|
||||
{ key: 'agent.maxIterations', value: '20', category: 'agent' },
|
||||
{ key: 'agent.totalTimeoutMs', value: '600000', category: 'agent' },
|
||||
{ key: 'agent.enableThinking', value: 'true', category: 'agent' },
|
||||
{ key: 'agent.thinkingEffort', value: '"high"', category: 'agent' },
|
||||
{ key: 'agent.enableReflection', value: 'false', category: 'agent' },
|
||||
// C-10 修复: 补充缺失的 agent 配置默认值
|
||||
// @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s)
|
||||
{ key: 'agent.confirmationTimeoutMs', value: '120000', category: 'agent' },
|
||||
{ key: 'agent.toolExecutionTimeoutMs', value: '120000', category: 'agent' },
|
||||
|
||||
// 安全配置
|
||||
{ key: 'security.requireWriteConfirmation', value: 'true', category: 'security' },
|
||||
{ key: 'security.maxFileWriteSizeKB', value: '1024', category: 'security' },
|
||||
{ key: 'security.promptInjectionDefense', value: 'true', category: 'security' },
|
||||
|
||||
// UI 配置
|
||||
{ key: 'ui.theme', value: '"auto"', category: 'ui' },
|
||||
{ key: 'ui.animationMode', value: '"auto"', category: 'ui' },
|
||||
{ key: 'ui.fontSize', value: '"medium"', category: 'ui' },
|
||||
|
||||
// 日志配置
|
||||
{ key: 'logging.level', value: '"info"', category: 'logging' },
|
||||
{ key: 'logging.auditEnabled', value: 'true', category: 'logging' },
|
||||
{ key: 'logging.traceEnabled', value: 'true', category: 'logging' },
|
||||
|
||||
// Ollama 配置
|
||||
{ key: 'ollama.numCtx', value: 'null', category: 'ollama' },
|
||||
|
||||
// Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示)
|
||||
// v0.3.1: DeepSeek/Agnes 改为可配置,不再写死 1M
|
||||
{ key: 'deepseek.contextWindow', value: '1000000', category: 'deepseek' },
|
||||
{ key: 'agnes.contextWindow', value: '1000000', category: 'agnes' },
|
||||
// v0.3.4: MiMo 上下文窗口(与 DeepSeek 一致,1M)
|
||||
{ key: 'mimo.contextWindow', value: '1000000', category: 'mimo' },
|
||||
|
||||
// Onboarding
|
||||
{ key: 'onboarding.completed', value: 'false', category: 'general' },
|
||||
];
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT OR IGNORE INTO app_config (key, value, category) VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction((items: typeof defaults) => {
|
||||
const insertMany = db.transaction((items: ConfigDefaultEntry[]) => {
|
||||
for (const item of items) {
|
||||
insert.run(item.key, item.value, item.category);
|
||||
insert.run(item.key, JSON.stringify(item.value), item.category);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(defaults);
|
||||
insertMany(CONFIG_DEFAULTS);
|
||||
log.info('Default config seeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import log from 'electron-log';
|
||||
import { CONFIG_DEFAULTS } from './database.service';
|
||||
import { decryptConfigValue, encryptConfigValue, isSensitiveConfigKey } from '../utils/secure-config';
|
||||
|
||||
/** 全局配置文件路径(userData 下,与工作空间无关) */
|
||||
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
|
||||
@@ -38,6 +40,8 @@ const GLOBAL_KEY_PREFIXES = [
|
||||
'deepseek.',
|
||||
'agnes.',
|
||||
'mimo.',
|
||||
'openai.',
|
||||
'anthropic.',
|
||||
'onboarding.',
|
||||
];
|
||||
|
||||
@@ -48,39 +52,13 @@ export function isGlobalKey(key: string): boolean {
|
||||
|
||||
/**
|
||||
* seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层")
|
||||
* 必须与 database.service.ts 的 seedDefaults 保持同步
|
||||
*
|
||||
* P1-13: 由 database.service.ts 的 CONFIG_DEFAULTS 单一来源派生,
|
||||
* 不再手工维护副本(原双源曾出现漂移风险)。
|
||||
*/
|
||||
export const SEED_DEFAULTS: Record<string, unknown> = {
|
||||
'llm.provider': '',
|
||||
'llm.model': '',
|
||||
'llm.apiKey': '',
|
||||
'llm.baseURL': '',
|
||||
'llm.temperature': 0,
|
||||
'llm.maxTokens': 63488,
|
||||
'llm.fallbackProvider': '',
|
||||
'llm.fallbackModel': '',
|
||||
'agent.maxIterations': 20,
|
||||
'agent.totalTimeoutMs': 600000,
|
||||
'agent.enableThinking': true,
|
||||
'agent.thinkingEffort': 'high',
|
||||
'agent.enableReflection': false,
|
||||
'agent.confirmationTimeoutMs': 120000,
|
||||
'agent.toolExecutionTimeoutMs': 120000,
|
||||
'security.requireWriteConfirmation': true,
|
||||
'security.maxFileWriteSizeKB': 1024,
|
||||
'security.promptInjectionDefense': true,
|
||||
'ui.theme': 'auto',
|
||||
'ui.animationMode': 'auto',
|
||||
'ui.fontSize': 'medium',
|
||||
'logging.level': 'info',
|
||||
'logging.auditEnabled': true,
|
||||
'logging.traceEnabled': true,
|
||||
'ollama.numCtx': null,
|
||||
'deepseek.contextWindow': 1000000,
|
||||
'agnes.contextWindow': 1000000,
|
||||
'mimo.contextWindow': 1000000,
|
||||
'onboarding.completed': false,
|
||||
};
|
||||
export const SEED_DEFAULTS: Record<string, unknown> = Object.fromEntries(
|
||||
CONFIG_DEFAULTS.map((d) => [d.key, d.value]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 判断全局 key 在工作空间 DB 中的值是否为"未配置"(应回退到全局层)
|
||||
@@ -146,7 +124,7 @@ export class GlobalConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全局配置
|
||||
* 读取全局配置(P0-1: 敏感 key 自动解密)
|
||||
*/
|
||||
get<T = unknown>(key: string): T | null {
|
||||
if (!this.initialized) {
|
||||
@@ -154,26 +132,27 @@ export class GlobalConfigService {
|
||||
return null;
|
||||
}
|
||||
if (key in this.data) {
|
||||
return this.data[key] as T;
|
||||
const raw = this.data[key];
|
||||
return (isSensitiveConfigKey(key) ? decryptConfigValue(raw) : raw) as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入全局配置(内存 + 立即落盘)
|
||||
* 写入全局配置(内存 + 立即落盘;P0-1: 敏感 key 加密后存储)
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
if (!this.initialized) {
|
||||
log.warn('[GlobalConfig] set() called before initialize()');
|
||||
return;
|
||||
}
|
||||
this.data[key] = value;
|
||||
this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
|
||||
this.flush();
|
||||
log.debug(`[GlobalConfig] set: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量写入(仅落盘一次,避免多次 IO)
|
||||
* 批量写入(仅落盘一次,避免多次 IO;敏感 key 加密后存储)
|
||||
*/
|
||||
setBatch(entries: Array<{ key: string; value: unknown }>): void {
|
||||
if (!this.initialized) {
|
||||
@@ -181,17 +160,21 @@ export class GlobalConfigService {
|
||||
return;
|
||||
}
|
||||
for (const { key, value } of entries) {
|
||||
this.data[key] = value;
|
||||
this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
|
||||
}
|
||||
this.flush();
|
||||
log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有全局配置(用于迁移和调试)
|
||||
* 获取所有全局配置(用于迁移和调试;敏感 key 解密后返回)
|
||||
*/
|
||||
getAll(): GlobalConfigData {
|
||||
return { ...this.data };
|
||||
const out: GlobalConfigData = {};
|
||||
for (const [key, value] of Object.entries(this.data)) {
|
||||
out[key] = isSensitiveConfigKey(key) ? decryptConfigValue(value) : value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +205,7 @@ export class GlobalConfigService {
|
||||
// 注意:isUnconfiguredGlobalKey 已包含"值等于默认值"判断,此处复用
|
||||
if (isUnconfiguredGlobalKey(key, value)) continue;
|
||||
|
||||
this.data[key] = value;
|
||||
this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
|
||||
migrated++;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,13 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* 初始化:从数据库加载已启用的 MCP Server 并连接
|
||||
*
|
||||
* P1-11: 等待所有连接完成(或 30s 超时)后才返回,保证调用方广播 tools:ready
|
||||
* 时 MCP 工具已实际注册完成。原实现 connectServer 不 await,工具就绪广播
|
||||
* 早于工具注册,存在"广播后一段时间内 MCP 工具仍不可用"的窗口。
|
||||
*
|
||||
* 单个 server 连接超时(30s)用于兜底:stdio server 挂起时不阻塞整体就绪,
|
||||
* 超时的 server 会显示 error 状态,用户可在设置面板查看原因。
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const db = this.getDB();
|
||||
@@ -212,24 +219,31 @@ export class MCPManager {
|
||||
command: string | null; args: string | null; url: string | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const config: MCPServerConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
transport: row.transport as 'stdio' | 'sse',
|
||||
command: row.command ?? undefined,
|
||||
args: row.args ? safeParseArgs(row.args) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
enabled: true,
|
||||
};
|
||||
const connectWithTimeout = (config: MCPServerConfig): Promise<unknown> =>
|
||||
Promise.race([
|
||||
this.connectServer(config),
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000)),
|
||||
]);
|
||||
|
||||
// 异步连接,不阻塞启动
|
||||
this.connectServer(config).catch((err) => {
|
||||
log.warn(`MCP server "${config.name}" auto-connect failed: ${err}`);
|
||||
});
|
||||
}
|
||||
const settled = await Promise.allSettled(
|
||||
rows.map((row) => {
|
||||
const config: MCPServerConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
transport: row.transport as 'stdio' | 'sse',
|
||||
command: row.command ?? undefined,
|
||||
args: row.args ? safeParseArgs(row.args) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
enabled: true,
|
||||
};
|
||||
return connectWithTimeout(config).catch((err) => {
|
||||
log.warn(`MCP server "${config.name}" auto-connect failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
log.info(`MCP Manager initialized: ${rows.length} server(s) configured`);
|
||||
const failed = settled.filter((s) => s.status === 'rejected').length;
|
||||
log.info(`MCP Manager initialized: ${rows.length} server(s) configured (${failed} failed)`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
* 负责将完整的会话执行轨迹写入 session_*.jsonl 文件。
|
||||
* 每行一条 JSON 事件,支持事后回放和分析。
|
||||
*
|
||||
* P1-6 重构:
|
||||
* 1. 多会话支持——每个会话独立的状态(文件路径/seq),并发会话互不串扰
|
||||
* (原实现单会话状态,第二个会话 startRecording 会覆盖第一个的录制目标)
|
||||
* 2. 事件签名统一携带 sessionId 参数,与 README 宣称的 9 种事件对齐:
|
||||
* session_start, context_built, iteration_start, llm_request,
|
||||
* llm_response, tool_call, tool_result, iteration_end, session_end
|
||||
*
|
||||
* 日志格式:SSE-like JSON Lines
|
||||
* 事件类型:session_start, context_built, iteration_start, llm_request,
|
||||
* llm_response, tool_call, tool_result, iteration_end, session_end
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 全链路透明可追踪
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章
|
||||
@@ -37,58 +42,76 @@ export interface TraceEvent {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 单个会话的录制状态 */
|
||||
interface SessionRecordState {
|
||||
filePath: string;
|
||||
seq: number;
|
||||
buffer: string[];
|
||||
flushTimer: NodeJS.Timeout | null;
|
||||
flushing: boolean;
|
||||
}
|
||||
|
||||
// ===== 服务类 =====
|
||||
|
||||
export class SessionRecorder {
|
||||
private filePath: string | null = null;
|
||||
private seq = 0;
|
||||
private sessionId: string | null = null;
|
||||
// #35 修复: 缓冲写入,避免高频 appendFileSync 阻塞主进程(30-50 次/秒 → 每 100ms 批量异步 flush)
|
||||
private buffer: string[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private flushing = false;
|
||||
/** P1-6: 每会话独立状态(支持并发会话录制) */
|
||||
private sessions = new Map<string, SessionRecordState>();
|
||||
|
||||
constructor(private workspacePath: string) {}
|
||||
|
||||
/** 获取指定会话的录制状态(不存在返回 null) */
|
||||
private state(sessionId: string): SessionRecordState | null {
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制会话
|
||||
*/
|
||||
startRecording(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
this.seq = 0;
|
||||
|
||||
const logsDir = join(this.workspacePath, 'logs');
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
this.filePath = join(logsDir, `session_${sessionId}_${timestamp}.jsonl`);
|
||||
const filePath = join(logsDir, `session_${sessionId}_${timestamp}.jsonl`);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
filePath,
|
||||
seq: 0,
|
||||
buffer: [],
|
||||
flushTimer: null,
|
||||
flushing: false,
|
||||
});
|
||||
|
||||
// 写入 session_start 事件
|
||||
this.writeEvent({
|
||||
this.writeEvent(sessionId, {
|
||||
event: 'session_start',
|
||||
sessionId,
|
||||
workspace: this.workspacePath,
|
||||
});
|
||||
|
||||
log.info(`Session recording started: ${this.filePath}`);
|
||||
log.info(`Session recording started: ${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制
|
||||
* 停止录制(P1-6: 按会话停止,不影响其他并发录制)
|
||||
*/
|
||||
stopRecording(params: {
|
||||
totalIterations: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
terminationReason: string;
|
||||
}): void {
|
||||
if (!this.sessionId || !this.filePath) return;
|
||||
stopRecording(
|
||||
sessionId: string,
|
||||
params: {
|
||||
totalIterations: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
terminationReason: string;
|
||||
},
|
||||
): void {
|
||||
const state = this.state(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
this.writeEvent({
|
||||
this.writeEvent(sessionId, {
|
||||
event: 'session_end',
|
||||
sessionId: this.sessionId,
|
||||
sessionId,
|
||||
totalIterations: params.totalIterations,
|
||||
totalTokens: params.totalTokens,
|
||||
durationMs: params.durationMs,
|
||||
@@ -96,20 +119,19 @@ export class SessionRecorder {
|
||||
});
|
||||
|
||||
// #35 修复: 同步 flush 确保最后的 session_end 事件写入文件
|
||||
this.flushSync();
|
||||
this.flushSync(sessionId);
|
||||
|
||||
log.info(`Session recording stopped: ${this.filePath}`);
|
||||
this.filePath = null;
|
||||
this.sessionId = null;
|
||||
log.info(`Session recording stopped: ${state.filePath}`);
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录上下文构建
|
||||
*/
|
||||
recordContextBuilt(params: { tokenCount: number; usageRatio: number }): void {
|
||||
this.writeEvent({
|
||||
recordContextBuilt(sessionId: string, params: { tokenCount: number; usageRatio: number }): void {
|
||||
this.writeEvent(sessionId, {
|
||||
event: 'context_built',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId,
|
||||
tokens: params.tokenCount,
|
||||
ratio: params.usageRatio,
|
||||
});
|
||||
@@ -118,10 +140,10 @@ export class SessionRecorder {
|
||||
/**
|
||||
* 记录迭代开始
|
||||
*/
|
||||
recordIterationStart(iteration: number): void {
|
||||
this.writeEvent({
|
||||
recordIterationStart(sessionId: string, iteration: number): void {
|
||||
this.writeEvent(sessionId, {
|
||||
event: 'iteration_start',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId,
|
||||
iteration,
|
||||
});
|
||||
}
|
||||
@@ -130,14 +152,15 @@ export class SessionRecorder {
|
||||
* 记录 LLM 请求
|
||||
*/
|
||||
recordLLMRequest(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
this.writeEvent(params.sessionId, {
|
||||
event: 'llm_request',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
@@ -149,14 +172,15 @@ export class SessionRecorder {
|
||||
* 记录 LLM 响应
|
||||
*/
|
||||
recordLLMResponse(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
content: string;
|
||||
finishReason: string;
|
||||
tokenUsage: { input: number; output: number; total: number };
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
this.writeEvent(params.sessionId, {
|
||||
event: 'llm_response',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
contentPreview: params.content.slice(0, 200),
|
||||
finishReason: params.finishReason,
|
||||
@@ -168,13 +192,14 @@ export class SessionRecorder {
|
||||
* 记录工具调用
|
||||
*/
|
||||
recordToolCall(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
this.writeEvent(params.sessionId, {
|
||||
event: 'tool_call',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
tool: params.toolName,
|
||||
args: params.args,
|
||||
@@ -185,6 +210,7 @@ export class SessionRecorder {
|
||||
* 记录工具结果
|
||||
*/
|
||||
recordToolResult(params: {
|
||||
sessionId: string;
|
||||
iteration: number;
|
||||
toolName: string;
|
||||
success: boolean;
|
||||
@@ -192,9 +218,9 @@ export class SessionRecorder {
|
||||
resultPreview?: string;
|
||||
error?: string;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
this.writeEvent(params.sessionId, {
|
||||
event: 'tool_result',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId: params.sessionId,
|
||||
iteration: params.iteration,
|
||||
tool: params.toolName,
|
||||
success: params.success,
|
||||
@@ -207,13 +233,10 @@ export class SessionRecorder {
|
||||
/**
|
||||
* 记录迭代结束
|
||||
*/
|
||||
recordIterationEnd(params: {
|
||||
iteration: number;
|
||||
durationMs: number;
|
||||
}): void {
|
||||
this.writeEvent({
|
||||
recordIterationEnd(sessionId: string, params: { iteration: number; durationMs: number }): void {
|
||||
this.writeEvent(sessionId, {
|
||||
event: 'iteration_end',
|
||||
sessionId: this.sessionId!,
|
||||
sessionId,
|
||||
iteration: params.iteration,
|
||||
durationMs: params.durationMs,
|
||||
});
|
||||
@@ -222,8 +245,10 @@ export class SessionRecorder {
|
||||
/**
|
||||
* 获取录制文件路径
|
||||
*/
|
||||
getFilePath(): string | null {
|
||||
return this.filePath;
|
||||
getFilePath(sessionId?: string): string | null {
|
||||
if (sessionId) return this.state(sessionId)?.filePath ?? null;
|
||||
const first = this.sessions.values().next().value;
|
||||
return first?.filePath ?? null;
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
@@ -231,16 +256,17 @@ export class SessionRecorder {
|
||||
/**
|
||||
* 写入事件到缓冲区
|
||||
*
|
||||
* #35 修复: 改为缓冲写入,定时异步 flush,避免每次 appendFileSync 阻塞主进程
|
||||
* #35 修复: 缓冲写入,定时异步 flush,避免每次 appendFileSync 阻塞主进程
|
||||
* 高频事件(30-50 次/秒)先 push 到内存 buffer,每 100ms 批量异步写入文件
|
||||
*/
|
||||
private writeEvent(data: Record<string, unknown>): void {
|
||||
if (!this.filePath) return;
|
||||
private writeEvent(sessionId: string, data: Record<string, unknown>): void {
|
||||
const state = this.state(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
const event: TraceEvent = {
|
||||
seq: this.seq++,
|
||||
seq: state.seq++,
|
||||
ts: new Date().toISOString(),
|
||||
sessionId: this.sessionId!,
|
||||
sessionId,
|
||||
...data,
|
||||
} as TraceEvent;
|
||||
|
||||
@@ -248,38 +274,44 @@ export class SessionRecorder {
|
||||
|
||||
// 审查修复: buffer 上限防止 OOM — 高频事件持续 flush 失败时避免内存无限增长
|
||||
const MAX_BUFFER_SIZE = 1000;
|
||||
if (this.buffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (state.buffer.length >= MAX_BUFFER_SIZE) {
|
||||
// 超限时强制同步写入,避免内存无限增长
|
||||
this.flushSync();
|
||||
this.flushSync(sessionId);
|
||||
}
|
||||
this.buffer.push(line);
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
state.buffer.push(line);
|
||||
if (!state.flushTimer) {
|
||||
state.flushTimer = setTimeout(() => {
|
||||
const st = this.state(sessionId);
|
||||
if (st) {
|
||||
st.flushTimer = null;
|
||||
void this.flush(sessionId);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #35 修复: 异步 flush 缓冲区到文件
|
||||
* 审查修复: 用局部变量保存 filePath,防止 stopRecording 将 filePath 置 null 后 appendFile 抛错;
|
||||
* 审查修复: 用局部变量保存 filePath,防止 stopRecording 将状态删除后 appendFile 抛错;
|
||||
* 失败时将数据 unshift 回 buffer 避免丢整批数据
|
||||
*/
|
||||
private async flush(): Promise<void> {
|
||||
if (this.flushing || this.buffer.length === 0 || !this.filePath) return;
|
||||
this.flushing = true;
|
||||
const filePath = this.filePath; // 局部变量,防止中途变 null
|
||||
const data = this.buffer.join('\n') + '\n';
|
||||
this.buffer = [];
|
||||
private async flush(sessionId: string): Promise<void> {
|
||||
const state = this.state(sessionId);
|
||||
if (!state || state.flushing || state.buffer.length === 0) return;
|
||||
state.flushing = true;
|
||||
const filePath = state.filePath; // 局部变量,防止中途状态被删除
|
||||
const data = state.buffer.join('\n') + '\n';
|
||||
state.buffer = [];
|
||||
try {
|
||||
await promises.appendFile(filePath, data, 'utf-8');
|
||||
} catch (error) {
|
||||
log.error('Trace event flush failed:', error);
|
||||
// 审查修复: 失败时将数据放回 buffer 头部,下次 flush/flushSync 重试
|
||||
this.buffer.unshift(data.trimEnd());
|
||||
const st = this.state(sessionId);
|
||||
if (st) st.buffer.unshift(data.trimEnd());
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
const st = this.state(sessionId);
|
||||
if (st) st.flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,20 +320,22 @@ export class SessionRecorder {
|
||||
* 用于 stopRecording 确保最后的数据(如 session_end 事件)写入文件
|
||||
* 审查修复: 如果异步 flush 正在进行(flushing=true),等待其完成后再写入,避免数据交叉/丢失
|
||||
*/
|
||||
private flushSync(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
private flushSync(sessionId: string): void {
|
||||
const state = this.state(sessionId);
|
||||
if (!state) return;
|
||||
if (state.flushTimer) {
|
||||
clearTimeout(state.flushTimer);
|
||||
state.flushTimer = null;
|
||||
}
|
||||
if (this.buffer.length === 0 || !this.filePath) return;
|
||||
const data = this.buffer.join('\n') + '\n';
|
||||
this.buffer = [];
|
||||
if (state.buffer.length === 0) return;
|
||||
const data = state.buffer.join('\n') + '\n';
|
||||
state.buffer = [];
|
||||
try {
|
||||
appendFileSync(this.filePath, data, 'utf-8');
|
||||
appendFileSync(state.filePath, data, 'utf-8');
|
||||
} catch (error) {
|
||||
log.error('Trace event flushSync failed:', error);
|
||||
// 审查修复: 失败时将数据放回 buffer,避免数据丢失
|
||||
this.buffer.unshift(data.trimEnd());
|
||||
state.buffer.unshift(data.trimEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Session Summary Service — 会话摘要分层上下文(P2-11)
|
||||
*
|
||||
* 解决"历史消息全量加载"问题:sendMessage 原实现从 DB 加载全部历史消息进 LLM
|
||||
* 上下文,超长会话(数百条消息)token 成本线性膨胀,只能依赖运行时压缩兜底。
|
||||
*
|
||||
* 分层策略(DB 层持久化,跨 run 生效):
|
||||
* 1. 会话消息数超过阈值(50 条)后,将较早消息(保留尾部 20 条原文)交由 LLM
|
||||
* 生成滚动摘要,持久化到 session_summaries 表(含 summarized_until_rowid 游标)
|
||||
* 2. 下次 sendMessage 只加载:[摘要消息] + [rowid > 游标的近期原文消息]
|
||||
* 3. 摘要随新消息增量滚动更新(旧摘要作为上下文参与新一轮总结)
|
||||
*
|
||||
* 与 engine 运行时压缩(compressMessages)的关系:
|
||||
* 运行时压缩处理"单次 run 内"的上下文膨胀;本服务处理"跨 run"的 DB 历史分层,
|
||||
* 两者互补,运行时压缩触发频率将显著下降。
|
||||
*
|
||||
* @see electron/services/database.service.ts — session_summaries 表结构
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaProviderAdapter, MetonaMessage, MetonaRequest } from '../harness/types';
|
||||
import type { SessionService } from './session.service';
|
||||
|
||||
/** 触发摘要的最小消息总数(低于此值保持全量加载) */
|
||||
const MIN_MESSAGES_TO_SUMMARIZE = 50;
|
||||
/** 摘要后保留的尾部原文条数 */
|
||||
const TAIL_KEEP = 20;
|
||||
/** 每次摘要需新增的最小未总结消息数(避免每条消息都触发 LLM 摘要) */
|
||||
const MIN_NEW_TO_SUMMARIZE = 15;
|
||||
/** LLM 摘要调用超时 */
|
||||
const SUMMARY_TIMEOUT_MS = 30_000;
|
||||
/** 传给 LLM 的单条消息内容截断 */
|
||||
const PER_MESSAGE_TRUNCATE = 600;
|
||||
/** 传给 LLM 的总字符上限 */
|
||||
const MAX_DIGEST_CHARS = 24_000;
|
||||
|
||||
export class SessionSummaryService {
|
||||
constructor(
|
||||
private getDB: () => Database.Database,
|
||||
private sessionService: SessionService,
|
||||
private adapterGetter: () => IMetonaProviderAdapter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 构建分层历史消息(sendMessage 的加载入口)
|
||||
*
|
||||
* @returns MetonaMessage 数组:存在摘要时为 [摘要消息, 近期原文...],否则全量原文
|
||||
*/
|
||||
buildHistoryMessages(sessionId: string): MetonaMessage[] {
|
||||
const existing = this.getSummary(sessionId);
|
||||
const tail = this.sessionService.getMessages(sessionId, {
|
||||
afterRowid: existing?.summarizedUntilRowid ?? 0,
|
||||
});
|
||||
|
||||
const messages: MetonaMessage[] = tail
|
||||
.filter((m) => m.role !== 'system')
|
||||
.map((m) => ({
|
||||
role: m.role as MetonaMessage['role'],
|
||||
content: m.content,
|
||||
reasoningContent: m.reasoningContent,
|
||||
toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
|
||||
toolResult: m.toolResult as MetonaMessage['toolResult'],
|
||||
timestamp: m.timestamp,
|
||||
iteration: m.iteration,
|
||||
}));
|
||||
|
||||
if (existing && messages.length > 0) {
|
||||
// 摘要以 assistant 角色注入(与 engine 运行时压缩的注入策略一致)
|
||||
const summaryMessage: MetonaMessage = {
|
||||
role: 'assistant',
|
||||
content: `[Context Summary] The following is a rolling summary of earlier conversation history:\n\n${existing.summary}`,
|
||||
timestamp: 0,
|
||||
};
|
||||
return [summaryMessage, ...messages];
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话结束后评估并生成滚动摘要(fire-and-forget 调用,失败仅记录日志)
|
||||
*/
|
||||
async maybeSummarize(sessionId: string): Promise<void> {
|
||||
const rows = this.sessionService.getMessages(sessionId);
|
||||
if (rows.length < MIN_MESSAGES_TO_SUMMARIZE) return;
|
||||
|
||||
const existing = this.getSummary(sessionId);
|
||||
const lastSummarized = existing?.summarizedUntilRowid ?? 0;
|
||||
const unsummarized = rows.filter((r) => (r.rowId ?? 0) > lastSummarized);
|
||||
|
||||
// 未总结增量不足(保留尾部 TAIL_KEEP 后仍需 ≥ MIN_NEW_TO_SUMMARIZE 条)
|
||||
if (unsummarized.length <= TAIL_KEEP + MIN_NEW_TO_SUMMARIZE) return;
|
||||
|
||||
const toSummarize = unsummarized.slice(0, unsummarized.length - TAIL_KEEP);
|
||||
if (toSummarize.length < MIN_NEW_TO_SUMMARIZE) return;
|
||||
|
||||
const summary = await this.summarizeViaLLM(existing?.summary ?? '', toSummarize);
|
||||
if (!summary) return;
|
||||
|
||||
const untilRowid = toSummarize[toSummarize.length - 1].rowId ?? 0;
|
||||
this.saveSummary(sessionId, summary, untilRowid);
|
||||
log.info(
|
||||
`[SessionSummary] session ${sessionId}: summarized ${toSummarize.length} messages (up to rowid ${untilRowid}), kept ${TAIL_KEEP} recent`,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 摘要表 CRUD =====
|
||||
|
||||
getSummary(sessionId: string): { summary: string; summarizedUntilRowid: number } | null {
|
||||
const db = this.getDB();
|
||||
const row = db
|
||||
.prepare('SELECT summary, summarized_until_rowid FROM session_summaries WHERE session_id = ?')
|
||||
.get(sessionId) as { summary: string; summarized_until_rowid: number } | undefined;
|
||||
return row ? { summary: row.summary, summarizedUntilRowid: row.summarized_until_rowid } : null;
|
||||
}
|
||||
|
||||
saveSummary(sessionId: string, summary: string, untilRowid: number): void {
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
summary = excluded.summary,
|
||||
summarized_until_rowid = excluded.summarized_until_rowid,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(sessionId, summary, untilRowid, Date.now());
|
||||
}
|
||||
|
||||
// ===== LLM 摘要 =====
|
||||
|
||||
/**
|
||||
* 调用 LLM 生成滚动摘要(复用主 Provider adapter)
|
||||
*
|
||||
* @param priorSummary 既有摘要(滚动总结上下文,可为空)
|
||||
* @param messages 本轮待总结的消息
|
||||
*/
|
||||
private async summarizeViaLLM(
|
||||
priorSummary: string,
|
||||
messages: Array<{ role: string; content: string | null }>,
|
||||
): Promise<string | null> {
|
||||
let total = 0;
|
||||
const transcript = messages
|
||||
.map((m) => {
|
||||
const content = (m.content ?? '').slice(0, PER_MESSAGE_TRUNCATE);
|
||||
if (total < MAX_DIGEST_CHARS) {
|
||||
total += content.length;
|
||||
return `[${m.role.toUpperCase()}] ${content}`;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((s): s is string => s !== null)
|
||||
.join('\n\n');
|
||||
|
||||
const request: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: 'session-summary',
|
||||
iteration: 0,
|
||||
requestId: `ss_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a conversation summarizer for an AI agent application.',
|
||||
outputConstraints:
|
||||
'Produce a rolling summary of the conversation history below. ' +
|
||||
'If a prior summary exists, merge it with the new content into one updated summary. ' +
|
||||
'Preserve key facts, decisions, tool outcomes, file paths, and open questions needed for future reasoning. ' +
|
||||
'Output in the same language as the conversation. Maximum 400 words. Output ONLY the summary text.',
|
||||
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `${priorSummary ? `## Prior summary (merge and update):\n\n${priorSummary}\n\n` : ''}## New messages to summarize:\n\n${transcript}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('summary timeout')), SUMMARY_TIMEOUT_MS);
|
||||
});
|
||||
const response = await Promise.race([this.adapterGetter().send(request), timeoutPromise]);
|
||||
const summary = response.content.trim();
|
||||
return summary || null;
|
||||
} catch (err) {
|
||||
log.warn('[SessionSummary] LLM summarization failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export interface SessionRow {
|
||||
|
||||
export interface MessageRow {
|
||||
id: string;
|
||||
/** P2: rowid(插入序号,消息截断与分层加载的游标) */
|
||||
row_id?: number;
|
||||
session_id: string;
|
||||
role: string;
|
||||
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
|
||||
@@ -52,6 +54,8 @@ export interface SessionInfo {
|
||||
|
||||
export interface MessageInfo {
|
||||
id: string;
|
||||
/** P2: rowid(插入序号;会话摘要分层加载与消息截断的游标) */
|
||||
rowId?: number;
|
||||
role: string;
|
||||
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
|
||||
content: string | null;
|
||||
@@ -178,27 +182,81 @@ export class SessionService {
|
||||
*
|
||||
* #44 修复: 添加 limit/offset 参数支持分页,避免超长会话一次性加载导致 OOM
|
||||
* 默认不限制(limit=0),保持向后兼容;调用者可传 limit 限制返回条数
|
||||
* P2-11: 新增 afterRowid 参数——仅返回 rowid 大于该值的消息(分层上下文加载游标);
|
||||
* 排序改用 rowid(插入序号),与截断/摘要游标语义一致
|
||||
*/
|
||||
getMessages(
|
||||
sessionId: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
options: { limit?: number; offset?: number; afterRowid?: number } = {},
|
||||
): MessageInfo[] {
|
||||
const db = this.getDBFn();
|
||||
const { limit = 0, offset = 0 } = options;
|
||||
const { limit = 0, offset = 0, afterRowid = 0 } = options;
|
||||
|
||||
const rows = (limit > 0
|
||||
? db
|
||||
.prepare(
|
||||
'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?',
|
||||
)
|
||||
.all(sessionId, limit, offset)
|
||||
: db
|
||||
.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC')
|
||||
.all(sessionId)) as MessageRow[];
|
||||
let sql = 'SELECT rowid AS row_id, * FROM messages WHERE session_id = ?';
|
||||
const params: unknown[] = [sessionId];
|
||||
if (afterRowid > 0) {
|
||||
sql += ' AND rowid > ?';
|
||||
params.push(afterRowid);
|
||||
}
|
||||
sql += ' ORDER BY rowid ASC';
|
||||
if (limit > 0) {
|
||||
sql += ' LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
}
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as MessageRow[];
|
||||
return rows.map((row) => this.toMessageInfo(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-11: 截断消息——删除指定消息(含/不含)之后的所有消息
|
||||
*
|
||||
* 用途:
|
||||
* - 编辑重发:删除原用户消息及其后所有消息(inclusive=true),重新发送修订版
|
||||
* - 重新生成:删除最后一条用户消息之后的所有回复(inclusive=true 于该用户消息)
|
||||
*
|
||||
* 审查修复(全量复检 #1): 同步清理 session_summaries 摘要游标。
|
||||
* 若摘要游标(summarized_until_rowid)落在被删除范围内而不清理,会导致两个缺陷:
|
||||
* 1. buildHistoryMessages 的 afterRowid 过滤返回空 tail —— 截断点之前的原文永远不加载
|
||||
* 2. 摘要内容包含已被撤销的消息("未来"内容因果污染——用户回退历史但 LLM 仍记得)
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param messageId 锚点消息 ID
|
||||
* @param inclusive true=连同锚点消息一起删除;false=仅删除其后消息
|
||||
* @returns 是否有消息被删除
|
||||
*/
|
||||
truncateMessagesAfter(sessionId: string, messageId: string, inclusive = true): boolean {
|
||||
const db = this.getDBFn();
|
||||
const op = inclusive ? '>=' : '>';
|
||||
|
||||
// 先查锚点 rowid(删除后无法再定位)
|
||||
const anchor = db
|
||||
.prepare('SELECT rowid AS rid FROM messages WHERE session_id = ? AND id = ?')
|
||||
.get(sessionId, messageId) as { rid: number } | undefined;
|
||||
if (!anchor) return false;
|
||||
|
||||
const result = db
|
||||
.prepare(`DELETE FROM messages WHERE session_id = ? AND rowid ${op} ?`)
|
||||
.run(sessionId, anchor.rid);
|
||||
|
||||
if (result.changes > 0) {
|
||||
// 同步修正会话消息计数(避免侧栏计数与实际不一致)
|
||||
db.prepare(
|
||||
`UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?), updated_at = ? WHERE id = ?`,
|
||||
).run(sessionId, Date.now(), sessionId);
|
||||
|
||||
// 摘要游标清理:游标覆盖到被删除范围即删摘要(下次消息量达标后由 maybeSummarize 重建)
|
||||
// - inclusive=true:锚点本身被删,游标 >= 锚点即视为被覆盖
|
||||
// - inclusive=false:锚点保留,游标 > 锚点才被覆盖(游标==锚点时摘要与现存消息仍一致)
|
||||
db.prepare(
|
||||
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
|
||||
).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
|
||||
|
||||
log.info(`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`);
|
||||
}
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存一条消息
|
||||
*/
|
||||
@@ -336,6 +394,7 @@ export class SessionService {
|
||||
const parsedAttachments = row.attachments ? this.safeJsonParse(row.attachments) : undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
rowId: row.row_id,
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
reasoningContent: row.reasoning_content ?? undefined,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Update Service — 自动更新服务
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十二章
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
bytesPerSecond: number;
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private autoUpdater: {
|
||||
checkForUpdates: () => void;
|
||||
quitAndInstall: () => void;
|
||||
on: (event: string, callback: (...args: unknown[]) => void) => void;
|
||||
logger: unknown;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
autoDownload: boolean;
|
||||
} | null = null;
|
||||
|
||||
constructor(private mainWindow: BrowserWindow) {}
|
||||
|
||||
initialize(): void {
|
||||
try {
|
||||
// electron-updater 可能未安装
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
this.autoUpdater = autoUpdater;
|
||||
if (!this.autoUpdater) return;
|
||||
this.autoUpdater.logger = log;
|
||||
this.autoUpdater.autoInstallOnAppQuit = true;
|
||||
this.autoUpdater.autoDownload = true;
|
||||
|
||||
this.autoUpdater.on('checking-for-update', () => this.sendStatus('checking'));
|
||||
this.autoUpdater.on('update-available', (info: unknown) => {
|
||||
this.sendStatus('available', { version: (info as UpdateInfo).version });
|
||||
});
|
||||
this.autoUpdater.on('download-progress', (progress: unknown) => {
|
||||
this.sendStatus('downloading', { progress });
|
||||
});
|
||||
this.autoUpdater.on('update-downloaded', (info: unknown) => {
|
||||
this.sendStatus('downloaded', { version: (info as UpdateInfo).version });
|
||||
});
|
||||
this.autoUpdater.on('error', (err: unknown) => {
|
||||
this.sendStatus('error', { error: (err as Error).message });
|
||||
});
|
||||
|
||||
setTimeout(() => this.autoUpdater?.checkForUpdates(), 5_000);
|
||||
} catch {
|
||||
log.warn('electron-updater not available, auto-update disabled');
|
||||
}
|
||||
}
|
||||
|
||||
checkNow(): void {
|
||||
this.autoUpdater?.checkForUpdates();
|
||||
}
|
||||
|
||||
installAndRestart(): void {
|
||||
this.autoUpdater?.quitAndInstall();
|
||||
}
|
||||
|
||||
private sendStatus(stage: string, data?: Record<string, unknown>): void {
|
||||
if (!this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update:status', { stage, ...data });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||||
*/
|
||||
|
||||
import { BrowserWindow, globalShortcut, app, shell } from 'electron';
|
||||
import { BrowserWindow, globalShortcut, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
|
||||
Reference in New Issue
Block a user