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 = `- .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 @@ - +