diff --git a/README.md b/README.md index 258b753..893856a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 生产级通用 AI Agent 智能体桌面应用 -[![Version](https://img.shields.io/badge/version-0.3.9-blue)](./package.json) +[![Version](https://img.shields.io/badge/version-0.3.10-blue)](./package.json) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 65859ce..8fd35a9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -756,6 +756,169 @@ export function registerAllIPCHandlers( log.info(`[CONFIG] Workspace path saved (restart required): ${value}`); } catch (err) { log.error('[CONFIG] Failed to save workspace path:', err); + // v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径 + // 用户看到"配置已保存"却实际未生效,会误以为配置系统故障 + return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` }; + } + } + + return { success: true }; + }); + + // ===== v0.3.9: 批量保存配置(解决串行保存中间态触发 reloadAdapter 失败问题)===== + // 设计原因:前端设置页一次保存 8 个字段,串行 config:set 会在中间态(如 provider + // 已改但 apiKey 还没保存)触发 reloadAdapter,导致返回"LLM 配置不完整"错误。 + // 批量保存:先写入所有字段,最后统一触发一次 reloadAdapter 和 Engine/Orchestrator 同步。 + ipcMain.handle('config:setBatch', async (_event, entries: unknown) => { + // 参数校验:必须是 {key, value}[] 非空数组 + if (!Array.isArray(entries) || entries.length === 0) { + return { success: false, error: 'Invalid entries: must be non-empty array of {key, value}' }; + } + // 逐条校验每个元素的结构和类型 + for (const entry of entries) { + if (!entry || typeof entry !== 'object') { + return { success: false, error: 'Invalid entry: must be {key, value} object' }; + } + const e = entry as { key?: unknown; value?: unknown }; + if (typeof e.key !== 'string' || !e.key.trim()) { + return { success: false, error: 'Invalid config key in batch' }; + } + const v = e.value; + if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') { + return { success: false, error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null` }; + } + } + + // 敏感字段脱敏工具(与 config:set 保持一致) + const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password']; + const maskSensitive = (key: string, value: unknown): unknown => { + const isSensitive = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p)); + if (isSensitive && typeof value === 'string' && value.length > 0) { + return value.length > 4 ? '***' + value.slice(-4) : '***'; + } + return value; + }; + + // LLM 相关字段集合(用于判断是否需要 reloadAdapter) + const LLM_KEYS = ['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx', + 'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow']; + + // 第一步:逐条写入 configService + 审计日志 + // 不在此处触发 reloadAdapter,避免中间态失败 + let needsReloadAdapter = false; + const engineUpdates: { key: string; value: unknown }[] = []; + let workspacePathValue: string | null = null; + let logLevelValue: 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null = null; + + // v0.3.10 防御性修复: Provider 切换时清空 API key 的逻辑必须在循环前执行, + // 不能依赖 entries 中 llm.provider 出现在 llm.apiKey 之前。 + // 旧实现:若前端误把 llm.apiKey 放在 llm.provider 之前,会先 set apiKey, + // 随后处理 llm.provider 时清空 apiKey,导致用户填写的 apiKey 丢失。 + // 新实现:先扫描 entries 找出 llm.provider 的值,与当前值比较,若变化则清空 apiKey, + // 然后循环按 entries 顺序 set(包括 llm.apiKey),最终值正确。 + const providerEntry = (entries as Array<{ key: string; value: unknown }>) + .find((e) => e.key === 'llm.provider'); + if (providerEntry) { + const oldProvider = configService.get('llm.provider') ?? ''; + const newProvider = (providerEntry.value as string) ?? ''; + if (oldProvider && newProvider && oldProvider !== newProvider) { + configService.set('llm.apiKey', ''); + log.info(`[CONFIG] Provider changed (${oldProvider} → ${newProvider}), API key cleared (batch mode)`); + } + } + + for (const entry of entries) { + const { key, value } = entry as { key: string; value: unknown }; + + configService.set(key, value); + needsReloadAdapter = needsReloadAdapter || LLM_KEYS.includes(key); + + // 收集 Engine/Orchestrator 相关更新(稍后统一应用) + if (key === 'agent.maxIterations' || key === 'agent.totalTimeoutMs' || + key === 'agent.enableThinking' || key === 'agent.thinkingEffort' || + key === 'ollama.numCtx' || key === 'deepseek.contextWindow' || + key === 'agnes.contextWindow' || key === 'mimo.contextWindow' || + key === 'agent.toolExecutionTimeoutMs' || key === 'agent.confirmationTimeoutMs') { + engineUpdates.push({ key, value }); + } + + if (key === 'workspace.path' && typeof value === 'string') { + workspacePathValue = value; + } + if (key === 'logging.level' && typeof value === 'string') { + logLevelValue = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly'; + } + + // 审计日志(脱敏) + auditService.log({ + sessionId: '', + eventType: 'config_change', + actor: 'user', + target: key, + details: { value: maskSensitive(key, value) }, + outcome: 'success', + }); + } + + // 第二步:统一触发一次 reloadAdapter(仅在 LLM 配置变更时) + if (needsReloadAdapter) { + const reloadSuccess = reloadAdapter(); + if (!reloadSuccess) { + log.warn('[CONFIG] Adapter reload failed after batch config save'); + return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' }; + } else { + log.info('[CONFIG] LLM config batch changed, adapter reloaded'); + } + } + + // 第三步:统一应用 Engine/Orchestrator 更新(取每个 key 的最后值) + // 注意:reloadAdapter 内部已重建 adapter 并同步 contextWindow,此处仅处理非 LLM 字段 + // 的 Engine 配置(maxIterations/totalTimeoutMs/enableThinking 等) + for (const { key, value } of engineUpdates) { + if (key === 'agent.maxIterations') { + agentLoop.updateConfig({ maxIterations: value as number }); + } else if (key === 'agent.totalTimeoutMs') { + agentLoop.updateConfig({ totalTimeoutMs: value as number }); + } else if (key === 'agent.enableThinking') { + agentLoop.updateConfig({ thinkingEnabled: value as boolean }); + orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean }); + } else if (key === 'agent.thinkingEffort') { + agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' }); + orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' }); + } else if (key === 'ollama.numCtx') { + agentLoop.updateConfig({ contextLength: (value as number) || undefined }); + orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); + } else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow' || key === 'mimo.contextWindow') { + const ctxWindow = (value as number) || undefined; + agentLoop.updateConfig({ contextWindow: ctxWindow }); + orchestrator.updateDefaultConfig({ contextWindow: ctxWindow }); + } else if (key === 'agent.confirmationTimeoutMs') { + confirmationHook.setConfirmationTimeout(value as number); + } else if (key === 'agent.toolExecutionTimeoutMs') { + agentLoop.updateConfig({ toolExecutionTimeoutMs: value as number }); + } + } + if (engineUpdates.length > 0) { + log.info(`[CONFIG] Engine/Orchestrator updated with ${engineUpdates.length} config changes (batch mode)`); + } + + // 第四步:日志级别即时应用 + if (logLevelValue) { + log.transports.file.level = logLevelValue; + log.transports.console.level = logLevelValue; + log.info(`[CONFIG] Log level updated to ${logLevelValue}`); + } + + // 第五步:工作空间路径写入独立文件(下次启动生效) + if (workspacePathValue) { + try { + const { writeWorkspacePathToFile } = await import('../main'); + writeWorkspacePathToFile(workspacePathValue); + log.info(`[CONFIG] Workspace path saved (restart required): ${workspacePathValue}`); + } catch (err) { + log.error('[CONFIG] Failed to save workspace path:', err); + // v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径 + return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` }; } } diff --git a/electron/preload.ts b/electron/preload.ts index d26db5b..8453741 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -121,6 +121,9 @@ const metonaAPI = { config: { get: (key: string) => ipcRenderer.invoke('config:get', key), set: (key: string, value: unknown) => ipcRenderer.invoke('config:set', key, value), + // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 + setBatch: (entries: Array<{ key: string; value: unknown }>) => + ipcRenderer.invoke('config:setBatch', entries), }, // ===== 应用工具 ===== diff --git a/package-lock.json b/package-lock.json index 91af3b6..04eed08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.9", + "version": "0.3.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.9", + "version": "0.3.10", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 925da77..dea156c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.9", + "version": "0.3.10", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 29431eb..84b486e 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -26,23 +26,25 @@ export function OnboardingWizard(): React.JSX.Element | null { const handleNext = async () => { if (step < STEPS.length - 1) { setStep(step + 1); return; } try { - if (window.metona?.config) { - const configSets: Promise[] = []; - if (provider.trim()) configSets.push(window.metona.config.set('llm.provider', provider.trim())); - if (baseURL.trim()) configSets.push(window.metona.config.set('llm.baseURL', baseURL.trim())); - if (model.trim()) configSets.push(window.metona.config.set('llm.model', model.trim())); - if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim())); - if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim())); - configSets.push(window.metona.config.set('onboarding.completed', true)); - // M-26 修复: 改用 Promise.allSettled,单个配置写入失败不阻止 onboarding 完成 - // 但 onboarding.completed 必须成功,否则用户重启后仍会看到引导 - const results = await Promise.allSettled(configSets); - const failedCount = results.filter((r) => r.status === 'rejected').length; - if (failedCount > 0) { - console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`); - // 用户主动操作失败必须有反馈,否则按钮看起来无响应 - import('metona-toast').then((mod) => mod.default.error(`部分配置保存失败(${failedCount} 项),请重试`)).catch(() => {}); - // 不调用 setOnboardingCompleted(true),让用户重试 + if (window.metona?.config?.setBatch) { + // v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败 + // 旧实现问题:Promise.allSettled 并行 6 个 config.set, + // - llm.provider 写入会清空 llm.apiKey(与 llm.apiKey 写入竞态) + // - reloadAdapter 被调用 4 次,中间态必然失败 + // - 代码只检查 status === 'rejected',完全忽略 { success: false } 的情况 + // - 实际配置失败但前端显示成功并关闭向导 + const entries: Array<{ key: string; value: unknown }> = []; + if (provider.trim()) entries.push({ key: 'llm.provider', value: provider.trim() }); + if (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() }); + if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() }); + if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() }); + if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() }); + entries.push({ key: 'onboarding.completed', value: true }); + + const r = await window.metona.config.setBatch(entries); + if (r && !r.success) { + console.error('[OnboardingWizard]', 'Batch config save failed:', r.error); + import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {}); return; } useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || ''); diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 928edbb..c6a32a5 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -440,35 +440,29 @@ function LLMSettings() { } setSaving(true); try { - const setConfig = window.metona?.config?.set; - if (!setConfig) { + const setBatch = window.metona?.config?.setBatch; + if (!setBatch) { import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {}); return; } - // 串行保存:避免并发 IPC 调用(reloadAdapter 内部 lastConfigSig 比较会跳过中间态的重载) - const fields: Array<[string, unknown]> = [ - ['llm.provider', provider], - ['llm.model', model], - ['llm.apiKey', apiKey], - ['llm.baseURL', baseURL], - ['ollama.numCtx', numCtx], - ['deepseek.contextWindow', dsCtxWindow], - ['agnes.contextWindow', agnesCtxWindow], - ['mimo.contextWindow', mimoCtxWindow], + // v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败 + // 旧实现:串行 config:set 8 次,provider 切换后第 1 步会清空 apiKey, + // 此时 reloadAdapter 读到空 apiKey 返回 false,前端 toast 报"配置不全", + // 但所有字段实际已写入,第二次点保存才显示"已保存"。 + // 新实现:一次性传所有字段,后端先写入全部,最后统一 reloadAdapter 一次。 + const entries: Array<{ key: string; value: unknown }> = [ + { key: 'llm.provider', value: provider }, + { key: 'llm.model', value: model }, + { key: 'llm.apiKey', value: apiKey }, + { key: 'llm.baseURL', value: baseURL }, + { key: 'ollama.numCtx', value: numCtx }, + { key: 'deepseek.contextWindow', value: dsCtxWindow }, + { key: 'agnes.contextWindow', value: agnesCtxWindow }, + { key: 'mimo.contextWindow', value: mimoCtxWindow }, ]; - let firstError: string | null = null; - for (const [key, value] of fields) { - try { - const r = await setConfig(key, value); - if (r && !r.success && !firstError) { - firstError = r.error ?? '配置保存失败'; - } - } catch (err) { - if (!firstError) firstError = (err as Error).message; - } - } - if (firstError) { - import('metona-toast').then((mod) => mod.default.error(firstError!)).catch(() => {}); + const r = await setBatch(entries); + if (r && !r.success) { + import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {}); } else { import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {}); } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index efc7d88..695dc12 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -162,6 +162,8 @@ interface MetonaMemoryAPI { interface MetonaConfigAPI { get: (key: string) => Promise; set: (key: string, value: unknown) => Promise<{ success: boolean }>; + // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 + setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean }>; } // ===== App API =====