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
This commit is contained in:
Ronny
2026-04-05 21:45:08 +02:00
parent 4234269ba0
commit 07e0705380
10 changed files with 1615 additions and 15 deletions
+488
View File
@@ -0,0 +1,488 @@
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>
)}
</>
);
}