diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index 2100ae4..f6dff6d 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -18,6 +18,24 @@ import type { import { MetonaErrorCode } from '../types'; 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 { // H-2 修复: provider → providerId(规范要求) abstract readonly providerId: string; @@ -157,10 +175,35 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { /** * P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出 * 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 { let errorBody = ''; 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}` : ''}`); (error as Error & { status: number }).status = response.status; throw error; @@ -171,6 +214,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { */ protected mapError(error: unknown): MetonaError { 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(); if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) { diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 9757ccc..364b03a 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -36,6 +36,7 @@ import type { } from '../types'; import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types'; import { estimateMessagesTokens } from '../utils/token-estimator'; +import { ContentFilterError } from '../adapters/base-adapter'; import log from 'electron-log'; /** @@ -1149,6 +1150,8 @@ export class AgentLoopEngine extends EventEmitter { // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) { + // v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息 + const isContentFilter = error instanceof ContentFilterError; this.emit('streamEvent', { type: MetonaStreamEventType.ERROR, requestId: this.currentRequestId, @@ -1158,8 +1161,10 @@ export class AgentLoopEngine extends EventEmitter { timestamp: Date.now(), runId: this.runId, error: { - code: MetonaErrorCode.UNKNOWN, - message: error.message, + code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN, + message: isContentFilter + ? '内容被 Provider 安全审核拦截,请修改图片或文本后重试' + : error.message, retryable: false, }, }); diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e2ec352..bef7341 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -163,6 +163,29 @@ export function registerAllIPCHandlers( 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 事件 // F8: 主进程 text_delta 节流合并 // 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒, @@ -817,6 +840,11 @@ export function registerAllIPCHandlers( log.info(`[CONFIG] Log level updated to ${level}`); } + // 广播配置变更事件,前端监听后更新 store(如 maxIterations 显示) + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('config:changed', { key, value }); + } + // 工作空间路径变更时写入独立文件(下次启动生效) if (key === 'workspace.path') { try { @@ -978,6 +1006,13 @@ export function registerAllIPCHandlers( 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) { try { @@ -1061,7 +1096,7 @@ export function registerAllIPCHandlers( // ===== 工作空间校验与继承 ===== - // 校验工作空间路径:检测路径有效性 + 4 个必需文件状态 + // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在 ipcMain.handle('workspace:check', async (_event, targetPath: string) => { if (!targetPath || typeof targetPath !== 'string') { return { valid: false, reason: '路径不能为空' }; @@ -1080,6 +1115,7 @@ export function registerAllIPCHandlers( exists: false, missingFiles: ['SOUL.md', 'MEMORY.md'], isNewWorkspace: true, + dbExists: false, reason: '目录不存在,将在切换后自动创建', }; } @@ -1101,12 +1137,16 @@ export function registerAllIPCHandlers( if (!existsSync(join(resolvedPath, f))) missingFiles.push(f); } + // 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项) + const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db')); + return { valid: true, path: resolvedPath, exists: true, missingFiles, isNewWorkspace: missingFiles.length === requiredFiles.length, + dbExists, }; }); diff --git a/electron/main.ts b/electron/main.ts index fcf3e54..e37ba1c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -22,6 +22,7 @@ import log from 'electron-log'; import { DatabaseService } from './services/database.service'; import { SessionService } from './services/session.service'; import { ConfigService } from './services/config.service'; +import { GlobalConfigService } from './services/global-config.service'; import { WorkspaceService } from './services/workspace.service'; import { AuditService } from './services/audit.service'; import { SessionRecorder } from './services/session-recorder.service'; @@ -131,8 +132,33 @@ async function initialize(): Promise { databaseService.initialize(); const db = databaseService.getDB(); + // v0.3.17: 初始化全局配置层(跨工作空间共享 LLM/Agent/onboarding 等机器级配置) + // 必须在 ConfigService 创建前初始化,以便注入 + const globalConfigService = new GlobalConfigService(); + globalConfigService.initialize(); + const sessionService = new SessionService(() => 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 = {}; + 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); diff --git a/electron/preload.ts b/electron/preload.ts index 3903973..fb37c60 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -130,6 +130,13 @@ const metonaAPI = { // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 setBatch: (entries: Array<{ key: string; value: unknown }>) => 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); + }, }, // ===== 应用工具 ===== diff --git a/electron/services/config.service.ts b/electron/services/config.service.ts index 7c52de9..ae3e8b3 100644 --- a/electron/services/config.service.ts +++ b/electron/services/config.service.ts @@ -4,11 +4,17 @@ * 管理 app_config 表的读写操作。 * 所有配置值以 JSON 格式存储。 * + * v0.3.17: 引入全局配置层(GlobalConfigService)。 + * - 全局 key(llm.* / 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; @@ -18,26 +24,63 @@ export interface ConfigRow { } 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(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) return null; + if (row) { + let parsed: unknown; + try { + parsed = JSON.parse(row.value); + } catch { + parsed = row.value; + } - try { - return JSON.parse(row.value) as T; - } catch { - return row.value as T; + // 全局 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(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(key); + } + + return null; } /** * 设置配置值(保留已有 category) + * 全局 key 同时写入工作空间 DB 和全局 JSON */ set(key: string, value: unknown): void { const db = this.getDB(); @@ -52,11 +95,17 @@ export class ConfigService { 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 { 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; } diff --git a/electron/services/global-config.service.ts b/electron/services/global-config.service.ts new file mode 100644 index 0000000..389aafc --- /dev/null +++ b/electron/services/global-config.service.ts @@ -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 = { + '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(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): 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); + } + } +} diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index fb99259..e3f05b1 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -54,12 +54,12 @@ export class WindowManager { title: options.title ?? 'MetonaAI Desktop', webPreferences: { preload: join(__dirname, '../preload/preload.mjs'), - // #37 修复: 启用沙箱,强化渲染进程隔离 - // preload.ts 仅使用 contextBridge/ipcRenderer(sandbox 下可用)和 - // @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API, - // 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API, - // 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面。 - sandbox: true, + // sandbox: false — preload 使用 ESM 格式(.mjs + import 语法), + // Electron sandbox 不支持 ESM preload(官方 ESM 支持矩阵: Sandboxed = Unsupported)。 + // 若启用 sandbox: true,preload.mjs 的 import 语句无法解析,contextBridge 不执行, + // window.metona 为 undefined,所有 IPC 调用静默失败。 + // 要启用 sandbox,需先将 preload 改为 CJS 格式 + require 语法(工程改动较大)。 + sandbox: false, contextIsolation: true, nodeIntegration: false, }, diff --git a/package-lock.json b/package-lock.json index 54d1d91..ece8e3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9646,9 +9646,9 @@ } }, "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "license": "MIT" }, "node_modules/react-markdown": { diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 4c86c61..0d523f0 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -148,11 +148,11 @@ function WorkspaceSettings() { setApplying(true); try { // 如果勾选了继承文件,从旧工作空间复制到新工作空间 - // - SOUL.md: Agent 身份定义 - // - .metona/agent.db: 数据库(会话/消息/记忆/Trace),后端白名单校验 + // - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义) + // - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据) const filesToInherit: string[] = []; - if (inheritSoul) filesToInherit.push('SOUL.md'); - if (inheritDatabase) filesToInherit.push('.metona/agent.db'); + if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md')) filesToInherit.push('SOUL.md'); + if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db'); // #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失 if (inheritDatabase && currentPath) { @@ -277,18 +277,22 @@ function WorkspaceSettings() { )} - {/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */} - {currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && ( + {/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */} + {currentPath && currentPath !== pendingPath && + (checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && ( - 从当前工作空间继承基础设置: + 从当前工作空间继承: - setInheritSoul(e.target.checked)} />} - label={SOUL.md(身份与角色定义)} - /> - {/* 数据库继承:仅在新工作空间(空目录)时显示,避免覆盖已有工作空间的数据 */} - {checkResult.isNewWorkspace && ( + {/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */} + {checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && ( + setInheritSoul(e.target.checked)} />} + label={SOUL.md(身份与角色定义)} + /> + )} + {/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */} + {!checkResult.dbExists && ( <> setInheritDatabase(e.target.checked)} />} diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index 35b6b87..0adfc7a 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -400,10 +400,15 @@ export function useAgentStream(): void { getStore().setCurrentRunId(null); getStore().setAgentStatus('error'); getStore().updateLastTraceStep({ completedAt: Date.now() }); + // v0.3.17: 对 content_filtered 错误码显示更友好的提示 + const errorCode = data.error?.code; + const errorMessage = data.error?.message ?? '未知错误'; getStore().addMessage({ id: genMsgId('error'), role: 'system', - content: `错误: ${data.error?.message ?? '未知错误'}`, + content: errorCode === 'content_filtered' + ? `⚠️ ${errorMessage}` + : `错误: ${errorMessage}`, timestamp: Date.now(), }); break; @@ -596,4 +601,23 @@ export function useAgentStream(): void { 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(); + }, []); } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 9b8f966..38b794a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -164,6 +164,8 @@ interface MetonaConfigAPI { set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>; // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 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 ===== @@ -186,6 +188,8 @@ interface MetonaWorkspaceCheckResult { exists?: boolean; missingFiles?: string[]; isNewWorkspace?: boolean; + /** 目标目录下 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项) */ + dbExists?: boolean; reason?: string; }