- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
/**
|
||
* 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 工具调用
|
||
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||
msg.tool_calls = m.toolCalls.map((tc) => ({
|
||
function: { name: tc.name, arguments: tc.args },
|
||
}));
|
||
}
|
||
return msg;
|
||
}),
|
||
];
|
||
|
||
const body: Record<string, unknown> = {
|
||
model: this.config.defaultModel,
|
||
messages,
|
||
options: {
|
||
temperature: request.params.temperature ?? 0,
|
||
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: data.done ? MetonaFinishReason.STOP : MetonaFinishReason.LENGTH,
|
||
};
|
||
}
|
||
}
|