// Conversational chat over Ollama Cloud. // // 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 grounds the conversation in // the user's own application data via a context-rich system prompt. // // 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); function isConfigured() { return Boolean(process.env.OLLAMA_API_KEY); } // Stream a chat completion from Ollama. `messages` = [{role, content}] in // chronological order (system prompt is prepended separately). `onToken` is // called with each incremental text chunk as it arrives. Returns the full // assistant text once the stream finishes. Aborts cleanly via `signal`. async function streamChat({ system, messages, 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) allMessages.push({ role: m.role, content: m.content }); let res; try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: OLLAMA_MODEL, stream: true, options: { temperature }, messages: allMessages, }), 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 body = await res.text().catch(() => ''); throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.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 text = ((data && data.message && data.message.content) || '').trim(); if (onToken && text) onToken(text); return text; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let full = ''; // 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 delta = obj && obj.message && obj.message.content; if (delta) { full += delta; if (onToken) onToken(delta); } }; 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 full.trim(); } // Build the German system prompt that grounds the assistant in the user's // application data. `context` is gathered by the server from SQLite: // { settings, applications, recentEmails, upcomingTermine } // Each entry is already trimmed to the fields the prompt needs. 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 "-".' ); if (heute) { parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' + 'Terminangaben unten 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).'); } const apps = (ctx && ctx.applications) || []; if (apps.length) { parts.push('# Bewerbungen (jüngste zuerst)'); for (const a of apps) { const lines = [ `• ${a.firma || '?'} – ${a.stelle || '?'} (Status: ${a.status || '—'}, Datum: ${a.datum || '—'})`, ]; if (a.notizen) lines.push(` Notizen: ${a.notizen}`); parts.push(lines.join('\n')); } } const emails = (ctx && ctx.recentEmails) || []; if (emails.length) { parts.push('# Letzte eingegangene E-Mails'); for (const e of emails) { parts.push( `• ${e.subject || '(kein Betreff)'} — von ${e.from || '?'} (${e.bewerbung || '?'})` ); } } const termine = (ctx && ctx.upcomingTermine) || []; if (termine.length) { parts.push('# Kommende Termine'); for (const t of termine) { parts.push( `• ${t.titel} — ${t.start}${t.bewerbung ? ` (${t.bewerbung})` : ''}` ); } } parts.push( '# Aufgabe\n' + 'Beziehe dich bei Antworten auf den obigen Kontext, wenn relevant. ' + 'Wenn der Nutzer nach einer konkreten Bewerbung fragt, die im Kontext ' + 'nicht enthalten ist, weise darauf hin, dass du sie nicht findest.' ); return parts.join('\n\n'); } module.exports = { isConfigured, streamChat, buildContextPrompt, OLLAMA_MODEL };