Files
metona-ai-desktop/electron/ipc/handlers.ts
T
thzxx 4f5f570ac8 fix: 修复多轮对话400/Provider通知反复弹出/浏览器环境隔离三大缺陷
1. 多轮对话第二次发消息 AI 不响应 (CRITICAL)

handlers.ts: 保存 tool 结果消息到数据库,DeepSeek API 要求 assistant 有 tool_calls 时后续必须有对应 tool 结果消息

openai-format.ts: assistant 有 tool_calls 时 content 设为 null (API 规范,空字符串会导致 400)

agent-store.ts: 前端加载历史时过滤 tool 消息,避免冗余卡片

2. Provider 切换通知每次发消息都弹出

main.ts: reloadAdapter 添加配置签名比较 (lastConfigSig),配置未变化时幂等返回 true,不再重建 adapter 或发 toast

3. 浏览器工具无环境隔离

browser-window-manager.ts: 独立 partition 隔离 Cookie/存储; 恢复 webSecurity,CORS 通过 webRequest 处理; setWindowOpenHandler 拦截 window.open; 并发互斥锁串行化 open 调用; destroy() 改用 win.destroy() + clearStorageData 清理残留
2026-07-12 21:21:55 +08:00

1054 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
});
}
// v0.3.0 修复: 保存 tool 结果消息到数据库
// DeepSeek/OpenAI API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
// 缺失会导致第二次发消息时 API 返回 400 Bad Request
if (step.toolResults) {
for (const result of step.toolResults) {
const resultContent = typeof result.result === 'string'
? result.result
: JSON.stringify(result.result);
sessionService.saveMessage({
sessionId,
role: 'tool',
content: result.error ?? resultContent,
toolResult: result,
iteration: step.iteration,
});
}
}
}
// 更新 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: 任务管理 IPCTaskList 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');
}