This repository has been archived on 2026-05-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tippspiel/frontend/src/components/AgentChat.tsx
T
Ronny 07e0705380 feat: Günther-Agent (Netzer-Style) + Expertenblick im TipModal
- AgentChat-Widget mit SSE-Streaming, Quick-Actions, Multiple-Choice
- /api/agent/chat + /api/agent/insight Routen
- Expertenblick-Panel im TipModal (lazy load)
- Günther-Icon
2026-04-05 21:45:08 +02:00

489 lines
16 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.
import { useState, useRef, useEffect, useCallback } from 'react';
import styles from './AgentChat.module.css';
import netzerpng from '../assets/guenther_icon.png';
// ============================================================
// Choices Parser
// Extrahiert [CHOICES]...[/CHOICES] aus Agent-Antwort
// ============================================================
function parseChoices(text: string): { before: string; choices: string[]; hasChoices: boolean } {
const match = text.match(/\[CHOICES\]([\s\S]*?)\[\/CHOICES\]/);
if (!match) return { before: text, choices: [], hasChoices: false };
const before = text.slice(0, match.index).trimEnd();
const choices = match[1]
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
return { before, choices, hasChoices: true };
}
// ============================================================
// ChoiceSelector Komponente
// ============================================================
function ChoiceSelector({
choices,
onConfirm,
disabled,
}: {
choices: string[];
onConfirm: (selected: string[]) => void;
disabled: boolean;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
function toggle(choice: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(choice) ? next.delete(choice) : next.add(choice);
return next;
});
}
function handleConfirm() {
if (selected.size === 0) return;
onConfirm([...selected]);
}
function handleAll() {
onConfirm(choices);
}
return (
<div className={styles.choiceSelector}>
<div className={styles.choiceList}>
{choices.map((c) => (
<button
key={c}
className={`${styles.choiceItem} ${selected.has(c) ? styles.choiceItemSelected : ''}`}
onClick={() => toggle(c)}
disabled={disabled}
>
<span className={styles.choiceCheckbox}>
{selected.has(c) ? '✓' : ''}
</span>
{c}
</button>
))}
</div>
<div className={styles.choiceActions}>
<button
className={styles.choiceConfirm}
onClick={handleConfirm}
disabled={disabled || selected.size === 0}
>
{selected.size > 0 ? `${selected.size} Spiel${selected.size > 1 ? 'e' : ''} analysieren` : 'Auswahl treffen'}
</button>
<button
className={styles.choiceAll}
onClick={handleAll}
disabled={disabled}
>
Alle
</button>
</div>
</div>
);
}
// ============================================================
// Mini Markdown Renderer
// Unterstützt: ## Header, **bold**, *italic*, --- Trennlinie, - Listen
// ============================================================
function renderMarkdown(text: string): React.ReactNode[] {
const lines = text.split('\n');
const result: React.ReactNode[] = [];
let listBuffer: string[] = [];
let keyCounter = 0;
const k = () => keyCounter++;
function flushList() {
if (listBuffer.length === 0) return;
result.push(
<ul key={k()} style={{ paddingLeft: '1.2em', margin: '4px 0', listStyleType: 'disc' }}>
{listBuffer.map((item, i) => (
<li key={i} style={{ marginBottom: '2px' }}>{inlineFormat(item)}</li>
))}
</ul>
);
listBuffer = [];
}
function inlineFormat(line: string): React.ReactNode {
// **bold** und *italic* inline parsen
const parts: React.ReactNode[] = [];
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*)/g;
let last = 0;
let match;
while ((match = regex.exec(line)) !== null) {
if (match.index > last) parts.push(line.slice(last, match.index));
if (match[2]) parts.push(<strong key={match.index}>{match[2]}</strong>);
else if (match[3]) parts.push(<em key={match.index}>{match[3]}</em>);
last = match.index + match[0].length;
}
if (last < line.length) parts.push(line.slice(last));
return parts.length === 1 ? parts[0] : parts;
}
for (const line of lines) {
// Trennlinie ---
if (/^---+$/.test(line.trim())) {
flushList();
result.push(
<hr key={k()} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 0' }} />
);
continue;
}
// ## H2
if (line.startsWith('## ')) {
flushList();
result.push(
<div key={k()} style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontWeight: 700, fontSize: '13px', color: 'var(--primary)', marginTop: '10px', marginBottom: '3px', letterSpacing: '-0.1px' }}>
{inlineFormat(line.slice(3))}
</div>
);
continue;
}
// # H1
if (line.startsWith('# ')) {
flushList();
result.push(
<div key={k()} style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontWeight: 800, fontSize: '14px', color: 'var(--gold)', marginTop: '8px', marginBottom: '4px' }}>
{inlineFormat(line.slice(2))}
</div>
);
continue;
}
// Listenpunkt - oder *
if (/^[-*]\s/.test(line)) {
listBuffer.push(line.slice(2));
continue;
}
// Leere Zeile
if (line.trim() === '') {
flushList();
result.push(<div key={k()} style={{ height: '6px' }} />);
continue;
}
// Normaler Absatz
flushList();
result.push(
<div key={k()} style={{ marginBottom: '2px' }}>{inlineFormat(line)}</div>
);
}
flushList();
return result;
}
// ============================================================
// Types
// ============================================================
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
// ============================================================
// Quick-Action Chips
// ============================================================
const QUICK_ACTIONS = [
{ label: '🎯 Tipp-Empfehlung', prompt: 'Gib mir eine Tipp-Empfehlung für die nächsten Spiele der WM 2026!' },
{ label: '📊 Head-to-Head', prompt: 'Zeig mir ein interessantes Head-to-Head zwischen zwei WM-Teams!' },
{ label: '⚡ Fun Fact', prompt: 'Erzähl mir einen kuriosen oder legendären Fun Fact aus der WM-Geschichte!' },
{ label: '🏆 WM-Rekorde', prompt: 'Was sind die spektakulärsten Rekorde aller WM-Turniere?' },
];
// ============================================================
// API: Streaming Chat-Anfrage
// ============================================================
async function sendMessage(
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
onChunk: (text: string) => void,
onDone: () => void,
onError: (err: string) => void
) {
try {
const res = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
onError(data.error ?? `HTTP ${res.status}`);
return;
}
const reader = res.body?.getReader();
if (!reader) { onError('Stream nicht verfügbar'); return; }
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = 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) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') { onDone(); return; }
try {
const parsed = JSON.parse(payload);
if (parsed.text) onChunk(parsed.text);
if (parsed.error) { onError(parsed.error); return; }
} catch {
// ignore parse errors
}
}
}
onDone();
} catch (err) {
onError(err instanceof Error ? err.message : 'Netzwerkfehler');
}
}
// ============================================================
// AgentChat Component
// ============================================================
export default function AgentChat() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showQuickActions, setShowQuickActions] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamingIdRef = useRef<string | null>(null);
// Auto-scroll bei neuen Nachrichten
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Fokus ins Eingabefeld wenn Panel öffnet
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 300);
}
}, [isOpen]);
const formatTime = (date: Date) =>
date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const handleSend = useCallback(
async (text?: string) => {
const messageText = (text ?? input).trim();
if (!messageText || isLoading) return;
setInput('');
setShowQuickActions(false);
// User-Nachricht hinzufügen
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg]);
setIsLoading(true);
// Leere Assistenten-Nachricht für Streaming vorbereiten
const assistantId = (Date.now() + 1).toString();
streamingIdRef.current = assistantId;
const assistantMsg: Message = {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMsg]);
// Chat-History für API aufbauen (nur role + content)
const history = [
...messages.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user' as const, content: messageText },
];
await sendMessage(
history,
// onChunk: Text an laufende Nachricht anhängen
(chunk) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content + chunk } : m
)
);
},
// onDone
() => {
setIsLoading(false);
streamingIdRef.current = null;
},
// onError
(err) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ Fehler: ${err}` }
: m
)
);
setIsLoading(false);
streamingIdRef.current = null;
}
);
},
[input, isLoading, messages]
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleQuickAction = (prompt: string) => {
handleSend(prompt);
};
return (
<>
{/* ---- Floating Button ---- */}
<button
className={`${styles.trigger} ${isOpen ? styles.open : ''}`}
onClick={() => setIsOpen((o) => !o)}
aria-label={isOpen ? 'Chat schließen' : 'Günther öffnen'}
title="Günther Fußball-Experte"
>
{isOpen ? '✕' : <img src={netzerpng} alt="Günther" style={{ width: '52px', height: '52px', objectFit: 'contain' }} />}
</button>
{/* ---- Chat Panel ---- */}
{isOpen && (
<div className={styles.panel} role="dialog" aria-label="Fußball-Experte Chat">
{/* Header */}
<div className={styles.header}>
<span className={styles.headerIcon}><img src={netzerpng} alt="Günther" style={{ width: '28px', height: '28px', objectFit: 'contain' }} /></span>
<div style={{ flex: 1 }}>
<div className={styles.headerTitle}>Günther</div>
<div className={styles.headerSub}>Statistiken · Tipps · Fun Facts</div>
</div>
<div className={styles.headerOnline} title="Online" />
</div>
{/* Messages */}
<div className={styles.messages}>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary)', fontSize: 13, textAlign: 'center', paddingTop: 8 }}>
Frag mich alles rund um Fußball, WM &amp; EM!
</div>
)}
{messages.map((msg, idx) => {
const isLastAssistant =
msg.role === 'assistant' && idx === messages.length - 1;
const isStreaming = isLoading && streamingIdRef.current === msg.id;
const { before, choices, hasChoices } =
msg.role === 'assistant' && !isStreaming
? parseChoices(msg.content)
: { before: msg.content, choices: [], hasChoices: false };
return (
<div key={msg.id} className={`${styles.message} ${styles[msg.role]}`}>
<div className={styles.bubble}>
{msg.role === 'assistant' ? (
<>
{/* Typing Indicator solange Inhalt noch leer */}
{msg.content === '' && isStreaming ? (
<div className={styles.typing}>
<div className={styles.dot} />
<div className={styles.dot} />
<div className={styles.dot} />
</div>
) : (
<div className={styles.markdownBody}>
{renderMarkdown(hasChoices ? before : msg.content)}
</div>
)}
{/* Choice-Selector: nur bei letzter Assistenten-Nachricht */}
{hasChoices && isLastAssistant && (
<ChoiceSelector
choices={choices}
disabled={isLoading}
onConfirm={(selected) => {
const text =
selected.length === choices.length
? 'Analysiere bitte alle Spiele.'
: 'Analysiere bitte: ' + selected.join(', ');
handleSend(text);
}}
/>
)}
</>
) : (
msg.content
)}
</div>
<div className={styles.messageTime}>{formatTime(msg.timestamp)}</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Quick Actions (nur beim ersten Öffnen, solange kein Chat läuft) */}
{showQuickActions && messages.length === 0 && (
<div className={styles.quickActions}>
<div className={styles.quickActionsLabel}>Schnellauswahl</div>
<div className={styles.quickChips}>
{QUICK_ACTIONS.map((qa) => (
<button
key={qa.label}
className={styles.chip}
onClick={() => handleQuickAction(qa.prompt)}
disabled={isLoading}
>
{qa.label}
</button>
))}
</div>
</div>
)}
{/* Input Area */}
<div className={styles.inputArea}>
<textarea
ref={inputRef}
className={styles.input}
placeholder="Frag den Experten…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
rows={1}
/>
<button
className={styles.sendBtn}
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
aria-label="Senden"
>
</button>
</div>
</div>
)}
</>
);
}