feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
P0 安全与工程基线(止血): - .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401) - 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web), pre-commit 与 CI 门禁恢复拦截能力 - 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、 agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型 - 补装 v0.4.1 声明但未安装的 node-html-parser 依赖 P1 逻辑缺陷修复(跨模块边界): - ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离, abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染) - SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入 ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区) ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件) - main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起) P2 工程强化: - CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁; GITEA_NPM_AUTH secret 注入说明 - 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons (业务代码变更不再使 vendor 缓存失效) - database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建) P3 功能补全: - DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance) - FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 + 存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索 (短语转义防 FTS 运算符注入,按会话聚合展示 snippet) - 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮 文档一致性大扫除: - README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、 删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、 项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、 新增 GITEA_NPM_AUTH 配置说明、测试数 207 - 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、 磁盘文件 2 个现状注记、ipc/*.ts 路径) - eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型 测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例) 验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
This commit is contained in:
@@ -26,7 +26,7 @@ const SAFE_DEF: MetonaToolDef = {
|
||||
name: 'read_file',
|
||||
description: 'Read file (test fixture)',
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
category: MetonaToolCategory.FILE_SYSTEM,
|
||||
category: MetonaToolCategory.FILESYSTEM,
|
||||
riskLevel: MetonaRiskLevel.SAFE,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 1_000,
|
||||
@@ -37,7 +37,7 @@ const HIGH_RISK_DEF_2: MetonaToolDef = {
|
||||
name: 'delete_file',
|
||||
description: 'Delete file (test fixture)',
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
category: MetonaToolCategory.FILE_SYSTEM,
|
||||
category: MetonaToolCategory.FILESYSTEM,
|
||||
riskLevel: MetonaRiskLevel.HIGH,
|
||||
requiresPermission: true,
|
||||
timeoutMs: 1_000,
|
||||
@@ -315,3 +315,110 @@ describe('ConfirmationHook — clearPending', () => {
|
||||
expect(hook.getPendingConfirmations()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmationHook — 跨会话隔离(v0.5.0)', () => {
|
||||
it('A 会话记住拒绝,B 会话同工具仍正常询问(产生新 pending)', async () => {
|
||||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
|
||||
// A 会话记住拒绝
|
||||
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
|
||||
expect((await pA).blocked).toBe(true);
|
||||
|
||||
// B 会话同工具 — 不受 A 会话拒绝记忆影响,进入询问流程(产生新 pending)
|
||||
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
|
||||
const pendingB = hook.getPendingConfirmations();
|
||||
expect(pendingB).toHaveLength(1);
|
||||
hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
|
||||
expect((await pB).blocked).toBe(false);
|
||||
|
||||
// A 会话的拒绝记忆仍在(getRememberedDenials 限定 A 会话可见)
|
||||
expect(hook.getRememberedDenials('sess-a')).toHaveLength(1);
|
||||
expect(hook.getRememberedDenials('sess-b')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('A 会话记住批准,B 会话同工具仍需确认', async () => {
|
||||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
|
||||
// A 会话记住批准
|
||||
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
|
||||
expect((await pA).blocked).toBe(false);
|
||||
|
||||
// A 会话后续调用直接放行(无新 pending)
|
||||
const again = await hook.beforeExecute(makeToolCall(), 'sess-a');
|
||||
expect(again.blocked).toBe(false);
|
||||
expect(hook.getPendingConfirmations()).toHaveLength(0);
|
||||
|
||||
// B 会话同工具 — 进入询问流程(批准记忆不跨会话共享)
|
||||
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
|
||||
expect(hook.getPendingConfirmations()).toHaveLength(1);
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
|
||||
expect((await pB).blocked).toBe(false);
|
||||
});
|
||||
|
||||
it('中断 A 会话(clearPending 按会话)不影响 B 会话等待中的确认', async () => {
|
||||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
|
||||
// 两个会话各有一个等待中的确认
|
||||
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
|
||||
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
|
||||
expect(hook.getPendingConfirmations()).toHaveLength(2);
|
||||
|
||||
// 中断 A 会话 — 只拒绝 A 的 pending
|
||||
hook.clearPending('sess-a');
|
||||
expect((await pA).blocked).toBe(true);
|
||||
|
||||
// B 会话的 pending 仍在等待,用户批准后正常放行
|
||||
const remaining = hook.getPendingConfirmations();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].toolCallId).toBeDefined();
|
||||
hook.resolveConfirmation(remaining[0].toolCallId, true, false, false);
|
||||
expect((await pB).blocked).toBe(false);
|
||||
});
|
||||
|
||||
it('resetRememberedDenial 指定会话时只重置该会话的拒绝记忆', async () => {
|
||||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
|
||||
// A、B 两个会话都记住拒绝
|
||||
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
|
||||
await pA;
|
||||
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
|
||||
await pB;
|
||||
|
||||
// 重置 A 会话的拒绝记忆 — B 会话的记忆保留
|
||||
expect(hook.resetRememberedDenial('run_command', 'sess-a')).toBe(true);
|
||||
expect(hook.getRememberedDenials('sess-a')).toHaveLength(0);
|
||||
expect(hook.getRememberedDenials('sess-b')).toHaveLength(1);
|
||||
|
||||
// 缺省 sessionId — 重置所有会话
|
||||
expect(hook.resetRememberedDenial('run_command')).toBe(true);
|
||||
expect(hook.getRememberedDenials()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('setAutoExecute 启用时清除所有会话的拒绝记忆(全局设置优先)', async () => {
|
||||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
|
||||
// 两个会话都记住拒绝
|
||||
for (const sid of ['sess-a', 'sess-b']) {
|
||||
const p = hook.beforeExecute(makeToolCall(), sid);
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
|
||||
await p;
|
||||
}
|
||||
expect(hook.getRememberedDenials()).toHaveLength(1); // 聚合去重后同一工具 1 条
|
||||
|
||||
// 启用自动执行 — 所有会话的拒绝记忆被清除
|
||||
hook.setAutoExecute('run_command', true);
|
||||
expect(hook.getRememberedDenials()).toHaveLength(0);
|
||||
// 自动执行放行(任意会话)
|
||||
expect((await hook.beforeExecute(makeToolCall(), 'sess-a')).blocked).toBe(false);
|
||||
expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,14 @@ export class ConfirmationHook implements PreToolHook {
|
||||
/** 工具定义缓存(由外部设置) */
|
||||
private toolDefs = new Map<string, MetonaToolDef>();
|
||||
|
||||
/** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */
|
||||
private rememberedDecisions = new Map<string, { approved: boolean; at: number }>();
|
||||
/**
|
||||
* 用户选择记忆(sessionId → toolName → 决策)
|
||||
*
|
||||
* v0.5.0: 从全局 Map 改为按会话隔离的两级 Map — 多会话并发时,
|
||||
* A 会话的"记住拒绝/批准"不再影响 B 会话(与 PolicyEngine 频率限制的会话隔离策略一致)。
|
||||
* 值为 { approved, at } 以支持拒绝记忆 TTL(v0.4.1)。
|
||||
*/
|
||||
private rememberedDecisions = new Map<string, Map<string, { approved: boolean; at: number }>>();
|
||||
|
||||
/**
|
||||
* v0.4.1: 会话内拒绝记忆的 TTL(10 分钟)
|
||||
@@ -63,6 +69,8 @@ export class ConfirmationHook implements PreToolHook {
|
||||
timer: NodeJS.Timeout;
|
||||
toolName: string;
|
||||
expiresAt: number;
|
||||
/** v0.5.0: 发起确认的会话 ID(clearPending 按会话清理的依据) */
|
||||
sessionId: string;
|
||||
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
|
||||
args?: Record<string, unknown>;
|
||||
riskLevel?: string;
|
||||
@@ -119,8 +127,8 @@ export class ConfirmationHook implements PreToolHook {
|
||||
}
|
||||
if (enabled) {
|
||||
this.autoExecuteTools.add(toolName);
|
||||
// 自动执行时同步清除会话内"拒绝"记忆(避免冲突)
|
||||
this.rememberedDecisions.delete(toolName);
|
||||
// 自动执行时同步清除所有会话的"拒绝"记忆(避免冲突 — 自动执行是跨会话的全局设置)
|
||||
this.clearDenialMemoryAllSessions(toolName);
|
||||
} else {
|
||||
this.autoExecuteTools.delete(toolName);
|
||||
}
|
||||
@@ -194,9 +202,9 @@ export class ConfirmationHook implements PreToolHook {
|
||||
if (autoExecute && approved) {
|
||||
this.setAutoExecute(pending.toolName, true);
|
||||
}
|
||||
// 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期)
|
||||
// 会话内记忆(v0.4.1: 拒绝记忆带时间戳用于 TTL;v0.5.0: 按 pending 所属会话写入)
|
||||
if (remember) {
|
||||
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
|
||||
this.decisionsFor(pending.sessionId).set(pending.toolName, { approved, at: Date.now() });
|
||||
}
|
||||
this.pendingConfirmations.delete(toolCallId);
|
||||
}
|
||||
@@ -242,7 +250,7 @@ export class ConfirmationHook implements PreToolHook {
|
||||
this.setAutoExecute(pending.toolName, true);
|
||||
}
|
||||
if (remember) {
|
||||
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
|
||||
this.decisionsFor(pending.sessionId).set(pending.toolName, { approved, at: Date.now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,44 +283,57 @@ export class ConfirmationHook implements PreToolHook {
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1: 获取本会话内记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口)
|
||||
* v0.4.1: 获取记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口)
|
||||
* v0.5.0: 按 sessionId 过滤 — 只返回指定会话的拒绝记忆;未指定时聚合所有会话(按剩余时间最长去重)
|
||||
*
|
||||
* 拒绝记忆有 TTL(默认 10 分钟),到期自动恢复询问;
|
||||
* 此方法返回未过期的拒绝记忆,前端可提供"重新询问"按钮主动重置。
|
||||
*
|
||||
* @param sessionId 会话 ID(前端应传当前会话;缺省时聚合全部会话)
|
||||
*/
|
||||
getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> {
|
||||
getRememberedDenials(sessionId?: string): 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;
|
||||
// toolName → 剩余秒数(跨会话聚合时取最长剩余时间)
|
||||
const merged = new Map<string, number>();
|
||||
for (const [sid, decisions] of this.rememberedDecisions) {
|
||||
if (sessionId && sid !== sessionId) continue;
|
||||
for (const [toolName, decision] of decisions) {
|
||||
if (decision.approved) continue;
|
||||
const elapsed = now - decision.at;
|
||||
if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
|
||||
// 已过期 — 顺手清理,避免列表返回过期条目
|
||||
decisions.delete(toolName);
|
||||
continue;
|
||||
}
|
||||
const remaining = Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000);
|
||||
merged.set(toolName, Math.max(merged.get(toolName) ?? 0, remaining));
|
||||
}
|
||||
result.push({
|
||||
toolName,
|
||||
expiresInSeconds: Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return Array.from(merged.entries()).map(([toolName, expiresInSeconds]) => ({
|
||||
toolName,
|
||||
expiresInSeconds,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1: 重置指定工具的会话内拒绝记忆(恢复询问)
|
||||
* v0.5.0: sessionId 指定会话;未指定时重置所有会话中该工具的拒绝记忆
|
||||
* @returns true 表示重置成功(存在该工具的拒绝记忆);false 表示没有可重置的记忆
|
||||
*/
|
||||
resetRememberedDenial(toolName: string): boolean {
|
||||
const decision = this.rememberedDecisions.get(toolName);
|
||||
if (decision && !decision.approved) {
|
||||
this.rememberedDecisions.delete(toolName);
|
||||
return true;
|
||||
resetRememberedDenial(toolName: string, sessionId?: string): boolean {
|
||||
let reset = false;
|
||||
for (const [sid, decisions] of this.rememberedDecisions) {
|
||||
if (sessionId && sid !== sessionId) continue;
|
||||
const decision = decisions.get(toolName);
|
||||
if (decision && !decision.approved) {
|
||||
decisions.delete(toolName);
|
||||
reset = true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return reset;
|
||||
}
|
||||
|
||||
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
|
||||
async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise<HookResult> {
|
||||
const def = this.toolDefs.get(toolCall.name);
|
||||
if (!def) {
|
||||
// 未知工具,放行(由 ToolRegistry 处理未知工具错误)
|
||||
@@ -332,14 +353,15 @@ export class ConfirmationHook implements PreToolHook {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// 检查是否有记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问)
|
||||
const remembered = this.rememberedDecisions.get(toolCall.name);
|
||||
if (remembered !== undefined) {
|
||||
// 检查本会话记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问;v0.5.0: 按会话隔离)
|
||||
const sessionDecisions = this.rememberedDecisions.get(sessionId);
|
||||
const remembered = sessionDecisions?.get(toolCall.name);
|
||||
if (sessionDecisions && remembered !== undefined) {
|
||||
const isExpiredDenial =
|
||||
!remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS;
|
||||
if (isExpiredDenial) {
|
||||
// 拒绝记忆已过期 — 移除并继续走正常确认流程
|
||||
this.rememberedDecisions.delete(toolCall.name);
|
||||
sessionDecisions.delete(toolCall.name);
|
||||
} else {
|
||||
if (remembered.approved) return { blocked: false };
|
||||
return {
|
||||
@@ -366,7 +388,7 @@ export class ConfirmationHook implements PreToolHook {
|
||||
};
|
||||
|
||||
// 等待用户响应(带超时)
|
||||
const approved = await this.waitForConfirmation(request);
|
||||
const approved = await this.waitForConfirmation(request, sessionId);
|
||||
|
||||
if (!approved) {
|
||||
return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` };
|
||||
@@ -385,7 +407,7 @@ export class ConfirmationHook implements PreToolHook {
|
||||
*/
|
||||
private lastTimeoutToastAt = 0;
|
||||
|
||||
private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> {
|
||||
private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const expiresAt = Date.now() + this.confirmationTimeoutMs;
|
||||
|
||||
@@ -430,6 +452,7 @@ export class ConfirmationHook implements PreToolHook {
|
||||
timer,
|
||||
toolName: request.toolName,
|
||||
expiresAt,
|
||||
sessionId,
|
||||
// v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回
|
||||
args: request.args,
|
||||
riskLevel: request.riskLevel,
|
||||
@@ -447,13 +470,49 @@ export class ConfirmationHook implements PreToolHook {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有等待中的确认(会话结束时调用)
|
||||
* 清理等待中的确认(会话中断时调用)
|
||||
*
|
||||
* v0.5.0: 支持按会话清理 — 多会话并发时,中断 A 会话只拒绝 A 的 pending,
|
||||
* 不再误杀 B 会话等待中的确认请求(原实现全局清空)。
|
||||
*
|
||||
* @param sessionId 指定会话 ID 时只清理该会话的 pending;缺省时清空全部(紧急路径)
|
||||
*/
|
||||
clearPending(): void {
|
||||
for (const [, pending] of this.pendingConfirmations) {
|
||||
clearPending(sessionId?: string): void {
|
||||
if (sessionId === undefined) {
|
||||
for (const [, pending] of this.pendingConfirmations) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(false);
|
||||
}
|
||||
this.pendingConfirmations.clear();
|
||||
return;
|
||||
}
|
||||
for (const [id, pending] of this.pendingConfirmations) {
|
||||
if (pending.sessionId !== sessionId) continue;
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(false);
|
||||
this.pendingConfirmations.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 私有辅助(v0.5.0: 会话隔离) =====
|
||||
|
||||
/** 获取(或创建)指定会话的决策记忆表 */
|
||||
private decisionsFor(sessionId: string): Map<string, { approved: boolean; at: number }> {
|
||||
let decisions = this.rememberedDecisions.get(sessionId);
|
||||
if (!decisions) {
|
||||
decisions = new Map();
|
||||
this.rememberedDecisions.set(sessionId, decisions);
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
|
||||
/** 清除所有会话中指定工具的拒绝记忆(setAutoExecute 启用时调用) */
|
||||
private clearDenialMemoryAllSessions(toolName: string): void {
|
||||
for (const decisions of this.rememberedDecisions.values()) {
|
||||
const decision = decisions.get(toolName);
|
||||
if (decision && !decision.approved) {
|
||||
decisions.delete(toolName);
|
||||
}
|
||||
}
|
||||
this.pendingConfirmations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ import { EventEmitter } from 'events';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { AgentLoopEngine } from '../agent-loop/engine';
|
||||
import type { AgentLoopConfig } from '../agent-loop/types';
|
||||
import type { MetonaMessage, MetonaSystemPrompt, MetonaToolDef, IMetonaProviderAdapter } from '../types';
|
||||
import type {
|
||||
MetonaMessage,
|
||||
MetonaSystemPrompt,
|
||||
MetonaToolDef,
|
||||
IMetonaProviderAdapter,
|
||||
} from '../types';
|
||||
import type { ToolRegistry } from '../tools/registry';
|
||||
import type { PreToolHook } from '../hooks/pre-tool';
|
||||
import type { PostToolHook } from '../hooks/post-tool';
|
||||
@@ -32,12 +37,28 @@ import log from 'electron-log';
|
||||
|
||||
export interface SubAgentResult {
|
||||
taskId: string;
|
||||
/** v0.5.0: 委派方(父会话)ID — 供事件消费者按会话过滤 */
|
||||
parentSessionId: string;
|
||||
result: string;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: SubAgent 生命周期事件载荷(统一广播给前端 AgentMonitor 展示)
|
||||
*/
|
||||
export interface SubAgentEvent {
|
||||
taskId: string;
|
||||
parentSessionId: string;
|
||||
description: string;
|
||||
status: 'delegated' | 'running' | 'completed' | 'error';
|
||||
depth: number;
|
||||
durationMs?: number;
|
||||
iterations?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-10: 引擎供给接口(由 AgentEngineManager 实现)
|
||||
* orchestrator 不再持有单个引擎引用,而是按需创建独立实例。
|
||||
@@ -115,9 +136,12 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
// ===== 递归深度检查 =====
|
||||
const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
|
||||
if (currentDepth >= MAX_DELEGATION_DEPTH) {
|
||||
log.warn(`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`);
|
||||
log.warn(
|
||||
`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`,
|
||||
);
|
||||
return {
|
||||
taskId,
|
||||
parentSessionId: params.parentSessionId,
|
||||
result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`,
|
||||
success: false,
|
||||
durationMs: 0,
|
||||
@@ -127,7 +151,12 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
const depth = currentDepth + 1;
|
||||
this.sessionDepth.set(params.parentSessionId, depth);
|
||||
|
||||
this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId, depth });
|
||||
this.emit('taskDelegated', {
|
||||
taskId,
|
||||
description: params.description,
|
||||
parentSessionId: params.parentSessionId,
|
||||
depth,
|
||||
});
|
||||
|
||||
// ===== 创建独立的引擎实例(P2-10: 独立 adapter,隔离 abort 信号) =====
|
||||
const subEngine = new AgentLoopEngine(
|
||||
@@ -147,6 +176,10 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
subEngine.setFallbackAdapter(this.engines.getFallbackAdapter());
|
||||
subEngine.setWorkspacePath(this.engines.getWorkspacePath());
|
||||
|
||||
// v0.5.0: 转发 SubEngine 的流式/状态事件(供 TRACE 录制与前端可观测)
|
||||
// 事件以 taskId 为 sessionId,录制到独立 JSONL 文件,不污染父会话的流
|
||||
this.forwardSubEngineEvents(subEngine, taskId);
|
||||
|
||||
// ===== 工具白名单设置 =====
|
||||
const allowedTools = this.resolveTools(params.tools);
|
||||
subEngine.setTools(allowedTools);
|
||||
@@ -163,11 +196,21 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
handle.status = 'error';
|
||||
this.activeSubAgents.delete(taskId);
|
||||
},
|
||||
getStatus: () => ({ taskId, status: handle.status, description: handle.description, depth: handle.depth }),
|
||||
getStatus: () => ({
|
||||
taskId,
|
||||
status: handle.status,
|
||||
description: handle.description,
|
||||
depth: handle.depth,
|
||||
}),
|
||||
};
|
||||
|
||||
this.activeSubAgents.set(taskId, handle);
|
||||
this.emit('taskStarted', { taskId, depth });
|
||||
this.emit('taskStarted', {
|
||||
taskId,
|
||||
description: params.description,
|
||||
parentSessionId: params.parentSessionId,
|
||||
depth,
|
||||
});
|
||||
|
||||
try {
|
||||
// 构建用户消息
|
||||
@@ -184,7 +227,7 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
const output = await subEngine.runStream(
|
||||
userMessage,
|
||||
taskId,
|
||||
[], // 子 Agent 无历史
|
||||
[], // 子 Agent 无历史
|
||||
systemPrompt,
|
||||
);
|
||||
|
||||
@@ -192,6 +235,7 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
const success = output.terminationReason === 'completed';
|
||||
const result: SubAgentResult = {
|
||||
taskId,
|
||||
parentSessionId: params.parentSessionId,
|
||||
result: output.finalAnswer,
|
||||
success,
|
||||
durationMs,
|
||||
@@ -202,7 +246,9 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
handle.result = result;
|
||||
this.emit('taskCompleted', result);
|
||||
|
||||
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
|
||||
log.info(
|
||||
`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -210,6 +256,7 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
const errMsg = (error as Error).message;
|
||||
const result: SubAgentResult = {
|
||||
taskId,
|
||||
parentSessionId: params.parentSessionId,
|
||||
result: errMsg,
|
||||
success: false,
|
||||
durationMs,
|
||||
@@ -218,7 +265,12 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
|
||||
handle.status = 'error';
|
||||
handle.result = result;
|
||||
this.emit('taskError', { taskId, error: errMsg });
|
||||
this.emit('taskError', {
|
||||
taskId,
|
||||
parentSessionId: params.parentSessionId,
|
||||
description: params.description,
|
||||
error: errMsg,
|
||||
});
|
||||
|
||||
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
|
||||
|
||||
@@ -326,13 +378,25 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
const handle = this.activeSubAgents.get(taskId);
|
||||
if (handle && handle.status === 'running') {
|
||||
handle.status = success ? 'completed' : 'error';
|
||||
handle.result = { taskId, result, success, durationMs: 0, iterations: 0 };
|
||||
handle.result = {
|
||||
taskId,
|
||||
parentSessionId: handle.parentSessionId,
|
||||
result,
|
||||
success,
|
||||
durationMs: 0,
|
||||
iterations: 0,
|
||||
};
|
||||
this.activeSubAgents.delete(taskId);
|
||||
this.emit('taskCompleted', handle.result);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string; depth: number }> {
|
||||
getActiveAgentsStatus(): Array<{
|
||||
taskId: string;
|
||||
status: string;
|
||||
description: string;
|
||||
depth: number;
|
||||
}> {
|
||||
return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus());
|
||||
}
|
||||
|
||||
@@ -350,4 +414,24 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
// 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。
|
||||
this.sessionDepth.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: 转发 SubEngine 的事件到 orchestrator 事件总线
|
||||
*
|
||||
* SubEngine 不经过 AgentEngineManager 的转发管道(刻意隔离,避免污染父会话流),
|
||||
* 此处将其流式/状态事件重新包装为 subStreamEvent / subStateChange 广播:
|
||||
* - IPC 层监听后录制到独立 TRACE 文件(sessionId = taskId)
|
||||
* - 生命周期事件(taskDelegated 等)另行广播给前端 AgentMonitor
|
||||
*
|
||||
* @param engine SubAgent 的独立引擎实例
|
||||
* @param taskId 子任务 ID(SubEngine runStream 的 sessionId)
|
||||
*/
|
||||
private forwardSubEngineEvents(engine: AgentLoopEngine, taskId: string): void {
|
||||
engine.on('streamEvent', (event: Record<string, unknown>) => {
|
||||
this.emit('subStreamEvent', { taskId, event });
|
||||
});
|
||||
engine.on('stateChange', (data: Record<string, unknown>) => {
|
||||
this.emit('subStateChange', { taskId, data });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
buildHistoryMessages: vi.fn(() => []),
|
||||
maybeSummarize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
orchestrator: { abortByParent: vi.fn() },
|
||||
// v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制),
|
||||
// mock 改用 EventEmitter 提供真实 on/emit 行为
|
||||
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn() }),
|
||||
confirmationHook: { clearPending: vi.fn() },
|
||||
reloadAdapter: vi.fn(() => true),
|
||||
...overrides,
|
||||
|
||||
+253
-2
@@ -17,6 +17,7 @@ import { broadcast } from './context';
|
||||
import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||||
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
|
||||
import log from 'electron-log';
|
||||
|
||||
/** 单会话的 text_delta 节流状态 */
|
||||
@@ -511,8 +512,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
|
||||
|
||||
// v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
|
||||
// 类型谓词收窄 severity,确保与 MetonaValidationPayload.issues 的类型一致
|
||||
const visibleIssues = validation.issues
|
||||
.filter((i) => i.severity === 'warning' || i.severity === 'error')
|
||||
.filter(
|
||||
(i): i is typeof i & { severity: 'warning' | 'error' } =>
|
||||
i.severity === 'warning' || i.severity === 'error',
|
||||
)
|
||||
.slice(0, 5);
|
||||
if (visibleIssues.length > 0) {
|
||||
broadcast('agent:streamEvent', {
|
||||
@@ -683,6 +688,27 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
},
|
||||
);
|
||||
|
||||
// ===== v0.5.0: DeepSeek 余额查询(复用 DeepSeekAdapter.getBalance,原为死代码) =====
|
||||
ipcMain.handle('llm:getBalance', async () => {
|
||||
try {
|
||||
const adapter = agentEngineManager.getAdapter();
|
||||
if (!(adapter instanceof DeepSeekAdapter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Balance query is only supported for the DeepSeek provider',
|
||||
};
|
||||
}
|
||||
const balance = await adapter.getBalance();
|
||||
if (!balance) {
|
||||
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
|
||||
}
|
||||
return { success: true, data: balance };
|
||||
} catch (error) {
|
||||
log.warn('[AGENT] getBalance failed:', (error as Error).message);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 中断会话 =====
|
||||
|
||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||
@@ -693,7 +719,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
||||
await agentEngineManager.waitForAbort(sessionId);
|
||||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||||
confirmationHook.clearPending();
|
||||
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
||||
confirmationHook.clearPending(sessionId);
|
||||
|
||||
// TOOL 层:记录中断
|
||||
auditService.log({
|
||||
@@ -707,4 +734,228 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ===== v0.5.0: SubAgent 可观测性 =====
|
||||
// 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区)
|
||||
// 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流
|
||||
// 此前 orchestrator 的 6 个事件全项目零消费者,SubAgent 执行过程对 UI 与 TRACE 完全不可见
|
||||
|
||||
/** SubAgent 元信息(description/depth,供完成/失败事件广播时补全载荷) */
|
||||
const subMeta = new Map<string, { description: string; depth: number }>();
|
||||
/** SubAgent 迭代录制状态(THINKING 驱动新迭代,text 累积供 llm_response) */
|
||||
interface SubTraceState {
|
||||
iteration: number;
|
||||
startedAt: number;
|
||||
text: string;
|
||||
usage: { input: number; output: number; total: number };
|
||||
responded: boolean;
|
||||
}
|
||||
const subTraces = new Map<string, SubTraceState>();
|
||||
|
||||
const finishSubTrace = (
|
||||
taskId: string,
|
||||
reason: string,
|
||||
durationMs: number,
|
||||
iterations: number,
|
||||
): void => {
|
||||
sessionRecorder.stopRecording(taskId, {
|
||||
totalIterations: iterations,
|
||||
totalTokens: 0,
|
||||
durationMs,
|
||||
terminationReason: reason,
|
||||
});
|
||||
subTraces.delete(taskId);
|
||||
subMeta.delete(taskId);
|
||||
};
|
||||
|
||||
orchestrator.on(
|
||||
'taskDelegated',
|
||||
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
|
||||
broadcast('subagent:event', {
|
||||
taskId: d.taskId,
|
||||
parentSessionId: d.parentSessionId,
|
||||
description: d.description,
|
||||
status: 'delegated',
|
||||
depth: d.depth,
|
||||
});
|
||||
subMeta.set(d.taskId, { description: d.description, depth: d.depth });
|
||||
sessionRecorder.startRecording(d.taskId);
|
||||
},
|
||||
);
|
||||
|
||||
orchestrator.on(
|
||||
'taskStarted',
|
||||
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
|
||||
broadcast('subagent:event', {
|
||||
taskId: d.taskId,
|
||||
parentSessionId: d.parentSessionId,
|
||||
description: d.description,
|
||||
status: 'running',
|
||||
depth: d.depth,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
orchestrator.on(
|
||||
'taskCompleted',
|
||||
(r: {
|
||||
taskId: string;
|
||||
parentSessionId: string;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
iterations: number;
|
||||
}) => {
|
||||
const meta = subMeta.get(r.taskId);
|
||||
broadcast('subagent:event', {
|
||||
taskId: r.taskId,
|
||||
parentSessionId: r.parentSessionId,
|
||||
description: meta?.description ?? '',
|
||||
status: r.success ? 'completed' : 'error',
|
||||
depth: meta?.depth ?? 1,
|
||||
durationMs: r.durationMs,
|
||||
iterations: r.iterations,
|
||||
});
|
||||
finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
|
||||
},
|
||||
);
|
||||
|
||||
orchestrator.on(
|
||||
'taskError',
|
||||
(d: { taskId: string; parentSessionId: string; description: string; error: string }) => {
|
||||
const meta = subMeta.get(d.taskId);
|
||||
broadcast('subagent:event', {
|
||||
taskId: d.taskId,
|
||||
parentSessionId: d.parentSessionId,
|
||||
description: d.description ?? meta?.description ?? '',
|
||||
status: 'error',
|
||||
depth: meta?.depth ?? 1,
|
||||
error: d.error,
|
||||
});
|
||||
finishSubTrace(d.taskId, 'error', 0, 0);
|
||||
},
|
||||
);
|
||||
|
||||
// SubEngine 状态事件 → TRACE 迭代录制(与主管道相同的事件形状,sessionId = taskId)
|
||||
orchestrator.on(
|
||||
'subStateChange',
|
||||
({
|
||||
taskId,
|
||||
data,
|
||||
}: {
|
||||
taskId: string;
|
||||
data: { state?: string; current?: string; iteration?: number };
|
||||
}) => {
|
||||
const st = subTraces.get(taskId);
|
||||
const stateValue = data.state ?? data.current ?? '';
|
||||
|
||||
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
||||
if (
|
||||
stateValue === 'THINKING' &&
|
||||
data.iteration != null &&
|
||||
(!st || st.iteration !== data.iteration)
|
||||
) {
|
||||
if (st && !st.responded) {
|
||||
sessionRecorder.recordLLMResponse({
|
||||
sessionId: taskId,
|
||||
iteration: st.iteration,
|
||||
content: st.text,
|
||||
finishReason: 'stop',
|
||||
tokenUsage: st.usage,
|
||||
});
|
||||
sessionRecorder.recordIterationEnd(taskId, {
|
||||
iteration: st.iteration,
|
||||
durationMs: Date.now() - st.startedAt,
|
||||
});
|
||||
}
|
||||
subTraces.set(taskId, {
|
||||
iteration: data.iteration,
|
||||
startedAt: Date.now(),
|
||||
text: '',
|
||||
usage: { input: 0, output: 0, total: 0 },
|
||||
responded: false,
|
||||
});
|
||||
sessionRecorder.recordIterationStart(taskId, data.iteration);
|
||||
const provider = configService.get<string>('llm.provider') ?? '';
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
sessionRecorder.recordLLMRequest({
|
||||
sessionId: taskId,
|
||||
iteration: data.iteration,
|
||||
provider,
|
||||
model,
|
||||
messageCount: data.iteration + 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TERMINATED → 补记最终迭代(正常流程只在下一轮 THINKING 补记,最终轮无后续)
|
||||
if (stateValue === 'TERMINATED' && st) {
|
||||
if (!st.responded) {
|
||||
sessionRecorder.recordLLMResponse({
|
||||
sessionId: taskId,
|
||||
iteration: st.iteration,
|
||||
content: st.text,
|
||||
finishReason: 'stop',
|
||||
tokenUsage: st.usage,
|
||||
});
|
||||
}
|
||||
sessionRecorder.recordIterationEnd(taskId, {
|
||||
iteration: st.iteration,
|
||||
durationMs: Date.now() - st.startedAt,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// SubEngine 流事件 → TRACE 内容录制(文本累积 + 工具调用/结果)
|
||||
orchestrator.on(
|
||||
'subStreamEvent',
|
||||
({ taskId, event }: { taskId: string; event: MetonaStreamEvent }) => {
|
||||
const st = subTraces.get(taskId);
|
||||
if (!st) return;
|
||||
|
||||
switch (event.type) {
|
||||
case MetonaStreamEventType.TEXT_DELTA:
|
||||
if (event.delta) st.text += event.delta;
|
||||
break;
|
||||
case MetonaStreamEventType.USAGE:
|
||||
if (event.usage) {
|
||||
st.usage = {
|
||||
input: event.usage.inputTokens ?? 0,
|
||||
output: event.usage.outputTokens ?? 0,
|
||||
total: event.usage.totalTokens ?? 0,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
|
||||
if (event.toolCall) {
|
||||
sessionRecorder.recordToolCall({
|
||||
sessionId: taskId,
|
||||
iteration: event.iteration,
|
||||
toolName: event.toolCall.name,
|
||||
args: event.toolCall.args,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.TOOL_RESULT:
|
||||
if (event.toolResult) {
|
||||
const resultPreview =
|
||||
typeof event.toolResult.result === 'string'
|
||||
? event.toolResult.result
|
||||
: JSON.stringify(event.toolResult.result);
|
||||
sessionRecorder.recordToolResult({
|
||||
sessionId: taskId,
|
||||
iteration: event.iteration,
|
||||
toolName: event.toolResult.toolName,
|
||||
success: event.toolResult.success,
|
||||
durationMs: event.toolResult.durationMs ?? 0,
|
||||
resultPreview,
|
||||
error: event.toolResult.error,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+72
-35
@@ -108,46 +108,56 @@ export function registerAppHandlers(ctx: IPCContext): void {
|
||||
|
||||
// ===== SearXNG 连接测试 =====
|
||||
|
||||
ipcMain.handle('searxng:testConnection', async (_event, url: string, authKey: string, authType: string) => {
|
||||
try {
|
||||
if (!url || !/^https?:\/\//.test(url)) {
|
||||
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (authKey) {
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${authKey}`;
|
||||
} else if (authType === 'basic') {
|
||||
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
|
||||
ipcMain.handle(
|
||||
'searxng:testConnection',
|
||||
async (_event, url: string, authKey: string, authType: string) => {
|
||||
try {
|
||||
if (!url || !/^https?:\/\//.test(url)) {
|
||||
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (authKey) {
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${authKey}`;
|
||||
} else if (authType === 'basic') {
|
||||
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
|
||||
const response = await fetch(testUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, statusCode: response.status, latencyMs };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
statusCode: response.status,
|
||||
latencyMs,
|
||||
error: `HTTP ${response.status} ${response.statusText}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
|
||||
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
|
||||
const response = await fetch(testUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, statusCode: response.status, latencyMs };
|
||||
}
|
||||
return { success: false, statusCode: response.status, latencyMs, error: `HTTP ${response.status} ${response.statusText}` };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ===== v0.2.0: 审计日志链式哈希验证 =====
|
||||
|
||||
ipcMain.handle('audit:verifyChain', async () => {
|
||||
try {
|
||||
const result = auditService.verifyChain();
|
||||
log.info(`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`);
|
||||
log.info(
|
||||
`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`,
|
||||
);
|
||||
return { success: true, ...result };
|
||||
} catch (error) {
|
||||
log.error('[AUDIT] Chain verification failed:', error);
|
||||
@@ -155,11 +165,35 @@ export function registerAppHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
// v0.5.0: 审计日志导出(JSONL / CSV 两种格式)
|
||||
ipcMain.handle('audit:export', async (_event, format: unknown) => {
|
||||
const fmt = format === 'csv' ? 'csv' : 'jsonl';
|
||||
try {
|
||||
const content = fmt === 'csv' ? auditService.exportCSV() : auditService.exportJSONL();
|
||||
const recordCount = content.trim()
|
||||
? fmt === 'csv'
|
||||
? content.trim().split('\n').length - 1
|
||||
: content.trim().split('\n').length
|
||||
: 0;
|
||||
log.info(`[AUDIT] Exported ${recordCount} records as ${fmt.toUpperCase()}`);
|
||||
return { success: true, data: content, format: fmt, recordCount };
|
||||
} catch (error) {
|
||||
log.error('[AUDIT] Export failed:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
|
||||
// M-45 修复: 校验 filters 参数类型和范围
|
||||
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
|
||||
'tool_call', 'permission_check', 'error', 'llm_request',
|
||||
'llm_response', 'session_start', 'session_end', 'config_change',
|
||||
'tool_call',
|
||||
'permission_check',
|
||||
'error',
|
||||
'llm_request',
|
||||
'llm_response',
|
||||
'session_start',
|
||||
'session_end',
|
||||
'config_change',
|
||||
];
|
||||
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
|
||||
if (filters && typeof filters === 'object') {
|
||||
@@ -168,7 +202,10 @@ export function registerAppHandlers(ctx: IPCContext): void {
|
||||
if (typeof f.eventType === 'string' && f.eventType) {
|
||||
// 校验 eventType 是否在合法枚举内
|
||||
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
|
||||
return { success: false, error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})` };
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})`,
|
||||
};
|
||||
}
|
||||
safeFilters.eventType = f.eventType as AuditEventType;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
|
||||
// M-34 修复: 校验 sessionId 和 title
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' };
|
||||
if (typeof title !== 'string' || !title.trim())
|
||||
return { success: false, error: 'Invalid title' };
|
||||
return { success: sessionService.rename(sessionId, title) };
|
||||
});
|
||||
|
||||
@@ -55,7 +56,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
|
||||
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
|
||||
if (typeof messageId !== 'string' || !messageId)
|
||||
return { success: false, error: 'Invalid messageId' };
|
||||
return { success: sessionService.deleteMessage(messageId) };
|
||||
});
|
||||
|
||||
@@ -65,13 +67,29 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
|
||||
// P2-11: 消息截断(编辑重发 / 重新生成)
|
||||
ipcMain.handle('sessions:truncateAfter', async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
|
||||
const isInclusive = typeof inclusive === 'boolean' ? inclusive : true;
|
||||
ipcMain.handle(
|
||||
'sessions:truncateAfter',
|
||||
async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof messageId !== 'string' || !messageId)
|
||||
return { success: false, error: 'Invalid messageId' };
|
||||
const isInclusive = typeof inclusive === 'boolean' ? inclusive : true;
|
||||
try {
|
||||
const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
|
||||
return { success: true, truncated };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v0.5.0: 全文搜索会话内容(FTS5,跨会话)
|
||||
ipcMain.handle('sessions:searchContent', async (_event, query: unknown) => {
|
||||
if (typeof query !== 'string' || !query.trim())
|
||||
return { success: false, error: 'Invalid query' };
|
||||
try {
|
||||
const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
|
||||
return { success: true, truncated };
|
||||
const results = sessionService.searchMessages(query);
|
||||
return { success: true, data: results };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
|
||||
+18
-12
@@ -128,20 +128,26 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
|
||||
// ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) =====
|
||||
ipcMain.handle('tool:getRememberedDenials', async () => {
|
||||
return { success: true, data: confirmationHook.getRememberedDenials() };
|
||||
// v0.5.0: sessionId 可选参数 — 拒绝记忆按会话隔离,前端应传当前会话 ID
|
||||
ipcMain.handle('tool:getRememberedDenials', async (_event, sessionId?: unknown) => {
|
||||
const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
|
||||
return { success: true, data: confirmationHook.getRememberedDenials(sid) };
|
||||
});
|
||||
|
||||
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 };
|
||||
});
|
||||
ipcMain.handle(
|
||||
'tool:resetRememberedDenial',
|
||||
async (_event, toolName: unknown, sessionId?: unknown) => {
|
||||
if (typeof toolName !== 'string' || !toolName) {
|
||||
return { success: false, error: 'Invalid toolName' };
|
||||
}
|
||||
const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
|
||||
const reset = confirmationHook.resetRememberedDenial(toolName, sid);
|
||||
if (reset) {
|
||||
log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
|
||||
}
|
||||
return { success: reset };
|
||||
},
|
||||
);
|
||||
|
||||
// ===== v0.2.0: 持久化自动执行设置 =====
|
||||
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
|
||||
|
||||
+211
-81
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { app, shell, Menu, BrowserWindow } from 'electron';
|
||||
import { app, shell, Menu, BrowserWindow, dialog } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||
@@ -46,18 +46,29 @@ import { OpenAIAdapter } from './harness/adapters/openai.adapter';
|
||||
import { AnthropicAdapter } from './harness/adapters/anthropic.adapter';
|
||||
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
|
||||
import {
|
||||
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
||||
WebSearchTool, WebFetchTool,
|
||||
MemoryStoreTool, MemorySearchTool,
|
||||
ReadFileTool,
|
||||
WriteFileTool,
|
||||
ListDirectoryTool,
|
||||
SearchFilesTool,
|
||||
WebSearchTool,
|
||||
WebFetchTool,
|
||||
MemoryStoreTool,
|
||||
MemorySearchTool,
|
||||
RunCommandTool,
|
||||
WebBrowserTool, cleanupBrowser,
|
||||
WebBrowserTool,
|
||||
cleanupBrowser,
|
||||
DelegateTaskTool,
|
||||
FileEditorTool,
|
||||
CodeSearchTool,
|
||||
TaskManagerTool,
|
||||
DiffViewerTool,
|
||||
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
|
||||
LintCodeTool, RunTestsTool, ProjectInfoTool,
|
||||
GitStatusTool,
|
||||
GitDiffTool,
|
||||
GitLogTool,
|
||||
GitCommitTool,
|
||||
LintCodeTool,
|
||||
RunTestsTool,
|
||||
ProjectInfoTool,
|
||||
HttpRequestTool,
|
||||
ThinkTool,
|
||||
ViewImageTool,
|
||||
@@ -65,7 +76,13 @@ import {
|
||||
FileMoveTool,
|
||||
FileInfoTool,
|
||||
} from './harness/tools/built-in';
|
||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook, SecurityScanHook } from './harness/hooks';
|
||||
import {
|
||||
AuditLogHook,
|
||||
MemoryTriggerHook,
|
||||
PermissionCheckHook,
|
||||
RateLimitHook,
|
||||
SecurityScanHook,
|
||||
} from './harness/hooks';
|
||||
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
||||
import { PolicyEngine } from './harness/sandbox/permissions';
|
||||
import { SandboxManager } from './harness/sandbox/sandbox';
|
||||
@@ -139,7 +156,9 @@ async function initialize(): Promise<void> {
|
||||
if (savedWorkspacePath !== workspaceInfo.path) {
|
||||
writeWorkspacePathToFile(workspaceInfo.path);
|
||||
}
|
||||
log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`);
|
||||
log.info(
|
||||
`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`,
|
||||
);
|
||||
|
||||
// ===== 步骤 3: SQLite =====
|
||||
databaseService = new DatabaseService(workspaceInfo.path);
|
||||
@@ -157,7 +176,10 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
|
||||
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key
|
||||
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>;
|
||||
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
const workspaceConfig: Record<string, unknown> = {};
|
||||
for (const row of configRows) {
|
||||
try {
|
||||
@@ -168,7 +190,9 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
|
||||
if (migratedCount > 0) {
|
||||
log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`);
|
||||
log.info(
|
||||
`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 日志服务 =====
|
||||
@@ -185,8 +209,12 @@ async function initialize(): Promise<void> {
|
||||
const provider = configService.get<string>('llm.provider') ?? '';
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
const env = ENV_FALLBACK[provider] ?? {};
|
||||
const apiKey = configService.get<string>('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
|
||||
const baseURL = configService.get<string>('llm.baseURL') || (env.baseURL ? process.env[env.baseURL] : '') || '';
|
||||
const apiKey =
|
||||
configService.get<string>('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
|
||||
const baseURL =
|
||||
configService.get<string>('llm.baseURL') ||
|
||||
(env.baseURL ? process.env[env.baseURL] : '') ||
|
||||
'';
|
||||
|
||||
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding)
|
||||
if (!provider || !baseURL || !model) {
|
||||
@@ -200,24 +228,35 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
|
||||
// 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
|
||||
const contextWindow = provider !== 'ollama'
|
||||
? configService.get<number>(`${provider}.contextWindow`) ?? undefined
|
||||
: undefined;
|
||||
const contextWindow =
|
||||
provider !== 'ollama'
|
||||
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
|
||||
switch (provider) {
|
||||
case 'agnes': return new AgnesAdapter(adapterConfig);
|
||||
case 'mimo': return new MimoAdapter(adapterConfig);
|
||||
case 'ollama': return new OllamaAdapter(adapterConfig);
|
||||
case 'openai': return new OpenAIAdapter(adapterConfig);
|
||||
case 'anthropic': return new AnthropicAdapter(adapterConfig);
|
||||
default: return new DeepSeekAdapter(adapterConfig);
|
||||
case 'agnes':
|
||||
return new AgnesAdapter(adapterConfig);
|
||||
case 'mimo':
|
||||
return new MimoAdapter(adapterConfig);
|
||||
case 'ollama':
|
||||
return new OllamaAdapter(adapterConfig);
|
||||
case 'openai':
|
||||
return new OpenAIAdapter(adapterConfig);
|
||||
case 'anthropic':
|
||||
return new AnthropicAdapter(adapterConfig);
|
||||
default:
|
||||
return new DeepSeekAdapter(adapterConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见
|
||||
const FALLBACK_ADAPTER = new DeepSeekAdapter({
|
||||
provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096,
|
||||
provider: '',
|
||||
baseURL: '',
|
||||
apiKey: '',
|
||||
defaultModel: '',
|
||||
contextWindow: 4096,
|
||||
});
|
||||
|
||||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||||
@@ -262,11 +301,16 @@ async function initialize(): Promise<void> {
|
||||
toolRegistry.registerBuiltin(runCommandTool);
|
||||
|
||||
// P2(v0.3.13): 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
|
||||
toolRegistry.registerBuiltin(new TaskManagerTool(() => db, (sessionId) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('task:changed', sessionId);
|
||||
}
|
||||
}));
|
||||
toolRegistry.registerBuiltin(
|
||||
new TaskManagerTool(
|
||||
() => db,
|
||||
(sessionId) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('task:changed', sessionId);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||||
|
||||
@@ -308,7 +352,13 @@ async function initialize(): Promise<void> {
|
||||
contextLength: ollamaNumCtx ?? undefined,
|
||||
contextWindow: buildAdapter().getContextWindow(),
|
||||
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
|
||||
thinkingEffort: (configService.get<string>('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high',
|
||||
thinkingEffort:
|
||||
(configService.get<string>('agent.thinkingEffort') as
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'max'
|
||||
| null) ?? 'high',
|
||||
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
|
||||
},
|
||||
toolRegistry,
|
||||
@@ -323,21 +373,34 @@ async function initialize(): Promise<void> {
|
||||
const model = configService.get<string>('llm.fallbackModel');
|
||||
if (!provider || !model) return null;
|
||||
const env = ENV_FALLBACK[provider] ?? {};
|
||||
const apiKey = configService.get<string>('llm.fallbackApiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
|
||||
const baseURL = configService.get<string>('llm.fallbackBaseURL') || (env.baseURL ? process.env[env.baseURL] : '') || '';
|
||||
const apiKey =
|
||||
configService.get<string>('llm.fallbackApiKey') ||
|
||||
(env.apiKey ? process.env[env.apiKey] : '') ||
|
||||
'';
|
||||
const baseURL =
|
||||
configService.get<string>('llm.fallbackBaseURL') ||
|
||||
(env.baseURL ? process.env[env.baseURL] : '') ||
|
||||
'';
|
||||
if (!baseURL) return null;
|
||||
if (!apiKey && provider !== 'ollama') return null;
|
||||
const contextWindow = provider !== 'ollama'
|
||||
? configService.get<number>(`${provider}.contextWindow`) ?? undefined
|
||||
: undefined;
|
||||
const contextWindow =
|
||||
provider !== 'ollama'
|
||||
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
|
||||
: undefined;
|
||||
const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
|
||||
switch (provider) {
|
||||
case 'agnes': return new AgnesAdapter(cfg);
|
||||
case 'mimo': return new MimoAdapter(cfg);
|
||||
case 'ollama': return new OllamaAdapter(cfg);
|
||||
case 'openai': return new OpenAIAdapter(cfg);
|
||||
case 'anthropic': return new AnthropicAdapter(cfg);
|
||||
default: return new DeepSeekAdapter(cfg);
|
||||
case 'agnes':
|
||||
return new AgnesAdapter(cfg);
|
||||
case 'mimo':
|
||||
return new MimoAdapter(cfg);
|
||||
case 'ollama':
|
||||
return new OllamaAdapter(cfg);
|
||||
case 'openai':
|
||||
return new OpenAIAdapter(cfg);
|
||||
case 'anthropic':
|
||||
return new AnthropicAdapter(cfg);
|
||||
default:
|
||||
return new DeepSeekAdapter(cfg);
|
||||
}
|
||||
};
|
||||
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
|
||||
@@ -345,24 +408,27 @@ async function initialize(): Promise<void> {
|
||||
// P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口)
|
||||
// v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
|
||||
const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
|
||||
mcpManager.initialize().then(() => {
|
||||
agentEngineManager.setToolsAll(toolRegistry.listTools());
|
||||
log.info('[MCP] Tools registered and synced to all engines');
|
||||
toolsReadyRef.ready = true;
|
||||
toolsReadyRef.toolCount = toolRegistry.size;
|
||||
// 广播工具就绪事件给所有窗口
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
|
||||
}
|
||||
}).catch((err) => {
|
||||
log.warn('MCP Manager initialization error:', err);
|
||||
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
|
||||
toolsReadyRef.ready = true;
|
||||
toolsReadyRef.toolCount = toolRegistry.size;
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
|
||||
}
|
||||
});
|
||||
mcpManager
|
||||
.initialize()
|
||||
.then(() => {
|
||||
agentEngineManager.setToolsAll(toolRegistry.listTools());
|
||||
log.info('[MCP] Tools registered and synced to all engines');
|
||||
toolsReadyRef.ready = true;
|
||||
toolsReadyRef.toolCount = toolRegistry.size;
|
||||
// 广播工具就绪事件给所有窗口
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('MCP Manager initialization error:', err);
|
||||
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
|
||||
toolsReadyRef.ready = true;
|
||||
toolsReadyRef.toolCount = toolRegistry.size;
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md) =====
|
||||
const memoryConsolidator = new MemoryConsolidator(buildAdapter(), workspaceService);
|
||||
@@ -378,10 +444,19 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// ===== Task Orchestrator(子任务委派,P2-10: 适配 EngineProvider) =====
|
||||
const orchestrator = new TaskOrchestrator(
|
||||
agentEngineManager, toolRegistry, preToolHooks, postToolHooks,
|
||||
agentEngineManager,
|
||||
toolRegistry,
|
||||
preToolHooks,
|
||||
postToolHooks,
|
||||
{
|
||||
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
|
||||
thinkingEffort: (configService.get<string>('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high',
|
||||
thinkingEffort:
|
||||
(configService.get<string>('agent.thinkingEffort') as
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'max'
|
||||
| null) ?? 'high',
|
||||
contextLength: ollamaNumCtx ?? undefined,
|
||||
contextWindow: buildAdapter().getContextWindow(),
|
||||
},
|
||||
@@ -407,9 +482,10 @@ async function initialize(): Promise<void> {
|
||||
fallbackModel: configService.get<string>('llm.fallbackModel') ?? '',
|
||||
fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '',
|
||||
fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '',
|
||||
contextWindow: provider === 'ollama'
|
||||
? configService.get<number>('ollama.numCtx')
|
||||
: configService.get<number>(`${provider}.contextWindow`),
|
||||
contextWindow:
|
||||
provider === 'ollama'
|
||||
? configService.get<number>('ollama.numCtx')
|
||||
: configService.get<number>(`${provider}.contextWindow`),
|
||||
});
|
||||
};
|
||||
let lastConfigSig = buildConfigSig();
|
||||
@@ -425,7 +501,9 @@ async function initialize(): Promise<void> {
|
||||
// 配置变化 — 校验新配置可构建 adapter
|
||||
const probe = createAdapter();
|
||||
if (!probe) {
|
||||
log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)');
|
||||
log.warn(
|
||||
'[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)',
|
||||
);
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
for (const win of wins) {
|
||||
win.webContents.send('toast:show', {
|
||||
@@ -528,15 +606,23 @@ async function initialize(): Promise<void> {
|
||||
windowManager.registerGlobalShortcuts();
|
||||
|
||||
// ===== Agent 状态同步到托盘(P2-10: 监听 manager 聚合事件) =====
|
||||
agentEngineManager.on('stateChange', (data: { previous?: string; current?: string; state?: string }) => {
|
||||
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
||||
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
|
||||
EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking',
|
||||
COMPRESSING: 'thinking', TERMINATED: 'idle',
|
||||
};
|
||||
const stateValue = (data.current || data.state) ?? '';
|
||||
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
|
||||
});
|
||||
agentEngineManager.on(
|
||||
'stateChange',
|
||||
(data: { previous?: string; current?: string; state?: string }) => {
|
||||
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
||||
INIT: 'idle',
|
||||
THINKING: 'thinking',
|
||||
PARSING: 'thinking',
|
||||
EXECUTING: 'executing',
|
||||
OBSERVING: 'thinking',
|
||||
REFLECTING: 'thinking',
|
||||
COMPRESSING: 'thinking',
|
||||
TERMINATED: 'idle',
|
||||
};
|
||||
const stateValue = (data.current || data.state) ?? '';
|
||||
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
|
||||
},
|
||||
);
|
||||
|
||||
// ===== Agent 完成时发送系统通知 =====
|
||||
agentEngineManager.on('complete', (data: { sessionId: string; durationMs: number }) => {
|
||||
@@ -560,7 +646,10 @@ async function initialize(): Promise<void> {
|
||||
try {
|
||||
const report = await healthChecker.check();
|
||||
if (!report.healthy) {
|
||||
const failed = report.checks.filter((c) => !c.healthy).map((c) => c.name).join(', ');
|
||||
const failed = report.checks
|
||||
.filter((c) => !c.healthy)
|
||||
.map((c) => c.name)
|
||||
.join(', ');
|
||||
log.warn(`[Health] Unhealthy checks: ${failed}`);
|
||||
trayManager?.setStatus('error');
|
||||
} else {
|
||||
@@ -571,7 +660,7 @@ async function initialize(): Promise<void> {
|
||||
if (slo.violated) {
|
||||
log.warn(
|
||||
`[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` +
|
||||
`P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
|
||||
`P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -613,7 +702,10 @@ async function initialize(): Promise<void> {
|
||||
// v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
|
||||
const shutdownTimeout = setTimeout(() => {
|
||||
log.warn('[Shutdown] Timeout reached, forcing exit');
|
||||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||||
if (databaseService) {
|
||||
databaseService.close();
|
||||
databaseService = null;
|
||||
}
|
||||
app.exit(0);
|
||||
}, 40_000);
|
||||
|
||||
@@ -636,19 +728,36 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||||
if (databaseService) {
|
||||
databaseService.close();
|
||||
databaseService = null;
|
||||
}
|
||||
clearTimeout(shutdownTimeout);
|
||||
app.exit(0);
|
||||
})().catch((err) => {
|
||||
log.error('[Shutdown] Async cleanup failed:', err);
|
||||
clearTimeout(shutdownTimeout);
|
||||
if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; }
|
||||
if (databaseService) {
|
||||
try {
|
||||
databaseService.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
databaseService = null;
|
||||
}
|
||||
app.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 应用日志级别配置 =====
|
||||
const logLevel = configService.get<string>('logging.level') as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null;
|
||||
const logLevel = configService.get<string>('logging.level') as
|
||||
| 'error'
|
||||
| 'warn'
|
||||
| 'info'
|
||||
| 'debug'
|
||||
| 'verbose'
|
||||
| 'silly'
|
||||
| null;
|
||||
if (logLevel) {
|
||||
log.transports.file.level = logLevel;
|
||||
log.transports.console.level = logLevel;
|
||||
@@ -664,10 +773,31 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`);
|
||||
log.info(
|
||||
`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`,
|
||||
);
|
||||
}
|
||||
|
||||
app.whenReady().then(initialize);
|
||||
// v0.5.0: 启动链路异常兜底 — DB 损坏/工作空间不可写等初始化失败时,
|
||||
// 原实现会静默挂起(渲染进程白屏且无任何用户可见错误)。
|
||||
// 兜底策略:记录日志 + 弹出系统错误对话框 + 退出(exit 1)
|
||||
app
|
||||
.whenReady()
|
||||
.then(initialize)
|
||||
.catch((err) => {
|
||||
log.error('[Startup] Initialization failed:', err);
|
||||
try {
|
||||
dialog.showErrorBox(
|
||||
'MetonaAI Desktop 启动失败',
|
||||
`初始化过程中发生错误,应用即将退出。\n\n${(err as Error)?.message ?? String(err)}\n\n` +
|
||||
'可能原因:工作空间目录不可写、数据库文件损坏。\n' +
|
||||
'可尝试在设置中切换工作空间路径后重新启动。',
|
||||
);
|
||||
} catch {
|
||||
// showErrorBox 失败(极端环境)时仅保留日志
|
||||
}
|
||||
app.exit(1);
|
||||
});
|
||||
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
// C-8 修复: 全局 web-contents 监听器也校验 URL 协议
|
||||
|
||||
+51
-4
@@ -37,6 +37,12 @@ const metonaAPI = {
|
||||
ipcRenderer.on('agent:providerSwitched', listener);
|
||||
return () => ipcRenderer.removeListener('agent:providerSwitched', listener);
|
||||
},
|
||||
/** v0.5.0: 监听 SubAgent 生命周期事件(委派/运行/完成/失败) */
|
||||
onSubAgentEvent: (callback: (event: unknown) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
|
||||
ipcRenderer.on('subagent:event', listener);
|
||||
return () => ipcRenderer.removeListener('subagent:event', listener);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 会话管理 =====
|
||||
@@ -56,6 +62,21 @@ const metonaAPI = {
|
||||
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
|
||||
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
|
||||
ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive),
|
||||
/** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
|
||||
searchContent: (query: string) =>
|
||||
ipcRenderer.invoke('sessions:searchContent', query) as Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: Array<{
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
matchCount: number;
|
||||
lastMessageId: string;
|
||||
lastRole: string;
|
||||
snippet: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
}>,
|
||||
saveTrace: (sessionId: string, data: unknown) =>
|
||||
ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
|
||||
getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId),
|
||||
@@ -114,6 +135,15 @@ const metonaAPI = {
|
||||
verifyChain: () => ipcRenderer.invoke('audit:verifyChain'),
|
||||
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
|
||||
ipcRenderer.invoke('audit:query', filters),
|
||||
/** v0.5.0: 导出审计日志(jsonl / csv 两种格式) */
|
||||
export: (format: 'jsonl' | 'csv') =>
|
||||
ipcRenderer.invoke('audit:export', format) as Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: string;
|
||||
format?: string;
|
||||
recordCount?: number;
|
||||
}>,
|
||||
},
|
||||
|
||||
// ===== v0.2.0: 工具确认请求 =====
|
||||
@@ -150,13 +180,14 @@ const metonaAPI = {
|
||||
}>;
|
||||
}>,
|
||||
// v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问)
|
||||
getRememberedDenials: () =>
|
||||
ipcRenderer.invoke('tool:getRememberedDenials') as Promise<{
|
||||
// v0.5.0: sessionId 参数 — 拒绝记忆按会话隔离,传当前会话只查看/重置本会话的记忆
|
||||
getRememberedDenials: (sessionId?: string) =>
|
||||
ipcRenderer.invoke('tool:getRememberedDenials', sessionId) as Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ toolName: string; expiresInSeconds: number }>;
|
||||
}>,
|
||||
resetRememberedDenial: (toolName: string) =>
|
||||
ipcRenderer.invoke('tool:resetRememberedDenial', toolName) as Promise<{
|
||||
resetRememberedDenial: (toolName: string, sessionId?: string) =>
|
||||
ipcRenderer.invoke('tool:resetRememberedDenial', toolName, sessionId) as Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>,
|
||||
@@ -237,6 +268,22 @@ const metonaAPI = {
|
||||
ipcRenderer.invoke('searxng:testConnection', url, authKey, authType),
|
||||
},
|
||||
|
||||
// ===== LLM 运行时查询 =====
|
||||
llm: {
|
||||
/** v0.5.0: DeepSeek 余额查询(仅 DeepSeek Provider 支持) */
|
||||
getBalance: () =>
|
||||
ipcRenderer.invoke('llm:getBalance') as Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: {
|
||||
currency: string;
|
||||
totalBalance: string;
|
||||
grantedBalance: string;
|
||||
toppedUpBalance: string;
|
||||
};
|
||||
}>,
|
||||
},
|
||||
|
||||
// ===== Toast 通知桥接 =====
|
||||
toast: {
|
||||
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* FTS5 会话内容搜索 + 审计日志导出 回归测试(v0.5.0)
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. messages_fts 虚表创建与触发器实时同步(INSERT / DELETE)
|
||||
* 2. searchMessages:短语命中、跨会话聚合、FTS 运算符输入安全、snippet 生成
|
||||
* 3. 审计导出:JSONL 行格式、CSV RFC 4180 转义(逗号/引号/换行)
|
||||
*
|
||||
* 运行要求: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)('FTS5 会话内容搜索', () => {
|
||||
let db: any;
|
||||
let dir: string;
|
||||
let sessionService: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { SessionService } = await import('../session.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-search-'));
|
||||
db = new Database(':memory:');
|
||||
// 与 DatabaseService.createTables 一致的最小 schema(含 FTS 虚表 + 触发器)
|
||||
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,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
||||
content,
|
||||
content=messages,
|
||||
content_rowid=rowid
|
||||
);
|
||||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
END;
|
||||
`);
|
||||
sessionService = new SessionService(() => db);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
const insertMessage = (
|
||||
id: string,
|
||||
sessionId: string,
|
||||
role: string,
|
||||
content: string | null,
|
||||
ts: number,
|
||||
): void => {
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(id, sessionId, role, content, ts);
|
||||
};
|
||||
|
||||
it('FTS5 可用且插入消息实时进入索引', () => {
|
||||
// FTS5 编译可用性(better-sqlite3 预编译含 FTS5,此处防御性验证)
|
||||
insertMessage('m1', 's1', 'user', 'Hello FTS5 world', 1000);
|
||||
const rows = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'hello'`)
|
||||
.all() as Array<{ c: number }>;
|
||||
expect(rows[0].c).toBe(1);
|
||||
});
|
||||
|
||||
it('searchMessages 按会话聚合返回(最新匹配 + 匹配计数 + snippet)', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s1', '会话一', 1, 1)`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s2', '会话二', 1, 1)`,
|
||||
).run();
|
||||
insertMessage('m2', 's1', 'assistant', 'uniquekeyword 出现了一次', 2000);
|
||||
insertMessage('m3', 's2', 'user', 'uniquekeyword 在另一个会话', 3000);
|
||||
insertMessage('m4', 's2', 'assistant', 'uniquekeyword 再次出现', 4000);
|
||||
|
||||
const results = sessionService.searchMessages('uniquekeyword');
|
||||
expect(results).toHaveLength(2);
|
||||
// s2 的最新匹配(m4)先返回(按时间倒序聚合)
|
||||
expect(results[0].sessionId).toBe('s2');
|
||||
expect(results[0].matchCount).toBe(2);
|
||||
expect(results[0].lastMessageId).toBe('m4');
|
||||
expect(results[0].sessionTitle).toBe('会话二');
|
||||
expect(results[0].snippet).toContain('uniquekeyword');
|
||||
// s1 单条匹配
|
||||
expect(results[1].sessionId).toBe('s1');
|
||||
expect(results[1].matchCount).toBe(1);
|
||||
});
|
||||
|
||||
it('删除消息后索引同步(搜索不再命中)', () => {
|
||||
db.prepare(`DELETE FROM messages WHERE id = 'm2'`).run();
|
||||
const results = sessionService.searchMessages('uniquekeyword');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].sessionId).toBe('s2');
|
||||
});
|
||||
|
||||
it('FTS 运算符/引号输入安全(不抛错,按短语匹配)', () => {
|
||||
insertMessage('m5', 's1', 'user', '包含 AND OR NOT 与 "引号" 的消息', 5000);
|
||||
// 含 FTS 保留字与引号的查询不抛异常
|
||||
expect(() => sessionService.searchMessages('AND OR NOT')).not.toThrow();
|
||||
expect(() => sessionService.searchMessages('x"y')).not.toThrow();
|
||||
// 引号内容作为短语可命中
|
||||
const hit = sessionService.searchMessages('引号');
|
||||
expect(hit.some((r: any) => r.lastMessageId === 'm5')).toBe(true);
|
||||
});
|
||||
|
||||
it('空查询与无匹配返回空数组', () => {
|
||||
expect(sessionService.searchMessages('')).toHaveLength(0);
|
||||
expect(sessionService.searchMessages(' ')).toHaveLength(0);
|
||||
expect(sessionService.searchMessages('不存在的关键词xyz')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('NULL content 消息不进索引(assistant 仅有 tool_calls 场景)', () => {
|
||||
insertMessage('m6', 's1', 'assistant', null, 6000);
|
||||
const rows = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'nulltest'`)
|
||||
.all() as Array<{ c: number }>;
|
||||
expect(rows[0].c).toBe(0);
|
||||
// FTS 行数与 messages 行数一致(NULL 行在 FTS 中占位但不索引内容)
|
||||
const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
|
||||
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||||
expect(fts).toBe(msg);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0)', () => {
|
||||
let db: any;
|
||||
let dir: string;
|
||||
let auditService: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { AuditService } = await import('../audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-export-'));
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE 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,
|
||||
prev_hash TEXT,
|
||||
current_hash TEXT
|
||||
);
|
||||
`);
|
||||
auditService = new AuditService(() => db);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it('exportJSONL 每行一条 JSON 记录(含链式哈希字段)', () => {
|
||||
auditService.log({
|
||||
sessionId: 's1',
|
||||
eventType: 'tool_call',
|
||||
actor: 'agent',
|
||||
target: 'run_command',
|
||||
details: { command: 'ls' },
|
||||
outcome: 'success',
|
||||
durationMs: 10,
|
||||
});
|
||||
auditService.log({
|
||||
sessionId: 's1',
|
||||
eventType: 'session_end',
|
||||
actor: 'agent',
|
||||
target: 'session',
|
||||
outcome: 'success',
|
||||
});
|
||||
|
||||
const jsonl = auditService.exportJSONL();
|
||||
const lines = jsonl.trim().split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
const first = JSON.parse(lines[0]);
|
||||
expect(first.event_type).toBe('tool_call');
|
||||
expect(first.session_id).toBe('s1');
|
||||
expect(first.current_hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
// 第二条 prev_hash 等于第一条 current_hash(链完整保留在导出中)
|
||||
const second = JSON.parse(lines[1]);
|
||||
expect(second.prev_hash).toBe(first.current_hash);
|
||||
});
|
||||
|
||||
it('exportCSV 含表头且 RFC 4180 转义(逗号/引号/换行字段)', () => {
|
||||
auditService.log({
|
||||
sessionId: 's2',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'target,with,commas',
|
||||
details: { msg: '含"双引号"和\n换行' },
|
||||
outcome: 'error',
|
||||
});
|
||||
|
||||
const csv = auditService.exportCSV();
|
||||
const lines = csv.split('\n');
|
||||
// 表头
|
||||
expect(lines[0]).toBe(
|
||||
'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
|
||||
);
|
||||
// 含特殊字符的 target 字段被双引号包裹
|
||||
expect(csv).toContain('"target,with,commas"');
|
||||
// details 存储为 JSON 字符串(内部双引号为 \" 转义),CSV 再转义后为 \""(反斜杠保留 + 引号翻倍)
|
||||
expect(csv).toContain('\\""双引号\\""');
|
||||
// 记录数与 JSONL 一致
|
||||
const parsed = auditService
|
||||
.exportJSONL()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l: string) => JSON.parse(l));
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -37,10 +37,34 @@ function stableStringify(obj: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
|
||||
export type AuditEventType =
|
||||
| 'tool_call'
|
||||
| 'permission_check'
|
||||
| 'error'
|
||||
| 'llm_request'
|
||||
| 'llm_response'
|
||||
| 'session_start'
|
||||
| 'session_end'
|
||||
| 'config_change';
|
||||
export type AuditActor = 'agent' | 'user' | 'system';
|
||||
export type AuditOutcome = 'success' | 'denied' | 'error';
|
||||
|
||||
/** v0.5.0: 审计日志行类型(导出/查询共用) */
|
||||
interface AuditRecordRow {
|
||||
id: number;
|
||||
session_id: string;
|
||||
iteration: number | null;
|
||||
event_type: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
details: string | null;
|
||||
outcome: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: number;
|
||||
prev_hash: string | null;
|
||||
current_hash: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
sessionId: string;
|
||||
iteration?: number;
|
||||
@@ -64,9 +88,15 @@ export class AuditService {
|
||||
private computeHash(
|
||||
prevHash: string,
|
||||
entry: {
|
||||
sessionId: string; iteration: number | null; eventType: string;
|
||||
actor: string; target: string; details: string | null;
|
||||
outcome: string | null; durationMs: number | null; createdAt: number;
|
||||
sessionId: string;
|
||||
iteration: number | null;
|
||||
eventType: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
details: string | null;
|
||||
outcome: string | null;
|
||||
durationMs: number | null;
|
||||
createdAt: number;
|
||||
},
|
||||
): string {
|
||||
// #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
|
||||
@@ -94,8 +124,11 @@ export class AuditService {
|
||||
private getLastHash(): string {
|
||||
if (this.cachedLastHash !== null) return this.cachedLastHash;
|
||||
const db = this.getDB();
|
||||
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as { current_hash: string | null } | undefined;
|
||||
this.cachedLastHash = row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as
|
||||
| { current_hash: string | null }
|
||||
| undefined;
|
||||
this.cachedLastHash =
|
||||
row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
return this.cachedLastHash;
|
||||
}
|
||||
|
||||
@@ -120,10 +153,12 @@ export class AuditService {
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`,
|
||||
).run(
|
||||
entry.sessionId,
|
||||
entry.iteration ?? null,
|
||||
entry.eventType,
|
||||
@@ -257,11 +292,7 @@ export class AuditService {
|
||||
/**
|
||||
* 查询审计日志
|
||||
*/
|
||||
query(filters?: {
|
||||
sessionId?: string;
|
||||
eventType?: AuditEventType;
|
||||
limit?: number;
|
||||
}): Array<{
|
||||
query(filters?: { sessionId?: string; eventType?: AuditEventType; limit?: number }): Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
iteration: number | null;
|
||||
@@ -311,6 +342,68 @@ export class AuditService {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: 导出审计日志为 JSONL 文本(每行一条 JSON 记录)
|
||||
*
|
||||
* @returns JSONL 格式字符串(含全部记录,按 id 升序)
|
||||
*/
|
||||
exportJSONL(): string {
|
||||
const db = this.getDB();
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
|
||||
)
|
||||
.all() as AuditRecordRow[];
|
||||
return rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: 导出审计日志为 CSV 文本
|
||||
*
|
||||
* 字段含逗号/引号/换行时按 RFC 4180 转义(双引号包裹 + 内部双引号翻倍)。
|
||||
*
|
||||
* @returns CSV 格式字符串(含表头,按 id 升序)
|
||||
*/
|
||||
exportCSV(): string {
|
||||
const db = this.getDB();
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
|
||||
)
|
||||
.all() as AuditRecordRow[];
|
||||
|
||||
const headers = [
|
||||
'id',
|
||||
'session_id',
|
||||
'iteration',
|
||||
'event_type',
|
||||
'actor',
|
||||
'target',
|
||||
'details',
|
||||
'outcome',
|
||||
'duration_ms',
|
||||
'created_at',
|
||||
'prev_hash',
|
||||
'current_hash',
|
||||
];
|
||||
const escapeCsv = (value: string | number | null): string => {
|
||||
const s = value === null ? '' : String(value);
|
||||
if (/[",\r\n]/.test(s)) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const lines = [headers.join(',')];
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
headers
|
||||
.map((h) => escapeCsv((r as unknown as Record<string, string | number | null>)[h]))
|
||||
.join(','),
|
||||
);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.0: 验证链式哈希完整性
|
||||
*
|
||||
@@ -319,12 +412,30 @@ export class AuditService {
|
||||
*
|
||||
* @returns 验证结果,包括是否通过、首个篡改位置的 ID
|
||||
*/
|
||||
verifyChain(): { valid: boolean; totalRecords: number; verifiedRecords: number; tamperedId: number | null } {
|
||||
verifyChain(): {
|
||||
valid: boolean;
|
||||
totalRecords: number;
|
||||
verifiedRecords: number;
|
||||
tamperedId: number | null;
|
||||
} {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare('SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC').all() as Array<{
|
||||
id: number; session_id: string; iteration: number | null; event_type: string; actor: string;
|
||||
target: string; details: string | null; outcome: string | null; duration_ms: number | null;
|
||||
created_at: number; prev_hash: string | null; current_hash: string | null;
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
|
||||
)
|
||||
.all() as Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
iteration: number | null;
|
||||
event_type: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
details: string | null;
|
||||
outcome: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: number;
|
||||
prev_hash: string | null;
|
||||
current_hash: string | null;
|
||||
}>;
|
||||
|
||||
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
@@ -369,7 +480,12 @@ export class AuditService {
|
||||
prevHash = row.current_hash;
|
||||
continue;
|
||||
}
|
||||
return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id };
|
||||
return {
|
||||
valid: false,
|
||||
totalRecords: rows.length,
|
||||
verifiedRecords: verified,
|
||||
tamperedId: row.id,
|
||||
};
|
||||
}
|
||||
|
||||
prevHash = row.current_hash;
|
||||
|
||||
@@ -211,10 +211,12 @@ export class DatabaseService {
|
||||
);
|
||||
|
||||
-- ===== MCP 服务配置表 =====
|
||||
-- v0.5.0: 建表 CHECK 直接含 streamable-http(与迁移 6 的重建后 schema 对齐,
|
||||
-- 新库不再依赖迁移 6 立即重建一次表)
|
||||
CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse')),
|
||||
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
|
||||
command TEXT,
|
||||
args TEXT,
|
||||
url TEXT,
|
||||
@@ -305,6 +307,28 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
||||
|
||||
-- ===== v0.5.0: 消息全文搜索(FTS5 外内容表模式,content 列索引) =====
|
||||
-- 会话内容搜索通过 sessions:searchContent IPC 使用 MATCH 查询;
|
||||
-- 触发器保持索引与 messages 表实时同步(INSERT/UPDATE/DELETE)
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
content=messages,
|
||||
content_rowid=rowid
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
`);
|
||||
|
||||
// 审计日志防篡改触发器(INSERT-ONLY)
|
||||
@@ -414,6 +438,28 @@ export class DatabaseService {
|
||||
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
|
||||
}
|
||||
|
||||
// v0.5.0 迁移 7: 存量库 FTS 索引回填
|
||||
// messages_fts 虚表由 createTables 创建(IF NOT EXISTS),但升级到 v0.5.0 的存量库
|
||||
// 已有消息不会自动进入索引(触发器只覆盖新写入)。行数不一致时执行 rebuild 全量回填。
|
||||
try {
|
||||
const ftsCount = db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as {
|
||||
c: number;
|
||||
};
|
||||
const msgCount = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number };
|
||||
if (ftsCount.c !== msgCount.c) {
|
||||
log.info(
|
||||
`[DB] Migration: rebuilding messages_fts index (fts=${ftsCount.c}, messages=${msgCount.c})`,
|
||||
);
|
||||
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
|
||||
log.info('[DB] Migration: messages_fts index rebuilt successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = toErrorMessage(error);
|
||||
log.warn(`[DB] Migration 7 (messages_fts rebuild) skipped: ${msg}`);
|
||||
// 非致命 — 索引回填失败仅影响全文搜索结果完整性,不影响消息读写;
|
||||
// 新写入的消息仍通过触发器正常进入索引
|
||||
}
|
||||
|
||||
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
|
||||
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
||||
// SQLite 不支持 ALTER COLUMN,需要重建表
|
||||
@@ -457,13 +503,30 @@ export class DatabaseService {
|
||||
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
|
||||
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
|
||||
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
|
||||
// v0.5.0: DROP TABLE messages 连带删除了 FTS 触发器,此处必须重建
|
||||
db.exec(`
|
||||
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);
|
||||
`);
|
||||
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);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
`);
|
||||
});
|
||||
rebuildMessages();
|
||||
|
||||
// v0.5.0: 表重建后 rowid 全部重排,FTS 索引指向的旧 rowid 已失效 — 无条件全量回填
|
||||
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
|
||||
|
||||
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -86,11 +86,15 @@ export class SessionService {
|
||||
const db = this.getDBFn();
|
||||
const archived = options?.archived ?? false;
|
||||
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM sessions
|
||||
WHERE archived = ?
|
||||
ORDER BY pinned DESC, updated_at DESC
|
||||
`).all(archived ? 1 : 0) as SessionRow[];
|
||||
`,
|
||||
)
|
||||
.all(archived ? 1 : 0) as SessionRow[];
|
||||
|
||||
return rows.map((row) => this.toSessionInfo(row));
|
||||
}
|
||||
@@ -104,10 +108,12 @@ export class SessionService {
|
||||
const now = Date.now();
|
||||
const sessionTitle = title ?? '新会话';
|
||||
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO sessions (id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(id, sessionTitle, now, now);
|
||||
`,
|
||||
).run(id, sessionTitle, now, now);
|
||||
|
||||
log.info(`Session created: ${id} (${sessionTitle})`);
|
||||
|
||||
@@ -128,9 +134,13 @@ export class SessionService {
|
||||
*/
|
||||
rename(sessionId: string, title: string): boolean {
|
||||
const db = this.getDBFn();
|
||||
const result = db.prepare(`
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?
|
||||
`).run(title, Date.now(), sessionId);
|
||||
`,
|
||||
)
|
||||
.run(title, Date.now(), sessionId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
log.info(`Session renamed: ${sessionId} → ${title}`);
|
||||
@@ -158,9 +168,13 @@ export class SessionService {
|
||||
*/
|
||||
pin(sessionId: string, pinned: boolean): boolean {
|
||||
const db = this.getDBFn();
|
||||
const result = db.prepare(`
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?
|
||||
`).run(pinned ? 1 : 0, Date.now(), sessionId);
|
||||
`,
|
||||
)
|
||||
.run(pinned ? 1 : 0, Date.now(), sessionId);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -170,9 +184,13 @@ export class SessionService {
|
||||
*/
|
||||
archive(sessionId: string, archived: boolean): boolean {
|
||||
const db = this.getDBFn();
|
||||
const result = db.prepare(`
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE sessions SET archived = ?, updated_at = ? WHERE id = ?
|
||||
`).run(archived ? 1 : 0, Date.now(), sessionId);
|
||||
`,
|
||||
)
|
||||
.run(archived ? 1 : 0, Date.now(), sessionId);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -252,7 +270,9 @@ export class SessionService {
|
||||
'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})`);
|
||||
log.info(
|
||||
`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`,
|
||||
);
|
||||
}
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -279,10 +299,12 @@ export class SessionService {
|
||||
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
|
||||
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
|
||||
const saveMessageTxn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
params.sessionId,
|
||||
params.role,
|
||||
@@ -296,11 +318,13 @@ export class SessionService {
|
||||
);
|
||||
|
||||
// 更新会话的 updated_at 和 message_count
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE sessions
|
||||
SET updated_at = ?, message_count = message_count + 1
|
||||
WHERE id = ?
|
||||
`).run(now, params.sessionId);
|
||||
`,
|
||||
).run(now, params.sessionId);
|
||||
});
|
||||
saveMessageTxn();
|
||||
|
||||
@@ -322,9 +346,11 @@ export class SessionService {
|
||||
*/
|
||||
updateTokenUsage(sessionId: string, tokens: number): void {
|
||||
const db = this.getDBFn();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE sessions SET total_tokens = total_tokens + ? WHERE id = ?
|
||||
`).run(tokens, sessionId);
|
||||
`,
|
||||
).run(tokens, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,19 +368,126 @@ export class SessionService {
|
||||
clearMessages(sessionId: string): boolean {
|
||||
const db = this.getDBFn();
|
||||
const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
|
||||
db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(Date.now(), sessionId);
|
||||
db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(
|
||||
Date.now(),
|
||||
sessionId,
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: 全文搜索会话内容(FTS5)
|
||||
*
|
||||
* 跨会话搜索消息内容,按会话聚合返回(每个会话取最新一条匹配 + 匹配总数)。
|
||||
* 查询词按 FTS5 短语语法转义(内部双引号翻倍 + 整体加引号),防止用户输入的
|
||||
* FTS 运算符(AND/OR/NOT/*)导致语法错误或语义偏移。
|
||||
*
|
||||
* @param query 搜索关键词
|
||||
* @param options.maxSessions 返回的会话数上限(默认 20)
|
||||
* @param options.scanLimit 匹配行扫描上限(默认 200,避免超大结果集拖慢聚合)
|
||||
*/
|
||||
searchMessages(
|
||||
query: string,
|
||||
options: { maxSessions?: number; scanLimit?: number } = {},
|
||||
): Array<{
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
matchCount: number;
|
||||
lastMessageId: string;
|
||||
lastRole: string;
|
||||
snippet: string;
|
||||
timestamp: number;
|
||||
}> {
|
||||
const db = this.getDBFn();
|
||||
const { maxSessions = 20, scanLimit = 200 } = options;
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// FTS5 短语转义:内部双引号翻倍,整体作为带引号短语匹配
|
||||
const phrase = `"${trimmed.replace(/"/g, '""')}"`;
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
created_at: number;
|
||||
snip: string;
|
||||
}
|
||||
let rows: MatchRow[];
|
||||
try {
|
||||
rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT m.id, m.session_id, m.role, m.created_at,
|
||||
snippet(messages_fts, 0, '[', '] ', ' … ', 12) AS snip
|
||||
FROM messages_fts f
|
||||
JOIN messages m ON m.rowid = f.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(phrase, scanLimit) as MatchRow[];
|
||||
} catch (err) {
|
||||
log.warn('[SessionService] FTS search failed:', (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按会话聚合:取每个会话的最新匹配(rows 已按时间倒序,首次出现即最新)
|
||||
const aggregated = new Map<
|
||||
string,
|
||||
{
|
||||
matchCount: number;
|
||||
lastMessageId: string;
|
||||
lastRole: string;
|
||||
snippet: string;
|
||||
timestamp: number;
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const existing = aggregated.get(row.session_id);
|
||||
if (existing) {
|
||||
existing.matchCount++;
|
||||
} else {
|
||||
aggregated.set(row.session_id, {
|
||||
matchCount: 1,
|
||||
lastMessageId: row.id,
|
||||
lastRole: row.role,
|
||||
snippet: row.snip,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (aggregated.size === 0) return [];
|
||||
|
||||
// 补全会话标题(archived 一起搜——搜索不应因归档而缺结果)
|
||||
const sessionIds = Array.from(aggregated.keys());
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const titleRows = db
|
||||
.prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`)
|
||||
.all(...sessionIds) as Array<{ id: string; title: string }>;
|
||||
const titleMap = new Map(titleRows.map((r) => [r.id, r.title]));
|
||||
|
||||
return Array.from(aggregated.entries())
|
||||
.map(([sessionId, info]) => ({
|
||||
sessionId,
|
||||
sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)',
|
||||
...info,
|
||||
}))
|
||||
.slice(0, maxSessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话的 trace 步骤和 token 用量(存入 metadata JSON)
|
||||
*/
|
||||
saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void {
|
||||
const db = this.getDBFn();
|
||||
const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage });
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
|
||||
`).run(metadata, Date.now(), sessionId);
|
||||
`,
|
||||
).run(metadata, Date.now(), sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,7 +495,9 @@ export class SessionService {
|
||||
*/
|
||||
getTraceData(sessionId: string): { traceSteps: unknown[]; tokenUsage: unknown } | null {
|
||||
const db = this.getDBFn();
|
||||
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as { metadata: string } | undefined;
|
||||
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as
|
||||
| { metadata: string }
|
||||
| undefined;
|
||||
if (!row?.metadata) return null;
|
||||
try {
|
||||
const data = JSON.parse(row.metadata);
|
||||
|
||||
Reference in New Issue
Block a user