diff --git a/STUDIO.md b/STUDIO.md index 17deea5..ee55e0e 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -50,3 +50,34 @@ API (Administrator-Sitzung erforderlich, bestehender CSRF-Schutz): Die eigenständig implementierte Galerie orientiert sich an [LocalAIs Explore-/Detailansicht](https://github.com/mudler/LocalAI/blob/master/core/http/react-ui/src/pages/Models.jsx). Kein LocalAI-Frontendcode wurde übernommen. llama.cpp-Buildverwaltung und Einpassung der alten Referenzprofile sind unter Einstellungen verfügbar; Details in DEVELOPMENT.md. Validiert: 54 Python-Tests; JavaScript-Syntaxprüfung; isolierter Browsertest mit echten Katalogdaten und einer temporären Testdatei für Profilanlage, Komponentenhinweise und Download-Ausblenden. Keine produktiven Modellstarts. + +## Laufzeiten und Zusatzkomponenten + +Einstellungen → Laufzeiten bündelt `#runtime` (llama.cpp, bestehende aktive +Buildverwaltung) und `#image-runtime` (Bildgenerierung). Die Bildseite beschreibt +ComfyUI + ComfyUI-GGUF als vorgesehene Umgebung und zeigt ausdrücklich, dass noch +kein Deck-Bildworker installiert ist. Dieser Schritt installiert keine Laufzeit. + +Bildgenerierung → Profile → Komponenten bietet für +`abenzerps/Qwen-Image-2.1-Uncensored-GGUF` ein explizites Rezept gemäß Modellkarte: +Textencoder INT8 ConvRot oder BF16, dazu Qwen-Image-2.1-VAE BF16. Größen und feste +Quellrevision werden beim Öffnen aus dem öffentlichen Repository gelesen. +Downloads laufen über die vorhandene geprüfte Katalog-API und erscheinen unter +Downloads. Danach „Bibliothek aktualisieren“, je eine Datei auswählen und +„Zuordnung auf Athena speichern“. Mehrere Profile können dieselben Dateien nutzen. + +Die Zuordnung ist kein Nachweis eines funktionierenden Workers. Andere Modelle +bekommen ohne hinterlegtes Rezept keine behauptete automatische Kompatibilität. +Textencoder/VAE werden nicht als Hauptmodell des Qwen-Profils zugelassen. +Zuordnungen bleiben bei Parameteränderungen erhalten; bei Wechsel des Hauptmodells +werden sie zurückgesetzt. Fehlende Dateien bleiben als Blocker sichtbar. + +API (nur Administrator-Sitzung): +- `GET /api/v1/profiles/components?model_id=` liefert Rezept, + verfügbare Dateien und Quellkandidaten. +- `POST /api/v1/profiles/components` mit `id`, `revision`, `components` + (`text_encoder` und/oder `vae`: Bibliotheks-ID, leer zum Entfernen) speichert + atomar mit Konfliktprüfung. Rollen und tatsächliche Dateien werden geprüft. + +Validiert: 55 Tests und Browserprüfung der neuen Navigation und realen +Komponentenmetadaten. Keine Zusatzgewichte heruntergeladen, keine Modelle gestartet. diff --git a/app.js b/app.js index 4598c60..72786b8 100644 --- a/app.js +++ b/app.js @@ -8,7 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; -if(['chat','image','audio','video','runtime'].includes(page)){Studio.render(page);return;} +if(['chat','image','audio','video','runtime','runtimes','image-runtime'].includes(page)){Studio.render(page);return;} if(page==='access'){await accessPage();return;} if(page==='network'){await networkPage();return;} if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`
Anwendung
Bereit

Athena Deck läuft auf ${esc(s.location)}.

Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert

Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.

Keine Modell- oder Router-Steuerung angebunden

Demo-Dienst

LOKAL / CPU

Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
Lädt keine Modelle · endet beim Beenden von Athena Deck
`; diff --git a/index.html b/index.html index b1fac5c..a6055e2 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
+Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
diff --git a/profiles-ui.js b/profiles-ui.js index 872653b..272ddbb 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -4,14 +4,28 @@ window.ProfilesUI=(()=>{ const html=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} function bind(kind,modelId){ - const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={}; + 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));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('')}
`).join('')||`

Noch keine Profile

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

`; + 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; + 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))); root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});}); }catch(error){message(error.message);}} + async function components(profile){ + const sequence=++panelSequence; + el('profile-editor').innerHTML='

Komponenten werden geprüft …

'; + try{ + const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return; + el('profile-editor').innerHTML=`

Komponenten · ${e(profile.name)}

${e(data.message)}

${data.supported?`

Quelle: Modellkarte und Lizenz ↗ · ${e(data.license||'Lizenz nicht angegeben')}

Bildlaufzeit: ${e(data.runtime)} · noch nicht eingerichtet →

${data.requirements.map(r=>`

${e(r.label)}

${r.candidates.map((f,i)=>`

${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB

`).join('')||'

Keine passende Quelldatei verfügbar.

'}
`).join('')}

Lade je eine passende Datei für Textencoder und VAE. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.

`:''}

`; + el('components-close').onclick=()=>el('profile-editor').replaceChildren(); + el('components-refresh')?.addEventListener('click',()=>components(profile)); + el('components-form')?.addEventListener('submit',async event=>{event.preventDefault();const button=el('components-save');button.disabled=true;try{await api('profiles/components',{id:profile.id,revision:profile.revision,components:Object.fromEntries(new FormData(event.target))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Komponenten dem Profil zugeordnet. Kein Modell gestartet.');}catch(error){if(el('components-message'))el('components-message').textContent=error.message;}finally{button.disabled=false;}}); + root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'image'});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}}); + }catch(error){message(error.message);el('profile-editor').innerHTML='

Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.

';} + } function editor(p=null,id=null){ + panelSequence++; if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))}; el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; el('profile-close').onclick=()=>el('profile-editor').replaceChildren(); diff --git a/profiles.py b/profiles.py index d268362..922e626 100644 --- a/profiles.py +++ b/profiles.py @@ -12,6 +12,12 @@ SCHEMAS={ 'audio':{'speed':(.25,4,1)}, 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} } +# Explicit model-card recipe. This is a source recommendation, not a runtime test. +QWEN_REPO='abenzerps/Qwen-Image-2.1-Uncensored-GGUF' +QWEN_COMPONENTS={ + 'text_encoder':{'label':'Textencoder','files':['text_encoders/qwen3vl_8b_int8_convrot.safetensors','text_encoders/qwen3vl_8b_bf16.safetensors']}, + 'vae':{'label':'VAE','files':['vae/qwen_image_2.1_vae_bf16.safetensors']} +} class Profiles: def __init__(self,path,catalog): self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() @@ -24,7 +30,10 @@ class Profiles: p['model']=self.catalog.entry(p['model_id']) p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] if p['kind']=='image' and 'qwen-image-2.1' in p['model']['repo'].lower(): - p['blockers']=['Qwen-Image-2.1 benötigt zusätzlich einen kompatiblen Textencoder und VAE.','Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.'] + p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.'] + for role,info in reversed(list(QWEN_COMPONENTS.items())): + try:self._component(p['model'],role,p.get('components',{}).get(role)) + except ValueError:p['blockers'].insert(0,info['label']+' fehlt oder ist noch nicht zugeordnet.') except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] p['state']='configured';p['runnable']=False return {'profiles':rows,'schemas':SCHEMAS} @@ -33,6 +42,7 @@ class Profiles: kind=data['kind'];name=data['name'];params=data['parameters'] 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 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(): @@ -46,8 +56,41 @@ class Profiles: if data['id'] is not None and not existing:raise ValueError('Profil nicht gefunden.') if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.') if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.') - row=dict(id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time()) + components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {} + row=dict(components=components,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time()) rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row] self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return row + + def _component(self,model,role,ident): + if model['repo']!=QWEN_REPO or role not in QWEN_COMPONENTS:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.') + item=self.catalog.entry(ident) + if item['repo']!=QWEN_REPO or item['kind']!='image' or item['file'] not in QWEN_COMPONENTS[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.') + return item + def components(self,model_id): + model=self.catalog.entry(model_id) + if model['repo']!=QWEN_REPO or model['kind']!='image':return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.') + source=self.catalog.files(QWEN_REPO);entries=self.catalog.status()['entries'];requirements=[] + for role,info in QWEN_COMPONENTS.items(): + available=[] + for item in entries: + try: + self._component(model,role,item['id']);available.append(item) + except ValueError:pass + candidates=[dict(f,repo=QWEN_REPO,revision=source['revision'],gated=source['gated']) for name in info['files'] for f in source['files'] if f['name']==name] + requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates)) + return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF', + message='Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.') + def assign(self,data): + if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict) or set(data['components'])-set(QWEN_COMPONENTS):raise ValueError('Ungültige Komponentenzuordnung.') + with self.lock: + old=next((p for p in self.rows if p['id']==data['id']),None) + if not old or old['kind']!='image' or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.') + model=self.catalog.entry(old['model_id']) + components={k:v for k,v in data['components'].items() if v} + for role,ident in components.items():self._component(model,role,ident) + updated=dict(old,components=components,revision=old['revision']+1,updated_at=time.time()) + rows=[updated if p is old else p for p in self.rows] + temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows + return updated diff --git a/server.py b/server.py index fba5a5e..03d673f 100644 --- a/server.py +++ b/server.py @@ -271,6 +271,10 @@ class Handler(BaseHTTPRequestHandler): return self.respond((ROOT/name).read_bytes(), mime=mime) if self.path == '/api/v1/status': return self.respond(dict(name='Athena Deck', version='0.6.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status())) + if urlsplit(self.path).path == '/api/v1/profiles/components': + try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0])) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except Exception:return self.respond({'error':'Komponentenquelle nicht erreichbar. Bitte erneut versuchen.'},503) if self.path == '/api/v1/profiles': return self.respond(self.server.profiles.status()) if self.path == '/api/v1/hardware': @@ -340,6 +344,10 @@ class Handler(BaseHTTPRequestHandler): raise ValueError('Ungültige Laufzeitaktion.') except ValueError as exc:return self.respond({'error':str(exc)},400) except (OSError, subprocess.SubprocessError):return self.respond({'error':'Laufzeitaktion fehlgeschlagen; Speicher und Werkzeuge prüfen.'},503) + if self.path == '/api/v1/profiles/components': + try:return self.respond(self.server.profiles.assign(self.read_json())) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except OSError:return self.respond({'error':'Zuordnung konnte nicht gespeichert werden.'},503) if self.path == '/api/v1/profiles/save': try:return self.respond(self.server.profiles.save(self.read_json())) except ValueError as exc:return self.respond({'error':str(exc)},400) diff --git a/studio.js b/studio.js index 3d868ae..5f12835 100644 --- a/studio.js +++ b/studio.js @@ -6,13 +6,16 @@ const Studio=(()=>{ if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(category!==page){category=page;section='discover';} if(modelId)section='profiles'; - const body=page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>``).join('')}
${section==='profiles'?ProfilesUI.html():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Bild-, Audio- und Video-Worker sind noch nicht eingerichtet. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; + const body=page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>``).join('')}
${section==='profiles'?ProfilesUI.html():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Bild-, Audio- und Video-Worker sind noch nicht eingerichtet. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; document.querySelector('#view').innerHTML=`
${body}
`; if(page==='runtime'){RuntimeUI.bind();return;} + if(page==='runtimes'||page==='image-runtime')return; if(section==='profiles')ProfilesUI.bind(page,modelId); else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads'); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); } + function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →
`;} + function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

NOCH NICHT EINGERICHTET

ComfyUI + ComfyUI-GGUF

Vorgesehene Laufzeit für dein Qwen-Image-2.1-GGUF-Modell. Diese Kombination entspricht der Modellkarte; ein eigener Deck-Worker ist noch nicht installiert.

1. Laufzeit einrichten

ComfyUI, PyTorch/CUDA und die passende GGUF-Erweiterung in einer eigenen Umgebung auf Athena. Installation und Updates werden in einem nächsten Schritt angebunden.

2. Modell vervollständigen

Unter Bildgenerierung → Profile → Komponenten lädst du Textencoder und VAE nach und ordnest sie deinem Profil zu.

Zu Bildmodellen und Profilen →

Hier wurde keine Laufzeit installiert. Der vorhandene produktive Bilddienst wird nicht verwendet oder verändert. llama.cpp bleibt die Laufzeit für Sprachmodelle.

`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; })(); diff --git a/style.css b/style.css index c9bb790..2599856 100644 --- a/style.css +++ b/style.css @@ -12,3 +12,5 @@ aside{overflow-y:auto}nav{gap:3px}nav a{padding:11px 12px}.prototype-banner{disp .hub-search{display:flex;align-items:end;gap:12px;margin:24px 0}.hub-search label{flex:1;margin:0}.hub-search input{max-width:none;margin-bottom:0}.hub-search button{margin:0} .hub-layout{display:grid;grid-template-columns:minmax(240px,.8fr) minmax(0,1.4fr);gap:24px;align-items:start}.hub-list-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.hub-list-heading select{width:auto;max-width:160px;font-size:12px;margin:0;padding:8px}.hub-results{display:grid;gap:8px;max-height:850px;overflow-y:auto;padding:3px}.hub-model{display:grid;gap:9px;width:100%;margin:0;text-align:left;padding:18px;background:#181f21;color:#e4eee5;border:1px solid #303a37}.hub-model:hover{background:#222d27}.hub-model.selected{border-color:#bddda9;background:#26342b}.hub-model strong{font-size:14px;overflow-wrap:anywhere}.hub-author,.hub-model-meta{font-size:11px;color:#96a89c}.hub-detail{min-width:0}.hub-detail h2{font-size:22px}.hub-badges{display:flex;gap:8px;flex-wrap:wrap}.hub-fit{padding:16px;background:#202a25;border-radius:9px;font-size:13px;margin:22px 0}.hub-fit p{font-size:12px;margin-bottom:0}.hub-file-controls{display:grid;grid-template-columns:1.3fr 1fr;gap:12px}.hub-file-controls label{margin-top:0}.hub-file-controls input,.hub-file-controls select{font-size:12px;margin-bottom:0}.hub-variants{max-height:380px;overflow:auto;border-top:1px solid #303a37}.hub-variant{display:flex;align-items:center;gap:12px;padding:16px 8px;margin:0;border-bottom:1px solid #303a37;cursor:pointer}.hub-variant:has(input:checked){background:#28362b}.hub-variant input{width:16px;min-width:16px;margin:0;accent-color:#c5edaf}.hub-variant span{flex:1;min-width:0}.hub-variant strong{display:block;font-weight:500;font-size:12px;overflow-wrap:anywhere}.hub-variant small{display:block;margin-top:7px;font-size:10px;color:#95a69b}.hub-variant b{font-size:11px;white-space:nowrap}.hub-review{margin-top:22px}.hub-review button{width:100%;font-size:13px}.hub-filename{overflow-wrap:anywhere}.hub-operation{margin-top:20px;border-color:#647e58}.hub-operation h3{font-size:16px}.hub-operation p{margin:10px 0;font-size:12px}.hub-operation progress{height:9px}.hub-operation .card-top strong{font-size:20px} @media(max-width:1100px){.hub-layout{grid-template-columns:1fr}.hub-results{max-height:360px}.hub-file-controls{grid-template-columns:1fr 1fr}}@media(max-width:600px){.hub-search{align-items:stretch;flex-direction:column}.hub-file-controls{grid-template-columns:1fr}.hub-variant{gap:8px}} + +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} diff --git a/test_model_management.py b/test_model_management.py index 406adfb..3214524 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -52,3 +52,24 @@ class ManagementTests(unittest.TestCase): files=[dict(name='vae/vae.safetensors',size=1),dict(name='model-lora.safetensors',size=2),dict(name='model-00001-of-00002.gguf',size=3*GIB),dict(name='model-00002-of-00002.gguf',size=2*GIB)] result=overview(files,{'gpus':[]});self.assertEqual(result['min_bytes'],5*GIB) self.assertEqual(overview(files[:-1],{'gpus':[]})['scope'],'unknown') + def test_component_recipe_assignment_and_preservation(self): + from profiles import QWEN_REPO,QWEN_COMPONENTS + from unittest.mock import patch + main=self.root/'models'/self.model_id/'entry.json';entry=json.loads(main.read_text());entry['repo']=QWEN_REPO;main.write_text(json.dumps(entry)) + ids={};files=[] + for n,(role,recipe) in enumerate(QWEN_COMPONENTS.items(),1): + ident=str(n)*64;ids[role]=ident;target=self.root/'models'/ident;target.mkdir();(target/'model.safetensors').write_bytes(b'component') + f=recipe['files'][0];(target/'entry.json').write_text(json.dumps(dict(repo=QWEN_REPO,file=f,kind='image',size=9,revision='c'*40))) + files.append(dict(name=f,size=9,sha256=None)) + saved=self.profiles.save(self.request()) + with patch.object(self.catalog,'files',return_value=dict(files=files,revision='c'*40,gated=False,url='https://huggingface.co/'+QWEN_REPO,license='test')): + result=self.profiles.components(self.model_id) + self.assertTrue(result['supported']);self.assertEqual(len(result['requirements'][0]['available']),1) + assigned=self.profiles.assign(dict(id=saved['id'],revision=1,components=ids));self.assertEqual(assigned['revision'],2) + restarted=Profiles(self.root/'profiles.json',self.catalog);row=restarted.status()['profiles'][0] + self.assertEqual(row['components'],ids);self.assertEqual(len(row['blockers']),1);self.assertFalse(row['runnable']) + request=self.request();request.update(id=saved['id'],revision=2);self.assertEqual(restarted.save(request)['components'],ids) + with self.assertRaises(ValueError):restarted.assign(dict(id=saved['id'],revision=3,components={'vae':ids['text_encoder']})) + 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)