diff --git a/apps/rebreak-native/app/lyra.tsx b/apps/rebreak-native/app/lyra.tsx index e6a7c71..24b8e18 100644 --- a/apps/rebreak-native/app/lyra.tsx +++ b/apps/rebreak-native/app/lyra.tsx @@ -33,15 +33,7 @@ import { apiFetch } from '../lib/api'; import { supabase } from '../lib/supabase'; import { useColors } from '../lib/theme'; import { useThemeStore } from '../stores/theme'; - -const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i; -const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i; - -function detectEmotion(text: string): Emotion { - if (HAPPY_RE.test(text)) return 'happy'; - if (EMPATHY_RE.test(text)) return 'empathy'; - return 'idle'; -} +import { detectEmotion } from '../lib/lyraResponse'; function formatDuration(s: number): string { const m = Math.floor(s / 60); @@ -364,14 +356,17 @@ export default function CoachScreen() { try { const apiBase = Constants.expoConfig?.extra?.apiUrl as string; const session = (await supabase.auth.getSession()).data.session; - const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, { + const speakUrl = `${apiBase}/api/coach/speak`; + console.log('[tts] POST', speakUrl, 'text-len:', res.message.length); + const ttsRes = await fetch(speakUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), }, - body: JSON.stringify({ text: res.message, locale: i18n.language }), + body: JSON.stringify({ text: res.message, mode: 'chat' }), }); + console.log('[tts] response status:', ttsRes.status, 'content-type:', ttsRes.headers.get('content-type')); if (ttsRes.ok) { // Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben. const buffer = await ttsRes.arrayBuffer(); diff --git a/apps/rebreak-native/components/RiveAvatar.tsx b/apps/rebreak-native/components/RiveAvatar.tsx index 254e180..8f55344 100644 --- a/apps/rebreak-native/components/RiveAvatar.tsx +++ b/apps/rebreak-native/components/RiveAvatar.tsx @@ -39,18 +39,21 @@ function preloadRiveAsset(): Promise { // Render bereits die cached URI nutzt (außer im allerersten App-Start). preloadRiveAsset(); -export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy'; +// Supported emotions sind durch state-machine im .riv-file definiert. +// Neue states: nur EMOTION_ANIMATIONS + EMOTION_LABELS erweitern, kein weiterer Code-Change nötig. +export type SupportedEmotion = 'idle' | 'happy' | 'thinking' | 'empathy'; +export type Emotion = SupportedEmotion | (string & {}); // Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue). // "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop. -const EMOTION_ANIMATIONS: Record = { +const EMOTION_ANIMATIONS: Record = { idle: 'Idle Loop', happy: 'idle to Pose 1', thinking: 'WALK', empathy: '01 Wave 1', }; -const EMOTION_LABELS: Record = { +const EMOTION_LABELS: Record = { idle: 'bereit', happy: 'froh für dich', thinking: 'überlegt ...', @@ -67,13 +70,16 @@ type Props = { emotion: Emotion; size?: 'sm' | 'md' | 'lg'; showLabel?: boolean; + fallback?: Emotion; }; -export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) { +export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback = 'idle' }: Props) { const px = SIZE_PX[size]; + const resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback; + // Aktuelle Animation als deklarativer State (kein imperatives ref.play()). - const [currentAnim, setCurrentAnim] = useState(EMOTION_ANIMATIONS.idle); + const [currentAnim, setCurrentAnim] = useState(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle); // Lokale URI für die .riv-Datei — geht über expo-asset damit der File // im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen. @@ -92,14 +98,14 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) { }, [riveUri]); useEffect(() => { - if (emotion === 'happy') { + if (resolvedEmotion === 'happy') { // 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar) setCurrentAnim('idle to Pose 1'); const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900); return () => clearTimeout(t); } - setCurrentAnim(EMOTION_ANIMATIONS[emotion]); - }, [emotion]); + setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle); + }, [resolvedEmotion]); return ( @@ -158,7 +164,7 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) { {showLabel && ( - {EMOTION_LABELS[emotion]} + {EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''} )} diff --git a/apps/rebreak-native/lib/lyraResponse.ts b/apps/rebreak-native/lib/lyraResponse.ts index 5c4f2cf..83df91c 100644 --- a/apps/rebreak-native/lib/lyraResponse.ts +++ b/apps/rebreak-native/lib/lyraResponse.ts @@ -1,8 +1,8 @@ // Parser für Lyras LLM-JSON-Antworten + Emotion-Detection. -import type { Emotion as LyraEmotion } from '../components/RiveAvatar'; import { EMPATHY_RE, HAPPY_RE } from './sosConstants'; +import type { Emotion } from '../components/RiveAvatar'; -export type { LyraEmotion }; +export type LyraEmotion = Emotion; export type ChipSpec = { label: string; action: string }; // Parse LLM JSON response — robust gegen Markdown-Fences UND abgeschnittenes JSON diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index 79c3daa..ca431a6 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -243,6 +243,7 @@ import { PLAN_LIMITS } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; +import { stripMarkdown } from "../../utils/strip-markdown"; /** * Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren. @@ -599,6 +600,11 @@ export default defineEventHandler(async (event) => { }); } + // Markdown-Strip safety-net: trotz expliziter Prompt-Regel emittieren manche + // Modelle (insbesondere Haiku) weiterhin **bold** + Bullet-Lists. RN-Mobile + // rendert kein Markdown — User sieht rohe Sterne. Hier final cleanen. + text = stripMarkdown(text); + const feedbackSaved = await feedbackPromise; // Memory: markReferenced + Extraction fire-and-forget diff --git a/backend/server/utils/strip-markdown.ts b/backend/server/utils/strip-markdown.ts new file mode 100644 index 0000000..0cd4f89 --- /dev/null +++ b/backend/server/utils/strip-markdown.ts @@ -0,0 +1,58 @@ +/** + * Entfernt Markdown-Formatierung aus LLM-Antworten. + * + * Hintergrund: Trotz expliziter "kein Markdown"-Anweisung im System-Prompt + * emittieren manche Modelle (insbesondere Claude Haiku) weiterhin **bold**, + * Bullet-Lists und ähnliches. Das wirkt in der Mobile-App unsauber, weil dort + * kein Markdown-Renderer aktiv ist — User sehen rohe Sterne. + * + * Diese Funktion ist ein Safety-Net: nach LLM-Call angewendet, garantiert + * sie sauberen Klartext, ohne den eigentlichen Inhalt zu beschädigen. + */ +export function stripMarkdown(input: string): string { + if (!input) return input; + + let out = input; + + // Bold/italic: **text** / __text__ / *text* / _text_ + out = out.replace(/\*\*\*(.+?)\*\*\*/g, "$1"); + out = out.replace(/___(.+?)___/g, "$1"); + out = out.replace(/\*\*(.+?)\*\*/g, "$1"); + out = out.replace(/__(.+?)__/g, "$1"); + // Single * or _ als italic — nur wenn auf beiden Seiten Wort-Boundary, + // sonst zerstören wir Listen-Bullets oder Multiplikationen + out = out.replace(/(^|\s)\*([^\s*][^*]*?)\*(?=\s|$|[.,;:!?])/g, "$1$2"); + out = out.replace(/(^|\s)_([^\s_][^_]*?)_(?=\s|$|[.,;:!?])/g, "$1$2"); + + // Headings: # / ## / ### am Zeilenanfang + out = out.replace(/^#{1,6}\s+/gm, ""); + + // Bullet-Lists: "- foo", "* foo", "+ foo" am Zeilenanfang + // Behalte einfachen Aufzählungs-Bindestrich aber entferne markdown-marker-Charakter + // → "- Schutz" wird "• Schutz" (Bullet ohne Markdown-Semantik) + out = out.replace(/^[ \t]*[-*+][ \t]+/gm, "• "); + + // Numbered Lists: "1. foo", "2. foo" am Zeilenanfang + // Zahlen behalten (informativer als bullet) — nur falls strikt entfernt werden soll + // out = out.replace(/^[ \t]*\d+\.[ \t]+/gm, ""); + + // Inline-Code: `text` + out = out.replace(/`([^`]+)`/g, "$1"); + + // Code-Blocks: ```...``` + out = out.replace(/```[a-zA-Z]*\n?([\s\S]*?)```/g, "$1"); + + // Links: [text](url) → text + out = out.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); + + // Blockquotes: "> foo" → "foo" + out = out.replace(/^>[ \t]?/gm, ""); + + // Horizontal rules: --- / *** / ___ alleinstehend + out = out.replace(/^[\t ]*[-*_]{3,}[\t ]*$/gm, ""); + + // Multiple Leerzeilen → max 1 Leerzeile + out = out.replace(/\n{3,}/g, "\n\n"); + + return out.trim(); +}