核心引擎修复: - CE-1: 上下文压缩摘要 role 从 system 改为 user,避免被 adapter 过滤 - CE-2: 工具失败时优先使用 error 字段(engine/openai-format/ollama 三处) - P0-1: DeadLoopError 终止时正确传 error 参数,前端可见 ERROR 事件 - MT-1: 新增 waitForAbort 方法,abortSession 等待 run 结束再返回 - MT-2: TERMINATED 状态到达时标记步骤完成,避免 Trace Viewer 转圈 - MT-3: 压缩边界检测孤立 tool 消息,避免 API 400 错误 - isRetryableError 与 catch 分支统一 toLowerCase IPC 与主进程修复: - P0-2: createAdapter 配置缺失返回 null,FALLBACK_ADAPTER 兜底 - P0-3: reloadAdapter 失败返回 success:false 通知前端 - P1-5: 校验失败发 ERROR+DONE 流事件,防止 isStreaming 卡死 - P1-6: configLoaded 标志,配置加载前禁用发送按钮 - P1-7: MCP initialize 移到 agentLoop 后,完成后同步工具 - P2-11: beforeLoad 在 loadURL 前注册 IPC handler - P2-12: provider 切换竞态保护 前端状态同步修复: - clearSessions 后同步清空前端会话与消息状态 - clearMemories 通过 memoryVersion 触发 MemoryViewer 重新加载 - ContextMenu 4 个 session 操作补全 IPC 调用与 try/catch - useConfig 配置保存失败回滚 UI 并提示 - handleToggle 工具切换失败回滚单个工具状态 错误处理全量补全: - 所有 await window.metona 调用补全 try/catch 与 toast 反馈 - MCP addServer/toggleServer/removeServer 检查返回值 - showItemInFolder 检查返回值(handleOpen/handleOpenInFolder) - sse-stream/ollama NDJSON 解析失败改为 log.warn - adapter throwHttpError 带 status 属性供 isRetryableError 判断
570 lines
23 KiB
TypeScript
570 lines
23 KiB
TypeScript
/**
|
||
* MetonaAI Desktop — Electron 主进程入口
|
||
*
|
||
* 启动流程(按架构规范强制顺序):
|
||
* 1. 初始化日志系统
|
||
* 2. 选择/创建工作空间 → 校验必需文件(缺失自动创建)
|
||
* 3. 连接 SQLite → 执行 schema 迁移 → 加载配置
|
||
* 4. 初始化 Provider Adapter
|
||
* 5. 加载 4 个磁盘文件 → 构建 System Prompt
|
||
* 6. 注册内置工具 + 连接 MCP Servers
|
||
* 7. 启动 React UI → Agent 就绪
|
||
*
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 启动流程
|
||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||
*/
|
||
|
||
import { app, shell, Menu } from 'electron';
|
||
import { join } from 'path';
|
||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||
import log from 'electron-log';
|
||
import { DatabaseService } from './services/database.service';
|
||
import { SessionService } from './services/session.service';
|
||
import { ConfigService } from './services/config.service';
|
||
import { WorkspaceService } from './services/workspace.service';
|
||
import { AuditService } from './services/audit.service';
|
||
import { SessionRecorder } from './services/session-recorder.service';
|
||
import { TrayManager } from './services/tray-manager.service';
|
||
import { WindowManager } from './services/window-manager.service';
|
||
import { MCPManager } from './services/mcp-manager.service';
|
||
import { ContextBuilder } from './harness/prompts/context-builder';
|
||
import { MemoryManager } from './harness/memory/manager';
|
||
import { MemoryConsolidator } from './harness/memory/consolidator';
|
||
import { registerAllIPCHandlers } from './ipc/handlers';
|
||
import { AgentLoopEngine } from './harness/agent-loop';
|
||
import { ToolRegistry } from './harness/tools/registry';
|
||
import { DeepSeekAdapter } from './harness/adapters/deepseek.adapter';
|
||
import { AgnesAdapter } from './harness/adapters/agnes-ai.adapter';
|
||
import { MimoAdapter } from './harness/adapters/mimo.adapter';
|
||
import { OllamaAdapter } from './harness/adapters/ollama.adapter';
|
||
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
|
||
import {
|
||
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
||
WebSearchTool, WebFetchTool,
|
||
MemoryStoreTool, MemorySearchTool,
|
||
RunCommandTool,
|
||
WebBrowserTool, cleanupBrowser,
|
||
DelegateTaskTool,
|
||
// v0.2.0 新增工具
|
||
FileEditorTool,
|
||
CodeSearchTool,
|
||
TaskManagerTool,
|
||
DiffViewerTool,
|
||
// v0.3.1 新增工具(11 个)
|
||
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
|
||
LintCodeTool, RunTestsTool, ProjectInfoTool,
|
||
HttpRequestTool,
|
||
TodoWriteTool,
|
||
ThinkTool,
|
||
ViewImageTool,
|
||
// v0.3.2 新增工具(1 个)
|
||
DeleteFileTool,
|
||
} from './harness/tools/built-in';
|
||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
||
import { PolicyEngine } from './harness/sandbox/permissions';
|
||
import { SandboxManager } from './harness/sandbox/sandbox';
|
||
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
||
import { OutputValidator } from './harness/verification/output-validator';
|
||
import { TaskOrchestrator } from './harness/orchestration/orchestrator';
|
||
import { UpdateService } from './services/update.service';
|
||
|
||
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
|
||
log.transports.file.level = 'info';
|
||
log.transports.console.level = 'debug';
|
||
|
||
// ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)=====
|
||
const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json');
|
||
|
||
function readWorkspacePathFromFile(): string | null {
|
||
try {
|
||
if (existsSync(WORKSPACE_CONFIG_FILE)) {
|
||
const data = JSON.parse(readFileSync(WORKSPACE_CONFIG_FILE, 'utf-8'));
|
||
return data.workspacePath ?? null;
|
||
}
|
||
} catch {
|
||
// 忽略读取错误
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function writeWorkspacePathToFile(workspacePath: string): void {
|
||
try {
|
||
writeFileSync(WORKSPACE_CONFIG_FILE, JSON.stringify({ workspacePath }, null, 2), 'utf-8');
|
||
} catch (err) {
|
||
log.error('Failed to write workspace config file:', err);
|
||
}
|
||
}
|
||
|
||
export { readWorkspacePathFromFile, writeWorkspacePathToFile };
|
||
|
||
let databaseService: DatabaseService | null = null;
|
||
let trayManager: TrayManager | null = null;
|
||
let windowManager: WindowManager | null = null;
|
||
|
||
async function initialize(): Promise<void> {
|
||
log.info('MetonaAI Desktop starting...');
|
||
electronApp.setAppUserModelId('com.metona.ai-desktop');
|
||
|
||
// 移除原生菜单栏
|
||
Menu.setApplicationMenu(null);
|
||
|
||
app.on('browser-window-created', (_, window) => {
|
||
optimizer.watchWindowShortcuts(window);
|
||
});
|
||
|
||
// ===== 步骤 2: 工作空间(优先从独立配置文件读取路径)=====
|
||
const savedWorkspacePath = readWorkspacePathFromFile();
|
||
const workspaceService = new WorkspaceService(savedWorkspacePath ?? undefined);
|
||
const workspaceInfo = workspaceService.initialize();
|
||
// 持久化工作空间路径(供下次启动读取)
|
||
if (savedWorkspacePath !== workspaceInfo.path) {
|
||
writeWorkspacePathToFile(workspaceInfo.path);
|
||
}
|
||
log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`);
|
||
|
||
// ===== 步骤 3: SQLite =====
|
||
databaseService = new DatabaseService(workspaceInfo.path);
|
||
databaseService.initialize();
|
||
const db = databaseService.getDB();
|
||
|
||
const sessionService = new SessionService(() => db);
|
||
const configService = new ConfigService(() => db);
|
||
|
||
// ===== 日志服务 =====
|
||
const auditService = new AuditService(() => db);
|
||
const sessionRecorder = new SessionRecorder(workspaceInfo.path);
|
||
|
||
// ===== 记忆系统 =====
|
||
const memoryManager = new MemoryManager(() => db);
|
||
memoryManager.initialize();
|
||
|
||
// ===== 步骤 4: Provider Adapter 工厂 =====
|
||
// P0-2 修复: 配置缺失时返回 null 而非抛异常,让应用能启动到 Onboarding
|
||
const createAdapter = (): IMetonaProviderAdapter | null => {
|
||
const provider = configService.get<string>('llm.provider') ?? '';
|
||
const model = configService.get<string>('llm.model') ?? '';
|
||
const apiKey = configService.get<string>('llm.apiKey') ?? '';
|
||
const baseURL = configService.get<string>('llm.baseURL') ?? '';
|
||
|
||
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding)
|
||
if (!provider || !baseURL || !model) {
|
||
log.warn('LLM not fully configured. Please set provider, baseURL, and model in Settings.');
|
||
return null;
|
||
}
|
||
|
||
if (!apiKey && provider !== 'ollama') {
|
||
log.warn(`API key is required for provider "${provider}". Please set it in Settings.`);
|
||
return null;
|
||
}
|
||
|
||
// v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
|
||
const contextWindow = provider !== 'ollama'
|
||
? configService.get<number>(`${provider}.contextWindow`) ?? undefined
|
||
: undefined;
|
||
|
||
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
|
||
switch (provider) {
|
||
case 'agnes': return new AgnesAdapter(adapterConfig);
|
||
case 'mimo': return new MimoAdapter(adapterConfig);
|
||
case 'ollama': return new OllamaAdapter(adapterConfig);
|
||
default: return new DeepSeekAdapter(adapterConfig);
|
||
}
|
||
};
|
||
|
||
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但 P0-1 修复后前端可见
|
||
const FALLBACK_ADAPTER = new DeepSeekAdapter({
|
||
provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096,
|
||
});
|
||
|
||
const adapter = createAdapter() ?? FALLBACK_ADAPTER;
|
||
|
||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||
const contextBuilder = new ContextBuilder();
|
||
|
||
// ===== v0.2.0: 安全模块(必须在工具注册之前,便于 RunCommandTool 注入 SandboxManager)=====
|
||
const policyEngine = new PolicyEngine();
|
||
const sandboxManager = new SandboxManager({
|
||
allowedPaths: [workspaceInfo.path],
|
||
networkPolicy: 'allowlist',
|
||
});
|
||
const promptDefender = new PromptInjectionDefender();
|
||
const outputValidator = new OutputValidator();
|
||
|
||
// v0.2.0: ConfirmationHook(提前创建,mainWindow 创建后再注入)
|
||
// 注入 ConfigService 以支持持久化自动执行设置
|
||
const confirmationHook = new ConfirmationHook(null, configService);
|
||
|
||
// ===== 步骤 6: 注册内置工具 =====
|
||
const toolRegistry = new ToolRegistry();
|
||
toolRegistry.registerBuiltin(new ReadFileTool());
|
||
toolRegistry.registerBuiltin(new WriteFileTool());
|
||
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
||
toolRegistry.registerBuiltin(new SearchFilesTool());
|
||
|
||
// v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation)
|
||
toolRegistry.registerBuiltin(new DeleteFileTool());
|
||
|
||
// v0.2.0: 新增文件工具
|
||
toolRegistry.registerBuiltin(new FileEditorTool());
|
||
toolRegistry.registerBuiltin(new CodeSearchTool());
|
||
toolRegistry.registerBuiltin(new DiffViewerTool());
|
||
|
||
// WebFetchTool 先于 WebSearchTool 构造,注入为依赖
|
||
const webFetchTool = new WebFetchTool();
|
||
toolRegistry.registerBuiltin(webFetchTool);
|
||
toolRegistry.registerBuiltin(new WebSearchTool(configService, webFetchTool));
|
||
|
||
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
||
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
|
||
|
||
// v0.2.0: RunCommandTool 注入 SandboxManager
|
||
const runCommandTool = new RunCommandTool();
|
||
runCommandTool.setSandboxManager(sandboxManager);
|
||
toolRegistry.registerBuiltin(runCommandTool);
|
||
|
||
// v0.2.0: 任务管理工具
|
||
toolRegistry.registerBuiltin(new TaskManagerTool(() => db));
|
||
|
||
// 注册 Web Browser 统一浏览器工具
|
||
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||
|
||
// v0.3.1: Git 工具集(4 个)
|
||
toolRegistry.registerBuiltin(new GitStatusTool());
|
||
toolRegistry.registerBuiltin(new GitDiffTool());
|
||
toolRegistry.registerBuiltin(new GitLogTool());
|
||
toolRegistry.registerBuiltin(new GitCommitTool());
|
||
|
||
// v0.3.1: 开发工具集(3 个)
|
||
toolRegistry.registerBuiltin(new LintCodeTool());
|
||
toolRegistry.registerBuiltin(new RunTestsTool());
|
||
toolRegistry.registerBuiltin(new ProjectInfoTool());
|
||
|
||
// v0.3.1: 独立工具(4 个)
|
||
toolRegistry.registerBuiltin(new HttpRequestTool());
|
||
toolRegistry.registerBuiltin(new TodoWriteTool());
|
||
toolRegistry.registerBuiltin(new ThinkTool());
|
||
toolRegistry.registerBuiltin(new ViewImageTool());
|
||
|
||
// v0.3.1 修复 WARN-5: DelegateTaskTool 注册后再输出总数(此时才是完整的 26 个)
|
||
// log.info 移至 DelegateTaskTool 注册后
|
||
|
||
// ===== MCP Manager =====
|
||
const mcpManager = new MCPManager(() => db, toolRegistry);
|
||
// P1-7 修复: initialize() 移到 agentLoop 创建之后,完成后重新同步工具
|
||
|
||
// ===== Hooks =====
|
||
// v0.2.0: ConfirmationHook 注入到 preToolHooks 管道
|
||
// 注意:setToolDefs 延迟到 DelegateTaskTool 注册后调用,确保包含所有工具的风险等级
|
||
const preToolHooks = [
|
||
new PermissionCheckHook(policyEngine),
|
||
new RateLimitHook(20),
|
||
confirmationHook,
|
||
];
|
||
const postToolHooks = [
|
||
new AuditLogHook(auditService),
|
||
new MemoryTriggerHook(memoryManager),
|
||
];
|
||
|
||
// ===== Agent Loop =====
|
||
// Agent 配置初始化:仅此处一次性读取,配置变更时由 handlers.ts 的 config:set
|
||
// 监听器调用 agentLoop.updateConfig() 和 orchestrator.updateDefaultConfig() 即时生效。
|
||
// @see electron/ipc/handlers.ts — 'config:set' handler
|
||
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
|
||
const agentMaxIter = configService.get<number>('agent.maxIterations');
|
||
const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
|
||
const agentThinkingEnabled = configService.get<boolean>('agent.enableThinking');
|
||
const agentThinkingEffort = configService.get<string>('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null;
|
||
const toolExecTimeout = configService.get<number>('agent.toolExecutionTimeoutMs');
|
||
const agentLoop = new AgentLoopEngine(
|
||
{
|
||
maxIterations: agentMaxIter ?? 20,
|
||
totalTimeoutMs: agentTimeout ?? 600_000,
|
||
contextLength: ollamaNumCtx ?? undefined,
|
||
// v0.3.1: 从 adapter.getContextWindow() 读取,修复 Engine 128K vs Adapter 1M 不一致 bug
|
||
contextWindow: adapter.getContextWindow(),
|
||
thinkingEnabled: agentThinkingEnabled ?? true,
|
||
thinkingEffort: agentThinkingEffort ?? 'high',
|
||
toolExecutionTimeoutMs: toolExecTimeout ?? 120_000,
|
||
},
|
||
adapter, toolRegistry, preToolHooks, postToolHooks,
|
||
);
|
||
agentLoop.setTools(toolRegistry.listTools());
|
||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||
|
||
// P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop
|
||
mcpManager.initialize().then(() => {
|
||
agentLoop.setTools(toolRegistry.listTools());
|
||
log.info('[MCP] Tools registered and synced to AgentLoop');
|
||
}).catch((err) => {
|
||
log.warn('MCP Manager initialization error:', err);
|
||
});
|
||
|
||
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
||
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
||
|
||
// ===== Task Orchestrator(子任务委派)=====
|
||
const orchestrator = new TaskOrchestrator(
|
||
agentLoop, toolRegistry, preToolHooks, postToolHooks,
|
||
{
|
||
thinkingEnabled: agentThinkingEnabled ?? true,
|
||
thinkingEffort: agentThinkingEffort ?? 'high',
|
||
contextLength: ollamaNumCtx ?? undefined,
|
||
// v0.3.1: 同步 contextWindow 到 SubAgent 配置
|
||
contextWindow: adapter.getContextWindow(),
|
||
},
|
||
);
|
||
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
||
// 重新设置工具列表,包含新注册的 delegate_task
|
||
agentLoop.setTools(toolRegistry.listTools());
|
||
// v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
|
||
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
||
// v0.3.1: 所有工具(含 DelegateTaskTool)注册完成后输出总数,确保日志显示 26 而非 25
|
||
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
||
|
||
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
||
// 记录上次创建 adapter 时的配置快照,用于检测配置是否真的变化
|
||
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
||
// v0.3.1: configSig 加入 contextWindow 配置,使 contextWindow 变化也能触发热重载
|
||
const buildConfigSig = () => {
|
||
const provider = configService.get<string>('llm.provider') ?? '';
|
||
return JSON.stringify({
|
||
provider,
|
||
model: configService.get<string>('llm.model') ?? '',
|
||
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
||
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
||
// v0.3.1: 加入 contextWindow 配置(Ollama 用 numCtx,其他 Provider 用 ${provider}.contextWindow)
|
||
contextWindow: provider === 'ollama'
|
||
? configService.get<number>('ollama.numCtx')
|
||
: configService.get<number>(`${provider}.contextWindow`),
|
||
});
|
||
};
|
||
let lastConfigSig = buildConfigSig();
|
||
const reloadAdapter = (): boolean => {
|
||
try {
|
||
// 检测配置是否真的变化(避免每次发消息都重建 adapter 和弹 toast)
|
||
const currentConfig = {
|
||
provider: configService.get<string>('llm.provider') ?? '',
|
||
model: configService.get<string>('llm.model') ?? '',
|
||
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
||
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
||
};
|
||
const currentSig = buildConfigSig();
|
||
|
||
// 配置未变化 — 幂等返回成功,不重建 adapter,不发通知
|
||
if (currentSig === lastConfigSig) {
|
||
return true;
|
||
}
|
||
|
||
// 配置变化 — 重建 adapter
|
||
const newAdapter = createAdapter();
|
||
if (!newAdapter) {
|
||
log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)');
|
||
// 不更新 lastConfigSig,下次还会尝试
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('toast:show', {
|
||
type: 'warning',
|
||
message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model',
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
agentLoop.setAdapter(newAdapter);
|
||
memoryConsolidator.setAdapter(newAdapter);
|
||
// Provider 切换时同步 contextLength 和 contextWindow
|
||
const provider = currentConfig.provider;
|
||
if (provider === 'ollama') {
|
||
const numCtx = configService.get<number>('ollama.numCtx');
|
||
agentLoop.updateConfig({
|
||
contextLength: numCtx ?? undefined,
|
||
// v0.3.1: 同步 contextWindow(Ollama 的 getContextWindow 返回固定 4096)
|
||
contextWindow: newAdapter.getContextWindow(),
|
||
});
|
||
} else {
|
||
// v0.3.1: DeepSeek/Agnes 无 contextLength,但同步 contextWindow
|
||
agentLoop.updateConfig({
|
||
contextLength: undefined,
|
||
contextWindow: newAdapter.getContextWindow(),
|
||
});
|
||
}
|
||
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
||
// 仅在 Provider 真正变化时通知渲染进程
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
if (lastProvider && lastProvider !== provider) {
|
||
mainWindow.webContents.send('agent:providerSwitched', {
|
||
from: lastProvider,
|
||
to: provider,
|
||
reason: 'config_changed',
|
||
sessionId: '',
|
||
});
|
||
mainWindow.webContents.send('toast:show', {
|
||
type: 'success',
|
||
message: `Provider 已切换: ${lastProvider} → ${provider}`,
|
||
});
|
||
}
|
||
}
|
||
lastProvider = provider;
|
||
lastConfigSig = currentSig;
|
||
return true;
|
||
} catch (err) {
|
||
log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`);
|
||
// 通知渲染进程 Provider 切换失败(UI 显示错误 Toast)
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('toast:show', {
|
||
type: 'error',
|
||
message: `Provider 切换失败: ${(err as Error).message}`,
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// ===== 窗口管理 =====
|
||
windowManager = new WindowManager();
|
||
const mainWindow = windowManager.createWindow({
|
||
id: 'main',
|
||
workspacePath: workspaceInfo.path,
|
||
title: 'MetonaAI Desktop',
|
||
// P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口
|
||
beforeLoad: (win) => {
|
||
confirmationHook.setMainWindow(win);
|
||
registerAllIPCHandlers(
|
||
win, sessionService, configService, workspaceService,
|
||
contextBuilder, agentLoop, toolRegistry, auditService,
|
||
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
|
||
promptDefender, outputValidator,
|
||
confirmationHook, memoryConsolidator, orchestrator,
|
||
);
|
||
},
|
||
});
|
||
|
||
// P2-11 修复: ConfirmationHook.setMainWindow 已在 beforeLoad 中调用,此处无需重复
|
||
// (beforeLoad 在 loadURL 之前执行,确保 IPC handler 注册时 mainWindow 已注入)
|
||
|
||
// ===== 系统托盘 =====
|
||
const resourcesPath = join(__dirname, '../../assets');
|
||
trayManager = new TrayManager(resourcesPath);
|
||
trayManager.initialize(mainWindow);
|
||
|
||
// ===== 全局快捷键 =====
|
||
windowManager.registerGlobalShortcuts();
|
||
|
||
// ===== Agent 状态同步到托盘 =====
|
||
agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => {
|
||
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
||
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
|
||
EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking',
|
||
COMPRESSING: 'thinking', TERMINATED: 'idle',
|
||
};
|
||
const stateValue = (data.current || data.state) ?? '';
|
||
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
|
||
});
|
||
|
||
// ===== Agent 完成时发送系统通知 =====
|
||
agentLoop.on('complete', (data: { sessionId: string; durationMs: number }) => {
|
||
trayManager?.sendNotification(
|
||
'MetonaAI — 任务完成',
|
||
`Agent 已完成任务 (${(data.durationMs / 1000).toFixed(1)}s)`,
|
||
() => windowManager?.focusWindow(),
|
||
);
|
||
});
|
||
|
||
// TODO: Initialize UpdateService for auto-update functionality
|
||
// const updateService = new UpdateService();
|
||
// updateService.initialize(mainWindow);
|
||
|
||
// ===== 应用生命周期 =====
|
||
app.on('window-all-closed', () => {
|
||
// macOS: 保持应用运行(托盘模式)
|
||
});
|
||
|
||
app.on('activate', () => {
|
||
if (windowManager && windowManager.count === 0) {
|
||
windowManager.createWindow({ id: 'main', workspacePath: workspaceInfo.path });
|
||
} else {
|
||
windowManager?.focusWindow();
|
||
}
|
||
});
|
||
|
||
// M-13 修复: before-quit 回调改为同步 + event.preventDefault() 确保异步清理完成
|
||
// 之前 async 回调 Electron 不会 await,导致 MCP shutdown 未完成时应用已退出
|
||
app.on('before-quit', (event) => {
|
||
// 标记为正在退出,允许窗口关闭(两处 isQuitting 统一设置)
|
||
(global as Record<string, unknown>).isQuitting = true;
|
||
TrayManager.markQuitting();
|
||
windowManager?.unregisterGlobalShortcuts();
|
||
trayManager?.destroy();
|
||
windowManager?.closeAll();
|
||
|
||
// 防止重复触发(macOS before-quit 可能触发多次)
|
||
if ((global as Record<string, unknown>).shutdownInProgress === true) {
|
||
return;
|
||
}
|
||
(global as Record<string, unknown>).shutdownInProgress = true;
|
||
|
||
event.preventDefault();
|
||
// 内部异步清理,加 5 秒超时保护防止卡死
|
||
const shutdownTimeout = setTimeout(() => {
|
||
log.warn('[Shutdown] Timeout reached, forcing exit');
|
||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||
app.exit(0);
|
||
}, 5_000);
|
||
|
||
(async () => {
|
||
try {
|
||
await mcpManager.shutdown();
|
||
} catch (err) {
|
||
log.error('[Shutdown] MCP shutdown failed:', err);
|
||
}
|
||
cleanupBrowser();
|
||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||
clearTimeout(shutdownTimeout);
|
||
app.exit(0);
|
||
})().catch((err) => {
|
||
log.error('[Shutdown] Async cleanup failed:', err);
|
||
clearTimeout(shutdownTimeout);
|
||
if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; }
|
||
app.exit(1);
|
||
});
|
||
});
|
||
|
||
// ===== 应用日志级别配置 =====
|
||
const logLevel = configService.get<string>('logging.level') as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null;
|
||
if (logLevel) {
|
||
log.transports.file.level = logLevel;
|
||
log.transports.console.level = logLevel;
|
||
log.info(`Log level applied: ${logLevel}`);
|
||
}
|
||
|
||
// ===== 恢复工具启用/禁用状态 =====
|
||
for (const toolDef of toolRegistry.listAllTools()) {
|
||
const stored = configService.get<boolean>(`tools.${toolDef.name}.enabled`);
|
||
if (stored === false) {
|
||
toolRegistry.setToolEnabled(toolDef.name, false);
|
||
log.info(`Tool restored as disabled: ${toolDef.name}`);
|
||
}
|
||
}
|
||
|
||
log.info(`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`);
|
||
}
|
||
|
||
app.whenReady().then(initialize);
|
||
|
||
app.on('web-contents-created', (_, contents) => {
|
||
// C-8 修复: 全局 web-contents 监听器也校验 URL 协议
|
||
contents.setWindowOpenHandler(({ url }) => {
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||
shell.openExternal(url);
|
||
} else {
|
||
log.warn(`[Main] Blocked window.open with unsafe protocol: ${parsed.protocol}`);
|
||
}
|
||
} catch {
|
||
log.warn(`[Main] Blocked window.open with invalid URL: ${url.slice(0, 100)}`);
|
||
}
|
||
return { action: 'deny' };
|
||
});
|
||
});
|