224 lines
7.4 KiB
TypeScript
224 lines
7.4 KiB
TypeScript
/**
|
||
* 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: <text chunk>
|
||
*
|
||
* 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:<text>, overcome, share_success, rate_session, close, show_stats, need_help, feel:<text>
|
||
- 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();
|
||
const key = config.openrouterApiKey as string | undefined;
|
||
if (!key) {
|
||
throw createError({ statusCode: 503, message: "OpenRouter Key fehlt" });
|
||
}
|
||
|
||
// System-Prompt: Coach-Basis + SOS-Streaming-Regeln
|
||
const LANG: Record<string, string> = {
|
||
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);
|
||
|
||
const upstream = await fetch(
|
||
"https://openrouter.ai/api/v1/chat/completions",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${key}`,
|
||
"Content-Type": "application/json",
|
||
"HTTP-Referer": "https://rebreak.org",
|
||
"X-Title": "ReBreak SOS",
|
||
},
|
||
body: JSON.stringify({
|
||
model: "anthropic/claude-sonnet-4.5",
|
||
max_tokens: 400,
|
||
stream: true,
|
||
messages: [{ role: "system", content: systemPrompt }, ...trimmed],
|
||
}),
|
||
},
|
||
);
|
||
|
||
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<Uint8Array>({
|
||
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);
|
||
});
|