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:
parent
2ccfd7f6d6
commit
18ad442c94
6 changed files with 243 additions and 2 deletions
|
|
@ -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
43
app/model_presets.py
Normal 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
|
||||
|
|
@ -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)
|
||||
// ════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -411,6 +411,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js?v=52"></script>
|
||||
<script src="/app.js?v=53"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue