硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
1141 lines
47 KiB
TypeScript
1141 lines
47 KiB
TypeScript
/**
|
||
* MetonaAI Desktop — Electron 主进程入口
|
||
*
|
||
* 启动流程(按架构规范强制顺序):
|
||
* 1. 初始化日志系统 + 加载 .env 环境变量(P0-4: 应用内配置优先,env 作为回退)
|
||
* 2. 选择/创建工作空间 → 校验必需文件(缺失自动创建)
|
||
* 3. 连接 SQLite → 执行 schema 迁移 → 加载配置
|
||
* 4. 初始化 Provider Adapter(P2-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 { MemoryMaintainer } from './harness/memory/maintainer';
|
||
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, parseToolPolicy } 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.8.1 P0-4: 主进程 toast/通知文案双语(ui.locale 驱动)
|
||
import { setMainLocale, mt } from './utils/main-locale';
|
||
// 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'}`,
|
||
);
|
||
});
|
||
|
||
// ===== E2E 测试引导(v0.8.1 P2-5)=====
|
||
// METONA_USER_DATA_DIR:重定向 userData(workspace-config.json / 全局配置层),
|
||
// 使 Playwright 冒烟测试与真实用户数据完全隔离;
|
||
// METONA_E2E_SEED_CONFIG:跳过引导向导并写入一组确定性的合法 LLM 配置
|
||
//(Provider/Model/输出上限/上下文长度 —— 全部为设置面板合法配置项),
|
||
// 指向 e2e/mock-llm.ts 启动的本地 OpenAI 兼容服务。仅在显式设置时生效。
|
||
if (process.env['METONA_USER_DATA_DIR']) {
|
||
app.setPath('userData', process.env['METONA_USER_DATA_DIR']);
|
||
}
|
||
|
||
// ===== 工作空间路径独立存储(解决 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.8.1 P0-4: 主进程语言随 ui.locale 注入(变更经 applyConfigSideEffects 热切换)
|
||
setMainLocale(configService.get<string>('ui.locale'));
|
||
|
||
// v0.8.1 P2-5: E2E 引导种子(见文件顶部说明;仅 METONA_E2E_SEED_CONFIG 时生效)
|
||
if (process.env['METONA_E2E_SEED_CONFIG'] === '1') {
|
||
configService.set('onboarding.completed', true);
|
||
configService.set('llm.provider', 'deepseek');
|
||
configService.set('llm.model', 'deepseek-v4-flash');
|
||
configService.set('llm.baseURL', process.env['METONA_E2E_LLM_URL'] ?? 'http://127.0.0.1:0');
|
||
configService.set('llm.apiKey', 'e2e-test-key');
|
||
configService.set('llm.maxTokens', 2048);
|
||
configService.set('llm.contextWindow', 32768);
|
||
log.info('[E2E] Seeded deterministic LLM config for smoke test');
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// v0.8.1 硬性契约: 上下文窗口唯一合法来源是设置面板「上下文长度」
|
||
// (llm.contextWindow,全局单一配置,适用于一切 Provider/模型)。
|
||
// 删除了旧的 deepseek/agnes/mimo/openai/anthropic.contextWindow 分 Provider 键。
|
||
const contextWindow = configService.get<number>('llm.contextWindow') ?? 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 —— send/sendStream 会报错但前端可见。
|
||
// v0.8.1: 不再携带写死的 contextWindow(4096)—— 未配置时 getContextWindow
|
||
// 返回 0,压缩判定跳过;该 adapter 仅在 LLM 配置缺失时兜底存在。
|
||
const FALLBACK_ADAPTER = new DeepSeekAdapter({
|
||
provider: '',
|
||
baseURL: '',
|
||
apiKey: '',
|
||
defaultModel: '',
|
||
});
|
||
|
||
// ===== 步骤 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 —— 消费策略级 requireConfirmation(mcp_* 通配
|
||
// 策略),修复"外部 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);
|
||
|
||
// P2(v0.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);
|
||
|
||
// ===== Hooks(P0-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(每会话独立引擎,替代全局单引擎) =====
|
||
// v0.8.1: contextWindow / maxTokens 唯一来源是设置面板 llm.contextWindow / llm.maxTokens;
|
||
// Ollama 的 num_ctx(contextLength)与「上下文长度」同源 —— 同一设置同时驱动
|
||
// 压缩预算与 num_ctx 下发,不再存在独立的 ollama.numCtx 配置键。
|
||
const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER;
|
||
const getContextWindowSetting = (): number | undefined =>
|
||
configService.get<number>('llm.contextWindow') ?? undefined;
|
||
const getOllamaContextLength = (): number | undefined =>
|
||
(configService.get<string>('llm.provider') ?? '') === 'ollama'
|
||
? getContextWindowSetting()
|
||
: undefined;
|
||
const agentEngineManager = new AgentEngineManager({
|
||
buildAdapter,
|
||
baseConfig: {
|
||
maxIterations: configService.get<number>('agent.maxIterations') ?? 20,
|
||
totalTimeoutMs: configService.get<number>('agent.totalTimeoutMs') ?? 600_000,
|
||
contextLength: getOllamaContextLength(),
|
||
contextWindow: getContextWindowSetting(),
|
||
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,
|
||
// v0.8.1 硬性契约: 直接读设置面板值(种子默认由 CONFIG_DEFAULTS 保证),
|
||
// 不再在代码侧携带 63488 写死兜底
|
||
maxTokens: configService.get<number>('llm.maxTokens') ?? undefined,
|
||
},
|
||
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;
|
||
}
|
||
// v0.8.1: 上下文窗口全局单一配置(llm.contextWindow),不再按 Provider 读取
|
||
const contextWindow = configService.get<number>('llm.contextWindow') ?? 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());
|
||
|
||
// ===== v0.8.1 P1-1: 本地向量记忆嵌入器装配 =====
|
||
// 仅在 Provider=Ollama(本地推理、数据不出设备、零 token 成本)且用户在设置面板
|
||
// 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回
|
||
// null,MemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。
|
||
// adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。
|
||
memoryManager.setEmbedder({
|
||
embed: async (text) => {
|
||
const adapter = agentEngineManager.getAdapter();
|
||
if (!(adapter instanceof OllamaAdapter)) return null;
|
||
const model = configService.get<string>('memory.embeddingModel') ?? '';
|
||
if (!model.trim()) return null;
|
||
try {
|
||
const r = await adapter.embed({ model: model.trim(), input: text });
|
||
return r.embeddings?.[0] ?? null;
|
||
} catch (err) {
|
||
log.warn('[Memory] Embedding failed (falling back to TF-IDF):', (err as Error).message);
|
||
return null;
|
||
}
|
||
},
|
||
});
|
||
|
||
// ===== 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);
|
||
|
||
// ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段,应用前需用户确认) =====
|
||
const memoryMaintainer = new MemoryMaintainer(
|
||
() => agentEngineManager.getAdapter(),
|
||
workspaceService,
|
||
() => db,
|
||
);
|
||
|
||
// ===== 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: getOllamaContextLength(),
|
||
contextWindow: getContextWindowSetting(),
|
||
// 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());
|
||
|
||
// ===== v0.8.1 P2-1: 加载用户自定义工具策略(tools.{name}.policy)=====
|
||
// 设置面板保存经 shared.ts 热加载;此处覆盖应用启动的冷加载。
|
||
for (const toolDef of toolRegistry.listAllTools()) {
|
||
const raw = configService.get<string>(`tools.${toolDef.name}.policy`);
|
||
if (raw != null && raw !== '') {
|
||
const parsed = parseToolPolicy(raw);
|
||
policyEngine.setPolicyOverride(toolDef.name, parsed);
|
||
if (!parsed) log.warn(`[Policy] Ignored invalid policy config for tool: ${toolDef.name}`);
|
||
}
|
||
}
|
||
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') ?? '',
|
||
// v0.8.1: 全局单一「上下文长度」配置进入签名(替代旧的分 Provider 键)
|
||
contextWindow: configService.get<number>('llm.contextWindow') ?? null,
|
||
});
|
||
};
|
||
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: mt('config.toast.configIncomplete'),
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// P2-10: 重建所有引擎的 adapter(工厂闭包读取最新配置)
|
||
agentEngineManager.refreshAdapters();
|
||
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
|
||
memoryConsolidator.setAdapter(agentEngineManager.getAdapter());
|
||
|
||
// v0.8.1: 同步「上下文长度」到所有引擎(Ollama 同时作为 num_ctx/contextLength)
|
||
const provider = configService.get<string>('llm.provider') ?? '';
|
||
const ctxSetting = getContextWindowSetting();
|
||
agentEngineManager.updateConfigAll({
|
||
contextLength: provider === 'ollama' ? ctxSetting : undefined,
|
||
contextWindow: ctxSetting,
|
||
});
|
||
orchestrator.updateDefaultConfig({
|
||
contextLength: provider === 'ollama' ? ctxSetting : undefined,
|
||
contextWindow: ctxSetting,
|
||
});
|
||
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: mt('config.toast.providerSwitched', { from: lastProvider, to: 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: mt('config.toast.providerSwitchFailed', { message: (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,
|
||
policyEngine,
|
||
auditService,
|
||
sessionRecorder,
|
||
memoryManager,
|
||
mcpManager,
|
||
reloadAdapter,
|
||
promptInjectionDefender: promptDefender,
|
||
outputValidator,
|
||
confirmationHook,
|
||
memoryConsolidator,
|
||
memoryMaintainer,
|
||
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(
|
||
mt('notify.updateAvailable.title'),
|
||
mt('notify.updateAvailable.body', { version: 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(
|
||
mt('notify.taskCompleted.title'),
|
||
mt('notify.taskCompleted.body', { seconds: (data.durationMs / 1000).toFixed(1) }),
|
||
() => 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)
|
||
// v0.8.1 根治: session.defaultSession 必须在 app ready 后才能访问 —— 此前在
|
||
// 模块加载期(ready 前)直接调用,每次启动都抛 "Session can only be received
|
||
// when app is ready",权限白名单静默失效(FATAL 日志为证)。现延迟到 ready 后
|
||
// 注册,防线真正生效。
|
||
const ALLOWED_PERMISSIONS = new Set<string>(['clipboard-sanitized-write', 'fullscreen']);
|
||
void app
|
||
.whenReady()
|
||
.then(() => {
|
||
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
|
||
callback(ALLOWED_PERMISSIONS.has(permission));
|
||
});
|
||
session.defaultSession.setPermissionCheckHandler((_wc, permission) =>
|
||
ALLOWED_PERMISSIONS.has(permission),
|
||
);
|
||
log.info('[Security] Permission whitelist registered (app ready)');
|
||
})
|
||
.catch((err) => {
|
||
log.error('[Security] Failed to register permission whitelist:', err);
|
||
});
|
||
|
||
// 防线二:生产环境 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' };
|
||
});
|
||
});
|