chahinebrini f00d2319a5 fix(coach): markdown-strip safety-net + tier-aware speak-endpoint
Backend:
- New stripMarkdown() util (utils/strip-markdown.ts) — handles **bold**,
  bullet-lists, headings, code-fences, links, blockquotes
- /api/coach/message: applies stripMarkdown(text) post-LLM as safety-net
  because Haiku/Llama keep emitting markdown despite explicit prompt rule

Frontend:
- lyra.tsx voice-flow: hardcoded /api/coach/speak-openai → /api/coach/speak
  (tier-aware dispatcher: Free=Google, Pro=Cartesia, Legend=ElevenLabs)
- Added Metro debug-logs at TTS call-site for endpoint + status visibility
- detectEmotion extracted to lib/lyraResponse.ts (was inline duplicate)
- RiveAvatar: small type-export adjustment for shared Emotion type

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:54:34 +02:00

173 lines
5.8 KiB
TypeScript

import { useEffect, useState } from 'react';
import { View, Text, Platform } from 'react-native';
import { Asset } from 'expo-asset';
import Rive, { Fit, Alignment } from 'rive-react-native';
// Android: Rive akzeptiert NUR raw-resource oder url, kein file:// uri.
// Asset liegt als android/app/src/main/res/raw/lyra_avatar.riv (gebundelt
// via plugins/with-rive-asset-android.js). resourceName = filename ohne
// extension, lowercase + underscores (Android raw-resource convention).
const ANDROID_RIVE_RESOURCE = 'lyra_avatar';
// Modul-Level: nur EINMAL die Asset registrieren + URI cachen.
// In Production-Builds wird die .riv ins App-Bundle gebakt (Asset.localUri
// zeigt sofort auf das Bundle-File). In Dev-Builds wird sie beim ersten Mal
// von Metro gezogen + ins App-Sandbox-Cache geschrieben — danach offline.
const RIVE_MODULE = require('../assets/lyra-avatar.riv');
let cachedRiveUri: string | null = null;
let preloadPromise: Promise<string | null> | null = null;
function preloadRiveAsset(): Promise<string | null> {
if (cachedRiveUri) return Promise.resolve(cachedRiveUri);
if (preloadPromise) return preloadPromise;
preloadPromise = Asset.fromModule(RIVE_MODULE)
.downloadAsync()
.then((asset) => {
const uri = asset.localUri ?? asset.uri;
cachedRiveUri = uri;
return uri;
})
.catch((err) => {
console.warn('[RiveAvatar] preload failed:', err?.message ?? err);
preloadPromise = null;
return null;
});
return preloadPromise;
}
// Kicke den Preload sofort beim Modul-Import an — damit der erste
// Render bereits die cached URI nutzt (außer im allerersten App-Start).
preloadRiveAsset();
// 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<string, string> = {
idle: 'Idle Loop',
happy: 'idle to Pose 1',
thinking: 'WALK',
empathy: '01 Wave 1',
};
const EMOTION_LABELS: Record<string, string> = {
idle: 'bereit',
happy: 'froh für dich',
thinking: 'überlegt ...',
empathy: 'versteht dich',
};
const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = {
sm: 40,
md: 112,
lg: 160,
};
type Props = {
emotion: Emotion;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
fallback?: Emotion;
};
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<string>(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.
// Nach erstem Load funktioniert's auch komplett offline.
const [riveUri, setRiveUri] = useState<string | null>(cachedRiveUri);
useEffect(() => {
if (riveUri) return; // schon gecached
let active = true;
preloadRiveAsset().then((uri) => {
if (active && uri) setRiveUri(uri);
});
return () => {
active = false;
};
}, [riveUri]);
useEffect(() => {
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[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
}, [resolvedEmotion]);
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View
style={{
width: px,
height: px,
// Floating-Shadow nur für md/lg — bei sm zu klein, würde unsauber wirken
...(size !== 'sm' && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 16,
elevation: 10,
}),
}}
>
<View
style={{
width: px,
height: px,
borderRadius: px / 2,
overflow: 'hidden',
backgroundColor: '#ffffff',
borderWidth: size !== 'sm' ? 4 : 0,
borderColor: '#ffffff',
}}
>
{Platform.OS === 'android' ? (
// Android: Bundle-Resource direkt (kein expo-asset Preload nötig)
<Rive
key={currentAnim}
resourceName={ANDROID_RIVE_RESOURCE}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : riveUri ? (
// iOS: file:// URI aus expo-asset Cache funktioniert
<Rive
key={currentAnim}
source={{ uri: riveUri }}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : (
<View style={{ width: px, height: px, backgroundColor: '#ffffff' }} />
)}
</View>
</View>
{showLabel && (
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
{EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''}
</Text>
)}
</View>
);
}