1 Commits
Author SHA1 Message Date
thzxx cd681b949a 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 之前的隐含顺序依赖
2026-07-21 13:14:42 +08:00
8 changed files with 210 additions and 46 deletions
+1 -1
View File
@@ -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/)
+163
View File
@@ -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}` };
}
}
+3
View File
@@ -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),
},
// ===== 应用工具 =====
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+19 -17
View File
@@ -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<unknown>[] = [];
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() || '');
+19 -25
View File
@@ -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(() => {});
}
+2
View File
@@ -162,6 +162,8 @@ interface MetonaMemoryAPI {
interface MetonaConfigAPI {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean }>;
}
// ===== App API =====