my_voice_assistant_v3/app/web/app.js

1253 lines
56 KiB
JavaScript
Raw Normal View History

"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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmtDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
async function adminFetch(url, method = "GET", body = null) {
try {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (method === "DELETE" && res.status === 204) return {};
const data = await res.json().catch(() => null);
if (!res.ok) { console.error("Admin API", res.status, data); return null; }
return data;
} catch (e) { console.error("adminFetch", e); return null; }
}
// ---------- Panel öffnen / schließen ----------
function openAdminPanel() {
$("#admin-panel").classList.remove("hidden");
switchAdminTab("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 = '<p class="text-sm text-slate-400 text-center py-4">lade …</p>';
const users = await adminFetch("/api/admin/users");
if (!users) { container.innerHTML = '<p class="text-sm text-red-500 text-center py-4">Fehler beim Laden.</p>'; return; }
if (!users.length) { container.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">Noch keine Nutzer.</p>'; return; }
container.innerHTML = "";
users.forEach((u) => container.appendChild(buildUserCard(u)));
}
$("#create-user-form").addEventListener("submit", async (e) => {
e.preventDefault();
const nameEl = $("#new-user-name");
const name = nameEl.value.trim();
if (!name) return;
const data = await adminFetch("/api/admin/users", "POST", { display_name: name });
if (data?.token) {
$("#new-token-value").textContent = data.token;
$("#new-token-box").classList.remove("hidden");
nameEl.value = "";
loadAdminUsers();
}
});
function buildUserCard(u) {
const card = document.createElement("div");
card.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
const badgeSSO = u.external_id
? `<span class="rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs px-2 py-0.5">SSO</span>` : "";
card.innerHTML = `
<div class="flex items-start gap-3 flex-wrap">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-0.5">
<span class="font-semibold text-slate-900 dark:text-slate-100 user-dn">${escHtml(u.display_name)}</span>
${badgeSSO}
</div>
<p class="text-xs text-slate-400 font-mono">${u.user_id.slice(0, 8)}${u.external_id ? " · " + escHtml(u.external_id) : ""}</p>
<p class="text-xs text-slate-400">seit ${fmtDate(u.created_at)}</p>
</div>
<div class="flex flex-wrap gap-1.5">
<button class="btn-rename text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Umbenennen</button>
<button class="btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Token-Reset</button>
<button class="btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Löschen</button>
</div>
</div>
<!-- Token-Anzeige nach Reset -->
<div class="token-box hidden mt-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 p-3">
<p class="text-xs text-amber-700 dark:text-amber-300 font-medium mb-1">Neuer Token (einmalig jetzt kopieren!):</p>
<code class="token-val text-xs font-mono break-all text-amber-900 dark:text-amber-100 select-all"></code>
</div>
<!-- Umbenenn-Formular -->
<div class="rename-box hidden mt-3">
<form class="flex gap-2">
<input type="text" class="rename-inp flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value="${escHtml(u.display_name)}" />
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 transition-colors">Speichern</button>
<button type="button" class="rename-cancel rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Abbrechen</button>
</form>
</div>
<!-- Erinnerungen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
<span class="mem-arrow"></span> Erinnerungen
</button>
<div class="mem-section hidden mt-2 space-y-1.5">
<div class="mem-list"></div>
<form class="mem-add-form flex gap-2 mt-2">
<input type="text" placeholder="Neue Erinnerung …"
class="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors">Hinzufügen</button>
</form>
</div>
</div>
`;
const uid = u.user_id;
// 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 = '<p class="text-xs text-slate-400">lade …</p>';
const mems = await adminFetch(`/api/admin/users/${uid}/memories`);
if (!mems?.length) {
container.innerHTML = '<p class="text-xs text-slate-400 italic">Keine Erinnerungen vorhanden.</p>';
return;
}
container.innerHTML = "";
mems.forEach((m) => {
const row = document.createElement("div");
row.className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs";
row.innerHTML = `
<span class="flex-1 text-slate-700 dark:text-slate-300 leading-relaxed">${escHtml(m.content)}</span>
<button class="shrink-0 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-colors font-bold" title="Löschen"></button>
`;
row.querySelector("button").addEventListener("click", async () => {
await adminFetch(`/api/admin/users/${uid}/memories/${m.id}`, "DELETE");
row.remove();
});
container.appendChild(row);
});
}
// ════════════════════════════════════════
// TAB: GESPRÄCHE
// ════════════════════════════════════════
let _chatUser = null;
async function loadChatUsers() {
const list = $("#chat-user-list");
list.innerHTML = '<li class="px-3 py-3 text-sm text-slate-400">lade …</li>';
const users = await adminFetch("/api/admin/users");
if (!users) return;
list.innerHTML = "";
users.forEach((u) => {
const li = document.createElement("li");
li.className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col";
li.innerHTML = `<span class="font-medium truncate">${escHtml(u.display_name)}</span>
<span class="text-xs text-slate-400 font-mono truncate">${u.user_id.slice(0,8)}</span>`;
li.addEventListener("click", () => {
document.querySelectorAll("#chat-user-list li").forEach((el) =>
el.classList.remove("bg-blue-50", "dark:bg-blue-900/20", "text-blue-700", "dark:text-blue-300")
);
li.classList.add("bg-blue-50", "dark:bg-blue-900/20");
_chatUser = u;
loadChatSessions(u);
});
list.appendChild(li);
});
}
async function loadChatSessions(u) {
const panel = $("#chat-right-panel");
panel.innerHTML = `<p class="p-4 text-sm text-slate-400">lade Sessions für ${escHtml(u.display_name)} …</p>`;
const sessions = await adminFetch(`/api/admin/users/${u.user_id}/sessions`);
if (!sessions?.length) {
panel.innerHTML = '<p class="p-4 text-sm text-slate-400">Keine Sessions gefunden.</p>';
return;
}
panel.innerHTML = `
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/50">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wide">Sessions · ${escHtml(u.display_name)}</p>
</div>
<div id="session-list" class="divide-y divide-slate-100 dark:divide-slate-800/60"></div>
`;
const list = panel.querySelector("#session-list");
sessions.forEach((s) => {
const row = document.createElement("div");
row.className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors";
row.innerHTML = `
<p class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate">${escHtml(s.id)}</p>
<p class="text-xs text-slate-400 mt-0.5">${fmtDate(s.updated_at)} · ${s.msg_count} Nachrichten</p>
`;
row.addEventListener("click", () => loadTranscript(s.id));
list.appendChild(row);
});
}
async function loadTranscript(sessionId) {
const panel = $("#chat-right-panel");
panel.innerHTML = `<p class="p-4 text-sm text-slate-400">lade Transkript …</p>`;
const msgs = await adminFetch(`/api/admin/sessions/${sessionId}/messages`);
if (!msgs?.length) {
panel.innerHTML = `<div class="p-4">
<button class="back-btn text-xs text-blue-600 hover:underline mb-3 block"> zurück zu Sessions</button>
<p class="text-sm text-slate-400">Keine Nachrichten.</p></div>`;
panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser));
return;
}
const bubbles = msgs.map((m) => {
const isUser = m.role === "user";
return `<div class="flex ${isUser ? "justify-end" : "justify-start"}">
<div class="max-w-[80%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap leading-relaxed
${isUser
? "rounded-br-sm bg-blue-600 text-white"
: "rounded-bl-sm border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100"}">
${escHtml(m.content)}</div></div>`;
}).join("");
panel.innerHTML = `
<div class="sticky top-0 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur flex items-center gap-3">
<button class="back-btn text-xs text-blue-600 hover:underline"> Sessions</button>
<span class="text-xs text-slate-400 font-mono truncate">${escHtml(sessionId)}</span>
</div>
<div class="p-4 space-y-3">${bubbles}</div>
`;
panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser));
}
// ════════════════════════════════════════
// TAB: NOTFÄLLE
// ════════════════════════════════════════
async function loadEmergencyEvents() {
const container = $("#emergency-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const events = await adminFetch("/api/admin/emergency-events");
if (!events) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
if (!events.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Keine Notfall-Ereignisse protokolliert.</p>'; return; }
container.innerHTML = `
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/60">
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
<th class="text-left px-4 py-2.5">Zeitpunkt</th>
<th class="text-left px-4 py-2.5">Nutzer</th>
<th class="text-left px-4 py-2.5">Kategorie</th>
<th class="text-left px-4 py-2.5">Textausschnitt</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800">
${events.map((ev) => `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
<td class="px-4 py-2.5 text-xs text-slate-400 whitespace-nowrap">${fmtDate(ev.created_at)}</td>
<td class="px-4 py-2.5 text-xs font-medium">${escHtml(ev.display_name)}</td>
<td class="px-4 py-2.5">
<span class="rounded-full bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-xs px-2.5 py-0.5 font-medium">${escHtml(ev.category)}</span>
</td>
<td class="px-4 py-2.5 text-xs text-slate-600 dark:text-slate-300 max-w-xs truncate">${escHtml(ev.snippet)}</td>
</tr>`).join("")}
</tbody>
</table>
</div>`;
}
// ════════════════════════════════════════
// TAB: STATUS
// ════════════════════════════════════════
async function loadStatus() {
const container = $("#status-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
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) =>
`<dt class="text-slate-500 dark:text-slate-400">${label}</dt>
<dd class="font-mono text-xs text-slate-700 dark:text-slate-300">${escHtml(val || "—")}</dd>`;
container.innerHTML = `
<!-- Gateway-Status -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="h-2.5 w-2.5 rounded-full bg-emerald-500 shadow shadow-emerald-300/60"></span>
<h3 class="font-semibold text-sm">Gateway</h3>
<span class="text-xs text-emerald-600 dark:text-emerald-400 font-medium">läuft</span>
</div>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("Profil", cfg.profile)}
${providerRow("Umgebung", cfg.app_env)}
</dl>
</div>
<!-- Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Provider (Standard-Route)</h3>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("STT", route.stt_provider)}
${providerRow("LLM", route.llm_provider)}
${providerRow("TTS", route.tts_provider)}
${providerRow("Sprache", route.language)}
</dl>
</div>
<!-- Laufzeit-Metriken -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Laufzeit-Metriken</h3>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("Turns gesamt", turnsTotal)}
${providerRow("HTTP-Anfragen", httpTotal)}
</dl>
</div>
<!-- Verfügbare Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Verfügbare Provider</h3>
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
${providerRow("STT", (cfg.available?.stt_providers || []).join(", "))}
${providerRow("LLM", (cfg.available?.llm_providers || []).join(", "))}
${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}
</dl>
</div>
<!-- Datenbank-Export -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-1">Datenbank-Backup</h3>
<p class="text-xs text-slate-400 mb-3">SQLite-Datenbank als Datei herunterladen (Backup / Migration).</p>
<a href="/api/admin/db-export" download="voice-assistant.db"
class="inline-flex items-center gap-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-xs px-4 py-2 font-medium transition-colors">
voice-assistant.db herunterladen
</a>
</div>
`;
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
}
}
// ════════════════════════════════════════
// TAB: METRIKEN
// ════════════════════════════════════════
async function loadMetrics() {
const container = $("#metrics-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch("/api/admin/usage");
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; return; }
const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0);
const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1);
const bars = data.map((u) => {
const pct = Math.round(((u.total_requests || 0) / maxReq) * 100);
return `
<div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-xs text-slate-600 dark:text-slate-300 truncate">${escHtml(u.display_name)}</span>
<div class="flex-1 h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 dark:bg-blue-600 rounded-full" style="width:${pct}%"></div>
</div>
<span class="w-10 text-right text-xs font-mono text-slate-500 dark:text-slate-400">${u.total_requests ?? 0}</span>
</div>`;
}).join("");
container.innerHTML = `
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/60">
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
<th class="text-left px-4 py-2.5">Nutzer</th>
<th class="text-right px-4 py-2.5">Anfragen gesamt</th>
<th class="text-right px-4 py-2.5">Einheiten</th>
<th class="text-right px-4 py-2.5">Letzte Aktivität</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800">
${data.map((u) => `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
<td class="px-4 py-2.5 font-medium">${escHtml(u.display_name)}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_requests ?? 0}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_units ?? 0}</td>
<td class="px-4 py-2.5 text-right text-xs text-slate-400">${u.last_active || "—"}</td>
</tr>`).join("")}
</tbody>
<tfoot class="border-t-2 border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/60">
<tr class="text-xs font-semibold text-slate-600 dark:text-slate-300">
<td class="px-4 py-2">Gesamt</td>
<td class="px-4 py-2 text-right font-mono">${totalReq}</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-4">Anfragen je Nutzer</h3>
<div class="space-y-2.5">${bars}</div>
</div>
`;
}
// ════════════════════════════════════════
// TAB: WÖRTERBUCH
// ════════════════════════════════════════
async function loadWoerterbuch() {
const lang = ($("#words-lang") || {}).value || "de";
const container = $("#words-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch(`/api/admin/pronunciation/${lang}`);
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
container.innerHTML = "";
const sections = [
["abbreviations", "Abkürzungen"],
["units", "Einheiten"],
["terms", "Begriffe / Aussprache"],
];
for (const [section, label] of sections) {
container.appendChild(buildWortSection(label, section, data[section] || {}, lang));
}
}
function buildWortSection(title, section, entries, lang) {
const wrap = document.createElement("div");
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
const rows = Object.entries(entries).map(([k, v]) => `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group">
<td class="px-3 py-2 text-xs font-mono text-slate-700 dark:text-slate-300">${escHtml(k)}</td>
<td class="px-3 py-2 text-xs text-slate-400"></td>
<td class="px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium">${escHtml(v)}</td>
<td class="px-3 py-2 text-right">
<button class="del-btn text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
data-key="${escHtml(k)}"></button>
</td>
</tr>`).join("");
wrap.innerHTML = `
<h4 class="font-semibold text-sm mb-3 flex items-center justify-between">
${escHtml(title)}
<span class="text-xs font-normal text-slate-400">${Object.keys(entries).length} Einträge</span>
</h4>
${rows ? `
<div class="overflow-x-auto mb-4">
<table class="w-full">
<tbody class="divide-y divide-slate-100 dark:divide-slate-700/60">${rows}</tbody>
</table>
</div>` : '<p class="text-xs text-slate-400 italic mb-4">Keine Einträge.</p>'}
<form class="add-form flex gap-2 flex-wrap">
<input type="text" placeholder="Schlüssel" required
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input type="text" placeholder="Ersetzung" required
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit"
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 font-medium transition-colors whitespace-nowrap">+ Hinzufügen</button>
</form>
`;
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","en_US-ryan-high","es_ES-davefx-medium","fr_FR-gilles-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 = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch("/api/admin/config");
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
_settingsData = data;
container.innerHTML = "";
const byKey = Object.fromEntries(data.map((d) => [d.key, d]));
FIELD_GROUPS.forEach((group) => {
const items = group.keys.map((k) => byKey[k]).filter(Boolean);
if (!items.length) return;
const section = document.createElement("div");
section.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden";
const 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 = `<h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">${escHtml(group.label)}</h4>`;
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) =>
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(labels[o] || o)}</option>`
).join("");
return `<select id="${id}" class="${cls} flex-1">${options}</select>`;
}
if (meta.ui === "combo") {
const inList = meta.opts.includes(val);
const labels = meta.labels || {};
const options = meta.opts.map((o) =>
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(labels[o] || o)}</option>`
).join("");
const customSel = !inList ? 'selected' : '';
return `
<div class="flex-1 space-y-1.5">
<select class="combo-sel w-full ${cls}">
${options}
<option value="__custom__" ${customSel}> Eigener Wert </option>
</select>
<input id="${id}" type="text" value="${escHtml(val)}" placeholder="Eigenen Wert eingeben"
class="combo-inp w-full ${cls} ${inList ? "hidden" : ""}" />
</div>`;
}
if (meta.ui === "range") {
return `
<div class="flex-1 flex items-center gap-3">
<input id="${id}" type="range" min="${meta.min}" max="${meta.max}" step="${meta.step}" value="${escHtml(val)}"
class="flex-1 accent-blue-600" />
<span class="range-val text-xs font-mono w-8 text-right">${escHtml(val)}</span>
</div>`;
}
if (meta.ui === "number") {
const extra = meta.max !== undefined ? `max="${meta.max}"` : "";
return `<input id="${id}" type="number" min="${meta.min ?? 0}" ${extra} value="${escHtml(val)}" class="${cls} flex-1" />`;
}
if (meta.ui === "textarea") {
return `<textarea id="${id}" rows="3" class="${cls} flex-1 resize-y">${escHtml(val)}</textarea>`;
}
return `<input id="${id}" type="text" value="${escHtml(val)}" class="${cls} flex-1" />`;
}
function buildSettingRow(item) {
const meta = FIELD_META[item.key] || { ui: "text" };
const overridden = item.is_overridden;
const envName = item.key.toUpperCase();
const testType = meta.test;
const wrap = document.createElement("div");
wrap.className = "px-4 py-3" + (overridden ? " bg-amber-50/40 dark:bg-amber-900/10" : "");
const testBtn = testType
? `<button class="test-btn text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors whitespace-nowrap">${testType === "tts" ? "🔊 Test" : testType === "llm" ? "💬 Test" : "🎤 Test"}</button>`
: "";
const resetBtn = overridden
? `<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button>`
: "";
wrap.innerHTML = `
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="font-medium text-sm flex-1">${escHtml(item.label)}</span>
${overridden ? '<span class="text-xs rounded-full bg-amber-200 dark:bg-amber-800/60 text-amber-800 dark:text-amber-200 px-2 py-0.5 font-medium">überschrieben</span>' : ""}
${resetBtn}
</div>
<div class="flex gap-2 items-start flex-wrap">
${_buildControl(item, meta)}
<div class="flex flex-col gap-1.5 shrink-0">
<button class="save-btn rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-2 font-medium transition-colors">Speichern</button>
${testBtn}
</div>
</div>
<div class="flex items-baseline gap-2 mt-1.5 text-xs text-slate-400 flex-wrap">
<span class="font-mono font-medium text-slate-500 dark:text-slate-400">${envName}</span>
<span class="text-slate-300 dark:text-slate-600">·</span>
<span>.env: <span class="font-mono">${escHtml(item.base_value) || "—"}</span></span>
<span class="text-slate-300 dark:text-slate-600">·</span>
<span>${escHtml(item.hint)}</span>
</div>
<div class="feedback-msg text-xs mt-1 hidden"></div>
<div class="test-result text-xs mt-2 hidden rounded-lg bg-slate-100 dark:bg-slate-700/60 px-3 py-2 font-mono whitespace-pre-wrap max-h-28 overflow-y-auto"></div>
`;
// Combo-Synchronisation: Dropdown ↔ Textfeld
const comboSel = wrap.querySelector(".combo-sel");
const comboInp = wrap.querySelector(".combo-inp");
if (comboSel && comboInp) {
comboSel.addEventListener("change", () => {
if (comboSel.value === "__custom__") {
comboInp.classList.remove("hidden");
comboInp.focus();
} else {
comboInp.value = comboSel.value;
comboInp.classList.add("hidden");
}
});
comboInp.addEventListener("input", () => {
const match = Array.from(comboSel.options).find((o) => o.value === comboInp.value);
comboSel.value = match ? match.value : "__custom__";
});
}
// Range-Slider: Wert live anzeigen
const rangeInp = wrap.querySelector('input[type="range"]');
if (rangeInp) {
rangeInp.addEventListener("input", () => {
wrap.querySelector(".range-val").textContent = rangeInp.value;
});
}
// Aktuellen Wert holen (aus Combo-Textfeld oder normalem Input)
function getCurrentValue() {
if (comboInp) return comboInp.value.trim();
const inp = wrap.querySelector(`#cfg-${item.key}`);
return inp ? (inp.value ?? inp.textContent ?? "").trim() : "";
}
// Feedback-Anzeige
function showFeedback(ok, msg) {
const fb = wrap.querySelector(".feedback-msg");
fb.textContent = msg;
fb.className = "feedback-msg text-xs mt-1 " + (ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-500");
setTimeout(() => { fb.className = "feedback-msg text-xs mt-1 hidden"; }, 3000);
}
// Speichern
wrap.querySelector(".save-btn").addEventListener("click", async () => {
const val = getCurrentValue();
const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val });
if (res !== null) {
showFeedback(true, "✓ Gespeichert");
item.is_overridden = true;
item.effective_value = val;
// Overrides-Badge nachrüsten falls noch nicht da
if (!wrap.querySelector(".reset-btn")) {
wrap.querySelector(".flex.items-center.gap-2").insertAdjacentHTML("beforeend",
'<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button>');
wrap.querySelector(".reset-btn").addEventListener("click", doReset);
}
} else {
showFeedback(false, "✗ Fehler beim Speichern");
}
});
// Reset
async function doReset() {
if (!confirm(`Override für „${item.label}" entfernen?\nDer .env-Wert gilt danach wieder.`)) return;
const res = await adminFetch(`/api/admin/config/${item.key}`, "DELETE");
if (res !== null) loadSettings();
}
wrap.querySelector(".reset-btn")?.addEventListener("click", doReset);
// Test
const testBtnEl = wrap.querySelector(".test-btn");
if (testBtnEl) {
testBtnEl.addEventListener("click", async () => {
const resultEl = wrap.querySelector(".test-result");
resultEl.textContent = "…";
resultEl.classList.remove("hidden");
testBtnEl.disabled = true;
try {
if (testType === "tts") {
const body = { text: "Hallo, ich bin der Sprachassistent." };
if (meta.testProvider) body.tts_provider = meta.testProvider;
const r = await fetch("/api/speak", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("HTTP " + r.status);
const buf = await r.arrayBuffer();
const view = new DataView(buf);
const n = Math.floor(buf.byteLength / 2);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const abuf = audioCtx.createBuffer(1, n, 24000);
const ch = abuf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = abuf; src.connect(audioCtx.destination); src.start();
resultEl.textContent = "▶ Audio spielt …";
src.onended = () => { resultEl.classList.add("hidden"); };
} else if (testType === "llm") {
const r = await fetch("/api/chat?debug=true", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Sag kurz Hallo.", tts_provider: "piper" }),
});
const d = await r.json();
resultEl.textContent = d.response || d.semantic || d.trace?.response || JSON.stringify(d).slice(0, 200);
} else if (testType === "stt") {
const cfg = await fetch("/api/config").then((r) => r.json());
const available = cfg.available?.stt_providers || [];
const current = item.effective_value;
resultEl.textContent = available.includes(current)
? `✓ Provider „${current}" ist verfügbar.`
: `✗ Provider „${current}" nicht in verfügbaren: ${available.join(", ")}`;
}
} catch (e) {
resultEl.textContent = "Fehler: " + e.message;
}
testBtnEl.disabled = false;
});
}
return wrap;
}
$("#load-settings").addEventListener("click", loadSettings);