Files
metona-ai-desktop/electron/services/config.service.ts
T
thzxx 3e5ddea72d feat: v0.3.17 修复多项问题(sandbox/全局配置层/继承选项/配置刷新/附件提示/content_filter 错误处理)
- fix: sandbox: true → false(ESM preload 不兼容 sandbox 导致 window.metona 不可用,选择文件夹按钮无反应)
- feat: 全局配置层(GlobalConfigService)— LLM/Agent 等配置跨工作空间共享,切换工作空间后不再丢失
  - 读取时工作空间 DB 空值或默认值回退到全局 JSON
  - 写入时双写(DB + 全局 JSON)
  - 首次启动迁移旧配置到全局层
- fix: 工作空间继承选项显示条件放宽 + push 前校验避免覆盖目标已有文件(SOUL.md/agent.db)
- fix: config:changed IPC 广播 + 前端监听,修改 Agent 配置后详情栏 maxIterations 实时刷新
- feat: 上传图片/文件时在 System Prompt 注入附件提示,避免 AI 在工作空间查找用户上传的文件
- feat: content_filter 错误识别(ContentFilterError)+ 友好提示,替代原始 JSON 错误体透传
- fix: JSDoc 注释中 llm.* / agent.* / onboarding.completed 中的 */ 被解析为注释结束符
2026-07-22 13:12:15 +08:00

169 lines
5.5 KiB
TypeScript
Raw Permalink 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';
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;
}
// 全局 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
*/
set(key: string, value: unknown): void {
const db = this.getDB();
const jsonValue = JSON.stringify(value);
// 查询已有 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, value);
}
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;
}
}
// 全局层合并:与 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;
}
}