feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复: - API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容) - 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级) - error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计) - abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止) - run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化) - .env 真实生效(dotenv 回退加载,应用内配置优先) P1 工程基础: - ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线) - 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层) - test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行) - SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end) - Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知) - MCP 真就绪(等待全部连接完成再广播 tools:ready) - SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态) - CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移) P2 架构升级: - handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播) - AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号) - TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent - 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染) - 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI) - Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入 P3 能力扩展: - OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens) - Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机) - 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* IPC Agent Handlers — Agent 交互域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 职责:
|
||||
* 1. agent:sendMessage — 消息发送编排(历史加载/记忆注入/注入检测/runStream/持久化)
|
||||
* 2. agent:abortSession — 中断会话(联动 SubAgent)
|
||||
* 3. 常驻引擎事件管道 — 流式转发(按会话节流)、状态广播、TRACE 录制(P1-6 补全 9 种事件)、
|
||||
* 压缩/死循环/Provider 切换通知
|
||||
*
|
||||
* P2-10: 事件监听从"每消息 attach/detach"改为常驻管道(按 sessionId 隔离状态),
|
||||
* 支持多会话并发流式与多窗口广播。
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
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 log from 'electron-log';
|
||||
|
||||
/** 单会话的 text_delta 节流状态 */
|
||||
interface ThrottleState {
|
||||
buffer: string;
|
||||
lastEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null;
|
||||
flushTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/** 单会话的迭代录制状态(TRACE 层) */
|
||||
interface IterationTrace {
|
||||
iteration: number;
|
||||
startedAt: number;
|
||||
text: string;
|
||||
usage?: { input: number; output: number; total: number };
|
||||
responded: boolean; // 本轮 llm_response 是否已记录(PARSING 与下一轮 THINKING 去重)
|
||||
}
|
||||
|
||||
export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const {
|
||||
agentEngineManager, sessionRecorder, configService, sessionService,
|
||||
workspaceService, contextBuilder, auditService, memoryManager,
|
||||
promptInjectionDefender, outputValidator, memoryConsolidator,
|
||||
sessionSummaryService, orchestrator, confirmationHook,
|
||||
} = ctx;
|
||||
|
||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||
const throttleStates = new Map<string, ThrottleState>();
|
||||
const iterationTraces = new Map<string, IterationTrace>();
|
||||
|
||||
const flushThrottle = (sessionId: string): void => {
|
||||
const st = throttleStates.get(sessionId);
|
||||
if (!st) return;
|
||||
st.flushTimer = null;
|
||||
if (!st.buffer || !st.lastEventMeta) {
|
||||
st.buffer = '';
|
||||
st.lastEventMeta = null;
|
||||
return;
|
||||
}
|
||||
// 构建合并的 text_delta 事件,保留最后一个 delta 的元数据
|
||||
const mergedEvent: MetonaStreamEvent = {
|
||||
...st.lastEventMeta,
|
||||
type: MetonaStreamEventType.TEXT_DELTA,
|
||||
delta: st.buffer,
|
||||
seq: st.lastEventMeta.seq,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
broadcast('agent:streamEvent', mergedEvent);
|
||||
st.buffer = '';
|
||||
st.lastEventMeta = null;
|
||||
};
|
||||
|
||||
const cleanupSessionState = (sessionId: string): void => {
|
||||
const st = throttleStates.get(sessionId);
|
||||
if (st?.flushTimer) {
|
||||
clearTimeout(st.flushTimer);
|
||||
flushThrottle(sessionId);
|
||||
}
|
||||
throttleStates.delete(sessionId);
|
||||
iterationTraces.delete(sessionId);
|
||||
};
|
||||
|
||||
// ===== 常驻监听:流式事件(节流转发 + TRACE 录制) =====
|
||||
agentEngineManager.on('streamEvent', (event: MetonaStreamEvent) => {
|
||||
if (!event.sessionId) return;
|
||||
const sessionId = event.sessionId;
|
||||
const trace = iterationTraces.get(sessionId);
|
||||
|
||||
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
|
||||
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
|
||||
const st = throttleStates.get(sessionId) ?? {
|
||||
buffer: '', lastEventMeta: null, flushTimer: null,
|
||||
};
|
||||
throttleStates.set(sessionId, st);
|
||||
if (st.buffer === '') {
|
||||
st.lastEventMeta = {
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
iteration: event.iteration,
|
||||
seq: event.seq,
|
||||
timestamp: event.timestamp,
|
||||
runId: event.runId,
|
||||
};
|
||||
} else if (st.lastEventMeta) {
|
||||
st.lastEventMeta = { ...st.lastEventMeta, seq: event.seq, timestamp: event.timestamp, runId: event.runId };
|
||||
}
|
||||
st.buffer += event.delta;
|
||||
if (st.flushTimer === null) {
|
||||
st.flushTimer = setTimeout(() => flushThrottle(sessionId), 32);
|
||||
}
|
||||
// TRACE: 累积本轮 LLM 文本(供 llm_response 记录)
|
||||
if (trace) trace.text += event.delta;
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 text_delta 事件:先 flush 缓冲区,再立即转发(保证事件顺序)
|
||||
const st = throttleStates.get(sessionId);
|
||||
if (st?.flushTimer) {
|
||||
clearTimeout(st.flushTimer);
|
||||
flushThrottle(sessionId);
|
||||
}
|
||||
|
||||
// TRACE: 工具调用/结果/usage 录制(P1-6 补全)
|
||||
switch (event.type) {
|
||||
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
|
||||
if (event.toolCall) {
|
||||
sessionRecorder.recordToolCall({
|
||||
sessionId,
|
||||
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,
|
||||
iteration: event.iteration,
|
||||
toolName: event.toolResult.toolName,
|
||||
success: event.toolResult.success,
|
||||
durationMs: event.toolResult.durationMs ?? 0,
|
||||
resultPreview,
|
||||
error: event.toolResult.error,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.USAGE:
|
||||
if (trace && event.usage) {
|
||||
trace.usage = {
|
||||
input: event.usage.inputTokens ?? 0,
|
||||
output: event.usage.outputTokens ?? 0,
|
||||
total: event.usage.totalTokens ?? 0,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.DONE:
|
||||
cleanupSessionState(sessionId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
broadcast('agent:streamEvent', event);
|
||||
});
|
||||
|
||||
// ===== 常驻监听:状态变化(广播 + TRACE 迭代录制) =====
|
||||
agentEngineManager.on('stateChange', (data: {
|
||||
previous?: string; current?: string; state?: string;
|
||||
sessionId?: string; iteration?: number; runId?: string;
|
||||
}) => {
|
||||
if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`);
|
||||
broadcast('agent:stateChange', data);
|
||||
|
||||
const sessionId = data.sessionId;
|
||||
if (!sessionId || data.iteration == null) return;
|
||||
const stateValue = data.state ?? data.current ?? '';
|
||||
const trace = iterationTraces.get(sessionId);
|
||||
|
||||
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
||||
if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) {
|
||||
if (trace && !trace.responded) {
|
||||
sessionRecorder.recordLLMResponse({
|
||||
sessionId,
|
||||
iteration: trace.iteration,
|
||||
content: trace.text,
|
||||
finishReason: 'stop',
|
||||
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
|
||||
});
|
||||
}
|
||||
if (trace) {
|
||||
sessionRecorder.recordIterationEnd(sessionId, {
|
||||
iteration: trace.iteration,
|
||||
durationMs: Date.now() - trace.startedAt,
|
||||
});
|
||||
}
|
||||
iterationTraces.set(sessionId, {
|
||||
iteration: data.iteration,
|
||||
startedAt: Date.now(),
|
||||
text: '',
|
||||
responded: false,
|
||||
});
|
||||
sessionRecorder.recordIterationStart(sessionId, data.iteration);
|
||||
const provider = configService.get<string>('llm.provider') ?? '';
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
sessionRecorder.recordLLMRequest({
|
||||
sessionId,
|
||||
iteration: data.iteration,
|
||||
provider,
|
||||
model,
|
||||
messageCount: data.iteration + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// PARSING → 本轮流式结束,记录 llm_response
|
||||
if (stateValue === 'PARSING' && trace && trace.iteration === data.iteration && !trace.responded) {
|
||||
trace.responded = true;
|
||||
sessionRecorder.recordLLMResponse({
|
||||
sessionId,
|
||||
iteration: trace.iteration,
|
||||
content: trace.text,
|
||||
finishReason: 'stop',
|
||||
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
|
||||
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
|
||||
if (stateValue === 'TERMINATED') {
|
||||
if (trace) {
|
||||
sessionRecorder.recordIterationEnd(sessionId, {
|
||||
iteration: trace.iteration,
|
||||
durationMs: Date.now() - trace.startedAt,
|
||||
});
|
||||
}
|
||||
cleanupSessionState(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 常驻监听:上下文压缩(toast + streamEvent 通知) =====
|
||||
agentEngineManager.on('compressed', (data: {
|
||||
sessionId?: string; iteration?: number; originalTokens?: number; compressedTokens?: number;
|
||||
}) => {
|
||||
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
|
||||
// toast 通知用户压缩已发生
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`,
|
||||
});
|
||||
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
||||
broadcast('agent:streamEvent', {
|
||||
type: 'compressed',
|
||||
sessionId: data.sessionId ?? '',
|
||||
iteration: data.iteration ?? 0,
|
||||
originalTokens: data.originalTokens,
|
||||
compressedTokens: data.compressedTokens,
|
||||
savedTokens,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 常驻监听:死循环检测(toast 警告) =====
|
||||
agentEngineManager.on('deadLoop', (data: { iteration?: number; sessionId?: string }) => {
|
||||
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 常驻监听:Provider 故障转移(P1,通知前端 + toast) =====
|
||||
agentEngineManager.on('providerSwitched', (data: {
|
||||
from?: string; to?: string; reason?: string; sessionId?: string;
|
||||
}) => {
|
||||
broadcast('agent:providerSwitched', {
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
reason: data.reason ?? 'failover',
|
||||
sessionId: data.sessionId ?? '',
|
||||
});
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`,
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Agent 消息发送 =====
|
||||
|
||||
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
||||
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
||||
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
|
||||
const sendErrorEvent = (message: string, sid: string): void => {
|
||||
const errorEvent: MetonaStreamEvent = {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: '', sessionId: sid, iteration: 0, seq: 0, timestamp: Date.now(),
|
||||
error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false },
|
||||
};
|
||||
broadcast('agent:streamEvent', errorEvent);
|
||||
broadcast('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
|
||||
};
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||
sendErrorEvent('无效的会话 ID', sessionId ?? '');
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||||
sendErrorEvent('无效的消息格式', sessionId);
|
||||
return { success: false, error: 'Invalid message format' };
|
||||
}
|
||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||
|
||||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||||
log.error('[AGENT]', errorMsg);
|
||||
sendErrorEvent(errorMsg, sessionId);
|
||||
sessionRecorder.stopRecording(sessionId, { totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
// TRACE 层:开始录制 / TOOL 层:记录会话开始
|
||||
sessionRecorder.startRecording(sessionId);
|
||||
auditService.logSessionStart(sessionId);
|
||||
|
||||
// 保存用户消息到数据库
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: userMessage.content,
|
||||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||
});
|
||||
|
||||
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||||
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||||
|
||||
// 从工作空间文件构建 System Prompt
|
||||
const workspaceFiles = workspaceService.getFiles();
|
||||
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
|
||||
|
||||
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||||
if (contextBuilder.isUsingFallbackRole()) {
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
});
|
||||
}
|
||||
|
||||
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||||
if (memories.length > 0) {
|
||||
const memorySection = memories.map((m, i) =>
|
||||
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
|
||||
).join('\n');
|
||||
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
|
||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
|
||||
: memoryBlock;
|
||||
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||
}
|
||||
|
||||
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
|
||||
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||
const attachmentList = attachments.map((att, i) => {
|
||||
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
||||
const note = att.type === 'image'
|
||||
? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
|
||||
: att.type === 'text'
|
||||
? 'content already inlined in the user message, do NOT search in workspace or read it again'
|
||||
: 'uploaded directly by user, do NOT search in workspace';
|
||||
return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
|
||||
}).join('\n');
|
||||
|
||||
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`;
|
||||
|
||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
||||
: attachmentBlock;
|
||||
|
||||
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
const injectionResult = promptInjectionDefender.detect(userMessage.content);
|
||||
if (injectionResult.riskScore >= 7) {
|
||||
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
|
||||
sendErrorEvent(`Message blocked by prompt injection defense: ${injectionResult.recommendation}`, sessionId);
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
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(sessionId, {
|
||||
tokenCount: estimateMessagesTokens(history),
|
||||
usageRatio: 0,
|
||||
});
|
||||
|
||||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
||||
const engine = agentEngineManager.getEngine(sessionId);
|
||||
const output = await engine.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;
|
||||
|
||||
const toolCallsWithResults = step.toolCalls?.map((tc) => {
|
||||
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
status: result?.success ? 'success' as const : 'error' as const,
|
||||
result: result?.result,
|
||||
durationMs: result?.durationMs,
|
||||
error: result?.error,
|
||||
};
|
||||
});
|
||||
|
||||
// 只有当有内容、思考内容或工具调用时才保存
|
||||
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
|
||||
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
|
||||
const assistantContent = (toolCallsWithResults?.length && !step.thought.content)
|
||||
? null
|
||||
: step.thought.content;
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
reasoningContent: step.thought.reasoningContent || undefined,
|
||||
toolCalls: toolCallsWithResults,
|
||||
iteration: step.iteration,
|
||||
});
|
||||
}
|
||||
|
||||
// v0.3.0 修复: 保存 tool 结果消息到数据库
|
||||
// OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
|
||||
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`);
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('[AGENT] Memory consolidation failed:', err);
|
||||
});
|
||||
|
||||
// TOOL 层:记录会话结束 / TRACE 层:停止录制
|
||||
auditService.logSessionEnd({
|
||||
sessionId,
|
||||
totalIterations: output.iterations.length,
|
||||
totalTokens: output.totalTokenUsage.totalTokens,
|
||||
durationMs: output.durationMs,
|
||||
terminationReason: output.terminationReason,
|
||||
});
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: output.iterations.length,
|
||||
totalTokens: output.totalTokenUsage.totalTokens,
|
||||
durationMs: output.durationMs,
|
||||
terminationReason: output.terminationReason,
|
||||
});
|
||||
|
||||
// P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录)
|
||||
sessionSummaryService.maybeSummarize(sessionId).catch((err) => {
|
||||
log.warn('[AGENT] Session summary generation failed:', err);
|
||||
});
|
||||
|
||||
log.info(`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.error('[AGENT] Error:', error);
|
||||
|
||||
// TOOL 层:记录错误
|
||||
auditService.log({
|
||||
sessionId,
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'agent_loop',
|
||||
details: { error: (error as Error).message },
|
||||
outcome: 'error',
|
||||
});
|
||||
|
||||
// TRACE 层:停止录制
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error',
|
||||
});
|
||||
|
||||
// 发送错误事件到 UI
|
||||
const metonaError: MetonaError = {
|
||||
code: MetonaErrorCode.UNKNOWN,
|
||||
message: (error as Error).message,
|
||||
retryable: false,
|
||||
};
|
||||
broadcast('agent:streamEvent', {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(),
|
||||
error: metonaError,
|
||||
} satisfies MetonaStreamEvent);
|
||||
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 中断会话 =====
|
||||
|
||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||
log.info('[AGENT] Abort:', sessionId);
|
||||
// P2-10: 联动中断该会话派生的所有 SubAgent(消除"会话停了子任务还在跑")
|
||||
orchestrator.abortByParent(sessionId);
|
||||
agentEngineManager.abort(sessionId);
|
||||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
||||
await agentEngineManager.waitForAbort(sessionId);
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* IPC App Handlers — 应用工具域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 版本/路径查询、外部链接、文件夹选择、重启、SearXNG 连接测试、
|
||||
* 审计日志链验证/查询、渲染进程错误上报(P0-3 修复断链)。
|
||||
*/
|
||||
|
||||
import { ipcMain, shell, app, dialog } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import type { AuditEventType } from '../services/audit.service';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerAppHandlers(ctx: IPCContext): void {
|
||||
const { mainWindow, auditService } = ctx;
|
||||
|
||||
ipcMain.handle('app:getVersion', async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:getAppDataPath', async () => {
|
||||
return app.getPath('userData');
|
||||
});
|
||||
|
||||
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
|
||||
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
|
||||
if (typeof url !== 'string' || !url) {
|
||||
return { success: false, error: 'Invalid URL' };
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:'];
|
||||
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
|
||||
log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`);
|
||||
return { success: false, error: `Protocol not allowed: ${parsed.protocol}` };
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.warn('[IPC] openExternal failed:', (error as Error).message);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => {
|
||||
// M-36 修复: path 类型校验
|
||||
if (typeof path !== 'string' || !path) {
|
||||
return { success: false, error: 'Invalid path' };
|
||||
}
|
||||
try {
|
||||
shell.showItemInFolder(path);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
// ===== P0-3 修复: 渲染进程错误上报通道(原 preload 发送端存在但主进程无 handler,上报被静默丢弃) =====
|
||||
ipcMain.on('error:report', (_event, payload: unknown) => {
|
||||
try {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as Record<string, unknown>;
|
||||
// 截断超长字段(stack 可达数十 KB),防止日志膨胀
|
||||
const truncateStr = (v: unknown, max = 4000): unknown =>
|
||||
typeof v === 'string' ? v.slice(0, max) : v;
|
||||
const details = {
|
||||
type: truncateStr(p.type, 100),
|
||||
error: truncateStr(p.error, 1000),
|
||||
stack: truncateStr(p.stack),
|
||||
componentStack: truncateStr(p.componentStack, 4000),
|
||||
timestamp: p.timestamp,
|
||||
};
|
||||
log.error('[Renderer Error]', JSON.stringify(details));
|
||||
// 同步写入审计日志(TOOL 层),便于事后追溯
|
||||
auditService.log({
|
||||
sessionId: '',
|
||||
eventType: 'error',
|
||||
actor: 'user',
|
||||
target: 'renderer',
|
||||
details,
|
||||
outcome: 'error',
|
||||
});
|
||||
} catch (err) {
|
||||
// 错误上报自身失败仅记录,不抛出(单向通道无响应方)
|
||||
log.warn('[IPC] error:report handler failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 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: 审计日志链式哈希验证 =====
|
||||
|
||||
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?: 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',
|
||||
];
|
||||
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
|
||||
if (filters && typeof filters === 'object') {
|
||||
const f = filters as Record<string, unknown>;
|
||||
if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId;
|
||||
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(', ')})` };
|
||||
}
|
||||
safeFilters.eventType = f.eventType as AuditEventType;
|
||||
}
|
||||
if (f.limit !== undefined) {
|
||||
const limit = Number(f.limit);
|
||||
// 限制 1-1000 范围,防止过大查询拖慢性能
|
||||
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) {
|
||||
return { success: false, error: 'Invalid limit (must be 1-1000)' };
|
||||
}
|
||||
safeFilters.limit = Math.floor(limit);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return { success: true, data: auditService.query(safeFilters) };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* IPC Config Handlers — 配置管理域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* config:get / config:set / config:setBatch。
|
||||
* P2-9: set 与 setBatch 共享 applyConfigSideEffects(消除原 300 行重复逻辑)。
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import { LLM_CONFIG_KEYS, applyConfigSideEffects, clearApiKeyOnProviderChange, maskSensitive } from './shared';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerConfigHandlers(ctx: IPCContext): void {
|
||||
const { configService, auditService } = ctx;
|
||||
|
||||
ipcMain.handle('config:get', async (_event, key) => {
|
||||
return configService.get(key);
|
||||
});
|
||||
|
||||
ipcMain.handle('config:set', async (_event, key: unknown, value: unknown) => {
|
||||
// M-42 修复: 参数校验
|
||||
if (typeof key !== 'string' || !key.trim()) {
|
||||
return { success: false, error: 'Invalid config key' };
|
||||
}
|
||||
// value 必须是可序列化的基本类型(string|number|boolean|null)
|
||||
if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||||
return { success: false, error: 'Invalid config value: must be string, number, boolean, or null' };
|
||||
}
|
||||
|
||||
// C-1 修复: Provider 切换时清空 API key(P2-9: 统一走共享函数)
|
||||
clearApiKeyOnProviderChange(ctx, [{ key, value }]);
|
||||
|
||||
configService.set(key, value);
|
||||
|
||||
// M-42 修复: 敏感配置项脱敏后再写入审计日志
|
||||
auditService.log({
|
||||
sessionId: '',
|
||||
eventType: 'config_change',
|
||||
actor: 'user',
|
||||
target: key,
|
||||
details: { value: maskSensitive(key, value) },
|
||||
outcome: 'success',
|
||||
});
|
||||
|
||||
// P2-9: 统一副作用(reloadAdapter + Engine 同步 + 广播 + 工作空间路径)
|
||||
const sideEffectError = await applyConfigSideEffects(ctx, [{ key, value }]);
|
||||
if (sideEffectError) {
|
||||
return { success: false, error: sideEffectError };
|
||||
}
|
||||
if (LLM_CONFIG_KEYS.includes(key)) {
|
||||
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ===== v0.3.9: 批量保存配置(P2-9: 复用共享副作用逻辑) =====
|
||||
// 设计原因:前端设置页一次保存多个字段,串行 config:set 会在中间态(如 provider
|
||||
// 已改但 apiKey 还没保存)触发 reloadAdapter 失败。批量保存:先写入所有字段,
|
||||
// 最后统一触发一次 reloadAdapter 和 Engine/Orchestrator 同步。
|
||||
ipcMain.handle('config:setBatch', async (_event, entries: unknown) => {
|
||||
// 参数校验:必须是 {key, value}[] 非空数组
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return { success: false, error: 'Invalid entries: must be non-empty array of {key, value}' };
|
||||
}
|
||||
// 逐条校验每个元素的结构和类型
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return { success: false, error: 'Invalid entry: must be {key, value} object' };
|
||||
}
|
||||
const e = entry as { key?: unknown; value?: unknown };
|
||||
if (typeof e.key !== 'string' || !e.key.trim()) {
|
||||
return { success: false, error: 'Invalid config key in batch' };
|
||||
}
|
||||
const v = e.value;
|
||||
if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') {
|
||||
return { success: false, error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null` };
|
||||
}
|
||||
}
|
||||
|
||||
// v0.3.10 防御性修复: Provider 切换清空 apiKey 必须在循环前执行(共享函数内含说明)
|
||||
clearApiKeyOnProviderChange(ctx, entries as Array<{ key: string; value: unknown }>);
|
||||
|
||||
// 第一步:逐条写入 configService + 审计日志(脱敏)
|
||||
for (const entry of entries) {
|
||||
const { key, value } = entry as { key: string; value: unknown };
|
||||
configService.set(key, value);
|
||||
auditService.log({
|
||||
sessionId: '',
|
||||
eventType: 'config_change',
|
||||
actor: 'user',
|
||||
target: key,
|
||||
details: { value: maskSensitive(key, value) },
|
||||
outcome: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
// 第二步:统一副作用(reloadAdapter + Engine 同步 + 日志级别 + 广播 + 工作空间路径)
|
||||
const sideEffectError = await applyConfigSideEffects(ctx, entries as Array<{ key: string; value: unknown }>);
|
||||
if (sideEffectError) {
|
||||
log.warn('[CONFIG] Config batch save side effects failed');
|
||||
return { success: false, error: sideEffectError };
|
||||
}
|
||||
log.info(`[CONFIG] Config batch saved (${entries.length} keys)`);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* IPC Context — 跨模块共享上下文(P2-9)
|
||||
*
|
||||
* 原 handlers.ts 1940 行巨型函数的 17 个参数收敛为单一上下文对象,
|
||||
* 按 13 个域模块拆分注册。broadcast() 将事件发送到所有窗口
|
||||
* (P2-10 修复:原实现只发给初始 mainWindow,第二窗口收不到任何流式事件)。
|
||||
*/
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
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 { AgentEngineManager } from '../services/agent-engine-manager.service';
|
||||
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 { TaskOrchestrator } from '../harness/orchestration/orchestrator';
|
||||
import type { SessionSummaryService } from '../services/session-summary.service';
|
||||
|
||||
/** 工具就绪状态(main.ts 在 MCP 初始化完成后更新,tools.ts 的 isReady 查询读取) */
|
||||
export interface ToolsReadyRef {
|
||||
ready: boolean;
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
export interface IPCContext {
|
||||
mainWindow: BrowserWindow;
|
||||
sessionService: SessionService;
|
||||
configService: ConfigService;
|
||||
workspaceService: WorkspaceService;
|
||||
contextBuilder: ContextBuilder;
|
||||
agentEngineManager: AgentEngineManager;
|
||||
toolRegistry: ToolRegistry;
|
||||
auditService: AuditService;
|
||||
sessionRecorder: SessionRecorder;
|
||||
memoryManager: MemoryManager;
|
||||
mcpManager: MCPManager;
|
||||
reloadAdapter: () => boolean;
|
||||
promptInjectionDefender: PromptInjectionDefender;
|
||||
outputValidator: OutputValidator;
|
||||
confirmationHook: ConfirmationHook;
|
||||
memoryConsolidator: MemoryConsolidator;
|
||||
orchestrator: TaskOrchestrator;
|
||||
sessionSummaryService: SessionSummaryService;
|
||||
toolsReadyRef: ToolsReadyRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件到所有窗口(P2-10: 多窗口支持)
|
||||
*
|
||||
* 渲染进程已按 sessionId 过滤事件,多窗口广播不会产生跨会话污染。
|
||||
*/
|
||||
export function broadcast(channel: string, ...args: unknown[]): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* IPC Data Handlers — 数据导出与清理域(P2-9 从 handlers.ts 拆分)
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import log from 'electron-log';
|
||||
|
||||
/**
|
||||
* 导出限流常量:单会话导出的最大消息条数。
|
||||
*
|
||||
* 背景:导出会把所有会话×所有消息×完整 toolResult 一次性 JSON.stringify 成单个 Blob
|
||||
* 传到渲染进程。含 view_image 的会话单条 tool_result 就有 ~6.7MB base64 dataUrl,
|
||||
* 全量导出会直接撑爆渲染进程堆导致 OOM 崩溃。
|
||||
*
|
||||
* 双重防护:
|
||||
* 1. 剥离每条 tool 消息中的 dataUrl 等超大 base64(sanitizeExportMessage)
|
||||
* 2. 全量导出时按会话限制消息条数(MAX_EXPORT_MESSAGES_PER_SESSION)
|
||||
*/
|
||||
const MAX_EXPORT_MESSAGES_PER_SESSION = 2000;
|
||||
|
||||
/**
|
||||
* 剥离导出消息中的超大 base64 字段(如 view_image 的 dataUrl)。
|
||||
* 仅用于导出快照——原数据库数据不受影响。
|
||||
*/
|
||||
const sanitizeExportMessage = (msg: Record<string, unknown>): Record<string, unknown> => {
|
||||
const toolResult = msg.toolResult as Record<string, unknown> | undefined;
|
||||
if (toolResult && typeof toolResult === 'object' && 'result' in toolResult) {
|
||||
const result = toolResult.result;
|
||||
// result 含 dataUrl(view_image 等图片结果):剥离 dataUrl,保留 path/size/mimeType 等小字段
|
||||
if (result && typeof result === 'object' && !Array.isArray(result) && 'dataUrl' in result) {
|
||||
const { dataUrl: _omit, ...rest } = result as Record<string, unknown>;
|
||||
void _omit;
|
||||
return {
|
||||
...msg,
|
||||
toolResult: { ...toolResult, result: { ...rest, _displayNote: '[image base64 omitted in export]' } },
|
||||
};
|
||||
}
|
||||
}
|
||||
// attachments[].preview(用户上传图片的 base64 缩略图)也一并剥离,避免导出文件膨胀
|
||||
const attachments = msg.attachments;
|
||||
if (Array.isArray(attachments)) {
|
||||
const sanitized = attachments.map((att) => {
|
||||
if (att && typeof att === 'object' && 'preview' in att) {
|
||||
const { preview: _omit, ...rest } = att as Record<string, unknown>;
|
||||
void _omit;
|
||||
return { ...rest, _displayNote: '[preview omitted in export]' };
|
||||
}
|
||||
return att;
|
||||
});
|
||||
return { ...msg, attachments: sanitized };
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
export function registerDataHandlers(ctx: IPCContext): void {
|
||||
const { sessionService, configService } = ctx;
|
||||
|
||||
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
|
||||
try {
|
||||
if (sessionId) {
|
||||
// 导出单个会话:剥离超大 base64 后返回
|
||||
const messages = sessionService.getMessages(sessionId);
|
||||
const sanitized = messages.map((m) => sanitizeExportMessage(m as unknown as Record<string, unknown>));
|
||||
return { success: true, data: sanitized };
|
||||
}
|
||||
// 导出所有会话:剥离 dataUrl + 每会话限条数,防渲染进程 Blob 序列化 OOM
|
||||
const sessions = sessionService.list();
|
||||
const allData: Record<string, unknown> = { sessions: [], config: configService.getAll() };
|
||||
for (const session of sessions) {
|
||||
const rawMessages = sessionService.getMessages(session.id, { limit: MAX_EXPORT_MESSAGES_PER_SESSION });
|
||||
const sanitizedMessages = rawMessages.map((m) => sanitizeExportMessage(m as unknown as Record<string, unknown>));
|
||||
(allData.sessions as Array<Record<string, unknown>>).push({
|
||||
...session,
|
||||
messages: sanitizedMessages,
|
||||
truncated: rawMessages.length >= MAX_EXPORT_MESSAGES_PER_SESSION,
|
||||
});
|
||||
}
|
||||
return { success: true, data: allData };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('data:clearSessions', async () => {
|
||||
// M-40 修复: 危险操作审计日志,便于追踪异常调用
|
||||
log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted');
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages').get() as { c: number };
|
||||
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
|
||||
db.exec('DELETE FROM messages');
|
||||
db.exec('DELETE FROM sessions');
|
||||
db.exec('COMMIT');
|
||||
log.info(`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`);
|
||||
return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
|
||||
} catch (error) {
|
||||
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
|
||||
log.error('[DATA] clearSessions failed:', (error as Error).message);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('data:clearMemories', async () => {
|
||||
// M-40 修复: 危险操作审计日志
|
||||
log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted');
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
// M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致
|
||||
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as { c: number };
|
||||
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as { c: number };
|
||||
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as { c: number };
|
||||
db.exec('BEGIN');
|
||||
db.exec('DELETE FROM episodic_memories');
|
||||
db.exec('DELETE FROM semantic_memories');
|
||||
db.exec('DELETE FROM working_memories');
|
||||
db.exec('COMMIT');
|
||||
log.info(`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`);
|
||||
return { success: true, deletedEpisodic: epiCount.c, deletedSemantic: semCount.c, deletedWorking: workCount.c };
|
||||
} catch (error) {
|
||||
// M-41 修复: 失败时回滚事务,确保数据一致性
|
||||
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
|
||||
log.error('[DATA] clearMemories failed:', (error as Error).message);
|
||||
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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* IPC Handlers — 统一注册入口(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 原 1940 行巨型 registerAllIPCHandlers 按 13 个域模块拆分:
|
||||
* agent / sessions / config / tools / mcp / memory / tasks / data / workspace / app
|
||||
*
|
||||
* 注册顺序与 main.ts beforeLoad 中调用(P2-11 修复: IPC handler 在渲染进程
|
||||
* 加载前注册,消除时序窗口)。
|
||||
*/
|
||||
|
||||
import type { IPCContext } from './context';
|
||||
import { registerAgentHandlers } from './agent';
|
||||
import { registerSessionHandlers } from './sessions';
|
||||
import { registerConfigHandlers } from './config';
|
||||
import { registerToolHandlers } from './tools';
|
||||
import { registerMCPHandlers } from './mcp';
|
||||
import { registerMemoryHandlers } from './memory';
|
||||
import { registerTaskHandlers } from './tasks';
|
||||
import { registerDataHandlers } from './data';
|
||||
import { registerWorkspaceHandlers } from './workspace';
|
||||
import { registerAppHandlers } from './app';
|
||||
import log from 'electron-log';
|
||||
|
||||
export type { IPCContext, ToolsReadyRef } from './context';
|
||||
export { broadcast } from './context';
|
||||
|
||||
/** 防重入标志:ipcMain.handle 重复注册同一通道会抛异常 */
|
||||
let registered = false;
|
||||
|
||||
export function registerAllIPCHandlers(ctx: IPCContext): void {
|
||||
// 当前仅主窗口 beforeLoad 调用一次;此保护覆盖未来任何多窗口创建路径
|
||||
// 携带 beforeLoad 的场景(如 macOS activate 重建窗口)。
|
||||
if (registered) {
|
||||
log.warn('[SYS] IPC handlers already registered, skipping duplicate registration');
|
||||
return;
|
||||
}
|
||||
registered = true;
|
||||
|
||||
registerAgentHandlers(ctx);
|
||||
registerSessionHandlers(ctx);
|
||||
registerConfigHandlers(ctx);
|
||||
registerToolHandlers(ctx);
|
||||
registerMCPHandlers(ctx);
|
||||
registerMemoryHandlers(ctx);
|
||||
registerTaskHandlers(ctx);
|
||||
registerDataHandlers(ctx);
|
||||
registerWorkspaceHandlers(ctx);
|
||||
registerAppHandlers(ctx);
|
||||
log.info('[SYS] All IPC handlers registered');
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* IPC MCP Handlers — MCP 服务管理域(P2-9 从 handlers.ts 拆分)
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
const { mcpManager } = ctx;
|
||||
|
||||
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 }) => {
|
||||
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { success: false, error: 'Invalid config' };
|
||||
}
|
||||
if (typeof config.name !== 'string' || !config.name.trim()) {
|
||||
return { success: false, error: 'Server name is required' };
|
||||
}
|
||||
// M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言)
|
||||
if (config.transport !== 'stdio' && config.transport !== 'sse') {
|
||||
return { success: false, error: `Invalid transport: ${config.transport}. Must be 'stdio' or 'sse'` };
|
||||
}
|
||||
// stdio 类型必须有 command
|
||||
if (config.transport === 'stdio' && (typeof config.command !== 'string' || !config.command.trim())) {
|
||||
return { success: false, error: 'command is required for stdio transport' };
|
||||
}
|
||||
// sse 类型必须有合法 url
|
||||
if (config.transport === 'sse') {
|
||||
if (typeof config.url !== 'string' || !config.url.trim()) {
|
||||
return { success: false, error: 'url is required for sse transport' };
|
||||
}
|
||||
try { new URL(config.url); } catch {
|
||||
return { success: false, error: 'Invalid url format' };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mcpManager.addServer({
|
||||
name: config.name,
|
||||
transport: config.transport, // 已校验,无需断言
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
url: config.url,
|
||||
enabled: true,
|
||||
});
|
||||
log.info(`MCP server added: ${config.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
|
||||
// M-38 修复: name 校验
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
return { success: false, error: 'Invalid server name' };
|
||||
}
|
||||
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) => {
|
||||
// M-38 修复: name 和 enabled 校验
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
return { success: false, error: 'Invalid server name' };
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'Invalid enabled flag' };
|
||||
}
|
||||
try {
|
||||
await mcpManager.toggleServer(name, enabled);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* IPC Memory Handlers — 记忆系统域(P2-9 从 handlers.ts 拆分)
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import type { MemoryType } from '../harness/memory/manager';
|
||||
|
||||
const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working'];
|
||||
|
||||
export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
const { memoryManager, sessionService } = ctx;
|
||||
|
||||
ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
|
||||
// M-37 修复: query 和 options 校验
|
||||
if (typeof query !== 'string' || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
// 构造合法的搜索选项(仅保留已知字段,强制类型安全)
|
||||
const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {};
|
||||
if (options && typeof options === 'object') {
|
||||
const opts = options as Record<string, unknown>;
|
||||
// topK 限制范围 1-100(防止过大查询)
|
||||
if (opts.topK !== undefined) {
|
||||
const topK = Number(opts.topK);
|
||||
if (Number.isFinite(topK) && topK >= 1 && topK <= 100) {
|
||||
searchOptions.topK = topK;
|
||||
} else {
|
||||
searchOptions.topK = 10; // 默认值
|
||||
}
|
||||
}
|
||||
if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId;
|
||||
// type 必须是合法的 MemoryType 枚举值
|
||||
if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) {
|
||||
searchOptions.type = opts.type as MemoryType;
|
||||
}
|
||||
if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) {
|
||||
searchOptions.minImportance = opts.minImportance;
|
||||
}
|
||||
}
|
||||
return memoryManager.search(query, searchOptions);
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI) =====
|
||||
const VALID_MEMORY_TYPES_LIST: readonly string[] = ['episodic', 'semantic', 'working'];
|
||||
|
||||
ipcMain.handle('memory:listAll', async (_event, options?: unknown) => {
|
||||
// M-49 修复: 校验 options.type 枚举和 limit 范围
|
||||
let limit = 100;
|
||||
let type: string | undefined;
|
||||
if (options && typeof options === 'object') {
|
||||
const opts = options as Record<string, unknown>;
|
||||
if (opts.type !== undefined) {
|
||||
if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) {
|
||||
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
|
||||
}
|
||||
type = opts.type;
|
||||
}
|
||||
if (opts.limit !== undefined) {
|
||||
const num = Number(opts.limit);
|
||||
// 限制 1-1000 范围,SQLite 中 LIMIT -1 表示无限制,需阻止
|
||||
if (!Number.isFinite(num) || num < 1 || num > 1000) {
|
||||
return { success: false, error: 'Invalid limit (must be 1-1000)' };
|
||||
}
|
||||
limit = Math.floor(num);
|
||||
}
|
||||
}
|
||||
const db = sessionService.getDB();
|
||||
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: unknown, id: unknown) => {
|
||||
// M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型
|
||||
if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) {
|
||||
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
|
||||
}
|
||||
if (typeof id !== 'string' || !id) {
|
||||
return { success: false, error: 'Invalid memory id' };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* IPC Session Handlers — 会话管理域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 会话 CRUD、消息加载/删除/清空、Trace 持久化。
|
||||
* P2-11: 新增 sessions:truncateAfter(编辑重发/重新生成的消息截断)。
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
|
||||
/** M-34 修复: 统一 sessionId 校验辅助函数 */
|
||||
const isValidSessionId = (id: unknown): id is string =>
|
||||
typeof id === 'string' && id.length > 0 && id.length <= 200;
|
||||
|
||||
export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
const { sessionService } = ctx;
|
||||
|
||||
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: 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' };
|
||||
return { success: sessionService.rename(sessionId, title) };
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
|
||||
// M-34 修复: 校验 sessionId
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
return { success: sessionService.delete(sessionId) };
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
|
||||
// M-34 修复: 校验 sessionId
|
||||
if (!isValidSessionId(sessionId)) return [];
|
||||
return sessionService.getMessages(sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' };
|
||||
return { success: sessionService.pin(sessionId, pinned) };
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:archive', async (_event, sessionId: unknown, archived: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof archived !== 'boolean') return { success: false, error: 'Invalid archived flag' };
|
||||
return { success: sessionService.archive(sessionId, archived) };
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
|
||||
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
|
||||
return { success: sessionService.deleteMessage(messageId) };
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:clearMessages', async (_event, sessionId: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
return { success: sessionService.clearMessages(sessionId) };
|
||||
});
|
||||
|
||||
// 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;
|
||||
try {
|
||||
const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
|
||||
return { success: true, truncated };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:saveTrace', async (_event, sessionId: unknown, data: unknown) => {
|
||||
// M-34 修复: 校验 sessionId 和 data
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
// 严格校验 data 结构:traceSteps 必须为数组,tokenUsage 必须存在
|
||||
if (!data || typeof data !== 'object') return { success: false, error: 'Invalid trace data' };
|
||||
const traceData = data as { traceSteps?: unknown; tokenUsage?: unknown };
|
||||
if (!Array.isArray(traceData.traceSteps)) {
|
||||
return { success: false, error: 'Invalid trace data: traceSteps must be an array' };
|
||||
}
|
||||
if (traceData.tokenUsage === undefined) {
|
||||
return { success: false, error: 'Invalid trace data: tokenUsage is required' };
|
||||
}
|
||||
try {
|
||||
sessionService.saveTraceData(sessionId, {
|
||||
traceSteps: traceData.traceSteps,
|
||||
tokenUsage: traceData.tokenUsage,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:getTrace', async (_event, sessionId: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return null;
|
||||
return sessionService.getTraceData(sessionId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* IPC Shared — 配置写入的共享副作用逻辑(P2-9)
|
||||
*
|
||||
* 提取原 handlers.ts 中 config:set 与 config:setBatch 两处重复的:
|
||||
* 敏感值脱敏、Provider 切换清空 apiKey、Engine/Orchestrator 配置同步、
|
||||
* 日志级别应用、配置变更广播、工作空间路径持久化。
|
||||
*/
|
||||
|
||||
import log from 'electron-log';
|
||||
import type { IPCContext } from './context';
|
||||
import { broadcast } from './context';
|
||||
import { isSensitiveConfigKey } from '../utils/secure-config';
|
||||
|
||||
/** LLM 相关配置 key(变更时触发热重载 Adapter) */
|
||||
export const LLM_CONFIG_KEYS = [
|
||||
'llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL',
|
||||
'llm.fallbackProvider', 'llm.fallbackModel', 'llm.fallbackApiKey', 'llm.fallbackBaseURL',
|
||||
'ollama.numCtx',
|
||||
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow',
|
||||
'openai.contextWindow', 'anthropic.contextWindow',
|
||||
];
|
||||
|
||||
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
|
||||
export function maskSensitive(key: string, value: unknown): unknown {
|
||||
if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) {
|
||||
return value.length > 4 ? '***' + value.slice(-4) : '***';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 切换时清空 API key(C-1 修复,供 set/setBatch 共用)
|
||||
*
|
||||
* 必须在写入 entries 之前执行:若前端把 llm.apiKey 放在 llm.provider 之前,
|
||||
* 先 set apiKey 再处理 provider 会把用户刚填的 key 清空。
|
||||
*/
|
||||
export function clearApiKeyOnProviderChange(ctx: IPCContext, entries: Array<{ key: string; value: unknown }>): void {
|
||||
const providerEntry = entries.find((e) => e.key === 'llm.provider');
|
||||
if (!providerEntry) return;
|
||||
const oldProvider = ctx.configService.get<string>('llm.provider') ?? '';
|
||||
const newProvider = (providerEntry.value as string) ?? '';
|
||||
if (oldProvider && newProvider && oldProvider !== newProvider) {
|
||||
ctx.configService.set('llm.apiKey', '');
|
||||
log.info(`[CONFIG] Provider changed (${oldProvider} → ${newProvider}), API key cleared to prevent incompatible key usage`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步)
|
||||
*/
|
||||
export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void {
|
||||
const { agentEngineManager, orchestrator, confirmationHook } = ctx;
|
||||
switch (key) {
|
||||
case 'agent.maxIterations':
|
||||
agentEngineManager.updateConfigAll({ maxIterations: value as number });
|
||||
break;
|
||||
case 'agent.totalTimeoutMs':
|
||||
agentEngineManager.updateConfigAll({ totalTimeoutMs: value as number });
|
||||
break;
|
||||
case 'agent.enableThinking':
|
||||
agentEngineManager.updateConfigAll({ thinkingEnabled: value as boolean });
|
||||
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
|
||||
break;
|
||||
case 'agent.thinkingEffort':
|
||||
agentEngineManager.updateConfigAll({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
|
||||
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
|
||||
break;
|
||||
case 'agent.toolExecutionTimeoutMs':
|
||||
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
||||
break;
|
||||
case 'agent.confirmationTimeoutMs':
|
||||
confirmationHook.setConfirmationTimeout(value as number);
|
||||
break;
|
||||
case 'ollama.numCtx':
|
||||
agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined });
|
||||
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
|
||||
break;
|
||||
case 'deepseek.contextWindow':
|
||||
case 'agnes.contextWindow':
|
||||
case 'mimo.contextWindow':
|
||||
case 'openai.contextWindow':
|
||||
case 'anthropic.contextWindow':
|
||||
// reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底)
|
||||
agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined });
|
||||
orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置写入后的统一副作用(供 config:set / config:setBatch 共用)
|
||||
*
|
||||
* 1. LLM key 变更 → 统一 reloadAdapter 一次(避免中间态失败)
|
||||
* 2. Engine/Orchestrator 配置同步
|
||||
* 3. 日志级别即时应用
|
||||
* 4. 广播配置变更(前端 store 实时更新)
|
||||
* 5. 工作空间路径写独立文件(下次启动生效)
|
||||
*
|
||||
* @returns 错误信息(成功为 null)
|
||||
*/
|
||||
export async function applyConfigSideEffects(
|
||||
ctx: IPCContext,
|
||||
entries: Array<{ key: string; value: unknown }>,
|
||||
): Promise<string | null> {
|
||||
// 1. LLM 配置变更 → 统一热重载 Adapter(一次)
|
||||
if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) {
|
||||
if (!ctx.reloadAdapter()) {
|
||||
log.warn('[CONFIG] Adapter reload failed after config save');
|
||||
return 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Engine/Orchestrator/ConfirmationHook 配置同步
|
||||
for (const { key, value } of entries) {
|
||||
applyEngineConfigKey(ctx, key, value);
|
||||
}
|
||||
|
||||
// 3. 日志级别即时应用
|
||||
const logLevelEntry = entries.find((e) => e.key === 'logging.level');
|
||||
if (logLevelEntry && typeof logLevelEntry.value === 'string') {
|
||||
const { transports } = await import('electron-log');
|
||||
transports.file.level = logLevelEntry.value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
|
||||
transports.console.level = logLevelEntry.value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
|
||||
log.info(`[CONFIG] Log level updated to ${logLevelEntry.value}`);
|
||||
}
|
||||
|
||||
// 4. 广播所有配置变更事件(前端监听后更新 store)
|
||||
for (const { key, value } of entries) {
|
||||
broadcast('config:changed', { key, value });
|
||||
}
|
||||
|
||||
// 5. 工作空间路径写入独立文件(下次启动生效)
|
||||
const workspaceEntry = entries.find((e) => e.key === 'workspace.path' && typeof e.value === 'string');
|
||||
if (workspaceEntry) {
|
||||
try {
|
||||
const { writeWorkspacePathToFile } = await import('../main');
|
||||
writeWorkspacePathToFile(workspaceEntry.value as string);
|
||||
log.info(`[CONFIG] Workspace path saved (restart required): ${workspaceEntry.value}`);
|
||||
} catch (err) {
|
||||
log.error('[CONFIG] Failed to save workspace path:', err);
|
||||
// v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径
|
||||
return `工作空间路径保存失败:${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* IPC Task Handlers — 任务管理域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 注意:此处为 UI 直连的 CRUD 通道;Agent 运行时走 task_manager 工具
|
||||
* (electron/harness/tools/built-in/task-manager.ts),两侧共享 tasks 表。
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { IPCContext } from './context';
|
||||
|
||||
const VALID_TASK_PRIORITIES: readonly string[] = ['low', 'medium', 'high', 'critical'];
|
||||
const VALID_TASK_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'];
|
||||
|
||||
export function registerTaskHandlers(ctx: IPCContext): void {
|
||||
const { sessionService } = ctx;
|
||||
|
||||
ipcMain.handle('tasks:list', async (_event, sessionId?: unknown) => {
|
||||
// M-46 修复: 校验 sessionId 类型(可选参数)
|
||||
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId)) {
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
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: unknown) => {
|
||||
// M-46 修复: 校验 data 结构和字段类型/枚举
|
||||
if (!data || typeof data !== 'object') {
|
||||
return { success: false, error: 'Invalid task data' };
|
||||
}
|
||||
const req = data as Record<string, unknown>;
|
||||
if (typeof req.sessionId !== 'string' || !req.sessionId.trim()) {
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (typeof req.title !== 'string' || !req.title.trim()) {
|
||||
return { success: false, error: 'Invalid title' };
|
||||
}
|
||||
if (req.priority !== undefined && !VALID_TASK_PRIORITIES.includes(req.priority as string)) {
|
||||
return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` };
|
||||
}
|
||||
if (req.parentId !== undefined && req.parentId !== null && typeof req.parentId !== 'string') {
|
||||
return { success: false, error: 'Invalid parentId' };
|
||||
}
|
||||
const db = sessionService.getDB();
|
||||
// P4 统一(v0.3.13): ID 生成方式与 task_manager 工具一致(nanoid)
|
||||
const id = `task_${nanoid(12)}`;
|
||||
try {
|
||||
// P4 统一: 计算 order_idx = MAX(同 session+parent 的 order_idx) + 1(与 task_manager 工具一致)
|
||||
const parentId = (req.parentId as string | null) ?? null;
|
||||
let orderIdx = 0;
|
||||
if (parentId === null) {
|
||||
const orderRow = db.prepare(
|
||||
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id IS NULL'
|
||||
).get(req.sessionId) as { maxOrder: number } | undefined;
|
||||
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
|
||||
} else {
|
||||
const orderRow = db.prepare(
|
||||
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id = ?'
|
||||
).get(req.sessionId, parentId) as { maxOrder: number } | undefined;
|
||||
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, order_idx, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
req.sessionId,
|
||||
req.title,
|
||||
typeof req.description === 'string' ? req.description : '',
|
||||
(req.priority as string) ?? 'medium',
|
||||
parentId,
|
||||
orderIdx,
|
||||
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: unknown, updates: unknown, sessionId: unknown) => {
|
||||
// M-47 修复: 校验 id 和 updates 结构/枚举
|
||||
if (typeof id !== 'string' || !id) {
|
||||
return { success: false, error: 'Invalid task id' };
|
||||
}
|
||||
// P1 修复(v0.3.13): 补 session_id 越权保护(与 task_manager 工具一致)
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
return { success: false, error: 'Invalid updates' };
|
||||
}
|
||||
const u = updates as Record<string, unknown>;
|
||||
if (u.status !== undefined && !VALID_TASK_STATUSES.includes(u.status as string)) {
|
||||
return { success: false, error: `Invalid status (must be one of: ${VALID_TASK_STATUSES.join(', ')})` };
|
||||
}
|
||||
if (u.priority !== undefined && !VALID_TASK_PRIORITIES.includes(u.priority as string)) {
|
||||
return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` };
|
||||
}
|
||||
if (u.assignedTo !== undefined && u.assignedTo !== null && typeof u.assignedTo !== 'string') {
|
||||
return { success: false, error: 'Invalid assignedTo' };
|
||||
}
|
||||
// 审计补充修复: 补全 title/description 类型校验
|
||||
if (u.title !== undefined && typeof u.title !== 'string') {
|
||||
return { success: false, error: 'Invalid title (must be string)' };
|
||||
}
|
||||
if (u.description !== undefined && typeof u.description !== 'string') {
|
||||
return { success: false, error: 'Invalid description (must be string)' };
|
||||
}
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (u.title !== undefined) { fields.push('title = ?'); values.push(u.title); }
|
||||
if (u.description !== undefined) { fields.push('description = ?'); values.push(u.description); }
|
||||
if (u.status !== undefined) { fields.push('status = ?'); values.push(u.status); }
|
||||
if (u.priority !== undefined) { fields.push('priority = ?'); values.push(u.priority); }
|
||||
if (u.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(u.assignedTo); }
|
||||
if (fields.length === 0) return { success: true };
|
||||
fields.push('updated_at = ?'); values.push(Date.now());
|
||||
if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
|
||||
values.push(id, sessionId);
|
||||
// P1 修复(v0.3.13): WHERE 补 session_id 校验,防越权修改其他会话任务
|
||||
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('tasks:delete', async (_event, id: unknown, sessionId: unknown) => {
|
||||
// M-48 修复: 校验 id 类型
|
||||
if (typeof id !== 'string' || !id) {
|
||||
return { success: false, error: 'Invalid task id' };
|
||||
}
|
||||
// P1 修复(v0.3.13): 补 session_id 越权保护
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
db.prepare('DELETE FROM tasks WHERE id = ? AND session_id = ?').run(id, sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* IPC Tool Handlers — 工具与确认域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 工具列表/开关、工具就绪查询(P2-9: 从 main.ts beforeLoad 迁入)、
|
||||
* 工具确认(单条/批量/拉取 pending)、自动执行设置。
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerToolHandlers(ctx: IPCContext): void {
|
||||
const { toolRegistry, confirmationHook, agentEngineManager } = ctx;
|
||||
|
||||
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) => {
|
||||
// M-35 修复: 校验 toolName 合法性,防止配置 key 污染
|
||||
if (typeof toolName !== 'string' || !toolName || typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'Invalid parameters' };
|
||||
}
|
||||
// 校验 toolName 在已注册工具列表中(防止写入任意配置 key)
|
||||
const allTools = toolRegistry.listAllTools();
|
||||
if (!allTools.some((t) => t.name === toolName)) {
|
||||
log.warn(`[IPC] tools:toggle rejected: unknown tool "${toolName}"`);
|
||||
return { success: false, error: `Unknown tool: ${toolName}` };
|
||||
}
|
||||
// 工具开关通过配置持久化
|
||||
ctx.configService.set(`tools.${toolName}.enabled`, enabled);
|
||||
// 同步到 ToolRegistry(立即生效)
|
||||
toolRegistry.setToolEnabled(toolName, enabled);
|
||||
// 同步到所有会话引擎的工具列表
|
||||
agentEngineManager.setToolsAll(toolRegistry.listTools());
|
||||
log.info(`Tool ${toolName} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ===== 工具就绪查询(P2-9: 从 main.ts beforeLoad 迁入,解决事件竞态) =====
|
||||
ipcMain.handle('tools:isReady', () => ({
|
||||
ready: ctx.toolsReadyRef.ready,
|
||||
toolCount: ctx.toolsReadyRef.toolCount,
|
||||
}));
|
||||
|
||||
// ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程) =====
|
||||
ipcMain.on('tool:confirmationResponse', (_event, data: unknown) => {
|
||||
// M-43 修复: 校验 data 结构,防止 undefined/null 导致 TypeError
|
||||
if (!data || typeof data !== 'object') {
|
||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
|
||||
return;
|
||||
}
|
||||
const req = data as { toolCallId?: unknown; approved?: unknown; remember?: unknown; autoExecute?: unknown };
|
||||
if (typeof req.toolCallId !== 'string' || !req.toolCallId) {
|
||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
|
||||
return;
|
||||
}
|
||||
if (typeof req.approved !== 'boolean') {
|
||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid approved');
|
||||
return;
|
||||
}
|
||||
const remember = typeof req.remember === 'boolean' ? req.remember : false;
|
||||
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
|
||||
confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute);
|
||||
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
|
||||
});
|
||||
|
||||
// ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批) =====
|
||||
ipcMain.on('tool:confirmationResponseBatch', (_event, data: unknown) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid data');
|
||||
return;
|
||||
}
|
||||
const req = data as {
|
||||
toolCallIds?: unknown;
|
||||
approved?: unknown;
|
||||
remember?: unknown;
|
||||
autoExecute?: unknown;
|
||||
};
|
||||
// 严格校验 toolCallIds 数组
|
||||
if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) {
|
||||
log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array');
|
||||
return;
|
||||
}
|
||||
// 每个元素必须是字符串
|
||||
for (const id of req.toolCallIds) {
|
||||
if (typeof id !== 'string' || !id) {
|
||||
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid toolCallId in array');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof req.approved !== 'boolean') {
|
||||
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid approved');
|
||||
return;
|
||||
}
|
||||
const remember = typeof req.remember === 'boolean' ? req.remember : false;
|
||||
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
|
||||
const resolved = confirmationHook.resolveConfirmationsBatch(
|
||||
req.toolCallIds as string[],
|
||||
req.approved,
|
||||
remember,
|
||||
autoExecute,
|
||||
);
|
||||
log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
|
||||
});
|
||||
|
||||
// ===== v0.3.2: 拉取当前所有 pending 确认 =====
|
||||
ipcMain.handle('tool:getPendingConfirmations', async () => {
|
||||
return { success: true, data: confirmationHook.getPendingConfirmations() };
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 持久化自动执行设置 =====
|
||||
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
|
||||
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
|
||||
if (typeof toolName !== 'string' || !toolName) {
|
||||
return { success: false, error: 'Invalid toolName' };
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'Invalid enabled (must be boolean)' };
|
||||
}
|
||||
// 校验 toolName 在已注册工具列表中(与 tools:toggle 保持一致)
|
||||
const allTools = toolRegistry.listAllTools();
|
||||
if (!allTools.some((t) => t.name === toolName)) {
|
||||
log.warn(`[IPC] tool:setAutoExecute rejected: unknown tool "${toolName}"`);
|
||||
return { success: false, error: `Unknown tool: ${toolName}` };
|
||||
}
|
||||
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() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* IPC Workspace Handlers — 工作空间域(P2-9 从 handlers.ts 拆分)
|
||||
*
|
||||
* 工作空间校验、文件继承(SQLite backup API)、数据库完整性检查、信息查询。
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { join } from 'path';
|
||||
import type { IPCContext } from './context';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerWorkspaceHandlers(ctx: IPCContext): void {
|
||||
const { workspaceService } = ctx;
|
||||
|
||||
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
|
||||
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', 'MEMORY.md'],
|
||||
isNewWorkspace: true,
|
||||
dbExists: false,
|
||||
reason: '目录不存在,将在切换后自动创建',
|
||||
};
|
||||
}
|
||||
|
||||
// 校验 2: 是否为目录
|
||||
try {
|
||||
const stat = statSync(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return { valid: false, reason: '路径不是目录' };
|
||||
}
|
||||
} catch {
|
||||
return { valid: false, reason: '无法访问路径' };
|
||||
}
|
||||
|
||||
// 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md)
|
||||
const requiredFiles = ['SOUL.md', 'MEMORY.md'];
|
||||
const missingFiles: string[] = [];
|
||||
for (const f of requiredFiles) {
|
||||
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
|
||||
}
|
||||
|
||||
// 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项)
|
||||
const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db'));
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
path: resolvedPath,
|
||||
exists: true,
|
||||
missingFiles,
|
||||
isNewWorkspace: missingFiles.length === requiredFiles.length,
|
||||
dbExists,
|
||||
};
|
||||
});
|
||||
|
||||
// 从旧工作空间继承文件到新工作空间
|
||||
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 });
|
||||
}
|
||||
|
||||
// 继承白名单:键为前端传入的标识,值为实际相对路径分段
|
||||
// 白名单映射防止路径遍历攻击(不直接使用用户传入的路径拼接到 fs 调用)
|
||||
const INHERIT_WHITELIST: Record<string, string[]> = {
|
||||
'SOUL.md': ['SOUL.md'],
|
||||
'.metona/agent.db': ['.metona', 'agent.db'],
|
||||
};
|
||||
|
||||
const inherited: string[] = [];
|
||||
const failed: Array<{ file: string; error: string }> = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
const pathSegments = INHERIT_WHITELIST[fileName];
|
||||
// 不在白名单中:拒绝(防止路径遍历)
|
||||
if (!pathSegments) {
|
||||
failed.push({ file: fileName, error: '不在继承白名单中' });
|
||||
continue;
|
||||
}
|
||||
const srcFile = join(resolvedSource, ...pathSegments);
|
||||
const dstFile = join(resolvedTarget, ...pathSegments);
|
||||
try {
|
||||
// 确保目标文件的父目录存在(如 .metona/)
|
||||
const dstDir = join(resolvedTarget, ...pathSegments.slice(0, -1));
|
||||
if (!existsSync(dstDir)) {
|
||||
mkdirSync(dstDir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(srcFile)) {
|
||||
failed.push({ file: fileName, error: '源文件不存在' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// === 数据库文件特殊处理 ===
|
||||
// agent.db 启用了 WAL 模式,直接 copyFileSync 会丢失 WAL 中未 checkpoint 的事务;
|
||||
// 用 better-sqlite3 的 backup API(自带 checkpoint + 一致性快照)
|
||||
if (fileName === '.metona/agent.db') {
|
||||
try {
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
srcDb.backup(dstFile);
|
||||
inherited.push(fileName);
|
||||
log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource} → ${resolvedTarget}`);
|
||||
} finally {
|
||||
srcDb.close();
|
||||
}
|
||||
} catch (err) {
|
||||
// backup 失败时回退到 copyFileSync(至少保证基本可用)
|
||||
log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`);
|
||||
try {
|
||||
copyFileSync(srcFile, dstFile);
|
||||
inherited.push(fileName);
|
||||
log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource} → ${resolvedTarget}`);
|
||||
} catch (err2) {
|
||||
failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === 普通文件直接复制 ===
|
||||
copyFileSync(srcFile, dstFile);
|
||||
inherited.push(fileName);
|
||||
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource} → ${resolvedTarget}`);
|
||||
} catch (err) {
|
||||
failed.push({ file: fileName, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, inherited, failed };
|
||||
});
|
||||
|
||||
// #51 修复: 校验源数据库完整性,防止继承损坏的数据库导致新工作空间数据丢失
|
||||
ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => {
|
||||
if (!sourcePath) return { success: false, error: '参数无效' };
|
||||
const { existsSync } = await import('fs');
|
||||
const { resolve } = await import('path');
|
||||
const srcFile = join(resolve(sourcePath), '.metona', 'agent.db');
|
||||
if (!existsSync(srcFile)) {
|
||||
// 源数据库不存在不算损坏(可能是新工作空间尚未创建数据库),视为校验通过
|
||||
return { success: true, ok: true, detail: 'source database not exist' };
|
||||
}
|
||||
try {
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
const db = new Database(srcFile, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
|
||||
const ok = result.integrity_check === 'ok';
|
||||
if (!ok) {
|
||||
log.warn(`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`);
|
||||
}
|
||||
return { success: true, ok, detail: result.integrity_check };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`[WORKSPACE] Failed to check database integrity: ${(err as Error).message}`);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态
|
||||
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', 'MEMORY.md'] as const;
|
||||
const AUTO_DIRS = ['logs', '.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, MEMORY.md → files.memory
|
||||
const contentKey = name.toLowerCase().replace('.md', '') as 'soul' | 'memory';
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user