From 0c16fb28c2158cd23cd24805f17a50d880bd4486 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Mon, 22 Jun 2026 16:47:37 +0200 Subject: [PATCH] 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 --- config.py | 5 ++ routers/cookbook.py | 151 ++++++++++++++++++++++++++++++++++- sources.py | 33 ++++++++ static/js/panels/cookbook.js | 79 +++++++++++++++++- 4 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 sources.py diff --git a/config.py b/config.py index 5c4cd4c..55d6b04 100644 --- a/config.py +++ b/config.py @@ -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). diff --git a/routers/cookbook.py b/routers/cookbook.py index 258261e..6ebb72f 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -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 '-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 {}) diff --git a/sources.py b/sources.py new file mode 100644 index 0000000..b5f4a4a --- /dev/null +++ b/sources.py @@ -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"] diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index bd1d2cd..97fad19 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -39,7 +39,13 @@ function mount() {
Lade Setups…
-
+

Aktuell beste Modelle für dein System

+ +
+
Automatisch aus vertrauenswürdigen Quellen (HuggingFace) — laufend aktuell, gefiltert auf das, was auf deine Hardware passt.
+
Lade aktuelle Empfehlungen…
+ +
Profi-Modus: HuggingFace direkt durchsuchen
@@ -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 = `
Frage Quellen ab…
`; } + 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 = `
Empfehlungen nicht ladbar: ${esc(e.message)}
`; + } + btn.disabled = false; btn.textContent = "Aktualisieren"; +} + +function renderDiscover(d) { + const box = $("#cb-discover"); + const cats = d.categories || []; + if (!cats.length) { box.innerHTML = `
Keine passenden Modelle gefunden.
`; return; } + box.innerHTML = cats.map(c => ` +
+
${icon(c.icon)} +

${esc(c.title)}

+
${c.models.map(discCard).join("")}
+
`).join(""); +} + +function discCard(m) { + const reqs = (m.requirements || []).map(r => + `
⚠ ${esc(r)}
`).join(""); + const action = m.installed + ? `✓ installiert` + : ``; + return `
+
+

${esc(m.name)}

+
${esc(m.author)} · ${m.params_b}B
+ ${esc(m.fit.text)} +
+
${metricLine(m.fit)} · ⬇ ${(m.downloads || 0).toLocaleString()}
+ ${reqs} +
+
${action}
+
`; +} + +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 ----