feat(diagnostic): Katalog von HuggingFace — Trends, Code, unzensiert; taeglich; aufraeumen

"Von HuggingFace aktualisieren" holte nur die 40 meistgeladenen GGUF
aller Zeiten — neue und unzensierte Modelle kamen so kaum vor. Jetzt:
- Blickwinkel: beliebt, im Trend (trendingScore), Code, unzensiert
  (uncensored/heretic/abliterated).
- Nur Sprachmodelle (pipeline_tag + Namensfilter gegen Bild/Audio/Musik/
  Video/Embedding) und hoechstens LLM_CATALOG_MAX_GB (30).
- Markierungen in der Liste: "unzensiert", "neu" (letzte 7 Tage).
- Einmal am Tag automatisch (LLM_CATALOG_REFRESH_HOURS, 0 = aus).
- Aufraeumen: HF-Eintraege aelter als LLM_CATALOG_KEEP_DAYS (30), auf
  keiner Box und nicht mehr gelistet, fliegen raus; kuratierte und von
  Hand hinzugefuegte bleiben.

Getestet gegen HuggingFace mit Test-Katalog: 43 neue in 2 s, keine
Bild-/Audio-Modelle mehr, groesstes 29,2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:26:11 +01:00
co-authored by Claude Opus 5.5
parent faa4868ee2
commit d289d9404a
2 changed files with 72 additions and 18 deletions
+3 -1
View File
@@ -9706,7 +9706,7 @@
const j = await r.json();
_llmCatalog = (j && j.models) || _llmCatalog;
renderLlmCatalog();
if (st) { st.textContent = j.ok ? `✓ ${j.added || 0} neue Modelle von HuggingFace` : `✗ ${j.error || 'Fehler'}`; st.style.color = j.ok ? '#3FFF3F' : '#FF6B6B'; }
if (st) { st.textContent = j.ok ? `✓ ${j.added || 0} neue Modelle von HuggingFace` + (j.removed ? `, ${j.removed} alte aufgeräumt` : '') + ' (läuft auch von selbst einmal am Tag)' : `✗ ${j.error || 'Fehler'}`; st.style.color = j.ok ? '#3FFF3F' : '#FF6B6B'; }
} catch (e) { if (st) { st.textContent = '✗ ' + e.message; st.style.color = '#FF6B6B'; } }
}
// online LLM-Boxen (fuer die Ziel-Auswahl + Verfuegbarkeit)
@@ -9823,6 +9823,8 @@
const searchText = [m.id, m.description, m.hfRepo, m.quant].filter(Boolean).join(' ');
return '<tr data-search="' + escapeHtml(searchText) + '" style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b>'
+ (m.uncensored ? ' <span title="Unzensierte Fassung — lehnt kaum etwas ab" style="font-size:10px;color:#FFB020;border:1px solid #FFB020;border-radius:8px;padding:0 5px;">unzensiert</span>' : '')
+ (m.added_at && (Date.now() - new Date(m.added_at).getTime()) < 7 * 864e5 ? ' <span title="In den letzten 7 Tagen in den Katalog gekommen" style="font-size:10px;color:#4DB8FF;border:1px solid #4DB8FF;border-radius:8px;padding:0 5px;">neu</span>' : '')
+ (m.hfRepo ? ' <button class="btn secondary" onclick="openModelInfo(' + i + ')" title="Was kann das Modell? Zensiert oder nicht?" style="padding:0 6px;font-size:11px;margin-left:4px;">ℹ</button>' : '')
+ '</td>' +
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '')
+69 -17
View File
@@ -517,22 +517,46 @@ async function hfRepoQuant(repo, wanted = "Q4_K_M") {
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
// "Von HuggingFace aktualisieren": mehrere Blickwinkel statt nur der
// meistgeladenen aller Zeiten (da gewinnen alte Modelle). Neu dazu:
// was gerade im Trend ist, Coder-Modelle und unzensierte Fassungen.
// Nur was eine Box laden kann (GGUF) und hoechstens LLM_CATALOG_MAX_GB.
const LLM_CATALOG_MAX_GB = Number(process.env.LLM_CATALOG_MAX_GB || 30);
const NON_LLM_RE = /image|audio|tts|speech|music|yue|whisper|diffusion|flux|sdxl|wan2|video|embed|rerank|ocr/i;
const HF_REFRESH_QUERIES = [
["beliebt", "search=GGUF&sort=downloads&direction=-1&limit=30"],
["im Trend", "filter=gguf&sort=trendingScore&direction=-1&limit=30"],
["Code", "search=coder&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=uncensored&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=heretic&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=abliterated&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
];
async function refreshLlmCatalogFromHF() {
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) throw new Error(`HF API ${r.status}`);
const list = await r.json();
const existing = loadLlmCatalog();
const byId = new Map(existing.map(m => [m.id, m]));
let added = 0;
for (const m of (Array.isArray(list) ? list : [])) {
const repo = m.id || m.modelId;
if (!repo || !/gguf/i.test(repo)) continue;
const fresh = new Map(); // repo → {cat, downloads}
for (const [cat, q] of HF_REFRESH_QUERIES) {
const list = await hfJson(`https://huggingface.co/api/models?${q}`);
for (const m of Array.isArray(list) ? list : []) {
const repo = m.id || m.modelId;
if (!repo || fresh.has(repo)) continue;
// Nur Sprachmodelle: Bild-, Audio-, Musik- und Sprach-Modelle kann die
// LLM-Box nicht als Chat-Modell laden.
const pt = String(m.pipeline_tag || "");
if (pt && !["text-generation", "image-text-to-text", "conversational"].includes(pt)) continue;
if (NON_LLM_RE.test(repo)) continue;
fresh.set(repo, { cat, downloads: m.downloads || 0 });
}
}
if (!fresh.size) throw new Error("HuggingFace hat nichts geliefert (erreichbar?)");
const known = new Set(existing.map(m => m.hfRepo));
for (const [repo, f] of fresh) {
if (known.has(repo)) continue;
const id = slugModelId(repo);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true };
byId.set(id, entry);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
}
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
@@ -540,18 +564,46 @@ async function refreshLlmCatalogFromHF() {
for (let i = 0; i < toCheck.length; i += 6) {
await Promise.all(toCheck.slice(i, i + 6).map(async e => {
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
if (!q) {
// Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else e.unusable = true;
if (!q || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
// Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
// aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
return;
}
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
}));
}
let added = 0;
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
// Aufraeumen: von HF uebernommen, vor > LLM_CATALOG_KEEP_DAYS Tagen, auf
// keiner Box (Platte/Karte), heute nicht mehr gelistet → raus. Kuratierte
// und von Hand hinzugefuegte bleiben immer.
const keepDays = Number(process.env.LLM_CATALOG_KEEP_DAYS || 30);
const onBox = new Set();
for (const w of workers.values()) {
for (const d of ((w.disk && w.disk.models) || [])) onBox.add(d.repo);
for (const sl of (w.slots || [])) if (sl.model) onBox.add(sl.model);
}
let removed = 0;
for (const [id, e] of [...byId]) {
const age = e.added_at ? (Date.now() - new Date(e.added_at).getTime()) / 864e5 : 0;
if (e.source === "hf" && !/Hand/.test(e.description || "") && age > keepDays
&& !fresh.has(e.hfRepo) && !onBox.has(e.hfRepo) && !onBox.has(e.id)) {
byId.delete(id); removed++;
}
}
const merged = Array.from(byId.values());
saveLlmCatalog(merged);
return { models: merged, added };
return { models: merged, added, removed };
}
// Einmal am Tag von selbst (LLM_CATALOG_REFRESH_HOURS, 0 = aus)
const LLM_CATALOG_REFRESH_HOURS = Number(process.env.LLM_CATALOG_REFRESH_HOURS ?? 24);
if (LLM_CATALOG_REFRESH_HOURS > 0) {
setInterval(() => {
refreshLlmCatalogFromHF()
.then(r => log("info", "llm", `LLM-Katalog automatisch aktualisiert: +${r.added}, -${r.removed}`))
.catch(err => log("warn", "llm", `LLM-Katalog automatisch: ${err.message}`));
}, LLM_CATALOG_REFRESH_HOURS * 3600 * 1000);
}
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
@@ -2609,7 +2661,7 @@ const server = http.createServer((req, res) => {
refreshLlmCatalogFromHF()
.then(r => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added, removed: r.removed }));
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
})
.catch(err => {