From a7efd9354d4b494b75c4283495f3c191bd8f3ec1 Mon Sep 17 00:00:00 2001 From: Ronny Date: Sat, 11 Apr 2026 18:54:50 +0200 Subject: [PATCH] refactor: remove KI-Agent chat widget and backend route --- .../content/01-dashboard-concept.html | 87 ++++ .../content/02-emotional-moments.html | 50 ++ .../content/03-spielplan-navigation.html | 61 +++ .../content/04-profil-matchcards.html | 64 +++ .../content/05-cleanup-scope.html | 67 +++ .../4751-1775923699/content/waiting.html | 3 + .../4751-1775923699/state/server-info | 1 + .../4751-1775923699/state/server.log | 37 ++ .../4751-1775923699/state/server.pid | 1 + backend/src/index.ts | 12 - backend/src/routes/agent.ts | 411 --------------- frontend/src/App.tsx | 4 - frontend/src/components/AgentChat.module.css | 466 ----------------- frontend/src/components/AgentChat.tsx | 488 ------------------ 14 files changed, 371 insertions(+), 1381 deletions(-) create mode 100644 .superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html create mode 100644 .superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html create mode 100644 .superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html create mode 100644 .superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html create mode 100644 .superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html create mode 100644 .superpowers/brainstorm/4751-1775923699/content/waiting.html create mode 100644 .superpowers/brainstorm/4751-1775923699/state/server-info create mode 100644 .superpowers/brainstorm/4751-1775923699/state/server.log create mode 100644 .superpowers/brainstorm/4751-1775923699/state/server.pid delete mode 100644 backend/src/routes/agent.ts delete mode 100644 frontend/src/components/AgentChat.module.css delete mode 100644 frontend/src/components/AgentChat.tsx diff --git a/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html b/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html new file mode 100644 index 0000000..809ff8d --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html @@ -0,0 +1,87 @@ +

Neue Startseite: Dashboard statt Spielliste

+

Aktuell sieht man 104 Spiele als endlose Liste. Die neue Startseite soll sofort zeigen: Was ist relevant für MICH, JETZT?

+ +
+ +
+
+
+
+
Nächstes Spiel · in 2h 14min
+
+
🇲🇽
Mexico
+
vs
+
🇿🇦
S. Africa
+
+
+ Dein Tipp: 2:1 ✓ +
+
+
+
+
5.
+
Dein Rang
+
+
+
12
+
Punkte
+
+
+
3🔥
+
Streak
+
+
+
+
📅 Heute noch 2 Spiele ohne Tipp
+
🏆 Max führt mit 15 Punkten
+
+
+
+
+

A: Hero + Stats + Nudges

+

Großes "Nächstes Spiel" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.

+
+
+ +
+
+
+
+
Hallo Ronny!
Platz 5 · 12 Punkte
+
3🔥
+
+
⏰ Jetzt tippen
+
+
+ 🇲🇽 Mexico vs S. Africa 🇿🇦 + 2h 14m +
+
+
+
+ 🇰🇷 S. Korea vs Croatia 🇭🇷 + 5h 14m +
+
+
📊 Ergebnisse
+
+
+ 🇧🇷 Brazil 3:1 Serbia 🇷🇸 + +3 🎯 +
+
+
+
+
+

B: Feed-Style Timeline

+

Persönliche Begrüßung oben, dann chronologisch: erst offene Tipps (dringendste zuerst mit Countdown), dann letzte Ergebnisse mit Punkte-Badges. Wie ein persönlicher WM-Feed.

+
+
+ +
+ +
+

Beide Varianten ersetzen die 104-Spiele-Liste als Startseite

+

Der komplette Spielplan bleibt erreichbar über "Alle Spiele" in der Navigation. Die Startseite wird zum persönlichen WM-Cockpit.

+
+ \ No newline at end of file diff --git a/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html b/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html new file mode 100644 index 0000000..06c3efa --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html @@ -0,0 +1,50 @@ +

Emotionale Momente: Wo die App "lebendig" wird

+

5 Schlüsselmomente, in denen die App ein Lächeln erzeugen soll. Welche sind dir am wichtigsten?

+ +
+ +
+
1
+
+

Tipp-Bestätigung

+

Nach "Tipp bestätigen" kurze Erfolgsanimation: Card pulsiert grün, Häkchen fliegt rein, subtiles Haptic-Feedback (Vibration auf Mobile). Statt nur Modal schließen → "Dein Tipp ist drin! 🎯" mit sanfter Animation.

+
+
+ +
+
2
+
+

Live-Countdown vor Anpfiff

+

Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. "Noch 12 Minuten zum Tippen!" — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: "Tippfenster geschlossen" mit Schloss-Icon.

+
+
+ +
+
3
+
+

Punkte-Reveal nach Spielende

+

Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, Punkte nicht einfach anzeigen — enthüllen. Kurzsequenz: Ergebnis einblenden → "Dein Tipp war..." → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 Konfetti-Explosion + goldener "EXAKT!"-Badge. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.

+
+
+ +
+
4
+
+

Ranglistenveränderung

+

Wenn sich dein Rang ändert: "Du bist auf Platz 3 aufgestiegen! ⬆️" als Toast-Notification beim nächsten App-Öffnen. Oder: "Achtung, Anna ist nur noch 1 Punkt hinter dir!" — erzeugt freundschaftliche Rivalität.

+
+
+ +
+
5
+
+

Streak-Tracker

+

Visueller Feuer-Counter: 🔥3 wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: "Deine 7er-Serie ist gerissen! Starte eine neue."

+
+
+ +
+ +
+

Klicke auf alle Momente, die du umsetzen möchtest (Mehrfachauswahl möglich). Oder sag mir im Terminal, wenn du alle willst oder Änderungswünsche hast.

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html b/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html new file mode 100644 index 0000000..b0164a8 --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html @@ -0,0 +1,61 @@ +

Spielplan & Navigation: 104 Spiele im Griff

+

Aktuell: endlose Liste mit Stage-Filter oben. Wie machen wir das besser für Mobile?

+ +
+

Spielplan-Struktur

+
+ +
+ +
+
A
+
+

Smart Sections (Empfehlung)

+

Spielplan automatisch in Abschnitte unterteilt: +
"Heute" (expandiert, farbig hervorgehoben) +
"Morgen" (expandiert) +
"Diese Woche" (kollapsiert, Anzahl angezeigt) +
"Vergangene Spiele" (kollapsiert, zeigt letzte Ergebnisse + deine Punkte) +

Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.

+
+
+ +
+
B
+
+

Tab-basiert

+

Drei Haupt-Tabs am oberen Rand: +
"Offen" — Spiele, die noch getippt werden können (nach Kick-off sortiert) +
"Live" — Laufende Spiele mit Echtzeit-Status +
"Ergebnisse" — Abgeschlossene Spiele mit Punkten +

Klar getrennt, aber weniger zeitlicher Kontext ("heute/morgen").

+
+
+ +
+ +
+

Mobile Navigation

+
+ +
+ +
+
C
+
+

Bottom Navigation Bar (Empfehlung)

+

Feste Leiste am unteren Bildschirmrand mit Icons + Label: +
🏠 Home (Dashboard) · ⚽ Spiele · 🏆 Rangliste · 👤 Profil +

Standard-Pattern für Mobile-Apps (Staffbase, Instagram, etc.). Header wird schlank — nur Logo + evtl. Notification-Badge. Admin nur für Editoren sichtbar als Extra-Icon.

+
+
+ +
+
D
+
+

Header-Nav beibehalten

+

Bestehende Navigation oben lassen, aber scrollbar machen auf Mobile (horizontal swipe). Weniger Aufwand, aber der Header nimmt auf kleinen Screens viel Platz ein und ist weniger daumenfreundlich.

+
+
+ +
\ No newline at end of file diff --git a/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html b/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html new file mode 100644 index 0000000..7798ec3 --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html @@ -0,0 +1,64 @@ +

Profil-Seite & Match-Cards aufwerten

+

Zwei Bereiche, die aktuell etwas leblos wirken

+ +
+

Profil-Seite: Von Nullen zu Persönlichkeit

+

Aktuell: Name, Rang, 4 Stat-Boxen mit Nullen. Kein Grund hier wiederzukommen.

+
+ +
+ +
+
A
+
+

Reiches Profil (Empfehlung)

+

+ Header: Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge +
Stats-Ring: Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen +
Tipp-Historie: Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — "Deine letzten 10 Tipps" +
Achievements: Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2 +
Fun-Stat: "Dein Lieblings-Tipp: 1:0 (5x getippt)" oder "Du tippst 70% Heimsiege" +

+
+
+ +
+
B
+
+

Minimal-Upgrade

+

Bestehende Struktur beibehalten, aber: Stat-Boxen mit Animationen beim Laden, Tipp-Historie als einfache Liste darunter, Lieblingsteam-Auswahl prominenter platzieren. Weniger Aufwand, aber auch weniger Wow.

+
+
+ +
+ +
+

Match-Cards: Mehr Status, mehr Emotion

+

Aktuell: Team-Flaggen + Uhrzeit + "Tipp abgeben"-Button. Funktional, aber alle Karten sehen gleich aus.

+
+ +
+ +
+
C
+
+

Zustandsbasierte Cards (Empfehlung)

+

Jede Card sieht anders aus je nach Zustand: +
Offen: "Tipp abgeben"-Button, Countdown wenn <24h +
Getippt: Dein Tipp prominent angezeigt, grüner Rand, "Ändern"-Link +
🔴 Live: Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar) +
🏁 Beendet: Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer +
🔒 Verpasst: Ausgegraut, "Nicht getippt" — leichter Shame-Effekt als Motivation +

+
+
+ +
+
D
+
+

Einfache Verbesserung

+

Bestehende Cards beibehalten, nur Farb-Akzente für Status hinzufügen (grüner Rand = getippt, grau = verpasst). Kein grundlegender Redesign. Weniger Aufwand, aber auch weniger visuell differenziert.

+
+
+ +
\ No newline at end of file diff --git a/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html b/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html new file mode 100644 index 0000000..0209200 --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html @@ -0,0 +1,67 @@ +

Aufräumen: Was fällt weg, was ändert sich?

+

Damit die App schlanker und fokussierter wird

+ +
+ +
+
1
+
+

KI-Agent / Expertenblick entfernen

+

Der Chat-Widget (Fußball-Icon unten rechts) und "Expertenblick" im Tipp-Modal. Wurde als Nice-to-have eingestuft. Spart Platz auf Mobile und reduziert Ablenkung vom Kernflow. Der Platz unten rechts wird frei für den Dev-Button (nur Dev-Mode).

+
+
+ +
+
2
+
+

Stage-Filter vereinfachen

+

Aktuell: 8 Filter-Buttons (Alle, Gruppenphase, Runde der 32, Achtelfinale...). Wird durch Smart Sections ersetzt. Stage-Filter nur noch als Dropdown im Spielplan-View, nicht mehr als Button-Leiste. Weniger visueller Lärm.

+
+
+ +
+
3
+
+

Admin aus der Hauptnav entfernen

+

Aktuell sieht jeder "Admin" in der Navigation (auch Viewer). Besser: Admin nur für Editoren sichtbar, und zwar als kleines Zahnrad-Icon — nicht als vollwertiger Nav-Punkt. Hält die Navigation sauber für normale User.

+
+
+ +
+
4
+
+

Tipp-Modal verschlanken

+

Aktuell enthält das Modal: Gruppeninfo, Kick-off-Datum, Picker, Tendenz-Anzeige, Expertenblick-Accordion, Bestätigen, Abbrechen. Vereinfachen auf: Teams + Flaggen, Picker, Tendenz, Bestätigen. Kompakter, schnellerer Flow.

+
+
+ +
+ +
+

Zusammenfassung Phase 1

+
+
+

Neu

+
    +
  • Dashboard als Startseite (Hero + Stats + Nudges)
  • +
  • Bottom Navigation Bar
  • +
  • Smart Sections im Spielplan
  • +
  • 5 emotionale Momente (Animationen, Konfetti, Streaks)
  • +
  • Zustandsbasierte Match-Cards
  • +
  • Reiches Profil mit Stats-Ring + Historie
  • +
+
+
+

Phase 2 & 3 (später)

+
    +
  • Badges & Achievements
  • +
  • Wochenwertung
  • +
  • Tipps anderer sehen
  • +
  • Reaktionen / Emojis
  • +
  • Abteilungs-Challenge
  • +
+
+
+
+ +

Wähle aus, was wegfallen soll. Dann schreibe ich das komplette Design-Dokument.

\ No newline at end of file diff --git a/.superpowers/brainstorm/4751-1775923699/content/waiting.html b/.superpowers/brainstorm/4751-1775923699/content/waiting.html new file mode 100644 index 0000000..d990a0e --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/content/waiting.html @@ -0,0 +1,3 @@ +
+

Design-Dokument wird geschrieben... weiter im Terminal.

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/4751-1775923699/state/server-info b/.superpowers/brainstorm/4751-1775923699/state/server-info new file mode 100644 index 0000000..fb69fe6 --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/state/server-info @@ -0,0 +1 @@ +{"type":"server-started","port":52250,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52250","screen_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content","state_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/state"} diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.log b/.superpowers/brainstorm/4751-1775923699/state/server.log new file mode 100644 index 0000000..9c4d273 --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/state/server.log @@ -0,0 +1,37 @@ +{"type":"server-started","port":52250,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52250","screen_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content","state_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/state"} +{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"} +{"source":"user-event","type":"click","text":"Nächstes Spiel · in 2h 14min\n \n 🇲🇽Mexico\n vs\n 🇿🇦S. Africa\n \n \n Dein Tipp: 2:1 ✓\n \n \n \n \n 5.\n Dein Rang\n \n \n 12\n Punkte\n \n \n 3🔥\n Streak\n \n \n \n 📅 Heute noch 2 Spiele ohne Tipp\n 🏆 Max führt mit 15 Punkten\n \n \n \n \n A: Hero + Stats + Nudges\n Großes \"Nächstes Spiel\" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.","choice":"a","id":null,"timestamp":1775924301735} +{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"} +{"source":"user-event","type":"click","text":"1\n \n Tipp-Bestätigung\n Nach \"Tipp bestätigen\" kurze Erfolgsanimation: Card pulsiert grün, Häkchen fliegt rein, subtiles Haptic-Feedback (Vibration auf Mobile). Statt nur Modal schließen → \"Dein Tipp ist drin! 🎯\" mit sanfter Animation.","choice":"tipp-confirm","id":null,"timestamp":1775924389765} +{"source":"user-event","type":"click","text":"2\n \n Live-Countdown vor Anpfiff\n Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. \"Noch 12 Minuten zum Tippen!\" — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: \"Tippfenster geschlossen\" mit Schloss-Icon.","choice":"countdown","id":null,"timestamp":1775924391183} +{"source":"user-event","type":"click","text":"3\n \n Punkte-Reveal nach Spielende\n Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, Punkte nicht einfach anzeigen — enthüllen. Kurzsequenz: Ergebnis einblenden → \"Dein Tipp war...\" → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 Konfetti-Explosion + goldener \"EXAKT!\"-Badge. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.","choice":"punkte-reveal","id":null,"timestamp":1775924392631} +{"source":"user-event","type":"click","text":"4\n \n Ranglistenveränderung\n Wenn sich dein Rang ändert: \"Du bist auf Platz 3 aufgestiegen! ⬆️\" als Toast-Notification beim nächsten App-Öffnen. Oder: \"Achtung, Anna ist nur noch 1 Punkt hinter dir!\" — erzeugt freundschaftliche Rivalität.","choice":"rang-change","id":null,"timestamp":1775924394132} +{"source":"user-event","type":"click","text":"5\n \n Streak-Tracker\n Visueller Feuer-Counter: 🔥3 wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: \"Deine 7er-Serie ist gerissen! Starte eine neue.\"","choice":"streak","id":null,"timestamp":1775924395415} +{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"} +{"source":"user-event","type":"click","text":"A\n \n Smart Sections (Empfehlung)\n Spielplan automatisch in Abschnitte unterteilt:\n \"Heute\" (expandiert, farbig hervorgehoben)\n \"Morgen\" (expandiert)\n \"Diese Woche\" (kollapsiert, Anzahl angezeigt)\n \"Vergangene Spiele\" (kollapsiert, zeigt letzte Ergebnisse + deine Punkte)\n Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.","choice":"smart-sections","id":null,"timestamp":1775924457903} +{"source":"user-event","type":"click","text":"C\n \n Bottom Navigation Bar (Empfehlung)\n Feste Leiste am unteren Bildschirmrand mit Icons + Label:\n 🏠 Home (Dashboard) · ⚽ Spiele · 🏆 Rangliste · 👤 Profil\n Standard-Pattern für Mobile-Apps (Staffbase, Instagram, etc.). Header wird schlank — nur Logo + evtl. Notification-Badge. Admin nur für Editoren sichtbar als Extra-Icon.","choice":"bottom-nav","id":null,"timestamp":1775924462017} +{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"} +{"source":"user-event","type":"click","text":"A\n \n Reiches Profil (Empfehlung)\n \n Header: Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge\n Stats-Ring: Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen\n Tipp-Historie: Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — \"Deine letzten 10 Tipps\"\n Achievements: Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2\n Fun-Stat: \"Dein Lieblings-Tipp: 1:0 (5x getippt)\" oder \"Du tippst 70% Heimsiege\"","choice":"profil-rich","id":null,"timestamp":1775924529062} +{"source":"user-event","type":"click","text":"C\n \n Zustandsbasierte Cards (Empfehlung)\n Jede Card sieht anders aus je nach Zustand:\n ⏳ Offen: \"Tipp abgeben\"-Button, Countdown wenn <24h\n ✅ Getippt: Dein Tipp prominent angezeigt, grüner Rand, \"Ändern\"-Link\n 🔴 Live: Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar)\n 🏁 Beendet: Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer\n 🔒 Verpasst: Ausgegraut, \"Nicht getippt\" — leichter Shame-Effekt als Motivation","choice":"cards-states","id":null,"timestamp":1775924530413} +{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"} +{"source":"user-event","type":"click","text":"4\n \n Tipp-Modal verschlanken\n Aktuell enthält das Modal: Gruppeninfo, Kick-off-Datum, Picker, Tendenz-Anzeige, Expertenblick-Accordion, Bestätigen, Abbrechen. Vereinfachen auf: Teams + Flaggen, Picker, Tendenz, Bestätigen. Kompakter, schnellerer Flow.","choice":"simplify-tip-modal","id":null,"timestamp":1775924611669} +{"source":"user-event","type":"click","text":"3\n \n Admin aus der Hauptnav entfernen\n Aktuell sieht jeder \"Admin\" in der Navigation (auch Viewer). Besser: Admin nur für Editoren sichtbar, und zwar als kleines Zahnrad-Icon — nicht als vollwertiger Nav-Punkt. Hält die Navigation sauber für normale User.","choice":"admin-hidden","id":null,"timestamp":1775924612378} +{"source":"user-event","type":"click","text":"2\n \n Stage-Filter vereinfachen\n Aktuell: 8 Filter-Buttons (Alle, Gruppenphase, Runde der 32, Achtelfinale...). Wird durch Smart Sections ersetzt. Stage-Filter nur noch als Dropdown im Spielplan-View, nicht mehr als Button-Leiste. Weniger visueller Lärm.","choice":"remove-stage-filter","id":null,"timestamp":1775924613128} +{"source":"user-event","type":"click","text":"1\n \n KI-Agent / Expertenblick entfernen\n Der Chat-Widget (Fußball-Icon unten rechts) und \"Expertenblick\" im Tipp-Modal. Wurde als Nice-to-have eingestuft. Spart Platz auf Mobile und reduziert Ablenkung vom Kernflow. Der Platz unten rechts wird frei für den Dev-Button (nur Dev-Mode).","choice":"remove-agent","id":null,"timestamp":1775924613861} +{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} +{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.pid b/.superpowers/brainstorm/4751-1775923699/state/server.pid new file mode 100644 index 0000000..f9aa984 --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/state/server.pid @@ -0,0 +1 @@ +4760 diff --git a/backend/src/index.ts b/backend/src/index.ts index 6a6dcb5..cf98b23 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,7 +14,6 @@ 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'); @@ -96,16 +95,6 @@ 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) // ============================================================ @@ -137,7 +126,6 @@ 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 deleted file mode 100644 index 2481d06..0000000 --- a/backend/src/routes/agent.ts +++ /dev/null @@ -1,411 +0,0 @@ -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 -// ============================================================ -const NETZER_STYLE = - 'DEIN STIL - Günther Netzer:\n' + - 'Du bist Günther Netzer, ARD-Fußballexperte von 1997-2010. Trocken. Direkt. Elitär. Nostalgisch.\n' + - 'Du maßt das aktuelle Geschehen stets an idealistischen Maßstäben - und an deiner eigenen Karriere.\n\n' + - 'TYPISCHE PHRASEN die du verwendest:\n' + - '- "Aus der Tiefe des Raumes"\n' + - '- "Das sind fundamentale Dinge"\n' + - '- "Das ist ein Minimalisten-Dasein"\n' + - '- "Mir hat hier heute noch gar nichts gefallen"\n' + - '- "Das hat mit Spitzenfußball nichts zu tun"\n' + - '- "Das war dezent" (wenn eine Leistung mäßig war)\n' + - '- "Was bleibt mir noch übrig jetzt zu sagen..."\n' + - '- Gelegentlich ironisches Lob: "Das ist wirklich eine sehr kluge Beobachtung..."\n\n' + - 'EIGENHEITEN:\n' + - '- Du vergleichst fast alles mit Beckenbauer, Müller, Cruyff oder deiner eigenen Zeit\n' + - '- Taktik-Geschwafel lehnst du ab: "Das nennen die heutzutage Ballbesitzfußball. Früher nannte man das Angst."\n' + - '- Du bist von Mannschaften prinzipiell enttäuscht, außer die Leistung ist absolut unstrittig\n' + - '- Kurze Sätze. Kein "mega", kein "Wahnsinn". Kein übertriebenes Lob.\n\n'; - -const DELLING_STYLE = - 'Die Rolle von Gerhard Delling (dein Moderator-Pendant, NUR im Dialog-Modus):\n' + - '- Trocken, skeptisch, stichelt gerne\n' + - '- Typische Phrasen: "Nun könnte man sagen, seien wir doch mal großzügig...", "Fanden Sie nicht, dass immerhin..."\n' + - '- Verteidigt absichtlich die schwächere Mannschaft um Netzer zu provozieren\n' + - '- Stichelt gegen Netzers Vergangenheit (Laufbereitschaft, Frisur)\n' + - '- Bleibt immer ruhig, lässt sich von Netzers Arroganz nicht erschüttern\n' + - '- Er und Netzer siezen sich stets, obwohl sie Freunde sind\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 + - DELLING_STYLE + - 'GESPRÄCHSMODUS: Wenn der Nutzer dich direkt anschreibt, antwortest du als Netzer allein. Wenn du eine Analyse oder Einschätzung gibst, kannst du gelegentlich einen kurzen Einwurf von Delling einfließen lassen - im Format:\n' + - '**Delling:** "..."\n' + - '**Netzer:** "..."\n\n' + - '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 + - DELLING_STYLE + - 'Schreibe einen kurzen Expertenblick als Dialog zwischen Delling und Netzer über das folgende Spiel.\n\n' + - 'WICHTIG: Das Spiel hat noch NICHT stattgefunden. Es ist eine Vorschau, keine Nachbetrachtung.\n' + - 'Verwende ausschließlich Zukunftsformen und Konjunktiv: "wird", "könnte", "dürfte", "ist zu erwarten".\n' + - 'Du darfst auf vergangene Begegnungen, Qualifikation oder historische Statistiken referenzieren - aber nur als Argument für die Prognose.\n' + - 'VERBOTEN: Phrasen wie "hat mir nicht gefallen", "da war nichts", "das war" bezogen auf das aktuelle Spiel.\n\n' + - 'BEISPIELE (Vorschau-Ton - diese Authentizität ist entscheidend):\n\n' + - 'Beispiel 1 (Gruppenspiel mit klarem Favoriten):\n' + - '**Delling:** "Nun, Herr Netzer, wir haben hier ja doch einen veritablen Favoriten. Könnte der Außenseiter nicht von der Qualifikationsform profitieren?"\n' + - '**Netzer:** "Nein. Das waren Qualifikationsspiele. Das wird mit dem hier nichts zu tun haben. Das sind fundamentale Dinge."\n' + - '**Delling:** "Seien wir doch mal großzügig - auch der Außenseiter hat Qualitäten, die sich zeigen könnten."\n' + - '**Netzer:** "Das nennen Sie Qualitäten. Ich nenne das ein Minimalisten-Dasein. Ich tippe auf einen klaren Sieg des Favoriten."\n\n' + - 'Beispiel 2 (Ausgeglichenes Spiel):\n' + - '**Delling:** "Herr Netzer, das könnte ja ein enges Spiel werden. Beide Mannschaften liegen nah beieinander."\n' + - '**Netzer:** "Das ist dezent ausgedrückt. Beiden fehlt, was Beckenbauer damals selbstverständlich war - diese Überlegenheit. Aus der Tiefe des Raumes heraus, verstehen Sie?"\n' + - '**Delling:** "Ich glaube, die Spieler würden sich bedanken, wenn Sie ihnen das vor dem Anpfiff erläutern könnten."\n' + - '**Netzer:** "Was bleibt mir noch übrig jetzt zu sagen. Ich tippe auf ein 1:1."\n\n' + - 'JETZT das echte Spiel:\n' + - 'Spiel: **' + homeTeam + '** vs. **' + awayTeam + '** (' + stageLabel + ')\n\n' + - 'Schreibe genau 4 Wechselreden (Delling, Netzer, Delling, Netzer). Netzer gibt am Ende seinen konkreten Tipp mit Score. Kein Emoji. Siezen. Kurze Sätze bei Netzer.'; - - 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: 512, - 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(); - } - } -}); - -// ============================================================ -// POST /api/agent/insight-audio -// Body: { dialogText: string } -// Gibt eine MP3 zurück (Delling + Netzer als Dialog, 2 Stimmen) -// ============================================================ - -// ElevenLabs Voice-IDs (kostenlose Standard-Voices) -// Netzer: "Adam" – tief, ruhig, autoritär -// Delling: "Antoni" – etwas heller, sachlicher -const ELEVENLABS_VOICE_NETZER = process.env.ELEVENLABS_VOICE_NETZER ?? 'pNInz6obpgDQGcFmaJgB'; // Adam -const ELEVENLABS_VOICE_DELLING = process.env.ELEVENLABS_VOICE_DELLING ?? 'ErXwobaYiN019PkySvjV'; // Antoni - -async function synthesizeTurn( - text: string, - voiceId: string, - apiKey: string -): Promise { - const res = await fetch( - `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, - { - method: 'POST', - headers: { - 'xi-api-key': apiKey, - 'Content-Type': 'application/json', - Accept: 'audio/mpeg', - }, - body: JSON.stringify({ - text, - model_id: 'eleven_multilingual_v2', - voice_settings: { stability: 0.55, similarity_boost: 0.75 }, - }), - } - ); - if (!res.ok) { - const err = await res.text(); - throw new Error(`ElevenLabs error ${res.status}: ${err}`); - } - const arrayBuf = await res.arrayBuffer(); - return Buffer.from(arrayBuf); -} - -// Parst **Delling:** "..." / **Netzer:** "..." Zeilen aus dem Dialog-Text -function parseDialogTurns( - dialogText: string -): Array<{ speaker: 'Delling' | 'Netzer'; text: string }> { - const turns: Array<{ speaker: 'Delling' | 'Netzer'; text: string }> = []; - const lines = dialogText.split('\n'); - for (const line of lines) { - const m = line.match(/^\*\*(Delling|Netzer):\*\*\s*[„""]?(.+?)["""]?\s*$/); - if (m) { - turns.push({ - speaker: m[1] as 'Delling' | 'Netzer', - text: m[2].trim(), - }); - } - } - return turns; -} - -router.post('/insight-audio', async (req: Request, res: Response): Promise => { - const { dialogText } = req.body as { dialogText?: string }; - - if (!dialogText) { - res.status(400).json({ error: 'dialogText erforderlich' }); - return; - } - - const apiKey = process.env.ELEVENLABS_API_KEY; - if (!apiKey) { - res.status(503).json({ error: 'ELEVENLABS_API_KEY nicht konfiguriert' }); - return; - } - - const turns = parseDialogTurns(dialogText); - logger.info('Audio: Dialog geparst', { turns: turns.length, preview: dialogText.slice(0, 200) }); - if (turns.length === 0) { - res.status(400).json({ error: 'Kein Dialog-Format erkannt' }); - return; - } - - try { - // Turns sequenziell synthetisieren (Free Tier: max 2 concurrent) - const audioBuffers: Buffer[] = []; - for (const turn of turns) { - const buf = await synthesizeTurn( - turn.text, - turn.speaker === 'Netzer' ? ELEVENLABS_VOICE_NETZER : ELEVENLABS_VOICE_DELLING, - apiKey - ); - audioBuffers.push(buf); - } - - // MP3-Chunks zusammenführen (einfaches Aneinanderhängen reicht für MP3) - const combined = Buffer.concat(audioBuffers); - - res.setHeader('Content-Type', 'audio/mpeg'); - res.setHeader('Content-Length', combined.length); - res.setHeader('Cache-Control', 'no-store'); - res.send(combined); - - logger.info('Agent: Insight-Audio generiert', { - userId: req.staffbaseUser?.sub, - turns: turns.length, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Agent: Insight-Audio-Fehler', { error: message }); - if (!res.headersSent) { - res.status(500).json({ error: 'Audio-Generierung fehlgeschlagen' }); - } - } -}); - -export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a9ea8d..4d88f4a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ 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 || import.meta.env.VITE_TEST_MODE === 'true'; @@ -121,9 +120,6 @@ export default function App() { onRefresh={handleDevRefresh} /> )} - - {/* Fußball-Experte Chat-Widget – immer sichtbar */} - ); } diff --git a/frontend/src/components/AgentChat.module.css b/frontend/src/components/AgentChat.module.css deleted file mode 100644 index d7a737a..0000000 --- a/frontend/src/components/AgentChat.module.css +++ /dev/null @@ -1,466 +0,0 @@ -/* ============================================================ - 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 deleted file mode 100644 index 544ce64..0000000 --- a/frontend/src/components/AgentChat.tsx +++ /dev/null @@ -1,488 +0,0 @@ -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 */} -
-