From c584bbcaa6ca5293f5cb0ca54dfb59a13119f6bd Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:00:15 +0200 Subject: [PATCH] Add isolated ComfyUI image playground with real profile-based generation --- README.md | 2 +- STUDIO.md | 43 +++++++ catalog-ui.js | 2 +- deploy/Dockerfile | 16 ++- deploy/NATIVE_INSTALL_REQUIREMENTS.md | 11 ++ deploy/image-requirements.lock | 104 ++++++++++++++++ deploy/install.py | 12 +- image-test-ui.js | 19 +++ image_test.py | 166 ++++++++++++++++++++++++++ index.html | 2 +- network/install_remote.py | 2 +- profiles-ui.js | 2 +- profiles.py | 7 +- server.py | 18 ++- studio.js | 10 +- style.css | 2 + test_image_test.py | 51 ++++++++ test_server.py | 8 ++ 18 files changed, 456 insertions(+), 21 deletions(-) create mode 100644 deploy/image-requirements.lock create mode 100644 image-test-ui.js create mode 100644 image_test.py create mode 100644 test_image_test.py diff --git a/README.md b/README.md index 4c7c09f..a16d80f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. -Modellstarts sind noch nicht angebunden. Details: [Modellverwaltung](STUDIO.md). +Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Andere Modellstarts sind noch nicht angebunden. Details: [Modellverwaltung](STUDIO.md). ## Zugang und API-Token diff --git a/STUDIO.md b/STUDIO.md index 75d06c2..c4af152 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -89,3 +89,46 @@ Dateiklassifikation (`role`, `role_label`, `profile_eligible`). Erkannte Textencoder, VAE, Projektoren und Adapter bleiben als Zusatzdateien sichtbar, bieten aber kein „Profil anlegen“ und können nicht als Hauptmodell gespeichert werden. Bestehende Komponenten-Zuordnungen und Dateien bleiben unverändert. + +## Bildgenerierung → Testen + +Der Reiter Testen startet jetzt echte Qwen-Image-2.1-GGUF-Aufträge über einen +eigenen ComfyUI-Prozess. Profil wählen, Prompt eingeben, Bild generieren; danach +erscheinen Vorschau und PNG-Download. Auflösung, Schritte, CFG und Seed kommen +aus dem Profil. Zunächst ein Bild pro Auftrag, maximal 1024 × 1024, keine +Referenzbilder und keine öffentliche OpenAI-Bild-API. Der interne Admin-Endpunkt +ist ausreichend für die GUI. + +- `GET /api/v1/image-tests`: Status der eigenen Laufzeit und des letzten Tests. +- `POST /api/v1/image-tests/start`: `{profile_id, prompt}`; Prompt 1–4000 Zeichen. +- `POST /api/v1/image-tests/cancel`: `{}`; beendet ausschließlich den eigenen Worker. +- `GET /api/v1/image-tests/image?id=`: letztes fertiges PNG, Sitzung erforderlich. + +Serialisierte Aufträge, eigene Prozessgruppe, 30-Minuten-Generierungslimit, +Abbruch und Entladen auch bei Fehlern. Der Textencoder läuft auf CPU; Diffusion +und VAE nutzen eine einzelne GPU. Vor dem Start werden RAM und GPU-Speicher +geprüft. Eine GPU mit vorhandenen Compute-Prozessen wird nicht verwendet; bei +einem weiteren Compute-Prozess während des Tests bricht Deck seinen Auftrag ab. +Das ist eine Schutzprüfung, keine exklusive Hardware-Reservierung gegenüber +fremden Prozessen. Produktive Router/Modelle werden nicht gesteuert. + +Die eigene ComfyUI-HTTP-Schnittstelle bindet nur Container-/Prozess-Loopback auf +einem freien Port, ohne Host-Portfreigabe. Es werden keine Produktionspfade +verwendet. ComfyUI-Ausgaben werden nicht als Logs gespeichert. PNG-Promptmetadaten +sind deaktiviert; Jobstatus enthält keinen Prompt. Ergebnisse und temporäre +Worker-Dateien liegen privat unter `state/image-tests/`; die GUI zeigt nur das +letzte Ergebnis. Frühere Jobverzeichnisse bleiben bislang auf dem Datenträger. + +Versionsgebundene Quellen: ComfyUI `8d534945ebd53cff61e8def81757c6a6c1b9cf2d`, +ComfyUI-GGUF `373048b8403a7820620065210a691263d4da0a61`, PyTorch 2.11.0 CUDA 12.8. +Python-Pakete sind in `deploy/image-requirements.lock` festgehalten. Eigenes +Virtualenv `/opt/deck-image-python`, ComfyUI `/opt/deck-comfy`. Der Docker-Installer +baut diese Umgebung mit ein; `image_runtime: true` im eigenen Installationsmanifest +setzt dafür 32 GiB RAM-Limit und 256 PIDs. CPU-Limit bleibt zwei Kerne. +Bestehende Installationen behalten ihre Ressourceneinstellung bis zur expliziten +Aktivierung. Keine NVIDIA-Treiber-/Hostinstallation im Container-Update. + +Validierung: 63 Tests; reale isolierte Generierungen 512 × 512 / 2 Schritte und +1024 × 1024 / 25 Schritte. Letztere dauerte ca. 99 Sekunden, PNG visuell geprüft, +GPU danach wieder frei. Nutzerprofile unverändert. Die einfache Profil-/Prompt-/ +Ergebnisanordnung orientiert sich an LocalAIs ImageGen-Seite, ohne deren Code zu kopieren. diff --git a/catalog-ui.js b/catalog-ui.js index 6b7ee02..04a67f9 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -26,7 +26,7 @@ window.CatalogUI = (() => { root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); } function renderLibrary() { - el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
${escape(x.role_label)} · HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

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

Version und Integrität

Revision ${escape(x.revision)}

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

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

Deine Bibliothek ist noch leer

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

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

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

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

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

Deine Bibliothek ist noch leer

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

'; } function renderJob() { el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`; diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 9d72c8f..e77d92a 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,8 +1,18 @@ FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3 git cmake g++ make ca-certificates libssl-dev && rm -rf /var/lib/apt/lists/* +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3 python3-venv git cmake g++ make ca-certificates libssl-dev && rm -rf /var/lib/apt/lists/* +# Isolated image runtime. Keep llama.cpp build tools and the app interpreter separate. +RUN python3 -m venv /opt/deck-image-python \ + && /opt/deck-image-python/bin/pip install --no-cache-dir torch==2.11.0 torchvision==0.26.0 --index-url https://download.pytorch.org/whl/cu128 +COPY deploy/image-requirements.lock /tmp/image-requirements.lock +RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \ + && git -C /opt/deck-comfy checkout 8d534945ebd53cff61e8def81757c6a6c1b9cf2d \ + && git clone https://github.com/leejet/ComfyUI-GGUF.git /opt/deck-comfy/custom_nodes/ComfyUI-GGUF \ + && git -C /opt/deck-comfy/custom_nodes/ComfyUI-GGUF checkout 373048b8403a7820620065210a691263d4da0a61 \ + && /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 profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ -COPY 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 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 \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ diff --git a/deploy/NATIVE_INSTALL_REQUIREMENTS.md b/deploy/NATIVE_INSTALL_REQUIREMENTS.md index 19906c2..25d221a 100644 --- a/deploy/NATIVE_INSTALL_REQUIREMENTS.md +++ b/deploy/NATIVE_INSTALL_REQUIREMENTS.md @@ -56,3 +56,14 @@ Athena bleiben Host-Pakete und Treiber unverändert; Entwicklung weiterhin isoli Erst nach diesen Prüfungen darf die Dokumentation den Installer als für ein frisches Debian vollständig geeignet bezeichnen. + +### Eigene Bild-Testlaufzeit + +Der native Installer muss dieselbe Bildumgebung wie der Docker-Testinstaller +bereitstellen: separates Virtualenv `/opt/deck-image-python`, gepinnte ComfyUI- +Quelle unter `/opt/deck-comfy` und die gepinnte ComfyUI-GGUF-Erweiterung. Konkrete +Commits stehen im Dockerfile; Paketversionen in `image-requirements.lock`. +PyTorch 2.11.0 CUDA 12.8 benötigt kompatiblen bestehenden Treiber. Keine +Treiberänderung oder automatischer Neustart. Der Deck-Service benötigt ausreichend +RAM für CPU-Textencoder; die Testinstallation begrenzt ihn auf 32 GiB und zwei +CPU-Kerne. Modelle/Encoder/VAE werden weiterhin separat über die Bibliothek geladen. diff --git a/deploy/image-requirements.lock b/deploy/image-requirements.lock new file mode 100644 index 0000000..17d0051 --- /dev/null +++ b/deploy/image-requirements.lock @@ -0,0 +1,104 @@ +aiohappyeyeballs==2.7.1 +aiohttp==3.14.3 +aiosignal==1.4.0 +alembic==1.20.0 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +attrs==26.1.0 +av==18.1.0 +blake3==1.0.9 +certifi==2026.7.22 +charset-normalizer==3.5.1 +click==8.5.0 +comfy-aimdo==0.5.5 +comfy-angle==0.1.1 +comfy-kitchen==0.2.35 +comfyui-embedded-docs==0.5.12 +comfyui-workflow-templates-core==0.3.361 +comfyui-workflow-templates-json==0.1.96 +comfyui-workflow-templates-media-api==0.3.84 +comfyui-workflow-templates-media-assets-01==0.1.48 +comfyui-workflow-templates-media-assets-02==0.1.6 +comfyui-workflow-templates-media-image==0.3.160 +comfyui-workflow-templates-media-other==0.3.229 +comfyui-workflow-templates-media-video==0.3.101 +comfyui_frontend_package==1.53.6 +comfyui_workflow_templates==0.11.70 +cuda-bindings==12.9.7 +cuda-pathfinder==1.6.0 +cuda-toolkit==12.8.1 +einops==0.8.2 +filelock==3.32.3 +frozenlist==1.8.0 +fsspec==2026.7.0 +gguf==0.19.0 +h11==0.16.0 +hf-xet==1.6.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface_hub==1.33.0 +idna==3.20 +Jinja2==3.1.6 +kornia==0.8.3 +kornia_rs==0.2.0 +Mako==1.4.3 +markdown-it-py==4.2.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +mpmath==1.3.0 +multidict==6.9.1 +networkx==3.6.1 +numpy==2.5.2 +nvidia-cublas-cu12==12.8.4.1 +nvidia-cuda-cupti-cu12==12.8.90 +nvidia-cuda-nvrtc-cu12==12.8.93 +nvidia-cuda-runtime-cu12==12.8.90 +nvidia-cudnn-cu12==9.19.0.56 +nvidia-cufft-cu12==11.3.3.83 +nvidia-cufile-cu12==1.13.1.3 +nvidia-curand-cu12==10.3.9.90 +nvidia-cusolver-cu12==11.7.3.90 +nvidia-cusparse-cu12==12.5.8.93 +nvidia-cusparselt-cu12==0.7.1 +nvidia-nccl-cu12==2.28.9 +nvidia-nvjitlink-cu12==12.8.93 +nvidia-nvshmem-cu12==3.4.5 +nvidia-nvtx-cu12==12.8.90 +packaging==26.3 +pillow==12.3.0 +propcache==0.5.4 +protobuf==7.36.2 +psutil==7.2.2 +pydantic==2.13.5 +pydantic-settings==2.15.0 +pydantic_core==2.46.5 +Pygments==2.21.0 +PyOpenGL==3.1.10 +python-dotenv==1.2.3 +PyYAML==6.0.3 +regex==2026.9.10 +requests==2.34.2 +rich==15.0.0 +safetensors==0.8.0 +scipy==1.18.1 +sentencepiece==0.2.2 +setuptools==78.1.0 +shellingham==1.5.4 +simpleeval==1.0.8 +spandrel==0.4.2 +SQLAlchemy==2.1.1 +sympy==1.14.0 +tokenizers==0.23.2 +torch==2.11.0+cu128 +torchsde==0.2.6 +torchvision==0.26.0+cu128 +tqdm==4.70.1 +trampoline==0.1.2 +transformers==5.17.0 +triton==3.6.0 +typer==0.27.2 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +urllib3==2.8.0 +yarl==1.25.1 diff --git a/deploy/install.py b/deploy/install.py index 0980717..04134cd 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 = ['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'] +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'] def run(*args, check=True, interactive=False): @@ -93,7 +93,7 @@ def launch(config): base=Path(config['base']) args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base), '--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true', - '--pids-limit','128','--memory','8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m', + '--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m', '-v',str(base/'state')+':/var/lib/deck:rw','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']), '-p','127.0.0.1:'+str(config['port'])+':8108', '--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"', @@ -151,8 +151,8 @@ def install(args): free_port(args.port) parent=base.parent while not parent.exists():parent=parent.parent - if shutil.disk_usage(parent).free < 2*1024**3: - raise RuntimeError('Mindestens 2 GiB freier Speicher für Installation erforderlich.') + if shutil.disk_usage(parent).free < 25*1024**3: + raise RuntimeError('Mindestens 25 GiB freier Speicher für Installation inklusive Bildlaufzeit erforderlich.') if not sys.stdin.isatty(): raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).') image=build() @@ -160,7 +160,7 @@ def install(args): for sub in ('state','models','backups','bootstrap'): (base/sub).mkdir(mode=0o700) os.chown(base/'state',65534,65534) - config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry) + config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry,image_runtime=True) write_manifest(base,config) provision(config) try: @@ -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')) + shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification')) 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 new file mode 100644 index 0000000..f086284 --- /dev/null +++ b/image-test-ui.js @@ -0,0 +1,19 @@ +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 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;}; + function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value);el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';} + async function refresh(){try{status=await api('image-tests');if(!root.isConnected)return;const j=status.job,signature=JSON.stringify(j);if(signature!==lastJob){lastJob=signature;el('image-test-state').innerHTML=j?`${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}

${e(j.profile_name)}

${e(j.phase)}

${e(j.gpu)} · Seed ${e(j.seed)}

${j.state==='running'?'':''}`:`

${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}

Noch kein Bildtest gestartet.

`; + el('image-test-result').innerHTML=j?.state==='complete'?`
Ergebnis des Bildtests
PNG herunterladen
`:''; + el('image-cancel')?.addEventListener('click',async event=>{event.target.disabled=true;try{await api('image-tests/cancel',{});message('Abbruch angefordert; der eigene Bildworker wird beendet.');}catch(error){message(error.message);}}); + }selection();}catch(error){message(error.message);}finally{if(root.isConnected)timer=setTimeout(refresh,2000);}} + if(!readonly){api('profiles').then(v=>{if(!root.isConnected)return;profiles=v.profiles.filter(p=>p.kind==='image');el('image-test-profile').innerHTML=profiles.map(p=>``).join('')||'';selection();}).catch(error=>message(error.message));el('image-test-profile').onchange=selection; + el('image-test-form').onsubmit=async event=>{event.preventDefault();submitting=true;selection();try{await api('image-tests/start',Object.fromEntries(new FormData(event.target)));message('Bildtest gestartet.');}catch(error){message(error.message);}finally{submitting=false;selection();}}; + } + refresh(); + } + return {html,bind}; +})(); diff --git a/image_test.py b/image_test.py new file mode 100644 index 0000000..18506c9 --- /dev/null +++ b/image_test.py @@ -0,0 +1,166 @@ +"""Owned, serial ComfyUI test jobs. Never talks to the production image router.""" +import json +import os +from pathlib import Path +import secrets +import shutil +import signal +import socket +import subprocess +import threading +import time +import urllib.request +import uuid +from profiles import QWEN_REPO + +PYTHON=Path('/opt/deck-image-python/bin/python') +COMFY=Path('/opt/deck-comfy') +GIB=1024**3 + +def probe(): + rows=subprocess.run(['nvidia-smi','--query-gpu=uuid,name,memory.total,memory.free','--format=csv,noheader,nounits'],capture_output=True,text=True,check=True,timeout=5).stdout + apps=subprocess.run(['nvidia-smi','--query-compute-apps=gpu_uuid,pid','--format=csv,noheader,nounits'],capture_output=True,text=True,check=True,timeout=5).stdout + counts={} + for line in apps.splitlines(): + gpu,_=line.split(',',1);counts[gpu.strip()]=counts.get(gpu.strip(),0)+1 + return [dict(uuid=u.strip(),name=n.strip(),total_mib=float(t),free_mib=float(f),processes=counts.get(u.strip(),0)) for u,n,t,f in (line.split(',') for line in rows.splitlines())] + +def workflow(prompt,params,seed): + return { + '1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}}, + '2':{'class_type':'CLIPLoader','inputs':{'clip_name':'encoder.safetensors','type':'qwen_image','device':'cpu'}}, + '3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}}, + '4':{'class_type':'TextEncodeQwenImage21','inputs':{'clip':['2',0],'prompt':prompt,'negative_prompt':'','resolution':max(params['width'],params['height'])}}, + '5':{'class_type':'EmptyLatentImage','inputs':{'width':params['width'],'height':params['height'],'batch_size':1}}, + '6':{'class_type':'KSampler','inputs':{'model':['1',0],'positive':['4',0],'negative':['4',1],'latent_image':['5',0],'seed':seed,'steps':params['steps'],'cfg':params['guidance'],'sampler_name':'euler','scheduler':'simple','denoise':1}}, + '7':{'class_type':'VAEDecode','inputs':{'samples':['6',0],'vae':['3',0]}}, + '8':{'class_type':'SaveImage','inputs':{'filename_prefix':'result','images':['7',0]}} + } + +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 + 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.') + def _save(self): + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + p=self.root/'status.tmp';p.write_text(json.dumps(self.job));p.replace(self.root/'status.json') + def _phase(self,text): + 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()) + def stop(self): + self.cancel.set() + with self.lock:process=self.process + if process and process.poll() is None: + try: + os.killpg(process.pid,signal.SIGTERM) + try:process.wait(timeout=3) + except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait() + except ProcessLookupError:pass + return {'cancellation_requested':True} + def model_path(self,item): + return (self.profiles.catalog.root/item['id']/('model'+Path(item['file']).suffix)).resolve() + def start(self,profile_id,prompt): + 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 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.') + model=profile['model'] + if model['repo']!=QWEN_REPO or not model['file'].endswith('.gguf'):raise ValueError('Der Bildtest unterstützt zunächst Qwen-Image-2.1 GGUF aus dem hinterlegten Rezept.') + params=profile['parameters'] + if params['width']>1024 or params['height']>1024:raise ValueError('Der isolierte Test unterstützt maximal 1024 × 1024 Pixel.') + encoder=self.profiles._component(model,'text_encoder',profile.get('components',{}).get('text_encoder')) + vae=self.profiles._component(model,'vae',profile.get('components',{}).get('vae')) + mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith(('MemAvailable:','MemTotal:'))} + required_ram=max(20*GIB,encoder['size']*2+4*GIB) + try: + raw=Path('/sys/fs/cgroup/memory.max').read_text().strip() + current=int(Path('/sys/fs/cgroup/memory.current').read_text()) + if raw!='max' and int(raw)-currentneeded] + if not gpus:raise ValueError('Keine unbelegte GPU mit ausreichend freiem VRAM. Deck stoppt keine produktiven Modelle.') + gpu=max(gpus,key=lambda g:g['free_mib']);self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.') + job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=0 else secrets.randbelow(2147483648) + self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],seed=seed,parameters=params) + self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,model,encoder,vae),daemon=True).start() + return dict(self.job) + def _run(self,job_id,prompt,params,seed,gpu,model,encoder,vae): + directory=self.root/job_id;process=None + try: + directory.mkdir(mode=0o700) + for role,item,filename in [('unet',model,'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]: + dest=directory/'models'/role;dest.mkdir(parents=True);(dest/filename).symlink_to(self.model_path(item)) + for folder in ('output','temp','user','input'):(directory/folder).mkdir() + config={'deck':{'base_path':str(directory/'models'),'unet':'unet','clip':'clip','vae':'vae'}} + (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')] + 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 + 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: + raw=r.read(4*1024*1024+1) + if len(raw)>4*1024*1024:raise ValueError('Bildlaufzeit-Antwort zu groß.') + return json.loads(raw) + def guard(): + if self.cancel.is_set():raise InterruptedError() + if process.poll() is not None:raise ValueError('Bildlaufzeit wurde beendet (Speicherlimit oder Startfehler).') + current=next((g for g in probe() if g['uuid']==gpu['uuid']),None) + if not current or current['processes']>1:raise ValueError('GPU wird inzwischen von einem weiteren Prozess verwendet. Deck-Test beendet.') + deadline=time.monotonic()+180 + while True: + guard() + try:request('/system_stats');break + except (OSError,ValueError): + if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.') + time.sleep(1) + 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') + 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()32*1024**2:raise ValueError('Ungültiges Ergebnisbild.') + with path.open('rb') as f: + if f.read(8)!=b'\x89PNG\r\n\x1a\n':raise ValueError('Ergebnis ist kein PNG.') + shutil.copyfile(path,directory/'result.png');break + time.sleep(2) + else:raise ValueError('Zeitlimit der Bildgenerierung erreicht.') + final_state='complete';phase='Bild fertig · Modell wurde entladen' + except InterruptedError:final_state='cancelled';phase='Bildtest abgebrochen' + except Exception as exc:final_state='failed';phase=str(exc) if isinstance(exc,ValueError) else 'Bildlaufzeit nicht erreichbar oder nicht bereit. Komponenten und Installation prüfen.' + finally: + if process and process.poll() is None: + try:os.killpg(process.pid,signal.SIGTERM);process.wait(timeout=10) + except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait() + except ProcessLookupError:pass + with self.lock: + self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save() + def image(self,job_id): + with self.lock: + if not self.job or self.job['id']!=job_id or self.job['state']!='complete':raise ValueError('Ergebnisbild nicht verfügbar.') + return (self.root/job_id/'result.png').read_bytes() diff --git a/index.html b/index.html index a6055e2..24952ab 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/network/install_remote.py b/network/install_remote.py index 76f7875..e53843a 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} +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', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} def run(*args, **kwargs): return subprocess.run(args, capture_output=True, timeout=600, **kwargs) diff --git a/profiles-ui.js b/profiles-ui.js index f78ef0c..a600d90 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -17,7 +17,7 @@ window.ProfilesUI=(()=>{ 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('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)} · ${data.runtime_installed?'installiert':'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;}}); diff --git a/profiles.py b/profiles.py index a03dec9..a360dea 100644 --- a/profiles.py +++ b/profiles.py @@ -21,6 +21,7 @@ QWEN_COMPONENTS={ class Profiles: def __init__(self,path,catalog): self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() + self.image_runtime_ready=lambda:False self.rows=json.loads(self.path.read_text()) if self.path.exists() else [] def status(self): with self.lock: @@ -35,7 +36,9 @@ class Profiles: 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 + if p.get('model') and p['model']['repo']==QWEN_REPO and p['model']['file'].endswith('.gguf') and self.image_runtime_ready(): + p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')] + p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured' return {'profiles':rows,'schemas':SCHEMAS} def save(self,data): if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.') @@ -80,7 +83,7 @@ class Profiles: 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', + return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(), 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.') diff --git a/server.py b/server.py index 03d673f..4a700be 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_test import ImageTests from urllib.parse import urlsplit, parse_qs from network.client import NetworkClient from network.config import parse_config, ConfigError @@ -102,6 +103,8 @@ class Server(ThreadingHTTPServer): self.catalog = Catalog(Path(state_dir or os.environ.get("DECK_STATE_DIR", ROOT/".state"))/"models") 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.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] self.demo = DemoService() self.hardware = HardwareProvider() self.started = time.time() @@ -265,12 +268,16 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') - routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript')} + routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')} if self.path in routes: name, mime = routes[self.path] 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-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') + except (OSError,ValueError):return self.respond({'error':'Bild nicht verfügbar.'},404) 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) @@ -344,6 +351,14 @@ 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-tests/start','/api/v1/image-tests/cancel'): + try: + data=self.read_json() + if self.path.endswith('/cancel') and not data:return self.respond(self.server.image_tests.stop()) + if self.path.endswith('/start') and set(data)=={'profile_id','prompt'}:return self.respond(self.server.image_tests.start(**data)) + raise ValueError('Ungültige Bildtest-Anfrage.') + except ValueError as exc:return self.respond({'error':str(exc)},400) + except Exception:return self.respond({'error':'Bildtest konnte nicht vorbereitet werden; Ressourcen 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) @@ -401,6 +416,7 @@ def main(): try: server.serve_forever() finally: + server.image_tests.stop() server.runtime.stop() server.catalog.stop() server.demo.stop() diff --git a/studio.js b/studio.js index 5f12835..691bfb8 100644 --- a/studio.js +++ b/studio.js @@ -6,16 +6,18 @@ 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==='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')}
`; + 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==='runtimes'||page==='image-runtime')return; - if(section==='profiles')ProfilesUI.bind(page,modelId); + if(page==='image-runtime'){ImageTestUI.bind(true);return;} + if(page==='runtimes')return; + if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running'); + else 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 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 openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; })(); diff --git a/style.css b/style.css index 2599856..ac74a80 100644 --- a/style.css +++ b/style.css @@ -14,3 +14,5 @@ aside{overflow-y:auto}nav{gap:3px}nav a{padding:11px 12px}.prototype-banner{disp @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} + +#image-test textarea{width:100%;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:8px;padding:14px;font:inherit;resize:vertical;margin:10px 0 20px}.generated-image{max-width:100%;height:auto;display:block;border-radius:8px}#image-test-result figure{margin:20px 0}#image-test-state{margin-top:20px} diff --git a/test_image_test.py b/test_image_test.py new file mode 100644 index 0000000..68ecabc --- /dev/null +++ b/test_image_test.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch,Mock +from image_test import ImageTests,workflow + +class ImageTestTests(unittest.TestCase): + def test_workflow_uses_selected_profile_and_owned_aliases(self): + p=dict(width=512,height=768,steps=12,guidance=1.5) + graph=workflow('benign test',p,99) + self.assertEqual(graph['1']['inputs']['unet_name'],'model.gguf') + self.assertEqual(graph['2']['inputs']['device'],'cpu') + self.assertEqual(graph['6']['inputs']['seed'],99) + self.assertEqual(graph['6']['inputs']['steps'],12) + self.assertEqual(graph['6']['inputs']['cfg'],1.5) + self.assertEqual(graph['5']['inputs']['height'],768) + def test_missing_runtime_and_invalid_prompts_do_not_spawn(self): + with tempfile.TemporaryDirectory() as d,patch('image_test.PYTHON',Path(d)/'missing'),patch('image_test.subprocess.Popen') as spawn: + manager=ImageTests(d,Mock()) + for prompt in ('',None,'x'*4001,'hello'): + with self.assertRaises(ValueError):manager.start('profile',prompt) + spawn.assert_not_called() + def test_images_only_accessible_for_current_completed_job(self): + with tempfile.TemporaryDirectory() as d: + manager=ImageTests(d,Mock());manager.job=dict(id='a'*32,state='complete') + p=Path(d)/('a'*32);p.mkdir();(p/'result.png').write_bytes(b'png') + self.assertEqual(manager.image('a'*32),b'png') + for value in ('../../secret','b'*32): + with self.assertRaises(ValueError):manager.image(value) + manager.job['state']='running' + with self.assertRaises(ValueError):manager.image('a'*32) + def test_restart_marks_active_job_interrupted(self): + with tempfile.TemporaryDirectory() as d: + (Path(d)/'status.json').write_text(json.dumps(dict(id='test',state='running'))) + manager=ImageTests(d,Mock());self.assertEqual(manager.status()['job']['state'],'interrupted') + def test_cancel_terminates_only_owned_process(self): + import subprocess,sys + with tempfile.TemporaryDirectory() as d: + manager=ImageTests(d,Mock()) + child=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'],start_new_session=True) + manager.process=child + try: + self.assertTrue(manager.stop()['cancellation_requested']);self.assertIsNotNone(child.poll()) + finally: + if child.poll() is None:child.kill();child.wait() + def test_busy_job_does_not_start_second_process(self): + with tempfile.TemporaryDirectory() as d,patch('image_test.subprocess.Popen') as spawn: + manager=ImageTests(d,Mock());manager.job={'state':'running'} + with self.assertRaises(ValueError):manager.start('profile','hello') + spawn.assert_not_called() diff --git a/test_server.py b/test_server.py index e53b9c7..6ebde84 100644 --- a/test_server.py +++ b/test_server.py @@ -52,6 +52,14 @@ class Tests(unittest.TestCase): 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('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})) + self.assertEqual(error.exception.code,401);error.exception.close() + with self.assertRaises(urllib.error.HTTPError) as error:self.request('image-tests/image?id=../../auth.json') + self.assertEqual(error.exception.code,404);error.exception.close() + def test_control_guard(self): for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)