feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen

Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).

- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
  RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
  (nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
  local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
  POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
  setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
  sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.

Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 20:02:44 +02:00
commit 18ad442c94
6 changed files with 243 additions and 2 deletions

View file

@ -19,6 +19,7 @@ from app.audit import log_admin_action
from app.auth import CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user
from app.config import settings
from app.dependencies import get_store
from app.model_presets import LLM_KEYS, load_presets
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
from app.schemas import AdminUserPrefsUpdate, MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate, UserAdminUpdate
@ -451,6 +452,50 @@ async def delete_runtime_config(key: str, request: Request):
return {"deleted": key}
# ── Erprobte KI-Modelle (Dropdown) ─────────────────────────────────────────
class ModelSelect(BaseModel):
index: int
@router.get("/admin/models", dependencies=[Depends(require_admin)])
async def list_model_presets():
"""Kuratierte Modell-Presets + aktuell aktives LLM (für das Admin-Dropdown)."""
presets = load_presets()
effective = {k: str(getattr(runtime_settings, k, "") or "") for k in LLM_KEYS}
overrides = get_store().get_config_overrides()
no_llm_override = not any(k in overrides for k in LLM_KEYS)
active = None
for p in presets:
if p["is_default"]:
if no_llm_override:
active = p["index"]
elif p["set"] and all(effective.get(k) == v for k, v in p["set"].items()):
if active is None:
active = p["index"]
return {"presets": presets, "current": effective, "active_index": active}
@router.post("/admin/models/select", dependencies=[Depends(require_admin)])
async def select_model_preset(body: ModelSelect, request: Request):
"""Wendet ein Preset an: Default -> LLM-Overrides löschen (Config-Standard);
sonst die Override-Keys des Presets setzen. Wirkt sofort (kein Neustart)."""
preset = next((p for p in load_presets() if p["index"] == body.index), None)
if preset is None:
raise HTTPException(status_code=404, detail=f"Preset {body.index} nicht gefunden.")
store = get_store()
if preset["is_default"]:
for key in LLM_KEYS:
store.delete_config_override(key)
log_admin_action(request, "model_preset_reset", label=preset["label"])
else:
for key, value in preset["set"].items():
store.set_config_override(key, value)
log_admin_action(request, "model_preset_set", label=preset["label"], set=preset["set"])
invalidate_cache()
return {"selected": preset["label"], "set": preset["set"], "is_default": preset["is_default"]}
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
@router.websocket("/admin/log")

43
app/model_presets.py Normal file
View file

@ -0,0 +1,43 @@
"""Kuratierte „erprobte" KI-Modell-Presets fürs Admin-Dropdown (System Status).
Geladen aus ``config/models.yaml``. Jede Auswahl stellt Provider + Modell als
Laufzeit-Override um (siehe RUNTIME_SETTABLE) ohne Neustart. Ein Preset mit
``default: true`` steht für den Config-Standard: seine Auswahl ENTFERNT die
LLM-Overrides, sodass wieder Profil/TOML greift.
"""
from pathlib import Path
import yaml
from app.runtime_config import RUNTIME_SETTABLE
_PATH = Path(__file__).resolve().parent.parent / "config" / "models.yaml"
# LLM-Schlüssel, die ein Preset/Reset berührt (Schnittmenge mit RUNTIME_SETTABLE).
LLM_KEYS = ("default_llm_provider", "openrouter_llm_model", "local_llm_model")
def load_presets() -> list[dict]:
"""Liest die Presets aus der YAML. Unbekannte/leere Datei -> leere Liste.
Nur Keys aus RUNTIME_SETTABLE werden im `set` übernommen (Sicherheit)."""
try:
data = yaml.safe_load(_PATH.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return []
out: list[dict] = []
for i, p in enumerate(data.get("presets", []) or []):
if not isinstance(p, dict):
continue
sets = {
k: str(v)
for k, v in (p.get("set") or {}).items()
if k in RUNTIME_SETTABLE
}
out.append({
"index": i,
"label": str(p.get("label") or f"Preset {i}"),
"note": str(p.get("note") or ""),
"is_default": bool(p.get("default")),
"set": sets,
})
return out

View file

@ -1570,13 +1570,36 @@ async function loadStatus() {
const container = $("#status-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
try {
const [cfg, met, runtimeCfg] = await Promise.all([
const [cfg, met, runtimeCfg, models] = await Promise.all([
fetch("/api/config").then((r) => r.json()),
fetch("/api/metrics").then((r) => r.json()),
adminFetch("/api/admin/config"),
adminFetch("/api/admin/models"),
]);
const cfgByKey = Object.fromEntries((runtimeCfg || []).map((d) => [d.key, d.effective_value]));
const llmModel = cfgByKey["openrouter_llm_model"] || "—";
// Dropdown „Zentrales KI-Modell" (erprobte Presets, Auswahl wirkt sofort).
const presets = (models && models.presets) || [];
const activeIdx = models ? models.active_index : null;
const cur = (models && models.current) || {};
const curProv = cur.default_llm_provider || "—";
const curModel = curProv === "openrouter"
? (cur.openrouter_llm_model || "—") : (cur.local_llm_model || "—");
const modelOptions = presets.map((p) =>
`<option value="${p.index}"${p.index === activeIdx ? " selected" : ""}>${escHtml(p.label)}</option>`).join("");
const modelCard = presets.length ? `
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-1">Zentrales KI-Modell</h3>
<p class="text-xs text-slate-400 mb-3">Erprobte Modelle Auswahl wirkt sofort (kein Neustart).</p>
<div class="flex flex-wrap items-center gap-2">
<select id="model-preset" class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5">
${modelOptions}
</select>
<span id="model-preset-msg" class="text-xs text-slate-400"></span>
</div>
<p class="text-xs text-slate-400 mt-2">Aktiv: <span class="font-mono text-slate-600 dark:text-slate-300">${escHtml(curProv)} · ${escHtml(curModel)}</span></p>
</div>` : "";
const route = cfg.default_route || {};
const counters = met.counters || {};
const turnsTotal = counters["turns_total"] || 0;
@ -1602,6 +1625,8 @@ async function loadStatus() {
</dl>
</div>
${modelCard}
<!-- Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Provider (Standard-Route)</h3>
@ -1633,12 +1658,29 @@ async function loadStatus() {
</dl>
</div>
`;
wireModelPicker();
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
}
}
// Dropdown „Zentrales KI-Modell": Auswahl wendet ein Preset an (Laufzeit-Override).
function wireModelPicker() {
const sel = $("#model-preset");
const msg = $("#model-preset-msg");
if (!sel) return;
sel.addEventListener("change", async () => {
msg.textContent = "stelle um …";
sel.disabled = true;
const res = await adminFetch("/api/admin/models/select", "POST", { index: +sel.value });
sel.disabled = false;
if (res === null) { msg.textContent = "Fehler beim Umstellen"; return; }
msg.textContent = "✓ " + (res.selected || "umgestellt");
loadStatus(); // Karte + „Aktiv"-Anzeige aktualisieren
});
}
// ════════════════════════════════════════
// TAB: WARTUNG (Konfiguration)
// ════════════════════════════════════════

View file

@ -411,6 +411,6 @@
</div>
</div>
<script src="/app.js?v=52"></script>
<script src="/app.js?v=53"></script>
</body>
</html>