Files
BSN-Chatsystem/voice-webapp/app.py
T

969 lines
35 KiB
Python

"""
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)