v4 Schritt 2: Cookbook 2.0 — Use-Case-Setups (Stacks)

- recipes.py: 6 kuratierte Use-Case-Stacks (Coding, Vision, Chat, LongDoc, Agents, Fast).
- cookbook.py: /recipes (Stack-Fit pro Modell + Gesamturteil) + /install-recipe
  (download + register aller Modelle mit optimalem, gedeckeltem Kontext).
- cookbook.js: Use-Case-Karten als Haupteinstieg + Setup-Modal + 'Komplettes Setup
  installieren'; Roh-Suche in 'Profi-Modus' (collapsible).
- models.py /register: exists()-Check entfernt (fixt frischen Download-Install).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 13:36:36 +02:00
parent 654720ed05
commit 29f37bd916
4 changed files with 251 additions and 104 deletions
+81
View File
@@ -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."},
],
},
]
+55
View File
@@ -8,11 +8,19 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import psutil import psutil
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
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)]) router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
class AnalyzeRequest(BaseModel): class AnalyzeRequest(BaseModel):
repo_id: str repo_id: str
ctx: int = 8192 ctx: int = 8192
@@ -22,6 +30,9 @@ class EvaluateRequest(BaseModel):
quant: str quant: str
ctx: int ctx: int
class InstallRecipeReq(BaseModel):
recipe_id: str
def extract_params_b(repo_id: str) -> float: def extract_params_b(repo_id: str) -> float:
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen.""" """Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
# z.B. Qwen2.5-Coder-32B -> 32 # 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) fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
return fit 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"])}
+2 -2
View File
@@ -145,8 +145,8 @@ def download(req: DownloadReq):
@router.post("/register") @router.post("/register")
def register(req: RegisterReq): def register(req: RegisterReq):
if not Path(req.model_path).exists(): # Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt
raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}") # erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist.
cfg = read_config() cfg = read_config()
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx)) cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx))
cfg["models"][req.alias] = { cfg["models"][req.alias] = {
+99 -88
View File
@@ -1,20 +1,9 @@
// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile. // cookbook.js — Cookbook 2.0 (v4): Use-Case-Setups („Wofür?") + Profi-Suche.
// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register. // Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math).
import { api } from "../core/api.js"; import { api } from "../core/api.js";
import { $, esc, icon, toast } from "../core/ui.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 = [ const FILTERS = [
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" }, { id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" }, { id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
@@ -24,18 +13,29 @@ let lastSys = null;
let currentResults = []; let currentResults = [];
let currentAnalysis = null; let currentAnalysis = null;
let activeFilter = ""; let activeFilter = "";
let RECIPES = [];
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad"; 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() { function mount() {
const c = $(".view[data-view='cookbook']"); const c = $(".view[data-view='cookbook']");
c.innerHTML = ` c.innerHTML = `
<div class="pagehead"><div> <div class="pagehead"><div>
<h1>Modell-Cookbook</h1> <h1>Modell-Cookbook</h1>
<div class="sub">Finde ein KI-Modell, das auf deinen Mini-PC passt — den Hardware-Check rechnen wir für dich aus.</div> <div class="sub">Wofür willst du es nutzen? Wir empfehlen das passende Setup für deine Hardware.</div>
</div></div> </div></div>
<div class="flex gap-3" style="margin:4px 0 6px"> <div class="card-h" style="align-items:center"><h3>Wofür möchtest du es nutzen?</h3>
<span class="chip" id="cb-hw">deine Hardware</span></div>
<div class="grid grid-3" id="cb-recipes">
<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">
<summary>Profi-Modus: HuggingFace direkt durchsuchen</summary>
<div class="acc-body">
<div class="flex gap-3" style="margin-bottom:8px">
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1"> <input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1">
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button> <button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
</div> </div>
@@ -48,40 +48,44 @@ function mount() {
<option value="trendingScore">Im Trend</option> <option value="trendingScore">Im Trend</option>
</select> </select>
</div> </div>
<div class="legend" style="margin:14px 0">
<div class="card-h" style="margin-top:18px;align-items:center">
<h3 id="cb-section-title">Empfohlen für deine Hardware</h3>
<span class="chip" id="cb-hw">deine Hardware</span>
</div>
<div class="legend mb-4">
<span><i style="background:var(--on)"></i>passt locker</span> <span><i style="background:var(--on)"></i>passt locker</span>
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span> <span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span> <span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
</div> </div>
<div class="grid grid-3" id="cb-grid"></div> <div class="grid grid-3" id="cb-grid"></div>
</div>
</details>
<div id="cb-recipe-modal" class="modal-overlay" style="display:none">
<div class="modal-card" style="max-width:580px">
<button id="cb-r-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3 id="cb-r-title">Setup</h3>
<p class="text-mut text-sm" id="cb-r-desc" style="margin:-4px 0 16px"></p>
<div id="cb-r-models"></div>
<button class="primary" id="cb-r-install" style="width:100%;margin-top:16px">Komplettes Setup installieren</button>
<div class="hint" style="margin-top:8px">Lädt alle Modelle herunter und pflegt sie mit optimalem Kontext ein. Fortschritt in der Aktivität.</div>
</div>
</div>
<div id="cb-modal" class="modal-overlay" style="display:none"> <div id="cb-modal" class="modal-overlay" style="display:none">
<div class="modal-card" style="max-width:560px"> <div class="modal-card" style="max-width:560px">
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button> <button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3 id="cb-m-title">Modell</h3> <h3 id="cb-m-title">Modell</h3>
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p> <p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
<label>Quantisierung (GGUF-Datei) wählen</label> <label>Quantisierung (GGUF-Datei) wählen</label>
<select id="cb-m-files"></select> <select id="cb-m-files"></select>
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div> <div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
<div class="row" style="margin-top:4px"> <div class="row" style="margin-top:4px">
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div> <div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div> <div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
</div> </div>
<div class="hint" id="cb-m-ctx-rec" style="margin:-6px 0 14px"></div> <div class="hint" id="cb-m-ctx-rec" style="margin:-6px 0 14px"></div>
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px"> <div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
<div><div style="font-size:13px">Ressourcen-Check</div> <div><div style="font-size:13px">Ressourcen-Check</div>
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div> <div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
<span id="cb-m-fit-badge"></span> <span id="cb-m-fit-badge"></span>
</div> </div>
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button> <button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button>
</div> </div>
</div>`; </div>`;
@@ -96,75 +100,99 @@ function mount() {
$("#cb-m-files").addEventListener("change", updateLiveFit); $("#cb-m-files").addEventListener("change", updateLiveFit);
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx); $("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
window.cbSetCtx = v => { $("#cb-m-ctx").value = v; 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(); 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 = `<div class="alert err" style="grid-column:1/-1">Setups nicht ladbar: ${esc(e.message)}</div>`;
}
}
function renderRecipes() {
$("#cb-recipes").innerHTML = RECIPES.map(r => `
<button class="card-btn" data-recipe="${esc(r.id)}">
<div class="flex justify-between items-center">
<span class="flex items-center gap-2"><span class="text-accent">${icon(r.icon)}</span>
<h3 style="margin:0;font-size:15px">${esc(r.title)}</h3></span>
<span class="fit-badge ${fitCls(r.fit_level)}">${fitWord(r.fit_level)}</span>
</div>
<p>${esc(r.desc)}</p>
<div class="text-xs text-mut">${r.models.length} Modell${r.models.length > 1 ? "e" : ""} im Setup</div>
</button>`).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 = `<div class="list">${r.models.map(m => `
<div class="li" style="align-items:flex-start">
<div class="li-main">
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id">${esc(m.name)}</span><span class="tag text">${esc(m.role)}</span></div>
<div class="li-sub">${esc(m.why)}</div>
<div class="li-sub mono-sm">~${m.fit.req_gb.toFixed(1)} GB · ~${Math.round(m.fit.tps)} Tok/s · optimal ~${Math.round(m.optimal_ctx / 1024)}k Kontext</div>
</div>
<span class="fit-badge ${fitCls(m.fit.level)}">${esc(m.fit.text)}</span>
</div>`).join("")}</div>`;
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() { function renderFilters() {
$("#cb-filters").innerHTML = FILTERS.map(f => $("#cb-filters").innerHTML = FILTERS.map(f =>
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>` `<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`).join("");
).join("");
$("#cb-filters").querySelectorAll("[data-f]").forEach(b => $("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); })); b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
} }
function renderHwChip() { function renderHwChip() {
const el = $("#cb-hw"); if (!el) return; const el = $("#cb-hw"); if (!el) return;
if (lastSys?.ram?.total) { if (lastSys?.ram?.total) el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
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 metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
function curatedCard(m, i, fit) {
return `<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-cur="${i}">
<div class="flex justify-between items-center"><h3 style="margin:0;font-size:15px;font-weight:500">${esc(m.name)}</h3>
<span class="fit-badge ${fitCls(fit.level)}">${esc(fit.text)}</span></div>
<div class="text-sm text-mut" style="margin:12px 0;flex:1;line-height:1.5">${esc(m.desc)}</div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
<span class="mono-sm">${metricLine(fit)}</span><span class="mono-sm">${esc(m.quant)}</span></div>
</div>`;
}
async function renderCurated() {
$("#cb-section-title").textContent = "Empfohlen für deine Hardware";
const grid = $("#cb-grid"); if (!grid) return;
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Berechne Hardware-Fit…</div>`;
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 = `<div class="alert err" style="grid-column:1/-1">Konnte Empfehlungen nicht laden: ${esc(e.message)}</div>`;
}
}
async function doSearch() { async function doSearch() {
let q = $("#cb-search").value.trim(); let q = $("#cb-search").value.trim();
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter; if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
if (!q) return renderCurated(); if (!q) { $("#cb-grid").innerHTML = ""; return; }
const sort = $("#cb-sort")?.value || "downloads"; const sort = $("#cb-sort")?.value || "downloads";
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…"; const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
$("#cb-section-title").textContent = "Suchergebnisse: " + q; $("#cb-grid").innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
const grid = $("#cb-grid");
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
try { try {
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`; const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`;
const r = await fetch(url); const r = await fetch(url);
currentResults = await r.json(); currentResults = await r.json();
renderResults(currentResults); renderResults(currentResults);
} catch (e) { } catch (e) { $("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`; }
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
}
btn.disabled = false; btn.textContent = "Suchen"; 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]; 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; b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF"); mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
} catch { } catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } }
const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; }
}
} }
// ---- Modal ----
function showFit() { function showFit() {
const file = $("#cb-m-files").value; const file = $("#cb-m-files").value;
const f = currentAnalysis?.files.find(x => x.filename === file); const f = currentAnalysis?.files.find(x => x.filename === file);
@@ -211,8 +236,7 @@ function showFit() {
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`; $("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec"); const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec");
if (recEl) recEl.innerHTML = opt if (recEl) recEl.innerHTML = opt
? `Empfohlener Kontext für deine Hardware: <b>~${Math.round(opt / 1024)}k</b> — <a href="#" onclick="event.preventDefault();window.cbSetCtx(${opt})">übernehmen</a>` ? `Empfohlener Kontext für deine Hardware: <b>~${Math.round(opt / 1024)}k</b> — <a href="#" onclick="event.preventDefault();window.cbSetCtx(${opt})">übernehmen</a>` : "";
: "";
const btn = $("#cb-m-download"); const btn = $("#cb-m-download");
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; } if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; } 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; } } 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 = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
$("#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() { async function doDownload() {
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value; 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; const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;