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:
@@ -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
|
||||
# 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")))
|
||||
# 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.
|
||||
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
|
||||
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
|
||||
|
||||
+150
-1
@@ -6,6 +6,7 @@ import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import psutil
|
||||
@@ -14,10 +15,12 @@ from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from auth import auth
|
||||
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 jobengine import start_job, JOBS, attach_download_progress
|
||||
from recipes import RECIPES, UPGRADES
|
||||
from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS
|
||||
|
||||
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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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):
|
||||
"""Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map)."""
|
||||
installed = (read_config().get("models") or {})
|
||||
|
||||
+33
@@ -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"]
|
||||
@@ -39,7 +39,13 @@ function mount() {
|
||||
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</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>
|
||||
<div class="acc-body">
|
||||
<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-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();
|
||||
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 ----
|
||||
|
||||
Reference in New Issue
Block a user