核心引擎修复: - 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 判断
181 lines
5.6 KiB
TypeScript
181 lines
5.6 KiB
TypeScript
/**
|
||
* Provider Adapter — 基类
|
||
*
|
||
* 所有 Provider 适配器共享的基类逻辑:
|
||
* - 请求超时处理
|
||
* - 错误映射到 MetonaError
|
||
* - 流式事件标准化
|
||
*/
|
||
|
||
import type {
|
||
IMetonaProviderAdapter,
|
||
AdapterConfig,
|
||
MetonaRequest,
|
||
MetonaResponse,
|
||
MetonaStreamEvent,
|
||
MetonaError,
|
||
} from '../types';
|
||
import { MetonaErrorCode } from '../types';
|
||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||
|
||
export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||
// H-2 修复: provider → providerId(规范要求)
|
||
abstract readonly providerId: string;
|
||
abstract readonly supportedModels: string[];
|
||
abstract readonly supportsToolCalling: boolean;
|
||
abstract readonly supportsThinking: boolean;
|
||
|
||
/**
|
||
* C-2 修复: 外部注入的 AbortSignal(来自 Engine 的 abortController)
|
||
*
|
||
* 用户点击中断时,Engine 调用 abortController.abort(),此信号触发后,
|
||
* 正在进行的 fetch 会被立即中断,避免资源泄漏。
|
||
*/
|
||
private externalAbortSignal: AbortSignal | undefined;
|
||
|
||
constructor(protected config: AdapterConfig) {}
|
||
|
||
// H-2 修复: chat → send(规范要求)
|
||
abstract send(request: MetonaRequest): Promise<MetonaResponse>;
|
||
// H-2 修复: chatStream → sendStream(规范要求)
|
||
abstract sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
|
||
|
||
/**
|
||
* H-2 修复: 获取上下文窗口大小(规范要求)
|
||
*
|
||
* 默认实现从 config 读取 contextWindow,子类可覆盖以支持动态查询。
|
||
* Engine 用此值估算上下文使用率,决定是否触发压缩。
|
||
*
|
||
* @returns 上下文窗口大小(token 数)
|
||
*/
|
||
getContextWindow(): number {
|
||
// 优先使用 AdapterConfig.contextWindow(如果存在)
|
||
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
|
||
if (typeof ctx === 'number' && ctx > 0) return ctx;
|
||
// 默认 1M(保守值,子类应覆盖)
|
||
return 1_000_000;
|
||
}
|
||
|
||
/**
|
||
* C-2 修复: 注入外部 AbortSignal
|
||
* Engine 在调用 send/sendStream 前调用此方法,关联 abortController
|
||
*/
|
||
setAbortSignal(signal: AbortSignal | undefined): void {
|
||
this.externalAbortSignal = signal;
|
||
}
|
||
|
||
/**
|
||
* C-2 修复: 合并外部 abort signal 和 timeout signal
|
||
*
|
||
* 使用 AbortSignal.any() 合并两个信号,任一触发都会中断 fetch:
|
||
* - timeout signal:防止请求挂起
|
||
* - external abort signal:用户主动中断
|
||
*
|
||
* @param timeoutMs 超时时间(毫秒)
|
||
* @returns 合并后的 AbortSignal
|
||
*/
|
||
protected getFetchSignal(timeoutMs: number): AbortSignal {
|
||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||
|
||
// 如果没有外部信号,直接使用 timeout signal
|
||
if (!this.externalAbortSignal) {
|
||
return timeoutSignal;
|
||
}
|
||
|
||
// 如果外部信号已经 abort,直接返回它
|
||
if (this.externalAbortSignal.aborted) {
|
||
return this.externalAbortSignal;
|
||
}
|
||
|
||
// 合并两个信号 — 任一触发都会 abort
|
||
// Node.js 20+ / Electron 35+ 支持 AbortSignal.any()
|
||
return AbortSignal.any([timeoutSignal, this.externalAbortSignal]);
|
||
}
|
||
|
||
async healthCheck(): Promise<boolean> {
|
||
try {
|
||
await this.listModels();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
|
||
*
|
||
* 默认实现将 supportedModels 映射为 MetonaModelInfo[],
|
||
* 子类可覆盖以从 API 获取完整元信息。
|
||
*/
|
||
async listModels(): Promise<MetonaModelInfo[]> {
|
||
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
|
||
*/
|
||
protected mapError(error: unknown): MetonaError {
|
||
if (error instanceof Error) {
|
||
const msg = error.message.toLowerCase();
|
||
|
||
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
|
||
return {
|
||
code: MetonaErrorCode.NETWORK_TIMEOUT,
|
||
message: error.message,
|
||
provider: this.providerId,
|
||
retryable: true,
|
||
retryAfterMs: 3000,
|
||
};
|
||
}
|
||
|
||
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) {
|
||
return {
|
||
code: MetonaErrorCode.NETWORK_ERROR,
|
||
message: error.message,
|
||
provider: this.providerId,
|
||
retryable: true,
|
||
retryAfterMs: 3000,
|
||
};
|
||
}
|
||
|
||
if (msg.includes('401') || msg.includes('unauthorized')) {
|
||
return {
|
||
code: MetonaErrorCode.AUTH_INVALID,
|
||
message: 'API key 无效或已过期',
|
||
provider: this.providerId,
|
||
retryable: false,
|
||
};
|
||
}
|
||
|
||
if (msg.includes('429') || msg.includes('rate limit')) {
|
||
return {
|
||
code: MetonaErrorCode.RATE_LIMITED,
|
||
message: '请求过于频繁,请稍后重试',
|
||
provider: this.providerId,
|
||
retryable: true,
|
||
retryAfterMs: 5000,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
code: MetonaErrorCode.UNKNOWN,
|
||
message: error instanceof Error ? error.message : 'Unknown error',
|
||
provider: this.providerId,
|
||
retryable: false,
|
||
};
|
||
}
|
||
}
|