Files
metona-ai-desktop/electron/services/config.service.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

181 lines
6.2 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.
/**
* Config Service — 配置读写
*
* 管理 app_config 表的读写操作。
* 所有配置值以 JSON 格式存储。
*
* v0.3.17: 引入全局配置层(GlobalConfigService)。
* - 全局 keyllm.* / agent.* / onboarding.completed 等)读取时优先工作空间 DB,
* miss 时回退到全局 JSON,确保切换工作空间后 LLM 凭据和引导状态不丢失。
* - 写入时同时写工作空间 DB(兼容)和全局 JSON(跨工作空间共享)。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
*/
import type Database from 'better-sqlite3';
import log from 'electron-log';
import { GlobalConfigService, isGlobalKey, isUnconfiguredGlobalKey } from './global-config.service';
import { decryptConfigValue, encryptConfigValue, isSensitiveConfigKey } from '../utils/secure-config';
export interface ConfigRow {
key: string;
value: string;
category: string;
updated_at: number;
}
export class ConfigService {
private globalConfig: GlobalConfigService | null = null;
constructor(private getDB: () => Database.Database) {}
/**
* 注入全局配置服务(可选,由 main.ts 在启动时调用)
* 注入后,全局 key 的读取会回退到全局层,写入会双写。
*/
setGlobalConfig(globalConfig: GlobalConfigService): void {
this.globalConfig = globalConfig;
}
/**
* 获取配置值
* 读取顺序:工作空间 DB → 全局 JSON(仅全局 key
*
* 特殊处理:对于"空值视为未配置"的全局 key(如 llm.apiKey=''、onboarding.completed=false),
* 当工作空间 DB 的值为空/默认值时,回退到全局层读取真实值。
* 这解决了新工作空间 DB 被 seedDefaults 灌入空值导致配置丢失的问题。
*/
get<T = unknown>(key: string): T | null {
const db = this.getDB();
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
if (row) {
let parsed: unknown;
try {
parsed = JSON.parse(row.value);
} catch {
parsed = row.value;
}
// P0-1: 敏感 key 落盘为密文,读取时解密(含历史明文的平滑兼容——明文原样返回)
if (isSensitiveConfigKey(key)) {
parsed = decryptConfigValue(parsed);
}
// 全局 key 的空值回退:若工作空间 DB 存在该 key 但值为"未配置"(如 llm.apiKey=''),
// 且全局层有真实值,则返回全局层的值。
// 解决场景:新工作空间 DB 被 seedDefaults 灌入 llm.apiKey='' / onboarding.completed=false
// 但用户在全局层已配置过,应回退到全局层。
if (this.globalConfig && isGlobalKey(key) && isUnconfiguredGlobalKey(key, parsed)) {
const globalValue = this.globalConfig.get<T>(key);
if (globalValue !== null && !isUnconfiguredGlobalKey(key, globalValue)) {
return globalValue;
}
}
return parsed as T;
}
// 工作空间 DB miss,若为全局 key 则回退到全局层
if (this.globalConfig && isGlobalKey(key)) {
return this.globalConfig.get<T>(key);
}
return null;
}
/**
* 设置配置值(保留已有 category)
* 全局 key 同时写入工作空间 DB 和全局 JSON
* P0-1: 敏感 keyapiKey 等)加密后再落盘(DB 与全局 JSON 均为密文)
*/
set(key: string, value: unknown): void {
const db = this.getDB();
const storedValue = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
const jsonValue = JSON.stringify(storedValue);
// 查询已有 category,避免 INSERT OR REPLACE 覆盖为默认值
const existing = db.prepare('SELECT category FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
const category = existing?.category ?? 'general';
db.prepare(`
INSERT OR REPLACE INTO app_config (key, value, category, updated_at)
VALUES (?, ?, ?, ?)
`).run(key, jsonValue, category, Date.now());
// 全局 key 同步写入全局层(跨工作空间共享;全局层内部同样加密)
if (this.globalConfig && isGlobalKey(key)) {
this.globalConfig.set(key, storedValue);
}
log.debug(`Config set: ${key}`);
}
/**
* 获取所有配置(工作空间 DB + 全局层合并)
* 合并规则与 get() 一致:全局 key 在 DB 中为"未配置"时回退到全局层
*/
getAll(): Record<string, unknown> {
const db = this.getDB();
const rows = db.prepare('SELECT key, value FROM app_config').all() as ConfigRow[];
const config: Record<string, unknown> = {};
for (const row of rows) {
try {
config[row.key] = JSON.parse(row.value);
} catch {
config[row.key] = row.value;
}
// P0-1: 敏感 key 解密后再返回(与 get() 行为一致)
if (isSensitiveConfigKey(row.key)) {
config[row.key] = decryptConfigValue(config[row.key]);
}
}
// 全局层合并:与 get() 的回退逻辑保持一致
if (this.globalConfig) {
const globalAll = this.globalConfig.getAll();
for (const [key, globalValue] of Object.entries(globalAll)) {
if (!(key in config)) {
// DB miss → 用全局层
config[key] = globalValue;
} else if (isGlobalKey(key) && isUnconfiguredGlobalKey(key, config[key])
&& !isUnconfiguredGlobalKey(key, globalValue)) {
// DB 有值但为"未配置"seedDefaults 默认值/空值),全局层有真实值 → 用全局层
config[key] = globalValue;
}
}
}
return config;
}
/**
* 获取指定分类的所有配置
*/
getByCategory(category: string): Record<string, unknown> {
const db = this.getDB();
const rows = db.prepare('SELECT key, value FROM app_config WHERE category = ?').all(category) as ConfigRow[];
const config: Record<string, unknown> = {};
for (const row of rows) {
try {
config[row.key] = JSON.parse(row.value);
} catch {
config[row.key] = row.value;
}
}
return config;
}
/**
* 删除配置
*/
delete(key: string): boolean {
const db = this.getDB();
const result = db.prepare('DELETE FROM app_config WHERE key = ?').run(key);
return result.changes > 0;
}
}