/** * 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'; import { CONFIG_DEFAULTS } from './database.service'; import { decryptConfigValue, encryptConfigValue, isSensitiveConfigKey } from '../utils/secure-config'; /** 全局配置文件路径(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.', 'openai.', 'anthropic.', 'onboarding.', ]; /** 判断 key 是否属于全局配置 */ export function isGlobalKey(key: string): boolean { return GLOBAL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); } /** * seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层") * * P1-13: 由 database.service.ts 的 CONFIG_DEFAULTS 单一来源派生, * 不再手工维护副本(原双源曾出现漂移风险)。 */ export const SEED_DEFAULTS: Record = Object.fromEntries( CONFIG_DEFAULTS.map((d) => [d.key, d.value]), ); /** * 判断全局 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; } /** * 读取全局配置(P0-1: 敏感 key 自动解密) */ get(key: string): T | null { if (!this.initialized) { log.warn('[GlobalConfig] get() called before initialize()'); return null; } if (key in this.data) { const raw = this.data[key]; return (isSensitiveConfigKey(key) ? decryptConfigValue(raw) : raw) as T; } return null; } /** * 写入全局配置(内存 + 立即落盘;P0-1: 敏感 key 加密后存储) */ set(key: string, value: unknown): void { if (!this.initialized) { log.warn('[GlobalConfig] set() called before initialize()'); return; } this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value; this.flush(); log.debug(`[GlobalConfig] set: ${key}`); } /** * 批量写入(仅落盘一次,避免多次 IO;敏感 key 加密后存储) */ 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] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value; } this.flush(); log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`); } /** * 获取所有全局配置(用于迁移和调试;敏感 key 解密后返回) */ getAll(): GlobalConfigData { const out: GlobalConfigData = {}; for (const [key, value] of Object.entries(this.data)) { out[key] = isSensitiveConfigKey(key) ? decryptConfigValue(value) : value; } return out; } /** * 从工作空间 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] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : 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); } } }