Files
metona-ai-desktop/electron/harness/adapters/base-adapter.ts
T
thzxx 49c9b25538
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一):
- 新增 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 双模式)
2026-08-21 13:58:48 +08:00

296 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
}
}