Classify library components consistently and hide standalone profile action
This commit is contained in:
@@ -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.
|
||||
|
||||
+1
-1
@@ -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 => `<article class="card"><span class="pill">DATEI HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>Laufzeit noch nicht angebunden</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${/\.(gguf|safetensors)$/i.test(x.file)?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
|
||||
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Laufzeit noch nicht angebunden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${x.profile_eligible===true?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
|
||||
}
|
||||
function renderJob() {
|
||||
el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`;
|
||||
|
||||
+16
-1
@@ -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):
|
||||
|
||||
+1
-1
@@ -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=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}</p><details><summary>Was fehlt zur Ausführung?</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
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)));
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user