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:
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Vendored
+40
-40
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user