// SSE Streaming Helper für Lyras SOS-Antworten. // // Architektur (bewusst entkoppelt): // - Step 1: POST /api/coach/sos-session → liefert sessionId // - Step 2: EventSource auf /api/coach/sos-stream?session= // - Events: 'message' (chunk), 'chips' (parsed), 'done', 'error' // // Phase B (sentence-level TTS): zusätzlich `onSentence?` Callback. Wenn gesetzt, // feuert er sobald ein vollständiger Satz erkannt wird (live während des // Streams) + einmal am Stream-Ende für den Tail. Aufrufer kann den Satz dann // direkt in eine TTS-Queue schieben → erste Audio-Wiedergabe ~3s früher als // "warten bis fullText fertig". import EventSource from 'react-native-sse'; import type { BenchOnMetric } from './sosTtsBenchmark'; import type { LlmProvider } from './llmProvider'; type SseEvents = 'message' | 'chips' | 'done'; export type StreamSosLyraOpts = { apiBase: string; token: string; messages: Array<{ role: 'user' | 'assistant'; content: string }>; locale: string; /** LLM-Provider-Switch: bestimmt welches Modell der Server für diese Session * benutzt. Default (undefined) → openrouter-sonnet auf Server-Seite. */ llmProvider?: LlmProvider; onTextUpdate: (full: string) => void; onChips: (chips: Array<{ label: string; action: string }>) => void; /** Phase B: feuert pro fertigem Satz live während des Streams + Tail beim * done. Bei aktivem TTS-Streaming sollte der Aufrufer hier seine Queue * füllen statt im onDone den ganzen Text zu sprechen. */ onSentence?: (sentence: string) => void; onDone: (full: string) => void; onError: (err: unknown) => void; /** Latenz-Benchmark: feuert session-post-start, session-post-done, * sse-first-chunk, sse-done. Siehe lib/sosTtsBenchmark.ts. */ onMetric?: BenchOnMetric; }; // Min-Länge für sentence-level TTS — winzige "Hm." / "Ja." kommen mit dem // nächsten Satz mit, sonst klingt's choppy. const MIN_SENTENCE_CHARS = 8; /** * Findet vollständige Sätze im Text. Ein Satz endet bei `[.!?]` GEFOLGT VON * Whitespace + Großbuchstaben (oder Zitat-Anfang). Das filtert die häufigen * deutschen Abkürzungen "z.B. einfach", "d.h. nichts" etc. ohne explizite * Abkürzungs-Liste — der nächste Char ist dann meistens lowercase. * * Returns: { sentences[], consumed } — wieviele chars vom Anfang von `text` * bereits in `sentences` enthalten sind (inkl. trailing whitespace). */ function consumeCompletedSentences(text: string): { sentences: string[]; consumed: number } { const sentences: string[] = []; const re = /[.!?](?=\s+[A-ZÄÖÜ"„])/g; let lastEnd = 0; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { const endOfSentence = m.index + 1; sentences.push(text.slice(lastEnd, endOfSentence)); const wsMatch = text.slice(endOfSentence).match(/^\s+/); lastEnd = endOfSentence + (wsMatch ? wsMatch[0].length : 0); re.lastIndex = lastEnd; } return { sentences, consumed: lastEnd }; } export async function streamSosLyra(opts: StreamSosLyraOpts): Promise<() => void> { // Step 1: POST zu /api/coach/sos-session → sessionId holen opts.onMetric?.('session-post-start'); const sessRes = await fetch(`${opts.apiBase}/api/coach/sos-session`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.token}`, }, body: JSON.stringify({ messages: opts.messages, locale: opts.locale, llmProvider: opts.llmProvider }), }); if (!sessRes.ok) throw new Error(`session: ${sessRes.status}`); const { sessionId } = await sessRes.json(); opts.onMetric?.('session-post-done'); // Step 2: EventSource für SSE-Stream // pollingInterval: 0 → KEIN Auto-Reconnect (Session ist one-time-use) const es = new EventSource(`${opts.apiBase}/api/coach/sos-stream?session=${sessionId}`, { headers: { Authorization: `Bearer ${opts.token}` }, pollingInterval: 0, lineEndingCharacter: '\n', }); let fullText = ''; let sentenceConsumedIndex = 0; let firstChunkSeen = false; const flushNewSentences = () => { if (!opts.onSentence) return; const remaining = fullText.slice(sentenceConsumedIndex); const { sentences, consumed } = consumeCompletedSentences(remaining); for (const s of sentences) { const trimmed = s.trim(); if (trimmed.length >= MIN_SENTENCE_CHARS) { opts.onSentence(trimmed); } } sentenceConsumedIndex += consumed; }; es.addEventListener('message', (event) => { if (!event.data) return; // Backend sendet JSON-encoded String → parse für korrekte Whitespace-Behandlung let chunk: string; try { chunk = JSON.parse(event.data); } catch { chunk = event.data; } if (!chunk) return; if (!firstChunkSeen) { firstChunkSeen = true; opts.onMetric?.('sse-first-chunk'); } fullText += chunk; opts.onTextUpdate(fullText); // Phase B: live sentence-detection für TTS-Queue flushNewSentences(); }); es.addEventListener('chips', (event) => { if (!event.data) return; try { const chips = JSON.parse(event.data); if (Array.isArray(chips)) opts.onChips(chips); } catch { /* ignore */ } }); es.addEventListener('done', () => { opts.onMetric?.('sse-done'); // Phase B: Tail flushen (letzter Satz ohne folgendes Capital-Letter wird // sonst nie als "complete" erkannt). Trim leeren Tail away. if (opts.onSentence) { const tail = fullText.slice(sentenceConsumedIndex).trim(); if (tail.length > 0) { opts.onSentence(tail); } } opts.onDone(fullText); es.close(); }); es.addEventListener('error', (err) => { opts.onError(err); es.close(); }); // Return cancel-Funktion return () => { es.close(); }; }