Add persistent base-model filter to discovery categories

This commit is contained in:
Mikei386 committed 2026-09-28 19:31:55 +02:00
1 parent 5cc732f993
commit 8e829928c7
6 files changed
+44 -8

No files matched your search

+9
View File
@@ -146,3 +146,12 @@ Docker-Erstinstallation. Weitere Dienste zeigt ausschließlich Container mit
`io.athena-deck.managed=true` **und** `io.athena-deck.role=application`.
Bestehende Container werden nicht automatisch übernommen. Details zu Systemhelfer,
Installer und Grenzen: [Docker-Dienste](docs/DOCKER_SERVICES.md).
Unter Entdecken blendet **Nur Basismodelle** anhand von `base_model`,
`base_model_relation` und Hub-Tags gekennzeichnete Ableitungen aus (Finetunes,
Adapter/LoRAs, Merges und separate Quantisierungen). Es werden bis zu 100 nach
der gewählten Sortierung gelieferte Kandidaten geprüft und maximal 20 angezeigt.
Die Einstellung bleibt im Browser über Kategorien hinweg erhalten. Fehlende
Herausgeber-Metadaten können dazu führen, dass Ableitungen sichtbar bleiben;
es handelt sich nicht um eine verifizierte Liste offizieller Herausgeber.
API-Parameter: `GET /api/v1/catalog/search?...&base_only=true`.
+3 -3
View File
@@ -10,7 +10,7 @@ window.CatalogUI = (() => {
function html(library, downloads=false) {
return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${downloads ? 'Abgeschlossene Einträge ausblenden. Die Dateien bleiben in deiner Bibliothek.' : library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}</p></div><button class="secondary" id="hub-refresh">Aktualisieren</button></div>
<div id="hub-capacity" class="small muted"></div><div id="hub-job"></div><p id="hub-message" role="status" aria-live="polite"></p>
${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" autocomplete="off"></label><button>Suchen</button></form><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option><option value="newest">Neu hinzugefügt</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`;
${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" autocomplete="off"></label><button>Suchen</button></form><label class="hub-base-filter"><input id="hub-base-only" type="checkbox"> Nur Basismodelle</label><p class="small muted">Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.</p><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option><option value="newest">Neu hinzugefügt</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`;
}
function bind(kind, library, downloads=false) {
const root = document.querySelector('#live-catalog'); if (!root) return;
@@ -45,7 +45,7 @@ window.CatalogUI = (() => {
}
async function search(q) {
const sequence = ++searchSequence; message('Modelle werden gesucht …');
try { const result = await api('/search?'+new URLSearchParams({q,kind,sort:el('hub-sort').value})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); enrich(sequence); message(kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : el('hub-sort').value==='newest'?'Neueste Hub-Repositories zuerst (Erstellungsdatum, nicht letztes Update).':'Wähle ein Modell für die Dateiauswahl. Name A–Z sortiert die angezeigten Treffer.'); }
try { const result = await api('/search?'+new URLSearchParams({q,kind,sort:el('hub-sort').value,base_only:String(el('hub-base-only').checked)})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); enrich(sequence); message(result.base_only ? `Basismodellfilter aktiv · ${result.scanned} Hub-Treffer geprüft, höchstens 20 angezeigt. Bei fehlenden Treffern Suche eingrenzen oder Filter ausschalten.` : kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : el('hub-sort').value==='newest'?'Neueste Hub-Repositories zuerst (Erstellungsdatum, nicht letztes Update).':'Wähle ein Modell für die Dateiauswahl. Name A–Z sortiert die angezeigten Treffer.'); }
catch(error) { if (sequence === searchSequence) message(error.message); }
}
async function enrich(sequence){
@@ -90,7 +90,7 @@ window.CatalogUI = (() => {
} catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '<p>Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.</p>'; message(error.message); } }
}
el('hub-refresh').onclick = refresh;
if (!library && !downloads) { el('hub-sort').onchange = () => search(new FormData(el('hub-search')).get('q').trim()); el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
if (!library && !downloads) { try { el('hub-base-only').checked=localStorage.getItem('deck-base-only')==='true'; } catch {} el('hub-base-only').onchange=()=>{try { localStorage.setItem('deck-base-only',String(el('hub-base-only').checked)); } catch {} search(new FormData(el('hub-search')).get('q').trim());}; el('hub-sort').onchange = () => search(new FormData(el('hub-search')).get('q').trim()); el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
refresh();
}
return {html,bind};
+15 -3
View File
@@ -51,6 +51,13 @@ def file_role(filename):
role,label='configuration','Konfiguration'
return dict(role=role,role_label=label,profile_eligible=role=='model')
def is_derived_model(model):
"""Use publisher metadata, never infer lineage from a repository name."""
card=model.get('cardData') or {}
if isinstance(card,dict) and (card.get('base_model') or card.get('base_model_relation')):return True
tags=model.get('tags') or []
return any(isinstance(tag,str) and (tag.startswith('base_model:') or tag.lower() in {'lora','peft','adapter','merge','mergekit','finetune','fine-tuned','quantized','gguf','gptq','awq'}) for tag in tags)
class Catalog:
def __init__(self,root):
self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event()
@@ -86,11 +93,16 @@ class Catalog:
path=self.root/ident/('model'+PurePosixPath(entry['file']).suffix)
if not path.is_file() or path.stat().st_size!=entry['size']:raise ValueError('Modelldatei fehlt oder ist unvollständig.')
return entry
def search(self,q,kind,sort="downloads"):
def search(self,q,kind,sort="downloads",base_only=False):
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.')
if sort not in ('downloads','name','newest'):raise ValueError('Ungültige Sortierung.')
rows=metadata('/api/models?'+urllib.parse.urlencode(dict(search=q,filter=KINDS[kind],limit=20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1)))
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False),created_at=x.get('createdAt')) for x in rows]}
if not isinstance(base_only,bool):raise ValueError('Ungültiger Basismodellfilter.')
query=dict(search=q,filter=KINDS[kind],limit=100 if base_only else 20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1)
if base_only:query['expand']=['downloads','createdAt','gated','cardData','tags']
rows=metadata('/api/models?'+urllib.parse.urlencode(query,doseq=True))
scanned=len(rows)
if base_only:rows=[x for x in rows if not is_derived_model(x)]
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False),created_at=x.get('createdAt')) for x in rows[:20]],'base_only':base_only,'scanned':scanned}
def files(self,repo):
repo=repo_id(repo)
with self.cache_lock:
+4 -1
View File
@@ -301,7 +301,10 @@ class Handler(BaseHTTPRequestHandler):
if self.path.startswith('/api/v1/catalog'):
try:
path=urlsplit(self.path);q=parse_qs(path.query)
if path.path=='/api/v1/catalog/search':return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0],q.get('sort',['downloads'])[0]))
if path.path=='/api/v1/catalog/search':
base=q.get('base_only',['false'])[0]
if base not in ('true','false'):raise ValueError('Ungültiger Basismodellfilter.')
return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0],q.get('sort',['downloads'])[0],base=='true'))
if path.path=='/api/v1/catalog/assessment':
data=self.server.catalog.files(q.get('repo',[''])[0])
return self.respond(overview(data['files'],self.server.hardware.snapshot()))
+2
View File
@@ -16,3 +16,5 @@ aside{overflow-y:auto}nav{gap:3px}nav a{padding:11px 12px}.prototype-banner{disp
nav a.nav-sub{padding-left:28px;font-size:12px;color:#a4bda8}.component-card{margin-top:16px}.component-card select{max-width:none}.component-card .hub-filename{font-size:12px}
#image-test textarea{width:100%;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:8px;padding:14px;font:inherit;resize:vertical;margin:10px 0 20px}.generated-image{max-width:100%;height:auto;display:block;border-radius:8px}#image-test-result figure{margin:20px 0}#image-test-state{margin-top:20px}
.hub-base-filter{display:flex;align-items:center;gap:.6rem;margin-top:1rem}.hub-base-filter input{width:auto;margin:0}
+11 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import time
import unittest
from unittest.mock import patch
from catalog import Catalog, safe_url, repo_id
from catalog import Catalog, safe_url, repo_id, is_derived_model
class CatalogTests(unittest.TestCase):
def test_reject_untrusted_urls_and_repos(self):
@@ -20,6 +20,16 @@ class CatalogTests(unittest.TestCase):
self.assertEqual(query['sort'],['createdAt']);self.assertEqual(query['direction'],['-1'])
self.assertEqual(result['models'][0]['created_at'],'2026-09-28T00:00:00Z')
with self.assertRaises(ValueError):Catalog(d).search('','image','invalid')
def test_base_filter_checks_lineage_and_filters_before_limit(self):
base={'id':'maker/base','cardData':{},'tags':['text-to-video']}
for metadata in ({'cardData':{'base_model':'maker/base'}},{'cardData':{'base_model':['maker/base']}},{'tags':['base_model:quantized:maker/base']},{'tags':['lora']},{'tags':['gguf']}):
self.assertTrue(is_derived_model(dict(base,**metadata)))
self.assertFalse(is_derived_model(base));self.assertFalse(is_derived_model(dict(base,id='maker/adapter-in-name',cardData=None)))
with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',return_value=[dict(base,id='derived/'+str(i),tags=['lora']) for i in range(20)]+[base]) as fetch:
value=Catalog(d).search('','video','newest',True)
self.assertEqual([x['repo'] for x in value['models']],['maker/base']);self.assertEqual(value['scanned'],21)
self.assertIn('limit=100',fetch.call_args.args[0]);self.assertIn('expand=cardData',fetch.call_args.args[0])
with self.assertRaises(ValueError):Catalog(d).search('','video','downloads','true')
def test_download_integrity_and_library(self):
for valid in (True,False):
with self.subTest(valid=valid),tempfile.TemporaryDirectory() as directory: