Files
metona-ai-desktop/electron/main.ts
T
thzxx 80cf5b482c
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m41s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 10m1s
fix: v0.6.1 修复回复/推理期间偶发崩溃 — 浏览器回退窗口竞态 + 崩溃可观测性
【根因(实证归因,非猜测)】
分析 userData/logs/main.log 全部 33 次启动会话,定位 3 处异常终止点
(07-25 ×2 / 08-22 ×1,启动标记前无 Database closed)。三处 100% 共享
同一模式:web_search 并行抓取 → 多个 web_fetch 同时进入浏览器回退 →
共享单例 BrowserWindowManager 中后到 open() 销毁前一个正在加载/执行
JS 的窗口。关键统计:56 次浏览器回退中 ERR_ABORTED(并发互毁的直接
证据)仅 3 次,而这 3 次恰好全部对应 3 个崩溃点;无并发销毁的 53 次
回退从未崩溃 —— 触发条件完全收敛。

缺陷链(三层叠加):
1. browserFetch 直接 open/evaluate 共享单例,无跨调用序列化 — 并发
   回退互相销毁窗口(ERR_ABORTED / "Object has been destroyed")
2. destroy() 对仍在使用中的 partition fire-and-forget
   clearStorageData/clearCache,与紧随其后的新窗口创建并发 —
   原生存储层竞态(崩溃引爆点)
3. ensureReady 检查与实际 executeJavaScript/loadURL 之间存在竞态窗口;
   loadURLWithTimeout 的 Race 落败方 rejection 无人处理

【修复(browser-window-manager.ts + web-fetch.ts + browser.ts)】
- 新增 fetchPageText:排队版页面抓取,串行化完整 open→等待→evaluate
  序列(与 open 共用单一操作链,destroy 只会在链上发生,跨链互毁彻底
  消除);web_fetch 浏览器回退改走此入口
- open() 拆分 openInternal(链内直调);open 与 fetchPageText 共用
  单一串行链,排队不分死锁
- destroy() 移除 session 存储清理(终态清理迁移至 close(),await 执行,
  不再与窗口创建并发)
- safeWebContents() 即时校验替代 racy 的 ensureReady;evaluate/extract/
  screenshot/click/type/scroll/waitForSelector 全部加固,消除对已销毁
  webContents 的调用
- loadURLWithTimeout 落败方 rejection 兜底(防 unhandledRejection)
- cleanupBrowser/cleanup/close 异步化适配(main.ts 退出链路 await)

【崩溃可观测性(此前崩溃无迹可查 — 日志无声截断)】
- process.on(uncaughtException/unhandledRejection) → [FATAL] 落盘
- app.on(render-process-gone/child-process-gone) → [FATAL] 落盘
- WindowManager: 每窗口 render-process-gone 日志 + 自动 reload 自愈
  (渲染进程 OOM/崩溃不再白屏卡死,可自动恢复)

【验证】
- lint 0/0;typecheck 双工程 0 错误;test:electron 252/252;build 通过
2026-08-22 19:16:41 +08:00

904 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetonaAI Desktop — Electron 主进程入口
*
* 启动流程(按架构规范强制顺序):
* 1. 初始化日志系统 + 加载 .env 环境变量(P0-4: 应用内配置优先,env 作为回退)
* 2. 选择/创建工作空间 → 校验必需文件(缺失自动创建)
* 3. 连接 SQLite → 执行 schema 迁移 → 加载配置
* 4. 初始化 Provider AdapterP2-10: AgentEngineManager 管理每会话独立引擎)
* 5. 加载磁盘文件 → 构建 System Prompt
* 6. 注册内置工具 + 连接 MCP Servers(P1-11: 等待连接完成再广播就绪)
* 7. 启动 React UI → Agent 就绪
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 启动流程
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
*/
import 'dotenv/config';
import { app, shell, Menu, BrowserWindow, dialog } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils';
import log from 'electron-log';
import { DatabaseService } from './services/database.service';
import { SessionService } from './services/session.service';
import { ConfigService } from './services/config.service';
import { GlobalConfigService } from './services/global-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 { SessionSummaryService } from './services/session-summary.service';
import { AgentEngineManager } from './services/agent-engine-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';
import type { ToolsReadyRef } from './ipc';
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 { OpenAIAdapter } from './harness/adapters/openai.adapter';
import { AnthropicAdapter } from './harness/adapters/anthropic.adapter';
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
import {
ReadFileTool,
WriteFileTool,
ListDirectoryTool,
SearchFilesTool,
WebSearchTool,
WebFetchTool,
MemoryStoreTool,
MemorySearchTool,
RunCommandTool,
WebBrowserTool,
cleanupBrowser,
DelegateTaskTool,
FileEditorTool,
CodeSearchTool,
TaskManagerTool,
DiffViewerTool,
GitStatusTool,
GitDiffTool,
GitLogTool,
GitCommitTool,
LintCodeTool,
RunTestsTool,
ProjectInfoTool,
HttpRequestTool,
ThinkTool,
ViewImageTool,
DeleteFileTool,
FileMoveTool,
FileInfoTool,
} from './harness/tools/built-in';
import {
AuditLogHook,
MemoryTriggerHook,
PermissionCheckHook,
RateLimitHook,
SecurityScanHook,
} from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
import { PolicyEngine } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox';
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
import { OutputValidator } from './harness/verification/output-validator';
import { TaskOrchestrator } from './harness/orchestration/orchestrator';
import { HealthChecker, SLOMonitor } from './utils/slo';
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
log.transports.file.level = 'info';
log.transports.console.level = 'debug';
// ===== 崩溃可观测性(此前崩溃无迹可查 — main.log 只会无声中断) =====
// 三个历史崩溃点(07-25 ×2 / 08-22 ×1)的日志均无声截断,无任何错误线索。
// 以下钩子确保任何致命异常/进程级崩溃都先落盘再终止,供事后归因。
process.on('uncaughtException', (err) => {
log.error('[FATAL] uncaughtException:', err?.stack ?? err);
});
process.on('unhandledRejection', (reason) => {
log.error('[FATAL] unhandledRejection:', reason instanceof Error ? reason.stack : reason);
});
app.on('render-process-gone', (_event, webContents, details) => {
log.error(
`[FATAL] render-process-gone: wcId=${webContents.id} reason=${details.reason} exitCode=${details.exitCode}`,
);
});
app.on('child-process-gone', (_event, details) => {
log.error(
`[FATAL] child-process-gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? 'n/a'}`,
);
});
// ===== 工作空间路径独立存储(解决 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;
/**
* P0-4: 环境变量回退映射(应用内配置优先,.env 值作为未配置时的默认)
* 与 .env.example 保持一致。
*/
const ENV_FALLBACK: Record<string, { apiKey?: string; baseURL?: string }> = {
deepseek: { apiKey: 'DEEPSEEK_API_KEY', baseURL: 'DEEPSEEK_BASE_URL' },
agnes: { apiKey: 'AGNES_API_KEY', baseURL: 'AGNES_BASE_URL' },
mimo: { apiKey: 'MIMO_API_KEY', baseURL: 'MIMO_BASE_URL' },
ollama: { baseURL: 'OLLAMA_BASE_URL' },
openai: { apiKey: 'OPENAI_API_KEY', baseURL: 'OPENAI_BASE_URL' },
anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseURL: 'ANTHROPIC_BASE_URL' },
};
/**
* F-7 修复: 已知 Provider 白名单
* 此前 createAdapter 的 switch default 静默落入 DeepSeekAdapter —— provider 配置
* 拼写错误(如 "deepsek")不报错,而是以空配置静默失败。显式校验后未知值
* 走"配置不完整"路径,用户可在设置中看到明确提示。
*/
const KNOWN_PROVIDERS = new Set(['deepseek', 'agnes', 'mimo', 'ollama', 'openai', 'anthropic']);
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();
// v0.3.17: 初始化全局配置层(跨工作空间共享 LLM/Agent/onboarding 等机器级配置)
const globalConfigService = new GlobalConfigService();
globalConfigService.initialize();
const sessionService = new SessionService(() => db);
const configService = new ConfigService(() => db);
// 注入全局配置层:读取时回退到全局 JSON,写入时双写
configService.setGlobalConfig(globalConfigService);
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{
key: string;
value: string;
}>;
const workspaceConfig: Record<string, unknown> = {};
for (const row of configRows) {
try {
workspaceConfig[row.key] = JSON.parse(row.value);
} catch {
workspaceConfig[row.key] = row.value;
}
}
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
if (migratedCount > 0) {
log.info(
`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`,
);
}
// ===== 日志服务 =====
const auditService = new AuditService(() => db);
const sessionRecorder = new SessionRecorder(workspaceInfo.path);
// ===== 记忆系统 =====
const memoryManager = new MemoryManager(() => db);
memoryManager.initialize();
// ===== 步骤 4: Provider Adapter 工厂(P2-10: 每引擎独立实例) =====
// P0-4: 配置缺失时回退读取 .env 环境变量(应用内配置优先)
const createAdapter = (): IMetonaProviderAdapter | null => {
const provider = configService.get<string>('llm.provider') ?? '';
const model = configService.get<string>('llm.model') ?? '';
const env = ENV_FALLBACK[provider] ?? {};
const apiKey =
configService.get<string>('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
const baseURL =
configService.get<string>('llm.baseURL') ||
(env.baseURL ? process.env[env.baseURL] : '') ||
'';
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding
if (!provider || !baseURL || !model) {
log.warn('LLM not fully configured. Please set provider, baseURL, and model in Settings.');
return null;
}
// F-7 修复: 未知 Provider 显式拒绝(此前 switch default 静默落入 DeepSeekAdapter
if (!KNOWN_PROVIDERS.has(provider)) {
log.warn(
`Unknown LLM provider "${provider}". Supported: ${[...KNOWN_PROVIDERS].join(', ')}.`,
);
return null;
}
if (!apiKey && provider !== 'ollama') {
log.warn(`API key is required for provider "${provider}". Please set it in Settings.`);
return null;
}
// 读取 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);
case 'openai':
return new OpenAIAdapter(adapterConfig);
case 'anthropic':
return new AnthropicAdapter(adapterConfig);
default:
return new DeepSeekAdapter(adapterConfig);
}
};
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见
const FALLBACK_ADAPTER = new DeepSeekAdapter({
provider: '',
baseURL: '',
apiKey: '',
defaultModel: '',
contextWindow: 4096,
});
// ===== 步骤 5: 工作空间文件 + System Prompt =====
const contextBuilder = new ContextBuilder();
// ===== F-8 接通: 此前为死配置的安全/日志开关真实消费 =====
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持默认开启
const promptInjectionEnabled =
configService.get<boolean>('security.promptInjectionDefense') !== false;
const auditEnabled = configService.get<boolean>('logging.auditEnabled') !== false;
const traceEnabled = configService.get<boolean>('logging.traceEnabled') !== false;
// SessionRecorder 录制总开关(false 时不写 TRACE JSONL 文件)
sessionRecorder.setEnabled(traceEnabled);
// ===== v0.2.0: 安全模块(必须在工具注册之前) =====
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 创建后再注入)
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());
toolRegistry.registerBuiltin(new DeleteFileTool());
toolRegistry.registerBuiltin(new FileMoveTool());
toolRegistry.registerBuiltin(new FileInfoTool());
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);
// P2v0.3.13: 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
toolRegistry.registerBuiltin(
new TaskManagerTool(
() => db,
(sessionId) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('task:changed', sessionId);
}
},
),
);
toolRegistry.registerBuiltin(new WebBrowserTool());
// v0.3.1: Git 工具集(4 个)+ 开发工具集(3 个)+ 独立工具(3 个)
toolRegistry.registerBuiltin(new GitStatusTool());
toolRegistry.registerBuiltin(new GitDiffTool());
toolRegistry.registerBuiltin(new GitLogTool());
toolRegistry.registerBuiltin(new GitCommitTool());
toolRegistry.registerBuiltin(new LintCodeTool());
toolRegistry.registerBuiltin(new RunTestsTool());
toolRegistry.registerBuiltin(new ProjectInfoTool());
toolRegistry.registerBuiltin(new HttpRequestTool());
toolRegistry.registerBuiltin(new ThinkTool());
toolRegistry.registerBuiltin(new ViewImageTool());
// ===== MCP Manager =====
const mcpManager = new MCPManager(() => db, toolRegistry);
// ===== HooksP0-2: SecurityScanHook 前置,对工具结果做间接注入防护) =====
// F-8 接通: SecurityScanHook / AuditLogHook 按 security.promptInjectionDefense
// 与 logging.auditEnabled 配置构建(此前两配置为死配置,hook 无条件挂载)
const preToolHooks = [
new PermissionCheckHook(policyEngine),
new RateLimitHook(20),
confirmationHook,
];
const postToolHooks = [
...(promptInjectionEnabled ? [new SecurityScanHook(promptDefender)] : []),
...(auditEnabled ? [new AuditLogHook(auditService)] : []),
new MemoryTriggerHook(memoryManager),
];
if (!promptInjectionEnabled) {
log.warn('[Security] promptInjectionDefense disabled by config — injection scanning off');
}
if (!auditEnabled) {
log.warn('[Logging] auditEnabled disabled by config — tool audit logging off');
}
// ===== P2-10: Agent Engine Manager(每会话独立引擎,替代全局单引擎) =====
const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER;
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
const agentEngineManager = new AgentEngineManager({
buildAdapter,
baseConfig: {
maxIterations: configService.get<number>('agent.maxIterations') ?? 20,
totalTimeoutMs: configService.get<number>('agent.totalTimeoutMs') ?? 600_000,
contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(),
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
thinkingEffort:
(configService.get<string>('agent.thinkingEffort') as
| 'low'
| 'medium'
| 'high'
| 'max'
| null) ?? 'high',
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
// F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488)
temperature: configService.get<number>('llm.temperature') ?? 0.0,
maxTokens: configService.get<number>('llm.maxTokens') ?? 63488,
},
toolRegistry,
preToolHooks,
postToolHooks,
});
agentEngineManager.setWorkspacePath(workspaceInfo.path);
// ===== P1: 故障转移 Provider(主 Provider 不可用时切换) =====
const buildFallbackAdapter = (): IMetonaProviderAdapter | null => {
const provider = configService.get<string>('llm.fallbackProvider');
const model = configService.get<string>('llm.fallbackModel');
if (!provider || !model) return null;
const env = ENV_FALLBACK[provider] ?? {};
const apiKey =
configService.get<string>('llm.fallbackApiKey') ||
(env.apiKey ? process.env[env.apiKey] : '') ||
'';
const baseURL =
configService.get<string>('llm.fallbackBaseURL') ||
(env.baseURL ? process.env[env.baseURL] : '') ||
'';
if (!baseURL) return null;
if (!apiKey && provider !== 'ollama') return null;
// F-7 修复: 故障转移 Provider 同样做白名单校验(未知值禁用故障转移而非静默降级)
if (!KNOWN_PROVIDERS.has(provider)) {
log.warn(
`[Fallback] Unknown provider "${provider}". Supported: ${[...KNOWN_PROVIDERS].join(', ')}. Failover disabled.`,
);
return null;
}
const contextWindow =
provider !== 'ollama'
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
: undefined;
const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) {
case 'agnes':
return new AgnesAdapter(cfg);
case 'mimo':
return new MimoAdapter(cfg);
case 'ollama':
return new OllamaAdapter(cfg);
case 'openai':
return new OpenAIAdapter(cfg);
case 'anthropic':
return new AnthropicAdapter(cfg);
default:
return new DeepSeekAdapter(cfg);
}
};
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
// ===== P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) =====
// v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
// v0.5.3: MCP 工具集合运行中变更(添加/断开/启停 server)→ 同步所有已存在引擎。
// 此前仅启动期 setToolsAll;运行中增删后已打开会话的引擎拿不到新工具
// (或持有失效定义,调用报 Unknown tool)。懒创建引擎由 createEngine
// 实时拉取(v0.5.2),此回调覆盖既有引擎。
mcpManager.setOnToolsChanged(() => {
agentEngineManager.setToolsAll(toolRegistry.listTools());
});
const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
mcpManager
.initialize()
.then(() => {
agentEngineManager.setToolsAll(toolRegistry.listTools());
log.info('[MCP] Tools registered and synced to all engines');
toolsReadyRef.ready = true;
toolsReadyRef.toolCount = toolRegistry.size;
// 广播工具就绪事件给所有窗口
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
})
.catch((err) => {
log.warn('MCP Manager initialization error:', err);
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
toolsReadyRef.ready = true;
toolsReadyRef.toolCount = toolRegistry.size;
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
});
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md =====
const memoryConsolidator = new MemoryConsolidator(buildAdapter(), workspaceService);
// v0.3.18: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉
memoryConsolidator.setMemoryManager(memoryManager);
// ===== P2-11: 会话摘要分层上下文服务 =====
const sessionSummaryService = new SessionSummaryService(
() => db,
sessionService,
() => buildAdapter(),
);
// ===== Task Orchestrator(子任务委派,P2-10: 适配 EngineProvider =====
const orchestrator = new TaskOrchestrator(
agentEngineManager,
toolRegistry,
preToolHooks,
postToolHooks,
{
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
thinkingEffort:
(configService.get<string>('agent.thinkingEffort') as
| 'low'
| 'medium'
| 'high'
| 'max'
| null) ?? 'high',
contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(),
},
);
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
// 重新设置工具列表,包含新注册的 delegate_task
agentEngineManager.setToolsAll(toolRegistry.listTools());
// 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
confirmationHook.setToolDefs(toolRegistry.listAllTools());
log.info(`Registered ${toolRegistry.size} built-in tools`);
// ===== 热重载 Adapter 回调(设置变更时触发) =====
let lastProvider = configService.get<string>('llm.provider') ?? '';
// v0.3.1 + P1: configSig 覆盖主/备 Provider 全部 LLM 字段
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') ?? '',
fallbackProvider: configService.get<string>('llm.fallbackProvider') ?? '',
fallbackModel: configService.get<string>('llm.fallbackModel') ?? '',
fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '',
fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '',
contextWindow:
provider === 'ollama'
? configService.get<number>('ollama.numCtx')
: configService.get<number>(`${provider}.contextWindow`),
});
};
let lastConfigSig = buildConfigSig();
const reloadAdapter = (): boolean => {
try {
const currentSig = buildConfigSig();
// 配置未变化 — 幂等返回成功,不重建 adapter,不发通知
if (currentSig === lastConfigSig) {
return true;
}
// 配置变化 — 校验新配置可构建 adapter
const probe = createAdapter();
if (!probe) {
log.warn(
'[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)',
);
const wins = BrowserWindow.getAllWindows();
for (const win of wins) {
win.webContents.send('toast:show', {
type: 'warning',
message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model',
});
}
return false;
}
// P2-10: 重建所有引擎的 adapter(工厂闭包读取最新配置)
agentEngineManager.refreshAdapters();
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
memoryConsolidator.setAdapter(agentEngineManager.getAdapter());
// Provider 切换时同步 contextLength 和 contextWindow
const provider = configService.get<string>('llm.provider') ?? '';
if (provider === 'ollama') {
const numCtx = configService.get<number>('ollama.numCtx');
agentEngineManager.updateConfigAll({
contextLength: numCtx ?? undefined,
contextWindow: agentEngineManager.getAdapter().getContextWindow(),
});
} else {
agentEngineManager.updateConfigAll({
contextLength: undefined,
contextWindow: agentEngineManager.getAdapter().getContextWindow(),
});
}
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
// 仅在 Provider 真正变化时通知渲染进程
if (lastProvider && lastProvider !== provider) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('agent:providerSwitched', {
from: lastProvider,
to: provider,
reason: 'config_changed',
sessionId: '',
});
win.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}`);
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('toast:show', {
type: 'error',
message: `Provider 切换失败: ${(err as Error).message}`,
});
}
return false;
}
};
// ===== 窗口管理 =====
windowManager = new WindowManager();
// 局部 const 捕获:闭包内 TS 无法对模块级可空变量做流收窄
const wm = windowManager;
// F-4 修复: 窗口创建收敛为单一入口 —— macOS activate / 托盘重建窗口此前不走
// beforeLoad,导致 confirmationHook 仍指向已销毁的旧窗口,所有需确认工具被
// "no main window available" 永久阻断。此闭包保证任何重建路径都完成
// setMainWindow + IPC 注册(registerAllIPCHandlers 有防重入标志,重复调用安全)。
const createMainWindow = (): BrowserWindow =>
wm.createWindow({
id: 'main',
workspacePath: workspaceInfo.path,
title: 'MetonaAI Desktop',
// P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口
beforeLoad: (win) => {
confirmationHook.setMainWindow(win);
registerAllIPCHandlers({
mainWindow: win,
sessionService,
configService,
workspaceService,
contextBuilder,
agentEngineManager,
toolRegistry,
auditService,
sessionRecorder,
memoryManager,
mcpManager,
reloadAdapter,
promptInjectionDefender: promptDefender,
outputValidator,
confirmationHook,
memoryConsolidator,
orchestrator,
sessionSummaryService,
toolsReadyRef,
});
},
});
const mainWindow = createMainWindow();
// ===== 系统托盘 =====
const resourcesPath = join(__dirname, '../../assets');
trayManager = new TrayManager(resourcesPath);
trayManager.initialize(mainWindow);
// ===== 全局快捷键 =====
windowManager.registerGlobalShortcuts();
// ===== Agent 状态同步到托盘(P2-10: 监听 manager 聚合事件) =====
agentEngineManager.on(
'stateChange',
(data: { previous?: string; current?: string; state?: string }) => {
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
INIT: 'idle',
THINKING: 'thinking',
PARSING: 'thinking',
EXECUTING: 'executing',
OBSERVING: 'thinking',
REFLECTING: 'thinking',
COMPRESSING: 'thinking',
TERMINATED: 'idle',
};
const stateValue = (data.current || data.state) ?? '';
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
},
);
// ===== Agent 完成时发送系统通知 =====
agentEngineManager.on('complete', (data: { sessionId: string; durationMs: number }) => {
trayManager?.sendNotification(
'MetonaAI — 任务完成',
`Agent 已完成任务 (${(data.durationMs / 1000).toFixed(1)}s)`,
() => windowManager?.focusWindow(),
);
});
// ===== P1-12: SLO 健康监控接入(原为死代码,现真实运行) =====
const healthChecker = new HealthChecker(
() => db,
join(workspaceInfo.path, '.metona', 'agent.db'),
);
const sloMonitor = new SLOMonitor();
agentEngineManager.on('complete', (data: { durationMs: number; terminationReason?: string }) => {
sloMonitor.recordRequest(data.durationMs, data.terminationReason === 'completed');
});
const healthTimer = setInterval(async () => {
try {
const report = await healthChecker.check();
if (!report.healthy) {
const failed = report.checks
.filter((c) => !c.healthy)
.map((c) => c.name)
.join(', ');
log.warn(`[Health] Unhealthy checks: ${failed}`);
trayManager?.setStatus('error');
} else {
// 恢复 idle(运行中状态会被后续 stateChange 覆盖)
trayManager?.setStatus('idle');
}
const slo = sloMonitor.getStatus();
if (slo.violated) {
log.warn(
`[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` +
`P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
);
}
} catch (err) {
log.warn('[Health] Check failed:', err);
}
}, 60_000);
healthTimer.unref?.();
// ===== 应用生命周期 =====
app.on('window-all-closed', () => {
// macOS: 保持应用运行(托盘模式)
});
app.on('activate', () => {
if (windowManager && windowManager.count === 0) {
// F-4 修复: 重建窗口走统一入口(beforeLoad 补 confirmationHook/IPC),
// 并把托盘引用重新绑定到新窗口(否则托盘点击指向已销毁窗口)
const win = createMainWindow();
trayManager?.rebindWindow(win);
} else {
windowManager?.focusWindow();
}
});
// M-13 修复: before-quit 回调改为同步 + event.preventDefault() 确保异步清理完成
app.on('before-quit', (event) => {
// 标记为正在退出,允许窗口关闭
(global as Record<string, unknown>).isQuitting = true;
TrayManager.markQuitting();
clearInterval(healthTimer);
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();
// v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
const shutdownTimeout = setTimeout(() => {
log.warn('[Shutdown] Timeout reached, forcing exit');
if (databaseService) {
databaseService.close();
databaseService = null;
}
app.exit(0);
}, 40_000);
(async () => {
try {
await mcpManager.shutdown();
} catch (err) {
log.error('[Shutdown] MCP shutdown failed:', err);
}
// 崩溃修复: cleanupBrowser 现为异步(close 含 session 存储终态清理)
try {
await cleanupBrowser();
} catch (err) {
log.error('[Shutdown] Browser cleanup failed:', err);
}
// v0.3.18 修复: 等待进行中的记忆固化任务完成,避免应用退出导致记忆丢失
if (memoryConsolidator.isRunning()) {
log.info('[Shutdown] Waiting for memory consolidation to complete...');
const completed = await memoryConsolidator.waitForCompletion(35_000);
if (!completed) {
log.warn('[Shutdown] Memory consolidation timed out, proceeding with exit');
} else {
log.info('[Shutdown] Memory consolidation completed');
}
}
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}]`,
);
}
// v0.5.0: 启动链路异常兜底 — DB 损坏/工作空间不可写等初始化失败时,
// 原实现会静默挂起(渲染进程白屏且无任何用户可见错误)。
// 兜底策略:记录日志 + 弹出系统错误对话框 + 退出(exit 1)
app
.whenReady()
.then(initialize)
.catch((err) => {
log.error('[Startup] Initialization failed:', err);
try {
dialog.showErrorBox(
'MetonaAI Desktop 启动失败',
`初始化过程中发生错误,应用即将退出。\n\n${(err as Error)?.message ?? String(err)}\n\n` +
'可能原因:工作空间目录不可写、数据库文件损坏。\n' +
'可尝试在设置中切换工作空间路径后重新启动。',
);
} catch {
// showErrorBox 失败(极端环境)时仅保留日志
}
app.exit(1);
});
app.on('web-contents-created', (_, contents) => {
// C-8 修复: 全局 web-contents 监听器也校验 URL 协议
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' };
});
});