Add isolated ComfyUI image playground with real profile-based generation
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 `<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
@@ -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
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.')
|
||||||
|
|||||||
@@ -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,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};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user