my_voice_assistant_v3_jamulix/app/web/app.js
dschlueter 69894b1e7b feat(admin): "Verlauf löschen" pro Nutzer (Konto bleibt)
Store.delete_user_history (loescht Sessions + Nachrichten eines Nutzers, ohne
Konto/Erinnerungen/Usage) + DELETE /api/admin/users/{id}/history (audit-geloggt)
+ Button im Admin-Nutzer-Bereich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:08:43 +02:00

2519 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
const $ = (sel) => document.querySelector(sel);
const messagesEl = $("#messages");
const statusEl = $("#status");
const promptEl = $("#prompt");
const formEl = $("#prompt-form");
const micBtn = $("#mic");
const ttsSel = $("#tts");
const langSel = $("#lang-sel");
const cartesiaHint = $("#cartesia-lang-hint");
let cartesiaUnsupportedLangs = []; // von /api/config: Sprachen, die Cartesia nicht spricht
const CARTESIA_LANG_LABELS = { ar: "Arabisch", it: "Italienisch", ru: "Russisch", pl: "Polnisch", nl: "Niederländisch", sv: "Schwedisch", tr: "Türkisch" };
// Die gewählte Sprache ist immer fest (Flex-Modus wurde entfernt).
function langOverrides() {
return { language: (langSel && langSel.value) || "de" };
}
// Nutzer-Präferenzen als Request-Overrides für jeden Turn.
// Geschlechtspräferenz: 'm', 'f', oder 'any' (= weiblich bevorzugt)
let _voiceGender = 'f';
function applyGenderChoice(gender) {
// 'any' aus alten Prefs → weiblich als Standardwert
_voiceGender = (gender === 'm') ? 'm' : 'f';
const btnM = document.getElementById('gender-m');
const btnF = document.getElementById('gender-f');
if (!btnM || !btnF) return;
const mActive = (_voiceGender === 'm');
const fActive = (_voiceGender === 'f');
// Aktiv: blauer Hintergrund + weiße Schrift
// Inaktiv: kein Hintergrund + dunkle Schrift (explizit, damit kein Vererbungsproblem)
btnM.classList.toggle('bg-blue-600', mActive);
btnM.classList.toggle('text-white', mActive);
btnM.classList.toggle('border-blue-600', mActive);
btnM.classList.toggle('text-slate-700', !mActive);
btnF.classList.toggle('bg-blue-600', fActive);
btnF.classList.toggle('text-white', fActive);
btnF.classList.toggle('border-blue-600', fActive);
btnF.classList.toggle('text-slate-700', !fActive);
}
function overrides() {
const out = { ...langOverrides() };
if (isDeviceMode()) {
out.text_only = true; // kein Server-Audio -> Gerät spricht selbst
} else if (ttsSel && ttsSel.value) {
out.tts_provider = ttsSel.value;
}
out.voice_gender = _voiceGender;
return out;
}
async function savePrefs(obj) {
try {
await fetch("/api/me/prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(obj),
});
} catch (e) { console.error("savePrefs", e); }
}
// ---------- Geräte-TTS (Web Speech API) ----------
// "Gerät" liest die Antwort lokal vor -> der Server schickt kein Audio (spart Bytes/Kosten).
const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window;
const LANG_BCP47 = {
de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES",
it: "it-IT", nl: "nl-NL", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN",
};
function isMobile() {
const ua = navigator.userAgent || "";
return /Android|iPhone|iPad|iPod/i.test(ua) ||
(navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); // iPad ab iOS 13
}
function isDeviceMode() { return !!ttsSel && ttsSel.value === "device"; }
let _voices = [];
function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : []; }
if (TTS_SUPPORTED) {
loadVoices();
// Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen).
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); applyTtsVisibility(); });
setTimeout(() => applyTtsVisibility(), 1500); // Backstop, falls kein voiceschanged kommt
}
// Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik
// über Stimmname/voiceURI. Liefert 'm', 'f' oder null (unbekannt). Bewusst konservativ:
// im Zweifel null, damit wir lieber auf Server-TTS ausweichen als falsch zu raten.
const _VOICE_F = ["female", "weiblich", "femme", "mujer", "femminile", "feminino",
"anna", "helena", "petra", "samantha", "victoria", "karen", "moira", "tessa", "fiona",
"susan", "zoe", "amelie", "amélie", "audrey", "aurelie", "aurélie", "marie", "alice",
"federica", "elsa", "monica", "mónica", "paulina", "luciana", "joana", "catarina",
"milena", "alena", "irina", "tatyana", "ting-ting", "mei-jia", "sin-ji", "yaoyao",
"huihui", "hedda", "katja", "hazel", "zira", "heami"];
const _VOICE_M = ["male", "männlich", "homme", "hombre", "maschile", "masculino",
"markus", "yannick", "reed", "aaron", "fred", "daniel", "oliver", "arthur", "rishi",
"gordon", "conrad", "thomas", "luca", "cosimo", "diego", "jorge", "carlos", "felipe",
"bruno", "yuri", "pavel", "maxim", "liang", "kangkang", "stefan", "david", "mark",
"george", "pablo", "ravi"];
function _voiceGenderOf(v) {
if (!v) return null;
if (v.gender === "male") return "m"; // nicht-Standard, aber manche Engines liefern es
if (v.gender === "female") return "f";
const s = ((v.name || "") + " " + (v.voiceURI || "")).toLowerCase();
if (_VOICE_F.some((n) => s.includes(n))) return "f";
if (_VOICE_M.some((n) => s.includes(n))) return "m";
return null;
}
// Sprach- UND (wenn erkennbar) geschlechtspassende Gerätestimme.
// Rückgabe: { voice, genderMatch }. genderMatch=false signalisiert dem Aufrufer, dass
// keine Stimme des Wunschgeschlechts gefunden wurde (-> Server-Fallback ist sinnvoll).
function pickVoice(bcp, gender) {
if (!_voices.length) loadVoices();
const lc = bcp.toLowerCase();
const two = lc.slice(0, 2);
const cands = _voices.filter(
(v) => v.lang && (v.lang.toLowerCase() === lc || v.lang.toLowerCase().startsWith(two)),
);
if (!cands.length) return { voice: null, genderMatch: false };
if (gender === "m" || gender === "f") {
const hit = cands.find((v) => _voiceGenderOf(v) === gender);
if (hit) return { voice: hit, genderMatch: true };
return { voice: cands[0], genderMatch: false }; // keine passende -> Caller weicht aus
}
return { voice: cands[0], genderMatch: true };
}
function _makeUtterance(text, lang) {
const u = new SpeechSynthesisUtterance(text);
u.lang = LANG_BCP47[lang] || lang || "de-DE";
const { voice } = pickVoice(u.lang, _voiceGender);
if (voice) u.voice = voice;
return u;
}
// Hat der Browser nutzbare Sprachstimmen? (Linux-Desktops haben oft KEINE -> stumm.)
function deviceVoicesReady() {
if (!TTS_SUPPORTED) return false;
if (!_voices.length) loadVoices();
return _voices.length > 0;
}
// Hat das Gerät eine Stimme für diese Sprache? Geschlecht ist Best-Effort: pickVoice wählt
// bei Treffer die geschlechtspassende, sonst die erste sprachrichtige Stimme (cands[0]).
// Früher wurde bei fehlendem Geschlechtsmatch hart auf Server-TTS ausgewichen — das
// verbannte Sprachen mit nur einer Geräte-Stimme (z.B. NL) auf eine schlechte Server-
// Aussprache. Jetzt gilt: eine sprachrichtige Stimme reicht; die m/f-Wahl wirkt am Gerät
// nur für Sprachen mit mehreren Stimmen (DE, EN …), sonst folgenlos.
function deviceVoiceReady(lang) {
const bcp = LANG_BCP47[lang] || lang || "de-DE";
return pickVoice(bcp, _voiceGender).voice != null;
}
// Server-Fallback: holt Audio via /api/speak (ohne tts_provider -> Server-Default,
// niemals "device") und spielt es ab. Greift, wenn Geräte-TTS keine Stimme hat.
async function serverSpeakFallback(text, lang, btn) {
try {
const body = { text };
if (lang) body.language = lang;
const r = await fetch("/api/speak", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("HTTP " + r.status);
startReplay([await r.arrayBuffer()], btn);
} catch (e) {
setReplayPlaying(btn, false);
statusEl.textContent = "Vorlesen fehlgeschlagen";
}
}
// Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort).
function deviceSpeak(text, lang) {
if (!text) return;
if (deviceVoicesReady() && deviceVoiceReady(lang)) {
speechSynthesis.cancel();
speechSynthesis.speak(_makeUtterance(text, lang));
} else {
serverSpeakFallback(text, lang, null); // keine Stimme für diese Sprache -> Server-Audio
}
}
// Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück.
function deviceSpeakBtn(text, lang, btn) {
if (!text) { setReplayPlaying(btn, false); return; }
if (!deviceVoicesReady() || !deviceVoiceReady(lang)) { serverSpeakFallback(text, lang, btn); return; }
speechSynthesis.cancel();
const u = _makeUtterance(text, lang);
u.onend = u.onerror = () => setReplayPlaying(btn, false);
setReplayPlaying(btn, true);
speechSynthesis.speak(u);
}
// iOS: sowohl SpeechSynthesis (Geräte-TTS) als auch der Web-Audio-Kontext (Server-Chunks)
// dürfen nur nach einer Nutzergeste starten -> beim ersten Tap beide freischalten.
let _ttsUnlocked = false;
let _audioUnlocked = false;
function unlockTTS() {
// Geräte-TTS per Leer-Utterance freischalten.
if (TTS_SUPPORTED && !_ttsUnlocked) {
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
}
// Web-Audio-Kontext jetzt (in der Geste) anlegen + per Stille-Buffer aufwecken, damit
// die satzweise Chunk-Wiedergabe in den Server-Modi auch auf iOS läuft (sonst bleibt ein
// erst im WS-Callback erzeugter AudioContext dort "suspended").
if (!_audioUnlocked) {
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
const src = audioCtx.createBufferSource();
src.buffer = audioCtx.createBuffer(1, 1, 22050); // 1 Sample Stille
src.connect(audioCtx.destination);
src.start(0);
_audioUnlocked = true;
} catch (e) {}
}
}
const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt
// Setzt das verborgene Quell-Select (#tts) beim Laden + spiegelt es in die Presets.
// Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät.
function applyPlaybackChoice(serverProvider) {
if (!ttsSel) return;
const local = localStorage.getItem(PLAYBACK_KEY);
if (local === "device" && TTS_SUPPORTED) {
ttsSel.value = "device";
} else if (serverProvider) {
ttsSel.value = serverProvider;
} else if (TTS_SUPPORTED && isMobile() && local === null) {
ttsSel.value = "device"; // Mobil-Default, solange nichts gewählt wurde
localStorage.setItem(PLAYBACK_KEY, "device");
} else if (!ttsSel.value) {
ttsSel.value = "piper"; // Fallback: Server
}
applyTtsVisibility(); // blendet Provider passend ein/aus + syncPresetUI
}
// ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ──
// Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel
// „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut.
let _ttsMenu = null; // zuletzt geladenes tts_menu von /api/config
const TTS_DEFAULT = "piper"; // garantiert sichtbarer Fallback -> Menü nie leer
function _ttsVisible(provider) {
if (provider === "device") return TTS_SUPPORTED && isMobile(); // nur mobil
if (provider === TTS_DEFAULT) return true; // garantierter Default
if (!_ttsMenu) return true; // vor dem Laden nichts verstecken
const e = _ttsMenu.find((m) => m.provider === provider);
return e ? e.visible !== false : true;
}
// Blendet Provider-Buttons (und die zugehörigen <option>) ein/aus und klemmt die
// aktive Auswahl auf einen sichtbaren Provider (sonst spielt nichts / Server-422).
function applyTtsVisibility() {
if (!ttsSel) return;
document.querySelectorAll(".tts-preset").forEach((btn) => {
const p = btn.dataset.tts;
const vis = _ttsVisible(p);
btn.classList.toggle("hidden", !vis);
const opt = ttsSel.querySelector(`option[value="${p}"]`);
if (opt) { opt.hidden = !vis; opt.disabled = !vis; }
});
const dsub = document.querySelector('.tts-preset[data-tts="device"] .tts-sub');
if (dsub) dsub.textContent = deviceVoicesReady()
? "Das Gerät liest vor · spart Daten"
: "Liest über den Server vor";
if (!_ttsVisible(ttsSel.value)) {
const wasServer = ttsSel.value && ttsSel.value !== "device";
ttsSel.value = TTS_DEFAULT;
if (wasServer) {
savePrefs({ tts_provider: TTS_DEFAULT }); // verhindert Server-422 beim nächsten Turn
if (statusEl) statusEl.textContent = "Stimme nicht verfügbar auf „Schnell“ umgestellt";
}
} else if (localStorage.getItem(PLAYBACK_KEY) === "device"
&& _ttsVisible("device") && ttsSel.value !== "device") {
ttsSel.value = "device"; // gespeicherte Geräte-Präferenz wieder aktivieren
}
syncPresetUI();
updateCartesiaLangHint();
}
// Holt die Sichtbarkeiten einmal vom (gecachten) Backend und wendet sie an.
async function refreshTtsMenu() {
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then((r) => r.json());
_ttsMenu = cfg.tts_menu || null;
cartesiaUnsupportedLangs = cfg.available?.cartesia_unsupported_languages || [];
const vEl = document.getElementById("app-version");
if (vEl && cfg.version) vEl.textContent = "v" + cfg.version;
} catch (e) { /* offline -> nichts verstecken */ }
applyTtsVisibility();
}
// Zeigt einen Hinweis, wenn Cartesia gewählt ist, die feste Sprache aber nicht
// unterstützt wird — dann übernimmt automatisch die lokale Piper-Stimme (tts_fallback).
function updateCartesiaLangHint() {
if (!cartesiaHint) return;
const lang = langSel && langSel.value;
const show = ttsSel && ttsSel.value === "cartesia"
&& lang && lang !== "flex"
&& cartesiaUnsupportedLangs.includes(lang);
if (show) {
const label = CARTESIA_LANG_LABELS[lang] || lang.toUpperCase();
cartesiaHint.textContent =
`Hinweis: Cartesia spricht ${label} nicht — es wird automatisch die lokale Stimme (Piper) verwendet.`;
cartesiaHint.classList.remove("hidden");
} else {
cartesiaHint.classList.add("hidden");
}
}
// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts.
function syncPresetUI() {
const val = ttsSel ? ttsSel.value : "";
document.querySelectorAll(".tts-preset").forEach((btn) => {
const active = btn.dataset.tts === val;
btn.classList.toggle("ring-2", active);
btn.classList.toggle("ring-blue-500", active);
btn.classList.toggle("border-blue-500", active);
const chk = btn.querySelector(".check");
if (chk) chk.style.opacity = active ? "1" : "0";
});
}
// ---------- Einstellungs-Menü (⋮) ----------
function openMenu() {
$("#settings-sheet").classList.remove("hidden");
$("#settings-backdrop").classList.remove("hidden");
refreshTtsMenu(); // beim Öffnen neu bewerten (Backend kann zur Laufzeit gewechselt haben)
}
function closeMenu() {
$("#settings-sheet").classList.add("hidden");
$("#settings-backdrop").classList.add("hidden");
}
let busy = false;
let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar)
let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in)
let streamPlayTime = 0; // naechster Startzeitpunkt fuer gapless Streaming-Wiedergabe
// Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
let sessionId = null;
let mePromise = null;
let myPrefs = {}; // Prefs des aktuellen Nutzers (u. a. location_consent für den Notruf-Standort)
let currentUserId = null;
const sessionKey = (uid) => "va-session-" + uid;
// Sprachen (Code -> Kurzlabel) für Filter + Admin-Auswahl.
function loginLink(token) { return location.origin + "/?k=" + token; }
async function copyText(text, btn) {
try { await navigator.clipboard.writeText(text); if (btn) { const t = btn.textContent; btn.textContent = "✓ kopiert"; setTimeout(() => (btn.textContent = t), 1500); } }
catch (e) { /* Clipboard evtl. blockiert -> select-all bleibt als Fallback */ }
}
const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"],
["nl","NL"],["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]];
// --- Format-Validierung der Notfall-Profil-Felder ------------------------
const _isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
const _isPhone = (v) => /^\+?[\d\s/().\-]{6,20}$/.test(v);
function _isGermanDate(v) {
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(v);
if (!m) return false;
const d = +m[1], mo = +m[2], y = +m[3];
const dt = new Date(y, mo - 1, d);
return dt.getDate() === d && dt.getMonth() === mo - 1 && dt.getFullYear() === y && y >= 1900 && y <= 2100;
}
const _csvAll = (v, fn) => v.split(",").map((s) => s.trim()).filter(Boolean).every(fn);
// pro Feld: ok(wert) wird NUR bei nicht-leerem Wert geprüft + Meckertext.
const PROF_VALIDATORS = {
postal_code: { ok: (v) => /^\d{5}$/.test(v), msg: "PLZ: genau 5 Ziffern" },
phone_mobile: { ok: _isPhone, msg: "Mobil: gültige Telefonnummer (z. B. +49 151 …)" },
phone_landline: { ok: _isPhone, msg: "Festnetz: gültige Telefonnummer" },
birth_date: { ok: _isGermanDate, msg: "Geburtsdatum: TT.MM.JJJJ" },
emergency_contacts: { ok: (v) => _csvAll(v, _isEmail), msg: "Kontakt-E-Mail(s): gültige Adressen (Komma-getrennt)" },
emergency_phones: { ok: (v) => _csvAll(v, _isPhone), msg: "Kontakt-Telefon(e): gültige Nummern (Komma-getrennt)" },
emergency_cooldown_minutes: { ok: (v) => /^\d+$/.test(v) && +v <= 60, msg: "Pause: 060 ganze Minuten" },
};
// Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt.
// Genau eine erlaubte Sprache -> Menü ausblenden (kein Stress für den Nutzer).
function applyAllowedLanguages(prefs) {
if (!langSel) return;
const allowed = String((prefs || {}).allowed_languages || "")
.split(",").map((s) => s.trim()).filter(Boolean);
Array.from(langSel.options).forEach((opt) => {
const blocked = allowed.length > 0 && !allowed.includes(opt.value);
opt.hidden = blocked; // versteckt (moderne Browser)
opt.disabled = blocked; // robust: auch wo <option hidden> ignoriert wird, nicht wählbar
});
let val = (prefs || {}).language || "de";
if (allowed.length && !allowed.includes(val)) val = allowed[0];
langSel.value = val;
langSel.classList.toggle("hidden", allowed.length === 1);
}
// ---------- Identitaet / Menue ----------
function loadMe() {
mePromise = (async () => {
try {
const res = await fetch("/api/me", { headers: { Accept: "application/json" } });
if (!res.ok) {
$("#identity").textContent = "Nicht angemeldet";
return null;
}
const me = await res.json();
currentUserId = me.user_id;
// Zuletzt aktive (ggf. frische) Session bevorzugen, sonst die Basis-Session.
sessionId = localStorage.getItem(sessionKey(me.user_id)) || ("web-" + me.user_id);
$("#identity").textContent = "Angemeldet als " + (me.display_name || me.external_id || "Gast");
if (me.sso_logout_url) {
const logout = $("#logout");
logout.href = me.sso_logout_url;
logout.classList.remove("hidden");
}
if (me.is_admin) {
$("#admin-toggle").classList.remove("hidden");
}
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
const prefs = me.prefs || {};
myPrefs = prefs;
// Standort-Erlaubnis ggf. vorab anfragen, damit im Notfall kein Browser-Dialog kommt.
if (prefs.location_consent && navigator.geolocation) {
try {
navigator.geolocation.getCurrentPosition(() => {}, () => {},
{ maximumAge: 600000, timeout: 8000 });
} catch (e) { /* ignore */ }
}
applyAllowedLanguages(prefs);
applyPlaybackChoice(prefs.tts_provider);
applyGenderChoice(prefs.voice_gender || 'f');
initSosCooldown(); // grünen Sperr-Zustand nach Reload wiederherstellen
return me;
} catch (e) {
$("#identity").textContent = "Verbindung fehlgeschlagen";
return null;
}
})();
return mePromise;
}
// Stellt sicher, dass eine nutzerspezifische Session-ID feststeht, bevor ein Turn startet.
async function ensureSession() {
if (!sessionId && mePromise) {
try { await mePromise; } catch (e) { /* ignore */ }
}
if (!sessionId) sessionId = "web-anon";
}
// loadUsers() ersetzt durch Admin-Panel (siehe unten)
// ---------- Nachrichten-UI ----------
// Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar.
function scrollToBottom() {
requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; });
}
const BUBBLE = {
user: "ml-auto max-w-[80%] rounded-2xl rounded-br-sm bg-blue-600 px-4 py-2 text-white whitespace-pre-wrap",
assistant: "mr-auto max-w-[80%] rounded-2xl rounded-bl-sm border border-slate-200 bg-white px-4 py-2 whitespace-pre-wrap dark:border-slate-700 dark:bg-slate-800",
system: "mx-auto max-w-[90%] rounded-lg bg-amber-100 px-3 py-1.5 text-xs text-amber-800 dark:bg-amber-900/40 dark:text-amber-200",
emergency: "mx-auto max-w-[90%] rounded-lg bg-red-100 px-3 py-1.5 text-sm font-semibold text-red-800 dark:bg-red-900/40 dark:text-red-200",
};
// Lautsprecher-/Stop-Icons als SVG (currentColor → per CSS einfärbbar, anders als Emoji).
const ICON_SPEAKER =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" ' +
'd="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" /></svg>';
const ICON_STOP =
'<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">' +
'<rect x="6" y="6" width="12" height="12" rx="2" /></svg>';
// meta.lang: Sprache, in der diese Bubble vorgelesen werden soll (für Replay).
function addMessage(role, text, meta = {}) {
const div = document.createElement("div");
div.className = BUBBLE[role] || BUBBLE.assistant;
const span = document.createElement("span");
span.className = "bubble-text";
span.dir = "auto";
span.textContent = text;
div.appendChild(span);
div._textEl = span;
// Vorlese-Symbol nur an Nutzer-/Assistenten-Bubbles (nicht System/Notfall).
if (role === "user" || role === "assistant") {
div.classList.add("relative", "pr-7");
div._replay = { lang: meta.lang || null, chunks: null }; // chunks: gecachtes PCM (Assistent)
const btn = document.createElement("button");
btn.type = "button";
btn.innerHTML = ICON_SPEAKER;
btn.title = "Vorlesen";
btn.setAttribute("aria-label", "Vorlesen");
// Kontrastreiche Farbe je Bubble: weiß auf Blau; im Dark-Mode hell auf Dunkelgrau.
const replayColor = role === "user"
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white";
btn.className = "replay-btn absolute top-1.5 right-1.5 transition-colors " + replayColor;
btn.addEventListener("click", () => replayBubble(div));
div._replayBtn = btn;
div.appendChild(btn);
}
messagesEl.appendChild(div);
scrollToBottom();
return div;
}
// Quellen-Links der Web-Suche unter die Antwort-Bubble (max. 4, idempotent).
function renderCitations(bubble, urls) {
if (!bubble || !Array.isArray(urls) || !urls.length) return;
let box = bubble._citationsEl;
if (!box) {
box = document.createElement("div");
box.className = "citations mt-1 text-xs text-slate-500 dark:text-slate-400 "
+ "flex flex-wrap gap-x-2 gap-y-0.5";
bubble._citationsEl = box;
bubble.appendChild(box);
}
box.innerHTML = "";
const label = document.createElement("span");
label.textContent = "Quellen:";
box.appendChild(label);
urls.slice(0, 4).forEach((u) => {
let host;
try { host = new URL(u).hostname.replace(/^www\./, ""); } catch { host = u; }
const a = document.createElement("a");
a.href = u; a.target = "_blank"; a.rel = "noopener noreferrer";
a.textContent = host;
a.className = "underline hover:text-slate-700 dark:hover:text-white";
box.appendChild(a);
});
}
// ---------- Vorlesen / Replay ----------
function setReplayPlaying(btn, on) {
if (!btn) return;
btn.innerHTML = on ? ICON_STOP : ICON_SPEAKER;
btn.dataset.playing = on ? "1" : "";
}
function startReplay(chunks, btn) {
stopAudio(); // Barge-in: laufendes Audio (auch andere Replays) stoppen
setReplayPlaying(btn, true);
playPcm(chunks, 24000, () => setReplayPlaying(btn, false));
}
async function speakBubble(text, lang, btn) {
try {
const body = { text };
if (lang) body.language = lang;
if (ttsSel && ttsSel.value) body.tts_provider = ttsSel.value;
const r = await fetch("/api/speak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("HTTP " + r.status);
startReplay([await r.arrayBuffer()], btn);
} catch (e) {
setReplayPlaying(btn, false);
statusEl.textContent = "Vorlesen fehlgeschlagen";
}
}
function replayBubble(div) {
const btn = div._replayBtn;
if (btn && btn.dataset.playing) { stopAudio(); setReplayPlaying(btn, false); return; } // Toggle
const info = div._replay || {};
const text = (div._textEl && div._textEl.textContent) || "";
if (info.chunks && info.chunks.length) {
startReplay(info.chunks, btn); // Assistent: gecachtes PCM, sofort & gratis
} else if (isDeviceMode()) {
stopAudio();
// Geräte-TTS: bereinigtes "spoken" bevorzugen (ohne Markdown/Emojis), sonst Rohtext.
deviceSpeakBtn(info.spoken || text, info.lang, btn);
} else {
speakBubble(text, info.lang, btn); // Server-TTS via /api/speak (bereinigt selbst)
}
}
// ---------- Barge-in-Hilfsfunktionen ----------
function stopAudio() {
activeSources.forEach((s) => { try { s.stop(); } catch (e) {} });
activeSources = [];
streamPlayTime = 0; // Streaming-Uhr verwerfen -> naechster Turn startet sofort
if (TTS_SUPPORTED) { try { speechSynthesis.cancel(); } catch (e) {} } // Geräte-TTS stoppen
}
function setMicIdle() {
micBtn.textContent = "🎤";
micBtn.title = "Mikrofon";
micBtn.classList.remove("bg-red-600", "animate-pulse", "bg-amber-500");
micBtn.classList.add("bg-emerald-600");
}
function setMicRecording() {
micBtn.classList.remove("bg-emerald-600", "bg-amber-500");
micBtn.classList.add("bg-red-600", "animate-pulse");
micBtn.title = "Aufnahme stoppen";
}
function setMicBusy() {
micBtn.textContent = "⏹";
micBtn.title = "KI unterbrechen (Barge-in)";
micBtn.classList.remove("bg-emerald-600", "bg-red-600", "animate-pulse");
micBtn.classList.add("bg-amber-500");
}
// ---------- Audio-Wiedergabe (PCM s16le) ----------
let audioCtx = null;
function playPcm(chunks, sampleRate, onended) {
if (!chunks.length) { if (onended) onended(); return; }
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) { merged.set(new Uint8Array(c), off); off += c.byteLength; }
const view = new DataView(merged.buffer);
const n = Math.floor(merged.byteLength / 2);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
const buf = audioCtx.createBuffer(1, n, sampleRate || 24000);
const ch = buf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
activeSources.push(src);
src.onended = () => {
activeSources = activeSources.filter((s) => s !== src);
if (onended) onended();
};
src.start();
}
// Inkrementelle Wiedergabe: einen einzelnen PCM-Chunk (chunked TTS, satzweise) lueckenlos
// an den bisher geplanten Stream anhaengen, statt bis zum done-Event zu puffern. So
// beginnt die Sprachausgabe, sobald der erste Satz fertig synthetisiert ist.
function scheduleChunk(arrayBuffer, sampleRate) {
const n = Math.floor(arrayBuffer.byteLength / 2);
if (!n) return;
const view = new DataView(arrayBuffer);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
const buf = audioCtx.createBuffer(1, n, sampleRate || 24000);
const ch = buf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
activeSources.push(src); // stoppbar per Barge-in (stopAudio)
src.onended = () => { activeSources = activeSources.filter((s) => s !== src); };
// Ab jetzt oder ab dem Ende des zuletzt geplanten Chunks fortsetzen (gapless).
const startAt = Math.max(audioCtx.currentTime, streamPlayTime);
src.start(startAt);
streamPlayTime = startAt + buf.duration;
}
// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ----------
function wsUrl(path) {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${path}?session_id=${encodeURIComponent(sessionId)}`;
}
// onopen: (ws) => sendet die Eingabe. Liefert ein Promise, das beim done-Event endet.
function runTurn(path, onopen) {
return new Promise((resolve) => {
const ws = new WebSocket(wsUrl(path));
activeWs = ws;
ws.binaryType = "arraybuffer";
const pcm = [];
let answerEl = null;
let sampleRate = 24000;
let expectChunk = false; // naechster Binaer-Frame ist ein Streaming-Chunk (audio-Event ging voraus)
let streamed = false; // mind. ein Chunk wurde schon inkrementell abgespielt
streamPlayTime = 0; // Streaming-Uhr fuer diesen Turn zuruecksetzen
// Sprache dieses Turns (für den Replay je Bubble) = feste Routensprache.
let routeLang = null;
const turnLang = () => routeLang || null;
ws.onopen = () => onopen(ws);
ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); };
ws.onclose = () => { activeWs = null; resolve(); };
ws.onmessage = (event) => {
if (typeof event.data !== "string") {
if (ws._aborted) return; // nach Barge-in: in-flight-Chunks verwerfen, nicht mehr abspielen
pcm.push(event.data); // immer cachen (Replay je Bubble)
if (expectChunk) { // satzweiser Chunk -> sofort lueckenlos abspielen
expectChunk = false;
if (!isDeviceMode()) { scheduleChunk(event.data, sampleRate); streamed = true; }
}
return;
}
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case "ack":
routeLang = (msg.route && msg.route.language) || null;
break;
case "filler":
// Beruhigungs-/Geduldssatz: transient in der Status-Zeile (nicht in der
// Antwort-Bubble) und im Geräte-Modus sofort sprechen — OHNE cancel, damit
// mehrere Filler in die SpeechSynthesis-Queue laufen; die Antwort (semantic)
// canceled später und übernimmt.
if (msg.text) statusEl.textContent = msg.text;
if (msg.text && isDeviceMode() && deviceVoicesReady() && deviceVoiceReady(turnLang())) {
try { speechSynthesis.speak(_makeUtterance(msg.text, turnLang())); } catch (e) {}
}
break;
case "audio":
// Ankuendigung: der naechste Binaer-Frame ist ein satzweiser TTS-Chunk.
expectChunk = true;
break;
case "transcript":
if (msg.text) addMessage("user", msg.text, { lang: turnLang() });
break;
case "token":
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
answerEl._textEl.textContent += msg.text || "";
scrollToBottom();
break;
case "semantic":
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
if (msg.text) answerEl._textEl.textContent = msg.text;
if (msg.citations) renderCitations(answerEl, msg.citations);
// Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen.
// Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen
// bleibt der Originaltext.
if (answerEl._replay) {
answerEl._replay.lang = turnLang();
answerEl._replay.spoken = msg.spoken || msg.text || "";
}
if (isDeviceMode()) {
const spoken = msg.spoken || msg.text;
if (spoken) deviceSpeak(spoken, turnLang());
}
scrollToBottom();
break;
break;
case "done":
sampleRate = msg.sample_rate || 24000;
// Geräte-Modus: kein Server-Audio -> nichts abspielen/cachen (Gerät hat schon gesprochen).
if (!isDeviceMode()) {
if (answerEl && answerEl._replay) answerEl._replay.chunks = pcm.slice(); // Replay-Cache
// Bei chunked TTS laeuft die Wiedergabe schon (scheduleChunk) -> nicht erneut starten.
if (!streamed) playPcm(pcm, sampleRate);
}
statusEl.textContent = "";
scrollToBottom();
ws.close();
break;
case "error":
addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt"));
ws.close();
break;
case "interrupted":
statusEl.textContent = "";
ws.close(); // → ws.onclose → resolve() → busy=false → setMicIdle()
break;
}
};
});
}
// ---------- Text senden ----------
async function sendText(text) {
if (busy || !text.trim()) return;
busy = true;
setMicBusy();
await ensureSession();
const lo = langOverrides();
addMessage("user", text, { lang: lo.language || null });
statusEl.textContent = "denkt …";
const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern
await runTurn("/ws/chat", (ws) => {
ws.send(JSON.stringify({ text, stream: true, audio_stream: audioStream, ...overrides() }));
});
busy = false;
setMicIdle();
}
formEl.addEventListener("submit", (e) => {
e.preventDefault();
unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten
const text = promptEl.value;
promptEl.value = "";
sendText(text);
});
// ---------- Mikrofon (Push-to-Talk) ----------
let mediaRecorder = null;
let recChunks = [];
async function startRecording(statusText) {
if (busy) return;
const md = navigator.mediaDevices;
if (!md || !md.getUserMedia) {
addMessage("system", "Mikrofon nicht freigegeben: Bitte die Seite direkt in Safari öffnen (nicht im In-App-Browser) und keinen Privat-Modus verwenden.");
return;
}
if (typeof MediaRecorder === "undefined") {
addMessage("system", "Aufnahme nicht unterstützt (iOS < 14.3?). Bitte iOS aktualisieren.");
return;
}
let stream;
try {
stream = await md.getUserMedia({ audio: true });
} catch (e) {
addMessage("system", "Mikrofon-Zugriff fehlgeschlagen: " + (e.name || "") +
". Tipp: iPhone → Einstellungen → Safari → Mikrofon = Erlauben/Fragen.");
return;
}
recChunks = [];
try {
mediaRecorder = new MediaRecorder(stream);
} catch (e) {
stream.getTracks().forEach((t) => t.stop());
addMessage("system", "Aufnahme nicht möglich: " + (e.name || e.message));
return;
}
mediaRecorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); };
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const mime = mediaRecorder.mimeType || "audio/mp4";
const blob = new Blob(recChunks, { type: mime });
const bytes = await blob.arrayBuffer();
// Safari -> audio/mp4, andere -> webm; der Server erkennt das Format am Inhalt.
sendVoice(bytes, mime.includes("mp4") || mime.includes("mpeg") ? "mp4" : "webm");
};
mediaRecorder.start();
setMicRecording();
statusEl.textContent = statusText || "Aufnahme … (zum Stoppen erneut tippen)";
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
setMicIdle();
}
async function sendVoice(bytes, fmt = "webm") {
if (busy) return;
busy = true;
setMicBusy();
await ensureSession();
statusEl.textContent = "verarbeite Sprache …";
const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern
await runTurn("/ws/voice", (ws) => {
ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: audioStream, ...overrides() }));
ws.send(bytes);
ws.send(JSON.stringify({ type: "end" }));
});
busy = false;
setMicIdle();
}
micBtn.addEventListener("click", () => {
unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten
if (busy) {
// Barge-in: laufende KI-Antwort sofort unterbrechen
if (activeWs) { activeWs._aborted = true; activeWs.send(JSON.stringify({ type: "interrupt" })); }
stopAudio();
statusEl.textContent = "Unterbrochen";
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
} else if (mediaRecorder && mediaRecorder.state === "recording") {
stopRecording();
} else {
startRecording(); // Server-STT (Upload) — überall gleich
}
});
$("#admin-toggle").addEventListener("click", () => { closeMenu(); openAdminPanel(); });
// ---------- Neues Gespräch ----------
// Startet eine frische Session (neuer session_id) -> der bisherige Verlauf wird nicht
// mehr als LLM-Kontext genutzt. Behebt "Sprache springt nicht um", wenn die History
// in einer anderen Sprache dominiert. Nicht-destruktiv: alte Session bleibt in der DB.
function newConversation() {
const base = currentUserId || "anon";
sessionId = "web-" + base + "-" + Date.now();
if (currentUserId) localStorage.setItem(sessionKey(currentUserId), sessionId);
stopAudio();
messagesEl.innerHTML = "";
statusEl.textContent = "Neues Gespräch begonnen";
setTimeout(() => {
if (statusEl.textContent === "Neues Gespräch begonnen") statusEl.textContent = "";
}, 2500);
}
$("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); });
// Gleiche Aktion als Symbol oben in der Kopfzeile (zwischen Sprachwahl und Menü).
const newChatTop = $("#new-chat-top");
if (newChatTop) newChatTop.addEventListener("click", newConversation);
// ---------- Menü (⋮) verdrahten ----------
$("#menu-toggle").addEventListener("click", () =>
$("#settings-sheet").classList.contains("hidden") ? openMenu() : closeMenu());
$("#settings-backdrop").addEventListener("click", closeMenu);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
// Ton-Presets: setzen das verborgene #tts + lösen die bestehende Persistenz-Logik aus.
document.querySelectorAll(".tts-preset").forEach((btn) =>
btn.addEventListener("click", () => {
unlockTTS();
ttsSel.value = btn.dataset.tts;
ttsSel.dispatchEvent(new Event("change"));
syncPresetUI();
})
);
// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt.
const themeBtn = $("#theme-toggle");
const setThemeIcon = () => {
const icon = $("#theme-icon");
if (icon) icon.textContent = document.documentElement.classList.contains("dark") ? "☀️" : "🌙";
};
themeBtn.addEventListener("click", () => {
const dark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", dark ? "dark" : "light");
setThemeIcon();
});
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (!localStorage.getItem("theme")) {
document.documentElement.classList.toggle("dark", e.matches);
setThemeIcon();
}
});
setThemeIcon();
// Wenn die Bildschirmtastatur die sichtbare Hoehe aendert (iOS), unten bleiben.
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", scrollToBottom);
}
window.addEventListener("resize", scrollToBottom);
promptEl.addEventListener("focus", () => setTimeout(scrollToBottom, 300));
// Dropdown-Änderungen dauerhaft als Nutzer-Präferenz speichern.
if (langSel) langSel.addEventListener("change", () => { savePrefs(langOverrides()); updateCartesiaLangHint(); });
if (ttsSel) {
ttsSel.addEventListener("change", () => {
unlockTTS(); // Auswahl ist eine Nutzergeste -> Geräte-TTS gleich freischalten
if (ttsSel.value === "device") {
// Geräte-TTS ist geräte-lokal (nicht server-seitig) -> nur lokal merken.
localStorage.setItem(PLAYBACK_KEY, "device");
} else {
localStorage.removeItem(PLAYBACK_KEY);
savePrefs({ tts_provider: ttsSel.value || null }); // echter Provider -> Server-Pref
}
updateCartesiaLangHint();
});
}
// Gender-Buttons
document.addEventListener("DOMContentLoaded", () => {
const btnM = document.getElementById("gender-m");
const btnF = document.getElementById("gender-f");
if (btnM) btnM.addEventListener("click", () => {
applyGenderChoice("m");
savePrefs({ voice_gender: "m" });
});
if (btnF) btnF.addEventListener("click", () => {
applyGenderChoice("f");
savePrefs({ voice_gender: "f" });
});
});
loadMe();
refreshTtsMenu();
// ═══════════════════════════════════════════════════════════════
// ADMIN PANEL
// ═══════════════════════════════════════════════════════════════
// ---------- Hilfsfunktionen ----------
function escHtml(str) {
if (str === null || str === undefined || str === "") return "";
return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmtDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
async function adminFetch(url, method = "GET", body = null) {
try {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (method === "DELETE" && res.status === 204) return {};
const data = await res.json().catch(() => null);
if (!res.ok) { console.error("Admin API", res.status, data); return null; }
return data;
} catch (e) { console.error("adminFetch", e); return null; }
}
// ---------- Panel öffnen / schließen ----------
function openAdminPanel() {
$("#admin-panel").classList.remove("hidden");
switchAdminTab("overview");
}
$("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden"));
// ---------- Tab-Struktur: 5 Bereiche, je mit 1+ Abschnitten ----------
// section = vorhandener Content-Block (#admin-tab-<section>); Bereiche mit mehreren
// Abschnitten bekommen eine Sub-Navigation.
const ADMIN_TABS = {
overview: [["overview", "Übersicht"]],
users: [["users", "Verwalten"], ["chat", "Gespräche"], ["metrics", "Nutzungs-Statistik"]],
system: [["status", "Status"], ["log", "Log"], ["emergency", "Notrufe"]],
config: [["settings", "Einstellungen"], ["words", "Wörterbuch"], ["maintenance", "Wartung"]],
};
const SECTION_LOADER = {
overview: () => loadOverview(),
users: () => loadAdminUsers(),
chat: () => loadChatUsers(),
emergency: () => loadEmergencyEvents(),
status: () => loadStatus(),
metrics: () => loadMetrics(),
words: () => loadWoerterbuch(),
settings: () => loadSettings(),
maintenance: () => loadMaintenance(),
log: () => {}, // Log: Nutzer verbindet manuell
};
function _setTabActive(btn, active) {
btn.classList.toggle("border-blue-600", active);
btn.classList.toggle("text-blue-600", active);
btn.classList.toggle("dark:border-blue-400", active);
btn.classList.toggle("dark:text-blue-400", active);
btn.classList.toggle("border-transparent", !active);
btn.classList.toggle("text-slate-500", !active);
}
function showAdminSection(section, top) {
document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden"));
const panel = $("#admin-tab-" + section);
if (panel) panel.classList.remove("hidden");
// Sub-Tab markieren
document.querySelectorAll(".admin-subtab").forEach((b) => {
const active = b.dataset.sec === section;
b.classList.toggle("bg-blue-600", active);
b.classList.toggle("text-white", active);
b.classList.toggle("border-blue-600", active);
b.classList.toggle("text-slate-600", !active);
b.classList.toggle("dark:text-slate-300", !active);
b.classList.toggle("border-slate-300", !active);
b.classList.toggle("dark:border-slate-600", !active);
});
const loader = SECTION_LOADER[section];
if (loader) loader();
}
function switchAdminTab(top) {
const sections = ADMIN_TABS[top] || [[top, top]];
document.querySelectorAll(".admin-tab").forEach((btn) => _setTabActive(btn, btn.dataset.tab === top));
const subnav = $("#admin-subnav");
if (sections.length <= 1) {
subnav.classList.add("hidden");
subnav.innerHTML = "";
showAdminSection(sections[0][0], top);
} else {
subnav.classList.remove("hidden");
subnav.innerHTML = sections.map(([sec, label]) =>
`<button class="admin-subtab text-xs rounded-lg border px-3 py-1.5 whitespace-nowrap transition-colors border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700" data-sec="${sec}">${escHtml(label)}</button>`
).join("");
subnav.querySelectorAll(".admin-subtab").forEach((b) =>
b.addEventListener("click", () => showAdminSection(b.dataset.sec, top))
);
showAdminSection(sections[0][0], top);
}
}
document.querySelectorAll(".admin-tab").forEach((btn) =>
btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab))
);
$("#load-overview").addEventListener("click", loadOverview);
$("#load-emergency").addEventListener("click", loadEmergencyEvents);
$("#load-status").addEventListener("click", loadStatus);
$("#load-metrics").addEventListener("click", loadMetrics);
// ════════════════════════════════════════
// TAB: ÜBERSICHT (Dashboard)
// ════════════════════════════════════════
async function loadOverview() {
const c = $("#overview-content");
c.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const [users, usage, emergencies, llm] = await Promise.all([
adminFetch("/api/admin/users"),
adminFetch("/api/admin/usage"),
adminFetch("/api/admin/emergency-events"),
adminFetch("/api/admin/llm/status"),
]);
const userCount = users ? users.length : "—";
const reqTotal = usage ? usage.reduce((s, u) => s + (u.total_requests || 0), 0) : "—";
const emCount = emergencies ? emergencies.length : "—";
const backend = llm ? ({ ollama: "Ollama", llamacpp: "llama.cpp" }[llm.backend] || llm.backend) : "—";
const gpu = (llm && llm.gpus && llm.gpus.length)
? llm.gpus.map((g) => `GPU ${g.index}: ${g.percent}%`).join(" · ") : "—";
const hasEmergencies = typeof emCount === "number" && emCount > 0;
// Kachel; mit data-jump wird sie anklickbar (springt in den Bereich).
const stat = (label, value, sub, opts = {}) => `
<${opts.jump ? `button data-jump="${opts.jump}"` : "div"}
class="ov-stat text-left bg-white dark:bg-slate-800 rounded-xl border ${opts.border || "border-slate-200 dark:border-slate-700"} p-4 ${opts.jump ? "hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors" : ""}">
<p class="text-xs text-slate-500 dark:text-slate-400">${escHtml(label)}</p>
<p class="text-2xl font-semibold mt-1 ${opts.accent || ""}">${escHtml(value)}</p>
${sub ? `<p class="text-xs text-slate-400 mt-0.5 truncate">${escHtml(sub)}</p>` : ""}
</${opts.jump ? "button" : "div"}>`;
const jump = (to, label) =>
`<button class="ov-jump text-sm rounded-lg border border-slate-300 dark:border-slate-600 px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" data-jump="${to}">${escHtml(label)}</button>`;
c.innerHTML = `
<div class="grid grid-cols-2 gap-3">
${stat("Nutzer", userCount, "", { jump: "users" })}
${stat("Anfragen gesamt", reqTotal, "", { jump: "system" })}
${stat("Notfälle", emCount, hasEmergencies ? "ansehen" : "keine",
{ jump: "emergency",
accent: hasEmergencies ? "text-red-600 dark:text-red-400" : "",
border: hasEmergencies ? "border-red-300 dark:border-red-800" : "" })}
${stat("LLM-Backend", backend, llm ? llm.model : "", { jump: "system" })}
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h4 class="font-semibold text-sm mb-2">GPU-Auslastung</h4>
<p class="text-xs font-mono text-slate-600 dark:text-slate-300">${escHtml(gpu)}</p>
</div>
<div class="flex flex-wrap gap-2">
${jump("users", "→ Nutzer")} ${jump("system", "→ System")} ${jump("emergency", "→ Notfälle")}
</div>`;
c.querySelectorAll(".ov-jump, .ov-stat[data-jump]").forEach((b) =>
b.addEventListener("click", () => switchAdminTab(b.dataset.jump)));
}
// ════════════════════════════════════════
// TAB: NUTZER
// ════════════════════════════════════════
async function loadAdminUsers() {
const container = $("#admin-user-cards");
container.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">lade …</p>';
const users = await adminFetch("/api/admin/users");
if (!users) { container.innerHTML = '<p class="text-sm text-red-500 text-center py-4">Fehler beim Laden.</p>'; return; }
if (!users.length) { container.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">Noch keine Nutzer.</p>'; return; }
container.innerHTML = "";
users.forEach((u) => container.appendChild(buildUserCard(u)));
}
$("#create-user-form").addEventListener("submit", async (e) => {
e.preventDefault();
const nameEl = $("#new-user-name");
const name = nameEl.value.trim();
if (!name) return;
const data = await adminFetch("/api/admin/users", "POST", { display_name: name });
if (data?.token) {
$("#new-token-value").textContent = loginLink(data.token);
$("#new-token-box").classList.remove("hidden");
nameEl.value = "";
loadAdminUsers();
}
});
function buildUserCard(u) {
const card = document.createElement("div");
card.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
const badgeSSO = u.external_id
? `<span class="rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs px-2 py-0.5">SSO</span>` : "";
card.innerHTML = `
<div class="flex items-start gap-3 flex-wrap">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-0.5">
<span class="font-semibold text-slate-900 dark:text-slate-100 user-dn">${escHtml(u.display_name)}</span>
${badgeSSO}
</div>
<p class="text-xs text-slate-400 font-mono">${u.user_id.slice(0, 8)}${u.external_id ? " · " + escHtml(u.external_id) : ""}</p>
<p class="text-xs text-slate-400">seit ${fmtDate(u.created_at)}</p>
</div>
<div class="flex flex-wrap gap-1.5">
<button class="btn-rename text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Umbenennen</button>
<button class="btn-admin text-xs rounded-lg border px-2.5 py-1.5 transition-colors"></button>
<button class="btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Link erneuern</button>
<button class="btn-history text-xs rounded-lg border border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300 px-2.5 py-1.5 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors">Verlauf löschen</button>
<button class="btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Löschen</button>
</div>
</div>
<!-- Token-Anzeige nach Reset -->
<div class="token-box hidden mt-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 p-3">
<p class="text-xs text-amber-700 dark:text-amber-300 font-medium mb-1">Neuer Login-Link (einmalig — jetzt kopieren!):</p>
<code class="token-val text-xs font-mono break-all text-amber-900 dark:text-amber-100 select-all"></code>
</div>
<!-- Umbenenn-Formular -->
<div class="rename-box hidden mt-3">
<form class="flex gap-2">
<input type="text" class="rename-inp flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value="${escHtml(u.display_name)}" />
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 transition-colors">Speichern</button>
<button type="button" class="rename-cancel rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Abbrechen</button>
</form>
</div>
<!-- Erlaubte Sprachen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Erlaubte Sprachen <span class="text-slate-400">(leer = alle)</span>:</p>
<div class="lang-allow flex flex-wrap gap-1"></div>
</div>
<!-- Notfall-Profil -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-prof text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
<span class="prof-arrow">▶</span> Notfall-Profil (fließt in Notruf-Meldungen)
</button>
<div class="prof-section hidden mt-2 space-y-1.5">
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="full_name" placeholder="Voller Name" />
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="street" placeholder="Straße + Hausnummer" />
<div class="flex gap-1.5">
<input class="prof-f w-1/3 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="postal_code" placeholder="PLZ" />
<input class="prof-f w-2/3 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="city" placeholder="Ort" />
</div>
<div class="flex gap-1.5">
<input class="prof-f w-1/2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="phone_mobile" placeholder="Mobil" />
<input class="prof-f w-1/2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="phone_landline" placeholder="Festnetz" />
</div>
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="birth_date" placeholder="Geburtsdatum (TT.MM.JJJJ)" />
<textarea class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="medical_notes" rows="2" placeholder="Medizinische Hinweise (Allergien, Medikamente, Vorerkrankungen)"></textarea>
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="emergency_contacts" placeholder="Kontakt-E-Mail(s), Komma-getrennt" />
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="emergency_phones" placeholder="Kontakt-Telefon(e), Komma-getrennt" />
<label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300">
<input type="checkbox" class="prof-consent" /> Standort im Notfall mitsenden
</label>
<label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300">
Pause bis zum erneuten Alarm:
<input type="number" min="0" max="60" inputmode="numeric"
class="prof-f w-16 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
data-k="emergency_cooldown_minutes" placeholder="Standard" /> Minuten
</label>
<p class="text-[11px] text-slate-400">leer = globaler Standard · 0 = aus</p>
</div>
</div>
<!-- Speichern (Einstellungs-Ebene) — prüft Datenformate, speichert ALLE Felder + Sprachen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 flex items-center gap-2 flex-wrap">
<button class="prof-save rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-1.5 transition-colors">Speichern</button>
<span class="prof-status text-xs text-slate-400"></span>
</div>
<!-- Erinnerungen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
<span class="mem-arrow">▶</span> Erinnerungen
</button>
<div class="mem-section hidden mt-2 space-y-1.5">
<div class="mem-list"></div>
<form class="mem-add-form flex gap-2 mt-2">
<input type="text" placeholder="Neue Erinnerung …"
class="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors">Hinzufügen</button>
</form>
</div>
</div>
`;
const uid = u.user_id;
// Erlaubte Sprachen (Toggle-Chips) — Admin schaltet Sprachen pro Nutzer frei.
const allowWrap = card.querySelector(".lang-allow");
let allowed = String(u.allowed_languages || "").split(",").map((s) => s.trim()).filter(Boolean);
function renderLangChips() {
allowWrap.innerHTML = ALL_LANGS.map(([code, label]) => {
const on = allowed.includes(code);
return `<button type="button" data-lang="${code}" class="lang-chip text-xs rounded-md border px-2 py-1 transition-colors ${on ? "bg-blue-600 text-white border-blue-600" : "border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"}">${label}</button>`;
}).join("");
allowWrap.querySelectorAll(".lang-chip").forEach((b) =>
b.addEventListener("click", async () => {
const code = b.dataset.lang;
allowed = allowed.includes(code) ? allowed.filter((c) => c !== code) : [...allowed, code];
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT",
{ allowed_languages: allowed.join(",") });
if (res) renderLangChips();
})
);
}
renderLangChips();
// Notfall-Profil — Admin pflegt Kontaktdaten + med. Hinweise + Standort-Freigabe.
const PROF_KEYS = ["full_name", "street", "postal_code", "city", "phone_mobile",
"phone_landline", "birth_date", "medical_notes",
"emergency_contacts", "emergency_phones",
"emergency_cooldown_minutes"];
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (el) el.value = u[k] || "";
});
const consentEl = card.querySelector(".prof-consent");
if (consentEl) consentEl.checked = !!u.location_consent;
const profBtn = card.querySelector(".btn-prof");
const profSection = card.querySelector(".prof-section");
const profArrow = card.querySelector(".prof-arrow");
profBtn.addEventListener("click", () => {
const open = !profSection.classList.contains("hidden");
profSection.classList.toggle("hidden", open);
profArrow.textContent = open ? "▶" : "▼";
});
const profStatus = card.querySelector(".prof-status");
// Prüft jedes Feld gegen sein Format (nur nicht-leere Werte), markiert Fehler rot.
// Liefert die Liste der Meckertexte (leer = alles konform).
function validateProfile() {
const errs = [];
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (!el) return;
const v = el.value.trim();
const rule = PROF_VALIDATORS[k];
const bad = !!v && !!rule && !rule.ok(v);
el.classList.toggle("border-red-500", bad);
el.classList.toggle("ring-2", bad);
el.classList.toggle("ring-red-500", bad);
if (bad) errs.push(rule.msg);
});
return errs;
}
// Rote Markierung verschwindet, sobald nachgebessert wird.
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (el) el.addEventListener("input", validateProfile);
});
card.querySelector(".prof-save").addEventListener("click", async () => {
const errs = validateProfile();
if (errs.length) {
profStatus.className = "prof-status text-xs text-red-600 dark:text-red-400";
profStatus.textContent = "Bitte korrigieren: " + errs.join(" · ");
return;
}
const body = { location_consent: !!consentEl.checked, allowed_languages: allowed.join(",") };
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
body[k] = el ? el.value.trim() : "";
});
profStatus.className = "prof-status text-xs text-slate-400";
profStatus.textContent = "speichere …";
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", body);
profStatus.className = "prof-status text-xs " + (res ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400");
profStatus.textContent = res ? "✓ alle Daten gespeichert" : "Fehler beim Speichern";
if (res) setTimeout(() => { profStatus.textContent = ""; }, 3000);
});
// Admin-Rechte (persistentes Flag) ein-/ausschalten
let isAdmin = !!u.is_admin;
const adminBtn = card.querySelector(".btn-admin");
function renderAdminBtn() {
adminBtn.textContent = isAdmin ? "★ Admin" : "Admin?";
adminBtn.classList.toggle("bg-blue-600", isAdmin);
adminBtn.classList.toggle("text-white", isAdmin);
adminBtn.classList.toggle("border-blue-600", isAdmin);
adminBtn.classList.toggle("border-slate-300", !isAdmin);
adminBtn.classList.toggle("dark:border-slate-600", !isAdmin);
adminBtn.classList.toggle("text-slate-600", !isAdmin);
adminBtn.classList.toggle("dark:text-slate-300", !isAdmin);
}
renderAdminBtn();
adminBtn.addEventListener("click", async () => {
const next = !isAdmin;
if (next && !confirm(`${u.display_name}" Admin-Rechte geben?`)) return;
const res = await adminFetch(`/api/admin/users/${uid}/admin`, "PUT", { is_admin: next });
if (res) { isAdmin = !!res.is_admin; renderAdminBtn(); }
});
// Umbenennen
card.querySelector(".btn-rename").addEventListener("click", () =>
card.querySelector(".rename-box").classList.toggle("hidden")
);
card.querySelector(".rename-cancel").addEventListener("click", () =>
card.querySelector(".rename-box").classList.add("hidden")
);
card.querySelector(".rename-box form").addEventListener("submit", async (e) => {
e.preventDefault();
const name = card.querySelector(".rename-inp").value.trim();
if (!name) return;
const res = await adminFetch(`/api/admin/users/${uid}`, "PUT", { display_name: name });
if (res) {
card.querySelector(".user-dn").textContent = name;
card.querySelector(".rename-box").classList.add("hidden");
}
});
// Token-Reset
card.querySelector(".btn-token").addEventListener("click", async () => {
if (!confirm(`Token für „${u.display_name}" zurücksetzen?\nDer alte Token wird sofort ungültig.`)) return;
const data = await adminFetch(`/api/admin/users/${uid}/token`, "POST");
if (data?.token) {
card.querySelector(".token-val").textContent = loginLink(data.token);
card.querySelector(".token-box").classList.remove("hidden");
}
});
// Verlauf löschen (Konto + Erinnerungen bleiben)
card.querySelector(".btn-history").addEventListener("click", async () => {
if (!confirm(`Gesprächsverlauf von „${u.display_name}" löschen?\nKonto und Erinnerungen bleiben erhalten.`)) return;
const res = await adminFetch(`/api/admin/users/${uid}/history`, "DELETE");
if (res) alert(`Verlauf gelöscht (${res.deleted_messages ?? 0} Nachrichten).`);
});
// Löschen
card.querySelector(".btn-delete").addEventListener("click", async () => {
if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return;
const res = await adminFetch(`/api/admin/users/${uid}`, "DELETE");
if (res !== null) card.remove();
});
// Erinnerungen auf-/zuklappen
const memBtn = card.querySelector(".btn-mem");
const memSection = card.querySelector(".mem-section");
const memArrow = card.querySelector(".mem-arrow");
memBtn.addEventListener("click", async () => {
const open = !memSection.classList.contains("hidden");
memSection.classList.toggle("hidden", open);
memArrow.textContent = open ? "▶" : "▼";
if (!open) await renderMemories(uid, card.querySelector(".mem-list"));
});
// Erinnerung hinzufügen
card.querySelector(".mem-add-form").addEventListener("submit", async (e) => {
e.preventDefault();
const inp = e.target.querySelector("input");
const content = inp.value.trim();
if (!content) return;
const res = await adminFetch(`/api/admin/users/${uid}/memories`, "POST", { content });
if (res) { inp.value = ""; await renderMemories(uid, card.querySelector(".mem-list")); }
});
return card;
}
async function renderMemories(uid, container) {
container.innerHTML = '<p class="text-xs text-slate-400">lade …</p>';
const mems = await adminFetch(`/api/admin/users/${uid}/memories`);
if (!mems?.length) {
container.innerHTML = '<p class="text-xs text-slate-400 italic">Keine Erinnerungen vorhanden.</p>';
return;
}
container.innerHTML = "";
mems.forEach((m) => {
const row = document.createElement("div");
row.className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs";
row.innerHTML = `
<span class="flex-1 text-slate-700 dark:text-slate-300 leading-relaxed">${escHtml(m.content)}</span>
<button class="shrink-0 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-colors font-bold" title="Löschen">✕</button>
`;
row.querySelector("button").addEventListener("click", async () => {
await adminFetch(`/api/admin/users/${uid}/memories/${m.id}`, "DELETE");
row.remove();
});
container.appendChild(row);
});
}
// ════════════════════════════════════════
// TAB: GESPRÄCHE
// ════════════════════════════════════════
let _chatUser = null;
async function loadChatUsers() {
const list = $("#chat-user-list");
list.innerHTML = '<li class="px-3 py-3 text-sm text-slate-400">lade …</li>';
const users = await adminFetch("/api/admin/users");
if (!users) return;
list.innerHTML = "";
users.forEach((u) => {
const li = document.createElement("li");
li.className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col";
li.innerHTML = `<span class="font-medium truncate">${escHtml(u.display_name)}</span>
<span class="text-xs text-slate-400 font-mono truncate">${u.user_id.slice(0,8)}…</span>`;
li.addEventListener("click", () => {
document.querySelectorAll("#chat-user-list li").forEach((el) =>
el.classList.remove("bg-blue-50", "dark:bg-blue-900/20", "text-blue-700", "dark:text-blue-300")
);
li.classList.add("bg-blue-50", "dark:bg-blue-900/20");
_chatUser = u;
loadChatSessions(u);
});
list.appendChild(li);
});
}
async function loadChatSessions(u) {
const panel = $("#chat-right-panel");
panel.innerHTML = `<p class="p-4 text-sm text-slate-400">lade Sessions für ${escHtml(u.display_name)} …</p>`;
const sessions = await adminFetch(`/api/admin/users/${u.user_id}/sessions`);
if (!sessions?.length) {
panel.innerHTML = '<p class="p-4 text-sm text-slate-400">Keine Sessions gefunden.</p>';
return;
}
panel.innerHTML = `
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/50">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wide">Sessions · ${escHtml(u.display_name)}</p>
</div>
<div id="session-list" class="divide-y divide-slate-100 dark:divide-slate-800/60"></div>
`;
const list = panel.querySelector("#session-list");
sessions.forEach((s) => {
const row = document.createElement("div");
row.className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors";
row.innerHTML = `
<p class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate">${escHtml(s.id)}</p>
<p class="text-xs text-slate-400 mt-0.5">${fmtDate(s.updated_at)} · ${s.msg_count} Nachrichten</p>
`;
row.addEventListener("click", () => loadTranscript(s.id));
list.appendChild(row);
});
}
async function loadTranscript(sessionId) {
const panel = $("#chat-right-panel");
panel.innerHTML = `<p class="p-4 text-sm text-slate-400">lade Transkript …</p>`;
const msgs = await adminFetch(`/api/admin/sessions/${sessionId}/messages`);
if (!msgs?.length) {
panel.innerHTML = `<div class="p-4">
<button class="back-btn text-xs text-blue-600 hover:underline mb-3 block">← zurück zu Sessions</button>
<p class="text-sm text-slate-400">Keine Nachrichten.</p></div>`;
panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser));
return;
}
const bubbles = msgs.map((m) => {
const isUser = m.role === "user";
return `<div class="flex ${isUser ? "justify-end" : "justify-start"}">
<div class="max-w-[80%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap leading-relaxed
${isUser
? "rounded-br-sm bg-blue-600 text-white"
: "rounded-bl-sm border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100"}">
${escHtml(m.content)}</div></div>`;
}).join("");
panel.innerHTML = `
<div class="sticky top-0 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur flex items-center gap-3">
<button class="back-btn text-xs text-blue-600 hover:underline">← Sessions</button>
<span class="text-xs text-slate-400 font-mono truncate">${escHtml(sessionId)}</span>
</div>
<div class="p-4 space-y-3">${bubbles}</div>
`;
panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser));
}
// ════════════════════════════════════════
// TAB: NOTFÄLLE
// ════════════════════════════════════════
async function loadEmergencyEvents() {
const container = $("#emergency-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const events = await adminFetch("/api/admin/emergency-events");
if (!events) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
if (!events.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Keine Notfall-Ereignisse protokolliert.</p>'; return; }
container.innerHTML = `
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/60">
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
<th class="text-left px-4 py-2.5">Zeitpunkt</th>
<th class="text-left px-4 py-2.5">Nutzer</th>
<th class="text-left px-4 py-2.5">Kategorie</th>
<th class="text-left px-4 py-2.5">Textausschnitt</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800">
${events.map((ev) => `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
<td class="px-4 py-2.5 text-xs text-slate-400 whitespace-nowrap">${fmtDate(ev.created_at)}</td>
<td class="px-4 py-2.5 text-xs font-medium">${escHtml(ev.display_name)}</td>
<td class="px-4 py-2.5">
<span class="rounded-full bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-xs px-2.5 py-0.5 font-medium">${escHtml(ev.category)}</span>
</td>
<td class="px-4 py-2.5 text-xs text-slate-600 dark:text-slate-300 max-w-[16rem] break-words">${escHtml(ev.snippet)}</td>
</tr>`).join("")}
</tbody>
</table>
</div>`;
}
// ════════════════════════════════════════
// TAB: STATUS
// ════════════════════════════════════════
// LLM-/GPU-Status-Karte (read-only) für den Status-Tab.
// Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden.
async function pollGatewayBack(msgEl, label) {
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 1500));
try {
const r = await fetch("/api/config", { cache: "no-store" });
if (r.ok) { if (msgEl) msgEl.textContent = label + " ✓"; loadStatus(); return; }
} catch (e) { /* noch nicht oben */ }
if (msgEl) msgEl.textContent = label + " … (" + (i + 1) + ")";
}
if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen.";
}
function wireRestart() {
const restartBtn = $("#gw-restart-btn");
const msg = $("#llm-ctrl-msg");
if (!restartBtn) return;
restartBtn.addEventListener("click", async () => {
if (!confirm("Gateway-Dienst jetzt neu starten?")) return;
restartBtn.disabled = true;
msg.textContent = "starte neu …";
const res = await adminFetch("/api/admin/gateway/restart", "POST");
if (res === null) { msg.textContent = "Fehler beim Neustart"; restartBtn.disabled = false; return; }
pollGatewayBack(msg, "Neu gestartet");
});
}
async function loadStatus() {
const container = $("#status-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
try {
const [cfg, met, runtimeCfg, models] = await Promise.all([
fetch("/api/config").then((r) => r.json()),
fetch("/api/metrics").then((r) => r.json()),
adminFetch("/api/admin/config"),
adminFetch("/api/admin/models"),
]);
const cfgByKey = Object.fromEntries((runtimeCfg || []).map((d) => [d.key, d.effective_value]));
const llmModel = cfgByKey["openrouter_llm_model"] || "—";
// Dropdown „Zentrales KI-Modell" (erprobte Presets, Auswahl wirkt sofort).
const presets = (models && models.presets) || [];
const activeIdx = models ? models.active_index : null;
const cur = (models && models.current) || {};
const curProv = cur.default_llm_provider || "—";
const curModel = curProv === "openrouter"
? (cur.openrouter_llm_model || "—") : (cur.local_llm_model || "—");
const modelOptions = presets.map((p) =>
`<option value="${p.index}"${p.index === activeIdx ? " selected" : ""}>${escHtml(p.label)}</option>`).join("");
const modelCard = presets.length ? `
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-1">Zentrales KI-Modell</h3>
<p class="text-xs text-slate-400 mb-3">Erprobte Modelle — Auswahl wirkt sofort (kein Neustart).</p>
<div class="flex flex-wrap items-center gap-2">
<select id="model-preset" class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5">
${modelOptions}
</select>
<span id="model-preset-msg" class="text-xs text-slate-400"></span>
</div>
<p class="text-xs text-slate-400 mt-2">Aktiv: <span class="font-mono text-slate-600 dark:text-slate-300">${escHtml(curProv)} · ${escHtml(curModel)}</span></p>
</div>` : "";
const route = cfg.default_route || {};
const counters = met.counters || {};
const turnsTotal = counters["turns_total"] || 0;
const httpTotal = Object.entries(counters)
.filter(([k]) => k.startsWith("http_requests_total"))
.reduce((s, [, v]) => s + v, 0);
const providerRow = (label, val) =>
`<dt class="text-slate-500 dark:text-slate-400">${label}</dt>
<dd class="font-mono text-xs text-slate-700 dark:text-slate-300">${escHtml(val || "—")}</dd>`;
container.innerHTML = `
<!-- Gateway-Status -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="h-2.5 w-2.5 rounded-full bg-emerald-500 shadow shadow-emerald-300/60"></span>
<h3 class="font-semibold text-sm">Gateway</h3>
<span class="text-xs text-emerald-600 dark:text-emerald-400 font-medium">läuft</span>
</div>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("Profil", cfg.profile)}
${providerRow("Umgebung", cfg.app_env)}
</dl>
</div>
${modelCard}
<!-- Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Provider (Standard-Route)</h3>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("STT", route.stt_provider)}
${providerRow("LLM", route.llm_provider)}
${providerRow("LLM-Modell", llmModel)}
${providerRow("TTS", route.tts_provider)}
${providerRow("Sprache", route.language)}
</dl>
</div>
<!-- Laufzeit-Metriken -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Laufzeit-Metriken</h3>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("Turns gesamt", turnsTotal)}
${providerRow("HTTP-Anfragen", httpTotal)}
</dl>
</div>
<!-- Verfügbare Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Verfügbare Provider</h3>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("STT", (cfg.available?.stt_providers || []).join(", "))}
${providerRow("LLM", (cfg.available?.llm_providers || []).join(", "))}
${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}
</dl>
</div>
`;
wireModelPicker();
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
}
}
// Dropdown „Zentrales KI-Modell": Auswahl wendet ein Preset an (Laufzeit-Override).
function wireModelPicker() {
const sel = $("#model-preset");
const msg = $("#model-preset-msg");
if (!sel) return;
sel.addEventListener("change", async () => {
msg.textContent = "stelle um …";
sel.disabled = true;
const res = await adminFetch("/api/admin/models/select", "POST", { index: +sel.value });
sel.disabled = false;
if (res === null) { msg.textContent = "Fehler beim Umstellen"; return; }
msg.textContent = "✓ " + (res.selected || "umgestellt");
loadStatus(); // Karte + „Aktiv"-Anzeige aktualisieren
});
}
// ════════════════════════════════════════
// TAB: WARTUNG (Konfiguration)
// ════════════════════════════════════════
async function loadMaintenance() {
const c = $("#maintenance-content");
c.innerHTML = `
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-1">Gateway neu starten</h3>
<p class="text-xs text-slate-400 mb-3">Nötig nach Einstellungen, die einen Neustart erfordern (Hinweis erscheint dort).</p>
<div class="flex flex-wrap items-center gap-2">
<button id="gw-restart-btn" class="rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 text-sm px-3 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Gateway neu starten</button>
<span id="llm-ctrl-msg" class="text-xs text-slate-400"></span>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-1">Datenbank-Backup</h3>
<p class="text-xs text-slate-400 mb-3">SQLite-Datenbank als Datei herunterladen (Backup / Migration).</p>
<a href="/api/admin/db-export" download="voice-assistant.db"
class="inline-flex items-center gap-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-xs px-4 py-2 font-medium transition-colors">
⬇ voice-assistant.db herunterladen
</a>
</div>
`;
wireRestart();
}
// ════════════════════════════════════════
// TAB: METRIKEN
// ════════════════════════════════════════
async function loadMetrics() {
const container = $("#metrics-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch("/api/admin/usage");
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; return; }
const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0);
const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1);
const bars = data.map((u) => {
const pct = Math.round(((u.total_requests || 0) / maxReq) * 100);
return `
<div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-xs text-slate-600 dark:text-slate-300 truncate">${escHtml(u.display_name)}</span>
<div class="flex-1 h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 dark:bg-blue-600 rounded-full" style="width:${pct}%"></div>
</div>
<span class="w-10 text-right text-xs font-mono text-slate-500 dark:text-slate-400">${u.total_requests ?? 0}</span>
</div>`;
}).join("");
container.innerHTML = `
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/60">
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
<th class="text-left px-4 py-2.5">Nutzer</th>
<th class="text-right px-4 py-2.5">Anfragen gesamt</th>
<th class="text-right px-4 py-2.5">Einheiten</th>
<th class="text-right px-4 py-2.5">Letzte Aktivität</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800">
${data.map((u) => `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
<td class="px-4 py-2.5 font-medium">${escHtml(u.display_name)}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_requests ?? 0}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_units ?? 0}</td>
<td class="px-4 py-2.5 text-right text-xs text-slate-400">${u.last_active || "—"}</td>
</tr>`).join("")}
</tbody>
<tfoot class="border-t-2 border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/60">
<tr class="text-xs font-semibold text-slate-600 dark:text-slate-300">
<td class="px-4 py-2">Gesamt</td>
<td class="px-4 py-2 text-right font-mono">${totalReq}</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-4">Anfragen je Nutzer</h3>
<div class="space-y-2.5">${bars}</div>
</div>
`;
}
// ════════════════════════════════════════
// TAB: WÖRTERBUCH
// ════════════════════════════════════════
async function loadWoerterbuch() {
const lang = ($("#words-lang") || {}).value || "de";
const container = $("#words-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch(`/api/admin/pronunciation/${lang}`);
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
container.innerHTML = "";
const sections = [
["abbreviations", "Abkürzungen"],
["units", "Einheiten"],
["terms", "Begriffe / Aussprache"],
];
for (const [section, label] of sections) {
container.appendChild(buildWortSection(label, section, data[section] || {}, lang));
}
}
function buildWortSection(title, section, entries, lang) {
const wrap = document.createElement("div");
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
const rows = Object.entries(entries).map(([k, v]) => `
<tr class="row-edit cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group"
data-key="${escHtml(k)}" data-val="${escHtml(v)}" title="Zum Bearbeiten anklicken">
<td class="px-3 py-2 text-xs font-mono text-slate-700 dark:text-slate-300">${escHtml(k)}</td>
<td class="px-3 py-2 text-xs text-slate-400">→</td>
<td class="px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium">${escHtml(v)}</td>
<td class="px-3 py-2 text-right">
<button class="del-btn text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
data-key="${escHtml(k)}" title="Löschen">✕</button>
</td>
</tr>`).join("");
wrap.innerHTML = `
<h4 class="font-semibold text-sm mb-3 flex items-center justify-between">
${escHtml(title)}
<span class="text-xs font-normal text-slate-400">${Object.keys(entries).length} Einträge</span>
</h4>
${rows ? `
<div class="overflow-x-auto mb-4">
<table class="w-full">
<tbody class="divide-y divide-slate-100 dark:divide-slate-700/60">${rows}</tbody>
</table>
</div>` : '<p class="text-xs text-slate-400 italic mb-4">Keine Einträge.</p>'}
<form class="add-form flex gap-2 flex-wrap">
<input type="text" placeholder="Schlüssel" required
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input type="text" placeholder="Ersetzung" required
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit"
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 font-medium transition-colors whitespace-nowrap">+ Hinzufügen</button>
</form>
`;
const form = wrap.querySelector(".add-form");
const [keyInp, valInp] = form.querySelectorAll("input");
const submitBtn = form.querySelector('button[type="submit"]');
// Zeile anklicken -> Begriffspaar in die Editier-Felder laden, Button wird zu „Speichern".
wrap.querySelectorAll("tr.row-edit").forEach((tr) =>
tr.addEventListener("click", () => {
keyInp.value = tr.dataset.key;
valInp.value = tr.dataset.val;
form.dataset.editKey = tr.dataset.key; // Originalschlüssel merken (für Umbenennen)
submitBtn.textContent = "Speichern";
valInp.focus();
})
);
wrap.querySelectorAll(".del-btn").forEach((btn) =>
btn.addEventListener("click", async (e) => {
e.stopPropagation(); // nicht zugleich die Zeile zum Bearbeiten laden
const key = btn.dataset.key;
if (!confirm(`Eintrag „${key}" löschen?`)) return;
const res = await adminFetch(
`/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(key)}`, "DELETE"
);
if (res !== null) btn.closest("tr").remove();
})
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const key = keyInp.value.trim();
const value = valInp.value.trim();
if (!key || !value) return;
const editKey = form.dataset.editKey || "";
// Hinzufügen ODER Aktualisieren (gleicher Schlüssel wird überschrieben; Backend sortiert).
const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value });
if (res === null) return;
// Umbenannt -> alten Schlüssel entfernen (neuer wurde oben bereits angelegt).
if (editKey && editKey !== key) {
await adminFetch(
`/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(editKey)}`, "DELETE"
);
}
loadWoerterbuch(); // neu laden -> sortierte Liste, Formular zurück auf „+ Hinzufügen"
});
return wrap;
}
$("#load-words").addEventListener("click", loadWoerterbuch);
$("#words-lang").addEventListener("change", loadWoerterbuch);
// ════════════════════════════════════════
// TAB: LOG
// ════════════════════════════════════════
let _logWs = null;
function connectLog() {
if (_logWs) return;
const proto = location.protocol === "https:" ? "wss" : "ws";
_logWs = new WebSocket(`${proto}://${location.host}/api/admin/log`);
const output = $("#log-output");
const logStat = $("#log-status");
const btnConn = $("#log-connect");
const btnDisc = $("#log-disconnect");
btnConn.disabled = true;
btnDisc.disabled = false;
btnDisc.classList.remove("opacity-40");
logStat.textContent = "verbinde …";
logStat.className = "text-xs text-amber-400";
_logWs.onopen = () => {
logStat.textContent = "verbunden";
logStat.className = "text-xs text-emerald-400";
output.innerHTML = "";
};
_logWs.onmessage = (e) => {
const line = document.createElement("div");
line.textContent = e.data;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
};
_logWs.onerror = () => {
logStat.textContent = "Verbindungsfehler";
logStat.className = "text-xs text-red-400";
};
_logWs.onclose = () => {
_logWs = null;
btnConn.disabled = false;
btnDisc.disabled = true;
btnDisc.classList.add("opacity-40");
if (!logStat.className.includes("red")) {
logStat.textContent = "getrennt";
logStat.className = "text-xs text-slate-400";
}
};
}
function disconnectLog() {
if (_logWs) { _logWs.close(); }
}
$("#log-connect").addEventListener("click", connectLog);
$("#log-disconnect").addEventListener("click", disconnectLog);
$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; });
// ════════════════════════════════════════
// TAB: EINSTELLUNGEN
// ════════════════════════════════════════
// Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ
const FIELD_META = {
default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" },
default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" },
default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" },
default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl","pt","pl","ar"],
labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } },
openrouter_llm_model: { ui: "datalist", test: "llm" },
openrouter_tts_model: { ui: "datalist", test: "tts", testProvider: "openrouter" },
openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" },
piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","nl_NL-mls-medium","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" },
local_llm_system_prompt: { ui: "textarea", test: "llm" },
local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" },
local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" },
local_llm_max_tokens: { ui: "number", min: 0, test: "llm" },
tts_normalize_level: { ui: "select", opts: ["auto","full","light","off"], test: "tts" },
audio_stream_default: { ui: "select", opts: ["true","false"],
labels: { "true":"Aktiviert (satzweises Streaming)","false":"Deaktiviert (alles auf einmal)" }, test: "tts" },
memory_extraction_enabled: { ui: "select", opts: ["true","false"],
labels: { "true":"Aktiviert","false":"Deaktiviert" } },
memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 },
daily_request_limit: { ui: "number", min: 0 },
};
const FIELD_GROUPS = [
{ label: "Sprache & Stimme", keys: ["default_language","default_tts_provider","openrouter_tts_voice","piper_voice"] },
{ label: "Antwortverhalten", keys: ["memory_extraction_enabled","audio_stream_default","daily_request_limit"] },
{ label: "Erweitert", advanced: true, keys: [
"default_stt_provider","default_llm_provider","openrouter_llm_model","openrouter_tts_model",
"tts_normalize_level","local_llm_system_prompt","local_llm_temperature","local_llm_top_p",
"local_llm_max_tokens","memory_extraction_every_n_turns" ] },
];
let _settingsData = [];
async function loadSettings() {
const container = $("#settings-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch("/api/admin/config");
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
_settingsData = data;
container.innerHTML = "";
const byKey = Object.fromEntries(data.map((d) => [d.key, d]));
FIELD_GROUPS.forEach((group) => {
const items = group.keys.map((k) => byKey[k]).filter(Boolean);
if (!items.length) return;
const section = document.createElement("div");
section.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden";
const body = document.createElement("div");
items.forEach((item, idx) => {
const row = buildSettingRow(item);
if (idx > 0) row.classList.add("border-t", "border-slate-100", "dark:border-slate-700/60");
body.appendChild(row);
});
const header = document.createElement(group.advanced ? "button" : "div");
header.className = "w-full text-left px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60 flex items-center gap-2";
if (group.advanced) {
body.classList.add("hidden");
header.innerHTML = `<span class="adv-arrow text-slate-400">▶</span>` +
`<h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide flex-1">${escHtml(group.label)}</h4>` +
`<span class="text-xs text-slate-400">technische Optionen</span>`;
header.addEventListener("click", () => {
const open = !body.classList.contains("hidden");
body.classList.toggle("hidden", open);
header.querySelector(".adv-arrow").textContent = open ? "▶" : "▼";
});
} else {
header.innerHTML = `<h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">${escHtml(group.label)}</h4>`;
}
section.appendChild(header);
section.appendChild(body);
container.appendChild(section);
});
populateModelDatalists();
}
// Füllt alle Modell-<datalist>s mit der Live-Liste von OpenRouter (einmal gecacht).
let _orModels = null;
async function populateModelDatalists() {
const lists = document.querySelectorAll("datalist[data-or-models]");
if (!lists.length) return;
if (_orModels === null) {
const res = await adminFetch("/api/admin/openrouter/models");
_orModels = (res && res.models) || [];
}
const opts = _orModels.map((m) => `<option value="${escHtml(m)}"></option>`).join("");
lists.forEach((dl) => { dl.innerHTML = opts; });
}
function _buildControl(item, meta) {
const val = item.effective_value;
const id = "cfg-" + item.key;
const cls = "rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500";
if (meta.ui === "select") {
const labels = meta.labels || {};
const options = meta.opts.map((o) =>
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(labels[o] || o)}</option>`
).join("");
return `<select id="${id}" class="${cls} flex-1">${options}</select>`;
}
if (meta.ui === "combo") {
const inList = meta.opts.includes(val);
const labels = meta.labels || {};
const options = meta.opts.map((o) =>
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(labels[o] || o)}</option>`
).join("");
const customSel = !inList ? 'selected' : '';
return `
<div class="flex-1 space-y-1.5">
<select class="combo-sel w-full ${cls}">
${options}
<option value="__custom__" ${customSel}>✏ Eigener Wert …</option>
</select>
<input id="${id}" type="text" value="${escHtml(val)}" placeholder="Eigenen Wert eingeben"
class="combo-inp w-full ${cls} ${inList ? "hidden" : ""}" />
</div>`;
}
if (meta.ui === "datalist") {
// Live-Modell-Liste (OpenRouter) per <datalist>: tippen/auswählen, immer gültige Namen.
return `
<input id="${id}" list="${id}-list" type="text" value="${escHtml(val)}" placeholder="Modell eingeben oder wählen"
class="${cls} flex-1" />
<datalist id="${id}-list" data-or-models></datalist>`;
}
if (meta.ui === "range") {
return `
<div class="flex-1 flex items-center gap-3">
<input id="${id}" type="range" min="${meta.min}" max="${meta.max}" step="${meta.step}" value="${escHtml(val)}"
class="flex-1 accent-blue-600" />
<span class="range-val text-xs font-mono w-8 text-right">${escHtml(val)}</span>
</div>`;
}
if (meta.ui === "number") {
const extra = meta.max !== undefined ? `max="${meta.max}"` : "";
return `<input id="${id}" type="number" min="${meta.min ?? 0}" ${extra} value="${escHtml(val)}" class="${cls} flex-1" />`;
}
if (meta.ui === "textarea") {
return `<textarea id="${id}" rows="3" class="${cls} flex-1 resize-y">${escHtml(val)}</textarea>`;
}
return `<input id="${id}" type="text" value="${escHtml(val)}" class="${cls} flex-1" />`;
}
function buildSettingRow(item) {
const meta = FIELD_META[item.key] || { ui: "text" };
const overridden = item.is_overridden;
const envName = item.key.toUpperCase();
const testType = meta.test;
const wrap = document.createElement("div");
wrap.className = "px-4 py-3" + (overridden ? " bg-amber-50/40 dark:bg-amber-900/10" : "");
const testBtn = testType
? `<button class="test-btn text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors whitespace-nowrap">${testType === "tts" ? "🔊 Test" : testType === "llm" ? "💬 Test" : "🎤 Test"}</button>`
: "";
const resetBtn = overridden
? `<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button>`
: "";
wrap.innerHTML = `
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="font-medium text-sm flex-1">${escHtml(item.label)}</span>
${overridden ? '<span class="text-xs rounded-full bg-amber-200 dark:bg-amber-800/60 text-amber-800 dark:text-amber-200 px-2 py-0.5 font-medium">überschrieben</span>' : ""}
${resetBtn}
</div>
<div class="flex gap-2 items-start flex-wrap">
${_buildControl(item, meta)}
<div class="flex flex-col gap-1.5 shrink-0">
<button class="save-btn rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-2 font-medium transition-colors">Speichern</button>
${testBtn}
</div>
</div>
<div class="flex items-baseline gap-2 mt-1.5 text-xs text-slate-400 flex-wrap">
<span class="font-mono font-medium text-slate-500 dark:text-slate-400">${envName}</span>
<span class="text-slate-300 dark:text-slate-600">·</span>
<span>.env: <span class="font-mono">${escHtml(item.base_value) || "—"}</span></span>
<span class="text-slate-300 dark:text-slate-600">·</span>
<span>${escHtml(item.hint)}</span>
</div>
<div class="feedback-msg text-xs mt-1 hidden"></div>
<div class="test-result text-xs mt-2 hidden rounded-lg bg-slate-100 dark:bg-slate-700/60 px-3 py-2 font-mono whitespace-pre-wrap max-h-28 overflow-y-auto"></div>
`;
// Combo-Synchronisation: Dropdown ↔ Textfeld
const comboSel = wrap.querySelector(".combo-sel");
const comboInp = wrap.querySelector(".combo-inp");
if (comboSel && comboInp) {
comboSel.addEventListener("change", () => {
if (comboSel.value === "__custom__") {
comboInp.classList.remove("hidden");
comboInp.focus();
} else {
comboInp.value = comboSel.value;
comboInp.classList.add("hidden");
}
});
comboInp.addEventListener("input", () => {
const match = Array.from(comboSel.options).find((o) => o.value === comboInp.value);
comboSel.value = match ? match.value : "__custom__";
});
}
// Range-Slider: Wert live anzeigen
const rangeInp = wrap.querySelector('input[type="range"]');
if (rangeInp) {
rangeInp.addEventListener("input", () => {
wrap.querySelector(".range-val").textContent = rangeInp.value;
});
}
// Aktuellen Wert holen (aus Combo-Textfeld oder normalem Input)
function getCurrentValue() {
if (comboInp) return comboInp.value.trim();
const inp = wrap.querySelector(`#cfg-${item.key}`);
return inp ? (inp.value ?? inp.textContent ?? "").trim() : "";
}
// Feedback-Anzeige
function showFeedback(ok, msg) {
const fb = wrap.querySelector(".feedback-msg");
fb.textContent = msg;
fb.className = "feedback-msg text-xs mt-1 " + (ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-500");
setTimeout(() => { fb.className = "feedback-msg text-xs mt-1 hidden"; }, 3000);
}
// Speichern
wrap.querySelector(".save-btn").addEventListener("click", async () => {
const val = getCurrentValue();
const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val });
if (res !== null) {
showFeedback(true, "✓ Gespeichert");
item.is_overridden = true;
item.effective_value = val;
// Overrides-Badge nachrüsten falls noch nicht da
if (!wrap.querySelector(".reset-btn")) {
wrap.querySelector(".flex.items-center.gap-2").insertAdjacentHTML("beforeend",
'<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button>');
wrap.querySelector(".reset-btn").addEventListener("click", doReset);
}
} else {
showFeedback(false, "✗ Fehler beim Speichern");
}
});
// Reset
async function doReset() {
if (!confirm(`Override für „${item.label}" entfernen?\nDer .env-Wert gilt danach wieder.`)) return;
const res = await adminFetch(`/api/admin/config/${item.key}`, "DELETE");
if (res !== null) loadSettings();
}
wrap.querySelector(".reset-btn")?.addEventListener("click", doReset);
// Test
const testBtnEl = wrap.querySelector(".test-btn");
if (testBtnEl) {
testBtnEl.addEventListener("click", async () => {
const resultEl = wrap.querySelector(".test-result");
resultEl.textContent = "…";
resultEl.classList.remove("hidden");
testBtnEl.disabled = true;
try {
if (testType === "tts") {
const body = { text: "Hallo, ich bin der Sprachassistent." };
if (meta.testProvider) body.tts_provider = meta.testProvider;
const r = await fetch("/api/speak", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("HTTP " + r.status);
const buf = await r.arrayBuffer();
const view = new DataView(buf);
const n = Math.floor(buf.byteLength / 2);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const abuf = audioCtx.createBuffer(1, n, 24000);
const ch = abuf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = abuf; src.connect(audioCtx.destination); src.start();
resultEl.textContent = "▶ Audio spielt …";
src.onended = () => { resultEl.classList.add("hidden"); };
} else if (testType === "llm") {
const r = await fetch("/api/chat?debug=true", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Sag kurz Hallo.", tts_provider: "piper" }),
});
const d = await r.json();
resultEl.textContent = d.response || d.semantic || d.trace?.response || JSON.stringify(d).slice(0, 200);
} else if (testType === "stt") {
const cfg = await fetch("/api/config").then((r) => r.json());
const available = cfg.available?.stt_providers || [];
const current = item.effective_value;
resultEl.textContent = available.includes(current)
? `✓ Provider „${current}" ist verfügbar.`
: `✗ Provider „${current}" nicht in verfügbaren: ${available.join(", ")}`;
}
} catch (e) {
resultEl.textContent = "Fehler: " + e.message;
}
testBtnEl.disabled = false;
});
}
return wrap;
}
$("#load-settings").addEventListener("click", loadSettings);
// === NOTRUF-KNOPF =========================================================
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
const emergencyBtn = $("#emergency-btn");
// Standort (One-Shot) für den Notruf erfassen — nur mit Freigabe (location_consent),
// mit Timeout (hängendes GPS blockiert den Alarm nie). Liefert {ok:true,…} oder
// {ok:false, error:"…"} mit erklärbarem Grund (für Diagnose in Meldung/Log).
function captureGeo(timeoutMs = 8000) {
return new Promise((resolve) => {
if (!myPrefs.location_consent) return resolve({ ok: false, error: "Standort-Freigabe aus" });
if (!window.isSecureContext) return resolve({ ok: false, error: "kein HTTPS (insecure context)" });
if (!navigator.geolocation) return resolve({ ok: false, error: "Browser ohne Geolocation" });
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
const timer = setTimeout(() => finish({ ok: false, error: "Timeout" }), timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => {
clearTimeout(timer);
finish({
ok: true,
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy: pos.coords.accuracy,
ts: new Date(pos.timestamp).toISOString(),
});
},
(err) => { clearTimeout(timer); finish({ ok: false, error: "code " + err.code + ": " + err.message }); },
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 60000 }
);
});
}
// Geräteart/Plattform aus Browser-Daten (User-Agent Client Hints, Fallback UA-String).
function deviceInfo() {
try {
const uad = navigator.userAgentData;
if (uad) return { mobile: !!uad.mobile, platform: uad.platform || "" };
} catch (e) { /* ignore */ }
const ua = navigator.userAgent || "";
return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) };
}
// Sichtbarer Zustand am SOS-Button (Quadrat in Höhe der Sprachauswahl):
// idle (rot „SOS") | calling (rot, pulsierend) | done (grün ✓, pulsierend —
// während der Sperre) | error. Feedback über Farbe/Symbol, nicht über Wörter.
function setSosState(state) {
if (!emergencyBtn) return;
emergencyBtn.classList.remove(
"bg-red-600", "hover:bg-red-700", "bg-green-600", "hover:bg-green-700",
"animate-pulse", "text-sm", "text-lg"
);
if (state === "calling") {
emergencyBtn.textContent = "SOS"; // pulsiert = „wird gerufen“
emergencyBtn.disabled = true;
emergencyBtn.classList.add("bg-red-600", "animate-pulse", "text-sm");
} else if (state === "done") {
emergencyBtn.textContent = "✓"; // grün + Haken + pulsiert = Alarm raus
emergencyBtn.disabled = false; // klickbar -> beruhigender Hinweis
emergencyBtn.classList.add("bg-green-600", "hover:bg-green-700", "animate-pulse", "text-lg");
} else if (state === "error") {
emergencyBtn.textContent = "!";
emergencyBtn.disabled = false;
emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700", "text-lg");
} else { // idle
emergencyBtn.textContent = "SOS";
emergencyBtn.disabled = false;
emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700", "text-sm");
}
}
// ----- Wiederauslöse-Sperre (Cooldown) -----
// Nach einem Alarm bleibt der Button für die (im Admin einstellbare) Dauer grün
// und pulsierend; erneutes Drücken zeigt nur einen beruhigenden Hinweis, statt
// einen zweiten Alarm auszulösen — Schutz vor Mehrfach-Alarmen.
let sosCooldownActive = false;
let sosAlertedAtMs = 0;
let sosCooldownTimer = null;
function enterCooldown(remainingSec, minutesAgo) {
sosCooldownActive = true;
// Auslösezeitpunkt rekonstruieren, damit „vor X Minuten" stimmt (auch nach Reload).
sosAlertedAtMs = Date.now() - Math.max(0, minutesAgo || 0) * 60000;
setSosState("done");
if (sosCooldownTimer) clearTimeout(sosCooldownTimer);
if (remainingSec > 0) sosCooldownTimer = setTimeout(exitCooldown, remainingSec * 1000);
else exitCooldown();
}
function exitCooldown() {
sosCooldownActive = false;
sosCooldownTimer = null;
setSosState("idle");
}
function sosMinutesAgo() {
return Math.max(0, Math.floor((Date.now() - sosAlertedAtMs) / 60000));
}
async function runEmergency() {
const lang = (langSel && langSel.value) || "de";
setSosState("calling");
try {
const g = await captureGeo();
const body = { language: lang, device: deviceInfo() };
if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts };
else body.geo_error = g.error;
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (data && data.notify_ready) sosNotifyReady = !!data.notify_ready.any;
if (data && data.status === "cooldown") {
// Server hat den (Doppel-)Alarm unterdrückt -> nur beruhigen, kein neuer Alarm.
enterCooldown(data.remaining_seconds || 0, data.minutes_ago || 0);
openSosCooldown();
return;
}
const notice = (data && data.notice) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
addMessage("emergency", "🆘 " + notice);
if (data && data.status === "alerted") {
const cd = data.cooldown_seconds || 0;
if (cd > 0) {
enterCooldown(cd, 0); // grün/pulsierend für die Sperre
} else {
setSosState("done"); // Sperre aus -> nur kurzes Erfolgs-Feedback
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 6000);
}
} else {
// no_recipients / delivery_failed: es ging NICHTS raus -> KEIN grünes Erfolgssignal,
// sondern roter Fehlerzustand; der Hinweis nennt die Selbsthilfe (112).
setSosState("error");
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 8000);
}
} catch (e) {
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
setSosState("error");
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 4000);
}
}
// Ist (serverseitig) überhaupt ein Benachrichtigungskanal nutzbar? null = noch
// unbekannt; erst bei sicherem `false` zeigen wir die Vorab-Warnung im Dialog.
let sosNotifyReady = null;
// Großer eigener Bestätigungsdialog statt native confirm() — seniorentauglich.
const sosConfirm = $("#sos-confirm");
function openSosConfirm() {
const warn = $("#sos-confirm-warn");
if (warn) warn.classList.toggle("hidden", sosNotifyReady !== false);
if (sosConfirm) sosConfirm.classList.remove("hidden");
}
function closeSosConfirm() { if (sosConfirm) sosConfirm.classList.add("hidden"); }
// Beruhigender grüner Hinweis, wenn während der Sperre erneut gedrückt wird.
const sosCooldownDlg = $("#sos-cooldown");
function openSosCooldown() {
const m = sosMinutesAgo();
const when = m < 1 ? "gerade eben" : ("vor " + m + (m === 1 ? " Minute" : " Minuten"));
const txtEl = $("#sos-cooldown-text");
if (txtEl) txtEl.textContent =
"Ich habe Deine Helfer " + when + " alarmiert. " +
"Wahrscheinlich ist Deine Hilfe schon unterwegs.";
if (sosCooldownDlg) sosCooldownDlg.classList.remove("hidden");
}
function closeSosCooldown() { if (sosCooldownDlg) sosCooldownDlg.classList.add("hidden"); }
// Beim Laden den Sperr-Status vom Server holen, damit der grüne Zustand auch
// ein Seiten-Neuladen übersteht (verwirrte Senioren laden gern neu).
async function initSosCooldown() {
try {
const res = await fetch("/api/emergency/status", { headers: { Accept: "application/json" } });
if (!res.ok) return;
const s = await res.json();
if (s && s.notify_ready) sosNotifyReady = !!s.notify_ready.any;
if (s && s.active) enterCooldown(s.remaining_seconds || 0, s.minutes_ago || 0);
} catch (e) { /* ignore */ }
}
if (emergencyBtn) {
emergencyBtn.addEventListener("click", () => {
if (sosCooldownActive) openSosCooldown(); // schon alarmiert -> nur beruhigen
else openSosConfirm(); // sonst Notruf bestätigen
});
}
const sosYesBtn = $("#sos-confirm-yes");
const sosNoBtn = $("#sos-confirm-no");
if (sosYesBtn) sosYesBtn.addEventListener("click", () => { closeSosConfirm(); runEmergency(); });
if (sosNoBtn) sosNoBtn.addEventListener("click", closeSosConfirm);
const sosCooldownOkBtn = $("#sos-cooldown-ok");
if (sosCooldownOkBtn) sosCooldownOkBtn.addEventListener("click", closeSosCooldown);
// Klick auf den Hintergrund oder Escape schließt die Dialoge (kein Alarm).
if (sosConfirm) sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); });
if (sosCooldownDlg) sosCooldownDlg.addEventListener("click", (e) => { if (e.target === sosCooldownDlg) closeSosCooldown(); });
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm();
if (sosCooldownDlg && !sosCooldownDlg.classList.contains("hidden")) closeSosCooldown();
});