"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"); // GewΓ€hlter TTS-Provider (leer = Default/piper) als Request-Override. function overrides() { const t = ttsSel && ttsSel.value; return t ? { tts_provider: t } : {}; } 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; // ---------- 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(); sessionId = "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"); } 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", }; function addMessage(role, text) { const div = document.createElement("div"); div.className = BUBBLE[role] || BUBBLE.assistant; div.textContent = text; messagesEl.appendChild(div); scrollToBottom(); return div; } // ---------- Barge-in-Hilfsfunktionen ---------- function stopAudio() { activeSources.forEach((s) => { try { s.stop(); } catch (e) {} }); activeSources = []; } 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) { if (!chunks.length) 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)(); 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); }; 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; 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 "transcript": if (msg.text) addMessage("user", msg.text); break; case "token": if (!answerEl) answerEl = addMessage("assistant", ""); answerEl.textContent += msg.text || ""; scrollToBottom(); break; case "semantic": if (!answerEl) answerEl = addMessage("assistant", ""); if (msg.text) answerEl.textContent = msg.text; scrollToBottom(); break; case "emergency": addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")"); break; case "done": sampleRate = msg.sample_rate || 24000; 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(); addMessage("user", text); statusEl.textContent = "denkt …"; await runTurn("/ws/chat", (ws) => { ws.send(JSON.stringify({ text, stream: true, audio_stream: true, ...overrides() })); }); busy = false; setMicIdle(); } formEl.addEventListener("submit", (e) => { e.preventDefault(); 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 …"; await runTurn("/ws/voice", (ws) => { ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: true, ...overrides() })); ws.send(bytes); ws.send(JSON.stringify({ type: "end" })); }); busy = false; setMicIdle(); } micBtn.addEventListener("click", () => { 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); // 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)); 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 = `
${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 // ════════════════════════════════════════ async function loadStatus() { const container = $("#status-content"); container.innerHTML = '

    lade …

    '; try { const [cfg, met] = await Promise.all([ fetch("/api/config").then((r) => r.json()), fetch("/api/metrics").then((r) => r.json()), ]); 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)}

    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
    `; } 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.

    '}
    `; wrap.querySelectorAll(".del-btn").forEach((btn) => btn.addEventListener("click", async () => { 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(); }) ); wrap.querySelector(".add-form").addEventListener("submit", async (e) => { e.preventDefault(); const [keyInp, valInp] = e.target.querySelectorAll("input"); const key = keyInp.value.trim(); const value = valInp.value.trim(); if (!key || !value) return; const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value }); if (res !== null) { keyInp.value = ""; valInp.value = ""; loadWoerterbuch(); } }); 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","de_DE-kerstin-low","de_DE-karlsson-low"], 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 = `

    ${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);