feat(v9): Phase 10 Schritt 2 — Capability-Chips + Facetten-Filter im Cookbook

- Profi-Suche: HF-Suche mit &full=true (liefert Tags); Multi-Capability-Chips
  (Code/Bild/Logik/MoE·aktiv/Tools(yes|likely)/Chat) aus Name + HF-Tags.
- Facetten-Filter (Mehrfachauswahl: Tools/MoE/Logik/Bild/Code) statt
  Einzel-Kategorie — clientseitig auf den Treffern (Index für Fit/Best bleibt).
- Discover: dieselben Chips; Backend liefert HF-Tags je Modell mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-24 18:16:40 +02:00
parent ada85504f3
commit 701476179e
3 changed files with 106 additions and 53 deletions
+65 -13
View File
@@ -22,6 +22,52 @@
for (const c of CAP_KW) if (c.kw.some((k: string) => l.includes(k))) return c.label for (const c of CAP_KW) if (c.kw.some((k: string) => l.includes(k))) return c.label
return 'Allrounder' return 'Allrounder'
} }
// ---- Capability-Tags (Phase 10, Schritt 2): aus Name + HF-Tags ----
const TOOL_FAM = ['qwen2.5', 'qwen3', 'qwen2', 'hermes', 'mistral', 'mixtral', 'devstral',
'command-r', 'command_r', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama-4', 'llama4',
'functionary', 'watt', 'firefunction', 'granite', 'glm-4', 'ministral', 'gemma-3', 'gemma3']
const REASON_KW = ['-r1', 'deepseek-r1', 'qwq', 'magistral', '-think', 'thinking', '-o1',
'gpt-oss', 'reasoning', 'exaone-deep', 'phi-4-reasoning']
function caps(name: string, tags: any[] = []) {
const l = (name || '').toLowerCase()
const t = (tags || []).map((x: any) => String(x).toLowerCase())
const toolTag = t.some((x: string) => x.includes('function-calling') || x.includes('tool-use') || x.includes('tools'))
const toolFam = TOOL_FAM.some((f: string) => l.includes(f))
return {
moe: isMoe(l),
active_b: moeActive(l),
vision: ['-vl', 'vision', 'llava', 'pixtral', 'multimodal', '-mm-'].some(k => l.includes(k)) || t.includes('image-text-to-text'),
coder: ['coder', '-code', 'code-', 'codestral', 'starcoder'].some(k => l.includes(k)),
reasoning: REASON_KW.some(k => l.includes(k)) || t.includes('reasoning'),
tools: toolTag ? 'yes' : (toolFam ? 'likely' : 'no'),
}
}
// Facetten-Filter (Mehrfachauswahl) für die Profi-Suche
let facets = $state<Record<string, boolean>>({ tools: false, moe: false, reasoning: false, vision: false, coder: false })
const FACET_DEFS: [string, string][] = [['tools', '🛠 Tools'], ['moe', '🧩 MoE'], ['reasoning', '🧠 Logik'], ['vision', '👁 Bild'], ['coder', '💻 Code']]
function matchesFacets(name: string, tags: any[] = []) {
const active = Object.keys(facets).filter(k => facets[k])
if (!active.length) return true
const c: any = caps(name, tags)
return active.every(f => f === 'tools' ? c.tools !== 'no' : c[f])
}
function capsChipsHtml(name: string, tags: any[] = []) {
const c: any = caps(name, tags)
const out: string[] = []
if (c.coder) out.push('<span class="chip">💻 Code</span>')
if (c.vision) out.push('<span class="chip">👁 Bild</span>')
if (c.reasoning) out.push('<span class="chip">🧠 Logik</span>')
if (c.moe) out.push(`<span class="chip" title="${esc(MOE_HELP)}">🧩 MoE${c.active_b ? ` · ~${c.active_b}B` : ''}</span>`)
if (c.tools === 'yes') out.push('<span class="chip" title="Tool-Calling unterstützt">🛠 Tools</span>')
else if (c.tools === 'likely') out.push('<span class="chip" style="opacity:.55" title="laut Modell-Familie wahrscheinlich tool-fähig">🛠 Tools?</span>')
if (!c.coder && !c.vision) out.unshift('<span class="chip">💬 Chat</span>')
return out.join(' ')
}
function isMoe(s = '') { function isMoe(s = '') {
const l = s.toLowerCase() const l = s.toLowerCase()
return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes('mixtral') || /\bmoe\b/.test(l) return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes('mixtral') || /\bmoe\b/.test(l)
@@ -116,15 +162,14 @@
async function doSearch() { async function doSearch() {
const raw = searchQuery.trim() const raw = searchQuery.trim()
const repo = parseHfRepo(raw) const repo = parseHfRepo(raw)
let q = raw const q = raw
if (activeFilter && !repo) q = q ? q + ' ' + activeFilter : activeFilter
if (!q && !repo) { searchResults = []; return } if (!q && !repo) { searchResults = []; return }
searchLoading = true; cardFits = []; bestResultIdx = -1 searchLoading = true; cardFits = []; bestResultIdx = -1
try { try {
if (repo) { if (repo) {
searchResults = [{ id: repo, author: repo.split('/')[0], downloads: 0 }] searchResults = [{ id: repo, author: repo.split('/')[0], downloads: 0 }]
} else { } else {
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sortBy)}&direction=-1&limit=40` const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&full=true&sort=${encodeURIComponent(sortBy)}&direction=-1&limit=40`
const r = await fetch(url) const r = await fetch(url)
searchResults = await r.json() searchResults = await r.json()
} }
@@ -346,8 +391,7 @@
<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span> <span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>
</div> </div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px"> <div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capChipHtml(ROLE_LABEL[m.role] || capLabel(m.repo))} {@html capsChipsHtml(m.repo, m.tags)}
{@html moeChipHtml(m.repo)}
</div> </div>
<div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">{metricLine(m.fit)} · ⬇ {(m.downloads || 0).toLocaleString()}</div> <div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">{metricLine(m.fit)} · ⬇ {(m.downloads || 0).toLocaleString()}</div>
{#each (m.requirements || []) as req} {#each (m.requirements || []) as req}
@@ -387,19 +431,27 @@
</select> </select>
<button class="primary" disabled={searchLoading} onclick={doSearch}>{searchLoading ? 'Lade…' : 'Suchen'}</button> <button class="primary" disabled={searchLoading} onclick={doSearch}>{searchLoading ? 'Lade…' : 'Suchen'}</button>
</div> </div>
<!-- Filters --> <!-- Facetten-Filter (Mehrfachauswahl, clientseitig auf den Treffern) -->
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px"> <div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px;align-items:center">
{#each ['', 'coder', 'vision', 'reasoning', 'chat', 'agent'] as f} <span class="text-xs text-mut" style="margin-right:2px">Filter:</span>
<button class="{activeFilter === f ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px" {#each FACET_DEFS as [key, label]}
onclick={() => { activeFilter = f; doSearch() }}> <button class="{facets[key] ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px"
{f || 'Alle'} onclick={() => facets[key] = !facets[key]}>
{label}
</button> </button>
{/each} {/each}
{#if Object.values(facets).some(v => v)}
<button class="ghost" style="padding:4px 10px;border-radius:999px;font-size:12px;color:var(--mut)"
onclick={() => facets = { tools: false, moe: false, reasoning: false, vision: false, coder: false }}>
✕ zurücksetzen
</button>
{/if}
</div> </div>
<!-- Results --> <!-- Results -->
{#if searchResults.length} {#if searchResults.length}
<div class="grid grid-3" id="cb-grid"> <div class="grid grid-3" id="cb-grid">
{#each searchResults as m, i} {#each searchResults as m, i}
{#if matchesFacets(m.id, m.tags)}
<div class="card{i === bestResultIdx ? ' res-best' : ''}" style="display:flex;flex-direction:column;cursor:pointer" onclick={() => openModel(m.id, m.id.split('/').pop())}> <div class="card{i === bestResultIdx ? ' res-best' : ''}" style="display:flex;flex-direction:column;cursor:pointer" onclick={() => openModel(m.id, m.id.split('/').pop())}>
{#if i === bestResultIdx}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if} {#if i === bestResultIdx}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
<div class="flex justify-between" style="align-items:flex-start;gap:8px"> <div class="flex justify-between" style="align-items:flex-start;gap:8px">
@@ -410,8 +462,7 @@
<span class="fit-badge {cardFits[i] ? fitCls(cardFits[i]!) : 'warn'}">{cardFits[i] ? fitWord(cardFits[i]!) : 'prüfe…'}</span> <span class="fit-badge {cardFits[i] ? fitCls(cardFits[i]!) : 'warn'}">{cardFits[i] ? fitWord(cardFits[i]!) : 'prüfe…'}</span>
</div> </div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px"> <div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capChipHtml(capLabel(m.id))} {@html capsChipsHtml(m.id, m.tags)}
{@html moeChipHtml(m.id)}
</div> </div>
<div style="flex:1;margin-top:12px"></div> <div style="flex:1;margin-top:12px"></div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px"> <div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
@@ -419,6 +470,7 @@
<span class="mono-sm">{(m.downloads || 0).toLocaleString()}</span> <span class="mono-sm">{(m.downloads || 0).toLocaleString()}</span>
</div> </div>
</div> </div>
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}
+1
View File
@@ -421,6 +421,7 @@ def refresh_discover(ram_gb: float) -> dict:
by_cat[role].append({ by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid, "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "role": role, "params_b": params_b, "quant": quant,
"tags": [str(t) for t in (m.get("tags") or [])], # HF-Tags für Capability-Erkennung (Phase 10)
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0), "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"), "lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
+40 -40
View File
File diff suppressed because one or more lines are too long