diff --git a/app/api/admin.py b/app/api/admin.py index b8fc3ed..89b4ac7 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from app.auth import is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store +from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate router = APIRouter() @@ -249,6 +250,55 @@ async def delete_pronunciation(lang: str, section: str, key: str): return {"deleted": key} +# ── Laufzeit-Konfiguration ───────────────────────────────────────────────── + +class ConfigValue(BaseModel): + value: str + + +@router.get("/admin/config", dependencies=[Depends(require_admin)]) +async def get_runtime_config(): + """Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück.""" + overrides = get_store().get_config_overrides() + result = [] + for key, (label, type_str, hint) in RUNTIME_SETTABLE.items(): + base_val = getattr(settings, key, None) + effective_val = getattr(runtime_settings, key, base_val) + result.append({ + "key": key, + "label": label, + "type": type_str, + "hint": hint, + "base_value": str(base_val) if base_val is not None else "", + "override_value": overrides.get(key), + "effective_value": str(effective_val) if effective_val is not None else "", + "is_overridden": key in overrides, + }) + return result + + +@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)]) +async def set_runtime_config(key: str, body: ConfigValue): + """Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig).""" + if key not in RUNTIME_SETTABLE: + raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}") + get_store().set_config_override(key, body.value.strip()) + invalidate_cache() + return {"key": key, "value": body.value.strip()} + + +@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)]) +async def delete_runtime_config(key: str): + """Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück).""" + if key not in RUNTIME_SETTABLE: + raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}") + deleted = get_store().delete_config_override(key) + invalidate_cache() + if not deleted: + raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.") + return {"deleted": key} + + # ── Live-Log (journalctl → WebSocket) ────────────────────────────────────── @router.websocket("/admin/log") diff --git a/app/dependencies.py b/app/dependencies.py index 7da78ed..bfeec70 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from app.config import Settings, settings +from app.runtime_config import runtime_settings from app.errors import UnknownComponentError from app.audio.router import AudioRouter from app.audio.endpoints.input.local_default import LocalDefaultInput @@ -93,15 +94,18 @@ def _from_registry(registry: dict, name: str, kind: str, cfg: Settings): return factory(cfg) -def get_stt_provider(name: str | None = None, cfg: Settings = settings): +def get_stt_provider(name: str | None = None, cfg=None): + cfg = cfg or runtime_settings return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg) -def get_llm_provider(name: str | None = None, cfg: Settings = settings): +def get_llm_provider(name: str | None = None, cfg=None): + cfg = cfg or runtime_settings return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg) -def get_tts_provider(name: str | None = None, cfg: Settings = settings): +def get_tts_provider(name: str | None = None, cfg=None): + cfg = cfg or runtime_settings return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg) @@ -190,8 +194,9 @@ def resolve_route( user: User | None = None, session_id: str | None = None, overrides: dict | None = None, - cfg: Settings = settings, + cfg=None, ) -> ResolvedRoute: + cfg = cfg or runtime_settings """Loest die effektive Route auf. Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request. @@ -247,7 +252,8 @@ def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str: return level -def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator: +def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator: + cfg = cfg or runtime_settings return Orchestrator( stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg), llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg), diff --git a/app/quota.py b/app/quota.py index 06cae8d..720ff0d 100644 --- a/app/quota.py +++ b/app/quota.py @@ -4,7 +4,6 @@ Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_reques ueberschreibbar. 0 bedeutet unbegrenzt. """ -from app.config import settings, Settings from app.metrics import metrics @@ -15,7 +14,10 @@ class QuotaExceededError(Exception): super().__init__(f"Daily request limit reached ({count}/{limit})") -def effective_limit(user, cfg: Settings = settings) -> int: +def effective_limit(user, cfg=None) -> int: + if cfg is None: + from app.runtime_config import runtime_settings + cfg = runtime_settings pref = user.prefs.get("daily_request_limit") if user and user.prefs else None if pref is not None: try: @@ -25,7 +27,7 @@ def effective_limit(user, cfg: Settings = settings) -> int: return cfg.daily_request_limit -def enforce_quota(user, store, cfg: Settings = settings) -> None: +def enforce_quota(user, store, cfg=None) -> None: """Wirft QuotaExceededError, wenn das Tageslimit erreicht ist.""" limit = effective_limit(user, cfg) if limit and limit > 0: diff --git a/app/runtime_config.py b/app/runtime_config.py new file mode 100644 index 0000000..79b0c47 --- /dev/null +++ b/app/runtime_config.py @@ -0,0 +1,97 @@ +"""Laufzeit-Konfigurationsüberschreibungen aus der Datenbank. + +Einzelne Settings-Felder können zur Laufzeit via Admin-UI geändert werden, +ohne den Server neu zu starten. Die Werte liegen in der Tabelle +`config_overrides` und werden mit 30s TTL gecacht. + +Nur Felder aus RUNTIME_SETTABLE sind überschreibbar — alle anderen +kommen weiterhin aus .env / TOML / pydantic-settings. +""" + +import threading +import time +from typing import Any + +from app.config import Settings, settings as _base + +# (label, type_str, hint) +RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { + "default_stt_provider": ("STT-Provider (Standard)", "str", "openrouter | faster-whisper"), + "default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"), + "default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox"), + "default_language": ("Sprache (Standard)", "str", "de | en | …"), + "openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"), + "openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"), + "openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"), + "piper_voice": ("TTS-Stimme (piper)", "str", "z.B. de_DE-thorsten-high"), + "local_llm_system_prompt": ("Systemprompt (lokal)", "str", "Freier Text"), + "local_llm_temperature": ("Temperatur (lokal)", "float", "0.0–2.0"), + "local_llm_max_tokens": ("Max. Tokens (lokal)", "int", "0 = kein Limit"), + "tts_normalize_level": ("TTS-Normalisierung", "str", "auto | full | light | off"), + "audio_stream_default": ("Audio-Streaming Standard", "bool", "true | false"), + "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), + "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), + "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), +} + +_TTL = 30.0 +_cache: dict[str, str] = {} +_cache_time: float = 0.0 +_lock = threading.Lock() + + +def _coerce(key: str, raw: str) -> Any: + _, type_str, _ = RUNTIME_SETTABLE[key] + try: + if type_str == "bool": + return raw.strip().lower() in ("1", "true", "yes") + if type_str == "int": + return int(raw) + if type_str == "float": + return float(raw) + except (ValueError, AttributeError): + pass + return raw + + +def _get_cache() -> dict[str, str]: + global _cache, _cache_time + now = time.monotonic() + with _lock: + if now - _cache_time < _TTL: + return _cache + try: + from app.dependencies import get_store + _cache = get_store().get_config_overrides() + _cache_time = now + except Exception: + pass + return _cache + + +def invalidate_cache() -> None: + global _cache_time + with _lock: + _cache_time = 0.0 + + +class RuntimeSettings: + """Wraps Settings; liest überschreibbare Felder aus der DB (30s TTL).""" + + def __init__(self, base: Settings): + object.__setattr__(self, "_base", base) + + def __getattr__(self, name: str) -> Any: + if name in RUNTIME_SETTABLE: + overrides = _get_cache() + if name in overrides: + return _coerce(name, overrides[name]) + return getattr(object.__getattribute__(self, "_base"), name) + + # Delegiere Pydantic-Metadaten ans Basis-Objekt. + @property + def model_fields(self): + return self._base.model_fields + + +runtime_settings = RuntimeSettings(_base) diff --git a/app/store.py b/app/store.py index 4c50f11..d7624ba 100644 --- a/app/store.py +++ b/app/store.py @@ -152,6 +152,15 @@ class Store(ABC): @abstractmethod def get_all_usage(self) -> list[dict]: ... + @abstractmethod + def get_config_overrides(self) -> dict[str, str]: ... + + @abstractmethod + def set_config_override(self, key: str, value: str) -> None: ... + + @abstractmethod + def delete_config_override(self, key: str) -> bool: ... + class SQLiteStore(Store): def __init__(self, db_path: str): @@ -216,6 +225,11 @@ class SQLiteStore(Store): snippet TEXT NOT NULL, created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS config_overrides ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); """ ) # Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da). @@ -551,3 +565,21 @@ class SQLiteStore(Store): " GROUP BY u.user_id ORDER BY total_requests DESC", ).fetchall() return [dict(r) for r in rows] + + def get_config_overrides(self) -> dict[str, str]: + with self._connect() as conn: + rows = conn.execute("SELECT key, value FROM config_overrides").fetchall() + return {r["key"]: r["value"] for r in rows} + + def set_config_override(self, key: str, value: str) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO config_overrides(key, value, updated_at) VALUES(?,?,?)" + " ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (key, value, _now()), + ) + + def delete_config_override(self, key: str) -> bool: + with self._connect() as conn: + cur = conn.execute("DELETE FROM config_overrides WHERE key=?", (key,)) + return cur.rowcount > 0 diff --git a/app/web/app.js b/app/web/app.js index bfe5f9c..ed8af5f 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -385,6 +385,7 @@ function switchAdminTab(name) { if (name === "metrics") loadMetrics(); if (name === "words") loadWoerterbuch(); if (name === "log") { /* User muss manuell verbinden */ } + if (name === "settings") loadSettings(); } document.querySelectorAll(".admin-tab").forEach((btn) => @@ -968,3 +969,75 @@ function disconnectLog() { $("#log-connect").addEventListener("click", connectLog); $("#log-disconnect").addEventListener("click", disconnectLog); $("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; }); + +// ════════════════════════════════════════ +// TAB: EINSTELLUNGEN +// ════════════════════════════════════════ +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; } + container.innerHTML = ""; + data.forEach((item) => container.appendChild(buildSettingRow(item))); +} + +function buildSettingRow(item) { + const wrap = document.createElement("div"); + 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 inputId = "cfg-" + item.key; + const isLong = item.type === "str" && item.base_value.length > 60; + + wrap.innerHTML = ` +
+
+ + ${escHtml(item.key)} + ${overridden ? 'überschrieben' : ""} +
+ ${overridden ? `` : ""} +
+
+ ${isLong + ? `` + : `` + } + +
+

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

+ `; + + const inp = wrap.querySelector(`#${inputId}`); + + wrap.querySelector(".save-btn").addEventListener("click", async () => { + const val = inp.value ?? inp.textContent ?? ""; + const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val }); + if (res !== null) loadSettings(); + }); + + 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(); + }); + } + + return wrap; +} + +$("#load-settings").addEventListener("click", loadSettings); diff --git a/app/web/index.html b/app/web/index.html index c2681c7..d451c05 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -38,6 +38,7 @@ + @@ -164,6 +165,21 @@ + + + @@ -202,6 +218,6 @@
- +