feat: 升级至 v0.3.10 — LLM 配置批量保存 + workspace.path 失败感知 + provider 切换防御性修复
- 新增 config:setBatch IPC:批量写入 8 个 LLM 字段后统一 reloadAdapter, 解决设置页串行 config:set 在中间态触发"LLM 配置不完整"错误的问题 - SettingsModal.handleSave 和 OnboardingWizard.handleNext 改用 setBatch - workspace.path 写入独立文件失败时返回 success:false(config:set 和 setBatch 一致), 避免用户误以为保存成功但下次启动仍使用旧路径 - setBatch 将 provider 切换清空 apiKey 的逻辑移到循环前执行, 消除对 entries 中 llm.provider 必须出现在 llm.apiKey 之前的隐含顺序依赖
This commit is contained in:
@@ -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<string>('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}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user