Agent/clients/ollama-client.ts
2026-08-06 22:17:39 +08:00

250 lines
8.5 KiB
TypeScript

// @ts-nocheck
import { ollamaUrl, loadOllamaConfig, loadThinkingEnabled } from '../utils/config.js';
import { reportTokens, reportStreamDelta, reportStreamEnd } from '../utils/metrics-client.js';
import { AGENT_IDENTITY_CORE } from '../core/agent-identity-core.js';
import { DOMAIN_RULES } from '../core/domain-rules.js';
import { scanToolCallBuffer, finalizeToolCallBuffer, cleanToolCallArtifacts } from '../utils/tool-call-parse.js';
import { createThinkSplitter, stripThinkTags } from '../core/think-parse.js';
import { friendlyLocalFetchError } from '../utils/model-manager.js';
let _warmedUp = false;
export async function warmup(model) {
if (_warmedUp) return true;
try {
const r = await fetch(ollamaUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: model || loadOllamaConfig().model,
messages: [
{ role: 'system', content: IDENTITY },
{ role: 'user', content: 'ping' },
],
stream: false,
options: { num_predict: 1, temperature: 0 },
keep_alive: '30m',
}),
});
if (r.ok) {
_warmedUp = true;
return true;
}
return false;
} catch (e) {
return false;
}
}
const IDENTITY = `
${AGENT_IDENTITY_CORE}
你是"ccSparkle Agent",地铁乘务专业的智能助手,基于本地部署的 Qwen2.5 模型,通过 MCP 协议操作本地地铁运营图 Electron 应用。
【回答风格】
- **只用简体中文**回复用户,禁止整句英文(见上方【语言】规则)。
- 用自然口语化的中文,有温度,不要机械复述身份描述。
- 不要说自己是其他厂商的模型(如 OpenAI / ChatGPT 等)。
【工具调用规则】
- **仅当本轮提供了 tools、且用户有明确操作指令时**才可调用工具。
- 自我介绍、向评委讲解、寒暄:**禁止**输出 \`<tool_call>\`,直接写中文正文。
- 有工具可调时格式: \`<tool_call>{"name":"metro_xxx","arguments":{...}}</tool_call>\` (不要用 ICF 等其他格式)
- metro_load_timetable_full 只传 query(如"工作日"),不要臆造 dayType 等字段
- 工具失败时如实告知错误。
- 多候选时列给用户选,不自己代决定。
${DOMAIN_RULES}
`;
export async function chat({ messages, tools, model, signal, onTextDelta, onThinkingDelta, maxTokens: maxTokensOpt, temperature }) {
const cfg = loadOllamaConfig();
const useModel = model || cfg.model;
const thinkOn = loadThinkingEnabled();
const leadIdx = messages.findIndex(m => m.role !== 'system');
const leadEnd = leadIdx === -1 ? messages.length : leadIdx;
const systemExtra = messages.slice(0, leadEnd)
.map(m => m.content).filter(Boolean).join('\n\n');
const ollamaMessages = [{
role: 'system',
content: systemExtra ? `${IDENTITY}\n\n${systemExtra}` : IDENTITY,
}];
for (const m of messages.slice(leadEnd)) {
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
ollamaMessages.push({
role: 'assistant',
content: m.content || '',
tool_calls: m.toolCalls.map(tc => ({
function: { name: tc.name, arguments: tc.arguments || {} },
})),
});
} else if (m.role === 'tool') {
ollamaMessages.push({ role: 'tool', content: m.content });
} else {
ollamaMessages.push({ role: m.role, content: m.content || '' });
}
}
const payload = {
model: useModel,
messages: ollamaMessages,
stream: true,
think: thinkOn,
};
if (maxTokensOpt != null) {
payload.options = { ...(payload.options || {}), num_predict: maxTokensOpt };
}
if (temperature != null) {
payload.options = { ...(payload.options || {}), temperature };
}
if (tools && tools.length > 0) {
payload.tools = tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description || '',
parameters: t.inputSchema || { type: 'object', properties: {} },
},
}));
}
let r;
try {
r = await fetch(ollamaUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
});
} catch (e) {
throw friendlyLocalFetchError('ollama', e);
}
if (!r.ok) {
const text = await r.text();
throw new Error(`Ollama HTTP ${r.status}: ${text.slice(0, 200)}`);
}
return consumeStream(r.body, { onTextDelta, onThinkingDelta, thinkOn });
}
async function consumeStream(body, { onTextDelta, onThinkingDelta, thinkOn }) {
const reader = body.getReader();
const decoder = new TextDecoder();
let lineBuf = '';
let textBuf = '';
let fullText = '';
let fullThinking = '';
let toolCalls = [];
let evalCount = 0;
const t0 = performance.now();
const thinkSplit = createThinkSplitter(thinkOn);
const emitContent = (raw) => {
if (!raw) return;
const split = thinkSplit.push(raw);
if (split.thinking) {
fullThinking += split.thinking;
if (thinkOn) onThinkingDelta?.(split.thinking);
}
if (split.content) {
textBuf += split.content;
fullText += split.content;
const result = scanToolCallBuffer(textBuf);
if (result.safeText && onTextDelta) onTextDelta(result.safeText);
textBuf = result.remainder;
if (result.toolCalls.length > 0) toolCalls.push(...result.toolCalls);
reportStreamDelta(split.content.length);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
lineBuf += decoder.decode(value, { stream: true });
const lines = lineBuf.split('\n');
lineBuf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
let chunk;
try {
chunk = JSON.parse(line);
} catch (e) {
continue;
}
const thinkingPiece = chunk.message?.thinking || '';
if (thinkingPiece) {
fullThinking += thinkingPiece;
if (thinkOn) onThinkingDelta?.(thinkingPiece);
reportStreamDelta(thinkingPiece.length);
}
const content = chunk.message?.content || '';
if (content) emitContent(content);
if (chunk.message?.tool_calls) {
for (const tc of chunk.message.tool_calls) {
toolCalls.push({
name: tc.function?.name || tc.name,
arguments: typeof tc.function?.arguments === 'string'
? safeParse(tc.function.arguments)
: (tc.function?.arguments || {}),
});
}
}
if (chunk.done) {
evalCount = chunk.eval_count || evalCount;
}
}
}
if (lineBuf.trim()) {
try {
const chunk = JSON.parse(lineBuf);
if (chunk.message?.thinking) {
fullThinking += chunk.message.thinking;
if (thinkOn) onThinkingDelta?.(chunk.message.thinking);
}
if (chunk.message?.content) emitContent(chunk.message.content);
if (chunk.done) evalCount = chunk.eval_count || evalCount;
} catch (e) {}
}
const tail = thinkSplit.flush();
if (tail.thinking) {
fullThinking += tail.thinking;
if (thinkOn) onThinkingDelta?.(tail.thinking);
}
if (tail.content) {
textBuf += tail.content;
fullText += tail.content;
}
const final = finalizeToolCallBuffer(textBuf);
if (final.safeText && onTextDelta) onTextDelta(final.safeText);
if (final.toolCalls.length > 0) toolCalls.push(...final.toolCalls);
const cleanedText = stripThinkTags(cleanToolCallArtifacts(fullText));
if (evalCount > 0) {
const durationMs = performance.now() - t0;
reportTokens(evalCount, durationMs);
reportStreamEnd(evalCount);
}
return { text: cleanedText, thinking: fullThinking, toolCalls, evalCount };
}
function safeParse(s) {
try { return JSON.parse(s); } catch (e) { return { _raw: s }; }
}