diff --git a/recipes.py b/recipes.py new file mode 100644 index 0000000..07b33fa --- /dev/null +++ b/recipes.py @@ -0,0 +1,81 @@ +""" +Kuratierte Use-Case-"Setups" (Stacks) fuers Cookbook 2.0. + +Idee: Der Nutzer waehlt *wofuer* er es braucht — nicht *welches Modell*. Jedes Setup ist eine +Kombination aus Rollen (z.B. Haupt-Coder + schneller Coder + Vision). Hardware-Fit + optimaler +Kontext werden zur Laufzeit aus der echten RAM-Groesse berechnet (hw_math), nicht hier hartkodiert. + +Daten, kein Code — bewusst getrennt (KISS/SoC). Repo/Datei sind unsloth-GGUFs auf HuggingFace. +""" + +RECIPES = [ + { + "id": "coding", "title": "Coden & Programmieren", "icon": "code", + "desc": "Ein starker Haupt-Coder, ein flinker für Autovervollständigung und Vision für Screenshots.", + "models": [ + {"role": "coder", "name": "Qwen2.5 Coder 32B", "repo": "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF", + "file": "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf", "params_b": 32, "quant": "Q4_K_M", + "why": "Hauptmodell — versteht große Codebasen und komplexe Aufgaben."}, + {"role": "coder-fast", "name": "Qwen2.5 Coder 7B", "repo": "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", + "file": "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M", + "why": "Schnelle Autovervollständigung & einfache Edits."}, + {"role": "vision", "name": "Llama 3.2 Vision 11B", "repo": "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", + "file": "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf", "params_b": 11, "quant": "Q4_K_M", + "why": "Liest Screenshots & Fehlerbilder für die Analyse."}, + ], + }, + { + "id": "vision", "title": "Bilder verstehen", "icon": "eye", + "desc": "Ein multimodales Modell, das Bilder und Text gemeinsam versteht.", + "models": [ + {"role": "vision", "name": "Llama 3.2 Vision 11B", "repo": "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", + "file": "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf", "params_b": 11, "quant": "Q4_K_M", + "why": "Beschreibt Bilder, liest Diagramme, analysiert Screenshots."}, + ], + }, + { + "id": "chat", "title": "Allrounder & Chat", "icon": "compass", + "desc": "Ein vielseitiges Modell für alltägliche Fragen, Texte und Brainstorming.", + "models": [ + {"role": "scout", "name": "Qwen2.5 7B", "repo": "unsloth/Qwen2.5-7B-Instruct-GGUF", + "file": "Qwen2.5-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M", + "why": "Schneller, kluger Allrounder — guter Standard."}, + {"role": "scout-pro", "name": "Qwen2.5 14B", "repo": "unsloth/Qwen2.5-14B-Instruct-GGUF", + "file": "Qwen2.5-14B-Instruct-Q4_K_M.gguf", "params_b": 14, "quant": "Q4_K_M", + "why": "Mehr Tiefe, wenn die Antworten besser sein sollen."}, + ], + }, + { + "id": "longdoc", "title": "Lange Dokumente", "icon": "file", + "desc": "Großes Modell mit großem Kontext — für lange Texte, Verträge, Bücher.", + "models": [ + {"role": "reader", "name": "Qwen2.5 14B", "repo": "unsloth/Qwen2.5-14B-Instruct-GGUF", + "file": "Qwen2.5-14B-Instruct-Q4_K_M.gguf", "params_b": 14, "quant": "Q4_K_M", + "why": "Solide Qualität bei großem Kontextfenster (auto-optimiert)."}, + ], + }, + { + "id": "agents", "title": "Agenten & Tool-Use", "icon": "layers", + "desc": "Modelle, die gut mit Werkzeugen/Funktionen umgehen — für autonome Agenten.", + "models": [ + {"role": "agent", "name": "Qwen2.5 32B", "repo": "unsloth/Qwen2.5-32B-Instruct-GGUF", + "file": "Qwen2.5-32B-Instruct-Q4_K_M.gguf", "params_b": 32, "quant": "Q4_K_M", + "why": "Stark im Function-Calling und mehrstufigem Denken."}, + {"role": "agent-fast", "name": "Qwen2.5 7B", "repo": "unsloth/Qwen2.5-7B-Instruct-GGUF", + "file": "Qwen2.5-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M", + "why": "Schneller Helfer für einfache Tool-Schritte."}, + ], + }, + { + "id": "fast", "title": "Schnell & sparsam", "icon": "bolt", + "desc": "Kleine, flinke Modelle — ideal bei wenig Speicher oder für schnelle Antworten.", + "models": [ + {"role": "mini", "name": "Qwen2.5 3B", "repo": "unsloth/Qwen2.5-3B-Instruct-GGUF", + "file": "Qwen2.5-3B-Instruct-Q4_K_M.gguf", "params_b": 3, "quant": "Q4_K_M", + "why": "Winzig & sehr schnell für einfache Aufgaben."}, + {"role": "coder-fast", "name": "Qwen2.5 Coder 7B", "repo": "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", + "file": "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M", + "why": "Flinker Coder, falls es doch mal Code sein soll."}, + ], + }, +] diff --git a/routers/cookbook.py b/routers/cookbook.py index 8b24d53..4cbcc7d 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -8,11 +8,19 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel import psutil +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 +from llamaswap import read_config, write_config +from jobengine import start_job, JOBS +from recipes import RECIPES router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)]) +_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} + class AnalyzeRequest(BaseModel): repo_id: str ctx: int = 8192 @@ -22,6 +30,9 @@ class EvaluateRequest(BaseModel): quant: str ctx: int +class InstallRecipeReq(BaseModel): + recipe_id: str + def extract_params_b(repo_id: str) -> float: """Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen.""" # z.B. Qwen2.5-Coder-32B -> 32 @@ -100,3 +111,47 @@ def evaluate_single(req: EvaluateRequest): fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb) return fit + +@router.get("/recipes") +def recipes(): + """Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN + Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit.""" + ram_gb = psutil.virtual_memory().total / (1024 ** 3) + out = [] + for r in RECIPES: + models, worst = [], "perfect" + for m in r["models"]: + fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb) + models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)}) + if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]: + worst = fit["level"] + out.append({**r, "models": models, "fit_level": worst}) + return {"recipes": out, "sys_ram_gb": round(ram_gb, 1)} + + +@router.post("/install-recipe") +def install_recipe(req: InstallRecipeReq): + """Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit + optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config) + übernimmt es, sobald die Datei da ist.""" + recipe = next((r for r in RECIPES if r["id"] == req.recipe_id), None) + if not recipe: + raise HTTPException(404, "Setup nicht gefunden.") + ram_gb = psutil.virtual_memory().total / (1024 ** 3) + cfg = read_config() + job_ids = [] + for m in recipe["models"]: + target = MODELS_DIR / m["repo"].split("/")[-1] + target.mkdir(parents=True, exist_ok=True) + args = ["hf", "download", m["repo"], m["file"], "--local-dir", str(target)] + jid = start_job(args, f"download {m['name']}", env={"HF_XET_HIGH_PERFORMANCE": "1"}) + JOBS[jid]["result_path"] = str(target / m["file"]) + job_ids.append(jid) + # Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart. + ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) + path = str(target / m["file"]) + cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) + cfg["models"][m["role"]] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} + write_config(cfg) + return {"job_ids": job_ids, "count": len(recipe["models"])} + diff --git a/routers/models.py b/routers/models.py index 24a1e69..11fb5d6 100644 --- a/routers/models.py +++ b/routers/models.py @@ -145,8 +145,8 @@ def download(req: DownloadReq): @router.post("/register") def register(req: RegisterReq): - if not Path(req.model_path).exists(): - raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}") + # Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt + # erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist. cfg = read_config() cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx)) cfg["models"][req.alias] = { diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index d75ba94..7ad8e7c 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -1,20 +1,9 @@ -// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile. -// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register. +// cookbook.js — Cookbook 2.0 (v4): Use-Case-Setups („Wofür?") + Profi-Suche. +// Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math). import { api } from "../core/api.js"; import { $, esc, icon, toast } from "../core/ui.js"; -const CURATED = [ - { name: "Qwen 2.5 Coder 7B", repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf", - desc: "Schnell und locker auf deiner Kiste — ideal zum Programmieren.", params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast" }, - { name: "Qwen 2.5 Coder 32B", repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF", file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf", - desc: "Mehr Können, etwas langsamer — der starke Allround-Coder.", params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder" }, - { name: "Llama 3.2 Vision 11B", repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf", - desc: "Versteht Bilder und Text — gut für multimodale Aufgaben.", params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision" }, - { name: "Qwen 2.5 7B", repo: "unsloth/Qwen2.5-7B-Instruct-GGUF", file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf", - desc: "Hervorragender Allrounder — schnell und vielseitig.", params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout" }, -]; - const FILTERS = [ { id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" }, { id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" }, @@ -24,64 +13,79 @@ let lastSys = null; let currentResults = []; let currentAnalysis = null; let activeFilter = ""; +let RECIPES = []; const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad"; +const fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß"; function mount() { const c = $(".view[data-view='cookbook']"); c.innerHTML = `

Modell-Cookbook

-
Finde ein KI-Modell, das auf deinen Mini-PC passt — den Hardware-Check rechnen wir für dich aus.
+
Wofür willst du es nutzen? Wir empfehlen das passende Setup für deine Hardware.
-
- - -
-
-
- +

Wofür möchtest du es nutzen?

+ deine Hardware
+
+
Lade Setups…
-
-

Empfohlen für deine Hardware

- deine Hardware +
+ Profi-Modus: HuggingFace direkt durchsuchen +
+
+ + +
+
+
+ +
+
+ passt locker + läuft, aber knapp + zu groß für deinen Speicher +
+
+
+
+ + -
- passt locker - läuft, aber knapp - zu groß für deinen Speicher -
-
`; @@ -96,75 +100,99 @@ function mount() { $("#cb-m-files").addEventListener("change", updateLiveFit); $("#cb-m-ctx").addEventListener("change", reanalyzeCtx); window.cbSetCtx = v => { $("#cb-m-ctx").value = v; reanalyzeCtx(); }; + $("#cb-r-close").addEventListener("click", () => $("#cb-recipe-modal").style.display = "none"); + $("#cb-recipe-modal").addEventListener("click", e => { if (e.target.id === "cb-recipe-modal") $("#cb-recipe-modal").style.display = "none"; }); renderHwChip(); - renderCurated(); + loadRecipes(); } +// ---- Use-Case-Setups ---- +async function loadRecipes() { + try { + const d = await api("/api/cookbook/recipes"); + RECIPES = d.recipes || []; + renderRecipes(); + } catch (e) { + $("#cb-recipes").innerHTML = `
Setups nicht ladbar: ${esc(e.message)}
`; + } +} + +function renderRecipes() { + $("#cb-recipes").innerHTML = RECIPES.map(r => ` + `).join(""); + $("#cb-recipes").querySelectorAll("[data-recipe]").forEach(b => + b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe")))); +} + +function openRecipe(id) { + const r = RECIPES.find(x => x.id === id); if (!r) return; + $("#cb-r-title").textContent = r.title; + $("#cb-r-desc").textContent = r.desc; + $("#cb-r-models").innerHTML = `
${r.models.map(m => ` +
+
+
+ ${esc(m.name)}${esc(m.role)}
+
${esc(m.why)}
+
~${m.fit.req_gb.toFixed(1)} GB · ~${Math.round(m.fit.tps)} Tok/s · optimal ~${Math.round(m.optimal_ctx / 1024)}k Kontext
+
+ ${esc(m.fit.text)} +
`).join("")}
`; + const btn = $("#cb-r-install"); + btn.className = r.fit_level === "too_tight" ? "primary warn" : "primary"; + btn.textContent = r.fit_level === "too_tight" ? "Trotzdem installieren (zu groß)" : "Komplettes Setup installieren"; + btn.onclick = () => installRecipe(r.id); + $("#cb-recipe-modal").style.display = "flex"; +} + +async function installRecipe(id) { + const btn = $("#cb-r-install"); btn.disabled = true; btn.textContent = "Starte…"; + try { + const r = await api("/api/cookbook/install-recipe", { method: "POST", body: JSON.stringify({ recipe_id: id }) }); + toast(`Setup wird installiert (${r.count} Modelle) — siehe Aktivität.`); + $("#cb-recipe-modal").style.display = "none"; + document.querySelector(".nav-item[data-view='activity']")?.click(); + } catch (e) { toast("Fehler: " + e.message, true); } + btn.disabled = false; btn.textContent = "Komplettes Setup installieren"; +} + +// ---- Hardware-Chip / Filter ---- function renderFilters() { $("#cb-filters").innerHTML = FILTERS.map(f => - `` - ).join(""); + ``).join(""); $("#cb-filters").querySelectorAll("[data-f]").forEach(b => b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); })); } - function renderHwChip() { const el = $("#cb-hw"); if (!el) return; - if (lastSys?.ram?.total) { - el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`; - } + if (lastSys?.ram?.total) el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`; } -// ---- Karten ---- +// ---- Profi-Suche (HuggingFace) ---- function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; } -function curatedCard(m, i, fit) { - return `
-

${esc(m.name)}

- ${esc(fit.text)}
-
${esc(m.desc)}
-
- ${metricLine(fit)}${esc(m.quant)}
-
`; -} - -async function renderCurated() { - $("#cb-section-title").textContent = "Empfohlen für deine Hardware"; - const grid = $("#cb-grid"); if (!grid) return; - grid.innerHTML = `
Berechne Hardware-Fit…
`; - try { - let html = ""; - for (let i = 0; i < CURATED.length; i++) { - const m = CURATED[i]; - const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) }); - html += curatedCard(m, i, fit); - } - grid.innerHTML = html; - grid.querySelectorAll("[data-cur]").forEach(el => el.addEventListener("click", () => openCurated(+el.getAttribute("data-cur")))); - } catch (e) { - grid.innerHTML = `
Konnte Empfehlungen nicht laden: ${esc(e.message)}
`; - } -} - async function doSearch() { let q = $("#cb-search").value.trim(); if (activeFilter) q = q ? q + " " + activeFilter : activeFilter; - if (!q) return renderCurated(); - + if (!q) { $("#cb-grid").innerHTML = ""; return; } const sort = $("#cb-sort")?.value || "downloads"; const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…"; - $("#cb-section-title").textContent = "Suchergebnisse: " + q; - const grid = $("#cb-grid"); - grid.innerHTML = `
Suche auf HuggingFace…
`; + $("#cb-grid").innerHTML = `
Suche auf HuggingFace…
`; try { const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`; const r = await fetch(url); currentResults = await r.json(); renderResults(currentResults); - } catch (e) { - grid.innerHTML = `
${esc(e.message)}
`; - } + } catch (e) { $("#cb-grid").innerHTML = `
${esc(e.message)}
`; } btn.disabled = false; btn.textContent = "Suchen"; } @@ -196,12 +224,9 @@ async function fetchFitForCard(i, repo_id) { let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0]; b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text; mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF"); - } catch { - const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } - } + } catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } } } -// ---- Modal ---- function showFit() { const file = $("#cb-m-files").value; const f = currentAnalysis?.files.find(x => x.filename === file); @@ -211,8 +236,7 @@ function showFit() { $("#cb-m-fit-badge").innerHTML = `${esc(f.fit.text)}`; const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec"); if (recEl) recEl.innerHTML = opt - ? `Empfohlener Kontext für deine Hardware: ~${Math.round(opt / 1024)}kübernehmen` - : ""; + ? `Empfohlener Kontext für deine Hardware: ~${Math.round(opt / 1024)}kübernehmen` : ""; const btn = $("#cb-m-download"); if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; } else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; } @@ -253,19 +277,6 @@ async function openResult(i) { } catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; } } -async function openCurated(i) { - const m = CURATED[i]; if (!m) return; - openModalBase(m.name, m.repo); - $("#cb-m-files").style.display = "block"; $("#cb-m-loading").style.display = "none"; - $("#cb-m-files").innerHTML = ``; - $("#cb-m-alias").value = m.alias; $("#cb-m-ctx").value = m.ctx; $("#cb-m-download").disabled = false; - try { - const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) }); - currentAnalysis = { repo: m.repo, params_b: m.params_b, files: [{ filename: m.file, quant: m.quant, fit, optimal_ctx: fit.optimal_ctx }] }; - showFit(); - } catch {} -} - async function doDownload() { const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value; const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;