feat(diagnostic): ℹ im Modell-Katalog — was kann das Modell, ist es zensiert?

Neben jedem Katalog-Eintrag ℹ → Fenster mit:
- Kurzbeschreibung auf Deutsch, beim ersten Oeffnen vom LOKALEN Modell aus
  der Modellkarte (Grundmodell + GGUF-Repo) erzeugt und im Katalog
  gespeichert (summary_de) — so hat jedes neue Modell eine, ohne Pflege.
  Hinweis "kann Fehler enthalten". Laeuft kein Modell: Meldung.
- Feste deutsche Texte je Modellfamilie (Qwen3-Coder, Foundation-Sec,
  VulnLLM, Dolphin, Hermes, Lexi, Gemma, Qwen, Llama, Mistral, DeepSeek,
  Phi), erkannt als ganzes Wort in Name/Grundmodell/Schlagworten.
- "unzensiert" / "mit Schutzfiltern" (uncensored/abliterated/heretic/
  dolphin/lexi … in Name oder Schlagworten).
- Fakten von HF: Grundmodell, Autor, Lizenz, Beliebtheit, Stand,
  Schlagworte; Auszug der Modellkarte (Link-Absaetze uebersprungen).

Server: /api/llm-model-info, /api/llm-model-summary, rvsRequest() als
Promise-Variante fuer eigene RVS-Anfragen.

Getestet: Info-Teil gegen HuggingFace (4 Modelle). Die Zusammenfassung
durch das lokale Modell ungetestet (Karte gerade im Gegenlauf belegt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:24:33 +01:00
co-authored by Claude Opus 5.5
parent 848eee5d1c
commit faa4868ee2
2 changed files with 212 additions and 1 deletions
+67 -1
View File
@@ -9822,7 +9822,9 @@
: '<option value="">(keine Box online)</option>';
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></td>' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b>'
+ (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 || '')
+ (m.quant ? ' <span style="color:#555570;" title="Quantisierung, die geladen wird' + (m.quants ? ' — vorhanden: ' + escapeHtml(m.quants.join(', ')) : '') + '">· ' + escapeHtml(m.quant) + '</span>' : '')
+ (m.unusable ? ' <span style="color:#FF6B6B;" title="Im Repo liegt keine ladbare GGUF-Modelldatei mehr">· nicht ladbar</span>' : '')
@@ -10241,6 +10243,70 @@
: '');
} catch (e) { out.innerHTML = '<span style="color:#FF6B6B;">✗ ' + escapeHtml(e.message) + '</span>'; }
}
// ℹ: Kurzbeschreibung, zensiert/unzensiert, Fakten von HuggingFace
async function openModelInfo(i) {
const m = _llmCatalog[i];
if (!m || !m.hfRepo) return;
const box = _modelInfoModal('<div style="color:#8888AA;">Frage HuggingFace nach ' + escapeHtml(m.hfRepo) + ' …</div>');
let j;
try { j = await (await fetch('/api/llm-model-info?repo=' + encodeURIComponent(m.hfRepo))).json(); }
catch (e) { j = { ok: false, error: e.message }; }
if (!document.body.contains(box)) return;
if (!j.ok) { _modelInfoModal('<div style="color:#FF6B6B;">✗ ' + escapeHtml(j.error || 'Fehler') + '</div>'); return; }
const fam = (j.families || []).map(f => `<div style="margin-bottom:8px;"><b>${escapeHtml(f.name)}</b><div style="color:#C8C8DA;margin-top:2px;">${escapeHtml(f.text)}</div></div>`).join('')
|| '<div style="color:#8888AA;">Für diese Modellfamilie habe ich keine Kurzbeschreibung — siehe Modellkarte unten.</div>';
const cens = j.uncensored
? `<span style="background:#3A2410;color:#FFB020;border:1px solid #FFB020;border-radius:10px;padding:1px 8px;">unzensiert</span> <span style="color:#8888AA;font-size:11px;">${escapeHtml(j.uncensoredWhy)} — lehnt kaum etwas ab; du bist selbst verantwortlich, was du damit machst.</span>`
: `<span style="background:#103A20;color:#34C759;border:1px solid #34C759;border-radius:10px;padding:1px 8px;">mit Schutzfiltern</span> <span style="color:#8888AA;font-size:11px;">Standard des Herstellers — lehnt heikle Anfragen ab (keine Hinweise auf „uncensored“ o.ä.).</span>`;
_loadModelSummary(m.hfRepo);
const row = (k, v) => v ? `<tr><td style="color:#8888AA;padding:2px 10px 2px 0;white-space:nowrap;">${k}</td><td>${v}</td></tr>` : '';
const date = x => x ? new Date(x).toLocaleDateString('de-DE') : '';
_modelInfoModal(`
<h3 style="margin:0 0 4px;">ℹ ${escapeHtml(m.id)}</h3>
<a href="https://huggingface.co/${escapeHtml(m.hfRepo)}" target="_blank" rel="noopener noreferrer" style="color:#4DB8FF;font-size:12px;">${escapeHtml(m.hfRepo)}</a>
<div style="margin:12px 0;">${cens}</div>
<h4 style="margin:10px 0 6px;">Was kann es?</h4>
<div id="model-info-summary" style="margin-bottom:10px;color:#8888AA;">Kurzbeschreibung wird vom lokalen Modell aus der Modellkarte erstellt …</div>
${fam}
<table style="font-size:12px;margin-top:8px;">
${row('Größe', m.sizeGB ? '~' + m.sizeGB + ' GB · ' + escapeHtml(m.quant || '') : '')}
${row('Grundmodell', j.base ? escapeHtml(j.base) : '')}
${row('Von', escapeHtml(j.author))}
${row('Lizenz', escapeHtml(j.license))}
${row('Beliebtheit', '↓ ' + Number(j.downloads).toLocaleString('de') + ' · ♥ ' + j.likes)}
${row('Stand', date(j.updated))}
${row('Schlagworte', (j.tags || []).map(t => '<span style="color:#9a9ab5;">' + escapeHtml(t) + '</span>').join(', '))}
</table>
${j.intro ? `<details style="margin-top:10px;"><summary style="cursor:pointer;color:#8888AA;">Aus der Modellkarte (${escapeHtml(j.introFrom)}, englisch)</summary><div style="white-space:pre-wrap;color:#C8C8DA;font-size:12px;margin-top:6px;">${escapeHtml(j.intro)}</div></details>` : ''}`);
}
// Deutsche Kurzbeschreibung: einmal vom lokalen Modell erzeugt, danach aus dem Katalog
async function _loadModelSummary(repo) {
let j;
try { j = await (await fetch('/api/llm-model-summary?repo=' + encodeURIComponent(repo))).json(); }
catch (e) { j = { ok: false, error: e.message }; }
const el = document.getElementById('model-info-summary');
if (!el) return;
if (j.ok) {
el.style.color = '#E0E0F0';
el.innerHTML = escapeHtml(j.summary) + `<div style="color:#555570;font-size:11px;margin-top:4px;">Zusammengefasst aus der Modellkarte von ${escapeHtml(j.by || 'dem lokalen Modell')} — kann Fehler enthalten.</div>`;
} else {
el.innerHTML = '<span style="color:#8888AA;">Keine Kurzbeschreibung: ' + escapeHtml(j.error || 'unbekannt') + ' (läuft ein lokales Modell?)</span>';
}
}
function _modelInfoModal(html) {
let m = document.getElementById('model-info-modal');
if (!m) {
m = document.createElement('div');
m.id = 'model-info-modal';
m.style.cssText = 'position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;';
m.addEventListener('click', e => { if (e.target === m) m.remove(); });
document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { m.remove(); document.removeEventListener('keydown', esc); } });
document.body.appendChild(m);
}
m.innerHTML = `<div style="position:relative;background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:640px;width:100%;max-height:86vh;overflow-y:auto;color:#E0E0F0;font-size:13px;line-height:1.5;">
<button onclick="document.getElementById('model-info-modal').remove()" style="position:absolute;top:8px;right:10px;background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button>${html}</div>`;
return m;
}
async function addTrendingToCatalog(btn, repo) {
btn.disabled = true; btn.textContent = '…';
try {
+145
View File
@@ -660,6 +660,121 @@ async function hfGgufAlternatives(repo) {
} catch { return []; }
}
// ── ℹ im Modell-Katalog: was kann das Modell, ist es zensiert? ──────
// HuggingFace liefert Grundmodell, Lizenz und Schlagworte, aber keine
// verstaendliche Beschreibung. Darum: kurze deutsche Texte je Modellfamilie
// (erkannt am Namen) + die Fakten von HF + Anfang der Modellkarte.
const MODEL_FAMILIES = [
["qwen3-coder", "Qwen3-Coder (Alibaba)", "Aufs Programmieren spezialisiert: Code schreiben, verstehen, umbauen, mit Werkzeugen arbeiten (Agenten). Die 30B-A3B-Fassung ist ein MoE-Modell: pro Wort rechnen nur ~3 Mrd. Parameter — schnell für seine Größe."],
["foundation-sec", "Foundation-Sec (Cisco Foundation AI)", "Auf IT-Sicherheit trainiert: Schwachstellen, Angriffstechniken (MITRE ATT&CK), Logs und Warnmeldungen auswerten. Die reine „Foundation-Sec-8B“ ist ein Basismodell (setzt nur Text fort) — zum Chatten die Instruct- oder Reasoning-Fassung nehmen."],
["vulnllm", "VulnLLM-R", "Sucht Sicherheitslücken in Quellcode und begründet Schritt für Schritt (Reasoning), warum eine Stelle verwundbar ist."],
["dolphin", "Dolphin (Cognitive Computations)", "Bewusst ohne Ablehnungen trainiert: macht, was man verlangt, und lässt sich stark über den Systemprompt steuern. Gut für Aufgaben, die große Anbieter verweigern — man ist aber selbst verantwortlich, was man damit tut."],
["hermes", "Hermes (Nous Research)", "Sehr gutes Befolgen von Anweisungen, Rollenspiel und Werkzeug-Aufrufe (Function Calling), wenige Ablehnungen. Hermes 4 kann zusätzlich „denken“ (Reasoning)."],
["lexi", "Lexi (Orenguteng)", "Unzensierter Llama-Ableger für allgemeinen Chat, beantwortet auch Fragen, die das Original ablehnt."],
["gemma", "Gemma (Google)", "Allround-Modell mit gutem Deutsch; neuere Fassungen verstehen auch Bilder. Eher vorsichtig, lehnt heikle Themen ab."],
["qwen", "Qwen (Alibaba)", "Starke Allround-Familie: Wissen, Logik, Mathe, Code, viele Sprachen inkl. Deutsch; neuere Fassungen mit Denkmodus. Standard-Fassungen haben Schutzfilter."],
["llama", "Llama (Meta)", "Bewährtes Allround-Modell und Basis für sehr viele Ableger; ordentliches Deutsch, solide Werkzeug-Aufrufe."],
["mistral", "Mistral (Mistral AI, Frankreich)", "Gut in europäischen Sprachen, effizient; Mistral Small 24B ist für seine Größe sehr stark."],
["deepseek", "DeepSeek", "Stark in Logik, Mathe und Code, oft mit ausführlichem Denkweg (Reasoning)."],
["phi", "Phi (Microsoft)", "Klein und schnell, erstaunlich gut in Logik für die Größe; eher Englisch."],
];
const UNCENSORED_RE = /uncensor|abliterat|heretic|obliterat|dolphin|lexi|unfiltered|nsfw|no.?refus/i;
async function hfJson(url) {
try {
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
return r.ok ? await r.json() : null;
} catch { return null; }
}
async function hfReadmeIntro(repo) {
try {
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return "";
let t = await r.text();
t = t.replace(/^---[\s\S]*?\n---\n/, ""); // YAML-Kopf
const paras = t.split(/\n\s*\n/).map(p => p.trim())
.filter(p => p && !/^(#|!\[|<|\||```|\[!\[|>|-{3,})/.test(p) && p.replace(/[^A-Za-z]/g, "").length > 60)
// Absaetze, die fast nur Links sind (Website:, Twitter: …), ueberspringen
.filter(p => p.replace(/https?:\/\/\S+/g, "").replace(/[^A-Za-z]/g, "").length > 80);
return paras.slice(0, 2).join("\n\n").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").slice(0, 900);
} catch { return ""; }
}
// Ganze Modellkarte als Text (ohne YAML/Bilder/HTML), fuer die Zusammenfassung
async function hfReadmeText(repo, max = 7000) {
try {
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return "";
return (await r.text()).replace(/^---[\s\S]*?\n---\n/, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").slice(0, max);
} catch { return ""; }
}
// Deutsche Kurzbeschreibung vom LOKALEN Modell aus der Modellkarte — einmal
// erzeugt, dann im Katalog gespeichert (summary_de). So hat jedes neue Modell
// eine, ohne dass jemand Texte pflegt.
async function llmModelSummary(repo) {
repo = hfRepoFromInput(repo);
const cat = loadLlmCatalog();
const entry = cat.find(m => m.hfRepo === repo);
if (entry && entry.summary_de) return { summary: entry.summary_de, by: entry.summary_by || "", cached: true };
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
let base = (meta.cardData || {}).base_model;
base = Array.isArray(base) ? base[0] : base || "";
const card = [await hfReadmeText(base) , await hfReadmeText(repo, 3000)].filter(Boolean).join("\n\n---\n\n");
if (!card.trim()) return { summary: "", error: "Keine Modellkarte auf HuggingFace." };
const tags = (meta.tags || []).filter(t => !/^(base_model|license|region|endpoints)/.test(t)).slice(0, 25).join(", ");
const res = await rvsRequest("llm_request", {
requestId: "modelinfo_" + Date.now(),
messages: [
{ role: "system", content: "Du fasst Modellkarten von KI-Sprachmodellen fuer einen Laien auf Deutsch zusammen. "
+ "Nur was in der Karte steht, nichts erfinden. 3 bis 5 kurze Saetze: wofuer das Modell gedacht ist, worin es "
+ "besonders gut ist, ob es Ablehnungen/Schutzfilter entfernt hat (uncensored/abliterated), und Besonderheiten "
+ "(Denkmodus, Werkzeuge, Sprachen, Groesse). Keine Ueberschrift, kein Markdown." },
{ role: "user", content: `Modell: ${repo}\nGrundmodell: ${base || "-"}\nSchlagworte: ${tags}\n\nModellkarte:\n${card}` },
],
max_tokens: 400, temperature: 0.2, model: "", targetInstance: "",
}, "llm_response", 180000);
const text = String(res.content || res.text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
if (!res.ok || !text) return { summary: "", error: res.error || "Das lokale Modell hat nicht geantwortet." };
const by = res.model || "lokales Modell";
if (entry) {
entry.summary_de = text; entry.summary_by = by; entry.summary_at = new Date().toISOString();
saveLlmCatalog(cat);
}
return { summary: text, by, cached: false };
}
async function llmModelInfo(repo) {
repo = hfRepoFromInput(repo);
if (!repo) throw new Error("ungueltiges Repo");
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
const card = meta.cardData || {};
let base = card.base_model;
base = Array.isArray(base) ? base[0] : base || "";
const baseMeta = base ? (await hfJson(`https://huggingface.co/api/models/${base}`) || {}) : {};
const tags = [...new Set([...(meta.tags || []), ...((card.tags) || [])])]
.filter(t => !/^(base_model|license|region|endpoints|autotrain|text-generation-inference|safetensors|transformers|gguf|conversational|imatrix)/.test(t));
const hay = [repo, base, ...tags].join(" ").toLowerCase();
const fams = [];
for (const [key, name, text] of MODEL_FAMILIES) {
// Ganzes Wort am Anfang ("phi" nicht in "dolphin" finden)
const re = new RegExp("(^|[^a-z0-9])" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
if (name && re.test(hay) && !fams.some(f => f.name === name)) fams.push({ name, text });
}
// Allgemeine Familien (Qwen/Llama …) nur, wenn keine speziellere passt
const special = fams.filter(f => !/^(Qwen|Llama|Mistral|Gemma) \(/.test(f.name));
const families = special.length ? [...special, ...fams.filter(f => !special.includes(f)).slice(0, 1)] : fams.slice(0, 1);
const unc = UNCENSORED_RE.exec(hay);
return {
repo, base, author: meta.author || repo.split("/")[0], license: card.license || "",
downloads: meta.downloads || 0, likes: meta.likes || 0,
created: meta.createdAt || "", updated: meta.lastModified || "",
tags: tags.slice(0, 20), families,
uncensored: !!unc,
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
introFrom: base || repo,
};
}
async function addHfRepoToCatalog(repo, description = "") {
repo = hfRepoFromInput(repo);
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
@@ -1689,6 +1804,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
freshWs.on("error", () => {});
}
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
// eigene Verbindung, wartet auf expectType mit passender requestId.
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
return new Promise((resolve) => {
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
const proto = RVS_TLS === "true" ? "wss" : "ws";
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
ws2.on("message", (raw) => {
try {
const r = JSON.parse(raw.toString());
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
} catch {}
});
ws2.on("error", (e) => done({ ok: false, error: e.message }));
});
}
function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
@@ -2504,6 +2639,16 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
});
} else if (req.url.startsWith("/api/llm-model-info") && req.method === "GET") {
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
llmModelInfo(repo)
.then(info => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...info })); })
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
} else if (req.url.startsWith("/api/llm-model-summary") && req.method === "GET") {
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
llmModelSummary(repo)
.then(r => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: !!r.summary, ...r })); })
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
} else if (req.url === "/api/llm-catalog/add" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });