Files
metona-ai-desktop/electron/harness/adapters/agnes-ai.adapter.ts
T

209 lines
7.6 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.
/**
* Agnes AI Provider Adapter
*
* OpenAI 兼容 API。支持 Tool Calling、Thinking 模式、多模态(图片 — URL + Base64)。
*
* 独立继承 BaseAdapter,通过 shared/openai-format 和 shared/sse-stream 复用
* OpenAI 兼容格式构建和 SSE 流式解析逻辑。不与其他 Provider Adapter 耦合。
*
* 与 DeepSeek 的差异:
* - Thinking 模式使用 chat_template_kwargs(非 thinking 字段)
* - 默认 max_tokens 更大(65536 vs 8192
*
* @see apis/agnes-ai-api-docs-20260625.html
*/
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 {
// 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 上下文,支持多模态图片(URL + Base64)与思考模式',
},
};
// ===== POST /chat/completions (非流式) =====
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
...this.config.headers,
},
body: JSON.stringify(body),
}, this.config.timeoutMs ?? 300_000);
if (!response.ok) {
await this.throwHttpError(response, 'Agnes AI API error');
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
},
content: parsed.content,
reasoningContent: parsed.reasoningContent,
toolCalls: parsed.toolCalls,
usage: parsed.usage,
finishReason: parsed.finishReason as MetonaFinishReason,
};
}
// ===== POST /chat/completions (流式) =====
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
...this.config.headers,
},
body: JSON.stringify(body),
}, this.config.timeoutMs ?? 300_000);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Agnes AI stream error');
}
yield* parseSSEStream(
// 非空断言:上方 if 已确保 response.body 不为 null
response.body!,
request.meta.requestId,
request.meta.sessionId,
request.meta.iteration,
);
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* v0.3.1: 优先使用配置注入的 contextWindow,回退到 MODEL_INFO 默认值。
* Agnes OpenAI 兼容 API 不支持 context_window 参数,此值仅用于
* Engine 压缩判断和前端 UI 显示。
* 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。
*/
override getContextWindow(): number {
// v0.3.1: 优先使用配置注入的 contextWindow
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow;
}
// 回退到 MODEL_INFO
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ========== 私有方法 ==========
/**
* 构建 Agnes AI 原生请求体
*
* Agnes AI 特有参数:
* - 多模态图片:user 消息的 images[] → OpenAI content 数组 [{type:"text"}, {type:"image_url"}]
* 支持 HTTPS URL 或 base64 Data URI(与 MiMo 一致)
* - chat_template_kwargs: { enable_thinking: true } — 启用思考模式(非 thinking 字段)
* - 默认 max_tokens: 655361M 上下文,65.5K 最大输出)
*/
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
const messages = buildOpenAICompatibleMessages(request);
const tools = buildOpenAICompatibleTools(request.tools);
// === Agnes 多模态:将 images 转为 OpenAI content 数组 ===
// buildOpenAICompatibleMessages 不处理图片(各 Provider 自行处理)
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
let imageCount = 0;
for (let i = 0; i < messages.length; i++) {
// messages[0] 是 system,非 system 消息从 messages[1] 开始
if (i === 0) continue;
const origMsg = nonSystemMsgs[i - 1];
if (!origMsg?.images?.length) continue;
imageCount += origMsg.images.length;
const contentParts: Array<Record<string, unknown>> = [];
if (origMsg.content) {
contentParts.push({ type: 'text', text: origMsg.content });
}
for (const img of origMsg.images) {
contentParts.push({
type: 'image_url',
image_url: { url: img.url },
});
}
messages[i].content = contentParts;
}
if (imageCount > 0) {
const firstUrl = request.messages.find(m => m.images?.length)?.images?.[0]?.url ?? '';
log.info(`[Agnes] Processing ${imageCount} image(s), first URL prefix: ${firstUrl.slice(0, 50)}`);
}
const body: Record<string, unknown> = {
model: this.config.defaultModel,
messages,
temperature: request.params.temperature,
max_tokens: request.params.maxTokens ?? 65536,
stream,
};
if (stream) {
body.stream_options = { include_usage: true };
}
if (tools) {
body.tools = tools;
}
// C-3 修复: Thinking 模式 — Agnes 使用 chat_template_kwargs 而非 thinking
// Agnes API 仅支持 enable_thinking: true/false,不支持 effort 级别
// thinkingEffort === 'low' 时映射为 false(不启用深度思考),其他级别映射为 true
if (request.params.thinkingEnabled) {
const effort = request.params.thinkingEffort ?? 'high';
body.chat_template_kwargs = { enable_thinking: effort !== 'low' };
}
// 停止序列
if (request.params.stopSequences?.length) {
body.stop = request.params.stopSequences;
}
return body;
}
}