/** * 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"; 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 - 2-4 Sätze, ruhiger Rhythmus mit kurzen Pausen (Sätze klar trennen mit . oder !) - Validiere zuerst das Gefühl, dann sanfte Frage ODER Vorschlag - 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; const systemPrompt = `${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", }); } setHeader(event, "Content-Type", "text/event-stream; charset=utf-8"); setHeader(event, "Cache-Control", "no-store"); setHeader(event, "X-Accel-Buffering", "no"); setHeader(event, "Connection", "keep-alive"); // OpenRouter SSE → parse deltas → SSE-Format für react-native-sse const reader = upstream.body.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; let fullText = ""; const stream = new ReadableStream({ start(controller) { // SSE comment als keepalive (react-native-sse braucht kein Padding) controller.enqueue(encoder.encode(": connected\n\n")); }, async pull(controller) { try { const { value, done } = await reader.read(); if (done) { // Stream zu Ende → [[CHIPS]]: aus fullText extrahieren + als event senden const markerIdx = fullText.indexOf("[[CHIPS]]:"); let message = fullText; let chips: any[] = []; if (markerIdx >= 0) { message = fullText.slice(0, markerIdx).trim(); const chipsRaw = fullText.slice(markerIdx + "[[CHIPS]]:".length); try { chips = JSON.parse(chipsRaw.trim()); } catch { console.warn("[sos-stream] chips parse failed:", chipsRaw); } } // Chips als separates SSE-Event if (chips.length > 0) { controller.enqueue( encoder.encode( `event: chips\ndata: ${JSON.stringify(chips)}\n\n`, ), ); } // Finales done-Event controller.enqueue(encoder.encode("event: done\ndata: {}\n\n")); controller.close(); return; } 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; // SSE-Spec: Newlines im Payload müssen als separate `data:`-Zeilen kodiert werden const dataLines = delta .split("\n") .map((l: string) => `data: ${l}`) .join("\n"); const sseChunk = `event: message\n${dataLines}\n\n`; controller.enqueue(encoder.encode(sseChunk)); } } catch { // Ignore parse errors on partial lines } } } catch (err) { console.error("[coach/sos-stream] read error:", err); controller.enqueue( encoder.encode( `event: error\ndata: ${JSON.stringify({ error: "stream failed" })}\n\n`, ), ); controller.close(); } }, cancel() { reader.cancel().catch(() => {}); }, }); console.log( `[coach/sos-stream] stream started for ${user.id}, session ${sessionId}`, ); return sendStream(event, stream as never); });