feat(diagnostic): Modell per HuggingFace-Link in den Katalog — mit GGUF-Vorschlaegen

Bisher kamen Modelle nur ueber "Von HuggingFace aktualisieren" und die
Trends in den Katalog. Jetzt: Feld "HuggingFace-Link oder org/name".
Hat das Repo kein GGUF (z.B. nur safetensors wie Virtue-AI-HUB/VulnLLM-R-7B
oder fdtn-ai/Foundation-Sec-8B), sucht der Server GGUF-Fassungen desselben
Modells (HF-Suche, filter=gguf, nach Downloads) und bietet sie mit
"Diese nehmen" an. Hinzugefuegte stehen danach gefiltert in der Liste.

Getestet gegen HuggingFace: beide Beispiele ohne GGUF erkannt, je 5
ladbare GGUF-Fassungen gefunden (Q4_K_M ~4,7–4,9 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:14:24 +01:00
co-authored by Claude Opus 5.5
parent 2cd8f1a0de
commit 848eee5d1c
2 changed files with 68 additions and 8 deletions
+35
View File
@@ -960,6 +960,12 @@
den Fortschritt. Danach ist es im Modell-Dropdown oben waehlbar. den Fortschritt. Danach ist es im Modell-Dropdown oben waehlbar.
<span style="color:#FFD60A;">Achte auf den VRAM der Box (Spalte VRAM).</span> <span style="color:#FFD60A;">Achte auf den VRAM der Box (Spalte VRAM).</span>
</p> </p>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px;">
<input id="llm-catalog-add" type="text" placeholder="HuggingFace-Link oder org/name hinzufügen …" onkeydown="if(event.key==='Enter')addCatalogByLink()"
style="flex:1;min-width:240px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;">
<button class="btn secondary" onclick="addCatalogByLink()" style="padding:4px 10px;font-size:11px;">Hinzufügen</button>
</div>
<div id="llm-catalog-add-result" style="font-size:12px;margin:0 0 8px;"></div>
<input id="llm-catalog-search" type="search" placeholder="🔍 Modell suchen … (z.B. coder, qwen3, 30b)" oninput="filterLlmCatalog()" <input id="llm-catalog-search" type="search" placeholder="🔍 Modell suchen … (z.B. coder, qwen3, 30b)" oninput="filterLlmCatalog()"
style="display:none;width:100%;max-width:360px;box-sizing:border-box;margin:0 0 8px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;"> style="display:none;width:100%;max-width:360px;box-sizing:border-box;margin:0 0 8px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;">
<div id="llm-catalog-list" style="font-size:12px;color:#8888AA;overflow-x:auto;">(lade Katalog…)</div> <div id="llm-catalog-list" style="font-size:12px;color:#8888AA;overflow-x:auto;">(lade Katalog…)</div>
@@ -10206,6 +10212,35 @@
'<td style="' + td + 'text-align:right;white-space:nowrap;">' + act + '</td></tr>'; '<td style="' + td + 'text-align:right;white-space:nowrap;">' + act + '</td></tr>';
}).join('') + '</table></div>'; }).join('') + '</table></div>';
} }
// Modell per Link/Repo in den Katalog. Ohne GGUF: GGUF-Fassungen vorschlagen.
async function addCatalogByLink(repo) {
const inp = document.getElementById('llm-catalog-add');
const out = document.getElementById('llm-catalog-add-result');
const val = repo || (inp.value || '').trim();
if (!val) return;
out.innerHTML = '<span style="color:#8888AA;">Prüfe ' + escapeHtml(val) + ' auf HuggingFace …</span>';
try {
const j = await (await fetch('/api/llm-catalog/add', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hfRepo: val, manual: true }) })).json();
if (j.ok) {
_llmCatalog = j.models || _llmCatalog;
renderLlmCatalog();
inp.value = '';
out.innerHTML = '<span style="color:#34C759;">✓ ' + escapeHtml(j.id) + (j.existed ? ' war schon im Katalog' : ' ist jetzt im Katalog') + ' — unten Ziel-Karte wählen und laden.</span>';
const s = document.getElementById('llm-catalog-search');
if (s) { s.value = j.id; filterLlmCatalog(); }
return;
}
const alts = j.alternatives || [];
out.innerHTML = '<span style="color:#FF9F0A;">⚠ ' + escapeHtml(j.error || 'Fehler') + '</span>'
+ (alts.length ? '<div style="color:#9a9ab5;margin-top:4px;">GGUF-Fassungen desselben Modells:</div>' + alts.map(a =>
`<div style="display:flex;gap:8px;align-items:center;margin-top:3px;">
<a href="https://huggingface.co/${escapeHtml(a.repo)}" target="_blank" rel="noopener noreferrer" style="color:#E0E0F0;">${escapeHtml(a.repo)}</a>
<span style="color:#555570;">↓ ${Number(a.downloads).toLocaleString('de')}</span>
<button class="btn secondary" onclick="addCatalogByLink('${escapeHtmlAttr(a.repo)}')" style="padding:1px 8px;font-size:11px;">Diese nehmen</button></div>`).join('')
: '');
} catch (e) { out.innerHTML = '<span style="color:#FF6B6B;">✗ ' + escapeHtml(e.message) + '</span>'; }
}
async function addTrendingToCatalog(btn, repo) { async function addTrendingToCatalog(btn, repo) {
btn.disabled = true; btn.textContent = '…'; btn.disabled = true; btn.textContent = '…';
try { try {
+33 -8
View File
@@ -640,17 +640,42 @@ async function discoverGguf(cat, trusted) {
} }
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden") // Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
async function addHfRepoToCatalog(repo) { // Link oder "org/name" → "org/name" (huggingface.co/org/name/tree/main … geht auch)
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo"); function hfRepoFromInput(input) {
const t = String(input || "").trim().replace(/^https?:\/\/(www\.)?huggingface\.co\//i, "").replace(/[?#].*$/, "");
const m = t.match(/^([\w.-]+)\/([\w.-]+)/);
return m ? `${m[1]}/${m[2]}` : "";
}
// Repo ohne GGUF (z.B. nur safetensors): GGUF-Fassungen desselben Modells
// auf HuggingFace suchen — meist von mradermacher, bartowski, unsloth & Co.
async function hfGgufAlternatives(repo) {
const name = String(repo).split("/")[1] || "";
if (!name) return [];
try {
const r = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(name)}&filter=gguf&sort=downloads&direction=-1&limit=12`,
{ headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return [];
return (await r.json()).filter(m => m.id && m.id !== repo)
.map(m => ({ repo: m.id, downloads: m.downloads || 0, likes: m.likes || 0 })).slice(0, 8);
} catch { return []; }
}
async function addHfRepoToCatalog(repo, description = "") {
repo = hfRepoFromInput(repo);
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
const q = await hfRepoQuant(repo, "Q4_K_M"); const q = await hfRepoQuant(repo, "Q4_K_M");
if (!q) throw new Error("kein ladbares GGUF-Modell in diesem Repo"); if (!q) {
const err = new Error("In diesem Repo gibt es kein GGUF — das braucht die Box (llama.cpp).");
err.alternatives = await hfGgufAlternatives(repo);
throw err;
}
const cat = loadLlmCatalog(); const cat = loadLlmCatalog();
let id = slugModelId(repo); let id = slugModelId(repo);
const same = cat.find(m => m.hfRepo === repo); const same = cat.find(m => m.hfRepo === repo);
if (same) return { models: cat, id: same.id, existed: true }; if (same) return { models: cat, id: same.id, existed: true };
while (cat.some(m => m.id === id)) id += "-2"; while (cat.some(m => m.id === id)) id += "-2";
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants, cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
description: "Aus den HuggingFace-Trends übernommen", source: "hf" }); description: description || "Aus den HuggingFace-Trends übernommen", source: "hf" });
saveLlmCatalog(cat); saveLlmCatalog(cat);
return { models: cat, id, existed: false }; return { models: cat, id, existed: false };
} }
@@ -2483,9 +2508,9 @@ const server = http.createServer((req, res) => {
let body = ""; let body = "";
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); }); req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });
req.on("end", () => { req.on("end", () => {
let repo = ""; let repo = "", manual = false;
try { repo = JSON.parse(body || "{}").hfRepo; } catch {} try { const b = JSON.parse(body || "{}"); repo = b.hfRepo; manual = !!b.manual; } catch {}
addHfRepoToCatalog(repo) addHfRepoToCatalog(repo, manual ? "Von Hand hinzugefügt" : "")
.then(r => { .then(r => {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, ...r })); res.end(JSON.stringify({ ok: true, ...r }));
@@ -2493,7 +2518,7 @@ const server = http.createServer((req, res) => {
}) })
.catch(err => { .catch(err => {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message })); res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
}); });
}); });
} else if (req.url === "/api/local-llm-config" && req.method === "GET") { } else if (req.url === "/api/local-llm-config" && req.method === "GET") {