82d15d82db
A1: 32768-Kontext-Cap entfernt (install-recipe, install-model, register) →
max_ctx_for() liefert nun bis zu 128k auf Strix Halo; behebt "context
size exceeded" bei externen Tools.
A2: Download-State jetzt im Status-Endpoint sichtbar: Modelle zeigen
"↓ Download X%" statt "bereit" während Job läuft (Backend + Frontend).
A3: PUT /api/cookbook/user-recipe/{id} + Edit-Button (✎) für eigene Setups.
Download-Modal setzt Kontext-Input automatisch auf optimal.
A4: /api/updates liefert apt_cache_age_h; Badge zeigt Tooltip + ⚠ wenn >24h.
A5: Swap-Flash: Topbar-Text pulst kurz teal wenn Modell den State wechselt.
A6: LLM-Engine-Update fragt jetzt per confirmModal nach (Konsistenz).
A7: Event-Delegation statt per-render addEventListener in models.js.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
586 lines
32 KiB
JavaScript
586 lines
32 KiB
JavaScript
// 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 MOE_HELP = "MoE (Mixture of Experts): viele Parameter fürs Wissen, aber pro Wort ist nur ein kleiner Teil aktiv → fast große-Modell-Qualität bei hohem Tempo. Ideal für deine APU.";
|
||
|
||
// Was kann das Modell? (Fähigkeit aus dem Namen / der Backend-Rolle ableiten.)
|
||
const ROLE_LABEL = { vision: "Bilder", coder: "Coden", reasoning: "Logik", agent: "Agenten & Tools",
|
||
scout: "Allrounder", reviewer: "Review", manager: "Manager" };
|
||
const CAP_KW = [
|
||
{ label: "Bilder", kw: ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"] },
|
||
{ label: "Coden", kw: ["coder", "-code-", "code-", "codestral", "starcoder"] },
|
||
{ label: "Logik", kw: ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"] },
|
||
{ label: "Agenten & Tools", kw: ["hermes", "-tool", "command-r", "watt", "-fc-", "function"] },
|
||
];
|
||
function capLabel(name) {
|
||
const l = (name || "").toLowerCase();
|
||
for (const c of CAP_KW) if (c.kw.some(k => l.includes(k))) return c.label;
|
||
return "Allrounder";
|
||
}
|
||
// MoE-Erkennung über strukturelle Marker im Namen (zuverlässig, keine Falschaussagen):
|
||
// 8x7B-Stil, „…-A3B" (aktive Parameter), oder explizit „mixtral"/„moe".
|
||
function isMoe(s = "") {
|
||
const l = s.toLowerCase();
|
||
return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes("mixtral") || /\bmoe\b/.test(l);
|
||
}
|
||
function moeActive(s = "") {
|
||
const m = s.toLowerCase().match(/(?<![a-z])a(\d+(?:\.\d+)?)b\b/);
|
||
return m ? m[1] : null;
|
||
}
|
||
function moeChip(name) {
|
||
if (!isMoe(name)) return "";
|
||
const a = moeActive(name);
|
||
return `<span class="chip" title="${esc(MOE_HELP)}">MoE${a ? ` · ~${a}B aktiv` : ""}</span>`;
|
||
}
|
||
function capChip(label) { return label ? `<span class="chip">${esc(label)}</span>` : ""; }
|
||
|
||
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 id="cb-new-modal-title">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>Rolle (optional)</label><input id="cb-m-alias" placeholder="z.B. coder, vision, scout"></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 & 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(m => discCard(m, c.recommended)).join("")}</div>
|
||
</div>`).join("");
|
||
}
|
||
|
||
function discCard(m, recommended) {
|
||
const reqs = (m.requirements || []).map(r =>
|
||
`<div class="li-sub" style="color:var(--warn);margin-top:3px">⚠ ${esc(r)}</div>`).join("");
|
||
const best = recommended && m.repo === recommended;
|
||
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>` : "";
|
||
const chips = `<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">${capChip(ROLE_LABEL[m.role] || capLabel(m.repo))}${moeChip(m.repo)}</div>`;
|
||
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${best ? " res-best" : ""}" style="display:flex;flex-direction:column;position:relative">
|
||
${tag}
|
||
<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>
|
||
${chips}
|
||
<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-editr" data-editr="${esc(r.id)}" title="Setup bearbeiten"
|
||
style="position:absolute;top:10px;right:36px;color:var(--accent);cursor:pointer;font-size:14px;line-height:1">✎</span>
|
||
<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")); }));
|
||
$("#cb-recipes").querySelectorAll("[data-editr]").forEach(x =>
|
||
x.addEventListener("click", e => { e.stopPropagation(); openEditRecipe(x.getAttribute("data-editr")); }));
|
||
}
|
||
|
||
// ---- 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>`;
|
||
}
|
||
|
||
let editRecipeId = null;
|
||
|
||
function openNewRecipe() {
|
||
editRecipeId = null;
|
||
$("#cb-new-title").value = "";
|
||
$("#cb-new-desc").value = "";
|
||
$("#cb-new-models").innerHTML = newModelRow();
|
||
$("#cb-new-modal-title").textContent = "Eigenes Setup erstellen";
|
||
$("#cb-new-save").textContent = "Setup speichern";
|
||
$("#cb-new-modal").style.display = "flex";
|
||
}
|
||
|
||
function openEditRecipe(id) {
|
||
const r = RECIPES.find(x => x.id === id); if (!r) return;
|
||
editRecipeId = id;
|
||
$("#cb-new-title").value = r.title;
|
||
$("#cb-new-desc").value = r.desc || "";
|
||
$("#cb-new-models").innerHTML = r.models.map(m => {
|
||
const row = document.createElement("div");
|
||
row.innerHTML = newModelRow();
|
||
const el = row.firstElementChild;
|
||
el.querySelector(".cb-nm-repo").value = m.repo;
|
||
el.querySelector(".cb-nm-role").value = m.role;
|
||
const quant = el.querySelector(".cb-nm-quant");
|
||
if (quant) [...quant.options].forEach(o => { if (o.value === m.quant) o.selected = true; });
|
||
return el.outerHTML;
|
||
}).join("");
|
||
$("#cb-new-modal-title").textContent = "Setup bearbeiten";
|
||
$("#cb-new-save").textContent = "Änderungen speichern";
|
||
$("#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 {
|
||
const body = JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models });
|
||
if (editRecipeId) {
|
||
await api("/api/cookbook/user-recipe/" + encodeURIComponent(editRecipeId), { method: "PUT", body });
|
||
toast("Setup aktualisiert.");
|
||
} else {
|
||
await api("/api/cookbook/user-recipe", { method: "POST", body });
|
||
toast("Setup gespeichert.");
|
||
}
|
||
editRecipeId = null;
|
||
$("#cb-new-modal").style.display = "none";
|
||
loadRecipes();
|
||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||
btn.disabled = false; btn.textContent = editRecipeId ? "Änderungen speichern" : "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>${moeChip(m.repo)}</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 class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">${capChip(capLabel(m.id))}${moeChip(m.id)}</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 (opt) {
|
||
// Kontext-Input auto auf optimal setzen, wenn noch auf Default (8192)
|
||
const ctxInput = $("#cb-m-ctx");
|
||
if (ctxInput && (ctxInput.value === "8192" || ctxInput.value === "")) ctxInput.value = 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>` : "";
|
||
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 = "";
|
||
$("#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 role = $("#cb-m-alias").value.trim().toLowerCase(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||
if (!repo || !file) return toast("Bitte eine GGUF-Datei wählen.", 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({ role, 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 };
|