大版本迭代,新增安全增强、Agent Loop 增强、UI/UX 增强,经六轮全面审计修复所有问题。
新增功能:
- OutputValidator 事实一致性检查 + 幻觉检测
- PromptInjectionDefender 语义级检测(指令性动词密度、角色边界、分隔符嵌套)
- DeadLoopError 死循环检测(连续3轮相同工具调用自动终止)
- PolicyEngine maxFrequency 滑动窗口频率限制
- MemoryManager TF-IDF 语义检索 + IDF 缓存原子替换
- 斜杠命令(/tool /memory /clear /export)+ 快捷键(Ctrl+B/J/Shift+F/N/[/])
- 专注模式(Ctrl+Shift+F)带面板状态快照保存/恢复
六轮审计修复(共修复 3 CRITICAL + 8 HIGH + 13 MEDIUM + 11 LOW):
CRITICAL:
- main.ts 传入 createAdapter 而非 reloadAdapter,导致 Provider 切换完全失效
- Ctrl+N/Ctrl+[/Ctrl+] 不同步 agent-store,导致消息发到错误会话
- engine.ts emit('error') 无监听器导致 DONE 事件丢失、前端卡死
HIGH:
- 死循环检测在工具执行之后(移入 PARSING 后 EXECUTING 前)
- deadLoop 事件前端未处理
- ConfirmationHook.clearPending 从未调用导致定时器泄漏
- engine.ts retry abort listener 未移除导致监听器堆积
- MCPManager JSON.parse 无 try-catch 导致初始化崩溃
- sendMessage 自动创建会话不同步 session-store
- setCurrentSession 竞态导致旧请求覆盖新会话数据
MEDIUM:
- toggleFocusMode 覆盖用户原有面板状态
- 正则检测可被常见词绕过
- 频率限制内存泄漏 + customPolicies 覆盖
- LIKE 回退转义未包含反斜杠
- OutputValidator 新功能未传入 toolResults/context
- MemoryConsolidator LLM 调用无超时保护
- AuditService 每次 log 都查询数据库
- browser-window-manager 超时后未停止页面加载
- output-validator URL 比较大小写敏感
- FileReader 无 onerror 导致 Promise 永久挂起
- /clear /export 不关闭斜杠菜单
- 专注模式下 toggleSidebar/toggleDetail 未恢复另一面板快照
LOW:
- abortPromise 事件监听器堆积
- RateLimitHook Map 内存泄漏
- memory.ts await 同步方法
- PolicyEngine 死代码清理
- Orchestrator sessionDepth 会话结束不清理
- workspace.service.ts 元数据插入边界问题
1036 lines
38 KiB
TypeScript
1036 lines
38 KiB
TypeScript
/**
|
||
* IPC Handlers — 所有 IPC 通道处理逻辑
|
||
*
|
||
* 集成三层日志:
|
||
* - TOOL 层:AuditService 记录工具调用到 audit_logs 表
|
||
* - TRACE 层:SessionRecorder 写入 session_*.jsonl
|
||
* - AGENT 层:electron-log 记录状态转换
|
||
*
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — IPC 架构 + 日志分层
|
||
*/
|
||
|
||
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
|
||
import { join } from 'path';
|
||
import type { SessionService } from '../services/session.service';
|
||
import type { ConfigService } from '../services/config.service';
|
||
import type { WorkspaceService } from '../services/workspace.service';
|
||
import type { ContextBuilder } from '../harness/prompts/context-builder';
|
||
import type { AgentLoopEngine } from '../harness/agent-loop';
|
||
import type { ToolRegistry } from '../harness/tools/registry';
|
||
import type { AuditService } from '../services/audit.service';
|
||
import type { SessionRecorder } from '../services/session-recorder.service';
|
||
import type { MemoryManager } from '../harness/memory/manager';
|
||
import type { MCPManager } from '../services/mcp-manager.service';
|
||
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
|
||
import type { OutputValidator } from '../harness/verification/output-validator';
|
||
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
|
||
import type { MemoryConsolidator } from '../harness/memory/consolidator';
|
||
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
|
||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||
import type { MetonaError } from '../harness/types';
|
||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||
import log from 'electron-log';
|
||
|
||
/**
|
||
* 注册所有 IPC 处理器
|
||
*/
|
||
export function registerAllIPCHandlers(
|
||
mainWindow: BrowserWindow,
|
||
sessionService: SessionService,
|
||
configService: ConfigService,
|
||
workspaceService: WorkspaceService,
|
||
contextBuilder: ContextBuilder,
|
||
agentLoop: AgentLoopEngine,
|
||
toolRegistry: ToolRegistry,
|
||
auditService: AuditService,
|
||
sessionRecorder: SessionRecorder,
|
||
memoryManager: MemoryManager,
|
||
mcpManager: MCPManager,
|
||
reloadAdapter: () => boolean,
|
||
promptInjectionDefender: PromptInjectionDefender,
|
||
outputValidator: OutputValidator,
|
||
confirmationHook: ConfirmationHook,
|
||
memoryConsolidator: MemoryConsolidator,
|
||
): void {
|
||
|
||
// ===== Agent 交互 =====
|
||
|
||
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
||
log.info('[AGENT] sendMessage:', sessionId, userMessage.content.slice(0, 80));
|
||
|
||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||
if (!reloadAdapter()) {
|
||
const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||
log.error('[AGENT]', errorMsg);
|
||
if (!mainWindow.isDestroyed()) {
|
||
const errorEvent: MetonaStreamEvent = {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: '',
|
||
sessionId,
|
||
iteration: 0,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: { code: MetonaErrorCode.UNKNOWN, message: errorMsg, retryable: false },
|
||
};
|
||
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||
mainWindow.webContents.send('agent:streamEvent', {
|
||
...errorEvent,
|
||
type: MetonaStreamEventType.DONE,
|
||
});
|
||
}
|
||
sessionRecorder.stopRecording({ totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' });
|
||
return { success: false, error: errorMsg };
|
||
}
|
||
|
||
// TRACE 层:开始录制
|
||
sessionRecorder.startRecording(sessionId);
|
||
|
||
// TOOL 层:记录会话开始
|
||
auditService.logSessionStart(sessionId);
|
||
|
||
// 保存用户消息到数据库
|
||
// IPC boundary: frontend sends attachments not defined in the MetonaMessage type
|
||
sessionService.saveMessage({
|
||
sessionId,
|
||
role: 'user',
|
||
content: userMessage.content,
|
||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||
});
|
||
|
||
// 加载历史消息
|
||
const historyRows = sessionService.getMessages(sessionId);
|
||
const history: MetonaMessage[] = historyRows
|
||
.filter((m) => m.role !== 'system')
|
||
.slice(0, -1)
|
||
.map((m) => ({
|
||
role: m.role as MetonaMessage['role'],
|
||
content: m.content,
|
||
reasoningContent: m.reasoningContent,
|
||
toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
|
||
toolResult: m.toolResult as MetonaMessage['toolResult'],
|
||
timestamp: m.timestamp,
|
||
}));
|
||
|
||
// 从工作空间文件构建 System Prompt(注入当前工作空间路径,便于 LLM 在需要绝对路径时使用)
|
||
const workspaceFiles = workspaceService.getFiles();
|
||
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
|
||
|
||
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
||
try {
|
||
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||
if (memories.length > 0) {
|
||
const memorySection = memories.map((m, i) =>
|
||
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
|
||
).join('\n');
|
||
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
|
||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
|
||
: memoryBlock;
|
||
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
|
||
}
|
||
} catch (err) {
|
||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||
}
|
||
|
||
// 监听 Agent Loop 事件
|
||
const onStreamEvent = (event: MetonaStreamEvent) => {
|
||
if (!mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('agent:streamEvent', event);
|
||
}
|
||
};
|
||
|
||
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => {
|
||
// AGENT 层:记录状态转换
|
||
if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`);
|
||
// 转发到渲染进程(携带迭代号)
|
||
if (!mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('agent:stateChange', data);
|
||
}
|
||
};
|
||
|
||
// M-6: 转发上下文压缩事件为 toast 通知
|
||
const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => {
|
||
if (!mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('toast:show', {
|
||
type: 'info',
|
||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens`,
|
||
});
|
||
}
|
||
};
|
||
|
||
// v0.3.0: 转发死循环检测事件为 toast 警告
|
||
const onDeadLoop = (data: { iteration?: number; runId?: string; sessionId?: string }) => {
|
||
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
|
||
if (!mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('toast:show', {
|
||
type: 'warning',
|
||
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
|
||
});
|
||
}
|
||
};
|
||
|
||
agentLoop.on('streamEvent', onStreamEvent);
|
||
agentLoop.on('stateChange', onStateChange);
|
||
agentLoop.on('compressed', onCompressed);
|
||
agentLoop.on('deadLoop', onDeadLoop);
|
||
|
||
try {
|
||
// 提示注入检测(安全模块)
|
||
const injectionResult = promptInjectionDefender.detect(userMessage.content);
|
||
if (injectionResult.riskScore >= 7) {
|
||
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
|
||
if (!mainWindow.isDestroyed()) {
|
||
const metonaError: MetonaError = {
|
||
code: MetonaErrorCode.UNKNOWN,
|
||
message: `Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
|
||
retryable: false,
|
||
};
|
||
const errorEvent: MetonaStreamEvent = {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: '',
|
||
sessionId,
|
||
iteration: 0,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: metonaError,
|
||
};
|
||
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||
}
|
||
// TRACE 层:停止录制
|
||
sessionRecorder.stopRecording({
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
return { success: false, error: 'Message blocked by prompt injection defense' };
|
||
}
|
||
if (injectionResult.riskScore >= 4) {
|
||
log.warn('[PromptInjectionDefender] Suspicious patterns detected:', injectionResult.findings);
|
||
}
|
||
|
||
// TRACE 层:记录上下文构建
|
||
sessionRecorder.recordContextBuilt({
|
||
tokenCount: estimateMessagesTokens(history),
|
||
usageRatio: 0,
|
||
});
|
||
|
||
// 启动 Agent Loop
|
||
const output = await agentLoop.runStream(userMessage, sessionId, history, systemPrompt);
|
||
|
||
// 输出验证(不阻塞响应,仅记录警告)
|
||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
||
try {
|
||
const toolResults = output.iterations
|
||
.flatMap((step) => step.toolResults ?? [])
|
||
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
|
||
const context = [...history, { role: 'user', content: userMessage.content }]
|
||
.map((m) => `${m.role}: ${m.content}`).join('\n');
|
||
const validation = await outputValidator.validate(output.finalAnswer, {
|
||
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
||
context,
|
||
});
|
||
if (!validation.valid || validation.issues.length > 0) {
|
||
log.warn('[OutputValidator] Validation issues:', validation.issues);
|
||
}
|
||
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
|
||
} catch (err) {
|
||
log.error('[OutputValidator] Validation failed:', err);
|
||
}
|
||
|
||
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
|
||
for (const step of output.iterations) {
|
||
if (!step.thought) continue;
|
||
|
||
// 将 MetonaToolCall + MetonaToolResult 转换为前端 ToolCallInfo 格式
|
||
const toolCallsWithResults = step.toolCalls?.map((tc) => {
|
||
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
|
||
return {
|
||
id: tc.id,
|
||
name: tc.name,
|
||
args: tc.args,
|
||
status: result?.success ? 'success' as const : 'error' as const,
|
||
result: result?.result,
|
||
durationMs: result?.durationMs,
|
||
error: result?.error,
|
||
};
|
||
});
|
||
|
||
// 只有当有内容、思考内容或工具调用时才保存
|
||
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
|
||
sessionService.saveMessage({
|
||
sessionId,
|
||
role: 'assistant',
|
||
content: step.thought.content,
|
||
reasoningContent: step.thought.reasoningContent || undefined,
|
||
toolCalls: toolCallsWithResults,
|
||
iteration: step.iteration,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 更新 Token 统计
|
||
if (output.totalTokenUsage.totalTokens > 0) {
|
||
sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens);
|
||
}
|
||
|
||
// 更新 MEMORY.md 时间戳
|
||
workspaceService.updateMemoryTimestamp();
|
||
|
||
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
|
||
// 异步执行,不阻塞主流程返回;失败仅记录日志
|
||
memoryConsolidator
|
||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
||
.then((result) => {
|
||
if (result.appended > 0) {
|
||
log.info(`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`);
|
||
// 通知渲染进程记忆已更新
|
||
if (!mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('toast:show', {
|
||
type: 'info',
|
||
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
|
||
});
|
||
}
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
log.warn('[AGENT] Memory consolidation failed:', err);
|
||
});
|
||
|
||
// TOOL 层:记录会话结束
|
||
auditService.logSessionEnd({
|
||
sessionId,
|
||
totalIterations: output.iterations.length,
|
||
totalTokens: output.totalTokenUsage.totalTokens,
|
||
durationMs: output.durationMs,
|
||
terminationReason: output.terminationReason,
|
||
});
|
||
|
||
// TRACE 层:停止录制
|
||
sessionRecorder.stopRecording({
|
||
totalIterations: output.iterations.length,
|
||
totalTokens: output.totalTokenUsage.totalTokens,
|
||
durationMs: output.durationMs,
|
||
terminationReason: output.terminationReason,
|
||
});
|
||
|
||
log.info(`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`);
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
log.error('[AGENT] Error:', error);
|
||
|
||
// TOOL 层:记录错误
|
||
auditService.log({
|
||
sessionId,
|
||
eventType: 'error',
|
||
actor: 'agent',
|
||
target: 'agent_loop',
|
||
details: { error: (error as Error).message },
|
||
outcome: 'error',
|
||
});
|
||
|
||
// TRACE 层:停止录制
|
||
sessionRecorder.stopRecording({
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
|
||
// 发送错误事件到 UI
|
||
if (!mainWindow.isDestroyed()) {
|
||
const metonaError: MetonaError = {
|
||
code: MetonaErrorCode.UNKNOWN,
|
||
message: (error as Error).message,
|
||
retryable: false,
|
||
};
|
||
const errorEvent: MetonaStreamEvent = {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: '',
|
||
sessionId,
|
||
iteration: 0,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: metonaError,
|
||
};
|
||
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||
}
|
||
|
||
return { success: false, error: (error as Error).message };
|
||
} finally {
|
||
agentLoop.off('streamEvent', onStreamEvent);
|
||
agentLoop.off('stateChange', onStateChange);
|
||
agentLoop.off('compressed', onCompressed);
|
||
agentLoop.off('deadLoop', onDeadLoop);
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||
log.info('[AGENT] Abort:', sessionId);
|
||
agentLoop.abort();
|
||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||
confirmationHook.clearPending();
|
||
|
||
// TOOL 层:记录中断
|
||
auditService.log({
|
||
sessionId,
|
||
eventType: 'session_end',
|
||
actor: 'user',
|
||
target: 'session',
|
||
details: { reason: 'user_abort' },
|
||
outcome: 'denied',
|
||
});
|
||
|
||
return { success: true };
|
||
});
|
||
|
||
// ===== 会话管理 =====
|
||
|
||
ipcMain.handle('sessions:list', async () => {
|
||
return sessionService.list();
|
||
});
|
||
|
||
ipcMain.handle('sessions:create', async (_event, title?: string) => {
|
||
return sessionService.create(title);
|
||
});
|
||
|
||
ipcMain.handle('sessions:rename', async (_event, sessionId, title) => {
|
||
return { success: sessionService.rename(sessionId, title) };
|
||
});
|
||
|
||
ipcMain.handle('sessions:delete', async (_event, sessionId) => {
|
||
return { success: sessionService.delete(sessionId) };
|
||
});
|
||
|
||
ipcMain.handle('sessions:getMessages', async (_event, sessionId) => {
|
||
return sessionService.getMessages(sessionId);
|
||
});
|
||
|
||
ipcMain.handle('sessions:pin', async (_event, sessionId, pinned) => {
|
||
return { success: sessionService.pin(sessionId, pinned) };
|
||
});
|
||
|
||
ipcMain.handle('sessions:archive', async (_event, sessionId, archived) => {
|
||
return { success: sessionService.archive(sessionId, archived) };
|
||
});
|
||
|
||
ipcMain.handle('sessions:deleteMessage', async (_event, messageId) => {
|
||
return { success: sessionService.deleteMessage(messageId) };
|
||
});
|
||
|
||
ipcMain.handle('sessions:clearMessages', async (_event, sessionId) => {
|
||
return { success: sessionService.clearMessages(sessionId) };
|
||
});
|
||
|
||
ipcMain.handle('sessions:saveTrace', async (_event, sessionId, data) => {
|
||
try {
|
||
sessionService.saveTraceData(sessionId, data);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('sessions:getTrace', async (_event, sessionId) => {
|
||
return sessionService.getTraceData(sessionId);
|
||
});
|
||
|
||
// ===== MCP 管理 =====
|
||
|
||
ipcMain.handle('mcp:listServers', async () => {
|
||
return mcpManager.getServerStates();
|
||
});
|
||
|
||
ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => {
|
||
try {
|
||
await mcpManager.addServer({
|
||
name: config.name,
|
||
transport: config.transport as 'stdio' | 'sse',
|
||
command: config.command,
|
||
args: config.args,
|
||
url: config.url,
|
||
enabled: true,
|
||
});
|
||
log.info(`MCP server added: ${config.name}`);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
|
||
try {
|
||
await mcpManager.removeServer(name);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('mcp:toggleServer', async (_event, name: string, enabled: boolean) => {
|
||
try {
|
||
await mcpManager.toggleServer(name, enabled);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||
}
|
||
});
|
||
|
||
// ===== 记忆 =====
|
||
|
||
ipcMain.handle('db:searchMemories', async (_event, query, options) => {
|
||
return memoryManager.search(query, options);
|
||
});
|
||
|
||
// ===== 配置 =====
|
||
|
||
ipcMain.handle('config:get', async (_event, key) => {
|
||
return configService.get(key);
|
||
});
|
||
|
||
ipcMain.handle('config:set', async (_event, key, value) => {
|
||
configService.set(key, value);
|
||
|
||
// TOOL 层:记录配置变更
|
||
auditService.log({
|
||
sessionId: '',
|
||
eventType: 'config_change',
|
||
actor: 'user',
|
||
target: key,
|
||
details: { value },
|
||
outcome: 'success',
|
||
});
|
||
|
||
// LLM 相关配置变更时热重载 Adapter
|
||
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) {
|
||
reloadAdapter();
|
||
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
|
||
}
|
||
|
||
// Agent 配置变更时热更新 Engine
|
||
if (key === 'agent.maxIterations') {
|
||
agentLoop.updateConfig({ maxIterations: value as number });
|
||
log.info(`[CONFIG] Agent maxIterations updated to ${value}`);
|
||
} else if (key === 'agent.totalTimeoutMs') {
|
||
agentLoop.updateConfig({ totalTimeoutMs: value as number });
|
||
log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`);
|
||
} else if (key === 'agent.enableThinking') {
|
||
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
|
||
log.info(`[CONFIG] Agent thinkingEnabled updated to ${value}`);
|
||
} else if (key === 'agent.thinkingEffort') {
|
||
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
|
||
log.info(`[CONFIG] Agent thinkingEffort updated to ${value}`);
|
||
} else if (key === 'ollama.numCtx') {
|
||
// numCtx 同时更新 Engine 的 contextLength
|
||
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
|
||
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
|
||
} else if (key === 'agent.confirmationTimeoutMs') {
|
||
// 工具确认超时变更,即时应用到 ConfirmationHook
|
||
confirmationHook.setConfirmationTimeout(value as number);
|
||
log.info(`[CONFIG] Confirmation timeout updated to ${value}ms`);
|
||
} else if (key === 'agent.toolExecutionTimeoutMs') {
|
||
// 工具执行兜底超时变更,即时应用到 Engine
|
||
agentLoop.updateConfig({ toolExecutionTimeoutMs: value as number });
|
||
log.info(`[CONFIG] Tool execution timeout updated to ${value}ms`);
|
||
}
|
||
|
||
// 日志级别变更时即时应用
|
||
if (key === 'logging.level') {
|
||
const level = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
|
||
log.transports.file.level = level;
|
||
log.transports.console.level = level;
|
||
log.info(`[CONFIG] Log level updated to ${level}`);
|
||
}
|
||
|
||
// 工作空间路径变更时写入独立文件(下次启动生效)
|
||
if (key === 'workspace.path') {
|
||
try {
|
||
const { writeWorkspacePathToFile } = await import('../main');
|
||
if (value) writeWorkspacePathToFile(value as string);
|
||
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
|
||
} catch (err) {
|
||
log.error('[CONFIG] Failed to save workspace path:', err);
|
||
}
|
||
}
|
||
|
||
return { success: true };
|
||
});
|
||
|
||
// ===== 应用工具 =====
|
||
|
||
ipcMain.handle('app:getVersion', async () => {
|
||
return app.getVersion();
|
||
});
|
||
|
||
ipcMain.handle('app:getAppDataPath', async () => {
|
||
return app.getPath('userData');
|
||
});
|
||
|
||
ipcMain.handle('app:openExternal', async (_event, url) => {
|
||
await shell.openExternal(url);
|
||
});
|
||
|
||
ipcMain.handle('app:showItemInFolder', async (_event, path) => {
|
||
shell.showItemInFolder(path);
|
||
});
|
||
|
||
ipcMain.handle('app:selectFolder', async (_event, defaultPath?: string) => {
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
properties: ['openDirectory', 'createDirectory'],
|
||
defaultPath: defaultPath ?? app.getPath('home'),
|
||
title: '选择工作空间目录',
|
||
});
|
||
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
|
||
return { canceled: false, path: result.filePaths[0] };
|
||
});
|
||
|
||
// 重启应用(工作空间切换后调用)
|
||
ipcMain.handle('app:restart', async () => {
|
||
log.info('[APP] Restart requested');
|
||
// 延迟 200ms 让 IPC 响应先返回
|
||
setTimeout(() => {
|
||
app.relaunch();
|
||
app.exit(0);
|
||
}, 200);
|
||
return { success: true };
|
||
});
|
||
|
||
// ===== 工作空间校验与继承 =====
|
||
|
||
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态
|
||
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
|
||
if (!targetPath || typeof targetPath !== 'string') {
|
||
return { valid: false, reason: '路径不能为空' };
|
||
}
|
||
|
||
const { existsSync, statSync } = await import('fs');
|
||
const { resolve } = await import('path');
|
||
|
||
const resolvedPath = resolve(targetPath);
|
||
|
||
// 校验 1: 路径是否存在
|
||
if (!existsSync(resolvedPath)) {
|
||
return {
|
||
valid: true,
|
||
path: resolvedPath,
|
||
exists: false,
|
||
missingFiles: ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'],
|
||
isNewWorkspace: true,
|
||
reason: '目录不存在,将在切换后自动创建',
|
||
};
|
||
}
|
||
|
||
// 校验 2: 是否为目录
|
||
try {
|
||
const stat = statSync(resolvedPath);
|
||
if (!stat.isDirectory()) {
|
||
return { valid: false, reason: '路径不是目录' };
|
||
}
|
||
} catch {
|
||
return { valid: false, reason: '无法访问路径' };
|
||
}
|
||
|
||
// 校验 3: 检测 4 个必需文件状态
|
||
const requiredFiles = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'];
|
||
const missingFiles: string[] = [];
|
||
for (const f of requiredFiles) {
|
||
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
path: resolvedPath,
|
||
exists: true,
|
||
missingFiles,
|
||
isNewWorkspace: missingFiles.length === requiredFiles.length,
|
||
};
|
||
});
|
||
|
||
// 从旧工作空间继承文件到新工作空间
|
||
ipcMain.handle('workspace:inheritFiles', async (_event, params: {
|
||
targetPath: string;
|
||
sourcePath: string;
|
||
files: string[];
|
||
}) => {
|
||
const { targetPath, sourcePath, files } = params;
|
||
if (!targetPath || !sourcePath || !Array.isArray(files)) {
|
||
return { success: false, error: '参数无效' };
|
||
}
|
||
|
||
const { existsSync, mkdirSync, copyFileSync } = await import('fs');
|
||
const { resolve } = await import('path');
|
||
|
||
const resolvedTarget = resolve(targetPath);
|
||
const resolvedSource = resolve(sourcePath);
|
||
|
||
// 确保目标目录存在
|
||
if (!existsSync(resolvedTarget)) {
|
||
mkdirSync(resolvedTarget, { recursive: true });
|
||
}
|
||
|
||
const inherited: string[] = [];
|
||
const failed: Array<{ file: string; error: string }> = [];
|
||
|
||
for (const fileName of files) {
|
||
// 仅允许继承白名单文件(防止路径遍历)
|
||
if (!['SOUL.md', 'AGENTS.md', 'USERS.md'].includes(fileName)) {
|
||
failed.push({ file: fileName, error: '不在继承白名单中' });
|
||
continue;
|
||
}
|
||
const srcFile = join(resolvedSource, fileName);
|
||
const dstFile = join(resolvedTarget, fileName);
|
||
try {
|
||
if (existsSync(srcFile)) {
|
||
copyFileSync(srcFile, dstFile);
|
||
inherited.push(fileName);
|
||
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource} → ${resolvedTarget}`);
|
||
} else {
|
||
failed.push({ file: fileName, error: '源文件不存在' });
|
||
}
|
||
} catch (err) {
|
||
failed.push({ file: fileName, error: (err as Error).message });
|
||
}
|
||
}
|
||
|
||
return { success: true, inherited, failed };
|
||
});
|
||
|
||
// 获取当前工作空间详情:路径 + 4 个核心文件状态 + 自动目录状态
|
||
ipcMain.handle('workspace:getInfo', async () => {
|
||
const { existsSync, statSync, readFileSync, readdirSync } = await import('fs');
|
||
const workspacePath = workspaceService.getPath();
|
||
const files = workspaceService.reload(); // 同步外部可能的手动修改
|
||
|
||
const REQUIRED = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'] as const;
|
||
const AUTO_DIRS = ['logs', 'traces', '.metona'] as const;
|
||
|
||
const fileInfos = REQUIRED.map((name) => {
|
||
const filePath = join(workspacePath, name);
|
||
let exists = false;
|
||
let size = 0;
|
||
let mtime = 0;
|
||
let preview = '';
|
||
try {
|
||
if (existsSync(filePath)) {
|
||
const stat = statSync(filePath);
|
||
exists = true;
|
||
size = stat.size;
|
||
mtime = stat.mtimeMs;
|
||
// 截取前 500 字符作为预览
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
preview = content.length > 500 ? content.slice(0, 500) + '\n...(已截断)' : content;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
// 文件内容映射:SOUL.md → files.soul, AGENTS.md → files.agents 等
|
||
const contentKey = name.toLowerCase().replace('.md', '') as 'soul' | 'agents' | 'memory' | 'users';
|
||
return {
|
||
name,
|
||
path: filePath,
|
||
exists,
|
||
size,
|
||
mtime,
|
||
preview: exists ? preview : (files[contentKey] ?? ''),
|
||
};
|
||
});
|
||
|
||
const dirInfos = AUTO_DIRS.map((name) => {
|
||
const dirPath = join(workspacePath, name);
|
||
let exists = false;
|
||
let fileCount = 0;
|
||
try {
|
||
if (existsSync(dirPath)) {
|
||
exists = true;
|
||
const stat = statSync(dirPath);
|
||
if (stat.isDirectory()) {
|
||
fileCount = readdirSync(dirPath).length;
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return { name, path: dirPath, exists, fileCount };
|
||
});
|
||
|
||
return {
|
||
path: workspacePath,
|
||
files: fileInfos,
|
||
dirs: dirInfos,
|
||
};
|
||
});
|
||
|
||
// ===== 工具管理 =====
|
||
|
||
ipcMain.handle('tools:list', async () => {
|
||
return toolRegistry.listAllTools().map((t) => ({
|
||
name: t.name,
|
||
description: t.description,
|
||
category: t.category,
|
||
riskLevel: t.riskLevel,
|
||
requiresPermission: t.requiresPermission,
|
||
enabled: t.enabled,
|
||
}));
|
||
});
|
||
|
||
ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => {
|
||
// 工具开关通过配置持久化
|
||
configService.set(`tools.${toolName}.enabled`, enabled);
|
||
// 同步到 ToolRegistry(立即生效)
|
||
toolRegistry.setToolEnabled(toolName, enabled);
|
||
// 同步到 AgentLoop 的工具列表
|
||
agentLoop.setTools(toolRegistry.listTools());
|
||
log.info(`Tool ${toolName} ${enabled ? 'enabled' : 'disabled'}`);
|
||
return { success: true };
|
||
});
|
||
|
||
// ===== 数据管理 =====
|
||
|
||
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
|
||
try {
|
||
if (sessionId) {
|
||
// 导出单个会话
|
||
const messages = sessionService.getMessages(sessionId);
|
||
return { success: true, data: messages };
|
||
}
|
||
// 导出所有会话
|
||
const sessions = sessionService.list();
|
||
const allData: Record<string, unknown> = { sessions: [], config: configService.getAll() };
|
||
for (const session of sessions) {
|
||
(allData.sessions as Array<Record<string, unknown>>).push({
|
||
...session,
|
||
messages: sessionService.getMessages(session.id),
|
||
});
|
||
}
|
||
return { success: true, data: allData };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('data:clearSessions', async () => {
|
||
const db = sessionService.getDB();
|
||
try {
|
||
db.exec('BEGIN');
|
||
db.exec('DELETE FROM messages');
|
||
db.exec('DELETE FROM sessions');
|
||
db.exec('COMMIT');
|
||
log.info('[DATA] All sessions cleared');
|
||
return { success: true };
|
||
} catch (error) {
|
||
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('data:clearMemories', async () => {
|
||
try {
|
||
const db = sessionService.getDB();
|
||
db.exec('DELETE FROM episodic_memories');
|
||
db.exec('DELETE FROM semantic_memories');
|
||
db.exec('DELETE FROM working_memories');
|
||
log.info('[DATA] All memories cleared');
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('data:clearAuditLogs', async () => {
|
||
// 审计日志是 INSERT-ONLY,需要先禁用触发器
|
||
const db = sessionService.getDB();
|
||
try {
|
||
db.exec('BEGIN');
|
||
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
|
||
db.exec('DELETE FROM audit_logs');
|
||
db.exec(`
|
||
CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_logs
|
||
BEGIN
|
||
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
|
||
END
|
||
`);
|
||
db.exec('COMMIT');
|
||
log.info('[DATA] Audit logs cleared');
|
||
return { success: true };
|
||
} catch (error) {
|
||
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
// ===== 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')}`;
|
||
}
|
||
}
|
||
|
||
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: 工具确认响应(ConfirmationDialog → 主进程)=====
|
||
// 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送)
|
||
ipcMain.on('tool:confirmationResponse', (_event, data: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => {
|
||
confirmationHook.resolveConfirmation(data.toolCallId, data.approved, data.remember, data.autoExecute ?? false);
|
||
log.info(`[CONFIRM] Tool ${data.toolCallId} ${data.approved ? 'approved' : 'denied'}${data.remember ? ' (remembered)' : ''}${data.autoExecute ? ' (autoExecute)' : ''}`);
|
||
});
|
||
|
||
// ===== v0.2.0: 持久化自动执行设置 =====
|
||
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: string, enabled: boolean) => {
|
||
try {
|
||
confirmationHook.setAutoExecute(toolName, enabled);
|
||
log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${enabled}`);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('tool:getAutoExecuteList', async () => {
|
||
return { success: true, data: confirmationHook.getAutoExecuteList() };
|
||
});
|
||
|
||
// ===== 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})`);
|
||
return { success: true, ...result };
|
||
} catch (error) {
|
||
log.error('[AUDIT] Chain verification failed:', error);
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('audit:query', async (_event, filters?: { sessionId?: string; eventType?: string; limit?: number }) => {
|
||
try {
|
||
return { success: true, data: auditService.query(filters as Parameters<typeof auditService.query>[0]) };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
// ===== v0.2.0: 任务管理 IPC(TaskList UI)=====
|
||
ipcMain.handle('tasks:list', async (_event, sessionId?: string) => {
|
||
const db = sessionService.getDB();
|
||
let sql = 'SELECT * FROM tasks';
|
||
const params: unknown[] = [];
|
||
if (sessionId) {
|
||
sql += ' WHERE session_id = ?';
|
||
params.push(sessionId);
|
||
}
|
||
sql += ' ORDER BY order_idx ASC, created_at ASC';
|
||
return { success: true, data: db.prepare(sql).all(...params) };
|
||
});
|
||
|
||
ipcMain.handle('tasks:create', async (_event, data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => {
|
||
const db = sessionService.getDB();
|
||
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
try {
|
||
db.prepare(`
|
||
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||
`).run(id, data.sessionId, data.title, data.description ?? '', data.priority ?? 'medium', data.parentId ?? null, Date.now(), Date.now());
|
||
return { success: true, id };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('tasks:update', async (_event, id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => {
|
||
const db = sessionService.getDB();
|
||
try {
|
||
const fields: string[] = [];
|
||
const values: unknown[] = [];
|
||
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
||
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
|
||
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
||
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
|
||
if (updates.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(updates.assignedTo); }
|
||
if (fields.length === 0) return { success: true };
|
||
fields.push('updated_at = ?'); values.push(Date.now());
|
||
if (updates.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
|
||
values.push(id);
|
||
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('tasks:delete', async (_event, id: string) => {
|
||
const db = sessionService.getDB();
|
||
try {
|
||
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
// ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI)=====
|
||
ipcMain.handle('memory:listAll', async (_event, options?: { type?: string; limit?: number }) => {
|
||
const db = sessionService.getDB();
|
||
const limit = options?.limit ?? 100;
|
||
const type = options?.type;
|
||
const results: Record<string, unknown[]> = {};
|
||
try {
|
||
if (!type || type === 'episodic') {
|
||
const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||
results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' }));
|
||
}
|
||
if (!type || type === 'semantic') {
|
||
const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||
results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at }));
|
||
}
|
||
if (!type || type === 'working') {
|
||
const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||
results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() }));
|
||
}
|
||
return { success: true, data: results };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('memory:delete', async (_event, type: string, id: string) => {
|
||
const db = sessionService.getDB();
|
||
try {
|
||
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories';
|
||
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
log.info('[SYS] All IPC handlers registered');
|
||
}
|