diff --git a/README.md b/README.md
index d24d8ff..c9b4e5c 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/catalog-ui.js b/catalog-ui.js
index 6273b5b..7e4eeef 100644
--- a/catalog-ui.js
+++ b/catalog-ui.js
@@ -10,7 +10,7 @@ window.CatalogUI = (() => {
function html(library, downloads=false) {
return ` ${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.'} Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen. Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}
Welches Modell interessiert dich?
Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.
Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.
Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.
Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.
'; 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}; diff --git a/catalog.py b/catalog.py index 980c4c9..78bdcec 100644 --- a/catalog.py +++ b/catalog.py @@ -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: diff --git a/server.py b/server.py index aaf8109..574b1b4 100644 --- a/server.py +++ b/server.py @@ -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())) diff --git a/style.css b/style.css index ac74a80..f86e607 100644 --- a/style.css +++ b/style.css @@ -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} diff --git a/test_catalog.py b/test_catalog.py index cfe4311..bf5a199 100644 --- a/test_catalog.py +++ b/test_catalog.py @@ -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: