303 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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";
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:<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;
// 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<string, string> = {
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);
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",
});
}
// 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();
}
});