工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
296 lines
11 KiB
TypeScript
296 lines
11 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';
|
||
|
||
/**
|
||
* 内容审核错误 — 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;
|
||
// 默认 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
|
||
* @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。
|
||
*
|
||
* @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();
|
||
const timer = setTimeout(() => 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 });
|
||
} finally {
|
||
// #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer
|
||
clearTimeout(timer);
|
||
// 审查修复 M20: 清理 externalAbortSignal 上注册的 listener
|
||
// (即使 { once: true },请求正常完成时 listener 仍挂在 signal 上直到 abort 或 GC,需显式移除)
|
||
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;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// 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,
|
||
};
|
||
}
|
||
}
|