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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user