feat(diagnostic): Katalog zeigt, ob ein Modell Werkzeuge kann (🔧) + Filter

Erkennung: Chat-Vorlage des GGUF (HF expand[]=gguf) kennt ein Werkzeug-
Format, oder Schlagworte wie function-calling/tool-use. Zeigt, OB das
Format da ist — nicht, wie gut das Modell damit umgeht.
- Neue und hinzugefuegte Eintraege bekommen tools: true/false/null,
  bestehende beim naechsten "Von HuggingFace aktualisieren".
- Liste: 🔧-Abzeichen; Haken "nur Modelle mit Werkzeugen" (Standard an)
  blendet Modelle ohne Werkzeug-Format aus (unbekannt bleibt sichtbar).
- ℹ: Zeile "Werkzeuge: ja / nein / unbekannt".

Getestet gegen HuggingFace (7 Modelle, u.a. Hermes 3 ueber das Schlagwort,
weil dessen offizielle Vorlage minimal ist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:41:24 +01:00
co-authored by Claude Opus 5.5
parent c8911316d7
commit 91619d9307
2 changed files with 33 additions and 3 deletions
+10 -2
View File
@@ -966,6 +966,8 @@
<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>
<label style="display:inline-flex;gap:5px;align-items:center;font-size:11px;color:#9a9ab5;margin:0 0 6px;cursor:pointer;" title="Modelle ohne Werkzeug-Format ausblenden (z.B. für Übersetzen/Zusammenfassen reichen auch die)">
<input type="checkbox" id="llm-catalog-tools" checked onchange="filterLlmCatalog()"> nur Modelle mit Werkzeugen 🔧</label>
<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;">
<div id="llm-catalog-list" style="font-size:12px;color:#8888AA;overflow-x:auto;">(lade Katalog…)</div>
@@ -9737,7 +9739,11 @@
const q = (document.getElementById('llm-catalog-search') || {}).value || '';
const rows = document.querySelectorAll('#llm-catalog-list tbody tr[data-search]');
let shown = 0;
rows.forEach(tr => { const ok = _llmMatches(tr.dataset.search, q); tr.style.display = ok ? '' : 'none'; if (ok) shown++; });
const onlyTools = (document.getElementById('llm-catalog-tools') || {}).checked;
rows.forEach(tr => {
const ok = _llmMatches(tr.dataset.search, q) && !(onlyTools && tr.dataset.tools === 'nein');
tr.style.display = ok ? '' : 'none'; if (ok) shown++;
});
const none = document.getElementById('llm-catalog-none');
if (none) none.style.display = rows.length && !shown ? '' : 'none';
}
@@ -9822,8 +9828,9 @@
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
: '<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;">' +
return '<tr data-search="' + escapeHtml(searchText) + '" data-tools="' + (m.tools === false ? 'nein' : 'ja') + '" style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b>'
+ (m.tools ? ' <span title="Kann Werkzeuge aufrufen (laut Chat-Vorlage) — z.B. Websuche, wenn ARIA es lokal nutzt" style="font-size:10px;color:#34C759;border:1px solid #34C759;border-radius:8px;padding:0 5px;">🔧</span>' : '')
+ (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>' : '')
@@ -10272,6 +10279,7 @@
<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('Werkzeuge', j.tools === true ? '🔧 ja — die Chat-Vorlage kennt Werkzeug-Aufrufe' : j.tools === false ? 'nein — eher für Übersetzen, Zusammenfassen, Erklären' : 'unbekannt (keine Chat-Vorlage)')}
${row('Größe', m.sizeGB ? '~' + m.sizeGB + ' GB · ' + escapeHtml(m.quant || '') : '')}
${row('Grundmodell', j.base ? escapeHtml(j.base) : '')}
${row('Von', escapeHtml(j.author))}
+23 -1
View File
@@ -573,6 +573,11 @@ async function refreshLlmCatalogFromHF() {
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
}));
}
// Werkzeug-Faehigkeit nachtragen (alle Eintraege, die noch keine Angabe haben)
const noTools = [...byId.values()].filter(e => e.hfRepo && e.tools === undefined);
for (let i = 0; i < noTools.length; i += 6) {
await Promise.all(noTools.slice(i, i + 6).map(async e => { e.tools = await hfToolSupport(e.hfRepo); }));
}
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
@@ -821,12 +826,27 @@ async function llmModelInfo(repo) {
created: meta.createdAt || "", updated: meta.lastModified || "",
tags: tags.slice(0, 20), families,
uncensored: !!unc,
tools: await hfToolSupport(repo),
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
introFrom: base || repo,
};
}
// Kann das Modell Werkzeuge? Kennzeichen: die Chat-Vorlage im GGUF (HF liefert
// sie unter expand[]=gguf) kennt ein Werkzeug-Format, oder Schlagworte wie
// function-calling/tool-use. Zeigt nur, OB das Format da ist, nicht wie gut.
const TOOLS_TEMPLATE_RE = /\btools\b|tool_call|<tool|function_call/;
const TOOLS_TAG_RE = /function.?call|tool.?use|tool.?calling|agent/i;
async function hfToolSupport(repo) {
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=gguf&expand[]=tags`);
if (!d) return null;
const tpl = String(((d.gguf || {}).chat_template) || "");
if (TOOLS_TEMPLATE_RE.test(tpl)) return true;
if ((d.tags || []).some(t => TOOLS_TAG_RE.test(t))) return true;
return tpl ? false : null; // keine Vorlage = unbekannt
}
async function addHfRepoToCatalog(repo, description = "") {
repo = hfRepoFromInput(repo);
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
@@ -842,7 +862,9 @@ async function addHfRepoToCatalog(repo, description = "") {
if (same) return { models: cat, id: same.id, existed: true };
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,
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf" });
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
added_at: new Date().toISOString().slice(0, 10) });
saveLlmCatalog(cat);
return { models: cat, id, existed: false };
}