From 413906f6191acbe524076a22a725d5c4c5bae8a2 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:41:06 +0200 Subject: [PATCH] Classify library components consistently and hide standalone profile action --- STUDIO.md | 8 ++++++++ catalog-ui.js | 2 +- catalog.py | 17 ++++++++++++++++- profiles-ui.js | 2 +- profiles.py | 2 +- test_model_management.py | 10 ++++++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/STUDIO.md b/STUDIO.md index ee55e0e..75d06c2 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -81,3 +81,11 @@ API (nur Administrator-Sitzung): Validiert: 55 Tests und Browserprüfung der neuen Navigation und realen Komponentenmetadaten. Keine Zusatzgewichte heruntergeladen, keine Modelle gestartet. + +### Bibliotheksrollen + +Bibliothek, Profilauswahl und Profil-API verwenden dieselbe serverseitige +Dateiklassifikation (`role`, `role_label`, `profile_eligible`). Erkannte +Textencoder, VAE, Projektoren und Adapter bleiben als Zusatzdateien sichtbar, +bieten aber kein „Profil anlegen“ und können nicht als Hauptmodell gespeichert +werden. Bestehende Komponenten-Zuordnungen und Dateien bleiben unverändert. diff --git a/catalog-ui.js b/catalog-ui.js index 0fff3f5..6b7ee02 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -26,7 +26,7 @@ window.CatalogUI = (() => { root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); } function renderLibrary() { - el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
DATEI HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

Laufzeit noch nicht angebunden

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${/\.(gguf|safetensors)$/i.test(x.file)?`
`:""}
`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; + el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
${escape(x.role_label)} · HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

${x.profile_eligible?'Laufzeit noch nicht angebunden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${x.profile_eligible===true?`
`:""}
`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; } function renderJob() { el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`; diff --git a/catalog.py b/catalog.py index f8f2718..9c25858 100644 --- a/catalog.py +++ b/catalog.py @@ -36,6 +36,21 @@ def repo_id(value): if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z0-9_-][A-Za-z0-9_.-]{0,95}/[A-Za-z0-9_-][A-Za-z0-9_.-]{0,95}',value):raise ValueError('Ungültige Repository-ID.') return value +def file_role(filename): + """One classification shared by library UI and profile validation.""" + name=filename.lower();parts=PurePosixPath(name).parts + if any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name): + role,label='text_encoder','Textencoder' + elif 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name): + role,label='vae','VAE' + elif re.search(r'(?:^|[/_-])(?:mmproj|lora|adapter)(?:[/_.-]|$)',name): + role,label='auxiliary','Zusatzkomponente' + elif name.endswith(('.gguf','.safetensors')): + role,label='model','Modelldatei' + else: + role,label='configuration','Konfiguration' + return dict(role=role,role_label=label,profile_eligible=role=='model') + class Catalog: def __init__(self,root): self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event() @@ -101,7 +116,7 @@ class Catalog: if self.root.exists(): for p in self.root.glob('*/entry.json'): try: - item=json.loads(p.read_text());item['id']=p.parent.name;entries.append(item) + item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file']));entries.append(item) except (OSError,ValueError):pass return dict(entries=entries,downloads=[dict(x) for x in reversed(self.history) if not x.get("dismissed")],job=dict(self.job) if self.job else None,free_bytes=shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free) def start(self,repo,filename,revision,kind): diff --git a/profiles-ui.js b/profiles-ui.js index 272ddbb..f78ef0c 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -6,7 +6,7 @@ window.ProfilesUI=(()=>{ function bind(kind,modelId){ const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},panelSequence=0; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; - async function load(){try{const [p,c]=await Promise.all([api('profiles'),api('catalog')]);if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&/\.(gguf|safetensors)$/i.test(x.file)&&! /^(text_encoders|vae)\//i.test(x.file));schema=p.schemas[kind];el('profile-new').disabled=!models.length; + async function load(){try{const [p,c]=await Promise.all([api('profiles'),api('catalog')]);if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}

Was fehlt zur Ausführung?${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit))); diff --git a/profiles.py b/profiles.py index 922e626..a03dec9 100644 --- a/profiles.py +++ b/profiles.py @@ -43,7 +43,7 @@ class Profiles: if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}',name):raise ValueError('API-Name: 2–64 Buchstaben, Ziffern, Bindestrich oder Unterstrich.') model=self.catalog.entry(data['model_id']) if model['repo']==QWEN_REPO and any(model['file'] in r['files'] for r in QWEN_COMPONENTS.values()):raise ValueError('Textencoder und VAE werden über Komponenten zugeordnet, nicht als Hauptmodell.') - if model['kind']!=kind or not model['file'].lower().endswith(('.gguf','.safetensors')):raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.') + if model['kind']!=kind or not model['profile_eligible']:raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.') if not isinstance(params,dict) or set(params)!=set(SCHEMAS[kind]):raise ValueError('Unvollständige oder unbekannte Profilparameter.') for key,(lo,hi,_) in SCHEMAS[kind].items(): value=params[key] diff --git a/test_model_management.py b/test_model_management.py index 3214524..5ad74fc 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -73,3 +73,13 @@ class ManagementTests(unittest.TestCase): with self.assertRaises(ValueError):restarted.assign(dict(id=saved['id'],revision=2,components=ids)) request=self.request();request['model_id']=ids['vae'] with self.assertRaises(ValueError):restarted.save(request) + def test_auxiliary_files_cannot_be_profile_models(self): + from catalog import file_role + for name,role in [('text_encoders/qwen3vl_8b_int8_convrot.safetensors','text_encoder'),('vae/qwen_image_2.1_vae_bf16.safetensors','vae'),('qwen_image_vae.safetensors','vae'),('mmproj-model.gguf','auxiliary')]: + with self.subTest(name=name): + self.assertEqual(file_role(name)['role'],role) + path=self.root/'models'/self.model_id/'entry.json';data=json.loads(path.read_text());data['file']=name;path.write_text(json.dumps(data)) + suffix=Path(name).suffix;(path.parent/('model'+suffix)).write_bytes(b'GGUFtest') + self.assertFalse(self.catalog.status()['entries'][0]['profile_eligible']) + with self.assertRaises(ValueError):self.profiles.save(self.request()) + self.assertTrue(file_role('qwen-image-2.1-UC-Q8_0.gguf')['profile_eligible'])