Add persistent base-model filter to discovery categories
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user