This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mission-control/static/js/panels/cookbook.js
T
Hitonabi 0c16fb28c2 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>
2026-06-22 16:47:37 +02:00

507 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, getHfToken } from "../core/api.js";
import { $, esc, icon, toast, infoDot, confirmModal } from "../core/ui.js";
const CTX_HELP = "Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr, braucht aber mehr Speicher und wird etwas langsamer.";
const GGUF_HELP = "GGUF ist das lokal lauffähige Dateiformat. Unsere Engine lädt nur GGUF — Repos ohne GGUF-Datei kann sie nicht nutzen.";
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" },
];
let lastSys = null;
let currentResults = [];
let currentAnalysis = null;
let activeFilter = "";
let RECIPES = [];
let RECOMMENDED = null;
let cardFits = []; // Fit-Level je Such-Karte (fuer „Beste Wahl"-Markierung)
const FIT_RANK = { perfect: 0, marginal: 1, too_tight: 2 };
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 = `
<div class="pagehead"><div>
<h1>Modell-Cookbook</h1>
<div class="sub">Wofür willst du es nutzen? Wir empfehlen das passende Setup für deine Hardware.</div>
</div></div>
<div class="card-h" style="align-items:center"><h3>Wofür möchtest du es nutzen?</h3>
<span class="chip" id="cb-hw" style="margin-left:auto">deine Hardware</span>
<button class="ghost" id="cb-new-open" style="margin-left:10px">+ Eigenes Setup</button></div>
<div class="grid grid-3" id="cb-recipes">
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
</div>
<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">
<input id="cb-search" placeholder="Suchen oder HuggingFace-Link/Repo einfügen (z.B. unsloth/MiniMax-M3-GGUF)…" style="margin:0;flex:1">
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
</div>
<div class="flex gap-2 items-center" style="flex-wrap:wrap">
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap;flex:1"></div>
<select id="cb-sort" style="width:auto;margin:0;padding:7px 10px;font-family:var(--sans)">
<option value="downloads">Beliebteste Downloads</option>
<option value="likes">Am beliebtesten (Likes)</option>
<option value="lastModified">Zuletzt aktualisiert</option>
<option value="trendingScore">Im Trend</option>
</select>
</div>
<div class="legend" style="margin:14px 0">
<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(--err)"></i>zu groß für deinen Speicher</span>
</div>
<div class="hint" style="margin:-6px 0 14px">Es werden nur Modelle im GGUF-Format ${infoDot(GGUF_HELP)} gezeigt — nur die laufen lokal.</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>
<div class="hint" id="cb-r-note" style="margin-top:12px"></div>
<button class="primary" id="cb-r-install" style="width:100%;margin-top:8px">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-new-modal" class="modal-overlay" style="display:none">
<div class="modal-card" style="max-width:640px">
<button id="cb-new-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3>Eigenes Setup erstellen</h3>
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen — es erscheint danach mit Hardware-Ampel hier im Cookbook.</p>
<label>Titel</label>
<input id="cb-new-title" placeholder="z.B. Mein Schreib-Stack">
<label>Kurzbeschreibung (optional)</label>
<input id="cb-new-desc" placeholder="Wofür ist dieses Setup gut?">
<label style="margin-top:10px">Modelle</label>
<div id="cb-new-models"></div>
<button class="ghost" id="cb-new-add" style="margin-top:8px">+ Modell hinzufügen</button>
<div class="hint" style="margin-top:10px">Die <b>Repo-ID</b> findest du über die <b>Profi-Suche</b> weiter unten (z.B. <code>unsloth/Qwen3-8B-GGUF</code>). Die Modellgröße erkennen wir automatisch aus dem Namen.</div>
<button class="primary" id="cb-new-save" style="width:100%;margin-top:14px">Setup speichern</button>
</div>
</div>
<div id="cb-modal" class="modal-overlay" style="display:none">
<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>
<h3 id="cb-m-title">Modell</h3>
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
<label>Quantisierung (GGUF-Datei) wählen</label>
<select id="cb-m-files"></select>
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
<div class="row" style="margin-top:4px">
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
<div><label>Kontext-Größe ${infoDot(CTX_HELP)}</label><input id="cb-m-ctx" type="number" value="8192"></div>
</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><div style="font-size:13px">Ressourcen-Check</div>
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
<span id="cb-m-fit-badge"></span>
</div>
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button>
</div>
</div>`;
renderFilters();
$("#cb-btn-search").addEventListener("click", doSearch);
$("#cb-search").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
$("#cb-sort").addEventListener("change", () => { if ($("#cb-search").value.trim() || activeFilter) doSearch(); });
$("#cb-modal-close").addEventListener("click", () => $("#cb-modal").style.display = "none");
$("#cb-modal").addEventListener("click", e => { if (e.target.id === "cb-modal") $("#cb-modal").style.display = "none"; });
$("#cb-m-download").addEventListener("click", doDownload);
$("#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"; });
$("#cb-new-open").addEventListener("click", openNewRecipe);
$("#cb-new-close").addEventListener("click", () => $("#cb-new-modal").style.display = "none");
$("#cb-new-modal").addEventListener("click", e => { if (e.target.id === "cb-new-modal") $("#cb-new-modal").style.display = "none"; });
$("#cb-new-add").addEventListener("click", () => $("#cb-new-models").insertAdjacentHTML("beforeend", newModelRow()));
$("#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 ----
async function loadRecipes() {
try {
const d = await api("/api/cookbook/recipes");
RECIPES = d.recipes || [];
RECOMMENDED = d.recommended_id || null;
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 => {
const best = r.id === RECOMMENDED;
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`
: r.user ? `<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>` : "";
const del = r.user ? `<span class="cb-delr" data-delr="${esc(r.id)}" title="Setup löschen"
style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1">×</span>` : "";
return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}" style="position:relative">
${del}${tag}
<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"))));
$("#cb-recipes").querySelectorAll("[data-delr]").forEach(x =>
x.addEventListener("click", e => { e.stopPropagation(); deleteRecipe(x.getAttribute("data-delr")); }));
}
// ---- Eigenes Setup erstellen/löschen ----
function newModelRow() {
return `<div class="cb-nm-row flex gap-2" style="margin-bottom:8px;align-items:center">
<input class="cb-nm-repo" placeholder="Repo, z.B. unsloth/Qwen3-8B-GGUF" style="flex:2;margin:0">
<input class="cb-nm-role" placeholder="Rolle (z.B. coder)" style="flex:1;margin:0">
<select class="cb-nm-quant" style="width:auto;margin:0;font-family:var(--sans)">
<option>Q4_K_M</option><option>Q5_K_M</option><option>Q6_K</option><option>Q8_0</option><option>Q3_K_M</option>
</select>
<button class="ghost del cb-nm-del" title="Zeile entfernen" style="margin:0;padding:6px 10px">×</button>
</div>`;
}
function openNewRecipe() {
$("#cb-new-title").value = "";
$("#cb-new-desc").value = "";
$("#cb-new-models").innerHTML = newModelRow();
$("#cb-new-modal").style.display = "flex";
}
async function saveNewRecipe() {
const title = $("#cb-new-title").value.trim();
if (!title) return toast("Bitte einen Titel angeben.", true);
const models = [...$("#cb-new-models").querySelectorAll(".cb-nm-row")].map(row => ({
repo: row.querySelector(".cb-nm-repo").value.trim(),
role: row.querySelector(".cb-nm-role").value.trim() || "modell",
quant: row.querySelector(".cb-nm-quant").value,
})).filter(m => m.repo);
if (!models.length) return toast("Mindestens ein Modell (Repo) angeben.", true);
const btn = $("#cb-new-save"); btn.disabled = true; btn.textContent = "Speichere…";
try {
await api("/api/cookbook/user-recipe", { method: "POST",
body: JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models }) });
toast("Setup gespeichert.");
$("#cb-new-modal").style.display = "none";
loadRecipes();
} catch (e) { toast("Fehler: " + e.message, true); }
btn.disabled = false; btn.textContent = "Setup speichern";
}
async function deleteRecipe(id) {
const r = RECIPES.find(x => x.id === id);
if (!await confirmModal({ title: `${r ? r.title : id}" löschen?`,
body: "Dein eigenes Setup wird aus dem Cookbook entfernt. Bereits installierte Modelle bleiben unangetastet.",
confirmLabel: "Löschen", danger: true })) return;
try { await api("/api/cookbook/user-recipe/" + encodeURIComponent(id), { method: "DELETE" }); toast("Setup gelöscht."); loadRecipes(); }
catch (e) { toast(e.message, true); }
}
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 maxRam = Math.max(...r.models.map(m => m.fit.req_gb));
$("#cb-r-note").innerHTML = `Größter Spitzenbedarf: <b>~${maxRam.toFixed(1)} GB</b>. ` +
`Es läuft immer nur <b>ein</b> Modell gleichzeitig — das größte bestimmt, ob das Setup passt (kein Summieren).`;
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, hf_token: getHfToken() }) });
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 =>
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`).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`;
}
// ---- Profi-Suche (HuggingFace) ----
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
// Erkennt eine direkte Modell-Angabe: ganze HuggingFace-URL ODER „owner/repo".
// So kann man Links wie https://huggingface.co/unsloth/MiniMax-M3-GGUF einfach
// reinkopieren und landet exakt auf diesem Modell (statt einer unscharfen Suche).
function parseHfRepo(s) {
s = s.trim();
const u = s.match(/huggingface\.co\/([^\s/]+\/[^\s/?#]+)/i);
if (u) return u[1];
if (/^[\w.-]+\/[\w.-]+$/.test(s)) return s;
return null;
}
async function doSearch() {
const raw = $("#cb-search").value.trim();
const repo = parseHfRepo(raw);
let q = raw;
if (activeFilter && !repo) q = q ? q + " " + activeFilter : activeFilter;
if (!q && !repo) { $("#cb-grid").innerHTML = ""; return; }
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
$("#cb-grid").innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
try {
if (repo) {
// Direkter Treffer per Link/Repo — keine Stichwortsuche, sondern genau dieses Modell.
currentResults = [{ id: repo, author: repo.split("/")[0], downloads: 0 }];
renderResults(currentResults);
} else {
const sort = $("#cb-sort")?.value || "downloads";
// limit=40: auch große/seltenere Modelle (z.B. Llama-4-Scout 109B) erscheinen,
// statt von den Top-12-Trends verdrängt zu werden. Fit-Ampel sagt ehrlich, was passt.
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=40`;
const r = await fetch(url);
currentResults = await r.json();
renderResults(currentResults);
}
} catch (e) { $("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`; }
btn.disabled = false; btn.textContent = "Suchen";
}
function renderResults(results) {
const grid = $("#cb-grid");
cardFits = [];
if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
grid.innerHTML = results.map((m, i) => `
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0"><h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">${esc(m.id.split("/").pop())}</h3>
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author || "")}</div></div>
<span class="fit-badge warn" id="cb-b-${i}">prüfe…</span>
</div>
<div style="flex:1;margin-top:12px"></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" id="cb-m-${i}">Hardware-Fit…</span>
<span class="mono-sm">⬇ ${(m.downloads || 0).toLocaleString()}</span></div>
</div>`).join("");
grid.querySelectorAll("[data-res]").forEach(el => el.addEventListener("click", () => openResult(+el.getAttribute("data-res"))));
results.forEach((m, i) => fetchFitForCard(i, m.id));
}
async function fetchFitForCard(i, repo_id) {
try {
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
if (!b || !mt) return;
if (!res.files?.length) { b.className = "fit-badge"; b.style.cssText = "background:rgba(139,151,165,.14);color:var(--mut)"; b.title = GGUF_HELP; b.textContent = "kein GGUF"; mt.textContent = "—"; cardFits[i] = null; markBestResult(); return; }
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");
cardFits[i] = best.fit.level; markBestResult();
} catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } cardFits[i] = null; markBestResult(); }
}
// Markiert die beste System-Wahl in den Suchergebnissen: das meistgeladene Modell
// (Liste ist nach Downloads sortiert), das noch sauber auf die Hardware passt.
function markBestResult() {
const grid = $("#cb-grid"); if (!grid) return;
let bestI = -1, bestRank = 99;
cardFits.forEach((lvl, i) => {
if (lvl == null) return;
const rank = FIT_RANK[lvl] ?? 99;
if (rank < bestRank) { bestRank = rank; bestI = i; }
});
grid.querySelectorAll("[data-res]").forEach(el => { el.classList.remove("res-best"); el.querySelector(".cb-best-tag")?.remove(); });
if (bestI < 0 || bestRank > 1) return; // nur hervorheben, wenn etwas Lauffähiges (perfect/marginal) dabei ist
const el = grid.querySelector(`[data-res="${bestI}"]`);
if (el) { el.classList.add("res-best"); el.insertAdjacentHTML("afterbegin", `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`); }
}
function showFit() {
const file = $("#cb-m-files").value;
const f = currentAnalysis?.files.find(x => x.filename === file);
if (!f) { $("#cb-m-fit").style.display = "none"; return; }
$("#cb-m-fit").style.display = "flex";
$("#cb-m-fit-text").innerHTML = `Bedarf: <b>~${f.fit.req_gb.toFixed(1)} GB</b> · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
$("#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");
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>` : "";
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"; }
}
function updateLiveFit() { showFit(); }
async function reanalyzeCtx() {
if (!currentAnalysis) return;
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
const file = $("#cb-m-files").value;
try {
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: currentAnalysis.repo, ctx }) });
$("#cb-m-files").value = file; showFit();
} catch {}
}
function openModalBase(title, repo) {
$("#cb-modal").style.display = "flex";
$("#cb-m-title").textContent = title;
$("#cb-m-repo").textContent = repo;
}
async function openResult(i) {
const m = currentResults[i]; if (!m) return;
openModalBase(m.id.split("/").pop(), m.id);
$("#cb-m-alias").value = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9]/g, "-");
$("#cb-m-files").style.display = "none"; $("#cb-m-loading").style.display = "block"; $("#cb-m-download").disabled = true;
try {
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: m.id, ctx }) });
$("#cb-m-loading").style.display = "none"; $("#cb-m-files").style.display = "block";
if (!currentAnalysis.files?.length) { $("#cb-m-files").innerHTML = "<option>Keine GGUF-Dateien gefunden</option>"; $("#cb-m-fit").style.display = "none"; return; }
$("#cb-m-files").innerHTML = currentAnalysis.files.map(f => {
const mark = f.fit.level === "perfect" ? "●" : f.fit.level === "marginal" ? "◐" : "○";
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
}).join("");
$("#cb-m-download").disabled = false; showFit();
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
}
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;
if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true);
const btn = $("#cb-m-download"); btn.disabled = true; btn.textContent = "Starte…";
try {
const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file, hf_token: getHfToken() }) });
await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path: res.expected_path, ctx }) });
toast("Download gestartet — siehe Aktivität.");
$("#cb-modal").style.display = "none";
document.querySelector(".nav-item[data-view='activity']")?.click();
} catch (e) { toast("Fehler: " + e.message, true); }
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
}
function onSystem(sys) { lastSys = sys; renderHwChip(); }
export default { id: "cookbook", mount, onSystem };