diff --git a/app.py b/app.py index 4e412cd..41a6c77 100644 --- a/app.py +++ b/app.py @@ -1743,6 +1743,52 @@ def public_frontend(): .site-header { padding:0.8rem 1rem; } .main-grid { grid-template-columns:1fr; padding:0 1rem; gap:1rem; } } + + /* ── Chat Bubble + Overlay ────────────────────────────────── */ + .chat-bubble { position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999; + width:56px; height:56px; border-radius:50%; background:#20228a; + color:#fff; border:none; cursor:pointer; font-size:1.6rem; + box-shadow:0 6px 24px rgba(0,0,0,0.25); transition:transform 0.2s,box-shadow 0.2s; + display:flex; align-items:center; justify-content:center; } + .chat-bubble:hover { transform:scale(1.08); box-shadow:0 8px 32px rgba(0,0,0,0.35); } + .chat-bubble:active { transform:scale(0.95); } + .chat-bubble .bubble-dot { position:absolute; top:6px; right:6px; width:12px; height:12px; + background:#28a745; border-radius:50%; border:2px solid #fff; } + + .chat-overlay { position:fixed; bottom:5.5rem; right:1.5rem; z-index:9998; + width:380px; max-width:calc(100vw - 2rem); max-height:520px; + background:#fff; border-radius:18px; box-shadow:0 12px 48px rgba(0,0,0,0.22); + display:none; flex-direction:column; overflow:hidden; } + .chat-overlay.open { display:flex; } + .chat-header { background:#20228a; color:#fff; padding:0.9rem 1.2rem; + font-weight:700; font-size:0.9rem; display:flex; align-items:center; justify-content:space-between; } + .chat-header button { background:none; border:none; color:#fff; font-size:1.3rem; + cursor:pointer; opacity:0.8; transition:opacity 0.15s; padding:0; line-height:1; } + .chat-header button:hover { opacity:1; } + .chat-body { flex:1; overflow-y:auto; padding:1rem; display:flex; flex-direction:column; gap:0.6rem; + max-height:320px; background:#fafafa; } + .chat-msg { max-width:82%; padding:0.55rem 0.85rem; border-radius:14px; + font-size:0.82rem; line-height:1.45; word-break:break-word; animation:fadeUp 0.25s ease; } + .chat-msg.user { align-self:flex-end; background:#20228a; color:#fff; + border-bottom-right-radius:4px; } + .chat-msg.assistant { align-self:flex-start; background:#e9e9eb; color:#1a1a2e; + border-bottom-left-radius:4px; } + .chat-msg.typing { background:#e9e9eb; color:#999; font-style:italic; } + @keyframes fadeUp { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} } + .chat-footer { padding:0.7rem 0.9rem; border-top:1px solid #eee; display:flex; gap:0.5rem; } + .chat-footer input { flex:1; border:1.5px solid #ddd; border-radius:999px; + padding:0.55rem 1rem; font-size:0.82rem; font-family:inherit; outline:none; + transition:border-color 0.15s; } + .chat-footer input:focus { border-color:#20228a; } + .chat-footer button { background:#20228a; color:#fff; border:none; border-radius:999px; + padding:0.55rem 1rem; font-weight:600; font-size:0.82rem; cursor:pointer; + transition:background 0.15s; font-family:inherit; } + .chat-footer button:hover { background:#161e6e; } + .chat-footer button:disabled { opacity:0.5; cursor:default; } + @media(max-width:600px) { + .chat-overlay { bottom:5rem; right:0.5rem; width:calc(100vw - 1rem); max-height:60vh; } + .chat-bubble { bottom:1rem; right:1rem; } + } @@ -1767,6 +1813,104 @@ def public_frontend(): + + + + + +
+
+ 💬 BSN Assistant + +
+
+
Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!
+
+ +
+ + """ @@ -1806,6 +1950,97 @@ def api_query(): return jsonify({'results': results, 'count': len(results)}) +@app.route('/api/chat', methods=['POST']) +def api_chat(): + """Chat agent with read-only access to published submissions.""" + db = get_db() + + data = request.get_json(force=True, silent=True) or {} + user_message = (data.get('message') or '').strip() + if not user_message: + return jsonify({'reply': 'Leere Nachricht. Was möchtest du wissen?'}) + + # Build context from published submissions + rows = db.execute( + "SELECT id, halle, content, summary, type, sender_name, category, " + "spiel_titel, published_at " + "FROM submissions WHERE status='published' AND deleted_at IS NULL " + "ORDER BY published_at DESC LIMIT 60" + ).fetchall() + + if not rows: + return jsonify({'reply': 'Noch keine Beiträge veröffentlicht. Schau später wieder vorbei!'}) + + # Build compact context string + context_parts = [] + for i, r in enumerate(rows, 1): + text = (r['summary'] or r['content'] or '').strip()[:400] + halle = r['halle'] or '' + spiel = r['spiel_titel'] or '' + cat = r['category'] or '' + name = (r['sender_name'] or 'Anonym').strip() + context_parts.append( + f"[{i}] {text}" + + (f" | 📍{halle}" if halle else '') + + (f" | 🎲{spiel}" if spiel else '') + + (f" | #{cat}" if cat else '') + + (f" | von {name}" if name else '') + ) + context = '\n'.join(context_parts) + + # System prompt + system = ( + "Du bist der BSN Community Assistant von brettspiel-news.de. " + "Du hilfst Besuchern, Informationen über die Brettspiel-Community auf der SPIEL Essen zu finden.\n\n" + "WICHTIGE REGELN:\n" + "- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n" + "- Wenn die Antwort nicht im Kontext steht, sage ehrlich: 'Dazu habe ich leider keine Informationen.'\n" + "- Erfinde NIEMALS Informationen, Namen, Stände oder Spieletitel.\n" + "- Antworte immer auf Deutsch, freundlich und hilfsbereit.\n" + "- Halte Antworten kurz und präzise (2-4 Sätze).\n" + "- Bei Standortfragen nenne immer die Hallen-Nummer.\n" + "- Der Kontext enthält nummerierte Beiträge [1], [2] usw. — du kannst darauf verweisen.\n\n" + f"KONTEXT (veröffentlichte Community-Beiträge):\n{context[:8000]}" + ) + + # Read API key + config_path = os.path.expanduser("~/.hermes/config.yaml") + api_key = "" + try: + with open(config_path) as f: + import yaml as _yaml + cfg = _yaml.safe_load(f) + api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "") + except Exception: + pass + if not api_key: + return jsonify({'reply': 'Chat-Assistent ist aktuell nicht verfügbar (API-Key fehlt).'}) + + # Build messages with conversation history + messages = [{"role": "system", "content": system}] + history = data.get('history') or [] + if isinstance(history, list): + for h in history[-10:]: # last 10 messages max + if isinstance(h, dict) and 'role' in h and 'content' in h: + messages.append({"role": h['role'], "content": h['content']}) + messages.append({"role": "user", "content": user_message}) + + try: + r = requests.post( + "https://ui.datenhimmel.work/api/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": "deepseek-v4-flash:cloud", "messages": messages, "max_tokens": 400, "temperature": 0.4}, + timeout=15, + ) + if r.status_code != 200: + return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'}) + reply = r.json()["choices"][0]["message"]["content"].strip() + except Exception: + return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'}) + + return jsonify({'reply': reply}) + + @app.route("/beitrag/") def public_detail(sub_id: int): """Public detail view — full media, text, and metadata."""