diff --git a/backend/package-lock.json b/backend/package-lock.json index 99a12dc..3155658 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "wm2026-tippspiel-backend", "version": "1.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.82.0", "@staffbase/staffbase-plugin-sdk": "^1.3.7", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -26,6 +27,35 @@ "typescript": "^5.4.5" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.82.0.tgz", + "integrity": "sha512-xdHTjL1GlUlDugHq/I47qdOKp/ROPvuHl7ROJCgUQigbvPu7asf9KcAcU1EqdrP2LuVhEKaTs7Z+ShpZDRzHdQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1316,6 +1346,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2020,6 +2063,12 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/backend/package.json b/backend/package.json index 6373aac..3b5b6cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "db:seed": "tsx scripts/seed.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.82.0", "@staffbase/staffbase-plugin-sdk": "^1.3.7", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/backend/src/index.ts b/backend/src/index.ts index 9aa595d..25da303 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import leaderboardRouter from './routes/leaderboard'; import adminRouter from './routes/admin'; import profileRouter from './routes/profile'; import devRouter from './routes/dev'; +import agentRouter from './routes/agent'; const app = express(); const PORT = parseInt(process.env.PORT ?? '3001'); @@ -86,6 +87,16 @@ app.use( }) ); +// Rate limit für Agent-Chat (Claude API Calls sind kostenpflichtig) +app.use( + '/api/agent', + rateLimit({ + windowMs: 60 * 1000, // 1 Minute + max: 10, // 10 Chat-Nachrichten pro User/Minute + message: { error: 'Too many agent requests, bitte kurz warten.' }, + }) +); + // ============================================================ // Health Check (ohne Auth – für Railway/Render/Uptime-Monitoring) // ============================================================ @@ -119,6 +130,7 @@ app.use('/api/tips', tipsRouter); app.use('/api/leaderboard', leaderboardRouter); app.use('/api/admin', adminRouter); app.use('/api/profile', profileRouter); +app.use('/api/agent', agentRouter); if (process.env.NODE_ENV === 'development') { app.use('/api/dev', devRouter); } diff --git a/backend/src/routes/agent.ts b/backend/src/routes/agent.ts new file mode 100644 index 0000000..ad3b77f --- /dev/null +++ b/backend/src/routes/agent.ts @@ -0,0 +1,266 @@ +import { Router, Request, Response } from 'express'; +import Anthropic from '@anthropic-ai/sdk'; +import { query } from '../db/client'; +import { logger } from '../services/logger'; + +const router = Router(); + +// ============================================================ +// Anthropic Client (lazy-initialized, Key aus .env) +// ============================================================ +let anthropic: Anthropic | null = null; +function getClient(): Anthropic { + if (!anthropic) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set'); + anthropic = new Anthropic({ apiKey }); + } + return anthropic; +} + +// ============================================================ +// Kontext: aktuelle Spiele aus der DB holen +// ============================================================ +async function getMatchContext(): Promise { + try { + const rows = await query<{ + utc_date: Date; + status: string; + stage: string; + group_name: string | null; + home_team_name: string; + away_team_name: string; + score_home: number | null; + score_away: number | null; + }>( + `SELECT utc_date, status, stage, group_name, + home_team_name, away_team_name, score_home, score_away + FROM matches + ORDER BY utc_date ASC + LIMIT 200` + ); + + const now = new Date(); + const upcoming = rows.filter( + (r) => new Date(r.utc_date) > now && (r.status === 'SCHEDULED' || r.status === 'TIMED') + ); + const finished = rows.filter((r) => r.status === 'FINISHED'); + const inPlay = rows.filter((r) => r.status === 'IN_PLAY' || r.status === 'PAUSED'); + + const lines: string[] = []; + + if (inPlay.length > 0) { + lines.push('=== LIVE JETZT ==='); + inPlay.forEach((m) => { + lines.push( + `${m.home_team_name} ${m.score_home ?? '?'} : ${m.score_away ?? '?'} ${m.away_team_name} (LIVE)` + ); + }); + } + + if (upcoming.length > 0) { + lines.push('\n=== NÄCHSTE SPIELE (WM 2026) ==='); + upcoming.slice(0, 20).forEach((m) => { + const d = new Date(m.utc_date); + const dateStr = d.toLocaleDateString('de-DE', { + weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', + }); + const groupStr = m.group_name ? ` [${m.group_name}]` : ` [${m.stage}]`; + lines.push(`${dateStr}${groupStr}: ${m.home_team_name} vs. ${m.away_team_name}`); + }); + } + + if (finished.length > 0) { + lines.push('\n=== ZULETZT GESPIELT ==='); + finished.slice(-10).forEach((m) => { + lines.push( + `${m.home_team_name} ${m.score_home} : ${m.score_away} ${m.away_team_name} (Ergebnis)` + ); + }); + } + + return lines.join('\n'); + } catch (err) { + logger.error('Agent: Fehler beim Laden des Match-Kontexts', { err }); + return '(Keine Spieldaten verfügbar)'; + } +} + +// ============================================================ +// 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'; + +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 + + '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' + + 'Exakte Teamnamen aus dem Kontext. Kein Datum, keine Emojis im Block.\n\n' + + 'FORMATIERUNG: Kompakt (max. 4 Absaetze). **fett** fuer Namen/Scores. ## fuer Überschriften. --- als Trennlinie. Kein # H1. Kein Emoji-Overload.\n\n' + + 'SPIELPLAN-KONTEXT:\n'; + +// ============================================================ +// POST /api/agent/chat +// Body: { messages: [{role, content}][], quickAction?: string } +// ============================================================ +router.post('/chat', async (req: Request, res: Response): Promise => { + const { messages, quickAction } = req.body as { + messages?: Array<{ role: 'user' | 'assistant'; content: string }>; + quickAction?: string; + }; + + // Validierung + if (!messages && !quickAction) { + res.status(400).json({ error: 'messages oder quickAction erforderlich' }); + return; + } + + const chatMessages: Array<{ role: 'user' | 'assistant'; content: string }> = + messages ?? []; + + // Quick-Action als User-Nachricht hinzufügen + if (quickAction) { + chatMessages.push({ role: 'user', content: quickAction }); + } + + if (chatMessages.length === 0) { + res.status(400).json({ error: 'Keine Nachrichten vorhanden' }); + return; + } + + try { + const client = getClient(); + const matchContext = await getMatchContext(); + const systemPrompt = SYSTEM_PROMPT_BASE + matchContext; + + // Streaming-Antwort via SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const stream = await client.messages.stream({ + model: 'claude-sonnet-4-6', + max_tokens: 1024, + system: systemPrompt, + messages: chatMessages.slice(-10), // max. 10 Nachrichten Kontext + }); + + for await (const chunk of stream) { + if ( + chunk.type === 'content_block_delta' && + chunk.delta.type === 'text_delta' + ) { + const data = JSON.stringify({ text: chunk.delta.text }); + res.write(`data: ${data}\n\n`); + } + } + + res.write('data: [DONE]\n\n'); + res.end(); + + logger.info('Agent: Chat-Anfrage beantwortet', { + userId: req.staffbaseUser?.sub, + messageCount: chatMessages.length, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Agent: Fehler', { error: message }); + + if (!res.headersSent) { + res.status(500).json({ error: 'Agent nicht verfügbar', detail: message }); + } else { + res.write(`data: ${JSON.stringify({ error: message })}\n\n`); + res.end(); + } + } +}); + +// ============================================================ +// POST /api/agent/insight +// Kompakte Einschätzung für genau ein Spiel (für TipModal) +// Body: { homeTeam: string, awayTeam: string, stage: string, group?: string } +// ============================================================ +router.post('/insight', async (req: Request, res: Response): Promise => { + const { homeTeam, awayTeam, stage, group } = req.body as { + homeTeam: string; + awayTeam: string; + stage?: string; + group?: string; + }; + + if (!homeTeam || !awayTeam) { + res.status(400).json({ error: 'homeTeam und awayTeam erforderlich' }); + return; + } + + const stageLabel = group + ? group.replace('GROUP_', 'Gruppe ') + : stage ?? 'WM 2026'; + + 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 + ')'; + + try { + const client = getClient(); + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const stream = await client.messages.stream({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 256, + messages: [{ role: 'user', content: insightPrompt }], + }); + + for await (const chunk of stream) { + if ( + chunk.type === 'content_block_delta' && + chunk.delta.type === 'text_delta' + ) { + res.write('data: ' + JSON.stringify({ text: chunk.delta.text }) + '\n\n'); + } + } + + res.write('data: [DONE]\n\n'); + res.end(); + + logger.info('Agent: Insight geliefert', { + userId: req.staffbaseUser?.sub, + match: homeTeam + ' vs ' + awayTeam, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Agent: Insight-Fehler', { error: message }); + if (!res.headersSent) { + res.status(500).json({ error: 'Insight nicht verfügbar' }); + } else { + res.write('data: ' + JSON.stringify({ error: message }) + '\n\n'); + res.end(); + } + } +}); + +export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38bbeac..ac1c749 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import MatchesPage from './pages/MatchesPage'; import LeaderboardPage from './pages/LeaderboardPage'; import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; +import AgentChat from './components/AgentChat'; import styles from './App.module.css'; const IS_DEV = import.meta.env.DEV; @@ -92,6 +93,9 @@ export default function App() { onRefresh={handleDevRefresh} /> )} + + {/* Fußball-Experte Chat-Widget – immer sichtbar */} + ); } diff --git a/frontend/src/assets/guenther_icon.png b/frontend/src/assets/guenther_icon.png new file mode 100644 index 0000000..379e60c Binary files /dev/null and b/frontend/src/assets/guenther_icon.png differ diff --git a/frontend/src/components/AgentChat.module.css b/frontend/src/components/AgentChat.module.css new file mode 100644 index 0000000..d7a737a --- /dev/null +++ b/frontend/src/components/AgentChat.module.css @@ -0,0 +1,466 @@ +/* ============================================================ + AgentChat – Fußball-Experte Chat-Widget + Stadium Elite Design + ============================================================ */ + +/* ---- Floating Button ---- */ +.trigger { + position: fixed; + bottom: 28px; + right: 28px; + z-index: 500; + width: 60px; + height: 60px; + border-radius: 50%; + border: none; + background: transparent; + box-shadow: none; + cursor: pointer; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + line-height: 1; + transition: transform 0.2s; + color: #fff; + padding: 0; +} + +.trigger.open { + background: rgba(28, 38, 64, 0.9); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +.trigger:hover { + transform: scale(1.08); + box-shadow: 0 6px 28px rgba(75, 183, 248, 0.6); +} + +.trigger.open { + background: linear-gradient(135deg, #1C2640 0%, #111827 100%); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +/* Puls-Ring wenn geschlossen */ +.trigger:not(.open)::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid rgba(75, 183, 248, 0.4); + animation: pulse 2.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 0.8; } + 50% { transform: scale(1.15); opacity: 0; } +} + +/* ---- Panel ---- */ +.panel { + position: fixed; + bottom: 100px; + right: 28px; + z-index: 499; + width: 380px; + max-height: 560px; + display: flex; + flex-direction: column; + background: var(--surface-mid); + border-radius: var(--radius-lg); + border: 1px solid rgba(75, 183, 248, 0.15); + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.07); + overflow: hidden; + animation: slideUp 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(16px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ---- Header ---- */ +.header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + background: linear-gradient(135deg, rgba(75, 183, 248, 0.1) 0%, rgba(33, 150, 243, 0.05) 100%); + border-bottom: 1px solid rgba(75, 183, 248, 0.12); + flex-shrink: 0; +} + +.headerIcon { + font-size: 22px; + line-height: 1; +} + +.headerTitle { + font-family: 'Plus Jakarta Sans', sans-serif; + font-weight: 700; + font-size: 15px; + color: var(--text-primary); + flex: 1; +} + +.headerSub { + font-size: 11px; + color: var(--text-secondary); + margin-top: 1px; +} + +.headerOnline { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 6px var(--success); + flex-shrink: 0; +} + +/* ---- Messages Area ---- */ +.messages { + flex: 1; + overflow-y: auto; + padding: 16px 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ---- Quick Action Chips ---- */ +.quickActions { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 14px 14px; +} + +.quickActionsLabel { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + font-weight: 600; +} + +.quickChips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip { + padding: 6px 12px; + border-radius: 20px; + border: 1px solid rgba(75, 183, 248, 0.25); + background: rgba(75, 183, 248, 0.07); + color: var(--primary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.chip:hover { + background: rgba(75, 183, 248, 0.15); + border-color: rgba(75, 183, 248, 0.4); + transform: translateY(-1px); +} + +/* ---- Message Bubbles ---- */ +.message { + display: flex; + flex-direction: column; + max-width: 88%; +} + +.message.user { + align-self: flex-end; + align-items: flex-end; +} + +.message.assistant { + align-self: flex-start; + align-items: flex-start; +} + +.bubble { + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 14px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.message.user .bubble { + background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%); + color: #fff; + border-bottom-right-radius: 4px; +} + +.message.assistant .bubble { + background: var(--surface-high); + color: var(--text-primary); + border: 1px solid rgba(255, 255, 255, 0.06); + border-bottom-left-radius: 4px; +} + +/* Markdown-Inhalt in Assistenten-Bubbles */ +.markdownBody { + font-size: 13.5px; + line-height: 1.6; +} + +.markdownBody strong { + color: var(--text-primary); + font-weight: 700; +} + +.markdownBody em { + color: var(--text-secondary); + font-style: italic; +} + +.markdownBody ul { + padding-left: 1.2em; + margin: 4px 0; +} + +.markdownBody li { + margin-bottom: 3px; + color: var(--text-primary); +} + +.messageTime { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; + padding: 0 4px; +} + +/* ---- Typing Indicator ---- */ +.typing { + display: flex; + align-items: center; + gap: 4px; + padding: 10px 14px; + background: var(--surface-high); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-md); + border-bottom-left-radius: 4px; + width: fit-content; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-secondary); + animation: bounce 1.2s ease-in-out infinite; +} +.dot:nth-child(2) { animation-delay: 0.2s; } +.dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes bounce { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; } + 30% { transform: translateY(-5px); opacity: 1; } +} + +/* ---- Input Area ---- */ +.inputArea { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 12px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: var(--surface-low); + flex-shrink: 0; +} + +.input { + flex: 1; + background: var(--surface-mid); + border: 1px solid rgba(75, 183, 248, 0.2); + border-radius: var(--radius-sm); + padding: 10px 13px; + color: var(--text-primary); + font-size: 14px; + font-family: 'Inter', sans-serif; + resize: none; + min-height: 42px; + max-height: 120px; + outline: none; + transition: border-color 0.15s; + line-height: 1.4; +} + +.input::placeholder { color: var(--text-muted); } + +.input:focus { + border-color: rgba(75, 183, 248, 0.5); + box-shadow: 0 0 0 2px rgba(75, 183, 248, 0.1); +} + +.sendBtn { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + border: none; + background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: opacity 0.15s, transform 0.1s; + box-shadow: 0 2px 10px rgba(75, 183, 248, 0.3); +} + +.sendBtn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); } +.sendBtn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ---- Choice Selector ---- */ +.choiceSelector { + margin-top: 10px; + border-top: 1px solid rgba(75, 183, 248, 0.12); + padding-top: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.choiceList { + display: flex; + flex-direction: column; + gap: 5px; +} + +.choiceItem { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(75, 183, 248, 0.18); + background: rgba(75, 183, 248, 0.05); + color: var(--text-primary); + font-size: 13px; + font-family: 'Inter', sans-serif; + cursor: pointer; + text-align: left; + transition: all 0.15s; +} + +.choiceItem:hover:not(:disabled) { + background: rgba(75, 183, 248, 0.12); + border-color: rgba(75, 183, 248, 0.35); +} + +.choiceItem:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.choiceItemSelected { + background: rgba(75, 183, 248, 0.15); + border-color: var(--primary); + color: var(--primary); + font-weight: 500; +} + +.choiceCheckbox { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1.5px solid rgba(75, 183, 248, 0.4); + background: transparent; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + flex-shrink: 0; + color: var(--primary); + font-weight: 700; + transition: all 0.12s; +} + +.choiceItemSelected .choiceCheckbox { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.choiceActions { + display: flex; + gap: 6px; +} + +.choiceConfirm { + flex: 1; + padding: 8px 14px; + border-radius: var(--radius-sm); + border: none; + background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%); + color: #fff; + font-size: 13px; + font-weight: 600; + font-family: 'Plus Jakarta Sans', sans-serif; + cursor: pointer; + transition: opacity 0.15s; + box-shadow: 0 2px 10px rgba(75, 183, 248, 0.25); +} + +.choiceConfirm:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.choiceConfirm:hover:not(:disabled) { + opacity: 0.9; +} + +.choiceAll { + padding: 8px 14px; + border-radius: var(--radius-sm); + border: 1px solid rgba(75, 183, 248, 0.25); + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.choiceAll:hover:not(:disabled) { + color: var(--text-primary); + border-color: rgba(75, 183, 248, 0.4); + background: rgba(75, 183, 248, 0.07); +} + +.choiceAll:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ---- Responsive ---- */ +@media (max-width: 440px) { + .panel { + right: 12px; + left: 12px; + width: auto; + bottom: 88px; + } + .trigger { + right: 16px; + bottom: 20px; + } +} diff --git a/frontend/src/components/AgentChat.tsx b/frontend/src/components/AgentChat.tsx new file mode 100644 index 0000000..544ce64 --- /dev/null +++ b/frontend/src/components/AgentChat.tsx @@ -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>(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.map((item, i) => ( +
  • {inlineFormat(item)}
  • + ))} +
+ ); + 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 */} +
+