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
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 = '') {
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)
@@ -116,15 +162,14 @@
async function doSearch() {
const raw = searchQuery.trim()
const repo = parseHfRepo(raw)
let q = raw
if (activeFilter && !repo) q = q ? q + ' ' + activeFilter : activeFilter
const q = raw
if (!q && !repo) { searchResults = []; return }
searchLoading = true; cardFits = []; bestResultIdx = -1
try {
if (repo) {
searchResults = [{ id: repo, author: repo.split('/')[0], downloads: 0 }]
} 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)
searchResults = await r.json()
}
@@ -346,8 +391,7 @@
<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capChipHtml(ROLE_LABEL[m.role] || capLabel(m.repo))}
{@html moeChipHtml(m.repo)}
{@html capsChipsHtml(m.repo, m.tags)}
</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}
@@ -387,19 +431,27 @@
</select>
<button class="primary" disabled={searchLoading} onclick={doSearch}>{searchLoading ? 'Lade…' : 'Suchen'}</button>
</div>
<!-- Filters -->
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px">
{#each ['', 'coder', 'vision', 'reasoning', 'chat', 'agent'] as f}
<button class="{activeFilter === f ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px"
onclick={() => { activeFilter = f; doSearch() }}>
{f || 'Alle'}
<!-- Facetten-Filter (Mehrfachauswahl, clientseitig auf den Treffern) -->
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px;align-items:center">
<span class="text-xs text-mut" style="margin-right:2px">Filter:</span>
{#each FACET_DEFS as [key, label]}
<button class="{facets[key] ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px"
onclick={() => facets[key] = !facets[key]}>
{label}
</button>
{/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>
<!-- Results -->
{#if searchResults.length}
<div class="grid grid-3" id="cb-grid">
{#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())}>
{#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">
@@ -410,8 +462,7 @@
<span class="fit-badge {cardFits[i] ? fitCls(cardFits[i]!) : 'warn'}">{cardFits[i] ? fitWord(cardFits[i]!) : 'prüfe…'}</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capChipHtml(capLabel(m.id))}
{@html moeChipHtml(m.id)}
{@html capsChipsHtml(m.id, m.tags)}
</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">
@@ -419,6 +470,7 @@
<span class="mono-sm">{(m.downloads || 0).toLocaleString()}</span>
</div>
</div>
{/if}
{/each}
</div>
{/if}