Add isolated ComfyUI image playground with real profile-based generation

This commit is contained in:
Mikei386
2026-09-28 18:00:15 +02:00
parent 413906f619
commit c584bbcaa6
18 changed files with 456 additions and 21 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. 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 ## Zugang und API-Token
+43
View File
@@ -89,3 +89,46 @@ Dateiklassifikation (`role`, `role_label`, `profile_eligible`). Erkannte
Textencoder, VAE, Projektoren und Adapter bleiben als Zusatzdateien sichtbar, Textencoder, VAE, Projektoren und Adapter bleiben als Zusatzdateien sichtbar,
bieten aber kein „Profil anlegen“ und können nicht als Hauptmodell gespeichert bieten aber kein „Profil anlegen“ und können nicht als Hauptmodell gespeichert
werden. Bestehende Komponenten-Zuordnungen und Dateien bleiben unverändert. 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=<job-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.
+1 -1
View File
@@ -26,7 +26,7 @@ window.CatalogUI = (() => {
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
} }
function renderLibrary() { function renderLibrary() {
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Laufzeit noch nicht angebunden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${x.profile_eligible===true?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>'; el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${x.profile_eligible===true?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
} }
function renderJob() { function renderJob() {
el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`; el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`;
+13 -3
View File
@@ -1,8 +1,18 @@
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 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 WORKDIR /app
COPY profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ COPY image_test.py 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-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/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+11
View File
@@ -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 Erst nach diesen Prüfungen darf die Dokumentation den Installer als für ein
frisches Debian vollständig geeignet bezeichnen. 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.
+104
View File
@@ -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
+6 -6
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' 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): def run(*args, check=True, interactive=False):
@@ -93,7 +93,7 @@ def launch(config):
base=Path(config['base']) base=Path(config['base'])
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(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', '--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']), '-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', '-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\']"', '--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) free_port(args.port)
parent=base.parent parent=base.parent
while not parent.exists():parent=parent.parent while not parent.exists():parent=parent.parent
if shutil.disk_usage(parent).free < 2*1024**3: if shutil.disk_usage(parent).free < 25*1024**3:
raise RuntimeError('Mindestens 2 GiB freier Speicher für Installation erforderlich.') raise RuntimeError('Mindestens 25 GiB freier Speicher für Installation inklusive Bildlaufzeit erforderlich.')
if not sys.stdin.isatty(): if not sys.stdin.isatty():
raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).') raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).')
image=build() image=build()
@@ -160,7 +160,7 @@ def install(args):
for sub in ('state','models','backups','bootstrap'): for sub in ('state','models','backups','bootstrap'):
(base/sub).mkdir(mode=0o700) (base/sub).mkdir(mode=0o700)
os.chown(base/'state',65534,65534) 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) write_manifest(base,config)
provision(config) provision(config)
try: try:
@@ -186,7 +186,7 @@ def update(base, rollback=False):
backup_name=config['name']+'-previous' backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns()) 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'] was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name']) if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name) run('docker','rename',config['name'],backup_name)
+19
View File
@@ -0,0 +1,19 @@
window.ImageTestUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><button id="image-generate" disabled>Bild generieren</button><p class="small">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.</p></form>`}<p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;}
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?`<span class="pill">${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}</span><h3>${e(j.profile_name)}</h3><p>${e(j.phase)}</p><p class="small">${e(j.gpu)} · Seed ${e(j.seed)}</p>${j.state==='running'?'<button class="secondary" id="image-cancel">Bildtest abbrechen</button>':''}`:`<h3>${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}</h3><p>Noch kein Bildtest gestartet.</p>`;
el('image-test-result').innerHTML=j?.state==='complete'?`<figure class="card"><img class="generated-image" src="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" alt="Ergebnis des Bildtests"><figcaption><a class="link" href="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" download="athena-${e(j.id)}.png">PNG herunterladen</a></figcaption></figure>`:'';
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=>`<option value="${p.id}">${e(p.name)}${p.runnable?'':' · Komponenten / Laufzeit prüfen'}</option>`).join('')||'<option value="">Zuerst ein Bildprofil anlegen</option>';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};
})();
+166
View File
@@ -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)-current<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
except FileNotFoundError:pass
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
needed=(model['size']+vae['size']+3*GIB)/1024**2
gpus=[g for g in probe() if g['processes']==0 and g['free_mib']>needed]
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()<deadline:
guard();history=request('/history/'+prompt_id)
if prompt_id in history:
result=history[prompt_id]
if result.get('status',{}).get('status_str')=='error':
errors=[m[1] for m in result.get('status',{}).get('messages',[]) if m[0]=='execution_error']
error=errors[0] if errors else {}
# Do not include prompts, tensor dumps or upstream exception messages.
raise ValueError('Generierung fehlgeschlagen: '+str(error.get('node_type','Worker'))+' / '+str(error.get('exception_type','Fehler')))
images=[i for value in result.get('outputs',{}).values() for i in value.get('images',[])]
if images:
image=images[0];path=(directory/'output'/image.get('subfolder','')/image['filename']).resolve()
if not path.is_relative_to((directory/'output').resolve()) or path.stat().st_size>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()
+1 -1
View File
@@ -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="#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 &amp; 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="/image-test-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 &amp; 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>
+1 -1
View File
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network' NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck') 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): def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs) return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
+1 -1
View File
@@ -17,7 +17,7 @@ window.ProfilesUI=(()=>{
el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>'; el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
try{ try{
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return; 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('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)} · ${data.runtime_installed?'installiert':'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-close').onclick=()=>el('profile-editor').replaceChildren();
el('components-refresh')?.addEventListener('click',()=>components(profile)); 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;}}); 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;}});
+5 -2
View File
@@ -21,6 +21,7 @@ QWEN_COMPONENTS={
class Profiles: class Profiles:
def __init__(self,path,catalog): def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() 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 [] self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
def status(self): def status(self):
with self.lock: with self.lock:
@@ -35,7 +36,9 @@ class Profiles:
try:self._component(p['model'],role,p.get('components',{}).get(role)) 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:p['blockers'].insert(0,info['label']+' fehlt oder ist noch nicht zugeordnet.')
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] 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} return {'profiles':rows,'schemas':SCHEMAS}
def save(self,data): def save(self,data):
if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.') if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
@@ -80,7 +83,7 @@ class Profiles:
except ValueError:pass 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] 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)) 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.') 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): 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.') if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict) or set(data['components'])-set(QWEN_COMPONENTS):raise ValueError('Ungültige Komponentenzuordnung.')
+17 -1
View File
@@ -10,6 +10,7 @@ from catalog import Catalog
from profiles import Profiles from profiles import Profiles
from capacity import assess, overview from capacity import assess, overview
from runtime import Runtime from runtime import Runtime
from image_test import ImageTests
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from network.client import NetworkClient from network.client import NetworkClient
from network.config import parse_config, ConfigError 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.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.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.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.demo = DemoService()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
self.started = time.time() self.started = time.time()
@@ -265,12 +268,16 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/': if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') 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: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status': 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())) 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': 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])) 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 ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -344,6 +351,14 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError('Ungültige Laufzeitaktion.') raise ValueError('Ungültige Laufzeitaktion.')
except ValueError as exc:return self.respond({'error':str(exc)},400) 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) 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': if self.path == '/api/v1/profiles/components':
try:return self.respond(self.server.profiles.assign(self.read_json())) try:return self.respond(self.server.profiles.assign(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -401,6 +416,7 @@ def main():
try: try:
server.serve_forever() server.serve_forever()
finally: finally:
server.image_tests.stop()
server.runtime.stop() server.runtime.stop()
server.catalog.stop() server.catalog.stop()
server.demo.stop() server.demo.stop()
+6 -4
View File
@@ -6,16 +6,18 @@ const Studio=(()=>{
if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';} if(category!==page){category=page;section='discover';}
if(modelId)section='profiles'; if(modelId)section='profiles';
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>`; 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'],...(page==='image'?[['test','Testen']]:[]),['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==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. 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>`; document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(page==='runtime'){RuntimeUI.bind();return;} if(page==='runtime'){RuntimeUI.bind();return;}
if(page==='runtimes'||page==='image-runtime')return; if(page==='image-runtime'){ImageTestUI.bind(true);return;}
if(section==='profiles')ProfilesUI.bind(page,modelId); 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'); 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);}); 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 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 imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung wird mit dem Deck-Installer bereitgestellt. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);} function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile}; return {render,openProfile};
})(); })();
+2
View File
@@ -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}} @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} 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}
+51
View File
@@ -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()
+8
View File
@@ -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})) 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() 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): def test_control_guard(self):
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): 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) with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)