💬 Chat-Agent: Floating-Bubble + /api/chat mit DB-Kontext (nur published)

This commit is contained in:
Hermes Agent
2026-06-17 13:47:08 +02:00
parent 721ed50496
commit 924a759637
+235
View File
@@ -1743,6 +1743,52 @@ def public_frontend():
.site-header { padding:0.8rem 1rem; } .site-header { padding:0.8rem 1rem; }
.main-grid { grid-template-columns:1fr; padding:0 1rem; gap: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; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -1767,6 +1813,104 @@ def public_frontend():
<footer class="site-footer"> <footer class="site-footer">
<p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a> — Die Stimme der Brettspiel-Community</p> <p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a> — Die Stimme der Brettspiel-Community</p>
</footer> </footer>
<!-- ── Chat Bubble ──────────────────────────────────────────── -->
<button class="chat-bubble" id="chatBubble" title="BSN Community Assistant" aria-label="Chat öffnen">
💬<span class="bubble-dot"></span>
</button>
<!-- ── Chat Overlay ─────────────────────────────────────────── -->
<div class="chat-overlay" id="chatOverlay">
<div class="chat-header">
<span>💬 BSN Assistant</span>
<button id="chatClose" aria-label="Schließen">✕</button>
</div>
<div class="chat-body" id="chatBody">
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
</div>
<div class="chat-footer">
<input type="text" id="chatInput" placeholder="Frag was..." aria-label="Chat-Nachricht">
<button id="chatSend">Senden</button>
</div>
</div>
<script>
(function() {
const bubble = document.getElementById('chatBubble');
const overlay = document.getElementById('chatOverlay');
const closeBtn = document.getElementById('chatClose');
const body = document.getElementById('chatBody');
const input = document.getElementById('chatInput');
const sendBtn = document.getElementById('chatSend');
let history = [];
let open = false;
function toggle() {
open = !open;
overlay.classList.toggle('open', open);
if (open) setTimeout(() => input.focus(), 100);
}
bubble.addEventListener('click', toggle);
closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); });
function addMsg(role, text) {
const div = document.createElement('div');
div.className = 'chat-msg ' + role;
div.textContent = text;
body.appendChild(div);
body.scrollTop = body.scrollHeight;
return div;
}
function addTyping() {
const div = document.createElement('div');
div.className = 'chat-msg assistant typing';
div.textContent = '...';
div.id = 'typingIndicator';
body.appendChild(div);
body.scrollTop = body.scrollHeight;
return div;
}
function removeTyping() {
const t = document.getElementById('typingIndicator');
if (t) t.remove();
}
async function send() {
const msg = input.value.trim();
if (!msg) return;
input.value = '';
sendBtn.disabled = true;
addMsg('user', msg);
history.push({role:'user', content:msg});
const typing = addTyping();
try {
const r = await fetch('/api/chat', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg, history:history})
});
const data = await r.json();
removeTyping();
const reply = data.reply || 'Hmm, da ist was schiefgegangen.';
addMsg('assistant', reply);
history.push({role:'assistant', content:reply});
if (history.length > 20) history = history.slice(-20);
} catch(e) {
removeTyping();
addMsg('assistant', 'Der Chat-Assistent ist gerade nicht erreichbar. Versuch es später nochmal.');
}
sendBtn.disabled = false;
input.focus();
}
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
})();
</script>
</body> </body>
</html>""" </html>"""
@@ -1806,6 +1950,97 @@ def api_query():
return jsonify({'results': results, 'count': len(results)}) 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/<int:sub_id>") @app.route("/beitrag/<int:sub_id>")
def public_detail(sub_id: int): def public_detail(sub_id: int):
"""Public detail view — full media, text, and metadata.""" """Public detail view — full media, text, and metadata."""