ai-stream.ts133 lines · main
| 1 | import { env } from '../env.js'; |
| 2 | import { log } from '../lib/logger.js'; |
| 3 | |
| 4 | import { AiNotConfiguredError } from './ai-schema-gen.js'; |
| 5 | |
| 6 | /** |
| 7 | * Streaming variant of the ai-* services. The dashboard surfaces can |
| 8 | * now render tokens as they arrive instead of waiting up to 60s for |
| 9 | * the full response. The Ollama backend supports streaming natively |
| 10 | * via `stream: true` — the response body is a newline-delimited JSON |
| 11 | * stream where each line carries a `{response: "<chunk>", done: |
| 12 | * false}` event, with a final `{done: true, …}` marker. |
| 13 | * |
| 14 | * We re-emit those events as Server-Sent Events (SSE) so the browser |
| 15 | * can consume them via EventSource. SSE is supported natively on every |
| 16 | * modern browser, no extra library on the dashboard. |
| 17 | * |
| 18 | * Auth is the same as the non-streaming services — X-API-Key when |
| 19 | * BRIVEN_OLLAMA_API_KEY is set, no header otherwise. |
| 20 | */ |
| 21 | |
| 22 | export interface StreamInput { |
| 23 | system: string; |
| 24 | prompt: string; |
| 25 | model: string; |
| 26 | temperature: number; |
| 27 | timeoutMs?: number; |
| 28 | } |
| 29 | |
| 30 | export async function streamAiResponse(input: StreamInput): Promise<Response> { |
| 31 | if (!env.BRIVEN_OLLAMA_URL) { |
| 32 | throw new AiNotConfiguredError(); |
| 33 | } |
| 34 | |
| 35 | const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 36 | if (env.BRIVEN_OLLAMA_API_KEY) { |
| 37 | headers['x-api-key'] = env.BRIVEN_OLLAMA_API_KEY; |
| 38 | } |
| 39 | |
| 40 | const upstreamRes = await fetch(`${env.BRIVEN_OLLAMA_URL.replace(/\/$/, '')}/api/generate`, { |
| 41 | method: 'POST', |
| 42 | headers, |
| 43 | body: JSON.stringify({ |
| 44 | model: input.model, |
| 45 | system: input.system, |
| 46 | prompt: input.prompt, |
| 47 | options: { temperature: input.temperature }, |
| 48 | stream: true, |
| 49 | }), |
| 50 | signal: AbortSignal.timeout(input.timeoutMs ?? 120_000), |
| 51 | }); |
| 52 | |
| 53 | if (!upstreamRes.ok || !upstreamRes.body) { |
| 54 | const body = await upstreamRes.text().catch(() => ''); |
| 55 | log.warn('ai_stream_upstream_error', { |
| 56 | status: upstreamRes.status, |
| 57 | bodyPreview: body.slice(0, 240), |
| 58 | }); |
| 59 | throw new Error(`Ollama returned ${upstreamRes.status}`); |
| 60 | } |
| 61 | |
| 62 | // Translate the ollama ndjson stream into SSE. Each ollama line: |
| 63 | // {"response":"foo","done":false} |
| 64 | // becomes an SSE event: |
| 65 | // event: token |
| 66 | // data: foo |
| 67 | // |
| 68 | // The final ollama line is `{done:true, ...}` — we emit a final |
| 69 | // `event: done` so the client can dispose the EventSource cleanly. |
| 70 | const ollamaStream = upstreamRes.body; |
| 71 | const sseStream = new ReadableStream({ |
| 72 | async start(controller) { |
| 73 | const enc = new TextEncoder(); |
| 74 | const reader = ollamaStream.getReader(); |
| 75 | const dec = new TextDecoder(); |
| 76 | let buffer = ''; |
| 77 | try { |
| 78 | while (true) { |
| 79 | const { done, value } = await reader.read(); |
| 80 | if (done) break; |
| 81 | buffer += dec.decode(value, { stream: true }); |
| 82 | let idx; |
| 83 | while ((idx = buffer.indexOf('\n')) >= 0) { |
| 84 | const line = buffer.slice(0, idx).trim(); |
| 85 | buffer = buffer.slice(idx + 1); |
| 86 | if (!line) continue; |
| 87 | let parsed: { response?: string; done?: boolean }; |
| 88 | try { |
| 89 | parsed = JSON.parse(line) as { response?: string; done?: boolean }; |
| 90 | } catch { |
| 91 | continue; |
| 92 | } |
| 93 | if (parsed.response) { |
| 94 | controller.enqueue( |
| 95 | enc.encode(`event: token\ndata: ${jsonEscape(parsed.response)}\n\n`), |
| 96 | ); |
| 97 | } |
| 98 | if (parsed.done) { |
| 99 | controller.enqueue(enc.encode(`event: done\ndata: {}\n\n`)); |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | } catch (err) { |
| 104 | controller.enqueue( |
| 105 | enc.encode( |
| 106 | `event: error\ndata: ${jsonEscape(err instanceof Error ? err.message : 'stream error')}\n\n`, |
| 107 | ), |
| 108 | ); |
| 109 | } finally { |
| 110 | controller.close(); |
| 111 | } |
| 112 | }, |
| 113 | }); |
| 114 | |
| 115 | return new Response(sseStream, { |
| 116 | status: 200, |
| 117 | headers: { |
| 118 | 'content-type': 'text/event-stream; charset=utf-8', |
| 119 | 'cache-control': 'no-cache, no-transform', |
| 120 | // Disable buffering on proxies that respect this header (nginx). |
| 121 | 'x-accel-buffering': 'no', |
| 122 | }, |
| 123 | }); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * SSE data lines can't contain literal newlines without escaping. JSON- |
| 128 | * stringify handles that (and quotes everything), then strip the outer |
| 129 | * quotes since SSE doesn't need them. |
| 130 | */ |
| 131 | function jsonEscape(s: string): string { |
| 132 | return JSON.stringify(s).slice(1, -1); |
| 133 | } |