P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
230 lines
9.2 KiB
TypeScript
230 lines
9.2 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;
|
||
// v0.7.4 P4-5: 兜底从 1M 降至 128K —— 旧默认值 1M 在 config 与模型元信息均缺失时
|
||
// (如 DeepSeek 未知模型),压缩阈值按 1M 算,实际 64K/128K 模型会先 413 再压缩。
|
||
// 128K 是当前最保守的主流窗口,未知模型按最小值预算更安全。
|
||
return 128_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;
|
||
}
|
||
}
|