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:
@@ -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' };
|
||||
}
|
||||
|
||||
// 停止序列
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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 content,assistant 仅有 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,
|
||||
|
||||
@@ -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 {
|
||||
// 跳过解析失败的行
|
||||
|
||||
Reference in New Issue
Block a user