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>(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 (
{choices.map((c) => ( ))}
); } // ============================================================ // 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( ); 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({match[2]}); else if (match[3]) parts.push({match[3]}); 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(
); continue; } // ## H2 if (line.startsWith('## ')) { flushList(); result.push(
{inlineFormat(line.slice(3))}
); continue; } // # H1 if (line.startsWith('# ')) { flushList(); result.push(
{inlineFormat(line.slice(2))}
); continue; } // Listenpunkt - oder * if (/^[-*]\s/.test(line)) { listBuffer.push(line.slice(2)); continue; } // Leere Zeile if (line.trim() === '') { flushList(); result.push(
); continue; } // Normaler Absatz flushList(); result.push(
{inlineFormat(line)}
); } 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([]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [showQuickActions, setShowQuickActions] = useState(true); const messagesEndRef = useRef(null); const inputRef = useRef(null); const streamingIdRef = useRef(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) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleQuickAction = (prompt: string) => { handleSend(prompt); }; return ( <> {/* ---- Floating Button ---- */} {/* ---- Chat Panel ---- */} {isOpen && (
{/* Header */}
Günther
Günther
Statistiken · Tipps · Fun Facts
{/* Messages */}
{messages.length === 0 && (
Frag mich alles rund um Fußball, WM & EM! ⚽
)} {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 (
{msg.role === 'assistant' ? ( <> {/* Typing Indicator solange Inhalt noch leer */} {msg.content === '' && isStreaming ? (
) : (
{renderMarkdown(hasChoices ? before : msg.content)}
)} {/* Choice-Selector: nur bei letzter Assistenten-Nachricht */} {hasChoices && isLastAssistant && ( { const text = selected.length === choices.length ? 'Analysiere bitte alle Spiele.' : 'Analysiere bitte: ' + selected.join(', '); handleSend(text); }} /> )} ) : ( msg.content )}
{formatTime(msg.timestamp)}
); })}
{/* Quick Actions (nur beim ersten Öffnen, solange kein Chat läuft) */} {showQuickActions && messages.length === 0 && (
Schnellauswahl
{QUICK_ACTIONS.map((qa) => ( ))}
)} {/* Input Area */}