feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+151 -3
View File
@@ -53,13 +53,22 @@ export class SseUpstreamError extends Error {
* 引擎 chatStreamWithRetry 的 catch,自动走既有重试/故障转移通道。
* SSE / Ollama NDJSON / Anthropic 事件机三处读循环共用,杜绝三份重复实现漂移。
*
* v0.8.2 P3-3 根治: 新增外部中断贯通 —— 此前 abort 监听只在 fetch 头阶段有效
* BaseAdapter.fetchWithTimeout 在响应头返回后解除监听),流式消费阶段的
* reader.read() 对用户中断完全无感:配合保活/心跳型上游,"中断"按钮无法真正
* 终止挂起的 run(E2E 中断链路实测暴露)。现把外部 signal 传入本辅助:
* abort 触发时 cancel reader 并抛 AbortError —— 引擎 chatStreamWithRetry 由
* this.aborted 拦截原样抛出,executeRunStream 以 USER_INTERRUPT 收尾。
*
* @param reader 流的 reader
* @param idleTimeoutMs 空闲超时(默认 60s — 慢速思考模型正常 chunk 间隔可达数十秒)
* @param externalSignal 外部中断信号(引擎 abortController;可选)
* @returns { done, value }done=true 表示流正常结束
*/
export async function readStreamChunkWithIdleTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>,
idleTimeoutMs = 60_000,
externalSignal?: AbortSignal,
): Promise<{ done: boolean; value: Uint8Array | undefined }> {
let idleExpired = false;
let idleTimer: NodeJS.Timeout | undefined;
@@ -70,8 +79,37 @@ export async function readStreamChunkWithIdleTimeout(
}, idleTimeoutMs);
});
// 外部中断竞速(流式消费阶段的中断贯通)
let onExternalAbort: (() => void) | null = null;
const abortController = externalSignal
? new Promise<never>((_, reject) => {
if (externalSignal.aborted) {
reject(new Error('Aborted'));
return;
}
onExternalAbort = () => reject(new Error('Aborted'));
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
})
: null;
const abortError = (): Error => {
const err = new Error('Aborted');
err.name = 'AbortError';
void reader.cancel().catch(() => {
/* 连接销毁时 cancel 可能失败,忽略 */
});
return err;
};
try {
const result = await Promise.race([reader.read(), idleController]);
const result = await Promise.race([
reader.read(),
idleController,
...(abortController ? [abortController] : []),
]);
if (externalSignal?.aborted) {
throw abortError();
}
if (idleExpired) {
throw new SseUpstreamError(
`Stream idle timeout after ${idleTimeoutMs}ms (no data received)`,
@@ -79,8 +117,17 @@ export async function readStreamChunkWithIdleTimeout(
);
}
return result;
} catch (err) {
// 中断竞速赢时把底层读错误替换为标准 AbortErrorreader.read 会因 cancel 拒绝)
if (externalSignal?.aborted) {
throw abortError();
}
throw err;
} finally {
if (idleTimer) clearTimeout(idleTimer);
if (externalSignal && onExternalAbort) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
}
}
@@ -90,13 +137,18 @@ interface SseStreamFrame {
delta?: {
content?: string;
reasoning_content?: string;
annotations?: unknown;
tool_calls?: Array<{
index?: number;
function?: { name?: string; arguments?: string };
}>;
};
/** 部分网关在最后一个 chunk 附带完整 message(含 annotations */
message?: { annotations?: unknown };
finish_reason?: string;
}>;
/** v0.8.2 P2-6: MiMo 联网搜索引用注释(服务端 web_search 工具) */
annotations?: unknown;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
@@ -108,6 +160,54 @@ interface SseStreamFrame {
};
}
/**
* v0.8.2 P2-6 根治: MiMo 联网搜索引用(annotations)全链路丢失的收口。
*
*MiMo enableWebSearch 服务端工具在响应中返回 annotations[].{title,url,site_name,...}
*(非流式在 choices[].message.annotations;流式按文档"其余字段与非流式相同",
* 可能出现在最后一个 chunk 的顶层 / message / delta)。此前 sse-stream 完全不
* 读取该字段 —— mimo.adapter 注释宣称"由上层归并为文本内容展示"实为断头链路,
* 引用信息全链路丢失。
*
* 采集策略:按 url 去重累积;流结束时([DONE] / 断流兜底)把引用格式化为
* Markdown 列表以 TEXT_DELTA 追加到正文 —— 引擎/渲染层按既有文本管线自然
* 消费,无需新事件类型。
*/
function collectAnnotations(
annotations: unknown,
sink: Map<string, { title: string; url: string; siteName?: string }>,
): void {
if (!Array.isArray(annotations)) return;
for (const item of annotations) {
if (!item || typeof item !== 'object') continue;
const rec = item as Record<string, unknown>;
const url = typeof rec.url === 'string' ? rec.url : '';
if (!url || sink.has(url)) continue;
sink.set(url, {
title: typeof rec.title === 'string' && rec.title ? rec.title : url,
url,
siteName: typeof rec.site_name === 'string' ? rec.site_name : undefined,
});
}
}
function formatAnnotationsBlock(
sink: Map<string, { title: string; url: string; siteName?: string }>,
): string | null {
if (sink.size === 0) return null;
const MAX_CITATIONS = 20;
const lines: string[] = ['', '', '**References**', ''];
let count = 0;
for (const { title, url, siteName } of sink.values()) {
if (count >= MAX_CITATIONS) break;
const safeUrl = /^https?:\/\//i.test(url) ? url : '';
if (!safeUrl) continue;
lines.push(`- [${title}](${safeUrl})${siteName ? `${siteName}` : ''}`);
count++;
}
return count > 0 ? lines.join('\n') : null;
}
/**
* 从一条已 JSON.parse 的 SSE 数据帧中提取上游错误信息。
* 兼容三种形态:
@@ -312,6 +412,7 @@ function* flushToolCallBuffer(
* @param requestId - 对应的请求 ID
* @param sessionId - 会话 ID
* @param iteration - 当前迭代轮次
* @param externalSignal - 外部中断信号(v0.8.2 P3-3: 流式消费阶段的中断贯通,可选)
* @yields MetonaStreamEvent
*/
export async function* parseSSEStream(
@@ -319,6 +420,7 @@ export async function* parseSSEStream(
requestId: string,
sessionId: string,
iteration: number,
externalSignal?: AbortSignal,
): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader();
const decoder = new TextDecoder();
@@ -341,11 +443,17 @@ export async function* parseSSEStream(
// 工具调用缓冲区:index → { name, argsBuffer }
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
// v0.8.2 P2-6: MiMo 联网搜索引用采集(按 url 去重,流结束时回填正文)
const annotationsSink = new Map<string, { title: string; url: string; siteName?: string }>();
try {
while (true) {
// 数据到达即重置空闲窗口(辅助函数内部实现)
const { done, value } = await readStreamChunkWithIdleTimeout(reader, IDLE_TIMEOUT_MS);
const { done, value } = await readStreamChunkWithIdleTimeout(
reader,
IDLE_TIMEOUT_MS,
externalSignal,
);
if (done) break;
buffer += decoder.decode(value, { stream: true });
@@ -366,6 +474,20 @@ export async function* parseSSEStream(
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
// v0.8.2 P2-6: 引用注释回填正文(在 DONE 之前以 TEXT_DELTA 追加)
const citationBlock = formatAnnotationsBlock(annotationsSink);
if (citationBlock) {
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: citationBlock,
};
}
yield {
type: MetonaStreamEventType.DONE,
requestId,
@@ -405,6 +527,11 @@ export async function* parseSSEStream(
const delta = chunk.choices?.[0]?.delta;
// v0.8.2 P2-6: 采集引用注释(顶层 / message / delta 三处兼容)
collectAnnotations(chunk.annotations, annotationsSink);
collectAnnotations(chunk.choices?.[0]?.message?.annotations, annotationsSink);
collectAnnotations(delta?.annotations, annotationsSink);
// 文本内容增量
if (delta?.content) {
yield {
@@ -532,6 +659,19 @@ export async function* parseSSEStream(
'[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)',
);
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
// v0.8.2 P2-6: 断流兜底路径同样回填引用注释
const citationBlock = formatAnnotationsBlock(annotationsSink);
if (citationBlock) {
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: citationBlock,
};
}
yield {
type: MetonaStreamEventType.DONE,
requestId,
@@ -568,8 +708,16 @@ export function parseOpenAICompatibleResponse(data: Record<string, unknown>): {
const usage = data.usage as Record<string, unknown> | undefined;
const rawToolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
// v0.8.2 P2-6: 非流式路径的引用注释回填(MiMo 联网搜索)
const annotationsSink = new Map<string, { title: string; url: string; siteName?: string }>();
collectAnnotations(message?.annotations, annotationsSink);
collectAnnotations(data.annotations, annotationsSink);
let content = (message?.content as string) ?? '';
const citationBlock = formatAnnotationsBlock(annotationsSink);
if (citationBlock) content += citationBlock;
return {
content: (message?.content as string) ?? '',
content,
reasoningContent: message?.reasoning_content as string | undefined,
toolCalls: rawToolCalls?.map((tc) => {
const fn = tc.function as Record<string, unknown>;