feat(web): Einstellungen-Tab – typisierte Inputs, Gruppen, Test-Buttons (v13)

- Dropdown-Auswahl für Provider, Bool- und Enum-Felder
- Combo-Widget (Dropdown + Textfeld, synchronisiert) für Modelle/Stimmen
- Range-Slider für Temperature, Number-Input für Token/Limit-Felder
- Textarea für System-Prompt
- Test-Buttons: 🔊 TTS via WebAudio, 💬 LLM via /api/chat?debug=true, 🎤 STT-Verfügbarkeitscheck
- Inline-Save-Feedback (grün ✓ / rot ✗), kein Full-Reload mehr
- ENV-Variablenname in UPPERCASE angezeigt
- Sieben Gruppen-Sektionen statt flacher Liste
- Piper-Stimmentabelle in Doku korrigiert (nur installierte: thorsten/kerstin/karlsson)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-19 02:35:40 +02:00
commit d8b861db26
3 changed files with 247 additions and 42 deletions

View file

@ -785,9 +785,7 @@ Aktuell installierte Stimmen:
|---|---|---|---|
| `de_DE-thorsten-high` | Deutsch | high (22050 Hz) | **Default**, männlich |
| `de_DE-kerstin-low` | Deutsch | low (16000 Hz) | weiblich, hörbar gröber |
| `en_US-ryan-high` | Englisch | high | |
| `es_ES-davefx-medium` | Spanisch | medium | |
| `fr_FR-gilles-low` | Französisch | low | |
| `de_DE-karlsson-low` | Deutsch | low (16000 Hz) | männlich, hörbar gröber |
Stimme wechseln (in `.env`):
```bash

View file

@ -973,67 +973,274 @@ $("#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: "select", opts: ["openrouter","faster-whisper"], test: "stt" },
default_llm_provider: { ui: "select", opts: ["openrouter","local-openai-compatible"], test: "llm" },
default_tts_provider: { ui: "select", opts: ["openrouter","piper","chatterbox"], test: "tts" },
default_language: { ui: "select", 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" },
openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts" },
piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","de_DE-karlsson-low"], test: "tts" },
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 = "";
data.forEach((item) => container.appendChild(buildSettingRow(item)));
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 options = meta.opts.map((o) =>
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(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 wrap = document.createElement("div");
const meta = FIELD_META[item.key] || { ui: "text" };
const overridden = item.is_overridden;
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border " +
(overridden
? "border-amber-300 dark:border-amber-600"
: "border-slate-200 dark:border-slate-700") +
" p-4";
const envName = item.key.toUpperCase();
const testType = meta.test;
const inputId = "cfg-" + item.key;
const isLong = item.type === "str" && item.base_value.length > 60;
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-start justify-between gap-3 mb-2 flex-wrap">
<div>
<label for="${inputId}" class="font-medium text-sm">${escHtml(item.label)}</label>
<span class="ml-2 text-xs font-mono text-slate-400">${escHtml(item.key)}</span>
${overridden ? '<span class="ml-2 text-xs rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-2 py-0.5">überschrieben</span>' : ""}
<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>
${overridden ? `<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Zurücksetzen</button>` : ""}
<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 class="flex gap-2 items-end flex-wrap">
${isLong
? `<textarea id="${inputId}" rows="3"
class="flex-1 min-w-0 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 resize-y"
>${escHtml(item.effective_value)}</textarea>`
: `<input id="${inputId}" type="text" value="${escHtml(item.effective_value)}"
class="flex-1 min-w-0 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" />`
}
<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 whitespace-nowrap">Speichern</button>
</div>
<p class="text-xs text-slate-400 mt-1.5">
<span class="font-medium">.env:</span> <span class="font-mono">${escHtml(item.base_value) || ""}</span>
<span class="ml-3 text-slate-300 dark:text-slate-600">·</span>
<span class="ml-2">${escHtml(item.hint)}</span>
</p>
<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>
`;
const inp = wrap.querySelector(`#${inputId}`);
// 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 = inp.value ?? inp.textContent ?? "";
const val = getCurrentValue();
const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val });
if (res !== null) loadSettings();
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");
}
});
const resetBtn = wrap.querySelector(".reset-btn");
if (resetBtn) {
resetBtn.addEventListener("click", async () => {
// 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 r = await fetch("/api/speak", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Hallo, ich bin der Sprachassistent." }),
});
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;
});
}

View file

@ -218,6 +218,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<script src="/app.js?v=12"></script>
<script src="/app.js?v=13"></script>
</body>
</html>