337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
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"
|
|
}
|
|
];
|
|
|
|
function estimateMemoryGB(params_b, quant, ctx) {
|
|
const bpp = 0.6;
|
|
const weights = params_b * bpp;
|
|
const context = (ctx / 8192) * (params_b / 7) * 0.8;
|
|
return weights + context;
|
|
}
|
|
|
|
function estimateSpeed(req_gb, vram_gb) {
|
|
// Heuristic for speed in tokens/s
|
|
// Bosgame APU (Strix Halo) has unified memory with ~273 GB/s bandwidth.
|
|
// We approximate bandwidth: if huge VRAM/GTT, it's the APU.
|
|
const bw = (vram_gb > 32) ? 250 : 70; // 250 GB/s for APU, 70 GB/s for standard CPU
|
|
if (req_gb <= 0) return 0;
|
|
return (bw / req_gb) * 0.55; // 55% efficiency
|
|
}
|
|
|
|
function getFit(m, sys) {
|
|
const req = estimateMemoryGB(m.params_b, m.quant, m.ctx);
|
|
const vram_bytes = (sys?.gpu?.vram?.total || 0) + (sys?.gpu?.gtt?.total || 0);
|
|
const vram = vram_bytes / (1024 ** 3);
|
|
const ram_bytes = sys?.ram?.total || 0;
|
|
const ram_used = sys?.ram?.used || 0;
|
|
const ram = ram_bytes / (1024 ** 3);
|
|
const freeRam = (ram_bytes - ram_used) / (1024 ** 3);
|
|
|
|
const tps = estimateSpeed(req, vram);
|
|
|
|
if (vram === 0 && ram === 0) return { level: "perfect", class: "b-run", text: "Lade...", req, tps };
|
|
if (vram > 0 && req <= vram) return { level: "perfect", class: "b-run", text: "Passt in VRAM", req, tps };
|
|
if (req <= (vram + freeRam)) return { level: "good", class: "b-load", text: "RAM Offload", req, tps };
|
|
return { level: "too_tight", class: "b-err", text: "Zu groß (OOM)", req, tps };
|
|
}
|
|
|
|
let lastSys = null;
|
|
let currentResults = [];
|
|
|
|
function mount() {
|
|
const c = $(".view[data-view='cookbook']");
|
|
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">HuggingFace Suche</h2>
|
|
<p class="meta" style="margin-bottom:24px">Finde GGUF-Modelle direkt auf HuggingFace und lade sie in Mission Control herunter.</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>
|
|
</div>
|
|
|
|
<div class="card-h" style="margin-top:24px"><h3 id="cb-section-title">Kuratierte Empfehlungen</h3></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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
$("#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-m-download").addEventListener("click", doDownload);
|
|
|
|
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
|
$("#cb-m-ctx").addEventListener("input", updateLiveFit);
|
|
|
|
renderCurated();
|
|
}
|
|
|
|
function extractParamsB(name) {
|
|
const moe = name.match(/(\d+)x(\d+(?:\.\d+)?)[bB]/i);
|
|
if (moe) return parseInt(moe[1]) * parseFloat(moe[2]);
|
|
const m = name.match(/(\d+(?:\.\d+)?)[bB](?![a-zA-Z])/i);
|
|
if (m) return parseFloat(m[1]);
|
|
return 7; // Fallback
|
|
}
|
|
|
|
function extractQuant(filename) {
|
|
const m = filename.match(/(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)/i);
|
|
return m ? m[1].toUpperCase() : "Q4_K_M";
|
|
}
|
|
|
|
function updateLiveFit() {
|
|
const repo = $("#cb-m-repo").textContent;
|
|
const file = $("#cb-m-files").value;
|
|
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
|
|
|
if (!repo || !file) {
|
|
$("#cb-m-fit-container").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
const params_b = extractParamsB(repo);
|
|
const quant = extractQuant(file);
|
|
|
|
const m = { params_b, quant, ctx };
|
|
const fit = getFit(m, lastSys);
|
|
|
|
$("#cb-m-fit-container").style.display = "flex";
|
|
$("#cb-m-fit-text").innerHTML = `Geschätzter Bedarf: <b>~${fit.req.toFixed(1)} GB RAM/VRAM</b> <br><small class="meta">${params_b}B Params · ${quant} · ~${Math.round(fit.tps)} t/s</small>`;
|
|
$("#cb-m-fit-badge").innerHTML = `<span class="badge ${fit.class}">${fit.text}</span>`;
|
|
}
|
|
|
|
async function doSearch() {
|
|
const q = $("#cb-search").value.trim();
|
|
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>`;
|
|
|
|
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);
|
|
} 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");
|
|
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;
|
|
}
|
|
|
|
grid.innerHTML = results.map((m, i) => `
|
|
<div class="card" style="display:flex; flex-direction:column; cursor:pointer" onclick="window.openModelModal(${i})">
|
|
<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 style="flex:1; margin-top:16px;"></div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px">
|
|
<span class="badge b-idle" style="font-size:11px">GGUF</span>
|
|
<span class="meta" style="font-size:12px">⬇ ${(m.downloads||0).toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
// 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";
|
|
|
|
try {
|
|
const url = `https://huggingface.co/api/models/${m.id}/tree/main`;
|
|
const r = await fetch(url);
|
|
const tree = await r.json();
|
|
const files = tree.filter(f => f.path.endsWith('.gguf')).map(f => f.path);
|
|
|
|
$("#cb-m-loading").style.display = "none";
|
|
$("#cb-m-files").style.display = "block";
|
|
|
|
if (files.length === 0) {
|
|
$("#cb-m-files").innerHTML = "<option value=''>Keine GGUF-Dateien im Hauptverzeichnis gefunden.</option>";
|
|
$("#cb-m-download").disabled = true;
|
|
$("#cb-m-fit-container").style.display = "none";
|
|
} else {
|
|
$("#cb-m-files").innerHTML = files.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join("");
|
|
$("#cb-m-download").disabled = false;
|
|
updateLiveFit();
|
|
}
|
|
} catch(e) {
|
|
$("#cb-m-loading").textContent = "Fehler beim Laden der Dateien.";
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true);
|
|
|
|
$("#cb-m-download").disabled = true;
|
|
$("#cb-m-download").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.");
|
|
$("#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";
|
|
}
|
|
|
|
function renderCurated() {
|
|
$("#cb-section-title").textContent = "Kuratierte Empfehlungen";
|
|
const grid = $("#cb-grid");
|
|
if (!grid) return;
|
|
grid.innerHTML = CURATED_MODELS.map((m, i) => {
|
|
const fit = getFit(m, lastSys);
|
|
return `
|
|
<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 ${fit.class}">${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.toFixed(1)} GB RAM · ~${Math.round(fit.tps)} t/s</span>
|
|
<span>${m.quant}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
window.openCuratedModal = (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;
|
|
updateLiveFit();
|
|
};
|
|
|
|
function onSystem(sys) {
|
|
lastSys = sys;
|
|
if (!$("#cb-search").value.trim()) renderCurated();
|
|
}
|
|
|
|
function unmount() {}
|
|
|
|
export default { id: "cookbook", mount, unmount, onSystem };
|