Group runtime settings and add profile component downloads and assignments
This commit is contained in:
1 parent
458047a1d3
commit
057b9df644
9 files changed
+130
-8
No files matched your search
@@ -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=<Bibliotheks-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.
|
||||
@@ -8,7 +8,7 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
|
||||
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
|
||||
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.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
+17
-3
@@ -4,14 +4,28 @@ window.ProfilesUI=(()=>{
|
||||
const html=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
|
||||
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=>`<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></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>`;
|
||||
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=>`<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)));
|
||||
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='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
|
||||
try{
|
||||
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return;
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div><p>${e(data.message)}</p>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p><p><a class="link" href="#image-runtime">Bildlaufzeit: ${e(data.runtime)} · noch nicht eingerichtet →</a></p><form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${profile.components?.[r.role]===f.id?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB</p><button type="button" class="secondary" data-component-download="${r.role}:${i}" ${f.gated||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">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.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
|
||||
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='<section class="card"><p>Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.</p></section>';}
|
||||
}
|
||||
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=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" placeholder="z. B. qwen-image-test" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed'].includes(k)?'.05':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div><p class="note">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.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
el('profile-close').onclick=()=>el('profile-editor').replaceChildren();
|
||||
|
||||
+45
-2
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Bild-, Audio- und Video-Worker sind noch nicht eingerichtet. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||
const body=page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Bild-, Audio- und Video-Worker sind noch nicht eingerichtet. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||
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 `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section></div>`;}
|
||||
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><span class="pill">NOCH NICHT EINGERICHTET</span><h2>ComfyUI + ComfyUI-GGUF</h2><p>Vorgesehene Laufzeit für dein Qwen-Image-2.1-GGUF-Modell. Diese Kombination entspricht der Modellkarte; ein eigener Deck-Worker ist noch nicht installiert.</p><div class="grid"><div><h3>1. Laufzeit einrichten</h3><p>ComfyUI, PyTorch/CUDA und die passende GGUF-Erweiterung in einer eigenen Umgebung auf Athena. Installation und Updates werden in einem nächsten Schritt angebunden.</p></div><div><h3>2. Modell vervollständigen</h3><p>Unter Bildgenerierung → Profile → Komponenten lädst du Textencoder und VAE nach und ordnest sie deinem Profil zu.</p><a class="link" href="#image">Zu Bildmodellen und Profilen →</a></div></div><p class="note">Hier wurde keine Laufzeit installiert. Der vorhandene produktive Bilddienst wird nicht verwendet oder verändert. llama.cpp bleibt die Laufzeit für Sprachmodelle.</p></section>`;}
|
||||
function openProfile(page,modelId){render(page,true,modelId);}
|
||||
return {render,openProfile};
|
||||
})();
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user