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:
parent
5d022aaf63
commit
d8b861db26
3 changed files with 247 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
283
app/web/app.js
283
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 = '<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>
|
||||
<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>
|
||||
${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>
|
||||
<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 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>
|
||||
<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="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 () => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue