- DELETE /api/admin/user-data: loescht fuer ALLE Nutzer Verlauf, Statistik (usage/web_search_usage/cost_usage) und Erinnerungen; Konten bleiben. Gefahrenzone-Knopf mit doppelter Bestaetigung, audit-geloggt. - list_users jetzt ORDER BY LOWER(display_name) -> Nutzerkarten alphabetisch (ohne Gross-/Kleinschreibung). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2634 lines
124 KiB
JavaScript
2634 lines
124 KiB
JavaScript
"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: 0–60 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 "notice":
|
||
// Kleine System-Notiz (z. B. Web-Suche aus / Kontingent aufgebraucht).
|
||
if (msg.text) addMessage("system", msg.text, { lang: turnLang() });
|
||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
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();
|
||
}
|
||
});
|
||
|
||
// Alle Nutzerdaten löschen (Konten bleiben) — doppelte Sicherheitsabfrage.
|
||
$("#btn-wipe-all").addEventListener("click", async () => {
|
||
if (!confirm("Wirklich für ALLE Nutzer Verlauf, Statistik und Erinnerungen löschen?\nDie Konten bleiben erhalten. Dies kann nicht rückgängig gemacht werden.")) return;
|
||
if (!confirm("Letzte Warnung: Alle Nutzerdaten werden jetzt gelöscht. Fortfahren?")) return;
|
||
const res = await adminFetch("/api/admin/user-data", "DELETE");
|
||
if (res) {
|
||
alert(`Gelöscht: ${res.messages ?? 0} Nachrichten, ${res.sessions ?? 0} Sessions, ${res.stats ?? 0} Statistik-Zeilen, ${res.memories ?? 0} Erinnerungen.`);
|
||
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-stats 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">Statistik zurücksetzen</button>
|
||
<button class="btn-mem-clear 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">Erinnerungen 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>
|
||
|
||
<!-- Web-Suche (Weg 2) -->
|
||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||
<label class="text-xs text-slate-600 dark:text-slate-300 flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" class="ws-enabled" /> Web-Suche für aktuelle Fragen erlauben
|
||
<span class="ws-status text-slate-400"></span>
|
||
</label>
|
||
<div class="mt-1.5 flex items-center gap-2">
|
||
<span class="text-xs text-slate-500 dark:text-slate-400">max. Suchen pro Tag:</span>
|
||
<input type="number" min="0" class="ws-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
|
||
<span class="ws-limit-status text-xs text-slate-400"></span>
|
||
</div>
|
||
<div class="mt-1.5 flex items-center gap-2">
|
||
<span class="text-xs text-slate-500 dark:text-slate-400">max. Kosten pro Tag ($):</span>
|
||
<input type="number" min="0" step="0.01" class="cost-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
|
||
<span class="cost-limit-status text-xs text-slate-400"></span>
|
||
</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;
|
||
|
||
// Web-Suche pro Nutzer: sofort speichern bei Änderung
|
||
const wsEl = card.querySelector(".ws-enabled");
|
||
const wsStatus = card.querySelector(".ws-status");
|
||
if (wsEl) {
|
||
wsEl.checked = !!u.web_search_enabled;
|
||
wsEl.addEventListener("change", async () => {
|
||
if (wsStatus) wsStatus.textContent = "speichere …";
|
||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { web_search_enabled: wsEl.checked });
|
||
if (wsStatus) {
|
||
wsStatus.textContent = res ? "✓" : "Fehler";
|
||
setTimeout(() => { wsStatus.textContent = ""; }, 2000);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Web-Such-Tagesbudget (leer/0 = unbegrenzt) — sofort speichern
|
||
const wsLimit = card.querySelector(".ws-limit");
|
||
const wsLimitStatus = card.querySelector(".ws-limit-status");
|
||
if (wsLimit) {
|
||
const lim = Number(u.web_search_daily_limit);
|
||
wsLimit.value = Number.isFinite(lim) && lim > 0 ? lim : "";
|
||
wsLimit.addEventListener("change", async () => {
|
||
const n = parseInt(wsLimit.value, 10);
|
||
const val = Number.isFinite(n) && n > 0 ? n : 0; // leer/0 = unbegrenzt
|
||
if (wsLimitStatus) wsLimitStatus.textContent = "speichere …";
|
||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { web_search_daily_limit: val });
|
||
if (wsLimitStatus) {
|
||
wsLimitStatus.textContent = res ? "✓" : "Fehler";
|
||
setTimeout(() => { wsLimitStatus.textContent = ""; }, 2000);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Kosten-Tagesbudget in $ (leer/0 = unbegrenzt) — sofort speichern
|
||
const costLimit = card.querySelector(".cost-limit");
|
||
const costLimitStatus = card.querySelector(".cost-limit-status");
|
||
if (costLimit) {
|
||
const lim = Number(u.cost_daily_limit_usd);
|
||
costLimit.value = Number.isFinite(lim) && lim > 0 ? lim : "";
|
||
costLimit.addEventListener("change", async () => {
|
||
const n = parseFloat(costLimit.value);
|
||
const val = Number.isFinite(n) && n > 0 ? n : 0; // leer/0 = unbegrenzt
|
||
if (costLimitStatus) costLimitStatus.textContent = "speichere …";
|
||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { cost_daily_limit_usd: val });
|
||
if (costLimitStatus) {
|
||
costLimitStatus.textContent = res ? "✓" : "Fehler";
|
||
setTimeout(() => { costLimitStatus.textContent = ""; }, 2000);
|
||
}
|
||
});
|
||
}
|
||
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).`);
|
||
});
|
||
|
||
// Statistik zurücksetzen (Anfragen, Web-Suchen, Kosten)
|
||
card.querySelector(".btn-stats").addEventListener("click", async () => {
|
||
if (!confirm(`Statistik von „${u.display_name}" zurücksetzen?\nAnfragen, Web-Suchen und Kosten werden auf 0 gesetzt; Konto, Verlauf und Erinnerungen bleiben.`)) return;
|
||
const res = await adminFetch(`/api/admin/users/${uid}/stats`, "DELETE");
|
||
if (res) alert(`Statistik zurückgesetzt (${res.deleted_rows ?? 0} Zeilen).`);
|
||
});
|
||
|
||
// Erinnerungen löschen (alle)
|
||
card.querySelector(".btn-mem-clear").addEventListener("click", async () => {
|
||
if (!confirm(`Alle Erinnerungen von „${u.display_name}" löschen?\nKonto, Verlauf und Statistik bleiben.`)) return;
|
||
const res = await adminFetch(`/api/admin/users/${uid}/memories`, "DELETE");
|
||
if (res) alert(`Erinnerungen gelöscht (${res.deleted_memories ?? 0}).`);
|
||
});
|
||
|
||
// 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 totalWS = data.reduce((s, u) => s + (u.total_web_searches || 0), 0);
|
||
const totalCost = data.reduce((s, u) => s + (u.month_cost_usd || 0), 0);
|
||
const totalEstimated = data.reduce((s, u) => s + (u.month_cost_estimated_usd || 0), 0);
|
||
const fmtUsd = (n) => "$" + (Number(n) || 0).toFixed(4);
|
||
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">mit Websuche</th>
|
||
<th class="text-right px-4 py-2.5">Kosten (Monat)</th>
|
||
<th class="text-right px-4 py-2.5">Ø/Anfrage</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_web_searches ?? 0}</td>
|
||
<td class="px-4 py-2.5 text-right font-mono text-xs" title="davon geschätzt (TTS/Notruf/Strom): ${fmtUsd(u.month_cost_estimated_usd)}">${fmtUsd(u.month_cost_usd)}</td>
|
||
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.month_requests ? fmtUsd((u.month_cost_usd || 0) / u.month_requests) : "—"}</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 text-right font-mono">${totalWS}</td>
|
||
<td class="px-4 py-2 text-right font-mono">${fmtUsd(totalCost)}</td>
|
||
<td class="px-4 py-2"></td>
|
||
<td class="px-4 py-2"></td>
|
||
<td class="px-4 py-2"></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
<p class="text-xs text-slate-400 mb-4">Kosten = laufender Monat (USD). Exakt via OpenRouter (abgleichbar): <span class="font-mono">${fmtUsd(totalCost - totalEstimated)}</span> · geschätzt (TTS/Notruf/Strom): <span class="font-mono">${fmtUsd(totalEstimated)}</span>.</p>
|
||
|
||
<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();
|
||
});
|