Files
metona-ai-desktop/electron/services/global-config.service.ts
T

248 lines
7.7 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.
/**
* 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<string, unknown> = {
'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<T = unknown>(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<string, unknown>): 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);
}
}
}