| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- import { config } from "../config/index.js";
- import { createTtlCache } from "./simpleCache.js";
- function timeoutSignal(externalSignal, timeoutMs) {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(new Error(`ollama_timeout:${timeoutMs}ms`)), timeoutMs);
- const onExternalAbort = () => controller.abort(externalSignal.reason);
- if (externalSignal) {
- if (externalSignal.aborted) controller.abort(externalSignal.reason);
- else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
- }
- return {
- signal: controller.signal,
- cleanup() {
- clearTimeout(timer);
- externalSignal?.removeEventListener("abort", onExternalAbort);
- }
- };
- }
- async function ollamaFetch(path, body, { signal } = {}) {
- const { signal: combinedSignal, cleanup } = timeoutSignal(signal, config.ollama.timeoutMs);
- let res;
- try {
- res = await fetch(`${config.ollama.url}${path}`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- signal: combinedSignal
- });
- } finally {
- cleanup();
- }
- if (!res.ok) {
- const text = await res.text().catch(() => "");
- let msg = `ollama_error:${res.status}:${text || res.statusText}`;
- if (
- res.status === 404 &&
- typeof body?.model === "string" &&
- typeof text === "string" &&
- text.toLowerCase().includes("model") &&
- text.toLowerCase().includes("not found")
- ) {
- let installed = [];
- try {
- const tagsRes = await fetch(`${config.ollama.url}/api/tags`);
- const tags = await tagsRes.json().catch(() => ({}));
- installed = Array.isArray(tags?.models) ? tags.models.map((m) => m?.name).filter(Boolean) : [];
- } catch {}
- const installedList = installed.length ? installed.slice(0, 10).join(",") : "none";
- msg = `ollama_model_not_found:${body.model}:installed=${installedList}:hint=ollama pull ${body.model}`;
- }
- const err = new Error(msg);
- err.statusCode = 502;
- throw err;
- }
- return res.json();
- }
- function embedPrefixFor(role) {
- if (role === "query") return config.ollama.embedQueryPrefix;
- if (role === "passage") return config.ollama.embedPassagePrefix;
- return "";
- }
- const EMBED_CACHE_TTL_MS = 15 * 60 * 1000;
- const embedCache = createTtlCache(EMBED_CACHE_TTL_MS);
- async function embedSingle(text, { role, model } = {}) {
- const effectiveModel = model ?? config.ollama.embeddingsModel;
- const cacheKey = `${effectiveModel}:${role ?? ""}:${text}`;
- const cached = embedCache.get(cacheKey);
- if (cached) return cached;
- const prefix = embedPrefixFor(role);
- const data = await ollamaFetch("/api/embeddings", {
- model: effectiveModel,
- prompt: `${prefix}${text}`
- });
- embedCache.set(cacheKey, data.embedding);
- return data.embedding;
- }
- export async function embedTexts(texts, { role, model } = {}) {
- const BATCH = 10;
- const results = [];
- for (let i = 0; i < texts.length; i += BATCH) {
- const batch = texts.slice(i, i + BATCH);
- const embeddings = await Promise.all(batch.map((text) => embedSingle(text, { role, model })));
- results.push(...embeddings);
- }
- return results;
- }
- export async function chatCompletion({ messages, options, format, model, signal }) {
- const data = await ollamaFetch(
- "/api/chat",
- {
- model: model || config.ollama.chatModel,
- messages,
- stream: false,
- ...(format && { format }),
- ...(options && Object.keys(options).length > 0 && { options })
- },
- { signal }
- );
- return {
- content: data?.message?.content ?? ""
- };
- }
- export async function chatCompletionStream({ messages, onChunk, signal, options }) {
- const idleTimeoutMs = config.ollama.streamIdleTimeoutMs;
- const idleController = new AbortController();
- let idleTimer = setTimeout(
- () => idleController.abort(new Error(`ollama_stream_idle_timeout:${idleTimeoutMs}ms`)),
- idleTimeoutMs
- );
- const resetIdleTimer = () => {
- clearTimeout(idleTimer);
- idleTimer = setTimeout(
- () => idleController.abort(new Error(`ollama_stream_idle_timeout:${idleTimeoutMs}ms`)),
- idleTimeoutMs
- );
- };
- const onExternalAbort = () => idleController.abort(signal.reason);
- if (signal) {
- if (signal.aborted) idleController.abort(signal.reason);
- else signal.addEventListener("abort", onExternalAbort, { once: true });
- }
- try {
- const res = await fetch(`${config.ollama.url}/api/chat`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- model: config.ollama.chatModel,
- messages,
- stream: true,
- ...(options && Object.keys(options).length > 0 && { options })
- }),
- signal: idleController.signal
- });
- if (!res.ok) {
- const text = await res.text().catch(() => "");
- const err = new Error(`ollama_error:${res.status}:${text || res.statusText}`);
- err.statusCode = 502;
- throw err;
- }
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let fullContent = "";
- let buffer = "";
- const processLine = (line) => {
- if (!line.trim()) return;
- try {
- const parsed = JSON.parse(line);
- const delta = parsed?.message?.content ?? "";
- if (delta) {
- fullContent += delta;
- onChunk(delta);
- }
- } catch {}
- };
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- resetIdleTimer();
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() ?? "";
- for (const line of lines) processLine(line);
- }
- buffer += decoder.decode();
- processLine(buffer);
- return { content: fullContent };
- } finally {
- clearTimeout(idleTimer);
- signal?.removeEventListener("abort", onExternalAbort);
- }
- }
- export async function visionExtractFromImage({ imageBase64, prompt }) {
- const data = await ollamaFetch("/api/chat", {
- model: config.ollama.visionModel,
- messages: [
- {
- role: "user",
- content: prompt,
- images: [imageBase64]
- }
- ],
- stream: false
- });
- return {
- content: data?.message?.content ?? ""
- };
- }
|