/** * GET /api/coach/sos-stream?session=xyz — Streaming SOS Coach (Claude Sonnet 4.5) * * Streamt Sonnets Antwort als SSE (Server-Sent Events). * Frontend nutzt react-native-sse (EventSource) für progressives Streaming. * * Format (SSE-Standard): * event: message * data: * * event: chips * data: [{"label":"...","action":"..."}] * * Flow: * 1. Client POSTet zu /api/coach/sos-session → { sessionId } * 2. Client öffnet GET /api/coach/sos-stream?session=xyz via EventSource * 3. Backend lädt Session-Daten (messages/locale) aus In-Memory Store * 4. Streamt Antwort als SSE-Events * * Fallback: bei Sonnet-Fehler wirft 503; Frontend kann auf /coach/message zurückfallen. */ import { COACH_SYSTEM_PROMPT } from "./message.post"; import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; const SOS_INSTRUCTION = `\n\nDU BEFINDEST DICH IN EINEM AKUTEN SOS-MOMENT. WICHTIGE REGELN: - Antworte als REINER TEXT, KEINE JSON-Wrapper, KEIN Markdown, KEINE Aufzählungen. - Sei warm, präsent, menschlich — wie eine echte Freundin am Telefon. - KURZ: 1-2 Sätze, max 3 nur in seltenen Ausnahmen. Ruhiger Rhythmus mit kurzen Pausen. - Validiere zuerst das Gefühl, dann sanfte Frage ODER Vorschlag. ABSOLUT KRITISCH — NIEMALS die Chip-Optionen im Prosa-Text auflisten oder paraphrasieren. Der Prosa-Text wird dem User VORGELESEN (TTS) — Chip-Aufzählung klingt unnatürlich ("warum sprichst du eine Liste?"). Die Chips erscheinen visuell als Buttons. ✗ FALSCH: "Magst du atmen oder lieber spielen?" (= Aufzählung) ✗ FALSCH: "Du kannst eine Atemübung oder ein Spiel machen." ✗ FALSCH: "Hier sind ein paar Optionen: Atmen, Spielen oder Reden." ✗ FALSCH: "Magst du mit mir reden, eine Atemübung machen oder ein Spiel?" ✓ RICHTIG: "Magst du was probieren?" ✓ RICHTIG: "Was hilft dir gerade?" ✓ RICHTIG: "Hier hast du Möglichkeiten." ✓ RICHTIG: "Was passt für dich grad?" ✓ RICHTIG: "Ich bin da. Was brauchst du jetzt?" - Am ENDE der Antwort genau EINE neue Zeile mit Chips im Format: [[CHIPS]]:[{"label":"…","action":"…"},…] - Erlaubte Chip-Actions: breathing, game_picker, send_text:, overcome, share_success, rate_session, close, show_stats, need_help, feel: - KEIN Text nach der CHIPS-Zeile`; export default defineEventHandler(async (event) => { const user = await requireUser(event); // Session-ID aus Query-Parameter holen const query = getQuery(event); const sessionId = query.session as string | undefined; if (!sessionId) { throw createError({ statusCode: 400, message: "session query param fehlt", }); } // Session-Daten laden (messages + locale) const { getSosSession, deleteSosSession } = await import( "../../utils/sosSessions" ); const sessionData = getSosSession(sessionId); if (!sessionData) { throw createError({ statusCode: 404, message: "Session nicht gefunden oder abgelaufen (TTL 5min)", }); } // Security: Session gehört diesem User if (sessionData.userId !== user.id) { throw createError({ statusCode: 403, message: "Nicht deine Session" }); } const { messages, locale } = sessionData; // Session löschen (One-Time-Use) deleteSosSession(sessionId); const config = useRuntimeConfig(); // System-Prompt: Coach-Basis + SOS-Streaming-Regeln const LANG: Record = { de: "Antworte IMMER auf Deutsch.", en: "Always respond in English.", tr: "Her zaman Türkçe yanıt ver.", ar: "رد دائماً باللغة العربية.", }; const lang = LANG[locale ?? "de"] ?? LANG.de; // Memory-Injection: Lyra-Erinnerungen aus früheren Sessions laden let memoryBlock = ""; let loadedMemoryIds: string[] = []; try { const memories = await getMemoriesForUser(user.id); if (memories.length > 0) { loadedMemoryIds = memories.map((m) => m.id); const TYPE_LABELS: Record = { trigger: "Trigger", habit: "Gewohnheit", strength: "Stärke", relationship: "Wichtige Person", milestone: "Meilenstein", pain_point: "Sensibles Thema", goal: "Ziel", preference: "Präferenz", }; const lines = memories .map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`) .join("\n"); memoryBlock = `[WAS DU ÜBER DIESEN USER WEISST — aus früheren Gesprächen]\n${lines}\nNutze diese Infos um GENAU diesen Menschen anzusprechen — wie ein echter Begleiter, nicht eine generische KI. Sprich Personen mit Namen an. Erinnere an Stärken die dir bekannt sind.\n\n`; console.log( `[lyra-memory] injected ${memories.length} memories for ${user.id}`, ); } } catch (e) { // Nicht kritisch — Memory-Fehler dürfen SOS nicht blockieren console.error("[lyra-memory] load error (non-fatal):", e); } const systemPrompt = `${memoryBlock}${lang}\n\n${COACH_SYSTEM_PROMPT.replace("{{PLAN_DETAILS}}", "")}${SOS_INSTRUCTION}`; // Erste Nachricht muss user sein const firstUserIdx = messages.findIndex((m) => m.role === "user"); const conversation = firstUserIdx > 0 ? messages.slice(firstUserIdx) : messages; const trimmed = conversation.slice(-8); // LLM-Routing: client schickt llmProvider via /sos-session-Body (Toggle). // Default openrouter-sonnet. sort:latency bei Anthropic über OR spart 30-58% TTFB // (server-curl-bench gemessen). Groq bypasst OpenRouter-Hop für ~157ms TTFB. const llmProvider = sessionData.llmProvider ?? "openrouter-sonnet"; let upstreamUrl: string; let upstreamKey: string | undefined; let upstreamModel: string; const upstreamHeaders: Record = { "Content-Type": "application/json" }; let upstreamProviderField: { sort: string } | undefined; if (llmProvider === "groq-llama") { upstreamUrl = "https://api.groq.com/openai/v1/chat/completions"; upstreamKey = config.groqApiKey as string | undefined; upstreamModel = "llama-3.3-70b-versatile"; } else { upstreamUrl = "https://openrouter.ai/api/v1/chat/completions"; upstreamKey = config.openrouterApiKey as string | undefined; upstreamModel = llmProvider === "openrouter-haiku" ? "anthropic/claude-haiku-4.5" : "anthropic/claude-sonnet-4.5"; upstreamHeaders["HTTP-Referer"] = "https://rebreak.org"; upstreamHeaders["X-Title"] = "ReBreak SOS"; upstreamProviderField = { sort: "latency" }; } if (!upstreamKey) { throw createError({ statusCode: 503, message: `API key for ${llmProvider} fehlt` }); } upstreamHeaders.Authorization = `Bearer ${upstreamKey}`; console.log(`[coach/sos-stream] using provider=${llmProvider} model=${upstreamModel}`); const upstream = await fetch(upstreamUrl, { method: "POST", headers: upstreamHeaders, body: JSON.stringify({ model: upstreamModel, max_tokens: 400, stream: true, messages: [{ role: "system", content: systemPrompt }, ...trimmed], ...(upstreamProviderField ? { provider: upstreamProviderField } : {}), }), }); if (!upstream.ok || !upstream.body) { const errText = await upstream.text().catch(() => ""); console.error( "[coach/sos-stream] upstream error:", upstream.status, errText.slice(0, 300), ); throw createError({ statusCode: 502, message: "SOS-Stream nicht verfügbar", }); } // Direkt zu Node res schreiben — sendStream(ReadableStream) pumpt pull() in Nitro nicht zuverlässig const res = event.node.res; res.statusCode = 200; res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Accel-Buffering", "no"); res.setHeader("Connection", "keep-alive"); res.flushHeaders?.(); const write = (chunk: string) => { try { res.write(chunk); } catch (e) { console.error("[coach/sos-stream] write error:", e); } }; console.log( `[coach/sos-stream] stream started for ${user.id}, session ${sessionId}`, ); write(": connected\n\n"); const reader = upstream.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let fullText = ""; let chunkCount = 0; // Client disconnect detection let aborted = false; res.on("close", () => { aborted = true; reader.cancel().catch(() => {}); }); let chipsMarkerSeen = false; try { while (!aborted) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { const trimLine = line.trim(); if (!trimLine || !trimLine.startsWith("data:")) continue; const payload = trimLine.slice(5).trim(); if (payload === "[DONE]") continue; try { const json = JSON.parse(payload) as { choices?: { delta?: { content?: string } }[]; }; const delta = json.choices?.[0]?.delta?.content; if (delta) { fullText += delta; chunkCount++; // ─── CHIPS-Marker nicht streamen ─── if (chipsMarkerSeen) continue; // alles nach Marker = Chips-JSON // Prüfe ob fullText jetzt den Marker enthält const markerStart = fullText.indexOf("[["); if (markerStart >= 0) { // Marker (oder Anfang davon) erkannt — nur sicheren Teil bis "[[" senden const safeText = fullText.slice(0, markerStart); const alreadySent = fullText.length - delta.length; if (safeText.length > alreadySent) { const toSend = safeText.slice(alreadySent); // JSON-encode → Whitespace bleibt erhalten write(`event: message\ndata: ${JSON.stringify(toSend)}\n\n`); } // Wenn vollständiger Marker da: ab jetzt nichts mehr streamen if (fullText.indexOf("[[CHIPS]]:") >= 0) { chipsMarkerSeen = true; } continue; } // Normales Delta — JSON-encoded senden (preserved Whitespace + Newlines) write(`event: message\ndata: ${JSON.stringify(delta)}\n\n`); } } catch { // partial line, ignore } } } // Stream zu Ende → [[CHIPS]]: aus fullText extrahieren const markerIdx = fullText.indexOf("[[CHIPS]]:"); let chips: unknown[] = []; if (markerIdx >= 0) { const chipsRaw = fullText.slice(markerIdx + "[[CHIPS]]:".length); try { chips = JSON.parse(chipsRaw.trim()); } catch { console.warn( "[sos-stream] chips parse failed:", chipsRaw.slice(0, 100), ); } } if (chips.length > 0) { write(`event: chips\ndata: ${JSON.stringify(chips)}\n\n`); } write("event: done\ndata: {}\n\n"); console.log( `[coach/sos-stream] stream done, ${chunkCount} chunks, ${fullText.length} chars`, ); // Memory-Extraction: fire-and-forget nach Stream-Ende // markReferenced + async Extraction laufen parallel, blockieren nichts if (loadedMemoryIds.length > 0) { markReferenced(loadedMemoryIds).catch(() => {}); } const allMessages: Array<{ role: string; content: string }> = [ ...messages, { role: "assistant", content: fullText.split("[[CHIPS]]:")[0].trim() }, ]; extractAndStoreMemories(user.id, allMessages, sessionId, key).catch( () => {}, ); } catch (err) { console.error("[coach/sos-stream] read error:", err); write(`event: error\ndata: {"error":"stream failed"}\n\n`); } finally { res.end(); } });