From d8b861db2662465dcbc3b43ed65b43079568ae6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 02:35:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Einstellungen-Tab=20=E2=80=93=20ty?= =?UTF-8?q?pisierte=20Inputs,=20Gruppen,=20Test-Buttons=20(v13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- BEDIENUNGSANLEITUNG.md | 4 +- app/web/app.js | 283 +++++++++++++++++++++++++++++++++++------ app/web/index.html | 2 +- 3 files changed, 247 insertions(+), 42 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index fe200cd..982293e 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 diff --git a/app/web/app.js b/app/web/app.js index ed8af5f..0c0d6b5 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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 = '

lade …

'; const data = await adminFetch("/api/admin/config"); if (!data) { container.innerHTML = '

Fehler beim Laden.

'; 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 = `

${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 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 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 + ? `` + : ""; + + const resetBtn = overridden + ? `` + : ""; wrap.innerHTML = ` -
-
- - ${escHtml(item.key)} - ${overridden ? 'überschrieben' : ""} +
+ ${escHtml(item.label)} + ${overridden ? 'überschrieben' : ""} + ${resetBtn} +
+
+ ${_buildControl(item, meta)} +
+ + ${testBtn}
- ${overridden ? `` : ""}
-
- ${isLong - ? `` - : `` - } - +
+ ${envName} + · + .env: ${escHtml(item.base_value) || "—"} + · + ${escHtml(item.hint)}
-

- .env: ${escHtml(item.base_value) || "—"} - · - ${escHtml(item.hint)} -

+ + `; - 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", + ''); + 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 () => { - 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(); + // 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; }); } diff --git a/app/web/index.html b/app/web/index.html index d451c05..c41531c 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
- +