feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强

本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题,
并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。

Critical (10/10 完成):
- C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配
  可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过

High (11/11 完成):
- 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等

Medium (55/55 完成):
- 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、
  React 组件 cancelled 标志、类型收窄等

Low (20/20 完成):
- 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等)
- nanoid 统一替代 Date.now()+Math.random()
- confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等

返工审计修复 (10/10 完成):
- L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert
- M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死
- M-42: 脱敏短值(length <= 4)泄露修复
- M-47: tasks:update 补全 title/description 类型校验
- L-9: ollama.adapter 非流式路径 nanoid 统一
- M-45: audit:query limit 策略与 memory:listAll 一致化
- SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup
- L-15: CommandPalette useMemo 补全 sessions 响应式依赖
- useAgentStream 事件类型补全 seq/timestamp 字段

新增依赖: shell-quote + @types/shell-quote
版本号: 0.3.0 -> 0.3.1
This commit is contained in:
thzxx
2026-07-13 22:36:58 +08:00
parent 4f5f570ac8
commit e4d81d8247
47 changed files with 2247 additions and 475 deletions
+42 -9
View File
@@ -16,19 +16,35 @@
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
import log from 'electron-log';
export class AgnesAdapter extends BaseAdapter {
override readonly provider: string = 'agnes';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'agnes';
readonly supportedModels = ['agnes-2.0-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Agnes 模型元信息(1M 上下文,65.5K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'agnes-2.0-flash': {
id: 'agnes-2.0-flash',
name: 'Agnes 2.0 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 65_536,
supportsToolCalling: true,
supportsThinking: true,
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片与思考模式',
},
};
// ===== POST /chat/completions (非流式) =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -39,7 +55,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) {
@@ -48,12 +65,12 @@ export class AgnesAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.provider, this.config.defaultModel);
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -68,7 +85,8 @@ export class AgnesAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) =====
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -79,7 +97,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -94,6 +113,17 @@ export class AgnesAdapter extends BaseAdapter {
);
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Agnes 模型统一 1M 上下文窗口。
* 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。
*/
override getContextWindow(): number {
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ========== 私有方法 ==========
/**
@@ -154,9 +184,12 @@ export class AgnesAdapter extends BaseAdapter {
body.tools = tools;
}
// Thinking 模式Agnes 使用 chat_template_kwargs 而非 thinking
// C-3 修复: Thinking 模式Agnes 使用 chat_template_kwargs 而非 thinking
// Agnes API 仅支持 enable_thinking: true/false,不支持 effort 级别
// thinkingEffort === 'low' 时映射为 false(不启用深度思考),其他级别映射为 true
if (request.params.thinkingEnabled) {
body.chat_template_kwargs = { enable_thinking: true };
const effort = request.params.thinkingEffort ?? 'high';
body.chat_template_kwargs = { enable_thinking: effort !== 'low' };
}
// 停止序列
+80 -10
View File
@@ -16,17 +16,81 @@ import type {
MetonaError,
} from '../types';
import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export abstract class BaseAdapter implements IMetonaProviderAdapter {
abstract readonly provider: string;
// 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) {}
abstract chat(request: MetonaRequest): Promise<MetonaResponse>;
abstract chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
// 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
*/
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]);
}
async healthCheck(): Promise<boolean> {
try {
@@ -37,8 +101,14 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
}
}
async listModels(): Promise<string[]> {
return this.supportedModels;
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 默认实现将 supportedModels 映射为 MetonaModelInfo[]
* 子类可覆盖以从 API 获取完整元信息。
*/
async listModels(): Promise<MetonaModelInfo[]> {
return this.supportedModels.map((id) => ({ id }));
}
/**
@@ -52,7 +122,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.NETWORK_TIMEOUT,
message: error.message,
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 3000,
};
@@ -62,7 +132,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.NETWORK_ERROR,
message: error.message,
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 3000,
};
@@ -72,7 +142,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.AUTH_INVALID,
message: 'API key 无效或已过期',
provider: this.provider,
provider: this.providerId,
retryable: false,
};
}
@@ -81,7 +151,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.RATE_LIMITED,
message: '请求过于频繁,请稍后重试',
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 5000,
};
@@ -91,7 +161,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.UNKNOWN,
message: error instanceof Error ? error.message : 'Unknown error',
provider: this.provider,
provider: this.providerId,
retryable: false,
};
}
+62 -12
View File
@@ -13,18 +13,43 @@
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
export class DeepSeekAdapter extends BaseAdapter {
override readonly provider: string = 'deepseek';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'deepseek';
readonly supportedModels = ['deepseek-v4-pro', 'deepseek-v4-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'deepseek-v4-pro': {
id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用',
},
'deepseek-v4-flash': {
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 快速版,1M 上下文,低延迟推理',
},
};
// ===== POST /chat/completions (非流式) =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -35,7 +60,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 120_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
});
if (!response.ok) {
@@ -44,12 +70,12 @@ export class DeepSeekAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.provider, this.config.defaultModel);
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -64,7 +90,8 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) =====
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -75,7 +102,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -92,18 +120,40 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== GET /models =====
async listModels(): Promise<string[]> {
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 优先尝试从 API 获取实时模型列表,并合并本地 MODEL_INFO 元数据。
* API 不可用时回退到 supportedModels。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.config.baseURL}/models`, {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return this.supportedModels;
const data = await response.json() as { data?: Array<{ id: string }> };
return data.data?.map((m) => m.id) ?? this.supportedModels;
if (response.ok) {
const data = await response.json() as { data?: Array<{ id: string }> };
if (data.data?.length) {
// 合并 API 返回的模型 ID 与本地元数据
return data.data.map((m) => DeepSeekAdapter.MODEL_INFO[m.id] ?? { id: m.id });
}
}
} catch {
return this.supportedModels;
// API 不可用时降级
}
// 回退到 supportedModels(带本地元数据)
return this.supportedModels.map((id) => DeepSeekAdapter.MODEL_INFO[id] ?? { id });
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* DeepSeek 模型统一 1M 上下文窗口。
*/
override getContextWindow(): number {
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ===== GET /user/balance =====
+70 -15
View File
@@ -23,15 +23,22 @@
*/
import { BaseAdapter } from './base-adapter';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export class OllamaAdapter extends BaseAdapter {
override readonly provider: string = 'ollama';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'ollama';
readonly supportedModels = ['qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
private baseURL: string;
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
@@ -41,14 +48,16 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/chat =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) {
@@ -59,14 +68,16 @@ export class OllamaAdapter extends BaseAdapter {
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
}
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -133,7 +144,8 @@ export class OllamaAdapter extends BaseAdapter {
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
// L-9 修复: 统一使用 nanoid 生成工具调用 ID(与 sse-stream.ts 一致)
id: `tc_${nanoid(8)}`,
name: tc.function?.name ?? '',
args: parsedArgs,
iteration: request.meta.iteration,
@@ -250,17 +262,55 @@ export class OllamaAdapter extends BaseAdapter {
// ===== GET /api/tags =====
async listModels(): Promise<string[]> {
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* Ollama /api/tags 返回模型列表含详细信息(name, size, details),
* 转换为 MetonaModelInfo 并补充默认元数据。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.baseURL}/api/tags`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return this.supportedModels;
const data = await response.json() as { models?: Array<{ name: string }> };
return data.models?.map((m) => m.name) ?? this.supportedModels;
if (response.ok) {
const data = await response.json() as {
models?: Array<{
name: string;
size?: number;
details?: { parameter_size?: string; quantization_level?: string; family?: string };
}>;
};
if (data.models?.length) {
return data.models.map((m) => ({
id: m.name,
name: m.name,
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: true, // Ollama 多数模型支持,具体能力需通过 /api/show 查询
supportsThinking: true,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
}));
}
}
} catch {
return this.supportedModels;
// API 不可用时降级
}
// 回退到 supportedModels
return this.supportedModels.map((id) => ({ id }));
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Ollama 上下文窗口由 options.num_ctx 决定(默认 4096),
* Engine 应通过 MetonaRequest.params.contextLength 显式设置。
* 此处返回默认值,供 Engine 在未指定时参考。
*/
override getContextWindow(): number {
return OllamaAdapter.DEFAULT_CONTEXT_WINDOW;
}
// ===== POST /api/show =====
@@ -312,7 +362,10 @@ export class OllamaAdapter extends BaseAdapter {
try {
const chunk = JSON.parse(line);
onProgress?.({ status: chunk.status, completed: chunk.completed, total: chunk.total });
} catch {}
} catch {
// L-3 修复: 添加日志便于诊断非标准行(如进度通知、空行等)
log.debug('[Ollama] skipped non-JSON line during pull:', line.slice(0, 100));
}
}
}
}
@@ -366,7 +419,8 @@ export class OllamaAdapter extends BaseAdapter {
].filter(Boolean).join('\n\n'),
},
...request.messages.filter((m) => m.role !== 'system').map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
// 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) => {
@@ -439,7 +493,7 @@ export class OllamaAdapter extends BaseAdapter {
return {
meta: {
requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -464,7 +518,8 @@ export class OllamaAdapter extends BaseAdapter {
args = {};
}
return {
id: `tc_${Date.now()}_${i}`,
// L-9 修复(审计补充): 非流式路径统一使用 nanoid,与流式路径(sendStream)保持一致
id: `tc_${nanoid(8)}`,
name: (fn?.name as string) ?? '',
args,
iteration,
+56 -51
View File
@@ -12,6 +12,52 @@ 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 流式响应
*
@@ -29,7 +75,7 @@ export async function* parseSSEStream(
): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader();
const decoder = new TextDecoder();
let seq = 0;
const seqRef = { seq: 0 };
let buffer = '';
// 工具调用缓冲区:index → { name, argsBuffer }
@@ -50,36 +96,15 @@ export async function* parseSSEStream(
// 流结束
if (data === '[DONE]') {
// 将缓冲区中未完成拼接的工具调用发送
for (const [index, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: 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();
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
yield {
type: MetonaStreamEventType.DONE,
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
};
return;
@@ -96,7 +121,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.content,
};
@@ -109,7 +134,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.reasoning_content,
};
@@ -131,7 +156,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCallDelta: {
index: idx,
@@ -158,7 +183,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
usage,
};
@@ -167,28 +192,8 @@ export async function* parseSSEStream(
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
if (finishReason === 'tool_calls') {
for (const [index, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: 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();
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
}
} catch {
// 跳过解析失败的行