/** * IPC Shared — 配置写入的共享副作用逻辑(P2-9) * * 提取原 handlers.ts 中 config:set 与 config:setBatch 两处重复的: * 敏感值脱敏、Provider 切换清空 apiKey、Engine/Orchestrator 配置同步、 * 日志级别应用、配置变更广播、工作空间路径持久化。 */ import log from 'electron-log'; import type { IPCContext } from './context'; import { broadcast } from './context'; import { isSensitiveConfigKey } from '../utils/secure-config'; /** LLM 相关配置 key(变更时触发热重载 Adapter) */ export const LLM_CONFIG_KEYS = [ 'llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'llm.fallbackProvider', 'llm.fallbackModel', 'llm.fallbackApiKey', 'llm.fallbackBaseURL', 'ollama.numCtx', 'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow', 'openai.contextWindow', 'anthropic.contextWindow', ]; /** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */ export function maskSensitive(key: string, value: unknown): unknown { if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) { return value.length > 4 ? '***' + value.slice(-4) : '***'; } return value; } /** * 导出配置脱敏(S-1 修复):对 configService.getAll() 的结果逐 key 脱敏。 * * 背景:getAll() 对敏感 key 解密后返回明文(ConfigService 的读取契约), * data:export 直接透传会把明文 API Key / 认证密钥写入用户下载的 JSON 文件, * 绕过 safeStorage 密钥链加密。此函数确保任何导出路径不泄露明文密钥。 * * @param config configService.getAll() 的完整配置快照 * @returns 脱敏后的副本(敏感值替换为掩码,原对象不修改) */ export function sanitizeExportConfig(config: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(config)) { out[key] = maskSensitive(key, value); } return out; } /** * Provider 切换时清空 API key(C-1 修复,供 set/setBatch 共用) * * 必须在写入 entries 之前执行:若前端把 llm.apiKey 放在 llm.provider 之前, * 先 set apiKey 再处理 provider 会把用户刚填的 key 清空。 */ export function clearApiKeyOnProviderChange( ctx: IPCContext, entries: Array<{ key: string; value: unknown }>, ): void { const providerEntry = entries.find((e) => e.key === 'llm.provider'); if (!providerEntry) return; const oldProvider = ctx.configService.get('llm.provider') ?? ''; const newProvider = (providerEntry.value as string) ?? ''; if (oldProvider && newProvider && oldProvider !== newProvider) { ctx.configService.set('llm.apiKey', ''); log.info( `[CONFIG] Provider changed (${oldProvider} → ${newProvider}), API key cleared to prevent incompatible key usage`, ); } } /** * 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步) */ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void { const { agentEngineManager, orchestrator, confirmationHook } = ctx; switch (key) { case 'agent.maxIterations': agentEngineManager.updateConfigAll({ maxIterations: value as number }); break; case 'agent.totalTimeoutMs': agentEngineManager.updateConfigAll({ totalTimeoutMs: value as number }); break; case 'agent.enableThinking': agentEngineManager.updateConfigAll({ thinkingEnabled: value as boolean }); orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean }); break; case 'agent.thinkingEffort': agentEngineManager.updateConfigAll({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', }); orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', }); break; case 'agent.toolExecutionTimeoutMs': agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number }); break; case 'agent.confirmationTimeoutMs': confirmationHook.setConfirmationTimeout(value as number); break; case 'ollama.numCtx': agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined }); orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); break; case 'deepseek.contextWindow': case 'agnes.contextWindow': case 'mimo.contextWindow': case 'openai.contextWindow': case 'anthropic.contextWindow': // reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底) agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined }); orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined }); break; default: break; } } /** * 配置写入后的统一副作用(供 config:set / config:setBatch 共用) * * 1. LLM key 变更 → 统一 reloadAdapter 一次(避免中间态失败) * 2. Engine/Orchestrator 配置同步 * 3. 日志级别即时应用 * 4. 广播配置变更(前端 store 实时更新) * 5. 工作空间路径写独立文件(下次启动生效) * * @returns 错误信息(成功为 null) */ export async function applyConfigSideEffects( ctx: IPCContext, entries: Array<{ key: string; value: unknown }>, ): Promise { // 1. LLM 配置变更 → 统一热重载 Adapter(一次) if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) { if (!ctx.reloadAdapter()) { log.warn('[CONFIG] Adapter reload failed after config save'); return 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写'; } } // 2. Engine/Orchestrator/ConfirmationHook 配置同步 for (const { key, value } of entries) { applyEngineConfigKey(ctx, key, value); } // 3. 日志级别即时应用 const logLevelEntry = entries.find((e) => e.key === 'logging.level'); if (logLevelEntry && typeof logLevelEntry.value === 'string') { const { transports } = await import('electron-log'); transports.file.level = logLevelEntry.value as | 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly'; transports.console.level = logLevelEntry.value as | 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly'; log.info(`[CONFIG] Log level updated to ${logLevelEntry.value}`); } // 4. 广播所有配置变更事件(前端监听后更新 store) for (const { key, value } of entries) { broadcast('config:changed', { key, value }); } // 5. 工作空间路径写入独立文件(下次启动生效) const workspaceEntry = entries.find( (e) => e.key === 'workspace.path' && typeof e.value === 'string', ); if (workspaceEntry) { try { const { writeWorkspacePathToFile } = await import('../main'); writeWorkspacePathToFile(workspaceEntry.value as string); log.info(`[CONFIG] Workspace path saved (restart required): ${workspaceEntry.value}`); } catch (err) { log.error('[CONFIG] Failed to save workspace path:', err); // v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径 return `工作空间路径保存失败:${(err as Error).message}`; } } // v0.6.4 P4-5: 网络代理变更 → 重应用 session 级代理(default + agent-browser 分区) if (entries.some((e) => e.key === 'network.proxyUrl')) { const { applySessionProxy } = await import('../utils/network-proxy'); const proxyValue = entries.find((e) => e.key === 'network.proxyUrl')?.value; await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null); } return null; }