// 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 = `
Modell-Cookbook
Wofür willst du es nutzen? Wir empfehlen das passende Setup für deine Hardware.
Wofür möchtest du es nutzen?
deine Hardware
Profi-Modus: HuggingFace direkt durchsuchen
passt locker
läuft, aber knapp
zu groß für deinen Speicher
Es werden nur Modelle im GGUF-Format ${infoDot(GGUF_HELP)} gezeigt — nur die laufen lokal.
Setup
Lädt alle Modelle herunter und pflegt sie mit optimalem Kontext ein. Fortschritt in der Aktivität.
Modell
repo/name
Lade Dateien von HuggingFace…
Ressourcen-Check
Berechne…
`;
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);
renderHwChip();
loadRecipes();
}
// ---- 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 = `Setups nicht ladbar: ${esc(e.message)}
`;
}
}
function renderRecipes() {
$("#cb-recipes").innerHTML = RECIPES.map(r => {
const best = r.id === RECOMMENDED;
const tag = best ? `★ Beste Wahl für dein System
`
: r.user ? `Dein Setup
` : "";
const del = r.user ? `×` : "";
return ``;
}).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 `
`;
}
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 = `${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 maxRam = Math.max(...r.models.map(m => m.fit.req_gb));
$("#cb-r-note").innerHTML = `Größter Spitzenbedarf: ~${maxRam.toFixed(1)} GB. ` +
`Es läuft immer nur ein 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 =>
``).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 = `Suche auf HuggingFace…
`;
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 = `${esc(e.message)}
`; }
btn.disabled = false; btn.textContent = "Suchen";
}
function renderResults(results) {
const grid = $("#cb-grid");
cardFits = [];
if (!results?.length) { grid.innerHTML = `Keine GGUF-Modelle gefunden.
`; return; }
grid.innerHTML = results.map((m, i) => `
${esc(m.id.split("/").pop())}
${esc(m.author || "")}
prüfe…
Hardware-Fit…
⬇ ${(m.downloads || 0).toLocaleString()}
`).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", `★ Beste Wahl für dein System
`); }
}
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: ~${f.fit.req_gb.toFixed(1)} GB · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
$("#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` : "";
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 = ""; $("#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 ``;
}).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 };