feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端)

This commit is contained in:
2026-07-22 10:31:13 +08:00
parent 516d8a728f
commit 2c2ca4a692
43 changed files with 1454 additions and 301 deletions
@@ -47,7 +47,8 @@ export class AgnesAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -55,9 +56,7 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
}, this.config.timeoutMs ?? 300_000);
if (!response.ok) {
await this.throwHttpError(response, 'Agnes AI API error');
@@ -88,7 +87,8 @@ export class AgnesAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -96,9 +96,7 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
}, this.config.timeoutMs ?? 300_000);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Agnes AI stream error');
+51 -2
View File
@@ -73,6 +73,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
*
* @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);
@@ -92,6 +95,46 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
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();
@@ -150,7 +193,12 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
};
}
if (msg.includes('401') || msg.includes('unauthorized')) {
// #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 无效或已过期',
@@ -159,7 +207,8 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
};
}
if (msg.includes('429') || msg.includes('rate limit')) {
// #23 修复: 429 限流 — 优先用 status code'rate limit' 是单词不会误匹配
if (httpStatus === 429 || msg.includes('rate limit')) {
return {
code: MetonaErrorCode.RATE_LIMITED,
message: '请求过于频繁,请稍后重试',
@@ -52,7 +52,8 @@ export class DeepSeekAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -60,9 +61,7 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
});
}, this.config.timeoutMs ?? 120_000);
if (!response.ok) {
await this.throwHttpError(response, 'DeepSeek API error');
@@ -93,7 +92,8 @@ export class DeepSeekAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -101,9 +101,7 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
}, this.config.timeoutMs ?? 300_000);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'DeepSeek stream error');
+12 -7
View File
@@ -59,7 +59,8 @@ export class MimoAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -67,8 +68,7 @@ export class MimoAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
});
}, this.config.timeoutMs ?? 120_000);
if (!response.ok) {
await this.throwHttpError(response, 'MiMo API error');
@@ -98,7 +98,8 @@ export class MimoAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -106,8 +107,7 @@ export class MimoAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
}, this.config.timeoutMs ?? 300_000);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'MiMo stream error');
@@ -194,7 +194,12 @@ export class MimoAdapter extends BaseAdapter {
model: this.config.defaultModel,
messages,
// MiMo 使用 max_completion_tokens(非 max_tokens
max_completion_tokens: request.params.maxTokens,
// #41 修复: thinking 模式下 max_completion_tokens 未配置时使用兜底默认值(32768)
// thinking 占用 token 配额,未配置时 API 默认值可能过小导致输出被截断
// thinkingEnabled !== false 包含 true 和 undefinedMiMo 默认 enabled)两种情况
// 审查修复: 兜底值从 63488 改为 32768,避免超出标准版上限导致 API 400
max_completion_tokens: request.params.maxTokens
?? (request.params.thinkingEnabled !== false ? 32768 : undefined),
stream,
};
+59 -21
View File
@@ -50,15 +50,15 @@ export class OllamaAdapter extends BaseAdapter {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const nativeRequest = this.toNativeRequest(request);
// #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64
const nativeRequest = await this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
}, this.config.timeoutMs ?? 300_000);
if (!response.ok) {
await this.throwHttpError(response, 'Ollama API error');
@@ -70,15 +70,15 @@ export class OllamaAdapter extends BaseAdapter {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const nativeRequest = this.toNativeRequest(request);
// #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64
const nativeRequest = await this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
}, this.config.timeoutMs ?? 300_000);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Ollama stream error');
@@ -409,8 +409,33 @@ export class OllamaAdapter extends BaseAdapter {
// ========== 私有转换方法 ==========
private toNativeRequest(request: MetonaRequest): Record<string, unknown> {
const messages = [
/**
* #2 修复: 下载 http(s) URL 图片并转为纯 base64 字符串(不含 data: 前缀)
*
* Ollama API 的 images 字段要求纯 base64 字符串数组。
* 当 MetonaMessage.images 中存储的是 URL 时,需先下载转为 base64。
* 下载失败时返回空字符串(Ollama 会忽略空图片),不阻断整个请求。
*/
private async resolveImageToBase64(url: string): Promise<string> {
try {
// 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时,
// 避免用户中断时图片下载最多阻塞 30s×NexternalAbortSignal 是 BaseAdapter 的
// private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout
// 该方法同时处理了 listener 泄漏问题)
const res = await this.fetchWithTimeout(url, {}, 30_000);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
} catch (error) {
log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`);
return '';
}
}
private async toNativeRequest(request: MetonaRequest): Promise<Record<string, unknown>> {
const messages: Record<string, unknown>[] = [
{
role: 'system',
content: [
@@ -420,20 +445,34 @@ export class OllamaAdapter extends BaseAdapter {
request.systemPrompt.dynamicReminders,
].filter(Boolean).join('\n\n'),
},
...request.messages.filter((m) => m.role !== 'system').map((m) => {
];
// #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await)
for (const m of request.messages) {
if (m.role === 'system') continue;
// C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' };
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) {
msg.images = m.images.map((img) => {
// #2 修复: 支持公网 URL 图片,下载后转为纯 base64
// 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
const resolvedImages: string[] = [];
for (const img of m.images) {
const url = img.url;
// data:image/png;base64,iVBOR... → iVBOR...
if (url.startsWith('data:')) {
// data:image/png;base64,iVBOR... → iVBOR...
const base64Part = url.split(',')[1];
return base64Part ?? url;
resolvedImages.push(base64Part ?? url);
} else if (url.startsWith('http://') || url.startsWith('https://')) {
// #2 修复: 公网 URL → 下载 → 纯 base64
const base64 = await this.resolveImageToBase64(url);
if (base64) resolvedImages.push(base64);
} else {
// 已是纯 base64 字符串(无 data: 前缀)
resolvedImages.push(url);
}
return url;
});
}
msg.images = resolvedImages;
}
// 工具结果
if (m.role === 'tool' && m.toolResult) {
@@ -455,9 +494,8 @@ export class OllamaAdapter extends BaseAdapter {
if (m.role === 'assistant' && m.reasoningContent) {
(msg as Record<string, unknown>).reasoning_content = m.reasoningContent;
}
return msg;
}),
];
messages.push(msg);
}
const body: Record<string, unknown> = {
model: this.config.defaultModel,
@@ -46,6 +46,11 @@ export function buildOpenAICompatibleMessages(
content: m.content,
};
// 注意:图片(多模态)处理不在此共享函数中。
// DeepSeek 不支持多模态,images 被静默丢弃是正确行为。
// Agnes/MiMo 各自的 toNativeRequest 中有独立的 images 处理。
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 不支持多模态会导致 API 400,已撤销。
// === Assistant 消息 ===
if (m.role === 'assistant') {
// 工具调用历史
@@ -74,6 +79,10 @@ export function buildOpenAICompatibleMessages(
: (typeof m.toolResult.result === 'string'
? m.toolResult.result
: JSON.stringify(m.toolResult.result));
// #26 修复: 确保 tool 消息 content 不为 undefined
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
if (msg.content === undefined) msg.content = '';
}
return msg;
@@ -52,8 +52,14 @@ function* flushToolCallBuffer(
timestamp: Date.now(),
},
};
} catch {
// JSON 解析失败,跳过该工具调用
} catch (err) {
// #25 修复: 不再静默丢弃 JSON 解析失败工具调用
// 审查修复: 不再 yield ERROR 事件,因为 Engine 收到 ERROR 会 throw 中断整个请求,
// 导致一个好的工具调用 JSON 解析失败就丢弃所有工具调用。
// 改为 log.warn 记录后 continue 跳过这条坏的工具调用,继续处理 buffer 中剩余的。
const rawPreview = buf.argsBuffer?.slice(0, 200) ?? '';
log.warn(`[SSE] Tool call JSON parse failed: ${(err as Error).message}`, rawPreview);
continue;
}
}
toolCallsBuffer.clear();