💬 Chat-Agent: Floating-Bubble + /api/chat mit DB-Kontext (nur published)
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user