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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
|
|||||||
@@ -756,6 +756,169 @@ export function registerAllIPCHandlers(
|
|||||||
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
|
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('[CONFIG] Failed to save workspace path:', 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}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ const metonaAPI = {
|
|||||||
config: {
|
config: {
|
||||||
get: (key: string) => ipcRenderer.invoke('config:get', key),
|
get: (key: string) => ipcRenderer.invoke('config:get', key),
|
||||||
set: (key: string, value: unknown) => ipcRenderer.invoke('config:set', key, value),
|
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),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 应用工具 =====
|
// ===== 应用工具 =====
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -26,23 +26,25 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (step < STEPS.length - 1) { setStep(step + 1); return; }
|
if (step < STEPS.length - 1) { setStep(step + 1); return; }
|
||||||
try {
|
try {
|
||||||
if (window.metona?.config) {
|
if (window.metona?.config?.setBatch) {
|
||||||
const configSets: Promise<unknown>[] = [];
|
// v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败
|
||||||
if (provider.trim()) configSets.push(window.metona.config.set('llm.provider', provider.trim()));
|
// 旧实现问题:Promise.allSettled 并行 6 个 config.set,
|
||||||
if (baseURL.trim()) configSets.push(window.metona.config.set('llm.baseURL', baseURL.trim()));
|
// - llm.provider 写入会清空 llm.apiKey(与 llm.apiKey 写入竞态)
|
||||||
if (model.trim()) configSets.push(window.metona.config.set('llm.model', model.trim()));
|
// - reloadAdapter 被调用 4 次,中间态必然失败
|
||||||
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim()));
|
// - 代码只检查 status === 'rejected',完全忽略 { success: false } 的情况
|
||||||
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
|
// - 实际配置失败但前端显示成功并关闭向导
|
||||||
configSets.push(window.metona.config.set('onboarding.completed', true));
|
const entries: Array<{ key: string; value: unknown }> = [];
|
||||||
// M-26 修复: 改用 Promise.allSettled,单个配置写入失败不阻止 onboarding 完成
|
if (provider.trim()) entries.push({ key: 'llm.provider', value: provider.trim() });
|
||||||
// 但 onboarding.completed 必须成功,否则用户重启后仍会看到引导
|
if (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() });
|
||||||
const results = await Promise.allSettled(configSets);
|
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
|
||||||
const failedCount = results.filter((r) => r.status === 'rejected').length;
|
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
|
||||||
if (failedCount > 0) {
|
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
|
||||||
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`);
|
entries.push({ key: 'onboarding.completed', value: true });
|
||||||
// 用户主动操作失败必须有反馈,否则按钮看起来无响应
|
|
||||||
import('metona-toast').then((mod) => mod.default.error(`部分配置保存失败(${failedCount} 项),请重试`)).catch(() => {});
|
const r = await window.metona.config.setBatch(entries);
|
||||||
// 不调用 setOnboardingCompleted(true),让用户重试
|
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;
|
return;
|
||||||
}
|
}
|
||||||
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
||||||
|
|||||||
@@ -440,35 +440,29 @@ function LLMSettings() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const setConfig = window.metona?.config?.set;
|
const setBatch = window.metona?.config?.setBatch;
|
||||||
if (!setConfig) {
|
if (!setBatch) {
|
||||||
import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
|
import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 串行保存:避免并发 IPC 调用(reloadAdapter 内部 lastConfigSig 比较会跳过中间态的重载)
|
// v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败
|
||||||
const fields: Array<[string, unknown]> = [
|
// 旧实现:串行 config:set 8 次,provider 切换后第 1 步会清空 apiKey,
|
||||||
['llm.provider', provider],
|
// 此时 reloadAdapter 读到空 apiKey 返回 false,前端 toast 报"配置不全",
|
||||||
['llm.model', model],
|
// 但所有字段实际已写入,第二次点保存才显示"已保存"。
|
||||||
['llm.apiKey', apiKey],
|
// 新实现:一次性传所有字段,后端先写入全部,最后统一 reloadAdapter 一次。
|
||||||
['llm.baseURL', baseURL],
|
const entries: Array<{ key: string; value: unknown }> = [
|
||||||
['ollama.numCtx', numCtx],
|
{ key: 'llm.provider', value: provider },
|
||||||
['deepseek.contextWindow', dsCtxWindow],
|
{ key: 'llm.model', value: model },
|
||||||
['agnes.contextWindow', agnesCtxWindow],
|
{ key: 'llm.apiKey', value: apiKey },
|
||||||
['mimo.contextWindow', mimoCtxWindow],
|
{ 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;
|
const r = await setBatch(entries);
|
||||||
for (const [key, value] of fields) {
|
if (r && !r.success) {
|
||||||
try {
|
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
|
||||||
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(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
|
import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -162,6 +162,8 @@ interface MetonaMemoryAPI {
|
|||||||
interface MetonaConfigAPI {
|
interface MetonaConfigAPI {
|
||||||
get: (key: string) => Promise<unknown>;
|
get: (key: string) => Promise<unknown>;
|
||||||
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
|
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
|
||||||
|
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
|
||||||
|
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== App API =====
|
// ===== App API =====
|
||||||
|
|||||||
Reference in New Issue
Block a user