/** * Global Config Service — 全局配置层(跨工作空间共享) * * 设计原因: * 原实现中所有配置存在工作空间 DB({workspacePath}/.metona/agent.db)的 app_config 表里。 * 切换到新工作空间时,新 DB 被 seedDefaults 灌入空值,导致 LLM 凭据、onboarding.completed * 等本应"机器级"的配置全部丢失,用户被迫重新配置。 * * 方案: * 将"机器级全局配置"抽到 userData/global-config.json,与工作空间解耦。 * 工作空间 DB 仍保留会话/消息/记忆/审计等"工作空间级数据"。 * * 全局 key 规则: * - llm.* / agent.* / ollama.* / {provider}.contextWindow / security.* / ui.* / logging.* * - onboarding.completed * 写入时同时写工作空间 DB(兼容旧逻辑)和全局 JSON; * 读取时优先工作空间 DB,miss 时回退到全局 JSON。 * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 配置存储 */ import { app } from 'electron'; import { join } from 'path'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import log from 'electron-log'; /** 全局配置文件路径(userData 下,与工作空间无关) */ const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json'); /** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */ const GLOBAL_KEY_PREFIXES = [ 'llm.', 'agent.', 'ollama.', 'security.', 'ui.', 'logging.', 'deepseek.', 'agnes.', 'mimo.', 'onboarding.', ]; /** 判断 key 是否属于全局配置 */ export function isGlobalKey(key: string): boolean { return GLOBAL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); } /** * seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层") * 必须与 database.service.ts 的 seedDefaults 保持同步 */ export const SEED_DEFAULTS: Record = { 'llm.provider': '', 'llm.model': '', 'llm.apiKey': '', 'llm.baseURL': '', 'llm.temperature': 0, 'llm.maxTokens': 63488, 'llm.fallbackProvider': '', 'llm.fallbackModel': '', 'agent.maxIterations': 20, 'agent.totalTimeoutMs': 600000, 'agent.enableThinking': true, 'agent.thinkingEffort': 'high', 'agent.enableReflection': false, 'agent.confirmationTimeoutMs': 120000, 'agent.toolExecutionTimeoutMs': 120000, 'security.requireWriteConfirmation': true, 'security.maxFileWriteSizeKB': 1024, 'security.promptInjectionDefense': true, 'ui.theme': 'auto', 'ui.animationMode': 'auto', 'ui.fontSize': 'medium', 'logging.level': 'info', 'logging.auditEnabled': true, 'logging.traceEnabled': true, 'ollama.numCtx': null, 'deepseek.contextWindow': 1000000, 'agnes.contextWindow': 1000000, 'mimo.contextWindow': 1000000, 'onboarding.completed': false, }; /** * 判断全局 key 在工作空间 DB 中的值是否为"未配置"(应回退到全局层) * * 判断规则: * - 值为空字符串/null/undefined → 未配置 * - 值等于 seedDefaults 默认值 → 未配置(新工作空间 DB 被灌入的默认值) * - 其他 → 已配置(用户主动设置过,不回退) */ export function isUnconfiguredGlobalKey(key: string, value: unknown): boolean { // 空值一律视为未配置 if (value === '' || value === null || value === undefined) { return true; } // 值等于 seedDefaults 默认值 → 视为未配置(被 seedDefaults 灌入的) const defaultValue = SEED_DEFAULTS[key]; if (defaultValue !== undefined && value === defaultValue) { return true; } return false; } interface GlobalConfigData { /** key → value(已 JSON.parse) */ [key: string]: unknown; } export class GlobalConfigService { private data: GlobalConfigData = {}; private initialized = false; /** * 初始化:读取全局配置文件 * 若文件不存在则创建空文件(首次启动) */ initialize(): void { if (this.initialized) { log.warn('[GlobalConfig] Already initialized'); return; } try { if (existsSync(GLOBAL_CONFIG_FILE)) { const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8'); this.data = JSON.parse(raw) as GlobalConfigData; log.info(`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`); } else { // 确保父目录存在 const dir = join(GLOBAL_CONFIG_FILE, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } this.data = {}; this.flush(); log.info(`[GlobalConfig] Created new global config file: ${GLOBAL_CONFIG_FILE}`); } } catch (err) { log.error('[GlobalConfig] Failed to load, starting with empty config:', err); this.data = {}; } this.initialized = true; } /** * 读取全局配置 */ get(key: string): T | null { if (!this.initialized) { log.warn('[GlobalConfig] get() called before initialize()'); return null; } if (key in this.data) { return this.data[key] as T; } return null; } /** * 写入全局配置(内存 + 立即落盘) */ set(key: string, value: unknown): void { if (!this.initialized) { log.warn('[GlobalConfig] set() called before initialize()'); return; } this.data[key] = value; this.flush(); log.debug(`[GlobalConfig] set: ${key}`); } /** * 批量写入(仅落盘一次,避免多次 IO) */ setBatch(entries: Array<{ key: string; value: unknown }>): void { if (!this.initialized) { log.warn('[GlobalConfig] setBatch() called before initialize()'); return; } for (const { key, value } of entries) { this.data[key] = value; } this.flush(); log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`); } /** * 获取所有全局配置(用于迁移和调试) */ getAll(): GlobalConfigData { return { ...this.data }; } /** * 从工作空间 DB 迁移配置到全局层(首次启用全局配置层时调用) * * 迁移规则: * - 仅迁移 isGlobalKey 匹配的 key * - 仅当全局层该 key 不存在时才写入(不覆盖用户已保存的全局配置) * - 仅迁移"已配置"的值(空值/seedDefaults 默认值不迁移) * - false 不一律跳过:若 seedDefaults 默认值不是 false,则 false 是用户主动设置,应迁移 * * @param workspaceConfig 工作空间 DB 的全部 app_config */ migrateFromWorkspaceDB(workspaceConfig: Record): number { if (!this.initialized) { log.warn('[GlobalConfig] migrateFromWorkspaceDB() called before initialize()'); return 0; } let migrated = 0; for (const [key, value] of Object.entries(workspaceConfig)) { if (!isGlobalKey(key)) continue; // 全局层已有该 key,不覆盖 if (key in this.data) continue; // 空值不迁移(seedDefaults 灌入的空值) if (value === '' || value === null || value === undefined) continue; // 值等于 seedDefaults 默认值不迁移(避免把默认值同步到全局层) // 注意:isUnconfiguredGlobalKey 已包含"值等于默认值"判断,此处复用 if (isUnconfiguredGlobalKey(key, value)) continue; this.data[key] = value; migrated++; } if (migrated > 0) { this.flush(); log.info(`[GlobalConfig] Migrated ${migrated} keys from workspace DB to global config`); } return migrated; } /** * 落盘到 JSON 文件 */ private flush(): void { try { writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8'); } catch (err) { log.error('[GlobalConfig] Failed to write config file:', err); } } }