Files
metona-ai-desktop/electron/harness/adapters/shared/sse-stream.ts
T
thzxx 656c6b7af1 feat: 升级至 v0.3.4 — 接入 Xiaomi MiMo Provider + 文档全量校准
新增 MiMo (小米) LLM Provider 适配器,支持 mimo-v2.5-pro 和 mimo-v2.5 两个文本模型,复用 OpenAI 兼容 SSE 流式解析,支持 Thinking 模式和 Function Calling。同步校准全量 docs 文档与 README 使其与实际代码一致。

主要变更:
- 新增 mimo.adapter.ts 适配器(SSE + thinking.type + max_completion_tokens)
- 修复 thinking 逻辑 bug:禁用思考时未传 temperature/top_p
- 补全 sse-stream.ts 的 MiMo 缓存字段映射(prompt_tokens_details.cached_tokens)
- 补全 sse-stream.ts 的 finish_reason 映射(repetition_truncation)
- 注册 MiMo 适配器到 adapters/index.ts、main.ts 工厂
- handlers.ts 添加 mimo.contextWindow 热重载触发
- database.service.ts seed 添加 mimo 默认配置
- SettingsModal/OnboardingWizard/Header 添加 MiMo Provider UI
- constants.ts PROVIDER_LABELS 添加 mimo
- .env.example 添加 MIMO_API_KEY/MIMO_BASE_URL
- 反向修改 4 个 docs HTML 设计文档(工具数量/版本日期/适配器列表/数据库表)
- 反向修改 Agent网络工具通用设计-v2.md 附录 B 文件索引
- 完全重写 README.md(v0.3.4、27 工具、4 适配器、9 表)
2026-07-15 22:28:09 +08:00

277 lines
8.9 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.
/**
* SSE 流式解析工具
*
* 解析 OpenAI 兼容的 Server-Sent Events (SSE) 流式响应,
* 产出 MetonaStreamEvent。DeepSeek、Agnes AI 和 MiMo 共享此工具。
*
* SSE 格式:data: {json}\n\n
* 结束标记:data: [DONE]
*/
import { nanoid } from 'nanoid';
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
import { MetonaStreamEventType } from '../../types';
/**
* L-4 修复: 提取 flushToolCallBuffer 辅助函数,消除 [DONE] 分支和 finish_reason='tool_calls' 分支的重复代码
*
* 遍历工具调用缓冲区,对每个缓冲的工具调用:
* 1. JSON.parse argsBuffer(失败则跳过)
* 2. yield 一个 TOOL_CALL_COMPLETE 事件
* 3. 清空缓冲区
*
* @param toolCallsBuffer - 工具调用缓冲区(index → { name, argsBuffer }
* @param requestId - 请求 ID
* @param sessionId - 会话 ID
* @param iteration - 当前迭代轮次
* @param seqRef - seq 计数器引用(递增)
* @yields MetonaStreamEvent
*/
function* flushToolCallBuffer(
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
requestId: string,
sessionId: string,
iteration: number,
seqRef: { seq: number },
): Generator<MetonaStreamEvent> {
for (const [, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
iteration,
timestamp: Date.now(),
},
};
} catch {
// JSON 解析失败,跳过该工具调用
}
}
toolCallsBuffer.clear();
}
/**
* 解析 OpenAI 兼容 SSE 流式响应
*
* @param responseBody - fetch Response.body (ReadableStream<Uint8Array>)
* @param requestId - 对应的请求 ID
* @param sessionId - 会话 ID
* @param iteration - 当前迭代轮次
* @yields MetonaStreamEvent
*/
export async function* parseSSEStream(
responseBody: ReadableStream<Uint8Array>,
requestId: string,
sessionId: string,
iteration: number,
): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader();
const decoder = new TextDecoder();
const seqRef = { seq: 0 };
let buffer = '';
// 工具调用缓冲区:index → { name, argsBuffer }
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
// 流结束
if (data === '[DONE]') {
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
yield {
type: MetonaStreamEventType.DONE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
};
return;
}
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta;
// 文本内容增量
if (delta?.content) {
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.content,
};
}
// 推理内容增量(Thinking 模式)
if (delta?.reasoning_content) {
yield {
type: MetonaStreamEventType.REASONING_DELTA,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.reasoning_content,
};
}
// 工具调用增量 — 缓冲拼接
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!toolCallsBuffer.has(idx)) {
toolCallsBuffer.set(idx, { name: tc.function?.name ?? '', argsBuffer: '' });
}
const buf = toolCallsBuffer.get(idx)!;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.argsBuffer += tc.function.arguments;
yield {
type: MetonaStreamEventType.TOOL_CALL_DELTA,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCallDelta: {
index: idx,
name: tc.function?.name,
argsDelta: tc.function?.arguments,
},
};
}
}
// Token 使用统计 / finish_reason
if (chunk.usage) {
const usage: MetonaTokenUsage = {
inputTokens: chunk.usage.prompt_tokens ?? 0,
outputTokens: chunk.usage.completion_tokens ?? 0,
totalTokens: chunk.usage.total_tokens ?? 0,
reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens,
// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens
// MiMo: prompt_tokens_details.cached_tokens
cacheHitTokens: chunk.usage.prompt_cache_hit_tokens
?? chunk.usage.prompt_tokens_details?.cached_tokens,
cacheMissTokens: chunk.usage.prompt_cache_miss_tokens,
};
yield {
type: MetonaStreamEventType.USAGE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
usage,
};
}
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
if (finishReason === 'tool_calls') {
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
}
} catch {
// 跳过解析失败的行
}
}
}
}
/**
* 解析 OpenAI 兼容的非流式 JSON 响应 → MetonaResponse
*/
export function parseOpenAICompatibleResponse(
data: Record<string, unknown>,
requestId: string,
provider: string,
defaultModel: string,
): {
content: string;
reasoningContent?: string;
toolCalls?: Array<{ id: string; name: string; args: Record<string, unknown>; iteration: number; timestamp: number }>;
finishReason: string;
usage: MetonaTokenUsage;
} {
const choice = (data.choices as Array<Record<string, unknown>>)?.[0];
const message = choice?.message as Record<string, unknown> | undefined;
const usage = data.usage as Record<string, unknown> | undefined;
const rawToolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
return {
content: (message?.content as string) ?? '',
reasoningContent: message?.reasoning_content as string | undefined,
toolCalls: rawToolCalls?.map((tc) => {
const fn = tc.function as Record<string, unknown>;
let args: Record<string, unknown> = {};
const rawArgs = fn?.arguments;
if (typeof rawArgs === 'string') {
try {
args = JSON.parse(rawArgs);
} catch {
args = {};
}
} else if (rawArgs && typeof rawArgs === 'object') {
args = rawArgs as Record<string, unknown>;
}
return {
id: tc.id as string,
name: fn.name as string,
args,
iteration: 0,
timestamp: Date.now(),
};
}),
finishReason: mapOpenAIFinishReason(choice?.finish_reason as string),
usage: {
inputTokens: (usage?.prompt_tokens as number) ?? 0,
outputTokens: (usage?.completion_tokens as number) ?? 0,
totalTokens: (usage?.total_tokens as number) ?? 0,
reasoningTokens: (usage?.completion_tokens_details as Record<string, unknown>)?.reasoning_tokens as number | undefined,
// DeepSeek: prompt_cache_hit_tokens / MiMo: prompt_tokens_details.cached_tokens
cacheHitTokens: (usage?.prompt_cache_hit_tokens as number | undefined)
?? (usage?.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens as number | undefined,
cacheMissTokens: usage?.prompt_cache_miss_tokens as number | undefined,
},
};
}
function mapOpenAIFinishReason(reason: string): string {
switch (reason) {
case 'stop': return 'stop';
case 'length': return 'length';
case 'tool_calls': return 'tool_calls';
case 'content_filter': return 'content_filter';
// MiMo 特有:检测到复读截断
case 'repetition_truncation': return 'stop';
default: return 'stop';
}
}