Files
metona-ai-desktop/electron/main.ts
T
thzxx ebe45482b0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s
feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息
  前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 +
  cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token
  成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter
  同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点
  时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用
  + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo
  .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数,
  未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控
  [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键)

P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构
  resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup
  钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式
  退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接,
  替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用
  pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为
  可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin;
  ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent
  终结三处接线, 根治 rememberedDecisions 泄漏)

P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→
  Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs
  死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收
  管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP
  stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个
  run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页
  手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 +
  审计链一键校验)

P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用
  同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时
  刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机,
  teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页
  显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐
  docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/
  OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐)

测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context
  分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control
  四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向
  解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、
  trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机
  (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动
  (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试
  前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与
  并发去重的检查顺序竞态(去重先于幂等)

版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect)

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过
  (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
2026-08-30 09:44:43 +08:00

1019 lines
41 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, session } 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 { TitleGenerator } from './services/title-generator.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';
// v0.6.4 P4-5: session 级网络代理应用工具(default + agent-browser 分区)
// ===== 步骤 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;
// v0.6.4 P4-5: 应用启动即按配置/环境变量设置 session 级代理(变更在 shared.ts 侧联动)
void (async () => {
try {
const { applySessionProxy } = await import('./utils/network-proxy');
await applySessionProxy(configService.get<string>('network.proxyUrl') ?? null);
} catch (err) {
log.warn('[Network] Initial proxy application failed:', err);
}
})();
const auditEnabled = configService.get<boolean>('logging.auditEnabled') !== false;
const traceEnabled = configService.get<boolean>('logging.traceEnabled') !== false;
// SessionRecorder 录制总开关(false 时不写 TRACE JSONL 文件)
sessionRecorder.setEnabled(traceEnabled);
// v0.7.3 P3-3: 启动期清理旧 JSONL 录制文件(按 mtime 保留最近 200 个)
void sessionRecorder.pruneOldRecordings();
// ===== v0.2.0: 安全模块(必须在工具注册之前) =====
const policyEngine = new PolicyEngine();
const sandboxManager = new SandboxManager({
allowedPaths: [workspaceInfo.path],
});
const promptDefender = new PromptInjectionDefender();
const outputValidator = new OutputValidator();
// v0.2.0: ConfirmationHook(提前创建,mainWindow 创建后再注入)
// v0.6.4 P2-1: 注入 PolicyEngine —— 消费策略级 requireConfirmationmcp_* 通配
// 策略),修复"外部 MCP 工具全部免确认执行"的跨层防线不一致
const confirmationHook = new ConfirmationHook(null, configService);
confirmationHook.setPolicyEngine(policyEngine);
// ===== 步骤 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,
// v0.7.3 P3-1 接线: agent.enableReflection 此前为死配置(引擎读取但全链路
// 无置 true 路径)。现注入引擎基线配置,REFLECTING 状态由此开关真实驱动。
enableReflection: configService.get<boolean>('agent.enableReflection') === true,
// 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());
});
// v0.7.3 P4-2: MCP 自动重连开关(默认开启;配置变更经 shared.ts applyConfigSideEffects 联动)
mcpManager.setAutoReconnect(configService.get<boolean>('mcp.autoReconnect') !== false);
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(),
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection
enableReflection: configService.get<boolean>('agent.enableReflection') === true,
},
);
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;
}
};
// ===== P1-12 + v0.7.3 P3-4: SLO 健康监控接入(提前至窗口创建前,
// 使 IPC 侧 getHealthSnapshot 闭包可安全引用 sloMonitor/healthChecker =====
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');
});
/** 最近一次健康检查报告(供 IPC app:healthSnapshot 展示;初始 null = 尚未检查) */
let lastHealthReport: Awaited<ReturnType<HealthChecker['check']>> | null = null;
const healthTimer = setInterval(async () => {
try {
// v0.7.3 P3-1 接线: cleanupExpired 此前零调用方(且 expires_at 无写入方)双重死路。
// 现挂入健康检查周期,让过期记忆(episodic_memories.expires_at)真正可回收,
// 为后续记忆 TTL 语义预留真实管道;无过期条目时为零开销(一条带索引的 DELETE)。
const expired = memoryManager.cleanupExpired();
if (expired > 0) {
log.info(`[Health] Expired episodic memories purged: ${expired}`);
}
const report = await healthChecker.check();
lastHealthReport = report;
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?.();
// ===== v0.7.3 P4-1: 会话标题生成器(首轮 run 完成后 LLM 生成精炼标题) =====
const titleGenerator = new TitleGenerator(() => agentEngineManager.getAdapter(), sessionService);
// ===== 窗口管理 =====
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,
titleGenerator,
getHealthSnapshot: () => ({
slo: sloMonitor.getStatus(),
health: lastHealthReport,
generatedAt: Date.now(),
}),
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 完成时发送系统通知 =====
// v0.6.4 修复(B-6): 仅在任务真正完成时弹出系统通知 —— 引擎对 user_interrupt /
// timeout / dead_loop / error 等也走统一 finishRun 发出 complete 事件,
// 原实现让"用户主动停止"也会收到「Agent 已完成任务」的误导性通知。
agentEngineManager.on(
'complete',
(data: { sessionId: string; durationMs: number; terminationReason?: string }) => {
if (data.terminationReason !== undefined && data.terminationReason !== 'completed') {
return;
}
trayManager?.sendNotification(
'MetonaAI — 任务完成',
`Agent 已完成任务 (${(data.durationMs / 1000).toFixed(1)}s)`,
() => windowManager?.focusWindow(),
);
},
);
// ===== 应用生命周期 =====
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.7.2 A6: 单实例锁(根治双开) =====
// 此前无任何单实例约束 —— 双开应用会创建两个实例共用同一 agent.db(WAL 多进程
// 写存在 busy 冲突面),托盘/全局快捷键重复注册。现契约:
// - 抢锁失败的第二实例立即退出;
// - 抢锁成功的实例监听 second-instance,任何后续启动请求都聚焦已有主窗口
// (含从托盘隐藏状态唤起 —— focusWindow 内部处理 restore + show + focus)。
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
log.info('[SYS] Another MetonaAI Desktop instance is running — quitting this one');
app.quit();
} else {
app.on('second-instance', () => {
log.info('[SYS] Second instance launch requested — focusing existing window');
windowManager?.focusWindow();
});
// 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);
});
}
// ===== v0.6.4 安全加固:进程级权限防线(P2-3)=====
// 此前主窗口无 CSP、无 permission handler —— notifications/geo/media/clipboard
// 等请求全部走 Chromium 默认放行,且渲染层一旦被注入可静默触达敏感能力。
// 两条防线均为 deny-by-default 白名单制,任何一条失败都不放大攻击面。
{
// 防线一:权限请求白名单(deny-by-default
const ALLOWED_PERMISSIONS = new Set<string>(['clipboard-sanitized-write', 'fullscreen']);
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission));
});
session.defaultSession.setPermissionCheckHandler((_wc, permission) =>
ALLOWED_PERMISSIONS.has(permission),
);
// 防线二:生产环境 CSP 注入(仅 mainFrame,不触碰 dev server 的 HMR)。
// MUI/emotion 需要 style-src 'unsafe-inline'(运行时注入 <style> 标签与 style 属性);
// 附件预览/截图使用 data:/blob: 图片;渲染进程本身不直接外联(所有 fetch 在主进程)。
if (!process.env['ELECTRON_RENDERER_URL']) {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
if (details.resourceType === 'mainFrame') {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"frame-src 'none'",
"base-uri 'self'",
"form-action 'none'",
].join('; '),
],
},
});
} else {
callback({ responseHeaders: details.responseHeaders });
}
});
}
}
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' };
});
});