P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
224 lines
9.0 KiB
TypeScript
224 lines
9.0 KiB
TypeScript
/**
|
||
* Provider Adapter — 基类
|
||
*
|
||
* 所有 Provider 适配器共享的基类逻辑:
|
||
* - 请求超时处理(含显式的网络超时错误分类)
|
||
* - 内容审核错误类型
|
||
*
|
||
* v0.6.4 P3-2 错误分类单轨化:
|
||
* 此前本类存在两套互相漂移的错误分类器 —— `mapError`(protected,生产路径
|
||
* 无任何调用方,仅测试引用)与 engine.isRetryableError(真正生效)。运行时
|
||
* 行为由后者单独决定,导致 v0.4.1/#23 的映射修复只体现在测试里。
|
||
* 现已删除 mapError 与废弃的 getFetchSignal:错误分类的唯一事实来源是
|
||
* engine.isRetryableError(读取 error.status / error.code / message),
|
||
* 本层负责保证抛出的错误携带可判定的结构化字段:
|
||
* - HTTP 非 2xx → throwHttpError 挂 status
|
||
* - 本方法超时 → code='ETIMEDOUT' + 'timed out' message
|
||
* - content_filter → ContentFilterError 实例
|
||
*/
|
||
|
||
import type {
|
||
IMetonaProviderAdapter,
|
||
AdapterConfig,
|
||
MetonaRequest,
|
||
MetonaResponse,
|
||
MetonaStreamEvent,
|
||
} from '../types';
|
||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||
|
||
/**
|
||
* 内容审核错误 — Provider 的安全过滤策略触发的错误
|
||
*
|
||
* 当 Provider(如 MiMo)的 API 返回 content_filter 错误码时抛出。
|
||
* Engine 识别此错误后映射为 MetonaErrorCode.CONTENT_FILTERED,
|
||
* 前端显示友好提示而非原始 JSON 错误体。
|
||
*/
|
||
export class ContentFilterError extends Error {
|
||
/** Provider 返回的原始错误消息(如 "The request was rejected because it was considered high risk") */
|
||
readonly providerMessage: string;
|
||
|
||
constructor(providerMessage: string, context: string) {
|
||
super(`${context}: 内容被 Provider 安全审核拦截 - ${providerMessage}`);
|
||
this.name = 'ContentFilterError';
|
||
this.providerMessage = providerMessage;
|
||
}
|
||
}
|
||
|
||
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;
|
||
// 默认值仅是兜底 —— 子类应声明真实的模型级窗口,避免压缩阈值计算失真
|
||
return 1_000_000;
|
||
}
|
||
|
||
/**
|
||
* C-2 修复: 注入外部 AbortSignal
|
||
* Engine 在调用 send/sendStream 前调用此方法,关联 abortController
|
||
*/
|
||
setAbortSignal(signal: AbortSignal | undefined): void {
|
||
this.externalAbortSignal = signal;
|
||
}
|
||
|
||
/**
|
||
* #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏
|
||
*
|
||
* v0.6.4 P3-2(错误分类单轨化): 本方法自身触发的超时不再以裸 DOMException
|
||
* (消息不含 timeout 字样、被引擎误归 UNKNOWN 后仅因含 "aborted" 碰巧可重试)
|
||
* 冒泡 —— 显式转译为带 ETIMEDOUT code 的 Error,使其进入 engine.isRetryableError
|
||
* 的网络超时判定分支,与其他网络错误同轨。用户主动中断(外部信号)则原样抛出
|
||
* AbortError —— 引擎 chatStreamWithRetry 入口由 this.aborted 拦截,不会误触发重试。
|
||
*
|
||
* 已知边界(设计取舍,注明而非隐藏):响应头返回后 clearTimeout,后续 SSE 流体
|
||
* 不再受本超时约束;长挂流由引擎 totalTimeoutMs 兜底。中止时通过 removeEventListener
|
||
* 解除外部信号监听 —— 流式消费阶段外部 abort 不再打断底层连接(消费方停止拉取即终结)。
|
||
*
|
||
* @param url 请求 URL
|
||
* @param init fetch init(不含 signal,由本方法内部管理)
|
||
* @param timeoutMs 超时时间(毫秒)
|
||
*/
|
||
protected async fetchWithTimeout(
|
||
url: string,
|
||
init: RequestInit,
|
||
timeoutMs: number,
|
||
): Promise<Response> {
|
||
const controller = new AbortController();
|
||
let timedOut = false;
|
||
const timer = setTimeout(() => {
|
||
timedOut = true;
|
||
controller.abort();
|
||
}, timeoutMs);
|
||
|
||
// 审查修复 M20: 保存 listener 引用,finally 中 removeEventListener 清理,避免 listener 泄漏
|
||
const onExternalAbort = () => controller.abort();
|
||
|
||
try {
|
||
// 合并外部 abort signal(来自 Engine 的 abortController)
|
||
if (this.externalAbortSignal) {
|
||
if (this.externalAbortSignal.aborted) {
|
||
controller.abort();
|
||
} else {
|
||
this.externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||
}
|
||
}
|
||
|
||
return await fetch(url, { ...init, signal: controller.signal });
|
||
} catch (err) {
|
||
// 区分中止来源:
|
||
// a) 本方法超时且非外部中断 → 归类为可重试的网络超时(ETIMEDOUT)
|
||
// b) 外部信号 abort(用户中断)→ 原样抛 AbortError
|
||
// c) 底层网络错误 → 原样抛出
|
||
const externalAborted = this.externalAbortSignal?.aborted === true;
|
||
if (timedOut && !externalAborted) {
|
||
const timeoutError = new Error(
|
||
`Request timed out after ${timeoutMs}ms (url=${String(url).slice(0, 120)})`,
|
||
);
|
||
(timeoutError as Error & { code: string }).code = 'ETIMEDOUT';
|
||
throw timeoutError;
|
||
}
|
||
throw err;
|
||
} finally {
|
||
// #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer
|
||
clearTimeout(timer);
|
||
// 审查修复 M20: 清理 externalAbortSignal 上注册的 listener
|
||
if (this.externalAbortSignal) {
|
||
this.externalAbortSignal.removeEventListener('abort', onExternalAbort);
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
*
|
||
* v0.3.17: 解析 Provider 返回的 JSON 错误体,识别 content_filter 错误码,
|
||
* 抛出 ContentFilterError 让 Engine 映射为 CONTENT_FILTERED 错误码。
|
||
*/
|
||
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||
let errorBody = '';
|
||
try {
|
||
errorBody = await response.text();
|
||
} catch {
|
||
/* body 可能已消费或为 null */
|
||
}
|
||
|
||
// v0.3.17: 解析 JSON 错误体,识别 content_filter
|
||
if (errorBody) {
|
||
try {
|
||
const parsed = JSON.parse(errorBody);
|
||
// 兼容 OpenAI 格式 {error: {code, message}} 和 MiMo/其他格式
|
||
const errObj = parsed?.error ?? parsed;
|
||
const errorCode = errObj?.code ?? errObj?.type ?? '';
|
||
const errorMessage = errObj?.message ?? '';
|
||
|
||
if (typeof errorCode === 'string' && errorCode.toLowerCase().includes('content_filter')) {
|
||
const providerMsg = errorMessage || '内容触发安全过滤策略';
|
||
const cfError = new ContentFilterError(providerMsg, context);
|
||
(cfError as ContentFilterError & { status: number }).status = response.status;
|
||
throw cfError;
|
||
}
|
||
} catch (parseErr) {
|
||
// JSON 解析失败(非 JSON 响应体),走原逻辑
|
||
if (parseErr instanceof ContentFilterError) throw parseErr;
|
||
}
|
||
}
|
||
|
||
// v0.6.4: 巨大 HTML 错误页整体拼进消息会造成日志/事件载荷爆炸 —— 截断到合理长度
|
||
const safeBody =
|
||
errorBody.length > 500 ? `${errorBody.slice(0, 500)}…[truncated ${errorBody.length} chars]` : errorBody;
|
||
|
||
const error = new Error(`${context}: ${response.status} ${response.statusText}${safeBody ? ` - ${safeBody}` : ''}`);
|
||
(error as Error & { status: number }).status = response.status;
|
||
throw error;
|
||
}
|
||
}
|