); Bereiche mit mehreren
// Abschnitten bekommen eine Sub-Navigation.
const ADMIN_TABS = {
overview: [["overview", "Übersicht"]],
users: [["users", "Verwalten"], ["chat", "Gespräche"]],
emergency: [["emergency", "Notrufe"]],
system: [["status", "Status"], ["metrics", "Metriken"], ["log", "Log"]],
config: [["settings", "Einstellungen"], ["words", "Wörterbuch"]],
};
const SECTION_LOADER = {
overview: () => loadOverview(),
users: () => loadAdminUsers(),
chat: () => loadChatUsers(),
emergency: () => loadEmergencyEvents(),
status: () => loadStatus(),
metrics: () => loadMetrics(),
words: () => loadWoerterbuch(),
settings: () => loadSettings(),
log: () => {}, // Log: Nutzer verbindet manuell
};
function _setTabActive(btn, active) {
btn.classList.toggle("border-blue-600", active);
btn.classList.toggle("text-blue-600", active);
btn.classList.toggle("dark:border-blue-400", active);
btn.classList.toggle("dark:text-blue-400", active);
btn.classList.toggle("border-transparent", !active);
btn.classList.toggle("text-slate-500", !active);
}
function showAdminSection(section, top) {
document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden"));
const panel = $("#admin-tab-" + section);
if (panel) panel.classList.remove("hidden");
// Sub-Tab markieren
document.querySelectorAll(".admin-subtab").forEach((b) => {
const active = b.dataset.sec === section;
b.classList.toggle("bg-blue-600", active);
b.classList.toggle("text-white", active);
b.classList.toggle("border-blue-600", active);
b.classList.toggle("text-slate-600", !active);
b.classList.toggle("dark:text-slate-300", !active);
b.classList.toggle("border-slate-300", !active);
b.classList.toggle("dark:border-slate-600", !active);
});
const loader = SECTION_LOADER[section];
if (loader) loader();
}
function switchAdminTab(top) {
const sections = ADMIN_TABS[top] || [[top, top]];
document.querySelectorAll(".admin-tab").forEach((btn) => _setTabActive(btn, btn.dataset.tab === top));
const subnav = $("#admin-subnav");
if (sections.length <= 1) {
subnav.classList.add("hidden");
subnav.innerHTML = "";
showAdminSection(sections[0][0], top);
} else {
subnav.classList.remove("hidden");
subnav.innerHTML = sections.map(([sec, label]) =>
``
).join("");
subnav.querySelectorAll(".admin-subtab").forEach((b) =>
b.addEventListener("click", () => showAdminSection(b.dataset.sec, top))
);
showAdminSection(sections[0][0], top);
}
}
document.querySelectorAll(".admin-tab").forEach((btn) =>
btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab))
);
$("#load-overview").addEventListener("click", loadOverview);
$("#load-emergency").addEventListener("click", loadEmergencyEvents);
$("#load-status").addEventListener("click", loadStatus);
$("#load-metrics").addEventListener("click", loadMetrics);
// ════════════════════════════════════════
// TAB: ÜBERSICHT (Dashboard)
// ════════════════════════════════════════
async function loadOverview() {
const c = $("#overview-content");
c.innerHTML = 'lade …
';
const [users, usage, emergencies, llm] = await Promise.all([
adminFetch("/api/admin/users"),
adminFetch("/api/admin/usage"),
adminFetch("/api/admin/emergency-events"),
adminFetch("/api/admin/llm/status"),
]);
const userCount = users ? users.length : "—";
const reqTotal = usage ? usage.reduce((s, u) => s + (u.total_requests || 0), 0) : "—";
const emCount = emergencies ? emergencies.length : "—";
const backend = llm ? ({ ollama: "Ollama", llamacpp: "llama.cpp" }[llm.backend] || llm.backend) : "—";
const gpu = (llm && llm.gpus && llm.gpus.length)
? llm.gpus.map((g) => `GPU ${g.index}: ${g.percent}%`).join(" · ") : "—";
const hasEmergencies = typeof emCount === "number" && emCount > 0;
// Kachel; mit data-jump wird sie anklickbar (springt in den Bereich).
const stat = (label, value, sub, opts = {}) => `
<${opts.jump ? `button data-jump="${opts.jump}"` : "div"}
class="ov-stat text-left bg-white dark:bg-slate-800 rounded-xl border ${opts.border || "border-slate-200 dark:border-slate-700"} p-4 ${opts.jump ? "hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors" : ""}">
${escHtml(label)}
${escHtml(value)}
${sub ? `${escHtml(sub)}
` : ""}
${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 = 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 Token (einmalig — jetzt kopieren!):
`;
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 ``;
}).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)}
Wartung
Startet den Gateway-Dienst neu (z. B. nach Einstellungen, die einen Neustart erfordern).
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(", "))}
`;
wireRestart(); // Buttons der LLM-Karte verdrahten (nach dem Rendern)
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = 'Fehler beim Laden.
';
}
}
// ════════════════════════════════════════
// TAB: METRIKEN
// ════════════════════════════════════════
async function loadMetrics() {
const container = $("#metrics-content");
container.innerHTML = 'lade …
';
const data = await adminFetch("/api/admin/usage");
if (!data) { container.innerHTML = 'Fehler beim Laden.
'; return; }
if (!data.length) { container.innerHTML = 'Noch keine Nutzungsdaten vorhanden.
'; return; }
const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0);
const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1);
const bars = data.map((u) => {
const pct = Math.round(((u.total_requests || 0) / maxReq) * 100);
return `
${escHtml(u.display_name)}
${u.total_requests ?? 0}
`;
}).join("");
container.innerHTML = `
| 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: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" },
openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts", testProvider: "openrouter" },
openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" },
piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" },
local_llm_system_prompt: { ui: "textarea", test: "llm" },
local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" },
local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" },
local_llm_max_tokens: { ui: "number", min: 0, test: "llm" },
tts_normalize_level: { ui: "select", opts: ["auto","full","light","off"], test: "tts" },
audio_stream_default: { ui: "select", opts: ["true","false"],
labels: { "true":"Aktiviert (satzweises Streaming)","false":"Deaktiviert (alles auf einmal)" }, test: "tts" },
memory_extraction_enabled: { ui: "select", opts: ["true","false"],
labels: { "true":"Aktiviert","false":"Deaktiviert" } },
memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 },
daily_request_limit: { ui: "number", min: 0 },
};
const FIELD_GROUPS = [
{ label: "Provider & Sprache", keys: ["default_stt_provider","default_llm_provider","default_tts_provider","default_language"] },
{ label: "OpenRouter-Modelle & Stimmen", keys: ["openrouter_llm_model","openrouter_tts_model","openrouter_tts_voice"] },
{ label: "Piper TTS", keys: ["piper_voice"] },
{ label: "Lokales LLM", keys: ["local_llm_system_prompt","local_llm_temperature","local_llm_top_p","local_llm_max_tokens"] },
{ label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] },
{ label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] },
{ label: "Limits", keys: ["daily_request_limit"] },
];
let _settingsData = [];
async function loadSettings() {
const container = $("#settings-content");
container.innerHTML = 'lade …
';
const data = await adminFetch("/api/admin/config");
if (!data) { container.innerHTML = 'Fehler beim Laden.
'; return; }
_settingsData = data;
container.innerHTML = "";
const byKey = Object.fromEntries(data.map((d) => [d.key, d]));
FIELD_GROUPS.forEach((group) => {
const items = group.keys.map((k) => byKey[k]).filter(Boolean);
if (!items.length) return;
const section = document.createElement("div");
section.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden";
const header = document.createElement("div");
header.className = "px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60";
header.innerHTML = `${escHtml(group.label)}
`;
section.appendChild(header);
items.forEach((item, idx) => {
const row = buildSettingRow(item);
if (idx > 0) row.classList.add("border-t", "border-slate-100", "dark:border-slate-700/60");
section.appendChild(row);
});
container.appendChild(section);
});
}
function _buildControl(item, meta) {
const val = item.effective_value;
const id = "cfg-" + item.key;
const cls = "rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500";
if (meta.ui === "select") {
const labels = meta.labels || {};
const options = meta.opts.map((o) =>
``
).join("");
return ``;
}
if (meta.ui === "combo") {
const inList = meta.opts.includes(val);
const labels = meta.labels || {};
const options = meta.opts.map((o) =>
``
).join("");
const customSel = !inList ? 'selected' : '';
return `
`;
}
if (meta.ui === "range") {
return `
${escHtml(val)}
`;
}
if (meta.ui === "number") {
const extra = meta.max !== undefined ? `max="${meta.max}"` : "";
return ``;
}
if (meta.ui === "textarea") {
return ``;
}
return ``;
}
function buildSettingRow(item) {
const meta = FIELD_META[item.key] || { ui: "text" };
const overridden = item.is_overridden;
const envName = item.key.toUpperCase();
const testType = meta.test;
const wrap = document.createElement("div");
wrap.className = "px-4 py-3" + (overridden ? " bg-amber-50/40 dark:bg-amber-900/10" : "");
const testBtn = testType
? ``
: "";
const resetBtn = overridden
? ``
: "";
wrap.innerHTML = `
${escHtml(item.label)}
${overridden ? 'überschrieben' : ""}
${resetBtn}
${_buildControl(item, meta)}
${testBtn}
${envName}
·
.env: ${escHtml(item.base_value) || "—"}
·
${escHtml(item.hint)}
`;
// Combo-Synchronisation: Dropdown ↔ Textfeld
const comboSel = wrap.querySelector(".combo-sel");
const comboInp = wrap.querySelector(".combo-inp");
if (comboSel && comboInp) {
comboSel.addEventListener("change", () => {
if (comboSel.value === "__custom__") {
comboInp.classList.remove("hidden");
comboInp.focus();
} else {
comboInp.value = comboSel.value;
comboInp.classList.add("hidden");
}
});
comboInp.addEventListener("input", () => {
const match = Array.from(comboSel.options).find((o) => o.value === comboInp.value);
comboSel.value = match ? match.value : "__custom__";
});
}
// Range-Slider: Wert live anzeigen
const rangeInp = wrap.querySelector('input[type="range"]');
if (rangeInp) {
rangeInp.addEventListener("input", () => {
wrap.querySelector(".range-val").textContent = rangeInp.value;
});
}
// Aktuellen Wert holen (aus Combo-Textfeld oder normalem Input)
function getCurrentValue() {
if (comboInp) return comboInp.value.trim();
const inp = wrap.querySelector(`#cfg-${item.key}`);
return inp ? (inp.value ?? inp.textContent ?? "").trim() : "";
}
// Feedback-Anzeige
function showFeedback(ok, msg) {
const fb = wrap.querySelector(".feedback-msg");
fb.textContent = msg;
fb.className = "feedback-msg text-xs mt-1 " + (ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-500");
setTimeout(() => { fb.className = "feedback-msg text-xs mt-1 hidden"; }, 3000);
}
// Speichern
wrap.querySelector(".save-btn").addEventListener("click", async () => {
const val = getCurrentValue();
const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val });
if (res !== null) {
showFeedback(true, "✓ Gespeichert");
item.is_overridden = true;
item.effective_value = val;
// Overrides-Badge nachrüsten falls noch nicht da
if (!wrap.querySelector(".reset-btn")) {
wrap.querySelector(".flex.items-center.gap-2").insertAdjacentHTML("beforeend",
'');
wrap.querySelector(".reset-btn").addEventListener("click", doReset);
}
} else {
showFeedback(false, "✗ Fehler beim Speichern");
}
});
// Reset
async function doReset() {
if (!confirm(`Override für „${item.label}" entfernen?\nDer .env-Wert gilt danach wieder.`)) return;
const res = await adminFetch(`/api/admin/config/${item.key}`, "DELETE");
if (res !== null) loadSettings();
}
wrap.querySelector(".reset-btn")?.addEventListener("click", doReset);
// Test
const testBtnEl = wrap.querySelector(".test-btn");
if (testBtnEl) {
testBtnEl.addEventListener("click", async () => {
const resultEl = wrap.querySelector(".test-result");
resultEl.textContent = "…";
resultEl.classList.remove("hidden");
testBtnEl.disabled = true;
try {
if (testType === "tts") {
const body = { text: "Hallo, ich bin der Sprachassistent." };
if (meta.testProvider) body.tts_provider = meta.testProvider;
const r = await fetch("/api/speak", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("HTTP " + r.status);
const buf = await r.arrayBuffer();
const view = new DataView(buf);
const n = Math.floor(buf.byteLength / 2);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const abuf = audioCtx.createBuffer(1, n, 24000);
const ch = abuf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = abuf; src.connect(audioCtx.destination); src.start();
resultEl.textContent = "▶ Audio spielt …";
src.onended = () => { resultEl.classList.add("hidden"); };
} else if (testType === "llm") {
const r = await fetch("/api/chat?debug=true", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Sag kurz Hallo.", tts_provider: "piper" }),
});
const d = await r.json();
resultEl.textContent = d.response || d.semantic || d.trace?.response || JSON.stringify(d).slice(0, 200);
} else if (testType === "stt") {
const cfg = await fetch("/api/config").then((r) => r.json());
const available = cfg.available?.stt_providers || [];
const current = item.effective_value;
resultEl.textContent = available.includes(current)
? `✓ Provider „${current}" ist verfügbar.`
: `✗ Provider „${current}" nicht in verfügbaren: ${available.join(", ")}`;
}
} catch (e) {
resultEl.textContent = "Fehler: " + e.message;
}
testBtnEl.disabled = false;
});
}
return wrap;
}
$("#load-settings").addEventListener("click", loadSettings);
// === NOTRUF-KNOPF =========================================================
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
const emergencyBtn = $("#emergency-btn");
if (emergencyBtn) {
emergencyBtn.addEventListener("click", async () => {
if (!confirm("Wirklich Hilfe rufen?")) return;
const lang = (langSel && langSel.value) || "de";
emergencyBtn.disabled = true;
try {
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language: lang }),
});
const data = await res.json().catch(() => ({}));
const notice = (data && data.notice) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
addMessage("emergency", "🆘 " + notice);
} catch (e) {
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
} finally {
emergencyBtn.disabled = false;
}
});
}