Files
metona-ai-desktop/electron/harness/adapters/ollama.adapter.ts
T
thzxx aa0dd464ac fix: 对照 API 文档修复 3 个 Adapter 的 8 个实现差异
通过逐接口对比 DeepSeek/AgnesAI/Ollama 官方文档与实现代码,发现并修复:

DeepSeek (4 fix):
- temperature 默认值: 错误发送 0(API默认=1),改为 undefined 让 API 用默认值
- thinking 禁用: API 默认 enabled,不传=开启。改为显式发送 {type:disabled}
- reasoning_effort: 补充 xhigh→max 映射(文档规定)
- stream 请求: 补充 AbortSignal.timeout 超时控制

Agnes AI (2 fix):
- temperature 默认值: 同上
- 图片 image_url: 移除文档未提及的 detail 字段

Ollama (3 fix):
- temperature 默认值: 同上(Ollama默认≈0.8)
- tool_calls arguments: 从对象改为 JSON 字符串(REST API 要求)
- finishReason: 使用 done_reason 字段正确映射(之前始终返回 STOP)
2026-06-27 21:48:15 +08:00

474 lines
16 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.
/**
* Ollama Provider Adapter
*
* 本地推理引擎,支持 Tool Calling、Thinking 模式、NDJSON 流式。
* 无需 API Key,连接本地 http://localhost:11434。
*
* 完整实现 Ollama API 文档中所有端点和参数:
* - POST /api/chat (对话)
* - POST /api/generate (补全)
* - POST /api/embed (嵌入)
* - GET /api/tags (列出模型)
* - POST /api/show (模型详情)
* - POST /api/pull (下载模型)
* - GET /api/ps (运行中模型)
* - GET /api/version (版本)
* - think 参数(Thinking 模式)
* - options 参数(temperature/top_k/top_p/stop/num_ctx/num_predict
* - format 参数(structured output
* - images 参数(多模态)
* - tool_calls 流式处理
*
* @see apis/ollama-api-docs-20260518.html
*/
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
export class OllamaAdapter extends BaseAdapter {
override readonly provider: string = 'ollama';
readonly supportedModels = ['qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
private baseURL: string;
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
super(config);
this.baseURL = config.baseURL || 'http://localhost:11434';
}
// ===== POST /api/chat =====
async chat(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),
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
return this.toMetonaResponse(data, request.meta.requestId);
}
async *chatStream(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 }),
});
if (!response.ok || !response.body) {
throw new Error(`Ollama stream error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let seq = 0;
let buffer = '';
// 工具调用缓冲区
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) continue;
try {
const chunk = JSON.parse(trimmed);
// 思考内容
if (chunk.message?.thinking) {
yield {
type: MetonaStreamEventType.REASONING_DELTA,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
delta: chunk.message.thinking,
};
}
// 文本内容
if (chunk.message?.content) {
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
delta: chunk.message.content,
};
}
// 工具调用(Ollama 在最后一个 chunk 中整块返回)
if (chunk.message?.tool_calls) {
for (const tc of chunk.message.tool_calls) {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: tc.function?.name ?? '',
args: tc.function?.arguments ?? {},
iteration: request.meta.iteration,
timestamp: Date.now(),
},
};
}
}
// 流结束
if (chunk.done) {
// 发送 usage 信息
yield {
type: MetonaStreamEventType.USAGE,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
usage: {
inputTokens: chunk.prompt_eval_count ?? 0,
outputTokens: chunk.eval_count ?? 0,
totalTokens: (chunk.prompt_eval_count ?? 0) + (chunk.eval_count ?? 0),
},
};
yield {
type: MetonaStreamEventType.DONE,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
};
return;
}
} catch {
// 跳过解析失败的行
}
}
}
}
// ===== POST /api/generate =====
async generate(params: {
model: string;
prompt: string;
suffix?: string;
system?: string;
stream?: boolean;
think?: boolean | string;
format?: string | object;
images?: string[];
options?: Record<string, unknown>;
}): Promise<{ response: string; thinking?: string; done: boolean; totalDuration: number; evalCount: number }> {
const response = await fetch(`${this.baseURL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...params, stream: false }),
signal: AbortSignal.timeout(300_000),
});
if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`);
const data = await response.json() as {
response?: string; thinking?: string; done?: boolean;
total_duration?: number; eval_count?: number;
};
return {
response: data.response ?? '',
thinking: data.thinking,
done: data.done ?? true,
totalDuration: data.total_duration ?? 0,
evalCount: data.eval_count ?? 0,
};
}
// ===== POST /api/embed =====
async embed(params: {
model: string;
input: string | string[];
dimensions?: number;
}): Promise<{ embeddings: number[][]; totalDuration: number }> {
const response = await fetch(`${this.baseURL}/api/embed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`);
const data = await response.json() as { embeddings?: number[][]; total_duration?: number };
return {
embeddings: data.embeddings ?? [],
totalDuration: data.total_duration ?? 0,
};
}
// ===== GET /api/tags =====
async listModels(): Promise<string[]> {
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;
} catch {
return this.supportedModels;
}
}
// ===== POST /api/show =====
async showModel(model: string): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
try {
const response = await fetch(`${this.baseURL}/api/show`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return null;
const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] };
return {
parameters: data.parameters ?? '',
template: data.template ?? '',
capabilities: data.capabilities ?? [],
};
} catch {
return null;
}
}
// ===== POST /api/pull =====
async pullModel(model: string, onProgress?: (progress: { status: string; completed?: number; total?: number }) => void): Promise<void> {
const response = await fetch(`${this.baseURL}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: true }),
});
if (!response.ok || !response.body) throw new Error(`Ollama pull error: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
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) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
onProgress?.({ status: chunk.status, completed: chunk.completed, total: chunk.total });
} catch {}
}
}
}
// ===== GET /api/ps =====
async listRunning(): Promise<Array<{ name: string; size: number; sizeVram: number; contextLength: number }>> {
try {
const response = await fetch(`${this.baseURL}/api/ps`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return [];
const data = await response.json() as { models?: Array<{ name: string; size?: number; size_vram?: number; context_length?: number }> };
return (data.models ?? []).map((m) => ({
name: m.name ?? '',
size: m.size ?? 0,
sizeVram: m.size_vram ?? 0,
contextLength: m.context_length ?? 0,
}));
} catch {
return [];
}
}
// ===== GET /api/version =====
async getVersion(): Promise<string> {
try {
const response = await fetch(`${this.baseURL}/api/version`, {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return 'unknown';
const data = await response.json() as { version?: string };
return data.version ?? 'unknown';
} catch {
return 'unknown';
}
}
// ========== 私有转换方法 ==========
private toNativeRequest(request: MetonaRequest): Record<string, unknown> {
const messages = [
{
role: 'system',
content: [
request.systemPrompt.roleDefinition,
request.systemPrompt.outputConstraints,
request.systemPrompt.safetyGuidelines,
].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 };
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) {
msg.images = m.images.map((img) => {
const url = img.url;
// data:image/png;base64,iVBOR... → iVBOR...
if (url.startsWith('data:')) {
const base64Part = url.split(',')[1];
return base64Part ?? url;
}
return url;
});
}
// 工具结果
if (m.role === 'tool' && m.toolResult) {
msg.tool_call_id = m.toolResult.toolCallId;
msg.content = typeof m.toolResult.result === 'string'
? m.toolResult.result
: JSON.stringify(m.toolResult.result);
}
// assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
if (m.role === 'assistant' && m.toolCalls?.length) {
msg.tool_calls = m.toolCalls.map((tc) => ({
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}));
}
return msg;
}),
];
const body: Record<string, unknown> = {
model: this.config.defaultModel,
messages,
options: {
temperature: request.params.temperature,
num_predict: request.params.maxTokens,
top_p: request.params.topP,
stop: request.params.stopSequences,
},
};
// Tool Calling
if (request.tools?.length) {
body.tools = request.tools.map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
// Thinking 模式
if (request.params.thinkingEnabled) {
const effortMap: Record<string, string | boolean> = { low: 'low', medium: 'medium', high: 'high', max: true };
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
}
return body;
}
private toMetonaResponse(data: Record<string, unknown>, requestId: string): MetonaResponse {
const message = data.message as Record<string, unknown> | undefined;
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
return {
meta: {
requestId,
provider: this.provider,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
perfStats: {
loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined,
promptEvalDurationMs: data.prompt_eval_duration ? (data.prompt_eval_duration as number) / 1e6 : undefined,
evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
tokensPerSecond: data.eval_count && data.eval_duration
? ((data.eval_count as number) / ((data.eval_duration as number) / 1e9))
: undefined,
},
},
content: (message?.content as string) ?? '',
reasoningContent: message?.thinking as string | undefined,
toolCalls: toolCalls?.map((tc, i) => {
const fn = tc.function as Record<string, unknown>;
return {
id: `tc_${Date.now()}_${i}`,
name: (fn?.name as string) ?? '',
args: (fn?.arguments as Record<string, unknown>) ?? {},
iteration: 0,
timestamp: Date.now(),
};
}),
usage: {
inputTokens: (data.prompt_eval_count as number) ?? 0,
outputTokens: (data.eval_count as number) ?? 0,
totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0),
},
finishReason: mapOllamaDoneReason(data.done_reason as string | undefined, !!message?.tool_calls),
};
}
}
/**
* 映射 Ollama done_reason → MetonaFinishReason
*
* @see apis/ollama-api-docs-20260518.html — /api/chat 响应字段
*/
function mapOllamaDoneReason(
reason: string | undefined,
hasToolCalls: boolean,
): MetonaFinishReason {
if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS;
switch (reason) {
case 'stop': return MetonaFinishReason.STOP;
case 'length': return MetonaFinishReason.LENGTH;
case 'load': return MetonaFinishReason.ERROR;
case 'unload': return MetonaFinishReason.ERROR;
default: return MetonaFinishReason.STOP;
}
}