"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"); // "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern // automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt). // Beide internen Felder (language, language_mode) werden aus dieser einen Auswahl abgeleitet. function langOverrides() { const sel = langSel && langSel.value; if (!sel || sel === "flex") return { language_mode: "flex" }; return { language: sel, language_mode: "fix" }; } // Nutzer-Präferenzen als Request-Overrides für jeden Turn. 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; } 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", 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(); speechSynthesis.addEventListener("voiceschanged", loadVoices); } function pickVoice(bcp) { if (!_voices.length) loadVoices(); const lc = bcp.toLowerCase(); const two = lc.slice(0, 2); return _voices.find((v) => v.lang && v.lang.toLowerCase() === lc) || _voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(two)) || null; } function _makeUtterance(text, lang) { const u = new SpeechSynthesisUtterance(text); u.lang = LANG_BCP47[lang] || lang || "de-DE"; const v = pickVoice(u.lang); if (v) u.voice = v; return u; } // Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort). function deviceSpeak(text, lang) { if (!TTS_SUPPORTED || !text) return; speechSynthesis.cancel(); speechSynthesis.speak(_makeUtterance(text, lang)); } // Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück. function deviceSpeakBtn(text, lang, btn) { if (!TTS_SUPPORTED || !text) { setReplayPlaying(btn, false); return; } speechSynthesis.cancel(); const u = _makeUtterance(text, lang); u.onend = u.onerror = () => setReplayPlaying(btn, false); setReplayPlaying(btn, true); speechSynthesis.speak(u); } // iOS: speechSynthesis darf nur nach einer Nutzergeste starten -> beim ersten Tap freischalten. let _ttsUnlocked = false; function unlockTTS() { if (_ttsUnlocked || !TTS_SUPPORTED) return; try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} } const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt // Setzt das Qualität-Dropdown beim Laden. Vorrang: // 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät. function applyPlaybackChoice(serverProvider) { if (!ttsSel) return; // "Gerät"-Option ausblenden, wenn der Browser keine Web Speech API hat. if (!TTS_SUPPORTED) { const opt = ttsSel.querySelector('option[value="device"]'); if (opt) opt.remove(); } 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"); } } let busy = false; let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar) let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in) // 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 currentUserId = null; const sessionKey = (uid) => "va-session-" + uid; // ---------- 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 || {}; if (langSel) { // Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex). langSel.value = prefs.language_mode === "flex" ? "flex" : (prefs.language || "flex"); } applyPlaybackChoice(prefs.tts_provider); 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 = ''; const ICON_STOP = ''; // 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.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; } // ---------- 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 = []; 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(); } // ---------- 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; // Sprache dieses Turns (für den Replay je Bubble): Flex folgt erkannter Sprache. let routeLang = null, routeMode = null, detectedLang = null; const turnLang = () => (routeMode === "flex" ? (detectedLang || routeLang) : 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") { pcm.push(event.data); return; } let msg; try { msg = JSON.parse(event.data); } catch { return; } switch (msg.type) { case "ack": routeLang = (msg.route && msg.route.language) || null; routeMode = (msg.route && msg.route.language_mode) || null; break; case "transcript": detectedLang = msg.detected_language || null; 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; // 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; case "emergency": addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")"); 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 playPcm(pcm, sampleRate); } statusEl.textContent = ""; scrollToBottom(); ws.close(); break; case "error": addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt")); ws.close(); break; case "interrupted": statusEl.textContent = ""; ws.close(); // → ws.onclose → resolve() → busy=false → setMicIdle() break; } }; }); } // ---------- Text senden ---------- async function sendText(text) { if (busy || !text.trim()) return; busy = true; setMicBusy(); await ensureSession(); // Bei fester Sprache wird der eigene Text in dieser Sprache vorgelesen; bei Flex offen. const lo = langOverrides(); addMessage("user", text, { lang: lo.language_mode === "fix" ? 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() { 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 = "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.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(); } }); $("#admin-toggle").addEventListener("click", 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", newConversation); // Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt. const themeBtn = $("#theme-toggle"); const setThemeIcon = () => (themeBtn.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())); 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 } }); } loadMe(); // ═══════════════════════════════════════════════════════════════ // ADMIN PANEL // ═══════════════════════════════════════════════════════════════ // ---------- Hilfsfunktionen ---------- function escHtml(str) { if (str === null || str === undefined || str === "") return ""; return String(str).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("users"); } $("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden")); // ---------- Tab-Umschalter ---------- function switchAdminTab(name) { document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden")); $("#admin-tab-" + name).classList.remove("hidden"); document.querySelectorAll(".admin-tab").forEach((btn) => { const active = btn.dataset.tab === name; 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); }); if (name === "users") loadAdminUsers(); if (name === "chat") loadChatUsers(); if (name === "emergency") loadEmergencyEvents(); if (name === "status") loadStatus(); if (name === "metrics") loadMetrics(); if (name === "words") loadWoerterbuch(); if (name === "log") { /* User muss manuell verbinden */ } if (name === "settings") loadSettings(); } document.querySelectorAll(".admin-tab").forEach((btn) => btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab)) ); $("#load-emergency").addEventListener("click", loadEmergencyEvents); $("#load-status").addEventListener("click", loadStatus); $("#load-metrics").addEventListener("click", loadMetrics); // ════════════════════════════════════════ // TAB: NUTZER // ════════════════════════════════════════ async function loadAdminUsers() { const container = $("#admin-user-cards"); container.innerHTML = '
lade …
'; const users = await adminFetch("/api/admin/users"); if (!users) { container.innerHTML = 'Fehler beim Laden.
'; return; } if (!users.length) { container.innerHTML = 'Noch keine Nutzer.
'; 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 = data.token; $("#new-token-box").classList.remove("hidden"); nameEl.value = ""; loadAdminUsers(); } }); function buildUserCard(u) { const card = document.createElement("div"); card.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"; const badgeSSO = u.external_id ? `SSO` : ""; card.innerHTML = `${u.user_id.slice(0, 8)}…${u.external_id ? " · " + escHtml(u.external_id) : ""}
seit ${fmtDate(u.created_at)}
lade …
'; const mems = await adminFetch(`/api/admin/users/${uid}/memories`); if (!mems?.length) { container.innerHTML = 'Keine Erinnerungen vorhanden.
'; 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 = ` ${escHtml(m.content)} `; 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 = 'lade Sessions für ${escHtml(u.display_name)} …
`; const sessions = await adminFetch(`/api/admin/users/${u.user_id}/sessions`); if (!sessions?.length) { panel.innerHTML = 'Keine Sessions gefunden.
'; return; } panel.innerHTML = `Sessions · ${escHtml(u.display_name)}
${escHtml(s.id)}
${fmtDate(s.updated_at)} · ${s.msg_count} Nachrichten
`; row.addEventListener("click", () => loadTranscript(s.id)); list.appendChild(row); }); } async function loadTranscript(sessionId) { const panel = $("#chat-right-panel"); panel.innerHTML = `lade Transkript …
`; const msgs = await adminFetch(`/api/admin/sessions/${sessionId}/messages`); if (!msgs?.length) { panel.innerHTML = `Keine Nachrichten.
lade …
'; const events = await adminFetch("/api/admin/emergency-events"); if (!events) { container.innerHTML = 'Fehler beim Laden.
'; return; } if (!events.length) { container.innerHTML = 'Keine Notfall-Ereignisse protokolliert.
'; return; } container.innerHTML = `| Zeitpunkt | Nutzer | Kategorie | Textausschnitt |
|---|---|---|---|
| ${fmtDate(ev.created_at)} | ${escHtml(ev.display_name)} | ${escHtml(ev.category)} | ${escHtml(ev.snippet)} |
keine GPU-Daten (nvidia-smi)
'; return `lade …
'; try { const [cfg, met, llm] = await Promise.all([ fetch("/api/config").then((r) => r.json()), fetch("/api/metrics").then((r) => r.json()), adminFetch("/api/admin/llm/status"), ]); 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) => `SQLite-Datenbank als Datei herunterladen (Backup / Migration).
⬇ voice-assistant.db herunterladenFehler beim Laden.
'; } } // ════════════════════════════════════════ // TAB: METRIKEN // ════════════════════════════════════════ async function loadMetrics() { const container = $("#metrics-content"); container.innerHTML = 'lade …
'; const data = await adminFetch("/api/admin/usage"); if (!data) { container.innerHTML = 'Fehler beim Laden.
'; return; } if (!data.length) { container.innerHTML = 'Noch keine Nutzungsdaten vorhanden.
'; return; } const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); const bars = data.map((u) => { const pct = Math.round(((u.total_requests || 0) / maxReq) * 100); return `| Nutzer | Anfragen gesamt | Einheiten | Letzte Aktivität |
|---|---|---|---|
| ${escHtml(u.display_name)} | ${u.total_requests ?? 0} | ${u.total_units ?? 0} | ${u.last_active || "—"} |
| Gesamt | ${totalReq} |
lade …
'; const data = await adminFetch(`/api/admin/pronunciation/${lang}`); if (!data) { container.innerHTML = 'Fehler beim Laden.
'; 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]) => `Keine Einträge.
'} `; 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"], labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch" } }, openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" }, openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], 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","en_US-ryan-high","en_US-lessac-high","en_GB-cori-high","es_ES-sharvard-medium","fr_FR-siwis-medium","it_IT-paola-medium","nl_NL-mls-medium","ru_RU-irina-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_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: "Provider & Sprache", keys: ["default_stt_provider","default_llm_provider","default_tts_provider","default_language"] }, { label: "OpenRouter-Modelle & Stimmen", keys: ["openrouter_llm_model","openrouter_tts_model","openrouter_tts_voice"] }, { label: "Piper TTS", keys: ["piper_voice"] }, { label: "Lokales LLM", keys: ["local_llm_system_prompt","local_llm_temperature","local_llm_max_tokens"] }, { label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] }, { label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] }, { label: "Limits", keys: ["daily_request_limit"] }, ]; let _settingsData = []; async function loadSettings() { const container = $("#settings-content"); container.innerHTML = 'lade …
'; const data = await adminFetch("/api/admin/config"); if (!data) { container.innerHTML = 'Fehler beim Laden.
'; 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 header = document.createElement("div"); header.className = "px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60"; header.innerHTML = `