fix: QC-Ratings aus 7 älteren Artikeln entfernt (CHECK 10)
This commit is contained in:
@@ -0,0 +1,969 @@
|
||||
"""
|
||||
BSN Voice Chat — Browser-based voice dialog with DeepSeek v4 Flash.
|
||||
|
||||
Architecture:
|
||||
Browser (Web Speech API + Audio) → Flask proxy → DeepSeek API
|
||||
TTS via Piper (switchable DE/EN/ES voices)
|
||||
|
||||
Features:
|
||||
- Language selector: Deutsch | English | Español
|
||||
- DeepSeek v4 Flash as model (direct API, not OpenWebUI)
|
||||
- Stop button to interrupt speech
|
||||
- Thinking animation while AI processes
|
||||
- Light/Dark mode toggle
|
||||
- Mobile-responsive layout
|
||||
- Design with #20228a primary color
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request, jsonify, send_file, session, Response, stream_with_context
|
||||
from flask_cors import CORS
|
||||
from datetime import timedelta
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = secrets.token_hex(32)
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=24)
|
||||
CORS(app, supports_credentials=True)
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
|
||||
# Try environment first, then Hermes .env, then fallback
|
||||
_hermes_env = Path.home() / ".hermes" / ".env"
|
||||
if not os.environ.get("DEEPSEEK_API_KEY") and _hermes_env.exists():
|
||||
with open(_hermes_env) as _f:
|
||||
for _line in _f:
|
||||
_line = _line.strip()
|
||||
if _line.startswith("DEEPSEEK_API_KEY="):
|
||||
os.environ["DEEPSEEK_API_KEY"] = _line.split("=", 1)[1]
|
||||
break
|
||||
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
APP_PASSWORD = os.environ.get("APP_PASSWORD", "hermes2026")
|
||||
PIPER_BIN = "/home/hermes/.local/bin/piper"
|
||||
PIPER_ENV = {
|
||||
"LD_LIBRARY_PATH": "/home/hermes/.local/lib",
|
||||
"ESPEAK_DATA_PATH": "/home/hermes/.local/share/espeak-ng-data",
|
||||
"PATH": os.environ.get("PATH", "/usr/bin"),
|
||||
}
|
||||
TTS_OUTPUT_DIR = Path("/tmp/voice-webapp-tts")
|
||||
TTS_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── Language Configuration ──────────────────────────────────────────────────
|
||||
LANGUAGES = {
|
||||
"de": {
|
||||
"label": "Deutsch",
|
||||
"flag": "🇩🇪",
|
||||
"stt_lang": "de-DE",
|
||||
"system_prompt": (
|
||||
"Du bist ein hilfreicher Sprachassistent für die Brettspiel-Nachrichten-Website "
|
||||
"brettspiel-news.de. Antworte auf Deutsch. Keine Emojis. Kurze, natürliche Sätze "
|
||||
"(< 500 Zeichen). Du sprichst via Text-to-Speech, also verwende natürliche "
|
||||
"gesprochene Sprache."
|
||||
),
|
||||
"piper_model": "/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx",
|
||||
},
|
||||
"en": {
|
||||
"label": "English",
|
||||
"flag": "🇬🇧",
|
||||
"stt_lang": "en-US",
|
||||
"system_prompt": (
|
||||
"You are a helpful voice assistant for a board game news website called "
|
||||
"brettspiel-news.de. Answer in English. No emojis. Short natural sentences "
|
||||
"(< 500 characters). You are speaking via text-to-speech, so use natural "
|
||||
"spoken language."
|
||||
),
|
||||
"piper_model": "/home/hermes/.hermes/cache/piper-voices/en_US-joe-medium.onnx",
|
||||
},
|
||||
"es": {
|
||||
"label": "Español",
|
||||
"flag": "🇪🇸",
|
||||
"stt_lang": "es-ES",
|
||||
"system_prompt": (
|
||||
"Eres un asistente de voz útil para un sitio web de noticias de juegos de mesa "
|
||||
"llamado brettspiel-news.de. Responde en español. Sin emojis. Frases cortas y "
|
||||
"naturales (< 500 caracteres). Estás hablando mediante texto a voz, así que "
|
||||
"usa lenguaje hablado natural."
|
||||
),
|
||||
"piper_model": "/home/hermes/.hermes/cache/piper-voices/es_ES-sharvard-medium.onnx",
|
||||
},
|
||||
}
|
||||
|
||||
# ── SPIEL 2025 Hallplan (OPTIONAL — set False to remove) ──────────────────────
|
||||
USE_HALLPLAN = True
|
||||
HALLPLAN_FILE = Path(__file__).parent / "data" / "spiel2025_aussteller.json"
|
||||
|
||||
# Load hallplan data (empty list if disabled or missing)
|
||||
hallplan_data = []
|
||||
if USE_HALLPLAN and HALLPLAN_FILE.exists():
|
||||
try:
|
||||
with open(HALLPLAN_FILE) as f:
|
||||
hallplan_data = json.load(f)
|
||||
print(f"[HALLPLAN] Loaded {len(hallplan_data)} exhibitors from SPIEL 2025", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[HALLPLAN] Error loading: {e}", flush=True)
|
||||
|
||||
DEFAULT_LANG = "de"
|
||||
|
||||
|
||||
def get_lang():
|
||||
"""Return the current session language code (de|en|es)."""
|
||||
lang = session.get("language", DEFAULT_LANG)
|
||||
if lang not in LANGUAGES:
|
||||
lang = DEFAULT_LANG
|
||||
return lang
|
||||
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def check_auth():
|
||||
return session.get("authenticated", False)
|
||||
|
||||
|
||||
# ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
if not check_auth():
|
||||
return LOGIN_PAGE
|
||||
return CHAT_PAGE
|
||||
|
||||
|
||||
@app.route("/login", methods=["POST"])
|
||||
def login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
pw = data.get("password", "")
|
||||
if pw == APP_PASSWORD:
|
||||
session.permanent = True
|
||||
session["authenticated"] = True
|
||||
return jsonify({"ok": True})
|
||||
time.sleep(0.8)
|
||||
return jsonify({"ok": False, "error": "Wrong password"}), 401
|
||||
|
||||
|
||||
@app.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/language", methods=["GET", "POST"])
|
||||
def api_language():
|
||||
if not check_auth():
|
||||
return jsonify({"error": "Not authenticated"}), 401
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.get_json(silent=True) or {}
|
||||
lang = data.get("language", DEFAULT_LANG)
|
||||
if lang not in LANGUAGES:
|
||||
return jsonify({"error": f"Unsupported language: {lang}"}), 400
|
||||
session["language"] = lang
|
||||
return jsonify({"ok": True, "language": lang})
|
||||
|
||||
# GET — return current language info
|
||||
lang = get_lang()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"language": lang,
|
||||
"languages": {k: {"label": v["label"], "flag": v["flag"], "stt_lang": v["stt_lang"]} for k, v in LANGUAGES.items()},
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/chat", methods=["POST"])
|
||||
def chat():
|
||||
if not check_auth():
|
||||
return jsonify({"error": "Not authenticated"}), 401
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
user_message = data.get("message", "").strip()
|
||||
# Single source of truth: session language
|
||||
lang = get_lang()
|
||||
|
||||
if not user_message:
|
||||
return jsonify({"error": "No message provided"}), 400
|
||||
|
||||
config = LANGUAGES[lang]
|
||||
|
||||
# Optional: inject hallplan into system prompt for fair-related queries
|
||||
system_prompt = config["system_prompt"]
|
||||
|
||||
# Hallplan data always injected when enabled
|
||||
if hallplan_data:
|
||||
hall_summary = "\n\nSPIEL 2025 Halle 1 — Aussteller und Stände (Referenzdaten):\n"
|
||||
for ex in hallplan_data:
|
||||
hall_summary += f"- {ex['name']}: Stand {ex['stand']}\n"
|
||||
system_prompt += hall_summary + "\nWenn jemand nach einem Aussteller, Stand, Halle, Messe oder SPIEL fragt, nutze diese Liste für die Antwort."
|
||||
|
||||
import urllib.request
|
||||
|
||||
payload = json.dumps({
|
||||
"model": DEEPSEEK_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"max_tokens": 500,
|
||||
"temperature": 0.7,
|
||||
"stream": False,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
DEEPSEEK_API_URL,
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
result = json.loads(raw)
|
||||
choices = result.get("choices", [])
|
||||
if not choices:
|
||||
print(f"[CHAT] No choices in response: {str(result)[:300]}", flush=True)
|
||||
return jsonify({"error": "No response from model"}), 502
|
||||
msg = choices[0].get("message", {})
|
||||
reply = msg.get("content", "")
|
||||
if not reply or not reply.strip():
|
||||
print(f"[CHAT] Empty content. Finish reason: {choices[0].get('finish_reason')} Full: {str(result)[:300]}", flush=True)
|
||||
fallback = {
|
||||
"de": "Entschuldigung, ich konnte keine Antwort generieren. Bitte versuche es erneut.",
|
||||
"en": "Sorry, I could not generate a response. Please try again.",
|
||||
"es": "Lo siento, no pude generar una respuesta. Por favor, inténtalo de nuevo.",
|
||||
}
|
||||
return jsonify({"ok": True, "reply": fallback.get(lang, fallback["de"])})
|
||||
print(f"[CHAT] Reply: {len(reply)} chars [lang={lang}]", flush=True)
|
||||
return jsonify({"ok": True, "reply": reply, "language": lang})
|
||||
except urllib.error.HTTPError as e:
|
||||
err_body = e.read().decode("utf-8", errors="replace")
|
||||
return jsonify({"error": f"API error: {err_body[:200]}"}), 502
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/tts", methods=["POST"])
|
||||
def tts():
|
||||
if not check_auth():
|
||||
return jsonify({"error": "Not authenticated"}), 401
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "").strip()
|
||||
# Single source of truth: session language
|
||||
lang = get_lang()
|
||||
|
||||
if not text:
|
||||
return jsonify({"error": "No text provided"}), 400
|
||||
|
||||
piper_model = LANGUAGES[lang]["piper_model"]
|
||||
|
||||
text_hash = hashlib.md5((lang + ":" + text).encode()).hexdigest()[:12]
|
||||
out_path = TTS_OUTPUT_DIR / f"tts_{lang}_{text_hash}.wav"
|
||||
|
||||
# Check cache (cached per language + text)
|
||||
if out_path.exists() and out_path.stat().st_size > 1000:
|
||||
return send_file(str(out_path), mimetype="audio/wav")
|
||||
|
||||
text = text[:2000]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[PIPER_BIN, "--model", piper_model, "--output_file", str(out_path)],
|
||||
input=text.encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env=PIPER_ENV,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return jsonify({"error": f"TTS failed: {result.stderr.decode()[:200]}"}), 500
|
||||
if out_path.exists() and out_path.stat().st_size > 1000:
|
||||
return send_file(str(out_path), mimetype="audio/wav")
|
||||
return jsonify({"error": "Generated file empty"}), 500
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"error": "TTS timeout"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/check")
|
||||
def api_check():
|
||||
return jsonify({"authenticated": check_auth()})
|
||||
|
||||
|
||||
# ── Login Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
LOGIN_PAGE = """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BSN Voice Chat — Login</title>
|
||||
<style>
|
||||
:root { --bg: #0a0a14; --card: #13132a; --text: #e0e0e8; --muted: #8888a0;
|
||||
--primary: #20228a; --primary-hover: #2a2cb0; --border: #2a2a44; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
background:var(--bg); color:var(--text); display:flex; justify-content:center;
|
||||
align-items:center; min-height:100vh; padding:16px; }
|
||||
.login-box { background:var(--card); padding:44px 36px; border-radius:16px;
|
||||
width:100%; max-width:360px; box-shadow:0 12px 40px rgba(0,0,0,.5);
|
||||
border:1px solid var(--border); }
|
||||
h1 { font-size:26px; margin-bottom:6px; color:#fff; letter-spacing:-0.5px; }
|
||||
h1 span { color:var(--primary); }
|
||||
.sub { color:var(--muted); font-size:14px; margin-bottom:28px; line-height:1.5; }
|
||||
input { width:100%; padding:14px; border:1px solid var(--border);
|
||||
background:var(--bg); color:#fff; border-radius:8px; font-size:16px;
|
||||
margin-bottom:18px; transition:border-color .2s; }
|
||||
input:focus { outline:none; border-color:var(--primary); }
|
||||
button { width:100%; padding:14px; background:var(--primary); color:#fff;
|
||||
border:none; border-radius:8px; font-size:16px; font-weight:600;
|
||||
cursor:pointer; transition:background .2s; }
|
||||
button:hover { background:var(--primary-hover); }
|
||||
.error { color:#ff6b6b; font-size:13px; margin-bottom:14px; display:none;
|
||||
text-align:center; }
|
||||
.brand { text-align:center; margin-top:24px; font-size:11px; color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>🎙 <span>BSN</span> Voice Chat</h1>
|
||||
<p class="sub">Voice dialog with AI — speak naturally and get spoken responses.</p>
|
||||
<p class="error" id="error">Wrong password</p>
|
||||
<input type="password" id="pw" placeholder="Password" autofocus>
|
||||
<button onclick="doLogin()">Log in</button>
|
||||
<div class="brand">brettspiel-news.de</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('pw').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
||||
async function doLogin() {
|
||||
const r = await fetch('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('pw').value})});
|
||||
if(r.ok) window.location.reload();
|
||||
else document.getElementById('error').style.display='block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── Chat Page ───────────────────────────────────────────────────────────────
|
||||
|
||||
CHAT_PAGE = """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>BSN Voice Chat</title>
|
||||
<style>
|
||||
/* ── Theme Variables ── */
|
||||
:root, [data-theme="dark"] {
|
||||
--bg: #0a0a14; --surface: #111128; --card: #18183a; --card-agent: #151538;
|
||||
--text: #e0e0e8; --text-dim: #a0a0b8; --muted: #707090;
|
||||
--primary: #20228a; --primary-bright: #2e30b8; --primary-glow: rgba(32,34,138,.35);
|
||||
--user-msg: #20228a; --agent-msg: #18183a; --agent-border: #2a2a50;
|
||||
--input-bg: #0d0d20; --input-border: #2a2a50;
|
||||
--header-bg: #0d0d22; --controls-bg: #0d0d22;
|
||||
--stop-btn: #e74c3c; --shadow: rgba(0,0,0,.5);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #f4f4f8; --surface: #ffffff; --card: #fafafe; --card-agent: #f0f0f8;
|
||||
--text: #1a1a2e; --text-dim: #555570; --muted: #8888a0;
|
||||
--primary: #20228a; --primary-bright: #2e30b8; --primary-glow: rgba(32,34,138,.15);
|
||||
--user-msg: #20228a; --agent-msg: #f5f5fc; --agent-border: #d0d0e0;
|
||||
--input-bg: #ffffff; --input-border: #d0d0e0;
|
||||
--header-bg: #ffffff; --controls-bg: #ffffff;
|
||||
--stop-btn: #e74c3c; --shadow: rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height:100%; overflow:hidden; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
background:var(--bg); color:var(--text); display:flex; flex-direction:column;
|
||||
transition:background .3s,color .3s; }
|
||||
|
||||
/* ── Header ── */
|
||||
.header { background:var(--header-bg); padding:8px 12px; display:flex;
|
||||
flex-wrap:wrap; align-items:center; gap:6px;
|
||||
border-bottom:1px solid var(--input-border); }
|
||||
.header-left { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||
.header h1 { font-size:15px; color:var(--text); white-space:nowrap; }
|
||||
.header h1 span { color:var(--primary); font-weight:800; }
|
||||
|
||||
/* ── Language Dropdown ── */
|
||||
.lang-select { background:var(--card); color:var(--text); border:1px solid var(--input-border);
|
||||
border-radius:6px; padding:4px 6px; font-size:12px; cursor:pointer;
|
||||
transition:border-color .2s; outline:none; }
|
||||
.lang-select:hover, .lang-select:focus { border-color:var(--primary); }
|
||||
|
||||
/* ── Theme Toggle Switch ── */
|
||||
.switch-wrap { display:flex; align-items:center; gap:4px; }
|
||||
.switch-track { width:36px; height:20px; background:var(--card); border:1px solid var(--input-border);
|
||||
border-radius:10px; cursor:pointer; position:relative; transition:all .3s; flex-shrink:0; }
|
||||
.switch-track.active { background:var(--primary); border-color:var(--primary); }
|
||||
.switch-knob { width:14px; height:14px; background:var(--text-dim); border-radius:50%;
|
||||
position:absolute; top:2px; left:2px; transition:all .3s; }
|
||||
.switch-track.active .switch-knob { background:#fff; left:18px; }
|
||||
.switch-icon { font-size:12px; line-height:1; user-select:none; }
|
||||
#status { font-size:10px; padding:2px 8px; border-radius:8px;
|
||||
background:var(--card); color:var(--muted); font-weight:500; white-space:nowrap; }
|
||||
#status.listening { background:var(--primary); color:#fff; animation:pulse 1.2s infinite; }
|
||||
#status.thinking { background:#f39c12; color:#fff; }
|
||||
#status.speaking { background:#27ae60; color:#fff; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||
.header-right { display:flex; gap:4px; align-items:center; flex-wrap:wrap; margin-left:auto; }
|
||||
|
||||
/* ── Messages ── */
|
||||
.messages { flex:1; overflow-y:auto; padding:12px; display:flex;
|
||||
flex-direction:column; gap:10px; -webkit-overflow-scrolling:touch;
|
||||
min-height:0; }
|
||||
.msg { max-width:85%; padding:10px 14px; border-radius:12px; line-height:1.5;
|
||||
position:relative; word-wrap:break-word; font-size:15px; }
|
||||
.msg.user { align-self:flex-end; background:var(--user-msg); color:#fff;
|
||||
border-bottom-right-radius:4px; }
|
||||
.msg.agent { align-self:flex-start; background:var(--agent-msg); color:var(--text);
|
||||
border:1px solid var(--agent-border); border-bottom-left-radius:4px; }
|
||||
.msg .time { font-size:9px; color:var(--muted); margin-top:4px; }
|
||||
.interim { align-self:flex-end; color:var(--muted); font-style:italic;
|
||||
padding:0 14px; max-width:85%; font-size:13px; }
|
||||
|
||||
/* ── Thinking Overlay ── */
|
||||
.thinking-overlay {
|
||||
display:none; position:fixed; top:0; left:0; right:0; bottom:0;
|
||||
z-index:1000; background:rgba(255,255,255,0.75);
|
||||
backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px);
|
||||
align-items:center; justify-content:center;
|
||||
flex-direction:column; gap:36px;
|
||||
}
|
||||
.thinking-overlay.show { display:flex; }
|
||||
[data-theme="light"] .thinking-overlay {
|
||||
background:rgba(255,255,255,0.82);
|
||||
}
|
||||
|
||||
/* Orb — rotating gradient ring */
|
||||
.orb-ring {
|
||||
position:relative; width:140px; height:140px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.orb-ring svg {
|
||||
width:140px; height:140px; animation:orb-spin 1.8s cubic-bezier(0.4,0.0,0.2,1) infinite;
|
||||
}
|
||||
.orb-ring circle {
|
||||
fill:none; stroke-width:4; stroke-linecap:round;
|
||||
stroke:url(#orbGrad);
|
||||
stroke-dasharray:240; stroke-dashoffset:170;
|
||||
animation:orb-dash 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes orb-spin {
|
||||
100% { transform:rotate(360deg); }
|
||||
}
|
||||
@keyframes orb-dash {
|
||||
0% { stroke-dashoffset:260; }
|
||||
50% { stroke-dashoffset:60; }
|
||||
100% { stroke-dashoffset:260; }
|
||||
}
|
||||
|
||||
/* Inner pulsing glow */
|
||||
.orb-glow {
|
||||
position:absolute; width:70px; height:70px;
|
||||
border-radius:50%;
|
||||
background:radial-gradient(circle, rgba(32,34,138,0.4) 0%, rgba(32,34,138,0.1) 50%, transparent 70%);
|
||||
animation:orb-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes orb-pulse {
|
||||
0%,100% { transform:scale(0.7); opacity:0.4; }
|
||||
50% { transform:scale(1.3); opacity:1; }
|
||||
}
|
||||
|
||||
/* Floating particles */
|
||||
.particles { position:absolute; width:140%; height:140%; pointer-events:none; }
|
||||
.particle {
|
||||
position:absolute; width:5px; height:5px;
|
||||
border-radius:50%;
|
||||
background:#5058d0;
|
||||
opacity:0;
|
||||
animation:particle-float 3.5s ease-in-out infinite;
|
||||
}
|
||||
.particle:nth-child(1) { left:10%; top:25%; animation-delay:0s; }
|
||||
.particle:nth-child(2) { left:80%; top:15%; animation-delay:0.6s; width:4px; height:4px; }
|
||||
.particle:nth-child(3) { left:20%; top:75%; animation-delay:1.2s; width:6px; height:6px; }
|
||||
.particle:nth-child(4) { left:85%; top:70%; animation-delay:0.3s; }
|
||||
.particle:nth-child(5) { left:50%; top:5%; animation-delay:1.5s; width:4px; height:4px; }
|
||||
.particle:nth-child(6) { left:5%; top:50%; animation-delay:0.9s; }
|
||||
@keyframes particle-float {
|
||||
0% { transform:translateY(10px) scale(0.5); opacity:0; }
|
||||
30% { opacity:0.9; }
|
||||
70% { opacity:0.5; }
|
||||
100% { transform:translateY(-60px) scale(1.2); opacity:0; }
|
||||
}
|
||||
|
||||
/* Thinking label */
|
||||
.thinking-label {
|
||||
font-size:15px; color:#a0a8d0; letter-spacing:3px;
|
||||
text-transform:uppercase; font-weight:600;
|
||||
animation:label-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes label-pulse {
|
||||
0%,100% { opacity:0.4; }
|
||||
50% { opacity:1; }
|
||||
}
|
||||
|
||||
/* ── Controls ── */
|
||||
.controls { background:var(--controls-bg); padding:8px 12px; display:flex;
|
||||
gap:8px; align-items:center; border-top:1px solid var(--input-border);
|
||||
padding-bottom:env(safe-area-inset-bottom,8px); }
|
||||
.mic-btn { width:44px; height:44px; border-radius:50%; border:2px solid var(--input-border);
|
||||
background:var(--card); color:var(--text-dim); font-size:18px;
|
||||
cursor:pointer; display:flex; align-items:center; justify-content:center;
|
||||
transition:all .2s; flex-shrink:0; }
|
||||
.mic-btn:hover { border-color:var(--primary); color:var(--primary-bright); }
|
||||
.mic-btn.active { border-color:var(--stop-btn); background:var(--stop-btn);
|
||||
color:#fff; box-shadow:0 0 14px rgba(231,76,60,.4);
|
||||
animation:micPulse 1.5s infinite; }
|
||||
@keyframes micPulse { 0%,100%{box-shadow:0 0 6px rgba(231,76,60,.3)}
|
||||
50%{box-shadow:0 0 18px rgba(231,76,60,.5)} }
|
||||
.text-input { flex:1; min-width:60px; padding:11px 14px; background:var(--input-bg);
|
||||
border:1px solid var(--input-border); color:var(--text);
|
||||
border-radius:8px; font-size:15px; }
|
||||
.text-input:focus { outline:none; border-color:var(--primary); }
|
||||
.text-input::placeholder { color:var(--muted); font-size:13px; }
|
||||
.send-btn { padding:10px 16px; background:var(--primary); color:#fff;
|
||||
border:none; border-radius:8px; cursor:pointer; font-size:14px;
|
||||
font-weight:600; transition:background .2s; white-space:nowrap; }
|
||||
.send-btn:hover { background:var(--primary-bright); }
|
||||
.send-btn:disabled { background:var(--muted); cursor:not-allowed; opacity:.6; }
|
||||
|
||||
/* ── Stop Button ── */
|
||||
.stop-btn { display:none; padding:10px 16px; background:var(--stop-btn); color:#fff;
|
||||
border:none; border-radius:8px; cursor:pointer; font-size:14px;
|
||||
font-weight:600; animation:pulse .8s infinite; white-space:nowrap; }
|
||||
.stop-btn.show { display:block; }
|
||||
.logout-btn { background:none; border:1px solid var(--input-border); color:var(--muted);
|
||||
padding:3px 8px; border-radius:5px; cursor:pointer; font-size:11px;
|
||||
line-height:1.2; }
|
||||
.logout-btn:hover { border-color:var(--text-dim); color:var(--text); }
|
||||
|
||||
/* ── Placeholder ── */
|
||||
.placeholder { flex:1; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; color:var(--muted); font-size:14px;
|
||||
gap:10px; user-select:none; padding:20px; }
|
||||
.placeholder-big { font-size:36px; }
|
||||
|
||||
/* ── Mobile overrides ── */
|
||||
@media (max-width:480px) {
|
||||
.header { padding:6px 10px; gap:4px; }
|
||||
.header h1 { font-size:13px; }
|
||||
.lang-select { font-size:11px; padding:3px 5px; }
|
||||
.switch-track { width:32px; height:18px; }
|
||||
.switch-knob { width:12px; height:12px; }
|
||||
.switch-track.active .switch-knob { left:16px; }
|
||||
.header-right { gap:3px; }
|
||||
.controls { padding:6px 10px; gap:6px; }
|
||||
.mic-btn { width:40px; height:40px; font-size:16px; }
|
||||
.text-input { font-size:14px; padding:9px 12px; }
|
||||
.send-btn { padding:8px 14px; font-size:13px; }
|
||||
.msg { max-width:90%; font-size:14px; }
|
||||
.orb-ring { width:100px; height:100px; }
|
||||
.orb-ring svg { width:100px; height:100px; }
|
||||
.orb-ring circle { r:34; stroke-width:3.5; }
|
||||
.orb-glow { width:50px; height:50px; }
|
||||
.particle { width:4px; height:4px; }
|
||||
.thinking-label { font-size:13px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>🎙 <span>BSN</span> Chat</h1>
|
||||
<span id="status">Ready</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<select class="lang-select" id="langSelect" onchange="switchLang(this.value)">
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
</select>
|
||||
<div class="switch-wrap">
|
||||
<span class="switch-icon">☀️</span>
|
||||
<div class="switch-track" id="themeSwitch" onclick="toggleTheme()">
|
||||
<div class="switch-knob"></div>
|
||||
</div>
|
||||
<span class="switch-icon">🌙</span>
|
||||
</div>
|
||||
<button class="logout-btn" onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages" id="messages">
|
||||
<div class="placeholder" id="placeholder">
|
||||
<div class="placeholder-big">🎙</div>
|
||||
<div>Start speaking or type a message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thinking Overlay -->
|
||||
<div class="thinking-overlay" id="thinkingOverlay">
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<linearGradient id="orbGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#20228a"/>
|
||||
<stop offset="40%" stop-color="#3a3db0"/>
|
||||
<stop offset="100%" stop-color="#585cd0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="orb-ring">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="36"/>
|
||||
</svg>
|
||||
<div class="particles">
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
</div>
|
||||
<div class="orb-glow"></div>
|
||||
</div>
|
||||
<div class="thinking-label">Thinking</div>
|
||||
</div>
|
||||
|
||||
<div id="interim" class="interim" style="display:none"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="mic-btn" id="micBtn" onclick="toggleMic()" title="Click to talk">🎤</button>
|
||||
<input type="text" class="text-input" id="textInput" placeholder="Nachricht" autofocus>
|
||||
<button class="send-btn" id="sendBtn" onclick="sendText()">Send</button>
|
||||
<button class="stop-btn" id="stopBtn" onclick="stopAll()">⏹</button>
|
||||
</div>
|
||||
|
||||
<audio id="player" preload="auto"></audio>
|
||||
|
||||
<script>
|
||||
// ── State ──
|
||||
let listening = false;
|
||||
let recognition = null;
|
||||
let abortController = null;
|
||||
let currentLang = 'de';
|
||||
let langNames = {de:'Deutsch', en:'English', es:'Español'};
|
||||
let sttLangs = {de:'de-DE', en:'en-US', es:'es-ES'};
|
||||
|
||||
// ── Theme ──
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const current = html.getAttribute('data-theme');
|
||||
const next = current === 'light' ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', next);
|
||||
document.getElementById('themeSwitch').classList.toggle('active', next === 'dark');
|
||||
localStorage.setItem('theme', next);
|
||||
}
|
||||
|
||||
(function initTheme() {
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
document.getElementById('themeSwitch').classList.toggle('active', saved === 'dark');
|
||||
})();
|
||||
|
||||
// ── Language ──
|
||||
async function loadLang() {
|
||||
try {
|
||||
const r = await fetch('/api/language');
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
currentLang = data.language;
|
||||
// Store STT langs from server
|
||||
sttLangs = {};
|
||||
for (const [k, v] of Object.entries(data.languages)) {
|
||||
sttLangs[k] = v.stt_lang;
|
||||
}
|
||||
updateLangUI(currentLang);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function updateLangUI(lang) {
|
||||
currentLang = lang;
|
||||
document.getElementById('langSelect').value = lang;
|
||||
// Update STT language
|
||||
if (recognition) {
|
||||
recognition.lang = sttLangs[lang] || (lang === 'de' ? 'de-DE' : lang === 'es' ? 'es-ES' : 'en-US');
|
||||
}
|
||||
const input = document.getElementById('textInput');
|
||||
const placeholders = {de:'Nachricht', en:'Message', es:'Mensaje'};
|
||||
if (input) input.placeholder = placeholders[lang] || placeholders['de'];
|
||||
}
|
||||
|
||||
async function switchLang(lang) {
|
||||
try {
|
||||
const r = await fetch('/api/language', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({language: lang}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
updateLangUI(lang);
|
||||
const msgs = {de:'🇩🇪 Deutsch', en:'🇬🇧 English', es:'🇪🇸 Español'};
|
||||
addMessage('agent', msgs[lang]);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Web Speech API Init ──
|
||||
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
recognition = new SR();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = false;
|
||||
|
||||
recognition.onresult = (e) => {
|
||||
let interim = '', final = '';
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) final += e.results[i][0].transcript;
|
||||
else interim += e.results[i][0].transcript;
|
||||
}
|
||||
const interEl = document.getElementById('interim');
|
||||
if (interim) {
|
||||
interEl.style.display = 'block';
|
||||
interEl.textContent = interim;
|
||||
} else {
|
||||
interEl.style.display = 'none';
|
||||
}
|
||||
if (final) {
|
||||
interEl.style.display = 'none';
|
||||
stopListening();
|
||||
sendMessage(final.trim());
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (e) => {
|
||||
console.error('Speech error:', e.error);
|
||||
stopListening();
|
||||
setStatus('Ready');
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
if (listening) {
|
||||
try { recognition.start(); } catch(_) {}
|
||||
} else {
|
||||
setStatus('Ready');
|
||||
}
|
||||
};
|
||||
} else {
|
||||
document.getElementById('micBtn').style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleMic() {
|
||||
if (!recognition) return;
|
||||
listening ? stopListening() : startListening();
|
||||
}
|
||||
|
||||
function startListening() {
|
||||
listening = true;
|
||||
const btn = document.getElementById('micBtn');
|
||||
btn.classList.add('active');
|
||||
btn.textContent = '⬤';
|
||||
setStatus('Listening...', 'listening');
|
||||
try { recognition.start(); } catch(_) {}
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
listening = false;
|
||||
const btn = document.getElementById('micBtn');
|
||||
btn.classList.remove('active');
|
||||
btn.textContent = '🎤';
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = text;
|
||||
el.className = cls || '';
|
||||
}
|
||||
|
||||
function showThinking(show) {
|
||||
document.getElementById('thinkingOverlay').classList.toggle('show', show);
|
||||
}
|
||||
|
||||
function showStop(show) {
|
||||
document.getElementById('stopBtn').classList.toggle('show', show);
|
||||
if (show) {
|
||||
document.getElementById('sendBtn').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('sendBtn').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + role;
|
||||
const now = new Date();
|
||||
const time = now.getHours().toString().padStart(2,'0') + ':' +
|
||||
now.getMinutes().toString().padStart(2,'0');
|
||||
div.innerHTML = text + '<div class="time">' + time + '</div>';
|
||||
const msgs = document.getElementById('messages');
|
||||
msgs.appendChild(div);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Stop All ──
|
||||
function stopAll() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
const player = document.getElementById('player');
|
||||
if (player) { player.pause(); player.src = ''; }
|
||||
showThinking(false);
|
||||
showStop(false);
|
||||
setStatus('Ready');
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
}
|
||||
|
||||
// ── Send Message ──
|
||||
async function sendMessage(text) {
|
||||
if (!text) return;
|
||||
addMessage('user', text);
|
||||
showThinking(true);
|
||||
showStop(true);
|
||||
setStatus('Thinking...', 'thinking');
|
||||
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
sendBtn.disabled = true;
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ message: text }),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
showThinking(false);
|
||||
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
addMessage('agent', '❌ Error: ' + (err.error || 'Request failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
if (data.ok && data.reply) {
|
||||
// Sync language from server response
|
||||
if (data.language) {
|
||||
currentLang = data.language;
|
||||
updateLangUI(data.language);
|
||||
}
|
||||
addMessage('agent', data.reply);
|
||||
setStatus('Speaking...', 'speaking');
|
||||
await playTTS(data.reply);
|
||||
} else {
|
||||
addMessage('agent', '❌ No reply received');
|
||||
}
|
||||
} catch(e) {
|
||||
if (e.name === 'AbortError') {
|
||||
addMessage('agent', '⏹ Stopped');
|
||||
} else {
|
||||
addMessage('agent', '❌ Connection error');
|
||||
}
|
||||
showThinking(false);
|
||||
} finally {
|
||||
setStatus('Ready');
|
||||
sendBtn.disabled = false;
|
||||
showStop(false);
|
||||
if (abortController) abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendText() {
|
||||
const input = document.getElementById('textInput');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
sendMessage(text);
|
||||
}
|
||||
|
||||
document.getElementById('textInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendText();
|
||||
}
|
||||
});
|
||||
|
||||
// ── TTS ──
|
||||
async function playTTS(text) {
|
||||
try {
|
||||
const r = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ text }),
|
||||
signal: abortController ? abortController.signal : undefined,
|
||||
});
|
||||
if (!r.ok) { console.error('TTS error:', r.status); return; }
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const player = document.getElementById('player');
|
||||
player.src = url;
|
||||
showStop(true);
|
||||
await player.play();
|
||||
await new Promise((resolve) => {
|
||||
player.onended = resolve;
|
||||
player.onerror = resolve;
|
||||
player.onabort = resolve;
|
||||
|
||||
const checkAbort = setInterval(() => {
|
||||
if (abortController && abortController.signal.aborted) {
|
||||
player.pause();
|
||||
player.src = '';
|
||||
resolve();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
player.addEventListener('ended', () => clearInterval(checkAbort));
|
||||
player.addEventListener('error', () => clearInterval(checkAbort));
|
||||
});
|
||||
URL.revokeObjectURL(url);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Logout ──
|
||||
async function logout() {
|
||||
await fetch('/logout', { method:'POST' });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
// iOS Safari height fix: set body height on resize
|
||||
function fixHeight() {
|
||||
document.body.style.height = window.innerHeight + 'px';
|
||||
}
|
||||
window.addEventListener('resize', fixHeight);
|
||||
window.addEventListener('orientationchange', function() { setTimeout(fixHeight, 200); });
|
||||
|
||||
loadLang();
|
||||
fetch('/api/check').then(r => r.json()).then(d => {
|
||||
if (!d.authenticated) window.location.reload();
|
||||
});
|
||||
fixHeight();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting BSN Voice Chat on port 5001 (DeepSeek v4 Flash)...")
|
||||
app.run(host="::", port=5001, debug=False)
|
||||
Reference in New Issue
Block a user