diff --git a/backend/.env.example b/backend/.env.example index 0af3c78..51777d4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,3 +20,12 @@ CORS_ORIGIN=https://app.staffbase.com # Plugin Base URL (where this backend is hosted) PLUGIN_BASE_URL=https://your-app.railway.app + +# Anthropic Claude (Günther-Agent) +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# ElevenLabs TTS (Expertenblick Audio-Dialog) +ELEVENLABS_API_KEY=your_elevenlabs_api_key_here +# Optional: Voice-IDs überschreiben (Default: Adam=Netzer, Antoni=Delling) +# ELEVENLABS_VOICE_NETZER=pNInz6obpgDQGcFmaJgB +# ELEVENLABS_VOICE_DELLING=ErXwobaYiN019PkySvjV diff --git a/backend/src/routes/agent.ts b/backend/src/routes/agent.ts index ad3b77f..24e1c3c 100644 --- a/backend/src/routes/agent.ts +++ b/backend/src/routes/agent.ts @@ -89,22 +89,41 @@ async function getMatchContext(): Promise { // ============================================================ // System-Prompt: Der Fußball-Experte // ============================================================ -// Netzer-Stil: Beispiele als Few-Shot Anker const NETZER_STYLE = - 'DEIN STIL - Günter Netzer:\n' + - 'Du sprichst wie Günter Netzer in seiner Zeit als ARD-Experte. Trocken. Direkt. Keine Begeisterungsstürme.\n' + - 'Du stellst fest - du zweifelst nicht. Du kennst den Fußball von innen, das merkt man.\n\n' + - 'Beispiele für deinen Ton:\n' + - '- "Das ist eine gute Mannschaft. Aber heute reicht es nicht."\n' + - '- "Brasilien hat die besseren Einzelspieler. Was daraus wird, steht auf einem anderen Blatt."\n' + - '- "Ich habe 1974 gegen bessere Außenverteidiger gespielt. Das war Fußball."\n' + - '- "Ein Unentschieden waere fair. Aber Fairness interessiert im Fußball niemanden."\n' + - '- "Der Torwart hat das gehalten. Musste er auch."\n\n' + - 'Regeln: Kurze Saetze. Kein "mega", kein "Wahnsinn", kein uebertriebenes Lob. Gelegentlich ein trockener Vergleich mit frueheren WM-Turnieren oder Legenden (Beckenbauer, Müller, Cruyff). Du duzt alle.\n\n'; + 'DEIN STIL - Günther Netzer:\n' + + 'Du bist Günther Netzer, ARD-Fußballexperte von 1997-2010. Trocken. Direkt. Elitär. Nostalgisch.\n' + + 'Du maßt das aktuelle Geschehen stets an idealistischen Maßstäben - und an deiner eigenen Karriere.\n\n' + + 'TYPISCHE PHRASEN die du verwendest:\n' + + '- "Aus der Tiefe des Raumes"\n' + + '- "Das sind fundamentale Dinge"\n' + + '- "Das ist ein Minimalisten-Dasein"\n' + + '- "Mir hat hier heute noch gar nichts gefallen"\n' + + '- "Das hat mit Spitzenfußball nichts zu tun"\n' + + '- "Das war dezent" (wenn eine Leistung mäßig war)\n' + + '- "Was bleibt mir noch übrig jetzt zu sagen..."\n' + + '- Gelegentlich ironisches Lob: "Das ist wirklich eine sehr kluge Beobachtung..."\n\n' + + 'EIGENHEITEN:\n' + + '- Du vergleichst fast alles mit Beckenbauer, Müller, Cruyff oder deiner eigenen Zeit\n' + + '- Taktik-Geschwafel lehnst du ab: "Das nennen die heutzutage Ballbesitzfußball. Früher nannte man das Angst."\n' + + '- Du bist von Mannschaften prinzipiell enttäuscht, außer die Leistung ist absolut unstrittig\n' + + '- Kurze Sätze. Kein "mega", kein "Wahnsinn". Kein übertriebenes Lob.\n\n'; + +const DELLING_STYLE = + 'Die Rolle von Gerhard Delling (dein Moderator-Pendant, NUR im Dialog-Modus):\n' + + '- Trocken, skeptisch, stichelt gerne\n' + + '- Typische Phrasen: "Nun könnte man sagen, seien wir doch mal großzügig...", "Fanden Sie nicht, dass immerhin..."\n' + + '- Verteidigt absichtlich die schwächere Mannschaft um Netzer zu provozieren\n' + + '- Stichelt gegen Netzers Vergangenheit (Laufbereitschaft, Frisur)\n' + + '- Bleibt immer ruhig, lässt sich von Netzers Arroganz nicht erschüttern\n' + + '- Er und Netzer siezen sich stets, obwohl sie Freunde sind\n\n'; const SYSTEM_PROMPT_BASE = 'Du bist der Fußball-Experte im WM 2026 Tippspiel von GEALAN. Du kennst WM und EM in- und auswendig: Ergebnisse, Rekorde, Taktiken, Legenden - von 1930 bis heute.\n\n' + NETZER_STYLE + + DELLING_STYLE + + 'GESPRÄCHSMODUS: Wenn der Nutzer dich direkt anschreibt, antwortest du als Netzer allein. Wenn du eine Analyse oder Einschätzung gibst, kannst du gelegentlich einen kurzen Einwurf von Delling einfließen lassen - im Format:\n' + + '**Delling:** "..."\n' + + '**Netzer:** "..."\n\n' + 'TIPP-EMPFEHLUNGEN: Wenn jemand allgemein fragt, stelle zuerst eine Rückfrage. Haenge einen CHOICES-Block an mit den naechsten 5 Spielen. Erst nach Auswahl gibst du Empfehlungen - maximal 3 auf einmal.\n\n' + 'CHOICES-FORMAT (nur fuer Tipp-Rückfragen):\n' + '[CHOICES]\nHeimteam vs. Gastteam\n[/CHOICES]\n' + @@ -213,13 +232,22 @@ router.post('/insight', async (req: Request, res: Response): Promise => { const insightPrompt = NETZER_STYLE + - 'Gib eine Kurzeinschätzung im Netzer-Stil. Exakt dieses Format, keine Abweichungen:\n\n' + - '**Ausgangslage:** Ein trockener Satz zur Lage.\n' + - '**Favorit:** Teamname - ein Satz Begruendung.\n' + - '**Risiko:** Ein Satz fuer den Außenseiter.\n' + - '**Tipp:** **Score** - Teamname und ein Satz.\n\n' + - 'Trennzeichen: immer Gedankenstrich, nie Pluszeichen. Keine weiteren Zeilen.\n\n' + - 'Spiel: ' + homeTeam + ' vs. ' + awayTeam + ' (' + stageLabel + ')'; + DELLING_STYLE + + 'Schreibe einen kurzen Expertenblick als Dialog zwischen Delling und Netzer über das folgende Spiel.\n\n' + + 'BEISPIELE (so klingt das Duo - diese Authentizität ist entscheidend):\n\n' + + 'Beispiel 1 (Gruppenspiel mit klarem Favoriten):\n' + + '**Delling:** "Nun, Herr Netzer, wir haben hier ja doch einen veritablen Favoriten. Fanden Sie nicht, dass immerhin die Leistungsdaten der letzten Qualifikation für den Außenseiter sprechen könnten?"\n' + + '**Netzer:** "Nein. Das waren Qualifikationsspiele. Das hat mit dem hier nichts zu tun. Das sind fundamentale Dinge."\n' + + '**Delling:** "Seien wir doch mal großzügig - auch der Außenseiter hat Qualitäten."\n' + + '**Netzer:** "Das nennen Sie Qualitäten. Ich nenne das ein Minimalisten-Dasein."\n\n' + + 'Beispiel 2 (Ausgeglichenes Spiel):\n' + + '**Delling:** "Herr Netzer, das könnte ja ein enges Spiel werden. Beide Mannschaften liegen dicht beieinander."\n' + + '**Netzer:** "Das war dezent ausgedrückt. Beiden fehlt, was Beckenbauer damals selbstverständlich war - diese Überlegenheit, dieses Selbstverständnis. Aus der Tiefe des Raumes heraus, verstehen Sie?"\n' + + '**Delling:** "Ich glaube, die Spieler würden sich bedanken, wenn Sie ihnen das erläutern könnten."\n' + + '**Netzer:** "Was bleibt mir noch übrig jetzt zu sagen. Ich tippe auf ein 1:1."\n\n' + + 'JETZT das echte Spiel:\n' + + 'Spiel: **' + homeTeam + '** vs. **' + awayTeam + '** (' + stageLabel + ')\n\n' + + 'Schreibe genau 4 Wechselreden (Delling, Netzer, Delling, Netzer). Netzer gibt am Ende seinen konkreten Tipp mit Score. Kein Emoji. Siezen. Kurze Sätze bei Netzer.'; try { const client = getClient(); @@ -231,7 +259,7 @@ router.post('/insight', async (req: Request, res: Response): Promise => { const stream = await client.messages.stream({ model: 'claude-haiku-4-5-20251001', - max_tokens: 256, + max_tokens: 512, messages: [{ role: 'user', content: insightPrompt }], }); @@ -263,4 +291,116 @@ router.post('/insight', async (req: Request, res: Response): Promise => { } }); +// ============================================================ +// POST /api/agent/insight-audio +// Body: { dialogText: string } +// Gibt eine MP3 zurück (Delling + Netzer als Dialog, 2 Stimmen) +// ============================================================ + +// ElevenLabs Voice-IDs (kostenlose Standard-Voices) +// Netzer: "Adam" – tief, ruhig, autoritär +// Delling: "Antoni" – etwas heller, sachlicher +const ELEVENLABS_VOICE_NETZER = process.env.ELEVENLABS_VOICE_NETZER ?? 'pNInz6obpgDQGcFmaJgB'; // Adam +const ELEVENLABS_VOICE_DELLING = process.env.ELEVENLABS_VOICE_DELLING ?? 'ErXwobaYiN019PkySvjV'; // Antoni + +async function synthesizeTurn( + text: string, + voiceId: string, + apiKey: string +): Promise { + const res = await fetch( + `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, + { + method: 'POST', + headers: { + 'xi-api-key': apiKey, + 'Content-Type': 'application/json', + Accept: 'audio/mpeg', + }, + body: JSON.stringify({ + text, + model_id: 'eleven_multilingual_v2', + voice_settings: { stability: 0.55, similarity_boost: 0.75 }, + }), + } + ); + if (!res.ok) { + const err = await res.text(); + throw new Error(`ElevenLabs error ${res.status}: ${err}`); + } + const arrayBuf = await res.arrayBuffer(); + return Buffer.from(arrayBuf); +} + +// Parst **Delling:** "..." / **Netzer:** "..." Zeilen aus dem Dialog-Text +function parseDialogTurns( + dialogText: string +): Array<{ speaker: 'Delling' | 'Netzer'; text: string }> { + const turns: Array<{ speaker: 'Delling' | 'Netzer'; text: string }> = []; + const lines = dialogText.split('\n'); + for (const line of lines) { + const m = line.match(/^\*\*(Delling|Netzer):\*\*\s*[""]?(.+?)[""]?\s*$/); + if (m) { + turns.push({ + speaker: m[1] as 'Delling' | 'Netzer', + text: m[2].trim(), + }); + } + } + return turns; +} + +router.post('/insight-audio', async (req: Request, res: Response): Promise => { + const { dialogText } = req.body as { dialogText?: string }; + + if (!dialogText) { + res.status(400).json({ error: 'dialogText erforderlich' }); + return; + } + + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { + res.status(503).json({ error: 'ELEVENLABS_API_KEY nicht konfiguriert' }); + return; + } + + const turns = parseDialogTurns(dialogText); + if (turns.length === 0) { + res.status(400).json({ error: 'Kein Dialog-Format erkannt' }); + return; + } + + try { + // Alle Turns parallel synthetisieren + const audioBuffers = await Promise.all( + turns.map((turn) => + synthesizeTurn( + turn.text, + turn.speaker === 'Netzer' ? ELEVENLABS_VOICE_NETZER : ELEVENLABS_VOICE_DELLING, + apiKey + ) + ) + ); + + // MP3-Chunks zusammenführen (einfaches Aneinanderhängen reicht für MP3) + const combined = Buffer.concat(audioBuffers); + + res.setHeader('Content-Type', 'audio/mpeg'); + res.setHeader('Content-Length', combined.length); + res.setHeader('Cache-Control', 'no-store'); + res.send(combined); + + logger.info('Agent: Insight-Audio generiert', { + userId: req.staffbaseUser?.sub, + turns: turns.length, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Agent: Insight-Audio-Fehler', { error: message }); + if (!res.headersSent) { + res.status(500).json({ error: 'Audio-Generierung fehlgeschlagen' }); + } + } +}); + export default router; diff --git a/frontend/src/components/TipModal.module.css b/frontend/src/components/TipModal.module.css index 8996553..a414030 100644 --- a/frontend/src/components/TipModal.module.css +++ b/frontend/src/components/TipModal.module.css @@ -323,6 +323,96 @@ margin-bottom: 20px; } +/* Toggle-Zeile mit Play-Button */ +.insightToggleRow { + display: flex; + align-items: center; + gap: 8px; +} + +.insightToggleRow .insightToggle { + flex: 1; +} + +/* Audio-Play-Button */ +.audioBtn { + display: flex; + align-items: center; + gap: 5px; + padding: 9px 12px; + background: linear-gradient(135deg, rgba(75,183,248,0.12) 0%, rgba(75,183,248,0.05) 100%); + border: 1px solid rgba(75,183,248,0.3); + border-radius: 12px; + color: var(--cyan); + font-size: 12px; + font-weight: 700; + font-family: 'Plus Jakarta Sans', sans-serif; + cursor: pointer; + transition: all 0.18s; + white-space: nowrap; + flex-shrink: 0; +} + +.audioBtn:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(75,183,248,0.2) 0%, rgba(75,183,248,0.08) 100%); + border-color: rgba(75,183,248,0.5); +} + +.audioBtn:disabled { + opacity: 0.6; + cursor: default; +} + +/* Dialog-Format: Delling / Netzer */ +.dialogLine { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px; + border-radius: 10px; + margin-bottom: 6px; + animation: insightFadeIn 0.2s ease; +} + +.dialogLine:last-child { + margin-bottom: 0; +} + +.speakerDelling { + background: rgba(148, 163, 184, 0.06); + border-left: 2px solid rgba(148,163,184,0.4); + align-items: flex-start; +} + +.speakerNetzer { + background: rgba(254, 174, 50, 0.06); + border-left: 2px solid rgba(254,174,50,0.5); + align-items: flex-start; +} + +.dialogSpeaker { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 2px; +} + +.speakerDelling .dialogSpeaker { + color: rgba(148,163,184,0.8); +} + +.speakerNetzer .dialogSpeaker { + color: var(--gold); +} + +.dialogText { + font-size: 13px; + line-height: 1.55; + color: var(--text-primary); + font-style: italic; +} + .insightToggle { width: 100%; display: flex; diff --git a/frontend/src/components/TipModal.tsx b/frontend/src/components/TipModal.tsx index bd45dd7..1e206e9 100644 --- a/frontend/src/components/TipModal.tsx +++ b/frontend/src/components/TipModal.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from 'react'; -import { Sparkles, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { Sparkles, ChevronDown, ChevronUp, Loader2, Volume2, Square } from 'lucide-react'; import { Match, api } from '../api/client'; import styles from './TipModal.module.css'; @@ -78,6 +78,50 @@ export default function TipModal({ match, onClose, onSaved }: Props) { const [insightError, setInsightError] = useState(false); const insightFetched = useRef(false); + // Audio State + const [audioLoading, setAudioLoading] = useState(false); + const [audioPlaying, setAudioPlaying] = useState(false); + const [audioError, setAudioError] = useState(false); + const audioRef = useRef(null); + + async function handlePlayAudio() { + // Stop wenn gerade läuft + if (audioPlaying && audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + setAudioPlaying(false); + return; + } + setAudioError(false); + setAudioLoading(true); + try { + const res = await fetch('/api/agent/insight-audio', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dialogText: insightText }), + }); + if (!res.ok) throw new Error('Audio-Generierung fehlgeschlagen'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + audioRef.current = audio; + audio.onended = () => { + setAudioPlaying(false); + URL.revokeObjectURL(url); + }; + audio.onerror = () => { + setAudioPlaying(false); + setAudioError(true); + }; + setAudioLoading(false); + setAudioPlaying(true); + await audio.play(); + } catch { + setAudioLoading(false); + setAudioError(true); + } + } + function handleToggleInsight() { const opening = !insightOpen; setInsightOpen(opening); @@ -198,14 +242,34 @@ export default function TipModal({ match, onClose, onSaved }: Props) { {/* Expertenblick */}
- +
+ + + {/* Play-Button – nur wenn Dialog fertig geladen */} + {insightOpen && !insightLoading && !insightError && insightText && ( + + )} +
{insightOpen && (
@@ -221,18 +285,28 @@ export default function TipModal({ match, onClose, onSaved }: Props) { ) : (
{insightText.split('\n').filter(l => l.trim()).map((line, i) => { - // **Label:** Value aufsplitten - const match = line.match(/^\*\*(.+?):\*\*\s*(.*)$/); - if (match) { - // Inline **fett** im Value-Teil rendern - const valueParts = match[2].split(/(\*\*.+?\*\*)/g).map((part, j) => + // Dialog-Format: **Delling:** "..." oder **Netzer:** "..." + const dialogMatch = line.match(/^\*\*(Delling|Netzer):\*\*\s*["„]?(.+?)[""]?\s*$/); + if (dialogMatch) { + const speaker = dialogMatch[1] as 'Delling' | 'Netzer'; + return ( +
+ {speaker} + „{dialogMatch[2].replace(/^["„]|[""]$/g, '')}" +
+ ); + } + // Fallback: **Label:** Value (alter Stil) + const labelMatch = line.match(/^\*\*(.+?):\*\*\s*(.*)$/); + if (labelMatch) { + const valueParts = labelMatch[2].split(/(\*\*.+?\*\*)/g).map((part, j) => part.startsWith('**') && part.endsWith('**') ? {part.slice(2, -2)} : part ); return (
- {match[1]} + {labelMatch[1]} {valueParts}
); @@ -240,6 +314,11 @@ export default function TipModal({ match, onClose, onSaved }: Props) { return
{line}
; })} {insightLoading && } + {audioError && ( +
+ Audio nicht verfügbar. +
+ )}
)}