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 81f6861df8 feat: eigene Cookbook-Setups erstellen & loeschen
- Nutzer koennen im Cookbook eigene Use-Case-Setups anlegen (Titel, Beschreibung,
  beliebige Modelle: Repo + Rolle + Quant). Groesse wird aus dem Repo-Namen
  abgeleitet; Fit-Ampel + optimaler Kontext kommen wie bei kuratierten Setups
  zur Laufzeit aus hw_math.
- Persistenz: JSON-Datei (MC_USER_RECIPES, Default /srv/models/...) -> ueberlebt
  Deploys (liegt bewusst NICHT im rsync-Ziel).
- /api/cookbook/recipes merged eingebaute + eigene Setups; install-recipe findet
  beide. Neue Endpunkte POST/DELETE /api/cookbook/user-recipe.
- UI: "+ Eigenes Setup", Modal mit dynamischen Modell-Zeilen; eigene Karten mit
  "Dein Setup"-Tag + Loeschen. "Beste Wahl" bleibt auf kuratierte beschraenkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:13:55 +02:00

409 lines
23 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>
<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">
<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);
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 = `<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`; }
async function doSearch() {
let q = $("#cb-search").value.trim();
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
if (!q) { $("#cb-grid").innerHTML = ""; return; }
const sort = $("#cb-sort")?.value || "downloads";
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 {
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) { $("#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 };