// Conversational chat over Ollama Cloud with tool calling. // // The rest of the app uses the LLM as a one-shot generator (cover letters, // e-mail replies). This module adds an interactive chat: it streams assistant // tokens from Ollama (`stream: true`, NDJSON) and lets the assistant look up // the user's application data on demand via tool calls instead of having every // application baked into the system prompt (which would be slow and grow with // the data). // // Unlike lib/documents.js, no JSON output schema is enforced — the model just // replies as text, token by token, so the UI can render progressively. const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000); const MAX_TOOL_ROUNDS = 4; function isConfigured() { return Boolean(process.env.OLLAMA_API_KEY); } // Stream a single chat completion from Ollama. `messages` = [{role, content}] // in chronological order (system prompt is prepended separately). `tools` is an // optional Ollama tool-definition array; when the model calls a tool the // returned `toolCalls` is non-empty and `content` is usually empty. `onToken` // fires with each incremental text chunk. Returns { content, toolCalls }. // Aborts cleanly via `signal`. async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.'); const allMessages = []; if (system) allMessages.push({ role: 'system', content: system }); for (const m of messages) { // Preserve tool_calls (assistant) and tool_name (tool result) so Ollama can // match tool calls to their results across rounds. const msg = { role: m.role, content: m.content }; if (m.tool_calls) msg.tool_calls = m.tool_calls; if (m.tool_name) msg.tool_name = m.tool_name; allMessages.push(msg); } const body = { model: OLLAMA_MODEL, stream: true, options: { temperature }, messages: allMessages, }; if (tools && tools.length) body.tools = tools; let res; try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(body), signal, }); } catch (err) { if (err.name === 'AbortError') throw new Error('Abgebrochen.'); throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Ollama-API antwortete mit ${res.status}: ${text.slice(0, 300)}`); } if (!res.body || !res.body.getReader) { // Node without streaming body support: fall back to buffered response. const data = await res.json(); const msg = (data && data.message) || {}; const text = (msg.content || '').trim(); if (onToken && text) onToken(text); return { content: text, toolCalls: normalizeToolCalls(msg.tool_calls) }; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let full = ''; const toolCalls = []; // Ollama streams one JSON object per line (NDJSON). Accumulate partial // lines across chunks, then parse each complete line. const handleLine = (line) => { line = line.trim(); if (!line) return; let obj; try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives const msg = obj && obj.message; if (!msg) return; const delta = msg.content; if (delta) { full += delta; if (onToken) onToken(delta); } if (msg.tool_calls && msg.tool_calls.length) { // Ollama emits tool_calls (complete) on the final chunk; capture them. toolCalls.length = 0; toolCalls.push(...normalizeToolCalls(msg.tool_calls)); } }; while (true) { let chunk; try { chunk = await reader.read(); } catch (err) { if (err.name === 'AbortError') throw new Error('Abgebrochen.'); throw err; } if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); let nl; while ((nl = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); handleLine(line); } } handleLine(buffer); // flush trailing line return { content: full.trim(), toolCalls }; } // Coerce Ollama's tool_calls into a stable shape: [{function:{name, arguments}}] // where arguments is always a plain object (Ollama sometimes sends a string). function normalizeToolCalls(raw) { if (!Array.isArray(raw)) return []; const out = []; for (const tc of raw) { const fn = (tc && tc.function) || {}; let args = fn.arguments; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { args = {}; } } out.push({ function: { name: fn.name, arguments: args || {} } }); } return out; } // Run a full agentic turn: stream the model's reply; if it calls tools, // execute them (via `executeTool(name, args) -> any JSON-serialisable value`), // feed the results back, and loop until the model answers in plain text (or // MAX_TOOL_ROUNDS is hit). `onToken` streams the final text answer; `onToolCall` // fires for each tool invocation so the UI can show what's being looked up. async function runChat({ system, messages, tools, executeTool, onToken, onToolCall, signal, temperature = 0.6 }) { const convo = messages.map((m) => ({ role: m.role, content: m.content })); for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { const { content, toolCalls } = await streamChat({ system, messages: convo, tools, onToken, temperature, signal, }); if (!toolCalls || !toolCalls.length) return content; // Append the assistant's tool-call message verbatim so the model sees its // own call in the next round, then one tool-result message per call. convo.push({ role: 'assistant', content: content || '', tool_calls: toolCalls, }); for (const tc of toolCalls) { const name = tc.function.name; if (onToolCall) onToolCall(name, tc.function.arguments); let result; try { result = await executeTool(name, tc.function.arguments); } catch (err) { result = { error: err.message || String(err) }; } convo.push({ role: 'tool', tool_name: name, content: JSON.stringify(result), }); } } throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.'); } // Build the German system prompt that grounds the assistant. `context` is the // lightweight core gathered by the server: { heute, settings, profil }. // Application data is NOT baked in — the assistant fetches it via tools. function buildContextPrompt(ctx) { const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber'; const heute = (ctx && ctx.heute) || ''; const parts = []; parts.push( 'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für ' + name + '. ' + 'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' + 'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' + 'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' + 'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' + 'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' + 'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-". ' + 'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' + 'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.' ); parts.push( '# Werkzeuge\n' + 'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' + 'Nutzer nach einer konkreten Bewerbung, Firma, Status, Stellenbeschreibung, ' + 'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' + 'ab, die du per Werkzeug holen kannst. Lege mehrere benötigte Werkzeuge in ' + 'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' + 'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).' ); if (heute) { parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' + 'Terminangaben aus Werkzeugen sind UTC-Zeitstempel (ISO, endet auf Z). ' + 'Rechne in UTC-Zeiten und leite daraus ab, was "heute", "morgen" usw. ist. ' + 'Zeige dem Nutzer Terminzeiten in seiner lokalen Zeit (Europe/Berlin = UTC+1, ' + 'bzw. UTC+2 während der Sommerzeit).'); } if (ctx && ctx.profil) { parts.push('# Dein Profil (Lebenslauf)\n' + ctx.profil + '\nNutze dies, um Antworten auf den Bewerber zuzuschneiden: Wenn der Nutzer ' + 'nach einer Firma fragt, hebe hervor, welche Anforderungen der Stelle zu ' + 'seinem Profil passen und wo Lücken bestehen.'); } return parts.join('\n\n'); } module.exports = { isConfigured, streamChat, runChat, buildContextPrompt, OLLAMA_MODEL, MAX_TOOL_ROUNDS };