); Bereiche mit mehreren
// Abschnitten bekommen eine Sub-Navigation.
const ADMIN_TABS = {
overview: [["overview", "Übersicht"]],
users: [["users", "Verwalten"], ["chat", "Gespräche"], ["metrics", "Nutzungs-Statistik"]],
system: [["status", "Status"], ["log", "Log"], ["emergency", "Notrufe"]],
config: [["settings", "Einstellungen"], ["words", "Wörterbuch"], ["maintenance", "Wartung"]],
};
const SECTION_LOADER = {
overview: () => loadOverview(),
users: () => loadAdminUsers(),
chat: () => loadChatUsers(),
emergency: () => loadEmergencyEvents(),
status: () => loadStatus(),
metrics: () => loadMetrics(),
words: () => loadWoerterbuch(),
settings: () => loadSettings(),
maintenance: () => loadMaintenance(),
log: () => {}, // Log: Nutzer verbindet manuell
};
function _setTabActive(btn, active) {
btn.classList.toggle("border-blue-600", active);
btn.classList.toggle("text-blue-600", active);
btn.classList.toggle("dark:border-blue-400", active);
btn.classList.toggle("dark:text-blue-400", active);
btn.classList.toggle("border-transparent", !active);
btn.classList.toggle("text-slate-500", !active);
}
function showAdminSection(section, top) {
document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden"));
const panel = $("#admin-tab-" + section);
if (panel) panel.classList.remove("hidden");
// Sub-Tab markieren
document.querySelectorAll(".admin-subtab").forEach((b) => {
const active = b.dataset.sec === section;
b.classList.toggle("bg-blue-600", active);
b.classList.toggle("text-white", active);
b.classList.toggle("border-blue-600", active);
b.classList.toggle("text-slate-600", !active);
b.classList.toggle("dark:text-slate-300", !active);
b.classList.toggle("border-slate-300", !active);
b.classList.toggle("dark:border-slate-600", !active);
});
const loader = SECTION_LOADER[section];
if (loader) loader();
}
function switchAdminTab(top) {
const sections = ADMIN_TABS[top] || [[top, top]];
document.querySelectorAll(".admin-tab").forEach((btn) => _setTabActive(btn, btn.dataset.tab === top));
const subnav = $("#admin-subnav");
if (sections.length <= 1) {
subnav.classList.add("hidden");
subnav.innerHTML = "";
showAdminSection(sections[0][0], top);
} else {
subnav.classList.remove("hidden");
subnav.innerHTML = sections.map(([sec, label]) =>
``
).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)}
` : ""}
${opts.jump ? "button" : "div"}>`;
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 = loginLink(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)}
Neuer Login-Link (einmalig — jetzt kopieren!):
Erlaubte Sprachen (leer = alle):
`;
const uid = u.user_id;
// Erlaubte Sprachen (Toggle-Chips) — Admin schaltet Sprachen pro Nutzer frei.
const allowWrap = card.querySelector(".lang-allow");
let allowed = String(u.allowed_languages || "").split(",").map((s) => s.trim()).filter(Boolean);
function renderLangChips() {
allowWrap.innerHTML = ALL_LANGS.map(([code, label]) => {
const on = allowed.includes(code);
return ``;
}).join("");
allowWrap.querySelectorAll(".lang-chip").forEach((b) =>
b.addEventListener("click", async () => {
const code = b.dataset.lang;
allowed = allowed.includes(code) ? allowed.filter((c) => c !== code) : [...allowed, code];
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT",
{ allowed_languages: allowed.join(",") });
if (res) renderLangChips();
})
);
}
renderLangChips();
// Admin-Rechte (persistentes Flag) ein-/ausschalten
let isAdmin = !!u.is_admin;
const adminBtn = card.querySelector(".btn-admin");
function renderAdminBtn() {
adminBtn.textContent = isAdmin ? "★ Admin" : "Admin?";
adminBtn.classList.toggle("bg-blue-600", isAdmin);
adminBtn.classList.toggle("text-white", isAdmin);
adminBtn.classList.toggle("border-blue-600", isAdmin);
adminBtn.classList.toggle("border-slate-300", !isAdmin);
adminBtn.classList.toggle("dark:border-slate-600", !isAdmin);
adminBtn.classList.toggle("text-slate-600", !isAdmin);
adminBtn.classList.toggle("dark:text-slate-300", !isAdmin);
}
renderAdminBtn();
adminBtn.addEventListener("click", async () => {
const next = !isAdmin;
if (next && !confirm(`„${u.display_name}" Admin-Rechte geben?`)) return;
const res = await adminFetch(`/api/admin/users/${uid}/admin`, "PUT", { is_admin: next });
if (res) { isAdmin = !!res.is_admin; renderAdminBtn(); }
});
// Umbenennen
card.querySelector(".btn-rename").addEventListener("click", () =>
card.querySelector(".rename-box").classList.toggle("hidden")
);
card.querySelector(".rename-cancel").addEventListener("click", () =>
card.querySelector(".rename-box").classList.add("hidden")
);
card.querySelector(".rename-box form").addEventListener("submit", async (e) => {
e.preventDefault();
const name = card.querySelector(".rename-inp").value.trim();
if (!name) return;
const res = await adminFetch(`/api/admin/users/${uid}`, "PUT", { display_name: name });
if (res) {
card.querySelector(".user-dn").textContent = name;
card.querySelector(".rename-box").classList.add("hidden");
}
});
// Token-Reset
card.querySelector(".btn-token").addEventListener("click", async () => {
if (!confirm(`Token für „${u.display_name}" zurücksetzen?\nDer alte Token wird sofort ungültig.`)) return;
const data = await adminFetch(`/api/admin/users/${uid}/token`, "POST");
if (data?.token) {
card.querySelector(".token-val").textContent = loginLink(data.token);
card.querySelector(".token-box").classList.remove("hidden");
}
});
// 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 ``;
}).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 = `
| Zeitpunkt |
Nutzer |
Kategorie |
Textausschnitt |
${events.map((ev) => `
| ${fmtDate(ev.created_at)} |
${escHtml(ev.display_name)} |
${escHtml(ev.category)}
|
${escHtml(ev.snippet)} |
`).join("")}
`;
}
// ════════════════════════════════════════
// TAB: STATUS
// ════════════════════════════════════════
// LLM-/GPU-Status-Karte (read-only) für den Status-Tab.
// Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden.
async function pollGatewayBack(msgEl, label) {
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 1500));
try {
const r = await fetch("/api/config", { cache: "no-store" });
if (r.ok) { if (msgEl) msgEl.textContent = label + " ✓"; loadStatus(); return; }
} catch (e) { /* noch nicht oben */ }
if (msgEl) msgEl.textContent = label + " … (" + (i + 1) + ")";
}
if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen.";
}
function wireRestart() {
const restartBtn = $("#gw-restart-btn");
const msg = $("#llm-ctrl-msg");
if (!restartBtn) return;
restartBtn.addEventListener("click", async () => {
if (!confirm("Gateway-Dienst jetzt neu starten?")) return;
restartBtn.disabled = true;
msg.textContent = "starte neu …";
const res = await adminFetch("/api/admin/gateway/restart", "POST");
if (res === null) { msg.textContent = "Fehler beim Neustart"; restartBtn.disabled = false; return; }
pollGatewayBack(msg, "Neu gestartet");
});
}
async function loadStatus() {
const container = $("#status-content");
container.innerHTML = 'lade …
';
try {
const [cfg, met, runtimeCfg] = await Promise.all([
fetch("/api/config").then((r) => r.json()),
fetch("/api/metrics").then((r) => r.json()),
adminFetch("/api/admin/config"),
]);
const cfgByKey = Object.fromEntries((runtimeCfg || []).map((d) => [d.key, d.effective_value]));
const llmModel = cfgByKey["openrouter_llm_model"] || "—";
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("LLM-Modell", llmModel)}
${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(", "))}
`;
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = 'Fehler beim Laden.
';
}
}
// ════════════════════════════════════════
// TAB: WARTUNG (Konfiguration)
// ════════════════════════════════════════
async function loadMaintenance() {
const c = $("#maintenance-content");
c.innerHTML = `
Gateway neu starten
Nötig nach Einstellungen, die einen Neustart erfordern (Hinweis erscheint dort).
`;
wireRestart();
}
// ════════════════════════════════════════
// 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 = `
| Nutzer |
Anfragen gesamt |
Einheiten |
Letzte Aktivität |
${data.map((u) => `
| ${escHtml(u.display_name)} |
${u.total_requests ?? 0} |
${u.total_units ?? 0} |
${u.last_active || "—"} |
`).join("")}
| 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 ? `
` : '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: "datalist", test: "llm" },
openrouter_tts_model: { ui: "datalist", test: "tts", testProvider: "openrouter" },
openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" },
piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" },
local_llm_system_prompt: { ui: "textarea", test: "llm" },
local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" },
local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" },
local_llm_max_tokens: { ui: "number", min: 0, test: "llm" },
tts_normalize_level: { ui: "select", opts: ["auto","full","light","off"], test: "tts" },
audio_stream_default: { ui: "select", opts: ["true","false"],
labels: { "true":"Aktiviert (satzweises Streaming)","false":"Deaktiviert (alles auf einmal)" }, test: "tts" },
memory_extraction_enabled: { ui: "select", opts: ["true","false"],
labels: { "true":"Aktiviert","false":"Deaktiviert" } },
memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 },
daily_request_limit: { ui: "number", min: 0 },
};
const FIELD_GROUPS = [
{ label: "Sprache & Stimme", keys: ["default_language","default_tts_provider","openrouter_tts_voice","piper_voice"] },
{ label: "Antwortverhalten", keys: ["memory_extraction_enabled","audio_stream_default","daily_request_limit"] },
{ label: "Erweitert", advanced: true, keys: [
"default_stt_provider","default_llm_provider","openrouter_llm_model","openrouter_tts_model",
"tts_normalize_level","local_llm_system_prompt","local_llm_temperature","local_llm_top_p",
"local_llm_max_tokens","memory_extraction_every_n_turns" ] },
];
let _settingsData = [];
async function loadSettings() {
const container = $("#settings-content");
container.innerHTML = '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 body = document.createElement("div");
items.forEach((item, idx) => {
const row = buildSettingRow(item);
if (idx > 0) row.classList.add("border-t", "border-slate-100", "dark:border-slate-700/60");
body.appendChild(row);
});
const header = document.createElement(group.advanced ? "button" : "div");
header.className = "w-full text-left px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60 flex items-center gap-2";
if (group.advanced) {
body.classList.add("hidden");
header.innerHTML = `▶` +
`${escHtml(group.label)}
` +
`technische Optionen`;
header.addEventListener("click", () => {
const open = !body.classList.contains("hidden");
body.classList.toggle("hidden", open);
header.querySelector(".adv-arrow").textContent = open ? "▶" : "▼";
});
} else {
header.innerHTML = `${escHtml(group.label)}
`;
}
section.appendChild(header);
section.appendChild(body);
container.appendChild(section);
});
populateModelDatalists();
}
// Füllt alle Modell-