"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" }; // "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. // 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", 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(); refreshDevicePreset(); }); setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt } 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; } // 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; } // 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()) { speechSynthesis.cancel(); speechSynthesis.speak(_makeUtterance(text, lang)); } else { serverSpeakFallback(text, lang, null); // keine Stimmen -> 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()) { 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: 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 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 } refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI } // Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch, // sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die // gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben. function refreshDevicePreset() { const btn = document.querySelector('.tts-preset[data-tts="device"]'); if (!btn || !ttsSel) return; const ready = deviceVoicesReady(); btn.disabled = !ready; btn.classList.toggle("opacity-50", !ready); btn.classList.toggle("cursor-not-allowed", !ready); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = ready ? "Das Gerät liest vor · spart Daten" : "keine Sprachstimmen im Browser"; if (ready) { if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") { ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren } } else if (ttsSel.value === "device") { ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt) } syncPresetUI(); } // Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist. async function refreshChatterboxPreset() { const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.chatterbox_reachable === true; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "Chatterbox · natürliche Stimme" : "Chatterbox · kein GPU-Backend erreichbar"; if (!available && ttsSel.value === "chatterbox") { ttsSel.value = "piper"; syncPresetUI(); } } catch (e) { /* ignorieren */ } } // Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt. async function refreshCartesiaPreset() { const btn = document.querySelector('.tts-preset[data-tts="cartesia"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.cartesia_available === true; cartesiaUnsupportedLangs = cfg.available?.cartesia_unsupported_languages || []; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "Cartesia Sonic · natürlich & schnell" : "Cartesia · kein API-Key konfiguriert"; if (!available && ttsSel.value === "cartesia") { ttsSel.value = "piper"; syncPresetUI(); } updateCartesiaLangHint(); } catch (e) { /* ignorieren */ } } // 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"); } } // Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist. async function refreshOpenrouterTtsPreset() { const btn = document.querySelector('.tts-preset[data-tts="openrouter"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.openrouter_tts_available === true; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "OpenRouter · benötigt Internet" : "OpenRouter · kein TTS-Modell verfügbar"; if (!available && ttsSel.value === "openrouter") { ttsSel.value = "piper"; syncPresetUI(); } } catch (e) { /* ignorieren */ } } // 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"); } 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) // 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 || "de"); } applyPlaybackChoice(prefs.tts_provider); applyGenderChoice(prefs.voice_gender || 'f'); 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.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; } // ---------- 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(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.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(); }); // ---------- 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(); refreshChatterboxPreset(); refreshCartesiaPreset(); refreshOpenrouterTtsPreset(); // ═══════════════════════════════════════════════════════════════ // 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("overview"); } $("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden")); // ---------- Tab-Struktur: 5 Bereiche, je mit 1+ Abschnitten ---------- // section = vorhandener Content-Block (#admin-tab-
); Bereiche mit mehreren // Abschnitten bekommen eine Sub-Navigation. const ADMIN_TABS = { overview: [["overview", "Übersicht"]], users: [["users", "Verwalten"], ["chat", "Gespräche"]], emergency: [["emergency", "Notfälle"]], system: [["status", "Status"], ["metrics", "Metriken"], ["log", "Log"]], config: [["settings", "Einstellungen"], ["words", "Wörterbuch"]], }; const SECTION_LOADER = { overview: () => loadOverview(), users: () => loadAdminUsers(), chat: () => loadChatUsers(), emergency: () => loadEmergencyEvents(), status: () => loadStatus(), metrics: () => loadMetrics(), words: () => loadWoerterbuch(), settings: () => loadSettings(), 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]) => `` ).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 = '

lade …

'; 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" : ""}">

${escHtml(label)}

${escHtml(value)}

${sub ? `

${escHtml(sub)}

` : ""} `; const jump = (to, label) => ``; c.innerHTML = `
${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" })}

GPU-Auslastung

${escHtml(gpu)}

${jump("users", "→ Nutzer")} ${jump("system", "→ System")} ${jump("emergency", "→ Notfälle")}
`; 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 = '

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 = `
${escHtml(u.display_name)} ${badgeSSO}

${u.user_id.slice(0, 8)}…${u.external_id ? " · " + escHtml(u.external_id) : ""}

seit ${fmtDate(u.created_at)}

`; const uid = u.user_id; // 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 = data.token; card.querySelector(".token-box").classList.remove("hidden"); } }); // 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 = '

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 …
  • '; 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 = `${escHtml(u.display_name)} ${u.user_id.slice(0,8)}…`; 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 = `

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

    `; 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 = `

    ${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.

    `; panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser)); return; } const bubbles = msgs.map((m) => { const isUser = m.role === "user"; return `
    ${escHtml(m.content)}
    `; }).join(""); panel.innerHTML = `
    ${escHtml(sessionId)}
    ${bubbles}
    `; panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser)); } // ════════════════════════════════════════ // TAB: NOTFÄLLE // ════════════════════════════════════════ async function loadEmergencyEvents() { const container = $("#emergency-content"); container.innerHTML = '

    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 = `
    ${events.map((ev) => ` `).join("")}
    Zeitpunkt Nutzer Kategorie Textausschnitt
    ${fmtDate(ev.created_at)} ${escHtml(ev.display_name)} ${escHtml(ev.category)} ${escHtml(ev.snippet)}
    `; } // ════════════════════════════════════════ // TAB: STATUS // ════════════════════════════════════════ // LLM-/GPU-Status-Karte (read-only) für den Status-Tab. function renderLlmCard(llm) { if (!llm) return ""; const backendLabel = { ollama: "Ollama", llamacpp: "llama.cpp", unknown: "—" }[llm.backend] || llm.backend; const dot = (on) => ``; const row = (label, val) => `
    ${label}
    ${escHtml(val ?? "—")}
    `; const loaded = (llm.ollama_loaded || []).map((m) => `${escHtml(m.name)} (${escHtml(m.processor || "")})` ).join(", ") || "—"; const gpuBars = (llm.gpus || []).map((g) => `
    GPU ${g.index}
    ${g.used_mib} / ${g.total_mib} MiB
    `).join("") || '

    keine GPU-Daten (nvidia-smi)

    '; return `
    ${dot(true)}

    LLM-Backend

    ${escHtml(backendLabel)}
    ${row("Modell", llm.model)} ${row("URL", llm.base_url)} ${row("Ollama erreichbar", llm.ollama_reachable ? "ja" : "nein")} ${row("llama.cpp läuft", llm.llamacpp_running ? "ja" : "nein")} ${row("Geladene Modelle", loaded)} ${row("Gateway-Dienst", llm.gateway_service_active ? "aktiv (systemd)" : "Vordergrund/aus")}
    ${gpuBars}

    Hinweis: Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft (sonst .env/Backend umgestellt, aber Gateway manuell neu starten).

    `; } // 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 wireLlmControls() { const sel = $("#llm-backend-sel"); const switchBtn = $("#llm-switch-btn"); const restartBtn = $("#gw-restart-btn"); const msg = $("#llm-ctrl-msg"); if (!switchBtn) return; switchBtn.addEventListener("click", async () => { const backend = sel.value; const model = ($("#llm-model-inp").value || "").trim(); if (!confirm(`Backend auf „${backend}"${backend === "ollama" && model ? " (" + model + ")" : ""} umstellen?\nDie GPU des anderen Backends wird freigegeben.`)) return; switchBtn.disabled = true; msg.textContent = "stelle um …"; const body = { backend }; if (backend === "ollama" && model) body.model = model; const res = await adminFetch("/api/admin/llm/backend", "POST", body); if (res === null) { msg.textContent = "Fehler (Modell/Backend ungültig?)"; switchBtn.disabled = false; return; } pollGatewayBack(msg, "Backend gewechselt"); }); 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 = '

    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) => `
    ${label}
    ${escHtml(val || "—")}
    `; container.innerHTML = `

    Gateway

    läuft
    ${providerRow("Profil", cfg.profile)} ${providerRow("Umgebung", cfg.app_env)}

    Provider (Standard-Route)

    ${providerRow("STT", route.stt_provider)} ${providerRow("LLM", route.llm_provider)} ${providerRow("TTS", route.tts_provider)} ${providerRow("Sprache", route.language)}
    ${renderLlmCard(llm)}

    Laufzeit-Metriken

    ${providerRow("Turns gesamt", turnsTotal)} ${providerRow("HTTP-Anfragen", httpTotal)}

    Verfügbare Provider

    ${providerRow("STT", (cfg.available?.stt_providers || []).join(", "))} ${providerRow("LLM", (cfg.available?.llm_providers || []).join(", "))} ${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}

    Datenbank-Backup

    SQLite-Datenbank als Datei herunterladen (Backup / Migration).

    ⬇ voice-assistant.db herunterladen
    `; wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern) } catch (e) { console.error("loadStatus error:", e); container.innerHTML = '

    Fehler 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 `
    ${escHtml(u.display_name)}
    ${u.total_requests ?? 0}
    `; }).join(""); container.innerHTML = `
    ${data.map((u) => ` `).join("")}
    Nutzer Anfragen gesamt Einheiten Letzte Aktivität
    ${escHtml(u.display_name)} ${u.total_requests ?? 0} ${u.total_units ?? 0} ${u.last_active || "—"}
    Gesamt ${totalReq}

    Anfragen je Nutzer

    ${bars}
    `; } // ════════════════════════════════════════ // TAB: WÖRTERBUCH // ════════════════════════════════════════ async function loadWoerterbuch() { const lang = ($("#words-lang") || {}).value || "de"; const container = $("#words-content"); container.innerHTML = '

    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]) => ` ${escHtml(k)} → ${escHtml(v)} `).join(""); wrap.innerHTML = `

    ${escHtml(title)} ${Object.keys(entries).length} Einträge

    ${rows ? `
    ${rows}
    ` : '

    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","pt","pl","ar"], labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } }, 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","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","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: "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_top_p","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 = `

    ${escHtml(group.label)}

    `; section.appendChild(header); items.forEach((item, idx) => { const row = buildSettingRow(item); if (idx > 0) row.classList.add("border-t", "border-slate-100", "dark:border-slate-700/60"); section.appendChild(row); }); container.appendChild(section); }); } 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) => `` ).join(""); return ``; } if (meta.ui === "combo") { const inList = meta.opts.includes(val); const labels = meta.labels || {}; const options = meta.opts.map((o) => `` ).join(""); const customSel = !inList ? 'selected' : ''; return `
    `; } if (meta.ui === "range") { return `
    ${escHtml(val)}
    `; } if (meta.ui === "number") { const extra = meta.max !== undefined ? `max="${meta.max}"` : ""; return ``; } if (meta.ui === "textarea") { return ``; } return ``; } 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 ? `` : ""; const resetBtn = overridden ? `` : ""; wrap.innerHTML = `
    ${escHtml(item.label)} ${overridden ? 'überschrieben' : ""} ${resetBtn}
    ${_buildControl(item, meta)}
    ${testBtn}
    ${envName} · .env: ${escHtml(item.base_value) || "—"} · ${escHtml(item.hint)}
    `; // 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", ''); 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);