feat(cookbook): autonome Modell-Entdeckung aus vertrauenswuerdigen Quellen

"Aktuell beste Modelle fuer dein System": fragt vertrauenswuerdige HF-Orgs
(unsloth/bartowski/ggml-org/lmstudio-community) LIVE ab, kategorisiert die
Treffer (vision/coder/reasoning/agent/scout), filtert per hw_math auf das,
was auf die Hardware passt, und cached das Ergebnis (TTL 12 h, lazy + Knopf
"Aktualisieren"). Damit bleibt das Cookbook von selbst aktuell, ohne dass
Modelle hartkodiert werden.

- sources.py: TRUSTED_AUTHORS + CATEGORIES + SKIP_TOKENS (reine Daten).
- config.py: DISCOVER_CACHE_PATH (persistent neben den Modellen, uebersteht
  Deploys) + DISCOVER_TTL.
- cookbook.py: /api/cookbook/discover (force-Param), refresh_discover,
  Bestandsabgleich (_model_installed -> "schon installiert als X") und
  ehrliche Voraussetzungen je Modell (Vision->mmproj/jinja, unquantisiert,
  zu gross/knapp).
- cookbook.js: Sektion mit Kategorien, Fit-Ampel, Downloads, Hinweisen,
  1-Klick-Installieren bzw. "installiert"-Markierung; "Aktualisieren"-Knopf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 16:47:37 +02:00
parent 0ed4dd84b8
commit 0c16fb28c2
4 changed files with 266 additions and 2 deletions
+5
View File
@@ -20,6 +20,11 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Eigene Cookbook-Setups (vom Nutzer angelegt). Bewusst NICHT im App-Verzeichnis, sonst # Eigene Cookbook-Setups (vom Nutzer angelegt). Bewusst NICHT im App-Verzeichnis, sonst
# wuerde ein Deploy (rsync) sie ueberschreiben -> persistent neben den Modellen ablegen. # wuerde ein Deploy (rsync) sie ueberschreiben -> persistent neben den Modellen ablegen.
USER_RECIPES_PATH = Path(os.environ.get("MC_USER_RECIPES", str(MODELS_DIR / "mission-control-recipes.json"))) USER_RECIPES_PATH = Path(os.environ.get("MC_USER_RECIPES", str(MODELS_DIR / "mission-control-recipes.json")))
# Cache der automatischen Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Ebenfalls persistent neben den Modellen (uebersteht Deploys). TTL = wie lange der Cache
# als frisch gilt, bevor lazy neu von den Quellen geladen wird (Default 12 h).
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mission-control-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200"))
# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird. # Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird.
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen. # {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README). # WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
+150 -1
View File
@@ -6,6 +6,7 @@ import httpx
import json import json
import os import os
import re import re
import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import psutil import psutil
@@ -14,10 +15,12 @@ from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth from auth import auth
from hw_math import evaluate_fit, max_ctx_for from hw_math import evaluate_fit, max_ctx_for
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH, hf_bin from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH,
DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin)
from llamaswap import read_config, write_config from llamaswap import read_config, write_config
from jobengine import start_job, JOBS, attach_download_progress from jobengine import start_job, JOBS, attach_download_progress
from recipes import RECIPES, UPGRADES from recipes import RECIPES, UPGRADES
from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)]) router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
@@ -286,6 +289,152 @@ def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None:
return (pref or nosplit or ggufs)[0] return (pref or nosplit or ggufs)[0]
# ---------------------------------------------------------------------------
# Automatische Modell-Entdeckung ("aktuell beste Modelle", live von HF + Cache).
# ---------------------------------------------------------------------------
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout" # Fallback: Allrounder
def _installed_index() -> dict:
"""alias -> kleingeschriebene cmd-Zeile der installierten Modelle (fuer Bestandsabgleich)."""
return {alias: str(spec.get("cmd", "")).lower()
for alias, spec in (read_config().get("models") or {}).items()}
def _model_installed(repo: str, idx: dict) -> str | None:
"""Liefert den Alias, unter dem dieses Repo schon installiert ist — sonst None."""
base = repo.split("/")[-1].lower()
# GGUF-Repos heissen meist '<modell>-GGUF' -> auch ohne Suffix vergleichen
stem = base[:-5] if base.endswith("-gguf") else base
for alias, cmd in idx.items():
if base in cmd or (stem and stem in cmd):
return alias
return None
def _model_requirements(role: str, quant: str, fit: dict) -> list[str]:
"""Ehrliche Voraussetzungen/Hinweise pro Modell (Klartext fuer Anfaenger)."""
reqs = []
if role == "vision":
reqs.append("Bild-Modell: Projektor (mmproj) + --jinja werden beim Einpflegen automatisch ergänzt.")
if quant.upper() in ("FP16", "BF16", "F16", "F32"):
reqs.append("Unquantisiert — sehr groß. Eine Q4/Q5-Variante ist meist die bessere Wahl.")
if fit["level"] == "too_tight":
reqs.append("Größer als dein Speicher — nur mit kleinerem Quant realistisch.")
elif fit["level"] == "marginal":
reqs.append("Läuft, wird aber knapp — andere Programme währenddessen besser schließen.")
return reqs
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception: # noqa: BLE001
return []
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, nach Hardware-Fit + Beliebtheit ranken
und cachen. Wirft nur, wenn KEINE einzige Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0:
raise RuntimeError("Keine Quelle erreichbar.")
by_cat = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant fuer die Fit-Einschaetzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb)
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
})
cats = []
for c in CATEGORIES:
items = by_cat[c["role"]]
# Passendes zuerst (perfect/marginal vor too_tight), dann nach Downloads.
items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True)
top = items[:4]
if top:
cats.append({"role": c["role"], "title": c["title"], "icon": c["icon"], "models": top})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception: # noqa: BLE001
pass # Cache ist nur Beschleunigung — Entdeckung funktioniert auch ohne Schreibrecht
return data
def _load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
pass
return None
@router.get("/discover")
def discover(force: bool = False):
"""Aktuell beste Modelle je Kategorie — live aus den Quellen (gecacht, TTL).
Markiert pro Modell, ob es schon installiert ist, und nennt Voraussetzungen."""
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
cached = _load_discover()
fresh = bool(cached) and (time.time() - cached.get("updated", 0) < DISCOVER_TTL)
if fresh and not force:
data = cached
else:
try:
data = refresh_discover(ram_gb)
except Exception: # noqa: BLE001
if cached:
data = cached # Quellen down -> alter Cache ist besser als nichts
else:
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut versuchen.")
# Bestand + Voraussetzungen frisch dazurechnen (aendern sich unabhaengig vom Cache).
idx = _installed_index()
out = []
for c in data["categories"]:
ms = [{**m, "installed": _model_installed(m["repo"], idx),
"requirements": _model_requirements(m["role"], m.get("quant", "Q4_K_M"), m["fit"])}
for m in c["models"]]
out.append({**c, "models": ms})
return {"updated": data["updated"], "categories": out,
"sys_ram_gb": round(ram_gb, 1), "stale": not fresh}
def compute_upgrades(ram_gb): def compute_upgrades(ram_gb):
"""Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map).""" """Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map)."""
installed = (read_config().get("models") or {}) installed = (read_config().get("models") or {})
+33
View File
@@ -0,0 +1,33 @@
"""
Vertrauenswuerdige Quellen + Kategorien fuer die AUTOMATISCHE Modell-Entdeckung
(Cookbook „aktuell beste Modelle"). Bewusst nur Daten, kein Code.
Idee: Statt Modelle hartzukodieren, fragt MC diese HF-Orgs live ab (sie pflegen
zuverlaessig hochwertige GGUF-Quants), ordnet die Treffer per Stichwort einer
Kategorie zu, filtert per hw_math auf „passt zu deiner Hardware" und cached das
Ergebnis (siehe DISCOVER_CACHE_PATH/_TTL in config). So bleibt das Cookbook von
selbst aktuell, ohne Code-Aenderung.
"""
# HF-Orgs, die zuverlaessig aktuelle, hochwertige GGUF-Quants veroeffentlichen.
TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Prioritaet). Ein Modell wird der
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; bleibt nichts
# uebrig, faellt es in „scout" (Allrounder). Die `role` ist zugleich der Alias-Vorschlag.
CATEGORIES = [
{"role": "vision", "title": "Bilder verstehen", "icon": "eye",
"kw": ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"]},
{"role": "coder", "title": "Coden & Programmieren", "icon": "code",
"kw": ["coder", "-code-", "code-", "codestral", "starcoder"]},
{"role": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
"kw": ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"]},
{"role": "agent", "title": "Agenten & Tool-Use", "icon": "layers",
"kw": ["hermes", "-tool", "command-r", "watt", "-fc-", "function"]},
{"role": "scout", "title": "Allrounder & Chat", "icon": "compass",
"kw": []}, # Fallback: instruct/chat-Modelle
]
# Repo-Namensteile, die wir bei der Entdeckung ueberspringen (Roh-/Spezialformate,
# die fuer Anfaenger nicht die richtige Wahl sind).
SKIP_TOKENS = ["-base", "-bnb-", "-gptq", "-awq", "-fp8", "draft", "tokenizer"]
+78 -1
View File
@@ -39,7 +39,13 @@ function mount() {
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div> <div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
</div> </div>
<details class="guide-acc card" style="padding:0;overflow:hidden;margin-top:8px"> <div class="card-h" style="align-items:center;margin-top:20px"><h3>Aktuell beste Modelle für dein System</h3>
<span class="chip" id="cb-disc-when" style="margin-left:auto"></span>
<button class="ghost" id="cb-disc-refresh" style="margin-left:10px">Aktualisieren</button></div>
<div class="card-sub" style="margin:-6px 0 10px">Automatisch aus vertrauenswürdigen Quellen (HuggingFace) — laufend aktuell, gefiltert auf das, was auf deine Hardware passt.</div>
<div id="cb-discover"><div class="empty" style="text-align:center">Lade aktuelle Empfehlungen…</div></div>
<details class="guide-acc card" style="padding:0;overflow:hidden;margin-top:18px">
<summary>Profi-Modus: HuggingFace direkt durchsuchen</summary> <summary>Profi-Modus: HuggingFace direkt durchsuchen</summary>
<div class="acc-body"> <div class="acc-body">
<div class="flex gap-3" style="margin-bottom:8px"> <div class="flex gap-3" style="margin-bottom:8px">
@@ -135,8 +141,79 @@ function mount() {
$("#cb-new-models").addEventListener("click", e => { const d = e.target.closest(".cb-nm-del"); if (d) d.closest(".cb-nm-row").remove(); }); $("#cb-new-models").addEventListener("click", e => { const d = e.target.closest(".cb-nm-del"); if (d) d.closest(".cb-nm-row").remove(); });
$("#cb-new-save").addEventListener("click", saveNewRecipe); $("#cb-new-save").addEventListener("click", saveNewRecipe);
$("#cb-disc-refresh").addEventListener("click", () => loadDiscover(true));
$("#cb-discover").addEventListener("click", e => {
const b = e.target.closest("[data-inst]"); if (!b) return;
installDiscovered(b.getAttribute("data-inst"), b.getAttribute("data-role"), b.getAttribute("data-pb"), b);
});
renderHwChip(); renderHwChip();
loadRecipes(); loadRecipes();
loadDiscover();
}
// ---- Automatische Modell-Entdeckung („aktuell beste Modelle") ----
function fmtAgo(ts) {
const s = Date.now() / 1000 - ts;
if (s < 90) return "gerade eben";
if (s < 3600) return `vor ${Math.round(s / 60)} min`;
if (s < 86400) return `vor ${Math.round(s / 3600)} h`;
return `vor ${Math.round(s / 86400)} Tg.`;
}
async function loadDiscover(force = false) {
const box = $("#cb-discover"), when = $("#cb-disc-when"), btn = $("#cb-disc-refresh");
if (force) { btn.disabled = true; btn.textContent = "Suche…"; box.innerHTML = `<div class="empty" style="text-align:center">Frage Quellen ab…</div>`; }
try {
const d = await api("/api/cookbook/discover" + (force ? "?force=true" : ""));
if (when) when.textContent = d.updated ? `aktualisiert ${fmtAgo(d.updated)}` : "";
renderDiscover(d);
} catch (e) {
box.innerHTML = `<div class="alert warn" style="margin:0"><span class="a-dot"></span><span>Empfehlungen nicht ladbar: ${esc(e.message)}</span></div>`;
}
btn.disabled = false; btn.textContent = "Aktualisieren";
}
function renderDiscover(d) {
const box = $("#cb-discover");
const cats = d.categories || [];
if (!cats.length) { box.innerHTML = `<div class="empty" style="text-align:center">Keine passenden Modelle gefunden.</div>`; return; }
box.innerHTML = cats.map(c => `
<div style="margin-bottom:8px">
<div class="flex items-center gap-2" style="margin:6px 0 8px"><span class="text-accent">${icon(c.icon)}</span>
<h4 style="margin:0;font-size:13.5px;font-weight:600">${esc(c.title)}</h4></div>
<div class="grid grid-3">${c.models.map(discCard).join("")}</div>
</div>`).join("");
}
function discCard(m) {
const reqs = (m.requirements || []).map(r =>
`<div class="li-sub" style="color:var(--warn);margin-top:3px">⚠ ${esc(r)}</div>`).join("");
const action = m.installed
? `<span class="fit-badge ok" title="bereits eingerichtet als ${esc(m.installed)}">✓ installiert</span>`
: `<button class="primary" data-inst="${esc(m.repo)}" data-role="${esc(m.role)}" data-pb="${m.params_b}"
style="padding:6px 12px;font-size:12.5px">Installieren</button>`;
return `<div class="card" style="display:flex;flex-direction:column">
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0"><h3 style="margin:0;font-size:14px;font-weight:500;word-break:break-word">${esc(m.name)}</h3>
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author)} · ${m.params_b}B</div></div>
<span class="fit-badge ${fitCls(m.fit.level)}">${esc(m.fit.text)}</span>
</div>
<div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">${metricLine(m.fit)} · ⬇ ${(m.downloads || 0).toLocaleString()}</div>
${reqs}
<div style="flex:1;min-height:8px"></div>
<div class="flex justify-end" style="margin-top:10px">${action}</div>
</div>`;
}
async function installDiscovered(repo, role, params_b, btn) {
btn.disabled = true; btn.textContent = "Starte…";
try {
await api("/api/cookbook/install-model", { method: "POST",
body: JSON.stringify({ repo, role, params_b: parseFloat(params_b) || 7, quant: "Q4_K_M", hf_token: getHfToken() }) });
toast("Download gestartet — siehe Aktivität.");
document.querySelector(".nav-item[data-view='activity']")?.click();
} catch (e) { toast("Fehler: " + e.message, true); btn.disabled = false; btn.textContent = "Installieren"; }
} }
// ---- Use-Case-Setups ---- // ---- Use-Case-Setups ----