feat: 升级至 v0.3.7 — 前后端状态同步与错误处理全量修复
核心引擎修复: - CE-1: 上下文压缩摘要 role 从 system 改为 user,避免被 adapter 过滤 - CE-2: 工具失败时优先使用 error 字段(engine/openai-format/ollama 三处) - P0-1: DeadLoopError 终止时正确传 error 参数,前端可见 ERROR 事件 - MT-1: 新增 waitForAbort 方法,abortSession 等待 run 结束再返回 - MT-2: TERMINATED 状态到达时标记步骤完成,避免 Trace Viewer 转圈 - MT-3: 压缩边界检测孤立 tool 消息,避免 API 400 错误 - isRetryableError 与 catch 分支统一 toLowerCase IPC 与主进程修复: - P0-2: createAdapter 配置缺失返回 null,FALLBACK_ADAPTER 兜底 - P0-3: reloadAdapter 失败返回 success:false 通知前端 - P1-5: 校验失败发 ERROR+DONE 流事件,防止 isStreaming 卡死 - P1-6: configLoaded 标志,配置加载前禁用发送按钮 - P1-7: MCP initialize 移到 agentLoop 后,完成后同步工具 - P2-11: beforeLoad 在 loadURL 前注册 IPC handler - P2-12: provider 切换竞态保护 前端状态同步修复: - clearSessions 后同步清空前端会话与消息状态 - clearMemories 通过 memoryVersion 触发 MemoryViewer 重新加载 - ContextMenu 4 个 session 操作补全 IPC 调用与 try/catch - useConfig 配置保存失败回滚 UI 并提示 - handleToggle 工具切换失败回滚单个工具状态 错误处理全量补全: - 所有 await window.metona 调用补全 try/catch 与 toast 反馈 - MCP addServer/toggleServer/removeServer 检查返回值 - showItemInFolder 检查返回值(handleOpen/handleOpenInFolder) - sse-stream/ollama NDJSON 解析失败改为 log.warn - adapter throwHttpError 带 status 属性供 isRetryableError 判断
This commit is contained in:
@@ -60,8 +60,7 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
throw new Error(`Agnes AI API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
||||
await this.throwHttpError(response, 'Agnes AI API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
@@ -102,11 +101,12 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Agnes AI stream error: ${response.status}`);
|
||||
await this.throwHttpError(response, 'Agnes AI stream error');
|
||||
}
|
||||
|
||||
yield* parseSSEStream(
|
||||
response.body,
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
response.body!,
|
||||
request.meta.requestId,
|
||||
request.meta.sessionId,
|
||||
request.meta.iteration,
|
||||
|
||||
@@ -111,6 +111,18 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
return this.supportedModels.map((id) => ({ id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
|
||||
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx)
|
||||
*/
|
||||
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||||
let errorBody = '';
|
||||
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
||||
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||
(error as Error & { status: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将原生错误映射为 MetonaError
|
||||
*/
|
||||
|
||||
@@ -65,8 +65,7 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
throw new Error(`DeepSeek API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
||||
await this.throwHttpError(response, 'DeepSeek API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
@@ -107,11 +106,13 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`DeepSeek stream error: ${response.status}`);
|
||||
await this.throwHttpError(response, 'DeepSeek stream error');
|
||||
}
|
||||
|
||||
yield* parseSSEStream(
|
||||
response.body,
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
// TypeScript 无法通过 await Promise<never> 正确收窄,需显式断言
|
||||
response.body!,
|
||||
request.meta.requestId,
|
||||
request.meta.sessionId,
|
||||
request.meta.iteration,
|
||||
|
||||
@@ -71,8 +71,7 @@ export class MimoAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
throw new Error(`MiMo API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
||||
await this.throwHttpError(response, 'MiMo API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
@@ -111,11 +110,12 @@ export class MimoAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`MiMo stream error: ${response.status}`);
|
||||
await this.throwHttpError(response, 'MiMo stream error');
|
||||
}
|
||||
|
||||
yield* parseSSEStream(
|
||||
response.body,
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
response.body!,
|
||||
request.meta.requestId,
|
||||
request.meta.sessionId,
|
||||
request.meta.iteration,
|
||||
|
||||
@@ -61,7 +61,7 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
await this.throwHttpError(response, 'Ollama API error');
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
@@ -81,10 +81,11 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Ollama stream error: ${response.status}`);
|
||||
await this.throwHttpError(response, 'Ollama stream error');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let seq = 0;
|
||||
let buffer = '';
|
||||
@@ -183,8 +184,9 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 跳过解析失败的行
|
||||
} catch (parseErr) {
|
||||
// P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断
|
||||
log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,9 +438,12 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
// 工具结果
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
msg.tool_call_id = m.toolResult.toolCallId;
|
||||
msg.content = typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||
msg.content = m.toolResult.error
|
||||
? m.toolResult.error
|
||||
: (typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result));
|
||||
}
|
||||
// assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
|
||||
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||
|
||||
@@ -67,10 +67,13 @@ export function buildOpenAICompatibleMessages(
|
||||
// === 工具执行结果 ===
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
msg.tool_call_id = m.toolResult.toolCallId;
|
||||
msg.content =
|
||||
typeof m.toolResult.result === 'string'
|
||||
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||
// 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环
|
||||
msg.content = m.toolResult.error
|
||||
? m.toolResult.error
|
||||
: (typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
: JSON.stringify(m.toolResult.result));
|
||||
}
|
||||
|
||||
return msg;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import log from 'electron-log';
|
||||
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
|
||||
import { MetonaStreamEventType } from '../../types';
|
||||
|
||||
@@ -198,8 +199,9 @@ export async function* parseSSEStream(
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
}
|
||||
} catch {
|
||||
// 跳过解析失败的行
|
||||
} catch (parseErr) {
|
||||
// P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏
|
||||
log.warn(`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`, line.slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
for (const result of step.toolResults) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: typeof result.result === 'string' ? result.result : JSON.stringify(result.result),
|
||||
// CE-2 修复: 工具失败时 result.result 为 null,LLM 会看到 "null" 而非错误信息
|
||||
// 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环
|
||||
content: result.error
|
||||
? result.error
|
||||
: (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)),
|
||||
toolResult: result,
|
||||
timestamp: Date.now(),
|
||||
iteration: this.currentIteration,
|
||||
@@ -274,11 +278,16 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return this.finish(TerminationReason.TIMEOUT);
|
||||
} catch (error) {
|
||||
// v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止
|
||||
// P0-1 审查修复: 必须传 error 参数(第三参数),否则 finish() 不会发射 ERROR 事件
|
||||
// 前端只收到 DONE 会导致 agentStatus 被设为 'idle' 而非 'error',且无错误消息卡片
|
||||
if (error instanceof DeadLoopError) {
|
||||
return this.finish(TerminationReason.DEAD_LOOP, error.message);
|
||||
return this.finish(TerminationReason.DEAD_LOOP, undefined, error as Error);
|
||||
}
|
||||
const errMsg = (error as Error).message;
|
||||
if (this.aborted || errMsg.includes('Aborted')) {
|
||||
const errMsg = (error as Error).message ?? '';
|
||||
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
|
||||
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
|
||||
const errMsgLower = errMsg.toLowerCase();
|
||||
if (this.aborted || errMsgLower.includes('aborted') || errMsgLower.includes('timed out') || errMsgLower.includes('timeout')) {
|
||||
return this.finish(TerminationReason.USER_INTERRUPT);
|
||||
}
|
||||
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw,
|
||||
@@ -310,6 +319,29 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.emit('aborted');
|
||||
}
|
||||
|
||||
/**
|
||||
* MT-1 修复: 等待当前 run 结束(用于 abortSession IPC handler)
|
||||
*
|
||||
* abort() 只是设置了标志和触发了 abortController,
|
||||
* 但 currentRunPromise 仍在进行中(可能在等待工具超时或 LLM 响应)。
|
||||
* 如果不等待就返回,用户立即重发会导致新消息卡在 runStream 的 currentRunPromise 等待中。
|
||||
*
|
||||
* @param timeoutMs 等待超时(默认 5 秒,防止永久挂起)
|
||||
* @returns true 表示 run 已结束,false 表示等待超时
|
||||
*/
|
||||
async waitForAbort(timeoutMs: number = 5_000): Promise<boolean> {
|
||||
if (!this.currentRunPromise) return true;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.currentRunPromise.catch(() => {}),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁引擎,清理所有监听器
|
||||
*/
|
||||
@@ -854,8 +886,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||||
// 网络超时/连接错误 — 可重试
|
||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
||||
// SSE 流中断 — 可重试
|
||||
if (err.message?.includes('aborted') || err.message?.includes('socket hang up')) return true;
|
||||
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
|
||||
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
|
||||
const msg = err.message?.toLowerCase() ?? '';
|
||||
if (msg.includes('aborted') || msg.includes('socket hang up')) return true;
|
||||
// 其他错误(400/401/403/4xx)不重试
|
||||
return false;
|
||||
}
|
||||
@@ -954,8 +988,34 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool)
|
||||
if (messages.length <= keepRecent) return null;
|
||||
|
||||
const toCompress = messages.slice(0, messages.length - keepRecent);
|
||||
const toKeep = messages.slice(messages.length - keepRecent);
|
||||
let toCompress = messages.slice(0, messages.length - keepRecent);
|
||||
let toKeep = messages.slice(messages.length - keepRecent);
|
||||
|
||||
// MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头
|
||||
// OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息
|
||||
// 如果压缩边界正好切在 assistant(tool_calls) 和 tool 之间,下一轮 LLM 调用会返回 400
|
||||
if (toKeep.length > 0 && toKeep[0].role === 'tool' && toCompress.length > 0) {
|
||||
// 反向查找最近的带 tool_calls 的 assistant 消息
|
||||
let assistantIdx = -1;
|
||||
for (let i = toCompress.length - 1; i >= 0; i--) {
|
||||
if (toCompress[i].role === 'assistant' && toCompress[i].toolCalls?.length) {
|
||||
assistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (assistantIdx < 0) {
|
||||
// 找不到配对的 assistant 消息,直接丢弃孤立的 tool 消息
|
||||
toKeep = toKeep.slice(1);
|
||||
} else {
|
||||
// 将配对的 assistant 消息及其之后的所有消息移到 toKeep 开头
|
||||
const moved = toCompress.slice(assistantIdx);
|
||||
toCompress = toCompress.slice(0, assistantIdx);
|
||||
toKeep = [...moved, ...toKeep];
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 toCompress 为空,无法生成摘要
|
||||
if (toCompress.length === 0) return null;
|
||||
|
||||
// 构建摘要请求
|
||||
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
|
||||
@@ -998,7 +1058,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (!summary) return null;
|
||||
|
||||
const summaryMessage: MetonaMessage = {
|
||||
role: 'system',
|
||||
// CE-1 修复: 使用 role: 'user' 而非 'system'
|
||||
// adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息,
|
||||
// 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效。
|
||||
// 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep
|
||||
role: 'user',
|
||||
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
@@ -1023,6 +1087,26 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
answer?: string,
|
||||
error?: Error,
|
||||
): AgentLoopOutput {
|
||||
// P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误
|
||||
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
|
||||
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
|
||||
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: this.currentRequestId,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
runId: this.runId,
|
||||
error: {
|
||||
code: MetonaErrorCode.UNKNOWN,
|
||||
message: error.message,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
@@ -1032,6 +1116,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
runId: this.runId,
|
||||
terminationReason: reason,
|
||||
});
|
||||
|
||||
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
||||
|
||||
@@ -130,6 +130,9 @@ export interface MetonaStreamEvent {
|
||||
|
||||
/** ERROR */
|
||||
error?: MetonaError;
|
||||
|
||||
/** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */
|
||||
terminationReason?: string;
|
||||
}
|
||||
|
||||
// ===== 思考内容 =====
|
||||
|
||||
@@ -59,12 +59,31 @@ export function registerAllIPCHandlers(
|
||||
|
||||
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
||||
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
||||
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
const errorEvent: MetonaStreamEvent = {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: '', sessionId: sessionId ?? '', iteration: 0, seq: 0, timestamp: Date.now(),
|
||||
error: { code: MetonaErrorCode.UNKNOWN, message: '无效的会话 ID', retryable: false },
|
||||
};
|
||||
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||||
mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
|
||||
}
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
const errorEvent: MetonaStreamEvent = {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(),
|
||||
error: { code: MetonaErrorCode.UNKNOWN, message: '无效的消息格式', retryable: false },
|
||||
};
|
||||
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||||
mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
|
||||
}
|
||||
return { success: false, error: 'Invalid message format' };
|
||||
}
|
||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||
@@ -404,6 +423,9 @@ export function registerAllIPCHandlers(
|
||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||
log.info('[AGENT] Abort:', sessionId);
|
||||
agentLoop.abort();
|
||||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在
|
||||
// runStream 的 currentRunPromise 等待中(最长卡 120s 工具超时)
|
||||
await agentLoop.waitForAbort();
|
||||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||||
confirmationHook.clearPending();
|
||||
|
||||
@@ -670,10 +692,11 @@ export function registerAllIPCHandlers(
|
||||
// v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow / mimo.contextWindow,使上下文窗口配置变化也触发热重载
|
||||
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
|
||||
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'].includes(key)) {
|
||||
// C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端
|
||||
// P0-3 修复: reloadAdapter 失败时返回 success:false,让前端知道配置不完整
|
||||
const reloadSuccess = reloadAdapter();
|
||||
if (!reloadSuccess) {
|
||||
log.warn(`[CONFIG] Adapter reload failed after ${key} change`);
|
||||
return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' };
|
||||
} else {
|
||||
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
|
||||
}
|
||||
|
||||
+47
-18
@@ -38,6 +38,7 @@ import { DeepSeekAdapter } from './harness/adapters/deepseek.adapter';
|
||||
import { AgnesAdapter } from './harness/adapters/agnes-ai.adapter';
|
||||
import { MimoAdapter } from './harness/adapters/mimo.adapter';
|
||||
import { OllamaAdapter } from './harness/adapters/ollama.adapter';
|
||||
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
|
||||
import {
|
||||
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
||||
WebSearchTool, WebFetchTool,
|
||||
@@ -140,18 +141,22 @@ async function initialize(): Promise<void> {
|
||||
memoryManager.initialize();
|
||||
|
||||
// ===== 步骤 4: Provider Adapter 工厂 =====
|
||||
const createAdapter = () => {
|
||||
// P0-2 修复: 配置缺失时返回 null 而非抛异常,让应用能启动到 Onboarding
|
||||
const createAdapter = (): IMetonaProviderAdapter | null => {
|
||||
const provider = configService.get<string>('llm.provider') ?? '';
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
const apiKey = configService.get<string>('llm.apiKey') ?? '';
|
||||
const baseURL = configService.get<string>('llm.baseURL') ?? '';
|
||||
|
||||
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding)
|
||||
if (!provider || !baseURL || !model) {
|
||||
log.warn('LLM not configured. Please set provider, baseURL, and model in Settings.');
|
||||
log.warn('LLM not fully configured. Please set provider, baseURL, and model in Settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!apiKey && provider !== 'ollama') {
|
||||
throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`);
|
||||
log.warn(`API key is required for provider "${provider}". Please set it in Settings.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
|
||||
@@ -168,7 +173,12 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
const adapter = createAdapter();
|
||||
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但 P0-1 修复后前端可见
|
||||
const FALLBACK_ADAPTER = new DeepSeekAdapter({
|
||||
provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096,
|
||||
});
|
||||
|
||||
const adapter = createAdapter() ?? FALLBACK_ADAPTER;
|
||||
|
||||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||||
const contextBuilder = new ContextBuilder();
|
||||
@@ -242,9 +252,7 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// ===== MCP Manager =====
|
||||
const mcpManager = new MCPManager(() => db, toolRegistry);
|
||||
mcpManager.initialize().catch((err) => {
|
||||
log.warn('MCP Manager initialization error:', err);
|
||||
});
|
||||
// P1-7 修复: initialize() 移到 agentLoop 创建之后,完成后重新同步工具
|
||||
|
||||
// ===== Hooks =====
|
||||
// v0.2.0: ConfirmationHook 注入到 preToolHooks 管道
|
||||
@@ -285,6 +293,14 @@ async function initialize(): Promise<void> {
|
||||
agentLoop.setTools(toolRegistry.listTools());
|
||||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||||
|
||||
// P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop
|
||||
mcpManager.initialize().then(() => {
|
||||
agentLoop.setTools(toolRegistry.listTools());
|
||||
log.info('[MCP] Tools registered and synced to AgentLoop');
|
||||
}).catch((err) => {
|
||||
log.warn('MCP Manager initialization error:', err);
|
||||
});
|
||||
|
||||
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
||||
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
||||
|
||||
@@ -343,6 +359,17 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// 配置变化 — 重建 adapter
|
||||
const newAdapter = createAdapter();
|
||||
if (!newAdapter) {
|
||||
log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)');
|
||||
// 不更新 lastConfigSig,下次还会尝试
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'warning',
|
||||
message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model',
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
agentLoop.setAdapter(newAdapter);
|
||||
memoryConsolidator.setAdapter(newAdapter);
|
||||
// Provider 切换时同步 contextLength 和 contextWindow
|
||||
@@ -399,10 +426,21 @@ async function initialize(): Promise<void> {
|
||||
id: 'main',
|
||||
workspacePath: workspaceInfo.path,
|
||||
title: 'MetonaAI Desktop',
|
||||
// P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口
|
||||
beforeLoad: (win) => {
|
||||
confirmationHook.setMainWindow(win);
|
||||
registerAllIPCHandlers(
|
||||
win, sessionService, configService, workspaceService,
|
||||
contextBuilder, agentLoop, toolRegistry, auditService,
|
||||
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
|
||||
promptDefender, outputValidator,
|
||||
confirmationHook, memoryConsolidator, orchestrator,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// v0.2.0: 为 ConfirmationHook 注入主窗口(用于向渲染进程发送确认请求)
|
||||
confirmationHook.setMainWindow(mainWindow);
|
||||
// P2-11 修复: ConfirmationHook.setMainWindow 已在 beforeLoad 中调用,此处无需重复
|
||||
// (beforeLoad 在 loadURL 之前执行,确保 IPC handler 注册时 mainWindow 已注入)
|
||||
|
||||
// ===== 系统托盘 =====
|
||||
const resourcesPath = join(__dirname, '../../assets');
|
||||
@@ -432,15 +470,6 @@ async function initialize(): Promise<void> {
|
||||
);
|
||||
});
|
||||
|
||||
// ===== 注册 IPC =====
|
||||
registerAllIPCHandlers(
|
||||
mainWindow, sessionService, configService, workspaceService,
|
||||
contextBuilder, agentLoop, toolRegistry, auditService,
|
||||
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
|
||||
promptDefender, outputValidator,
|
||||
confirmationHook, memoryConsolidator, orchestrator,
|
||||
);
|
||||
|
||||
// TODO: Initialize UpdateService for auto-update functionality
|
||||
// const updateService = new UpdateService();
|
||||
// updateService.initialize(mainWindow);
|
||||
|
||||
@@ -36,6 +36,7 @@ export class WindowManager {
|
||||
workspacePath?: string;
|
||||
title?: string;
|
||||
state?: WindowState;
|
||||
beforeLoad?: (win: BrowserWindow) => void;
|
||||
}): BrowserWindow {
|
||||
const id = options.id ?? `window_${Date.now()}`;
|
||||
const state = options.state ?? { width: 1440, height: 900 };
|
||||
@@ -59,6 +60,9 @@ export class WindowManager {
|
||||
},
|
||||
});
|
||||
|
||||
// P2-11 修复: 在 loadURL 之前执行回调,确保 IPC handler 在渲染进程加载前注册
|
||||
if (options.beforeLoad) options.beforeLoad(win);
|
||||
|
||||
// 加载页面
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||
|
||||
Reference in New Issue
Block a user