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 中的 */ 被解析为注释结束符
This commit is contained in:
2026-07-22 13:12:15 +08:00
parent 9af9984418
commit 3e5ddea72d
12 changed files with 506 additions and 32 deletions
+53
View File
@@ -18,6 +18,24 @@ import type {
import { MetonaErrorCode } from '../types'; import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; import type { MetonaModelInfo } from '../types/metona-adapter';
/**
* 内容审核错误 — Provider 的安全过滤策略触发的错误
*
* 当 Provider(如 MiMo)的 API 返回 content_filter 错误码时抛出。
* Engine 识别此错误后映射为 MetonaErrorCode.CONTENT_FILTERED
* 前端显示友好提示而非原始 JSON 错误体。
*/
export class ContentFilterError extends Error {
/** Provider 返回的原始错误消息(如 "The request was rejected because it was considered high risk" */
readonly providerMessage: string;
constructor(providerMessage: string, context: string) {
super(`${context}: 内容被 Provider 安全审核拦截 - ${providerMessage}`);
this.name = 'ContentFilterError';
this.providerMessage = providerMessage;
}
}
export abstract class BaseAdapter implements IMetonaProviderAdapter { export abstract class BaseAdapter implements IMetonaProviderAdapter {
// H-2 修复: provider → providerId(规范要求) // H-2 修复: provider → providerId(规范要求)
abstract readonly providerId: string; abstract readonly providerId: string;
@@ -157,10 +175,35 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
/** /**
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出 * P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx * engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx
*
* v0.3.17: 解析 Provider 返回的 JSON 错误体,识别 content_filter 错误码,
* 抛出 ContentFilterError 让 Engine 映射为 CONTENT_FILTERED 错误码。
*/ */
protected async throwHttpError(response: Response, context: string): Promise<never> { protected async throwHttpError(response: Response, context: string): Promise<never> {
let errorBody = ''; let errorBody = '';
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ } try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
// v0.3.17: 解析 JSON 错误体,识别 content_filter
if (errorBody) {
try {
const parsed = JSON.parse(errorBody);
// 兼容 OpenAI 格式 {error: {code, message}} 和 MiMo/其他格式
const errObj = parsed?.error ?? parsed;
const errorCode = errObj?.code ?? errObj?.type ?? '';
const errorMessage = errObj?.message ?? '';
if (typeof errorCode === 'string' && errorCode.toLowerCase().includes('content_filter')) {
const providerMsg = errorMessage || '内容触发安全过滤策略';
const cfError = new ContentFilterError(providerMsg, context);
(cfError as ContentFilterError & { status: number }).status = response.status;
throw cfError;
}
} catch (parseErr) {
// JSON 解析失败(非 JSON 响应体),走原逻辑
if (parseErr instanceof ContentFilterError) throw parseErr;
}
}
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`); const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
(error as Error & { status: number }).status = response.status; (error as Error & { status: number }).status = response.status;
throw error; throw error;
@@ -171,6 +214,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
*/ */
protected mapError(error: unknown): MetonaError { protected mapError(error: unknown): MetonaError {
if (error instanceof Error) { if (error instanceof Error) {
// v0.3.17: 优先识别 ContentFilterError
if (error instanceof ContentFilterError) {
return {
code: MetonaErrorCode.CONTENT_FILTERED,
message: '内容被 Provider 安全审核拦截,请修改图片或文本后重试',
provider: this.providerId,
retryable: false,
};
}
const msg = error.message.toLowerCase(); const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) { if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
+7 -2
View File
@@ -36,6 +36,7 @@ import type {
} from '../types'; } from '../types';
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types'; import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator'; import { estimateMessagesTokens } from '../utils/token-estimator';
import { ContentFilterError } from '../adapters/base-adapter';
import log from 'electron-log'; import log from 'electron-log';
/** /**
@@ -1149,6 +1150,8 @@ export class AgentLoopEngine extends EventEmitter {
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) { if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
// v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息
const isContentFilter = error instanceof ContentFilterError;
this.emit('streamEvent', { this.emit('streamEvent', {
type: MetonaStreamEventType.ERROR, type: MetonaStreamEventType.ERROR,
requestId: this.currentRequestId, requestId: this.currentRequestId,
@@ -1158,8 +1161,10 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(), timestamp: Date.now(),
runId: this.runId, runId: this.runId,
error: { error: {
code: MetonaErrorCode.UNKNOWN, code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN,
message: error.message, message: isContentFilter
? '内容被 Provider 安全审核拦截,请修改图片或文本后重试'
: error.message,
retryable: false, retryable: false,
}, },
}); });
+41 -1
View File
@@ -163,6 +163,29 @@ export function registerAllIPCHandlers(
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err); log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
} }
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
// 解决场景:用户上传图片后,AI 先在工作空间找图片,找不到才意识到是用户上传的
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
if (Array.isArray(attachments) && attachments.length > 0) {
const attachmentList = attachments.map((att, i) => {
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
const note = att.type === 'image'
? 'visible via vision capability, do NOT search in workspace'
: att.type === 'text'
? 'content already inlined in the user message, do NOT search in workspace'
: 'uploaded directly by user, do NOT search in workspace';
return `${i + 1}. [${typeLabel}] ${att.name}${note}`;
}).join('\n');
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
: attachmentBlock;
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
}
// 监听 Agent Loop 事件 // 监听 Agent Loop 事件
// F8: 主进程 text_delta 节流合并 // F8: 主进程 text_delta 节流合并
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒, // 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
@@ -817,6 +840,11 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Log level updated to ${level}`); log.info(`[CONFIG] Log level updated to ${level}`);
} }
// 广播配置变更事件,前端监听后更新 store(如 maxIterations 显示)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('config:changed', { key, value });
}
// 工作空间路径变更时写入独立文件(下次启动生效) // 工作空间路径变更时写入独立文件(下次启动生效)
if (key === 'workspace.path') { if (key === 'workspace.path') {
try { try {
@@ -978,6 +1006,13 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Log level updated to ${logLevelValue}`); log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
} }
// 第四步半:广播所有配置变更事件,前端监听后更新 store
if (!mainWindow.isDestroyed()) {
for (const { key, value } of entries) {
mainWindow.webContents.send('config:changed', { key, value });
}
}
// 第五步:工作空间路径写入独立文件(下次启动生效) // 第五步:工作空间路径写入独立文件(下次启动生效)
if (workspacePathValue) { if (workspacePathValue) {
try { try {
@@ -1061,7 +1096,7 @@ export function registerAllIPCHandlers(
// ===== 工作空间校验与继承 ===== // ===== 工作空间校验与继承 =====
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态 // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => { ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') { if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' }; return { valid: false, reason: '路径不能为空' };
@@ -1080,6 +1115,7 @@ export function registerAllIPCHandlers(
exists: false, exists: false,
missingFiles: ['SOUL.md', 'MEMORY.md'], missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true, isNewWorkspace: true,
dbExists: false,
reason: '目录不存在,将在切换后自动创建', reason: '目录不存在,将在切换后自动创建',
}; };
} }
@@ -1101,12 +1137,16 @@ export function registerAllIPCHandlers(
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f); if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
} }
// 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项)
const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db'));
return { return {
valid: true, valid: true,
path: resolvedPath, path: resolvedPath,
exists: true, exists: true,
missingFiles, missingFiles,
isNewWorkspace: missingFiles.length === requiredFiles.length, isNewWorkspace: missingFiles.length === requiredFiles.length,
dbExists,
}; };
}); });
+26
View File
@@ -22,6 +22,7 @@ import log from 'electron-log';
import { DatabaseService } from './services/database.service'; import { DatabaseService } from './services/database.service';
import { SessionService } from './services/session.service'; import { SessionService } from './services/session.service';
import { ConfigService } from './services/config.service'; import { ConfigService } from './services/config.service';
import { GlobalConfigService } from './services/global-config.service';
import { WorkspaceService } from './services/workspace.service'; import { WorkspaceService } from './services/workspace.service';
import { AuditService } from './services/audit.service'; import { AuditService } from './services/audit.service';
import { SessionRecorder } from './services/session-recorder.service'; import { SessionRecorder } from './services/session-recorder.service';
@@ -131,8 +132,33 @@ async function initialize(): Promise<void> {
databaseService.initialize(); databaseService.initialize();
const db = databaseService.getDB(); const db = databaseService.getDB();
// v0.3.17: 初始化全局配置层(跨工作空间共享 LLM/Agent/onboarding 等机器级配置)
// 必须在 ConfigService 创建前初始化,以便注入
const globalConfigService = new GlobalConfigService();
globalConfigService.initialize();
const sessionService = new SessionService(() => db); const sessionService = new SessionService(() => db);
const configService = new ConfigService(() => db); const configService = new ConfigService(() => db);
// 注入全局配置层:读取时回退到全局 JSON,写入时双写
configService.setGlobalConfig(globalConfigService);
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key,重复执行无副作用
// 解决场景:老用户从 v0.3.16 升级,LLM 配置在工作空间 DB 里,需要迁移到全局层
// 注意: 此处直接查 DB 而不通过 configService.getAll(),因为后者会合并全局层,导致迁移逻辑循环
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>;
const workspaceConfig: Record<string, unknown> = {};
for (const row of configRows) {
try {
workspaceConfig[row.key] = JSON.parse(row.value);
} catch {
workspaceConfig[row.key] = row.value;
}
}
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
if (migratedCount > 0) {
log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`);
}
// ===== 日志服务 ===== // ===== 日志服务 =====
const auditService = new AuditService(() => db); const auditService = new AuditService(() => db);
+7
View File
@@ -130,6 +130,13 @@ const metonaAPI = {
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => setBatch: (entries: Array<{ key: string; value: unknown }>) =>
ipcRenderer.invoke('config:setBatch', entries), ipcRenderer.invoke('config:setBatch', entries),
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
onChanged: (callback: (data: { key: string; value: unknown }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { key: string; value: unknown }) =>
callback(data);
ipcRenderer.on('config:changed', listener);
return () => ipcRenderer.removeListener('config:changed', listener);
},
}, },
// ===== 应用工具 ===== // ===== 应用工具 =====
+69 -5
View File
@@ -4,11 +4,17 @@
* 管理 app_config 表的读写操作。 * 管理 app_config 表的读写操作。
* 所有配置值以 JSON 格式存储。 * 所有配置值以 JSON 格式存储。
* *
* v0.3.17: 引入全局配置层(GlobalConfigService)。
* - 全局 keyllm.* / agent.* / onboarding.completed 等)读取时优先工作空间 DB,
* miss 时回退到全局 JSON,确保切换工作空间后 LLM 凭据和引导状态不丢失。
* - 写入时同时写工作空间 DB(兼容)和全局 JSON(跨工作空间共享)。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置 * @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
*/ */
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import log from 'electron-log'; import log from 'electron-log';
import { GlobalConfigService, isGlobalKey, isUnconfiguredGlobalKey } from './global-config.service';
export interface ConfigRow { export interface ConfigRow {
key: string; key: string;
@@ -18,26 +24,63 @@ export interface ConfigRow {
} }
export class ConfigService { export class ConfigService {
private globalConfig: GlobalConfigService | null = null;
constructor(private getDB: () => Database.Database) {} 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 { get<T = unknown>(key: string): T | null {
const db = this.getDB(); const db = this.getDB();
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined; const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
if (!row) return null; if (row) {
let parsed: unknown;
try { try {
return JSON.parse(row.value) as T; parsed = JSON.parse(row.value);
} catch { } catch {
return row.value as T; 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) * 设置配置值(保留已有 category)
* 全局 key 同时写入工作空间 DB 和全局 JSON
*/ */
set(key: string, value: unknown): void { set(key: string, value: unknown): void {
const db = this.getDB(); const db = this.getDB();
@@ -52,11 +95,17 @@ export class ConfigService {
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(key, jsonValue, category, Date.now()); `).run(key, jsonValue, category, Date.now());
// 全局 key 同步写入全局层(跨工作空间共享)
if (this.globalConfig && isGlobalKey(key)) {
this.globalConfig.set(key, value);
}
log.debug(`Config set: ${key}`); log.debug(`Config set: ${key}`);
} }
/** /**
* 获取所有配置 * 获取所有配置(工作空间 DB + 全局层合并)
* 合并规则与 get() 一致:全局 key 在 DB 中为"未配置"时回退到全局层
*/ */
getAll(): Record<string, unknown> { getAll(): Record<string, unknown> {
const db = this.getDB(); const db = this.getDB();
@@ -71,6 +120,21 @@ export class ConfigService {
} }
} }
// 全局层合并:与 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; return config;
} }
+247
View File
@@ -0,0 +1,247 @@
/**
* 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': 131072,
'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);
}
}
}
+6 -6
View File
@@ -54,12 +54,12 @@ export class WindowManager {
title: options.title ?? 'MetonaAI Desktop', title: options.title ?? 'MetonaAI Desktop',
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/preload.mjs'), preload: join(__dirname, '../preload/preload.mjs'),
// #37 修复: 启用沙箱,强化渲染进程隔离 // sandbox: false — preload 使用 ESM 格式(.mjs + import 语法),
// preload.ts 仅使用 contextBridge/ipcRenderersandbox 下可用)和 // Electron sandbox 不支持 ESM preload(官方 ESM 支持矩阵: Sandboxed = Unsupported)。
// @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API // 若启用 sandbox: truepreload.mjs 的 import 语句无法解析,contextBridge 不执行
// 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API, // window.metona 为 undefined,所有 IPC 调用静默失败。
// 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面 // 要启用 sandbox,需先将 preload 改为 CJS 格式 + require 语法(工程改动较大)
sandbox: true, sandbox: false,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
}, },
+3 -3
View File
@@ -9646,9 +9646,9 @@
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.7", "version": "19.2.8",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-markdown": { "node_modules/react-markdown": {
+13 -9
View File
@@ -148,11 +148,11 @@ function WorkspaceSettings() {
setApplying(true); setApplying(true);
try { try {
// 如果勾选了继承文件,从旧工作空间复制到新工作空间 // 如果勾选了继承文件,从旧工作空间复制到新工作空间
// - SOUL.md: Agent 身份定义 // - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义)
// - .metona/agent.db: 数据库(会话/消息/记忆/Trace),后端白名单校验 // - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据)
const filesToInherit: string[] = []; const filesToInherit: string[] = [];
if (inheritSoul) filesToInherit.push('SOUL.md'); if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md')) filesToInherit.push('SOUL.md');
if (inheritDatabase) filesToInherit.push('.metona/agent.db'); if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db');
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失 // #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
if (inheritDatabase && currentPath) { if (inheritDatabase && currentPath) {
@@ -277,18 +277,22 @@ function WorkspaceSettings() {
</Alert> </Alert>
)} )}
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */} {/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */}
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && ( {currentPath && currentPath !== pendingPath &&
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
<Stack spacing={0.5} sx={{ mt: 0.5 }}> <Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography> </Typography>
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
<FormControlLabel <FormControlLabel
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />} control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
label={<Typography variant="caption">SOUL.md</Typography>} label={<Typography variant="caption">SOUL.md</Typography>}
/> />
{/* 数据库继承:仅在新工作空间(空目录)时显示,避免覆盖已有工作空间的数据 */} )}
{checkResult.isNewWorkspace && ( {/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
{!checkResult.dbExists && (
<> <>
<FormControlLabel <FormControlLabel
control={<Checkbox size="small" checked={inheritDatabase} onChange={(e) => setInheritDatabase(e.target.checked)} />} control={<Checkbox size="small" checked={inheritDatabase} onChange={(e) => setInheritDatabase(e.target.checked)} />}
+25 -1
View File
@@ -400,10 +400,15 @@ export function useAgentStream(): void {
getStore().setCurrentRunId(null); getStore().setCurrentRunId(null);
getStore().setAgentStatus('error'); getStore().setAgentStatus('error');
getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().updateLastTraceStep({ completedAt: Date.now() });
// v0.3.17: 对 content_filtered 错误码显示更友好的提示
const errorCode = data.error?.code;
const errorMessage = data.error?.message ?? '未知错误';
getStore().addMessage({ getStore().addMessage({
id: genMsgId('error'), id: genMsgId('error'),
role: 'system', role: 'system',
content: `错误: ${data.error?.message ?? '未知错误'}`, content: errorCode === 'content_filtered'
? `⚠️ ${errorMessage}`
: `错误: ${errorMessage}`,
timestamp: Date.now(), timestamp: Date.now(),
}); });
break; break;
@@ -596,4 +601,23 @@ export function useAgentStream(): void {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
// v0.3.17: 监听配置变更广播,实时更新 store 中的配置字段
// 解决场景:用户在 SettingsModal 修改 agent.maxIterations 后,详情栏分母不刷新
useEffect(() => {
if (!window.metona?.config?.onChanged) return;
const unsubscribe = window.metona.config.onChanged((data: { key: string; value: unknown }) => {
const store = useAgentStore.getState();
const { key, value } = data;
// 按需更新 store 中缓存的配置字段
if (key === 'agent.maxIterations' && typeof value === 'number') {
store.setMaxIterations(value);
}
// 其他 agent.* 配置项若 store 有对应字段,可在此扩展
});
return () => unsubscribe();
}, []);
} }
+4
View File
@@ -164,6 +164,8 @@ interface MetonaConfigAPI {
set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>; set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>; setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void;
} }
// ===== App API ===== // ===== App API =====
@@ -186,6 +188,8 @@ interface MetonaWorkspaceCheckResult {
exists?: boolean; exists?: boolean;
missingFiles?: string[]; missingFiles?: string[];
isNewWorkspace?: boolean; isNewWorkspace?: boolean;
/** 目标目录下 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项) */
dbExists?: boolean;
reason?: string; reason?: string;
} }