feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
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 桥契约
This commit is contained in:
@@ -2,9 +2,19 @@
|
||||
* Provider Adapter — 基类
|
||||
*
|
||||
* 所有 Provider 适配器共享的基类逻辑:
|
||||
* - 请求超时处理
|
||||
* - 错误映射到 MetonaError
|
||||
* - 流式事件标准化
|
||||
* - 请求超时处理(含显式的网络超时错误分类)
|
||||
* - 内容审核错误类型
|
||||
*
|
||||
* 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 {
|
||||
@@ -13,9 +23,7 @@ import type {
|
||||
MetonaRequest,
|
||||
MetonaResponse,
|
||||
MetonaStreamEvent,
|
||||
MetonaError,
|
||||
} from '../types';
|
||||
import { MetonaErrorCode } from '../types';
|
||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||
|
||||
/**
|
||||
@@ -70,7 +78,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
// 优先使用 AdapterConfig.contextWindow(如果存在)
|
||||
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
|
||||
if (typeof ctx === 'number' && ctx > 0) return ctx;
|
||||
// 默认 1M(保守值,子类应覆盖)
|
||||
// 默认值仅是兜底 —— 子类应声明真实的模型级窗口,避免压缩阈值计算失真
|
||||
return 1_000_000;
|
||||
}
|
||||
|
||||
@@ -82,43 +90,18 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
this.externalAbortSignal = signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* C-2 修复: 合并外部 abort signal 和 timeout signal
|
||||
*
|
||||
* 使用 AbortSignal.any() 合并两个信号,任一触发都会中断 fetch:
|
||||
* - timeout signal:防止请求挂起
|
||||
* - external abort signal:用户主动中断
|
||||
*
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
* @returns 合并后的 AbortSignal
|
||||
* @deprecated 审查修复 M20: 使用 fetchWithTimeout 替代。
|
||||
* getFetchSignal 内部 AbortSignal.timeout() 创建的 timer 在请求成功完成后仍会存活到超时,
|
||||
* 高频调用下 timer 句柄累积;fetchWithTimeout 用 setTimeout + clearTimeout 已解决此问题。
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏
|
||||
*
|
||||
* getFetchSignal 使用 AbortSignal.timeout() 内部创建的 timer 在请求成功完成后
|
||||
* 仍会存活到超时,高频调用下 timer 句柄累积。本方法使用 setTimeout + clearTimeout
|
||||
* 确保 fetch 完成(无论成功/失败/abort)后立即清理 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,由本方法内部管理)
|
||||
@@ -130,7 +113,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
// 审查修复 M20: 保存 listener 引用,finally 中 removeEventListener 清理,避免 listener 泄漏
|
||||
const onExternalAbort = () => controller.abort();
|
||||
@@ -146,11 +133,24 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
}
|
||||
|
||||
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
|
||||
// (即使 { once: true },请求正常完成时 listener 仍挂在 signal 上直到 abort 或 GC,需显式移除)
|
||||
if (this.externalAbortSignal) {
|
||||
this.externalAbortSignal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
@@ -212,84 +212,12 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error(
|
||||
`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将原生错误映射为 MetonaError
|
||||
*/
|
||||
protected mapError(error: unknown): MetonaError {
|
||||
if (error instanceof Error) {
|
||||
// v0.3.17: 优先识别 ContentFilterError
|
||||
if (error instanceof ContentFilterError) {
|
||||
return {
|
||||
code: MetonaErrorCode.CONTENT_FILTERED,
|
||||
message: '内容被 Provider 安全审核拦截,请修改图片或文本后重试',
|
||||
provider: this.providerId,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
// v0.4.1 修复: msg 已 toLowerCase,网络错误码常量必须用小写比较
|
||||
//(原 'ETIMEDOUT'/'ECONNREFUSED' 等大写常量在小写消息上永不匹配,
|
||||
// 导致网络错误全部落入 UNKNOWN,无法触发引擎的重试逻辑)
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// #23 修复: 优先基于 HTTP status code 判断 401/429,避免字符串 includes 误匹配 URL 端口等数字
|
||||
// throwHttpError 已将 response.status 挂到 error.status,优先读取此字段
|
||||
const httpStatus = (error as Error & { status?: number }).status;
|
||||
|
||||
// #23 修复: 401 认证失败 — 优先用 status code,'unauthorized' 是单词不会误匹配
|
||||
if (httpStatus === 401 || msg.includes('unauthorized')) {
|
||||
return {
|
||||
code: MetonaErrorCode.AUTH_INVALID,
|
||||
message: 'API key 无效或已过期',
|
||||
provider: this.providerId,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// #23 修复: 429 限流 — 优先用 status code,'rate limit' 是单词不会误匹配
|
||||
if (httpStatus === 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user