07e0705380
- 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
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
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 & 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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|