diff --git a/README.md b/README.md index a16d80f..32f014b 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,28 @@ CPU-/GPU-Temperaturen, VRAM und Auslastung vorhanden. Hardware-Ausfall im Test simuliert, ohne Athena oder SSH zu unterbrechen. Produktive Router-/Modell-/Bild-/Audio-/Netzwerkdienste nicht verändert. Vorhandene ungesicherte Änderungen im übergeordneten Repository unangetastet. + +### Bildlaufzeit installieren und Profile entfernen + +Unter **Einstellungen → Laufzeiten → Bildgenerierung** zeigt Deck die Herkunft +und die festgelegten ComfyUI-/GGUF-Versionen. Fehlt die Umgebung, installiert +**Bildlaufzeit installieren** sie ohne Rootrechte in `state/image-runtime`. +Voraussetzungen: Linux, Python mit venv, Git, Internet und 25 GiB freier Speicher; +ein kompatibler NVIDIA-Treiber muss vom Systeminstaller bereitgestellt sein. +Die Installation ist abbrechbar, startet kein Modell und aktiviert die neue +Umgebung erst nach Paketprüfung. Bereits mitgelieferte Umgebungen werden erkannt; +ein Versionsupdate der Bildlaufzeit ist hier noch nicht vorgesehen. + +Profile lassen sich in allen Kategorien löschen. Modell- und Komponentendateien +bleiben erhalten. Die API verhindert das Löschen des gerade ausgeführten +Bildprofils und prüft die Profilrevision gegen zwischenzeitliche Änderungen. + +Neue sitzungsgeschützte API-Routen: `GET /api/v1/image-runtime`, +`POST /api/v1/image-runtime/install` und `/cancel` mit `{}` sowie +`POST /api/v1/profiles/delete` mit `{ "id": "…", "revision": 1 }`. +POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`. + +Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken. +Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder +arbeitet auf CPU/System-RAM; GPU-Last kann daher während eines Auftrags null +sein. Nach Abschluss beendet Deck den eigenen Worker und gibt dessen VRAM frei. diff --git a/app.js b/app.js index 72786b8..e9f9d10 100644 --- a/app.js +++ b/app.js @@ -12,7 +12,7 @@ if(['chat','image','audio','video','runtime','runtimes','image-runtime'].include 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
`; -}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`
Hardware nicht verfügbar

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}
${bar(g.percent)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} +}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden. GPU-Rechenlast und belegter VRAM werden getrennt dargestellt.');if(!h.available)html+=`
Hardware nicht verfügbar

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}
GPU-Rechenlast${bar(g.percent)}VRAM-Belegung${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

Die Werte gelten für den gesamten Server einschließlich anderer Dienste. Beim Bildtest läuft der Textencoder auf der CPU; 0 % GPU-Rechenlast bedeutet nicht, dass kein Modell geladen ist. Nach dem Test wird der eigene Worker entladen.
${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`
+

Platz für den nächsten Schritt.

${p[2]}

Platzhalter · noch keine Funktionen

Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.

`;} if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));} }catch(e){error.textContent=e.message;view.innerHTML=heading('VERBINDUNG','Status nicht verfügbar','Die API ist gerade nicht erreichbar. Angezeigte Messwerte wurden verworfen.');}finally{refreshing=false;}} diff --git a/deploy/Dockerfile b/deploy/Dockerfile index e77d92a..4177254 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -11,7 +11,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \ && /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt \ && /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock WORKDIR /app -COPY image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ +COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock +COPY image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ COPY image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/install.py b/deploy/install.py index 04134cd..6166aa5 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] +FILES = ['image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] def run(*args, check=True, interactive=False): @@ -186,7 +186,7 @@ def update(base, rollback=False): backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') stamp=str(time.time_ns()) - shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification')) + shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-runtime')) was_running=previous['State']['Running'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) diff --git a/image-test-ui.js b/image-test-ui.js index f086284..b9510b2 100644 --- a/image-test-ui.js +++ b/image-test-ui.js @@ -1,7 +1,7 @@ window.ImageTestUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); 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 html(readonly=false){return `

${readonly?'Bildlaufzeit':'Bildmodell testen'}

Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.

${readonly?'':`

Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Nur eine freie GPU wird genutzt; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.

`}

`;} + function html(readonly=false){return `

${readonly?'Bildlaufzeit':'Bildmodell testen'}

Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.

${readonly?'':`

Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Nur eine freie GPU wird genutzt; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.

`}

Textencoder: CPU / System-RAM · Diffusionsmodell und VAE: GPU / VRAM. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →

`;} function bind(readonly=false){ const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false; const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;}; @@ -15,5 +15,10 @@ window.ImageTestUI=(()=>{ } refresh(); } - return {html,bind}; + function bindInstaller(){ + const root=document.querySelector('#image-runtime-install');let pending=false; + async function refresh(){try{const s=await api('image-runtime');if(!root.isConnected)return;const running=s.job?.state==='running';root.innerHTML=`

${s.installed?'Installiert · '+e(s.origin):'Noch nicht installiert'}

ComfyUI ${e(s.comfy_revision.slice(0,12))} · GGUF ${e(s.gguf_revision.slice(0,12))}

${e(s.job?.phase||'Installation richtet eine eigene Python-Umgebung mit ComfyUI, GGUF-Unterstützung und CUDA-PyTorch ein. Mindestens 25 GiB freier Speicher und Internetzugang erforderlich.')}

Systemvoraussetzungen: Python mit venv, Git und kompatibler NVIDIA-Treiber. Die Installation ändert keine Host-Pakete oder anderen Dienste.

`;root.querySelector('button').onclick=async()=>{pending=true;root.querySelector('button').disabled=true;try{await api('image-runtime/'+(running?'cancel':'install'),{});}catch(error){alert(error.message);}finally{pending=false;}};}catch(error){if(root.isConnected)root.textContent=error.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}} + refresh(); + } + return {html,bind,bindInstaller}; })(); diff --git a/image_runtime.py b/image_runtime.py new file mode 100644 index 0000000..cdd21a9 --- /dev/null +++ b/image_runtime.py @@ -0,0 +1,102 @@ +"""Unprivileged, pinned image-runtime installation in Deck's own state directory.""" +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import threading +import time +import uuid + +COMFY_REV='8d534945ebd53cff61e8def81757c6a6c1b9cf2d' +GGUF_REV='373048b8403a7820620065210a691263d4da0a61' +BUNDLED=(Path('/opt/deck-image-python/bin/python'),Path('/opt/deck-comfy')) + +class ImageRuntime: + def __init__(self,root): + self.root=Path(root);self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.worker=None;self.job=None + if (self.root/'installation.json').is_file(): + self.job=json.loads((self.root/'installation.json').read_text()) + if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Installation unterbrochen; erneut installieren.') + def paths(self): + marker=self.root/'active.json' + if marker.is_file(): + ident=json.loads(marker.read_text()).get('id','') + if len(ident)==32 and all(c in '0123456789abcdef' for c in ident): + base=self.root/ident + if (base/'ready').is_file():return base/'python/bin/python',base/'comfy' + return BUNDLED + def status(self): + with self.lock: + python,comfy=self.paths() + installed=python.is_file() and (comfy/'main.py').is_file() + return dict(installed=installed,origin='Deck-Installer' if (python,comfy)==BUNDLED else 'GUI-Installation',job=dict(self.job) if self.job else None,comfy_revision=COMFY_REV,gguf_revision=GGUF_REV,required_disk_gib=25) + def _save(self): + p=self.root/'installation.tmp';p.write_text(json.dumps(self.job));p.replace(self.root/'installation.json') + def _phase(self,text): + with self.lock:self.job['phase']=text;self._save() + def start(self): + with self.lock: + if self.job and self.job['state']=='running':raise ValueError('Eine Installation läuft bereits.') + if self.status()['installed']:raise ValueError('Die Bildlaufzeit ist bereits installiert und einsatzbereit.') + if sys.platform!='linux' or not shutil.which('git'):raise ValueError('Debian/Linux mit Python venv und Git erforderlich. Systemvoraussetzungen über den Deck-Installer bereitstellen.') + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + if shutil.disk_usage(self.root).free<25*1024**3:raise ValueError('Mindestens 25 GiB freier Speicher für die Bildlaufzeit erforderlich.') + self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Installation wird vorbereitet',started_at=time.time()) + self._save();self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status() + def stop(self): + with self.lock: + self.cancel.set();process=self.process + if process and process.poll() is None: + try:os.killpg(process.pid,signal.SIGTERM) + except ProcessLookupError:pass + if self.worker:self.worker.join(timeout=7) + return {'cancellation_requested':True} + def _command(self,args): + with self.lock: + if self.cancel.is_set():raise InterruptedError() + temporary=self.root/self.job['id']/'tmp';temporary.mkdir(exist_ok=True) + env=dict(os.environ,TMPDIR=str(temporary),PIP_NO_INPUT='1',PIP_DISABLE_PIP_VERSION_CHECK='1',GIT_TERMINAL_PROMPT='0',OMP_NUM_THREADS='2') + self.process=subprocess.Popen([str(a) for a in args],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,env=env) + process=self.process + deadline=time.monotonic()+3600 + while process.poll() is None: + if self.cancel.is_set() or time.monotonic()>deadline: + try: + os.killpg(process.pid,signal.SIGTERM) + try:process.wait(timeout=5) + except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait() + except ProcessLookupError:pass + if self.cancel.is_set():raise InterruptedError() + raise RuntimeError('Zeitlimit beim Installationsschritt erreicht.') + time.sleep(.2) + if self.cancel.is_set():raise InterruptedError() + if process.returncode:raise RuntimeError('Installationsschritt fehlgeschlagen. Internetzugang, freien Speicher und Python-venv-Voraussetzungen prüfen.') + def _run(self): + base=self.root/self.job['id'];comfy=base/'comfy';python=base/'python/bin/python' + phase='';state='failed' + try: + base.mkdir(mode=0o700) + self._phase('Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python']) + self._phase('ComfyUI herunterladen') + for url,path,rev in [('https://github.com/Comfy-Org/ComfyUI.git',comfy,COMFY_REV),('https://github.com/leejet/ComfyUI-GGUF.git',comfy/'custom_nodes/ComfyUI-GGUF',GGUF_REV)]: + self._command(['git','init',path]);self._command(['git','-C',path,'fetch','--depth','1',url,rev]);self._command(['git','-C',path,'checkout','--detach','FETCH_HEAD']) + self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern') + self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128']) + self._phase('Bildlaufzeit-Abhängigkeiten installieren') + self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt']) + self._phase('Installation prüfen') + self._command([python,'-m','pip','check']) + self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda']) + with self.lock: + if self.cancel.is_set():raise InterruptedError() + (base/'ready').write_text(COMFY_REV) + marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json') + state='complete';phase='Bildlaufzeit installiert. Es wurde kein Modell gestartet.' + except InterruptedError:state='cancelled';phase='Installation abgebrochen. Erneuter Versuch möglich.' + except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'Installation fehlgeschlagen. Systemvoraussetzungen und Speicher prüfen.' + finally: + if state!='complete':shutil.rmtree(base,ignore_errors=True) + with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save() diff --git a/image_test.py b/image_test.py index 18506c9..e7f67f9 100644 --- a/image_test.py +++ b/image_test.py @@ -39,7 +39,7 @@ def workflow(prompt,params,seed): class ImageTests: def __init__(self,root,profiles): - self.root=Path(root);self.profiles=profiles;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None + self.root=Path(root);self.profiles=profiles;self.runtime=None;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None if (self.root/'status.json').exists(): self.job=json.loads((self.root/'status.json').read_text()) if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Deck wurde neu gestartet; Test erneut starten.') @@ -50,7 +50,9 @@ class ImageTests: with self.lock: self.job['phase']=text;self._save() def status(self): - with self.lock:return dict(job=dict(self.job) if self.job else None,runtime_installed=PYTHON.is_file() and (COMFY/'main.py').is_file()) + with self.lock: + python,comfy=self.runtime.paths() if self.runtime else (PYTHON,COMFY) + return dict(job=dict(self.job) if self.job else None,runtime_installed=python.is_file() and (comfy/'main.py').is_file()) def stop(self): self.cancel.set() with self.lock:process=self.process @@ -67,6 +69,7 @@ class ImageTests: if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.') with self.lock: if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.') + if self.runtime and (self.runtime.status()['job'] or {}).get('state')=='running':raise ValueError('Die Bildlaufzeit wird gerade installiert.') if not self.status()['runtime_installed']:raise ValueError('Die eigene ComfyUI-Bildlaufzeit ist noch nicht installiert.') profile=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id),None) if not profile or profile['kind']!='image' or not profile['model']:raise ValueError('Bildprofil nicht verfügbar.') @@ -104,10 +107,10 @@ class ImageTests: (directory/'paths.json').write_text(json.dumps(config)) # JSON is valid YAML. with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='2',MKL_NUM_THREADS='2',HOME=str(directory),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',PYTHONDONTWRITEBYTECODE='1') - args=[str(PYTHON),str(COMFY/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')] + args=[str(self.runtime.paths()[0] if self.runtime else PYTHON),str((self.runtime.paths()[1] if self.runtime else COMFY)/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')] with self.lock: if self.cancel.is_set():raise InterruptedError() - process=subprocess.Popen(args,cwd=COMFY,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process + process=subprocess.Popen(args,cwd=self.runtime.paths()[1] if self.runtime else COMFY,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process def request(path,data=None): req=urllib.request.Request(f'http://127.0.0.1:{port}'+path,data=json.dumps(data).encode() if data is not None else None,headers={'Content-Type':'application/json'}) with urllib.request.urlopen(req,timeout=5) as r: @@ -129,7 +132,7 @@ class ImageTests: nodes=request('/object_info') required={'UnetLoaderGGUF','CLIPLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'} if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.') - self._phase('Modell und Textencoder laden · Bild wird erzeugt') + self._phase('Auftrag wird verarbeitet · Textencoder auf CPU, Bildberechnung auf GPU') response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id'] deadline=time.monotonic()+1800 while time.monotonic(){ 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&&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.'}

`; + 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-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); 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'});}); diff --git a/profiles.py b/profiles.py index a360dea..813feb3 100644 --- a/profiles.py +++ b/profiles.py @@ -66,6 +66,15 @@ class Profiles: temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return row + def delete(self,data): + if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.') + with self.lock: + old=next((p for p in self.rows if p['id']==data['id']),None) + if not old or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.') + rows=[p for p in self.rows if p is not old] + temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows + return {'deleted':old['id']} + 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) diff --git a/server.py b/server.py index 4a700be..a2c87ea 100644 --- a/server.py +++ b/server.py @@ -10,6 +10,7 @@ from catalog import Catalog from profiles import Profiles from capacity import assess, overview from runtime import Runtime +from image_runtime import ImageRuntime from image_test import ImageTests from urllib.parse import urlsplit, parse_qs from network.client import NetworkClient @@ -104,6 +105,8 @@ class Server(ThreadingHTTPServer): self.runtime = Runtime(Path(state_dir or os.environ.get("DECK_STATE_DIR", ROOT/".state"))/"runtime") self.profiles = Profiles(self.catalog.root.parent/"profiles.json",self.catalog) self.image_tests = ImageTests(self.catalog.root.parent/"image-tests",self.profiles) + self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime") + self.image_tests.runtime = self.image_runtime self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] self.demo = DemoService() self.hardware = HardwareProvider() @@ -274,6 +277,7 @@ 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 self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status()) if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status()) if urlsplit(self.path).path == '/api/v1/image-tests/image': try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png') @@ -351,6 +355,16 @@ 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 in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'): + try: + if self.read_json():raise ValueError('Keine Parameter erwartet.') + with self.server.image_tests.lock: + if self.path.endswith('/cancel'):return self.respond(self.server.image_runtime.stop()) + job=self.server.image_tests.job + if job and job.get('state')=='running':raise ValueError('Zuerst den laufenden Bildauftrag beenden.') + return self.respond(self.server.image_runtime.start()) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except OSError:return self.respond({'error':'Installation konnte nicht vorbereitet werden.'},503) if self.path in ('/api/v1/image-tests/start','/api/v1/image-tests/cancel'): try: data=self.read_json() @@ -363,6 +377,15 @@ class Handler(BaseHTTPRequestHandler): 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/delete': + try: + data=self.read_json() + with self.server.image_tests.lock: + job=self.server.image_tests.job + if job and job.get('state')=='running' and job.get('profile_id')==data.get('id'):raise ValueError('Dieses Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.') + return self.respond(self.server.profiles.delete(data)) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except OSError:return self.respond({'error':'Profil konnte nicht gelöscht 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) @@ -416,6 +439,7 @@ def main(): try: server.serve_forever() finally: + server.image_runtime.stop() server.image_tests.stop() server.runtime.stop() server.catalog.stop() diff --git a/studio.js b/studio.js index 691bfb8..185615c 100644 --- a/studio.js +++ b/studio.js @@ -9,7 +9,7 @@ const Studio=(()=>{ 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'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. 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==='image-runtime'){ImageTestUI.bind(true);return;} + if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;} if(page==='runtimes')return; if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running'); else if(section==='profiles')ProfilesUI.bind(page,modelId); @@ -17,7 +17,7 @@ const Studio=(()=>{ 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

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung wird mit dem Deck-Installer bereitgestellt. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} + function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; })(); diff --git a/test_image_runtime.py b/test_image_runtime.py new file mode 100644 index 0000000..fd79eab --- /dev/null +++ b/test_image_runtime.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from image_runtime import ImageRuntime + +class ImageRuntimeTests(unittest.TestCase): + def test_installed_bundle_blocks_reinstall(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);python=root/'python';python.touch();comfy=root/'comfy';comfy.mkdir();(comfy/'main.py').touch() + with patch('image_runtime.BUNDLED',(python,comfy)): + manager=ImageRuntime(root/'state');self.assertTrue(manager.status()['installed']) + with self.assertRaises(ValueError):manager.start() + def test_failed_install_preserves_active_and_removes_partial(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);manager=ImageRuntime(root);manager.job=dict(id='a'*32,state='running') + with patch.object(manager,'_command',side_effect=RuntimeError('test failure')):manager._run() + self.assertEqual(manager.status()['job']['state'],'failed');self.assertFalse((root/'active.json').exists());self.assertFalse((root/('a'*32)).exists()) + def test_success_activates_only_after_validation(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);manager=ImageRuntime(root);manager.job=dict(id='b'*32,state='running');commands=[] + def command(args): + self.assertFalse((root/'active.json').exists());commands.append([str(x) for x in args]) + with patch.object(manager,'_command',side_effect=command):manager._run() + self.assertEqual(manager.status()['job']['state'],'complete');self.assertTrue((root/('b'*32)/'ready').exists()) + self.assertTrue(any(x[-2:]==['pip','check'] for x in commands)) + self.assertEqual(manager.paths()[1],root/('b'*32)/'comfy') + def test_cancel_never_activates(self): + with tempfile.TemporaryDirectory() as d: + manager=ImageRuntime(d);manager.job=dict(id='c'*32,state='running');manager.cancel.set();manager._run() + self.assertEqual(manager.status()['job']['state'],'cancelled');self.assertFalse((Path(d)/'active.json').exists()) + def test_install_tempfiles_use_owned_disk_not_container_tmpfs(self): + import sys + with tempfile.TemporaryDirectory() as d: + manager=ImageRuntime(d);manager.job=dict(id='d'*32,state='running');base=Path(d)/manager.job['id'];base.mkdir() + manager._command([sys.executable,'-c',"import os; from pathlib import Path; p=Path(os.environ['TMPDIR']); assert p.name=='tmp'; (p/'test').write_text('ok')"]) + self.assertEqual((base/'tmp/test').read_text(),'ok') + def test_restart_interrupted_install(self): + with tempfile.TemporaryDirectory() as d: + (Path(d)/'installation.json').write_text(json.dumps(dict(state='running'))) + self.assertEqual(ImageRuntime(d).status()['job']['state'],'interrupted') diff --git a/test_model_management.py b/test_model_management.py index 5ad74fc..15dfb7c 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -29,6 +29,13 @@ class ManagementTests(unittest.TestCase): with self.subTest(key=key),self.assertRaises(ValueError):self.profiles.save(req) (self.root/'models'/self.model_id/'model.gguf').unlink() with self.assertRaises(ValueError):self.profiles.save(self.request()) + def test_delete_revision_and_model_preservation(self): + row=self.profiles.save(self.request()) + with self.assertRaises(ValueError):self.profiles.delete(dict(id=row['id'],revision=0)) + self.assertEqual(len(self.profiles.rows),1) + self.profiles.delete(dict(id=row['id'],revision=row['revision'])) + self.assertEqual(Profiles(self.root/'profiles.json',self.catalog).rows,[]) + self.assertTrue((self.root/'models'/self.model_id/'model.gguf').exists()) def test_clear_history_keeps_models_and_survives_restart(self): old=self.catalog.status()['downloads'][0];self.catalog.dismiss(old['id']) restart=Catalog(self.root/'models');self.assertEqual(restart.status()['downloads'],[]);self.assertEqual(len(restart.status()['entries']),1) diff --git a/test_server.py b/test_server.py index 6ebde84..e96d546 100644 --- a/test_server.py +++ b/test_server.py @@ -47,12 +47,22 @@ class Tests(unittest.TestCase): payload=dict(id=None,revision=0,name='image-test',kind='image',model_id='a'*64,parameters=dict(width=512,height=512,steps=10,seed=-1,guidance=1)) req=urllib.request.Request(self.url+'/api/v1/profiles/save',data=json.dumps(payload).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'}) with urllib.request.urlopen(req) as r:self.assertEqual(r.status,200) - self.assertEqual(self.request('profiles')['profiles'][0]['name'],'image-test') + row=self.request('profiles')['profiles'][0] + self.assertEqual(row['name'],'image-test') + delete=urllib.request.Request(self.url+'/api/v1/profiles/delete',data=json.dumps(dict(id=row['id'],revision=row['revision'])).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'}) + self.server.image_tests.job=dict(state='running',profile_id=row['id']) + with self.assertRaises(urllib.error.HTTPError) as busy:urllib.request.urlopen(delete) + self.assertEqual(busy.exception.code,400);busy.exception.close() + self.server.image_tests.job=None + with urllib.request.urlopen(delete) as r:self.assertEqual(r.status,200) + self.assertEqual(self.request('profiles')['profiles'],[]) + self.assertTrue((target/'model.gguf').exists()) with self.assertRaises(urllib.error.HTTPError) as error: urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/profiles',headers={'Authorization':'Bearer '+self.api_token})) self.assertEqual(error.exception.code,401);error.exception.close() def test_image_test_routes_are_admin_only(self): + self.assertIn('installed',self.request('image-runtime')) self.assertIn('runtime_installed',self.request('image-tests')) with self.assertRaises(urllib.error.HTTPError) as error: urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/image-tests',headers={'Authorization':'Bearer '+self.api_token}))