Files
metona-ai-desktop/electron/main.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

1057 lines
42 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.8.0 P2-1: 回收站 30 天自动清理(启动 + 每 24h;软删除会话超期硬删除)
const TRASH_RETENTION_DAYS = 30;
const purgeTrash = (): void => {
try {
const purged = sessionService.purgeExpiredTrash(TRASH_RETENTION_DAYS);
if (purged > 0) {
log.info(
`[Trash] Purged ${purged} session(s) older than ${TRASH_RETENTION_DAYS} days from trash`,
);
}
} catch (err) {
log.warn('[Trash] Purge failed:', err);
}
};
purgeTrash();
const trashTimer = setInterval(purgeTrash, 24 * 60 * 60 * 1000);
trashTimer.unref?.();
// ===== 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);
// ===== v0.8.0 P2-3: electron-updater 自动更新(仅生产环境 + 已配置 feed) =====
// dev 模式 / 未配置 app.updateFeedUrl 时静默禁用;状态事件广播 update:status
if (!process.env['ELECTRON_RENDERER_URL']) {
const feedUrl = configService.get<string>('app.updateFeedUrl') ?? '';
if (feedUrl) {
const { startAutoUpdater } = await import('./services/update.service');
await startAutoUpdater(feedUrl, (event) => {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send('update:status', event);
}
if (event.status === 'available') {
trayManager?.sendNotification(
'MetonaAI 更新可用',
`新版本 ${event.latestVersion} 已发布,可在 设置 → 日志与数据 中下载安装`,
);
}
});
}
}
// ===== 全局快捷键 =====
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' };
});
});