v3 Phase B: alle Panels auf das Design-System + Beginner-UX

- cookbook.js: Fit-Ampel (gruen/gelb/rot) + Legende + Klartext-Urteile, sauberes Modal.
- server.js: heikle Aktionen mit confirmModal/promptModal (Klartext-Konsequenz),
  Konsole im neuen Stil, Begriffe uebersetzt.
- models.js: Tabelle re-skinnt (Capability-Tags statt Emoji, --blue raus),
  Entladen mit Bestaetigung, Konfig-Modal vereinheitlicht.
- jobs.js (Aktivitaet): Metrik-Kacheln + Klartext-Verlaeufe.
- guides.js: Kopf + Intro, Integrations-URL aus Browser-Host abgeleitet.
- index.html: Mountpunkte fuer Modelle-/Aktivitaets-Kopf.
- app.py: no-cache-Middleware fuer /static (UI-Aenderungen wirken sofort nach rsync,
  kein Stale-JS mehr).
- base.css: Sidebar bei schmalem Viewport icon-only (Label-Ueberlappung gefixt).

Verifiziert: alle 6 Panels mounten fehlerfrei (0 Konsolenfehler), Fit-Ampel rechnet live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 07:05:15 +02:00
parent 52b0a3bff5
commit 1aea0f558e
8 changed files with 482 additions and 751 deletions
+196 -379
View File
@@ -1,453 +1,270 @@
// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile.
// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register.
import { api } from "../core/api.js";
import { $, esc, icon, toast } from "../core/ui.js";
import { track } from "./jobs.js";
const CURATED_MODELS = [
{
id: "qwen-coder-32b",
name: "Qwen 2.5 Coder 32B",
repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF",
file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
desc: "Top-Tier lokales Coder-Modell. Braucht ca. 24GB VRAM.",
params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder"
},
{
id: "qwen-coder-7b",
name: "Qwen 2.5 Coder 7B",
repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF",
file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
desc: "Schneller Coder. Perfekte Balance aus Speed und Qualität.",
params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast"
},
{
id: "llama3-vision-11b",
name: "Llama 3.2 Vision 11B",
repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF",
file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
desc: "Modell für Bilderkennung und multimodale Tasks.",
params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision"
},
{
id: "qwen-general-7b",
name: "Qwen 2.5 7B",
repo: "unsloth/Qwen2.5-7B-Instruct-GGUF",
file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
desc: "Hervorragendes Generalist/Scout Modell.",
params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout"
}
const CURATED = [
{ name: "Qwen 2.5 Coder 7B", repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
desc: "Schnell und locker auf deiner Kiste — ideal zum Programmieren.", params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast" },
{ name: "Qwen 2.5 Coder 32B", repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF", file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
desc: "Mehr Können, etwas langsamer — der starke Allround-Coder.", params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder" },
{ name: "Llama 3.2 Vision 11B", repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
desc: "Versteht Bilder und Text — gut für multimodale Aufgaben.", params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision" },
{ name: "Qwen 2.5 7B", repo: "unsloth/Qwen2.5-7B-Instruct-GGUF", file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
desc: "Hervorragender Allrounder — schnell und vielseitig.", params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout" },
];
// Lokale Mathe entfernt. Wir nutzen jetzt das Backend.
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 = "";
const FILTERS = [
{ id: "", label: "Alle" },
{ id: "vision", label: "Vision" },
{ id: "scout", label: "Scout" },
{ id: "coder", label: "Coder" },
{ id: "manager", label: "Manager" },
{ id: "reviewer", label: "Reviewer" }
];
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
function mount() {
const c = $(".view[data-view='cookbook']");
const filtersHtml = FILTERS.map(f =>
`<button class="ghost filter-btn ${activeFilter === f.id ? 'active' : ''}" style="margin-right:8px; border-radius:16px; padding:6px 16px; ${activeFilter === f.id ? 'background:var(--hi); color:var(--bg)' : 'background:var(--bg); border:1px solid var(--line)'}" onclick="window.setFilter('${f.id}')">${f.label}</button>`
).join("");
c.innerHTML = `
<div class="card" style="padding-bottom:32px">
<div style="max-width:600px; margin:0 auto; text-align:center;">
<h2 style="margin-top:20px; margin-bottom:8px">App Store für Modelle</h2>
<p class="meta" style="margin-bottom:24px">Entdecke Modelle live auf HuggingFace, Hardware-Fit inklusive.</p>
<div style="display:flex; gap:12px; position:relative">
<input id="cb-search" class="tokin" style="flex:1; padding:12px 18px; font-size:16px; border-radius:12px;" placeholder="Nach Modellen suchen (z.B. Llama 3)...">
<button class="primary" id="cb-btn-search" style="border-radius:12px; padding:0 24px;">Suchen</button>
</div>
<div id="cb-filters" style="margin-top:20px; display:flex; justify-content:center; flex-wrap:wrap; gap:8px">
${filtersHtml}
</div>
</div>
</div>
<div class="pagehead"><div>
<h1>Modell-Cookbook</h1>
<div class="sub">Finde ein KI-Modell, das auf deinen Mini-PC passt — den Hardware-Check rechnen wir für dich aus.</div>
</div></div>
<div class="card-h" style="margin-top:24px"><h3 id="cb-section-title">Kuratierte Empfehlungen</h3></div>
<div class="flex gap-3" style="margin:4px 0 6px">
<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 id="cb-filters" class="flex gap-2" style="flex-wrap:wrap"></div>
<div class="card-h" style="margin-top:18px;align-items:center">
<h3 id="cb-section-title">Empfohlen für deine Hardware</h3>
<span class="chip" id="cb-hw">deine Hardware</span>
</div>
<div class="legend mb-4">
<span><i style="background:var(--on)"></i>passt locker</span>
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
</div>
<div class="grid grid-3" id="cb-grid"></div>
<!-- Modal für HuggingFace Modell Details -->
<div id="cb-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
<div class="card" style="width:100%; max-width:600px; max-height:80vh; overflow-y:auto; position:relative">
<button id="cb-modal-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
<h2 id="cb-m-title" style="margin-top:0">Modell</h2>
<p class="meta" id="cb-m-repo">repo/name</p>
<div style="margin-top:24px">
<label>Wähle eine Quantisierung (GGUF-Datei)</label>
<select id="cb-m-files" class="tokin" style="width:100%; margin-top:8px; padding:12px"></select>
<div id="cb-m-loading" class="meta" style="margin-top:8px; display:none">Lade Dateien...</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</label><input id="cb-m-ctx" type="number" value="8192"></div>
</div>
<div style="margin-top:24px; display:flex; gap:12px">
<div style="flex:1">
<label>Alias (Rolle)</label>
<input id="cb-m-alias" class="tokin" style="width:100%; margin-top:8px" placeholder="z.B. coder">
</div>
<div style="flex:1">
<label>Context-Size</label>
<input id="cb-m-ctx" class="tokin" style="width:100%; margin-top:8px" value="8192" type="number">
</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>
<div id="cb-m-fit-container" style="margin-top:24px; padding:16px; background:var(--bg); border:1px solid var(--line); border-radius:8px; display:flex; justify-content:space-between; align-items:center;">
<div>
<div style="font-weight:600; font-size:14px; margin-bottom:4px;">Ressourcen-Check</div>
<div class="meta" id="cb-m-fit-text">Berechne...</div>
</div>
<div id="cb-m-fit-badge"></div>
</div>
<button class="primary" id="cb-m-download" style="width:100%; margin-top:24px; padding:12px">Herunterladen & Einpflegen</button>
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button>
</div>
</div>
`;
</div>`;
renderFilters();
$("#cb-btn-search").addEventListener("click", doSearch);
$("#cb-search").addEventListener("keydown", e => { if (e.key === "Enter") 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);
renderHwChip();
renderCurated();
}
// Aktuelle Analyse-Daten vom Backend
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 updateLiveFit() {
const file = $("#cb-m-files").value;
if (!currentAnalysis || !file) {
$("#cb-m-fit-container").style.display = "none";
return;
}
const fData = currentAnalysis.files.find(f => f.filename === file);
if (!fData) return;
const fit = fData.fit;
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
$("#cb-m-fit-container").style.display = "flex";
$("#cb-m-fit-text").innerHTML = `Geschätzter Bedarf: <b>~${fit.req_gb.toFixed(1)} GB RAM/VRAM</b> <br><small class="meta">${currentAnalysis.params_b}B Params · ${fData.quant} · ~${Math.round(fit.tps)} t/s</small>`;
$("#cb-m-fit-badge").innerHTML = `<span class="badge ${cls}">${fit.text}</span>`;
// Wenn "too_tight", machen wir den Download-Button gelb zur Warnung, erlauben ihn aber
const btn = $("#cb-m-download");
if (fit.level === "too_tight") {
btn.className = "primary warn";
btn.innerHTML = "Trotzdem herunterladen (OOM Risiko!)";
} else {
btn.className = "primary";
btn.innerHTML = "Herunterladen & Einpflegen";
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`;
}
}
async function reanalyzeCtx() {
if (!currentAnalysis) return;
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
const repo = currentAnalysis.repo;
const file = $("#cb-m-files").value;
$("#cb-m-fit-text").innerHTML = "Berechne neues Context-Limit...";
// ---- Karten ----
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
function curatedCard(m, i, fit) {
return `<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-cur="${i}">
<div class="flex justify-between items-center"><h3 style="margin:0;font-size:15px;font-weight:500">${esc(m.name)}</h3>
<span class="fit-badge ${fitCls(fit.level)}">${esc(fit.text)}</span></div>
<div class="text-sm text-mut" style="margin:12px 0;flex:1;line-height:1.5">${esc(m.desc)}</div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
<span class="mono-sm">${metricLine(fit)}</span><span class="mono-sm">${esc(m.quant)}</span></div>
</div>`;
}
async function renderCurated() {
$("#cb-section-title").textContent = "Empfohlen für deine Hardware";
const grid = $("#cb-grid"); if (!grid) return;
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Berechne Hardware-Fit…</div>`;
try {
const res = await api("/api/cookbook/analyze", {
method: "POST", body: JSON.stringify({ repo_id: repo, ctx })
});
currentAnalysis = res;
// Auswahl beibehalten
$("#cb-m-files").value = file;
updateLiveFit();
} catch(e) {}
}
window.setFilter = (filterId) => {
activeFilter = filterId;
const buttons = document.querySelectorAll('.filter-btn');
buttons.forEach(b => {
b.style.background = 'var(--bg)';
b.style.color = '';
b.style.border = '1px solid var(--line)';
});
const idx = FILTERS.findIndex(f => f.id === filterId);
if (idx !== -1 && buttons[idx]) {
buttons[idx].style.background = 'var(--hi)';
buttons[idx].style.color = 'var(--bg)';
buttons[idx].style.border = 'none';
let html = "";
for (let i = 0; i < CURATED.length; i++) {
const m = CURATED[i];
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
html += curatedCard(m, i, fit);
}
grid.innerHTML = html;
grid.querySelectorAll("[data-cur]").forEach(el => el.addEventListener("click", () => openCurated(+el.getAttribute("data-cur"))));
} catch (e) {
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Konnte Empfehlungen nicht laden: ${esc(e.message)}</div>`;
}
doSearch();
};
}
async function doSearch() {
let q = $("#cb-search").value.trim();
if (activeFilter) {
q = q ? (q + " " + activeFilter) : activeFilter;
}
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
if (!q) return renderCurated();
const btn = $("#cb-btn-search");
btn.disabled = true; btn.textContent = "Lade...";
$("#cb-section-title").textContent = "Suchergebnisse für: " + esc(q);
$("#cb-grid").innerHTML = `<div class="meta" style="grid-column:1/-1; text-align:center; padding:40px">Suche auf HuggingFace...</div>`;
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
$("#cb-section-title").textContent = "Suchergebnisse: " + q;
const grid = $("#cb-grid");
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=downloads&direction=-1&limit=12`;
const r = await fetch(url);
const data = await r.json();
currentResults = data;
renderResults(data);
currentResults = await r.json();
renderResults(currentResults);
} catch (e) {
$("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
}
btn.disabled = false; btn.textContent = "Suchen";
}
function renderResults(results) {
const grid = $("#cb-grid");
if (!results || results.length === 0) {
grid.innerHTML = `<div class="meta" style="grid-column:1/-1; text-align:center; padding:40px">Keine GGUF-Modelle gefunden.</div>`;
return;
}
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" onclick="window.openModelModal(${i})">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
<div>
<h3 style="margin:0; font-size:16px; word-break:break-all">${esc(m.id.split('/').pop())}</h3>
<div class="meta" style="font-size:12px; margin-top:4px">${esc(m.author)}</div>
</div>
<span id="cb-s-badge-${i}" class="badge b-load" style="white-space:nowrap;">Analysiere...</span>
<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:16px;"></div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px; font-size:12px" class="meta">
<span id="cb-s-metrics-${i}">Berechne Hardware-Fit...</span>
<span style="display:flex; gap:8px; align-items:center">
<span>⬇ ${(m.downloads||0).toLocaleString()}</span>
<span id="cb-s-quant-${i}" class="badge b-idle" style="font-size:11px">GGUF</span>
</span>
</div>
</div>
`).join("");
// Lade Metriken im Hintergrund
results.forEach((m, i) => fetchAnalysisForCard(i, m.id));
<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 fetchAnalysisForCard(index, repo_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 badgeEl = document.getElementById(`cb-s-badge-${index}`);
const metricsEl = document.getElementById(`cb-s-metrics-${index}`);
const quantEl = document.getElementById(`cb-s-quant-${index}`);
if (!badgeEl || !metricsEl || !quantEl) return; // card might be gone
if (!res.files || res.files.length === 0) {
badgeEl.className = "badge b-err";
badgeEl.textContent = "Keine GGUFs";
metricsEl.textContent = "Keine Dateien gefunden";
return;
}
let best = res.files.find(f => f.quant && f.quant.includes("Q4_K_M"));
if (!best) best = res.files[0];
const fit = best.fit;
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
badgeEl.className = `badge ${cls}`;
badgeEl.textContent = fit.text;
metricsEl.innerHTML = `~${fit.req_gb.toFixed(1)} GB RAM/VRAM &middot; ~${Math.round(fit.tps)} t/s`;
quantEl.textContent = best.quant || "GGUF";
} catch(e) {
const badgeEl = document.getElementById(`cb-s-badge-${index}`);
const metricsEl = document.getElementById(`cb-s-metrics-${index}`);
if (badgeEl) {
badgeEl.className = "badge b-err";
badgeEl.textContent = "Fehler";
}
if (metricsEl) {
metricsEl.textContent = "Metriken konnten nicht geladen werden";
}
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 bad"; b.textContent = "keine GGUFs"; mt.textContent = "—"; 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");
} catch {
const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; }
}
}
// Global hook for inline onclick
window.openModelModal = async (index) => {
const m = currentResults[index];
if (!m) return;
$("#cb-modal").style.display = "flex";
$("#cb-m-title").textContent = m.id.split('/').pop();
$("#cb-m-repo").textContent = m.id;
$("#cb-m-files").innerHTML = "";
$("#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;
// ---- Modal ----
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 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;
const res = await api("/api/cookbook/analyze", {
method: "POST", body: JSON.stringify({ repo_id: m.id, ctx })
});
currentAnalysis = res;
$("#cb-m-loading").style.display = "none";
$("#cb-m-files").style.display = "block";
if (!res.files || res.files.length === 0) {
$("#cb-m-files").innerHTML = "<option value=''>Keine GGUF-Dateien gefunden.</option>";
$("#cb-m-fit-container").style.display = "none";
} else {
// Optische Indikatoren im Dropdown
$("#cb-m-files").innerHTML = res.files.map(f => {
let mark = "";
if (f.fit.level === "perfect") mark = "🟢";
else if (f.fit.level === "marginal") mark = "🟡";
else mark = "🔴";
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
}).join("");
$("#cb-m-download").disabled = false;
updateLiveFit();
}
} catch(e) {
$("#cb-m-loading").textContent = "Fehler: " + e.message;
}
};
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 openCurated(i) {
const m = CURATED[i]; if (!m) return;
openModalBase(m.name, m.repo);
$("#cb-m-files").style.display = "block"; $("#cb-m-loading").style.display = "none";
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
$("#cb-m-alias").value = m.alias; $("#cb-m-ctx").value = m.ctx; $("#cb-m-download").disabled = false;
try {
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
currentAnalysis = { repo: m.repo, params_b: m.params_b, files: [{ filename: m.file, quant: m.quant, fit }] };
showFit();
} catch {}
}
async function doDownload() {
const repo = $("#cb-m-repo").textContent;
const file = $("#cb-m-files").value;
const alias = $("#cb-m-alias").value.trim();
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
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);
$("#cb-m-download").disabled = true;
$("#cb-m-download").textContent = "Starte...";
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 })
});
// Register directly, jobengine will download it, MC will pick it up when done
await api("/api/register", {
method: "POST",
body: JSON.stringify({ alias, model_path: res.expected_path, ctx })
});
toast("Download gestartet! Siehe Aktivitäten.");
const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) });
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";
track(res.job_id); // open in jobs
// Switch to activity tab
document.querySelector(".nav-item[data-view='activity']").click();
} catch (e) {
toast("Fehler: " + e.message, true);
}
$("#cb-m-download").disabled = false;
$("#cb-m-download").textContent = "Herunterladen & Einpflegen";
document.querySelector(".nav-item[data-view='activity']")?.click();
} catch (e) { toast("Fehler: " + e.message, true); }
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
}
async function renderCurated() {
$("#cb-section-title").textContent = "Kuratierte Empfehlungen";
const grid = $("#cb-grid");
if (!grid) return;
grid.innerHTML = "<div class='meta' style='grid-column:1/-1;text-align:center;padding:40px'>Berechne Hardware-Fit für Empfehlungen...</div>";
try {
let html = "";
for (let i = 0; i < CURATED_MODELS.length; i++) {
const m = CURATED_MODELS[i];
const fit = await api("/api/cookbook/evaluate", {
method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx })
});
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
html += `
<div class="card" style="display:flex; flex-direction:column; cursor:pointer" onclick="window.openCuratedModal(${i})">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0; font-size:16px">${esc(m.name)}</h3>
<span class="badge ${cls}">${fit.text}</span>
</div>
<div style="font-size:13px; color:var(--mut); margin-top:12px; flex:1; line-height:1.5;">
${m.desc}
</div>
<div style="display:flex; justify-content:space-between; margin-top:16px; font-size:12px" class="meta">
<span>~${fit.req_gb.toFixed(1)} GB RAM/VRAM · ~${Math.round(fit.tps)} t/s</span>
<span>${m.quant}</span>
</div>
</div>
`;
}
grid.innerHTML = html;
} catch (e) {
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Fehler beim Laden der Empfehlungen: ${e.message}</div>`;
}
}
function onSystem(sys) { lastSys = sys; renderHwChip(); }
window.openCuratedModal = async (index) => {
const m = CURATED_MODELS[index];
if (!m) return;
$("#cb-modal").style.display = "flex";
$("#cb-m-title").textContent = m.name;
$("#cb-m-repo").textContent = m.repo;
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
$("#cb-m-files").style.display = "block";
$("#cb-m-loading").style.display = "none";
$("#cb-m-alias").value = m.alias;
$("#cb-m-ctx").value = m.ctx;
$("#cb-m-download").disabled = false;
// Wir nutzen die neue API Struktur auch für das simulierte Modal
try {
const fit = await api("/api/cookbook/evaluate", {
method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx })
});
currentAnalysis = {
repo: m.repo,
params_b: m.params_b,
files: [{ filename: m.file, quant: m.quant, fit: fit }]
};
updateLiveFit();
} catch(e) {}
};
function onSystem(sys) {
lastSys = sys;
}
function unmount() {}
export default { id: "cookbook", mount, unmount, onSystem };
export default { id: "cookbook", mount, onSystem };