diff --git a/coding-agent/COMFYUI-DEPLOYMENT.md b/coding-agent/COMFYUI-DEPLOYMENT.md index 433d5ff..78db2da 100644 --- a/coding-agent/COMFYUI-DEPLOYMENT.md +++ b/coding-agent/COMFYUI-DEPLOYMENT.md @@ -95,7 +95,9 @@ Oczekiwany HTTP: **200** (po `start_period` healthcheck, ~3 min pierwszy start). ## Modele - Brak auto-download w `compose pull` -- Użytkownik: ComfyUI-Manager w UI lub ręcznie do `/data/apps/comfyui/models/` +- **Server UI** → zakładka **ComfyUI Modele** (`:8091`) — katalog + pobieranie z postępem +- **CLI:** `stacks/server-ui/scripts/comfyui/list-models.sh`, `download-model.sh ` — katalog [`comfyui-models.catalog.yaml`](../stacks/server-ui/comfyui-models.catalog.yaml) +- **ComfyUI-Manager** w UI `:8188` lub ręcznie do `/data/apps/comfyui/models/` - Szacunki VRAM: SDXL ~8–12 GB; Flux ~12–20 GB (mieści się na 24 GB przy zatrzymanym LocalAI) ## Dostęp sieciowy diff --git a/coding-agent/SERVER-UI-CLI.md b/coding-agent/SERVER-UI-CLI.md index 48016a1..1026574 100644 --- a/coding-agent/SERVER-UI-CLI.md +++ b/coding-agent/SERVER-UI-CLI.md @@ -1,7 +1,7 @@ # Server UI — CLI (terminal PTY) **Data:** 2026-07-05 -**Status:** wdrożone (bugfix FitAddon: 2026-07-05 — patrz [SERVER-UI-CLI-BUGFIX.md](SERVER-UI-CLI-BUGFIX.md)) +**Status:** wdrożone (bugfix FitAddon: 2026-07-05; pamięć sesji: 2026-07-05) Zakładka **CLI** w panelu Server UI (`:8091`) — interaktywny shell bash w przeglądarce (PTY + xterm.js). @@ -36,8 +36,17 @@ sequenceDiagram |----------|---------| | Ścieżka | `/api/cli/ws` | | Auth | `?api_key=` (query string) | +| Reattach | `?session_id=` (opcjonalnie, z `sessionStorage`) | | Protokół I/O | surowe bajty terminala (text/binary frames) | | Resize | JSON: `{"type":"resize","cols":120,"rows":40}` | +| Zamknij sesję | JSON: `{"type":"close"}` (klient → serwer) | + +### Handshake sesji (serwer → klient) + +| Payload | Znaczenie | +|---------|-----------| +| `{"type":"session","id":""}` | Nowa sesja — ID zapisane w `sessionStorage` (`server-ui-cli-session-id`) | +| `{"type":"replay","complete":true}` | Koniec replay bufora po reattach | ### Przykład (wscat) @@ -59,7 +68,36 @@ W [`/opt/control-plane/.env`](/opt/control-plane/.env) (opcjonalnie): | `CLI_ENABLED` | `1` | `0` = WebSocket odrzucany | | `CLI_SHELL` | `/bin/bash` | Shell PTY (np. `/bin/bash -l`) | | `CLI_DEFAULT_CWD` | `$HOME` usługi | Katalog startowy | -| `CLI_MAX_SESSIONS` | `5` | Limit równoległych sesji WS | +| `CLI_MAX_SESSIONS` | `5` | Limit równoległych sesji PTY (bash) | +| `CLI_SESSION_TTL` | `300` | Sekundy utrzymania odłączonej sesji przed kill | + +## Pamięć sesji + +- **Przełączanie zakładek** — WebSocket i bash pozostają aktywne (bez `disconnectCli`) +- **F5 / reload** — `session_id` z `sessionStorage` → reattach do tego samego bash (bufor ~256 KB replay) +- **Połącz ponownie** — wysyła `{"type":"close"}`, czyści `sessionStorage`, nowy bash +- **Zamknięcie karty** — serwer utrzymuje PTY przez `CLI_SESSION_TTL`, potem zwalnia slot + +```mermaid +sequenceDiagram + participant Browser + participant SS as sessionStorage + participant WS as WebSocket + participant Mgr as CliSessionManager + participant Bash as bash + + Browser->>WS: connect api_key + WS->>Mgr: create_session + Mgr->>Bash: spawn PTY + Mgr-->>Browser: session id + Browser->>SS: zapisz session_id + Note over Browser,Bash: przełączenie zakładki — WS żyje + Browser->>Browser: F5 reload + Browser->>SS: odczyt session_id + Browser->>WS: connect session_id + WS->>Mgr: attach_session + Mgr-->>Browser: replay + live output +``` ## Uprawnienia POSIX @@ -73,7 +111,7 @@ Server UI działa jako **`tomasz-syn-grzegorza`** (`server-ui.service`): | Plik | Rola | |------|------| -| [`stacks/server-ui/cli_pty.py`](../stacks/server-ui/cli_pty.py) | PTY + asyncio bridge | +| [`stacks/server-ui/cli_pty.py`](../stacks/server-ui/cli_pty.py) | `CliSessionManager` — PTY, detach/attach, bufor outputu | | [`stacks/server-ui/app.py`](../stacks/server-ui/app.py) | WebSocket `/api/cli/ws`, mount `/static` | | [`stacks/server-ui/static/index.html`](../stacks/server-ui/static/index.html) | Zakładka CLI | | [`stacks/server-ui/static/vendor/xterm/`](../stacks/server-ui/static/vendor/xterm/) | xterm.js + FitAddon (vendored; UMD: `new FitAddon.FitAddon()`) | @@ -82,9 +120,10 @@ Server UI działa jako **`tomasz-syn-grzegorza`** (`server-ui.service`): - Pełny shell przez LAN = **wysokie ryzyko** — wymagany `API_KEY` przy `SERVER_UI_HOST=0.0.0.0` - `CLI_ENABLED=0` jako kill switch -- `CLI_MAX_SESSIONS` ogranicza liczbę równoległych bashów +- `CLI_MAX_SESSIONS` ogranicza liczbę równoległych bashów (PTY, nie połączeń WS) - Brak whitelisty komend (właściciel serwera) -- Sesja kończy się przy zamknięciu zakładki / rozłączeniu WS (nowy bash przy reconnect) +- Jawne zamknięcie: przycisk **Połącz ponownie** lub `{"type":"close"}` +- Odłączenie WS (reload) → detach + TTL; reattach w tej samej karcie przeglądarki ## Deploy diff --git a/manual-tutorial/04a-api-key.md b/manual-tutorial/04a-api-key.md index 23a561f..19ea4eb 100644 --- a/manual-tutorial/04a-api-key.md +++ b/manual-tutorial/04a-api-key.md @@ -38,24 +38,26 @@ Na serwerze produkcyjnym (systemd): ## 3. Wyświetl klucz na serwerze -Zaloguj się na serwer (SSH) i wpisz: +**Polecane** — klucz + link + weryfikacja live: + +```bash +bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/show-api-key.sh +``` + +Skrypt czyta **`/opt/control-plane/.env`** (produkcja), weryfikuje klucz na działającym `server-ui` i pokazuje `Weryfikacja live: OK` lub `FAIL`. + +Tylko klucz (ręcznie): ```bash sudo grep ^API_KEY= /opt/control-plane/.env ``` -Przykład wyniku: - -``` -API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -``` - Skopiuj **tylko** część po `=` (bez słowa `API_KEY=`). -Alternatywa — gotowa instrukcja z linkiem: +Diagnostyka (exit 0 = OK): ```bash -bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/show-api-key.sh +bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/verify-api-key.sh ``` --- @@ -75,7 +77,7 @@ Przykład: `http://192.168.100.90:8091/` ### Krok B — wklej klucz 1. U góry strony znajdź pole **API Key** -2. Wklej skopiowany klucz +2. Wklej skopiowany klucz (bez spacji na końcach) 3. Kliknij **Zapisz** ### Krok C — sprawdź @@ -84,6 +86,8 @@ Przykład: `http://192.168.100.90:8091/` 2. Powinno pojawić się: **Klucz poprawny** (zielony tekst) 3. Dopiero teraz używaj Start/Stop, CLI itd. +Jeśli klucz jest zły: kliknij **Wyczyść klucz**, wklej ponownie ze skryptu z kroku 3. + --- ## 5. Szybsza metoda — gotowy link @@ -94,7 +98,7 @@ Zamiast kroków B–C możesz otworzyć od razu: http://:8091/?api_key=TWÓJ_KLUCZ_Z_KROKU_3 ``` -Klucz zapisze się w przeglądarce automatycznie. Nadal kliknij **Sprawdź klucz** dla pewności. +Klucz zapisze się w przeglądarce i **automatycznie** zweryfikuje (status „Klucz poprawny”). --- @@ -111,10 +115,18 @@ Klucz zapisze się w przeglądarce automatycznie. Nadal kliknij **Sprawdź klucz | # | Sprawdź | Co zrobić | |---|---------|-----------| | 1 | Kliknąłeś **Zapisz**? | Wklej klucz → **Zapisz** → **Sprawdź klucz** | -| 2 | Stary klucz w przeglądarce? | F12 → Application → Local Storage → usuń `server-ui-api-key`, odśwież stronę | -| 3 | Klucz z właściwego pliku? | Tylko `sudo grep ^API_KEY= /opt/control-plane/.env` | -| 4 | Panel po restarcie? | `sudo systemctl restart server-ui` | -| 5 | Właściwy proces na porcie? | `ss -tlnp \| grep 8091` — powinno być `/opt/server-ui` | +| 2 | Stary klucz w przeglądarce? | Kliknij **Wyczyść klucz** w panelu, lub F12 → Local Storage → usuń `server-ui-api-key` | +| 3 | Klucz z właściwego pliku? | `bash stacks/server-ui/scripts/show-api-key.sh` (nie dev ręcznie) | +| 4 | Klucz działa na serwerze? | `bash stacks/server-ui/scripts/verify-api-key.sh` | +| 5 | Panel po aktualizacji? | `sudo systemctl restart server-ui` | +| 6 | Właściwy proces na porcie? | `ss -tlnp \| grep 8091` — powinno być `/opt/server-ui` | + +Sync prod ↔ dev (gdy klucze się rozjechały): + +```bash +sudo bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/setup-control-plane-env.sh +sudo systemctl restart server-ui +``` --- @@ -133,8 +145,10 @@ bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/show-api-key.sh | Pytanie | Odpowiedź | |---------|-----------| | Gdzie klucz? | `/opt/control-plane/.env` → `API_KEY` | -| Jak wyświetlić? | `sudo grep ^API_KEY= /opt/control-plane/.env` | +| Jak wyświetlić? | `bash stacks/server-ui/scripts/show-api-key.sh` | +| Jak zweryfikować? | `bash stacks/server-ui/scripts/verify-api-key.sh` | | Jak wpisać w UI? | Pole API Key → Zapisz → Sprawdź klucz | +| Stary klucz? | **Wyczyść klucz** w toolbarze | | Gotowy link? | `http://IP:8091/?api_key=KLUCZ` | Powrót: [08 — Server UI](08-server-ui-install.md) · [README](README.md) diff --git a/manual-tutorial/07-comfyui-stack.md b/manual-tutorial/07-comfyui-stack.md index f58ff28..c87c260 100644 --- a/manual-tutorial/07-comfyui-stack.md +++ b/manual-tutorial/07-comfyui-stack.md @@ -210,15 +210,37 @@ cd ../localai && docker compose --profile localai start localai --- -## 10. Modele (później) +## 10. Modele Compose **nie pobiera** modeli automatycznie. -Opcje: +### Server UI (zalecane) -1. **ComfyUI-Manager** w UI — instalacja węzłów i modeli +1. Panel **Server UI** (`http://:8091`) → zakładka **ComfyUI Modele** +2. Wpisz API Key (rozdział 08) → wybierz model → **Pobierz** +3. Pliki lądują w `/data/apps/comfyui/models/` (bind mount — przetrwają restart kontenera) + +**Sekcja „Wymagane przez workflow”** skanuje `/data/apps/comfyui/workflows/*.json` i pokazuje brakujące pliki (checkpoint, LoRA, VAE itd.). URL pobierania jest brany z ComfyUI-Manager, gdy model nie ma wpisu w `stacks/server-ui/comfyui-models.catalog.yaml`. Zapis workflow w ComfyUI (`:8188`) automatycznie aktualizuje listę po odświeżeniu Server UI. + +Przykład testowy: `cp manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json /data/apps/comfyui/workflows/` + +### Skrypty CLI + +```bash +cd stacks/server-ui/scripts/comfyui +./list-models.sh +./download-model.sh +``` + +Katalog: `stacks/server-ui/comfyui-models.catalog.yaml` + +### Inne opcje + +1. **ComfyUI-Manager** w UI ComfyUI (`:8188`) — modele spoza katalogu repo 2. Ręcznie — pliki `.safetensors` / `.ckpt` do `/data/apps/comfyui/models/checkpoints/` +Opcjonalnie w `.env`: `HF_TOKEN`, `CIVITAI_TOKEN` (dla wybranych wpisów katalogu). + Szacunki VRAM (przy zatrzymanym LocalAI): | Model | VRAM (orientacyjnie) | diff --git a/manual-tutorial/10-server-ui-cli.md b/manual-tutorial/10-server-ui-cli.md index f0a8c58..52470b3 100644 --- a/manual-tutorial/10-server-ui-cli.md +++ b/manual-tutorial/10-server-ui-cli.md @@ -76,8 +76,11 @@ sudo ls /root | Co | Wyjaśnienie | |----|-------------| | Nie jesteś rootem | Panel działa jako Twój użytkownik — jak zwykłe SSH | -| Nowa sesja po reconnect | Zamknięcie zakładki / **Połącz ponownie** = nowy bash | -| Limit sesji | Domyślnie max 5 równoległych terminali (wszystkie karty) | +| Sesja żyje między zakładkami | Przełączenie na Stacki/Pliki **nie** zabija basha | +| F5 w tej samej karcie | Sesja wraca (cwd, procesy) — przez `sessionStorage` | +| Nowa sesja celowo | **Połącz ponownie** = świeży bash | +| Limit sesji | Domyślnie max 5 równoległych bashów (wszystkie karty) | +| Wygaśnięcie | Po zamknięciu karty bash żyje ~5 min (`CLI_SESSION_TTL`), potem wygasa | | Wyłączenie CLI | Admin może ustawić `CLI_ENABLED=0` w `/opt/control-plane/.env` | --- @@ -98,7 +101,9 @@ sudo ls /root 2. `echo test-cli-ok` → widzisz `test-cli-ok` 3. `docker ps` → lista kontenerów 4. Zmień rozmiar okna — prompt nie „łamie się” dziwnie -5. Przełącz na **Stacki** i wróć do **CLI** — nowa sesja (to normalne) +5. Uruchom `sleep 60`, przełącz na **Stacki**, wróć do **CLI** — `sleep` nadal działa +6. `cd /tmp`, odśwież stronę (F5) — po **Przywrócono sesję** jesteś w `/tmp` +7. **Połącz ponownie** — nowy bash (np. `pwd` → katalog domowy) --- diff --git a/manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json b/manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json new file mode 100644 index 0000000..c3c9cdc --- /dev/null +++ b/manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json @@ -0,0 +1,50 @@ +{ + "3": { + "inputs": { + "seed": 42, + "steps": 20, + "cfg": 8, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + }, + "class_type": "KSampler", + "_meta": { "title": "KSampler" } + }, + "4": { + "inputs": { + "ckpt_name": "sd_xl_refiner_1.0_0.9vae.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { "title": "Load Checkpoint" } + }, + "5": { + "inputs": { "width": 1024, "height": 1024, "batch_size": 1 }, + "class_type": "EmptyLatentImage", + "_meta": { "title": "Empty Latent Image" } + }, + "6": { + "inputs": { "text": "a scenic landscape", "clip": ["4", 1] }, + "class_type": "CLIPTextEncode", + "_meta": { "title": "CLIP Text Encode (Prompt)" } + }, + "7": { + "inputs": { "text": "text, watermark", "clip": ["4", 1] }, + "class_type": "CLIPTextEncode", + "_meta": { "title": "CLIP Text Encode (Prompt)" } + }, + "8": { + "inputs": { "samples": ["3", 0], "vae": ["4", 2] }, + "class_type": "VAEDecode", + "_meta": { "title": "VAE Decode" } + }, + "9": { + "inputs": { "filename_prefix": "ComfyUI", "images": ["8", 0] }, + "class_type": "SaveImage", + "_meta": { "title": "Save Image" } + } +} diff --git a/stacks/comfyui/.env.example b/stacks/comfyui/.env.example index 2fba3f0..62abd6c 100644 --- a/stacks/comfyui/.env.example +++ b/stacks/comfyui/.env.example @@ -12,3 +12,9 @@ CUDA_VISIBLE_DEVICES=0 # Extra CLI args passed to ComfyUI (e.g. --fast) CLI_ARGS= + +# Hugging Face token for gated models (optional) +# HF_TOKEN= + +# Civitai API token for civitai catalog entries (optional) +# CIVITAI_TOKEN= diff --git a/stacks/comfyui/README.md b/stacks/comfyui/README.md index d8c3e82..aaedc8f 100644 --- a/stacks/comfyui/README.md +++ b/stacks/comfyui/README.md @@ -1,134 +1,84 @@ # ComfyUI stack -[ComfyUI](https://github.com/comfyanonymous/ComfyUI) — grafowy interfejs do generowania obrazów (Stable Diffusion, Flux, …). Stack oparty na obrazie [`yanwk/comfyui-boot`](https://github.com/YanWenKun/ComfyUI-Docker). - -Zamiast [Stability Matrix](https://github.com/LykosAI/StabilityMatrix) (GUI desktop) używamy ComfyUI w Dockerze — zgodnie z filozofią headless serwera i tutoriala [03b](../../manual-tutorial/03b-system-tools.md). +[ComfyUI](https://github.com/comfyanonymous/ComfyUI) w Dockerze — obraz upstream [`yanwk/comfyui-boot:cu126-slim`](https://github.com/YanWenKun/ComfyUI-Docker). Stack **nie modyfikuje** ComfyUI; to tylko compose + lifecycle. Katalog modeli, pobieranie i skan workflow są w [**Server UI**](../server-ui/README.md) (`:8091` → zakładka **ComfyUI Modele**). ## Porty | Serwis | Port | URL | |--------|------|-----| | ComfyUI web UI | **8188** | `http://HOST:8188` | -| LocalAI (osobny stack) | 8070 | LLM / chat — **nie równolegle z dużym modelem SD** | +| Server UI (modele, stacki) | **8091** | `http://HOST:8091` | -## Jak to działa - -```mermaid -flowchart LR - browser["Przeglądarka :8188"] - comfyui["Kontener comfyui"] - gpu["RTX 3090 Ti"] - models["/data/apps/comfyui/models"] - - browser --> comfyui - comfyui --> gpu - comfyui --> models -``` - -| Element | Opis | -|---------|------| -| Obraz | `yanwk/comfyui-boot:cu126-slim` | -| Konfiguracja | `.env` + `docker-compose.yml` | -| Modele | `/data/apps/comfyui/models` (puste na start — pobierz ręcznie lub przez ComfyUI-Manager) | -| Pierwszy start | Kopiuje ComfyUI do `/data/apps/comfyui/storage/` (~kilka minut) | - -## Struktura +## Struktura (repo) ``` stacks/comfyui/ -├── README.md ├── docker-compose.yml ├── .env.example -├── .gitignore └── scripts/ ├── ensure-dirs.sh ├── pull.sh └── start.sh ``` -Na dysku `/data`: +## Dane na hoście (`/data/apps/comfyui/`) -``` -/data/apps/comfyui/ -├── storage/ # kopia ComfyUI z obrazu (pierwszy start) -├── models/ # checkpoints, LoRA, VAE, … -├── cache/ -│ ├── hf-hub/ -│ └── torch-hub/ -├── input/ -├── output/ -├── custom_nodes/ -└── workflows/ -``` +| Katalog | Zawartość | +|---------|-----------| +| `storage/` | Kopia ComfyUI z obrazu (pierwszy start) | +| `models/` | Checkpointy, LoRA, VAE, … | +| `custom_nodes/` | ComfyUI-Manager i wtyczki | +| `workflows/` | JSON workflow (bind → `user/default/workflows`) | +| `input/`, `output/`, `cache/` | Wejścia, wyniki, cache HF/torch | -## Workflow (bez modelu) +Update kontenera (`docker compose pull` + recreate) **nie usuwa** tych danych — wszystko to bind-mounty. + +## Szybki start ```bash cd stacks/comfyui cp .env.example .env - -./scripts/pull.sh ./scripts/start.sh ``` -Weryfikacja: +Weryfikacja: `curl -f http://127.0.0.1:8188/` + +## Polityka GPU + +RTX 3090 Ti — jeden duży workload GPU naraz (LocalAI **lub** ComfyUI z dużym modelem). `start.sh` ostrzega, gdy `localai` działa. W Server UI: Stop/Start stacków. + +## Update ComfyUI + +Zalecane: Server UI → karta **ComfyUI** → **Update** (pull + recreate gdy działa). + +CLI: ```bash -curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/ -# UI: http://:8188 +./scripts/pull.sh # tylko obraz +./scripts/start.sh # start z nowym obrazem ``` -## Zmienne `.env` +### Checklist po update obrazu -| Zmienna | Opis | Domyślnie | -|---------|------|-----------| -| `DATA_ROOT` | Mount dysku danych | `/data` | -| `COMFYUI_PORT` | Port na hoście | `8188` | -| `COMFYUI_IMAGE` | Obraz Docker | `yanwk/comfyui-boot:cu126-slim` | -| `CUDA_VISIBLE_DEVICES` | GPU | `0` | -| `CLI_ARGS` | Dodatkowe flagi ComfyUI | puste | +1. `curl -f http://127.0.0.1:8188/` — UI ComfyUI działa +2. Server UI → **ComfyUI Modele** → odśwież — skan workflow OK +3. Test pobrania modelu (katalog lub Custom URL) — job `completed` +4. Healthcheck fail → sprawdź, czy obraz ma `curl` na porcie 8188 +5. Pusty skan workflow → zweryfikuj mount `workflows` → `user/default/workflows` w [docs yanwk](https://github.com/YanWenKun/ComfyUI-Docker) -## Polityka GPU (LocalAI ↔ ComfyUI) +## Modele i workflow -RTX 3090 Ti 24 GB — **jeden** duży workload GPU naraz. +- **Server UI** (zalecane): katalog, workflow scan, pobieranie — [`../server-ui/README.md`](../server-ui/README.md) +- **ComfyUI-Manager** w UI `:8188` +- **CLI** (Server UI scripts): `../server-ui/scripts/comfyui/list-models.sh`, `download-model.sh` -Przed startem ComfyUI z dużym modelem (SDXL, Flux): +Przykładowy workflow: [`../../manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json`](../../manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json) ```bash -cd ../localai -docker compose --profile localai stop localai -``` - -Skrypt `start.sh` ostrzega, gdy `localai` jest uruchomiony. - -W Server UI (port 8091) → Stop/Start stack `localai` lub `comfyui` według potrzeb. - -## Modele (później) - -- **ComfyUI-Manager** w UI (custom node w obrazie yanwk) — pobieranie modeli i węzłów -- Ręcznie: pliki do `/data/apps/comfyui/models/checkpoints/` (lub odpowiednie podkatalogi) - -Szacunki VRAM (przy zatrzymanym LocalAI): - -| Model | VRAM (orientacyjnie) | -|-------|----------------------| -| SD 1.5 | ~4–6 GB | -| SDXL | ~8–12 GB | -| Flux | ~12–20 GB | - -## Zarządzanie - -```bash -docker compose --profile comfyui ps -docker compose --profile comfyui logs -f comfyui -docker compose --profile comfyui restart comfyui -docker compose --profile comfyui down +cp manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json /data/apps/comfyui/workflows/ ``` ## Dokumentacja - Tutorial: [manual-tutorial/07-comfyui-stack.md](../../manual-tutorial/07-comfyui-stack.md) -- Research Stability Matrix: [`coding-agent/STABILITYMATRIX-RESEARCH.md`](../../coding-agent/STABILITYMATRIX-RESEARCH.md) -- Wdrożenie: [`coding-agent/COMFYUI-DEPLOYMENT.md`](../../coding-agent/COMFYUI-DEPLOYMENT.md) - -Upstream: [github.com/comfyanonymous/ComfyUI](https://github.com/comfyanonymous/ComfyUI) +- Wdrożenie: [coding-agent/COMFYUI-DEPLOYMENT.md](../../coding-agent/COMFYUI-DEPLOYMENT.md) diff --git a/stacks/comfyui/compose.yaml b/stacks/comfyui/compose.yaml deleted file mode 120000 index 412c257..0000000 --- a/stacks/comfyui/compose.yaml +++ /dev/null @@ -1 +0,0 @@ -docker-compose.yml \ No newline at end of file diff --git a/stacks/comfyui/scripts/ensure-dirs.sh b/stacks/comfyui/scripts/ensure-dirs.sh index 422d91e..8e547f2 100755 --- a/stacks/comfyui/scripts/ensure-dirs.sh +++ b/stacks/comfyui/scripts/ensure-dirs.sh @@ -5,13 +5,25 @@ ensure_comfyui_dirs() { local data_root="${1:-/data}" mkdir -p \ "${data_root}/apps/comfyui/storage" \ - "${data_root}/apps/comfyui/models" \ + "${data_root}/apps/comfyui/models/checkpoints" \ + "${data_root}/apps/comfyui/models/loras" \ + "${data_root}/apps/comfyui/models/vae" \ + "${data_root}/apps/comfyui/models/controlnet" \ + "${data_root}/apps/comfyui/models/upscale_models" \ + "${data_root}/apps/comfyui/models/clip" \ + "${data_root}/apps/comfyui/models/unet" \ "${data_root}/apps/comfyui/cache/hf-hub" \ "${data_root}/apps/comfyui/cache/torch-hub" \ "${data_root}/apps/comfyui/input" \ "${data_root}/apps/comfyui/output" \ "${data_root}/apps/comfyui/custom_nodes" \ "${data_root}/apps/comfyui/workflows" + + # Host downloads (Server UI / CLI) need write access; container (root) can still write. + local owner="${COMFYUI_DATA_OWNER:-${SUDO_USER:-}}" + if [[ "${EUID}" -eq 0 && -n "${owner}" ]]; then + chown -R "${owner}:${owner}" "${data_root}/apps/comfyui/models" + fi } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then diff --git a/stacks/control-plane/env_loader.py b/stacks/control-plane/env_loader.py index 4cd34cb..d009a9e 100644 --- a/stacks/control-plane/env_loader.py +++ b/stacks/control-plane/env_loader.py @@ -87,6 +87,44 @@ def control_plane_env_paths(stack_dir: Path) -> list[Path]: return paths +def _has_stack_compose(repo_root: Path) -> bool: + stacks = repo_root / "stacks" + if not stacks.is_dir(): + return False + for name in ("compose.yaml", "docker-compose.yml", "docker-compose.yaml"): + if (stacks / "localai" / name).is_file(): + return True + return False + + +def _production_repo_root_candidates() -> list[Path]: + home = Path.home() + return [ + home / "cursor" / "ubuntu-bare-metal", + Path("/data/apps/ubuntu-bare-metal"), + Path("/repo"), + home / "ubuntu-bare-metal", + ] + + +def resolve_repo_root(stack_dir: Path, env: dict[str, str]) -> Path: + """Resolve ubuntu-bare-metal repo root for compose stack paths.""" + for key in ("REPO_ROOT",): + raw = (env.get(key) or os.environ.get(key) or "").strip() + if raw: + candidate = Path(raw).expanduser().resolve() + if _has_stack_compose(candidate): + return candidate + + if _is_production_stack_dir(stack_dir): + for candidate in _production_repo_root_candidates(): + resolved = candidate.expanduser().resolve() + if _has_stack_compose(resolved): + return resolved + + return (stack_dir.parent.parent).resolve() + + def api_key_source(stack_dir: Path, values: dict[str, str]) -> str: """Human-readable hint for logs (no secret values).""" if os.environ.get("API_KEY"): diff --git a/stacks/npmplus/scripts/pull.sh b/stacks/npmplus/scripts/pull.sh new file mode 100755 index 0000000..8fbb8c8 --- /dev/null +++ b/stacks/npmplus/scripts/pull.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +echo "=== NPMPlus — pull image only (no start) ===" +docker compose --profile npmplus pull + +echo "" +echo "Done. Start with: ./scripts/start.sh" diff --git a/stacks/server-ui/README.md b/stacks/server-ui/README.md index 3d79609..d193dc1 100644 --- a/stacks/server-ui/README.md +++ b/stacks/server-ui/README.md @@ -21,6 +21,7 @@ Sprawdzenie: `systemctl status server-ui` lub `docker compose --profile server-u | Server UI | **8091** | `http://HOST:8091` | | CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` | | Pliki (zakładka) | — | ten sam URL, zakładka **Pliki** lub `#files` | +| ComfyUI Modele (zakładka) | — | ten sam URL, zakładka **ComfyUI Modele** lub `#comfyui-models` | | GPU Fan (zakładka) | — | ten sam URL, zakładka **GPU Fan** | | gpu-fan agent API | **18090** | tylko localhost (proxy z Server UI) | @@ -70,10 +71,51 @@ Tutorial: [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutoria Porty edytowalne w UI (zapis do `stacks//.env`). Szczegóły: [`../../coding-agent/SERVER-UI-PORT-CONFIG.md`](../../coding-agent/SERVER-UI-PORT-CONFIG.md) +## Update stacków + +Przycisk **Update** na karcie stacka (Server UI → Stacki): + +1. `docker compose pull` — pobiera najnowszy obraz (timeout do ~10 min). +2. Gdy stack **działa** — `up -d --force-recreate` (krótka przerwa w usłudze). +3. Gdy stack **zatrzymany** — tylko pull; uruchom **Start**, aby użyć nowej wersji. + +Dane użytkownika są na dysku hosta (`/data/apps/*`) i **nie znikają** przy rebuildzie kontenera: + +| Stack | Katalogi danych | +|-------|-----------------| +| LocalAI | `/data/apps/localai/{models,backends,configuration,images,data}` | +| ComfyUI | `/data/apps/comfyui/{models,workflows,custom_nodes,input,output,cache,storage}` | +| vLLM | `/data/apps/vllm/huggingface` (cache Hugging Face) | +| NPMPlus | `/data/apps/npmplus` | + +CLI (pull bez startu): `./scripts/pull.sh` w katalogu danego stacka. + +### Wdrożenie zmian Server UI (prod) + +```bash +rsync -a --delete --exclude '.venv' --exclude '.env' --exclude '__pycache__' \ + ~/cursor/ubuntu-bare-metal/stacks/server-ui/ /opt/server-ui/ +sudo systemctl restart server-ui +``` + + +## ComfyUI integracja + +Logika katalogu modeli, pobierania i skanu workflow jest w **Server UI**, nie w stacku ComfyUI. Stack [`../comfyui/`](../comfyui/) to tylko Docker (compose + start/pull). + +| Plik | Rola | +|------|------| +| `comfyui-models.catalog.yaml` | Kuratorowana lista modeli (sekcja „Katalog repo”) | +| `comfyui_models.py` | API + joby pobierania | +| `workflow_scanner.py` | Skan workflow JSON | +| `scripts/comfyui/*.sh` | CLI pobierania (używane też przez backend) | + +Po update obrazu ComfyUI: checklist w [`../comfyui/README.md`](../comfyui/README.md#checklist-po-update-obrazu). + ## Bezpieczeństwo - Odczyt statusu stacków/logów/GPU — bez klucza (LAN). -- **Start / Stop / Restart** stacków, **GPU Fan**, **Pliki** (CRUD) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`). +- **Start / Stop / Restart / Update** stacków, **GPU Fan**, **Pliki** (CRUD), **ComfyUI Modele** (pobieranie, anulowanie, usuwanie) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`). - Przy `SERVER_UI_HOST=0.0.0.0` klucz jest **wymagany** przy starcie usługi. ## Tryb dev (z repo) @@ -88,6 +130,9 @@ cp ../control-plane/.env.example ../control-plane/.env ``` stacks/server-ui/ ├── app.py +├── comfyui_config.py # stałe ścieżek ComfyUI (container, mounty) +├── comfyui_models.py +├── comfyui-models.catalog.yaml ├── Dockerfile ├── docker-compose.yml ├── compose_runner.py @@ -97,6 +142,10 @@ stacks/server-ui/ ├── static/vendor/xterm/ # xterm.js dla zakładki CLI ├── cli_pty.py └── scripts/ + ├── comfyui/ # pobieranie modeli (CLI + backend Server UI) + │ ├── download-model.sh + │ ├── download-file.sh + │ └── list-models.sh ├── install-control-plane.sh # gpu-fan + server-ui (menu) ├── install.sh # native only ├── install-docker.sh # docker only diff --git a/stacks/server-ui/app.py b/stacks/server-ui/app.py index f68c311..7ffa3cb 100644 --- a/stacks/server-ui/app.py +++ b/stacks/server-ui/app.py @@ -20,6 +20,7 @@ from compose_runner import ( ComposeError, PortConfigError, StackConfig, + find_compose_file, find_running_gpu_stacks, get_container_state, get_logs, @@ -28,8 +29,26 @@ from compose_runner import ( stack_restart, stack_start, stack_stop, + stack_update, +) +from cli_pty import ( + CliSessionLimitError, + get_session_manager, + init_session_manager, + run_pty_session, + sessions_available, +) +from comfyui_models import ( + ComfyUIModelError, + DeleteModelError, + JobConflictError, + JobNotFoundError, + ModelNotFoundError, + disk_usage, + get_download_manager, + init_download_manager, + list_installed, ) -from cli_pty import CliSessionLimitError, run_pty_session, sessions_available from file_explorer import FileExplorer, FileExplorerError from gpu_info import GpuInfoError, query_gpu from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, resolve_agent_url @@ -37,10 +56,19 @@ from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, STACK_DIR = Path(__file__).resolve().parent # Unified control-plane env (see stacks/control-plane/env_loader.py) -for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")): - if _cp not in sys.path and Path(_cp).exists(): - sys.path.insert(0, _cp) -from env_loader import api_key_source, load_control_plane_env # noqa: E402 +_local_env_loader = STACK_DIR / "env_loader.py" +if _local_env_loader.is_file(): + if str(STACK_DIR) not in sys.path: + sys.path.insert(0, str(STACK_DIR)) +else: + for _cp in ( + "/opt/control-plane", + "/repo/stacks/control-plane", + str(STACK_DIR.parent / "control-plane"), + ): + if _cp not in sys.path and Path(_cp).joinpath("env_loader.py").is_file(): + sys.path.insert(0, _cp) +from env_loader import api_key_source, load_control_plane_env, resolve_repo_root # noqa: E402 STATIC_DIR = STACK_DIR / "static" CONFIG_PATH = STACK_DIR / "stacks.yaml" @@ -60,9 +88,7 @@ GPU_FAN_AGENT_URL = resolve_agent_url( ENV.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090"), API_KEY, ) -REPO_ROOT = Path( - ENV.get("REPO_ROOT", str(STACK_DIR.parent.parent)) -).resolve() +REPO_ROOT = resolve_repo_root(STACK_DIR, ENV) STACKS_BASE = REPO_ROOT / "stacks" STACKS: list[StackConfig] = load_stacks(CONFIG_PATH, STACKS_BASE) @@ -76,6 +102,13 @@ CLI_ENABLED = ENV.get("CLI_ENABLED", "1").strip().lower() in ("1", "true", "yes" CLI_SHELL = ENV.get("CLI_SHELL", "/bin/bash") CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~")) CLI_MAX_SESSIONS = int(ENV.get("CLI_MAX_SESSIONS", "5")) +CLI_SESSION_TTL = int(ENV.get("CLI_SESSION_TTL", "300")) + +DATA_ROOT = Path(ENV.get("DATA_ROOT", "/data")) +COMFYUI_JOB_TTL = int(ENV.get("COMFYUI_JOB_TTL", "3600")) + +init_session_manager(session_ttl=CLI_SESSION_TTL, max_sessions=CLI_MAX_SESSIONS) +init_download_manager(REPO_ROOT, DATA_ROOT, job_ttl=COMFYUI_JOB_TTL) app = FastAPI(title="Server UI", version="1.0.0") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @@ -104,6 +137,15 @@ class AuthVerifyBody(BaseModel): api_key: str = "" +class ComfyUIDownloadBody(BaseModel): + model_id: str = Field(min_length=1) + + +class ComfyUIFileDownloadBody(BaseModel): + url: str = Field(min_length=1) + relative_path: str = Field(min_length=1) + + def get_stack(stack_id: str) -> StackConfig: stack = STACK_BY_ID.get(stack_id) if not stack: @@ -135,6 +177,22 @@ def require_file_auth(request: Request) -> None: require_mutation_auth(request) +def require_comfyui_auth(request: Request) -> None: + require_file_auth(request) + + +def _comfyui_exc_to_http(exc: ComfyUIModelError) -> HTTPException: + if isinstance(exc, ModelNotFoundError): + return HTTPException(status_code=404, detail=str(exc)) + if isinstance(exc, JobNotFoundError): + return HTTPException(status_code=404, detail=str(exc)) + if isinstance(exc, JobConflictError): + return HTTPException(status_code=409, detail=str(exc)) + if isinstance(exc, DeleteModelError): + return HTTPException(status_code=403, detail=str(exc)) + return HTTPException(status_code=500, detail=str(exc)) + + def _cli_ws_auth_ok(websocket: WebSocket) -> bool: if not _is_lan_bind() or not API_KEY: return True @@ -253,6 +311,16 @@ def api_restart(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict return {"ok": True, "action": "restart", "stack_id": stack_id, "output": output} +@app.post("/api/stacks/{stack_id}/update") +def api_update(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]: + stack = get_stack(stack_id) + try: + output = stack_update(stack) + except ComposeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"ok": True, "action": "update", "stack_id": stack_id, "output": output} + + @app.patch("/api/stacks/{stack_id}/port") def api_set_port( stack_id: str, @@ -313,6 +381,109 @@ async def api_gpu_fan_proxy( return Response(content=payload, status_code=status, media_type=media_type) +@app.get("/api/comfyui/models") +def api_comfyui_models(_: None = Depends(require_comfyui_auth)) -> dict[str, Any]: + mgr = get_download_manager() + try: + payload = mgr.list_models_response() + payload["disk"] = disk_usage(mgr.data_root) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return payload + + +@app.get("/api/comfyui/models/installed") +def api_comfyui_models_installed(_: None = Depends(require_comfyui_auth)) -> dict[str, Any]: + mgr = get_download_manager() + return {"installed": list_installed(mgr.data_root), "data_root": str(mgr.data_root)} + + +@app.post("/api/comfyui/models/download") +def api_comfyui_models_download( + body: ComfyUIDownloadBody, + _: None = Depends(require_mutation_auth), +) -> dict[str, Any]: + mgr = get_download_manager() + model_id = body.model_id.strip() + try: + if model_id.startswith("wf:"): + job = mgr.start_workflow_download(model_id) + else: + job = mgr.start_download(model_id) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return {"ok": True, "job": job.to_dict()} + + +@app.post("/api/comfyui/models/download-file") +def api_comfyui_models_download_file( + body: ComfyUIFileDownloadBody, + _: None = Depends(require_mutation_auth), +) -> dict[str, Any]: + mgr = get_download_manager() + try: + job = mgr.start_file_download( + body.url.strip(), + body.relative_path.strip(), + ) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return {"ok": True, "job": job.to_dict()} + + +@app.get("/api/comfyui/models/jobs/{job_id}") +def api_comfyui_models_job( + job_id: str, + _: None = Depends(require_comfyui_auth), +) -> dict[str, Any]: + mgr = get_download_manager() + try: + job = mgr.get_job(job_id) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return {"job": job.to_dict()} + + +@app.delete("/api/comfyui/models/jobs/{job_id}") +def api_comfyui_models_job_cancel( + job_id: str, + cleanup: bool = Query(default=True), + _: None = Depends(require_mutation_auth), +) -> dict[str, Any]: + mgr = get_download_manager() + try: + job = mgr.cancel_job(job_id, cleanup=cleanup) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return {"ok": True, "job": job.to_dict()} + + +@app.delete("/api/comfyui/models/file") +def api_comfyui_models_delete_file( + relative_path: str = Query(min_length=1), + _: None = Depends(require_mutation_auth), +) -> dict[str, Any]: + mgr = get_download_manager() + try: + result = mgr.delete_model_file(relative_path.strip()) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return {"ok": True, **result} + + +@app.delete("/api/comfyui/models/{model_id}") +def api_comfyui_models_delete( + model_id: str, + _: None = Depends(require_mutation_auth), +) -> dict[str, Any]: + mgr = get_download_manager() + try: + result = mgr.delete_model(model_id.strip()) + except ComfyUIModelError as exc: + raise _comfyui_exc_to_http(exc) from exc + return {"ok": True, **result} + + @app.get("/api/files") def api_files_list( path: str = Query(default="/"), @@ -400,7 +571,13 @@ async def api_cli_ws(websocket: WebSocket) -> None: await websocket.close(code=1008, reason="CLI disabled") return - if not sessions_available(CLI_MAX_SESSIONS): + session_id = websocket.query_params.get("session_id", "").strip() or None + + if session_id: + if get_session_manager().get(session_id) is None: + await websocket.close(code=1008, reason="Session expired") + return + elif not sessions_available(CLI_MAX_SESSIONS): await websocket.close( code=1008, reason=f"Limit sesji CLI ({CLI_MAX_SESSIONS}) — spróbuj później", @@ -416,11 +593,27 @@ async def api_cli_ws(websocket: WebSocket) -> None: cwd=CLI_DEFAULT_CWD, env=os.environ.copy(), max_sessions=CLI_MAX_SESSIONS, + session_id=session_id, + session_ttl=CLI_SESSION_TTL, ) except CliSessionLimitError: await websocket.close(code=1008, reason="Session limit") +def _validate_stacks(stacks: list[StackConfig]) -> None: + for stack in stacks: + compose_dir = stack.path + if find_compose_file(compose_dir) is None: + log.error( + "Stack %s: brak compose w %s (REPO_ROOT=%s). " + "Ustaw REPO_ROOT w /opt/control-plane/.env i zrestartuj server-ui.", + stack.id, + compose_dir, + REPO_ROOT, + ) + sys.exit(1) + + def main() -> None: if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY: log.error( @@ -433,7 +626,11 @@ def main() -> None: log.error("No stacks loaded from %s", CONFIG_PATH) sys.exit(1) + _validate_stacks(STACKS) + log.info("Repo root: %s", REPO_ROOT) + if STACKS: + log.info("First stack compose_dir: %s", STACKS[0].compose_dir) log.info("Stacks: %s", ", ".join(s.id for s in STACKS)) log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL) if API_KEY: @@ -442,7 +639,13 @@ def main() -> None: else: log.warning("API_KEY not set (proxy may fail agent auth on LAN)") if CLI_ENABLED: - log.info("CLI: enabled (shell=%s, cwd=%s, max_sessions=%d)", CLI_SHELL, CLI_DEFAULT_CWD, CLI_MAX_SESSIONS) + log.info( + "CLI: enabled (shell=%s, cwd=%s, max_sessions=%d, session_ttl=%ds)", + CLI_SHELL, + CLI_DEFAULT_CWD, + CLI_MAX_SESSIONS, + CLI_SESSION_TTL, + ) else: log.info("CLI: disabled (CLI_ENABLED=0)") log.info("Web UI at http://%s:%d", HOST if HOST != "0.0.0.0" else "0.0.0.0", PORT) diff --git a/stacks/server-ui/cli_pty.py b/stacks/server-ui/cli_pty.py index c7149e5..0077eb8 100644 --- a/stacks/server-ui/cli_pty.py +++ b/stacks/server-ui/cli_pty.py @@ -12,6 +12,8 @@ import shlex import signal import struct import termios +import uuid +from dataclasses import dataclass, field from typing import Any from starlette.websockets import WebSocket, WebSocketDisconnect @@ -19,15 +21,17 @@ from starlette.websockets import WebSocket, WebSocketDisconnect log = logging.getLogger(__name__) TIOCSWINSZ = termios.TIOCSWINSZ - -_active_sessions = 0 -_session_lock = asyncio.Lock() +OUTPUT_BUFFER_MAX = 256 * 1024 class CliSessionLimitError(Exception): pass +class CliSessionNotFoundError(Exception): + pass + + def _set_winsize(fd: int, rows: int, cols: int) -> None: winsize = struct.pack("HHHH", rows, cols, 0, 0) fcntl.ioctl(fd, TIOCSWINSZ, winsize) @@ -38,25 +42,6 @@ def _parse_shell(shell: str) -> list[str]: return parts if parts else ["/bin/bash"] -async def _read_pty_loop( - master_fd: int, - websocket: WebSocket, - running: asyncio.Event, -) -> None: - loop = asyncio.get_running_loop() - while running.is_set(): - try: - data = await loop.run_in_executor(None, os.read, master_fd, 4096) - except OSError: - break - if not data: - break - try: - await websocket.send_bytes(data) - except Exception: - break - - async def _kill_process(process: asyncio.subprocess.Process) -> None: if process.returncode is not None: return @@ -74,34 +59,71 @@ async def _kill_process(process: asyncio.subprocess.Process) -> None: await process.wait() -def sessions_available(max_sessions: int) -> bool: - return _active_sessions < max_sessions - - -async def run_pty_session( - websocket: WebSocket, - *, - shell: str, - cwd: str, - env: dict[str, str], - max_sessions: int, -) -> None: - global _active_sessions - - async with _session_lock: - if _active_sessions >= max_sessions: - raise CliSessionLimitError( - f"Limit sesji CLI ({max_sessions}) — spróbuj później" - ) - _active_sessions += 1 - - master_fd: int | None = None - process: asyncio.subprocess.Process | None = None +@dataclass +class CliSession: + id: str + master_fd: int + process: asyncio.subprocess.Process + cols: int = 80 + rows: int = 24 + ws: WebSocket | None = None + output_buffer: bytearray = field(default_factory=bytearray) + running: asyncio.Event = field(default_factory=asyncio.Event) read_task: asyncio.Task[None] | None = None - running = asyncio.Event() - running.set() + detach_timer: asyncio.Task[None] | None = None + closed: bool = False + + def append_output(self, data: bytes) -> None: + self.output_buffer.extend(data) + overflow = len(self.output_buffer) - OUTPUT_BUFFER_MAX + if overflow > 0: + del self.output_buffer[:overflow] + + async def send_to_ws(self, data: bytes) -> bool: + if not self.ws: + return False + try: + await self.ws.send_bytes(data) + return True + except Exception: + return False + + async def send_json(self, obj: dict[str, Any]) -> bool: + if not self.ws: + return False + try: + await self.ws.send_text(json.dumps(obj)) + return True + except Exception: + return False + + +class CliSessionManager: + def __init__(self, *, session_ttl: int = 300, max_sessions: int = 5) -> None: + self.session_ttl = session_ttl + self.max_sessions = max_sessions + self._sessions: dict[str, CliSession] = {} + self._lock = asyncio.Lock() + + def sessions_available(self) -> bool: + return len(self._sessions) < self.max_sessions + + def get(self, session_id: str) -> CliSession | None: + return self._sessions.get(session_id) + + async def create_session( + self, + *, + shell: str, + cwd: str, + env: dict[str, str], + ) -> CliSession: + async with self._lock: + if len(self._sessions) >= self.max_sessions: + raise CliSessionLimitError( + f"Limit sesji CLI ({self.max_sessions}) — spróbuj później" + ) - try: master_fd, slave_fd = pty.openpty() shell_cmd = _parse_shell(shell) proc_env = {**env, "TERM": "xterm-256color"} @@ -116,59 +138,226 @@ async def run_pty_session( preexec_fn=os.setsid, ) os.close(slave_fd) - slave_fd = -1 - read_task = asyncio.create_task(_read_pty_loop(master_fd, websocket, running)) + session = CliSession( + id=str(uuid.uuid4()), + master_fd=master_fd, + process=process, + ) + session.running.set() - while True: + async with self._lock: + self._sessions[session.id] = session + + session.read_task = asyncio.create_task(self._read_pty_loop(session)) + log.info("CLI session created: %s", session.id) + return session + + async def _read_pty_loop(self, session: CliSession) -> None: + loop = asyncio.get_running_loop() + while session.running.is_set() and not session.closed: + try: + data = await loop.run_in_executor(None, os.read, session.master_fd, 4096) + except OSError: + break + if not data: + break + session.append_output(data) + await session.send_to_ws(data) + if session.process.returncode is not None: + break + + if not session.closed: + log.info("CLI session ended (shell exit): %s", session.id) + await self.close_session(session.id) + + async def attach_session(self, session_id: str, websocket: WebSocket) -> CliSession: + session = self.get(session_id) + if session is None or session.closed: + raise CliSessionNotFoundError(session_id) + + await self._cancel_detach_timer(session) + + if session.ws is not None and session.ws is not websocket: + try: + await session.ws.close() + except Exception: + pass + + session.ws = websocket + + if session.output_buffer: + await session.send_to_ws(bytes(session.output_buffer)) + + await session.send_json({"type": "replay", "complete": True}) + _set_winsize(session.master_fd, session.rows, session.cols) + log.info("CLI session reattached: %s", session.id) + return session + + async def detach_session(self, session_id: str) -> None: + session = self.get(session_id) + if session is None or session.closed: + return + session.ws = None + await self._schedule_detach_timer(session) + log.info("CLI session detached (TTL %ds): %s", self.session_ttl, session.id) + + async def close_session(self, session_id: str) -> None: + async with self._lock: + session = self._sessions.pop(session_id, None) + if session is None or session.closed: + return + + session.closed = True + session.running.clear() + await self._cancel_detach_timer(session) + + if session.read_task is not None: + session.read_task.cancel() + try: + await session.read_task + except asyncio.CancelledError: + pass + + await _kill_process(session.process) + try: + os.close(session.master_fd) + except OSError: + pass + + if session.ws is not None: + try: + await session.ws.close() + except Exception: + pass + + log.info("CLI session closed: %s", session_id) + + async def _cancel_detach_timer(self, session: CliSession) -> None: + if session.detach_timer is not None: + session.detach_timer.cancel() + try: + await session.detach_timer + except asyncio.CancelledError: + pass + session.detach_timer = None + + async def _schedule_detach_timer(self, session: CliSession) -> None: + await self._cancel_detach_timer(session) + + async def _expire() -> None: + try: + await asyncio.sleep(self.session_ttl) + except asyncio.CancelledError: + return + log.info("CLI session expired after detach TTL: %s", session.id) + await self.close_session(session.id) + + session.detach_timer = asyncio.create_task(_expire()) + + async def handle_resize(self, session: CliSession, rows: int, cols: int) -> None: + session.rows = rows + session.cols = cols + _set_winsize(session.master_fd, rows, cols) + + async def write_input(self, session: CliSession, payload: bytes) -> None: + try: + os.write(session.master_fd, payload) + except OSError: + await self.close_session(session.id) + + +_manager: CliSessionManager | None = None + + +def init_session_manager(*, session_ttl: int = 300, max_sessions: int = 5) -> CliSessionManager: + global _manager + _manager = CliSessionManager(session_ttl=session_ttl, max_sessions=max_sessions) + return _manager + + +def get_session_manager() -> CliSessionManager: + if _manager is None: + raise RuntimeError("CLI session manager not initialized") + return _manager + + +def sessions_available(max_sessions: int) -> bool: + if _manager is not None: + return _manager.sessions_available() + return True + + +async def _handle_ws_input(session: CliSession, message: dict[str, Any], mgr: CliSessionManager) -> bool: + """Process one WS message. Returns False to end the connection loop.""" + msg_type = message.get("type") + if msg_type == "websocket.disconnect": + return False + + if message.get("bytes"): + await mgr.write_input(session, message["bytes"]) + return True + + if message.get("text"): + text = message["text"] + if text.startswith("{"): + try: + obj: dict[str, Any] = json.loads(text) + cmd = obj.get("type") + if cmd == "resize": + rows = int(obj.get("rows", 24)) + cols = int(obj.get("cols", 80)) + await mgr.handle_resize(session, rows, cols) + return True + if cmd == "close": + await mgr.close_session(session.id) + return False + except (json.JSONDecodeError, TypeError, ValueError): + pass + await mgr.write_input(session, text.encode("utf-8")) + + if session.closed or session.process.returncode is not None: + return False + return True + + +async def run_pty_session( + websocket: WebSocket, + *, + shell: str, + cwd: str, + env: dict[str, str], + max_sessions: int, + session_id: str | None = None, + session_ttl: int = 300, +) -> None: + if _manager is None: + init_session_manager(session_ttl=session_ttl, max_sessions=max_sessions) + mgr = get_session_manager() + + session: CliSession + + if session_id: + try: + session = await mgr.attach_session(session_id, websocket) + except CliSessionNotFoundError: + await websocket.close(code=1008, reason="Session expired") + return + else: + session = await mgr.create_session(shell=shell, cwd=cwd, env=env) + session.ws = websocket + await websocket.send_text(json.dumps({"type": "session", "id": session.id})) + _set_winsize(session.master_fd, session.rows, session.cols) + + try: + while not session.closed: message = await websocket.receive() - msg_type = message.get("type") - if msg_type == "websocket.disconnect": + if not await _handle_ws_input(session, message, mgr): break - - payload: bytes | None = None - if message.get("bytes"): - payload = message["bytes"] - elif message.get("text"): - text = message["text"] - if text.startswith("{"): - try: - obj: dict[str, Any] = json.loads(text) - if obj.get("type") == "resize": - rows = int(obj.get("rows", 24)) - cols = int(obj.get("cols", 80)) - if master_fd is not None: - _set_winsize(master_fd, rows, cols) - continue - except (json.JSONDecodeError, TypeError, ValueError): - pass - payload = text.encode("utf-8") - - if payload and master_fd is not None: - try: - os.write(master_fd, payload) - except OSError: - break - - if process.returncode is not None: - break - except WebSocketDisconnect: pass finally: - running.clear() - if read_task is not None: - read_task.cancel() - try: - await read_task - except asyncio.CancelledError: - pass - if process is not None: - await _kill_process(process) - if master_fd is not None: - try: - os.close(master_fd) - except OSError: - pass - async with _session_lock: - _active_sessions = max(0, _active_sessions - 1) + if not session.closed: + if session.ws is websocket: + session.ws = None + await mgr.detach_session(session.id) diff --git a/stacks/server-ui/comfyui-models.catalog.yaml b/stacks/server-ui/comfyui-models.catalog.yaml new file mode 100644 index 0000000..6d84ac7 --- /dev/null +++ b/stacks/server-ui/comfyui-models.catalog.yaml @@ -0,0 +1,4 @@ +# ComfyUI model catalog — managed by Server UI (not the ComfyUI Docker stack). +# Use: stacks/server-ui/scripts/comfyui/list-models.sh +# stacks/server-ui/scripts/comfyui/download-model.sh + diff --git a/stacks/server-ui/comfyui_config.py b/stacks/server-ui/comfyui_config.py new file mode 100644 index 0000000..12b0d62 --- /dev/null +++ b/stacks/server-ui/comfyui_config.py @@ -0,0 +1,53 @@ +"""Shared ComfyUI paths and constants for Server UI integration.""" + +from __future__ import annotations + +from pathlib import Path + +COMFYUI_CONTAINER = "comfyui" +CONTAINER_MODELS_ROOT = "/root/ComfyUI/models" +CONTAINER_WORKFLOWS_ROOT = "/root/ComfyUI/user/default/workflows" +HOST_DATA_SUBDIR = "apps/comfyui" +STACK_ID = "comfyui" +CATALOG_FILENAME = "comfyui-models.catalog.yaml" + + +def server_ui_root(base: Path | None = None) -> Path: + return base or Path(__file__).resolve().parent + + +def catalog_path(base: Path | None = None) -> Path: + return server_ui_root(base) / CATALOG_FILENAME + + +def scripts_dir(base: Path | None = None) -> Path: + return server_ui_root(base) / "scripts" / "comfyui" + + +def stack_env_path(repo_root: Path) -> Path: + return repo_root / "stacks" / "comfyui" / ".env" + + +def host_models_root(data_root: Path) -> Path: + return data_root / HOST_DATA_SUBDIR / "models" + + +def host_workflows_dir(data_root: Path) -> Path: + return data_root / HOST_DATA_SUBDIR / "workflows" + + +def host_custom_nodes_dir(data_root: Path) -> Path: + return data_root / HOST_DATA_SUBDIR / "custom_nodes" + + +def container_model_path(relative: str) -> str: + return f"{CONTAINER_MODELS_ROOT}/{relative.lstrip('/')}" + + +def container_workflow_path(relative: str) -> str: + return f"{CONTAINER_WORKFLOWS_ROOT}/{relative.lstrip('/')}" + + +def host_to_container_model_path(host_path: Path, data_root: Path) -> str: + rel = host_path.resolve().relative_to(host_models_root(data_root).resolve()) + return container_model_path(rel.as_posix()) diff --git a/stacks/server-ui/comfyui_manager_catalog.py b/stacks/server-ui/comfyui_manager_catalog.py new file mode 100644 index 0000000..897ebed --- /dev/null +++ b/stacks/server-ui/comfyui_manager_catalog.py @@ -0,0 +1,65 @@ +"""Index ComfyUI-Manager model-list.json for filename → download URL lookup.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from comfyui_config import host_custom_nodes_dir + +log = logging.getLogger(__name__) + +MANAGER_REL_PATHS = ( + "custom_nodes/ComfyUI-Manager/model-list.json", + "custom_nodes/ComfyUI-Manager/node_db/new/model-list.json", +) + + +def _manager_list_paths(data_root: Path) -> list[Path]: + base = host_custom_nodes_dir(data_root).parent + paths: list[Path] = [] + for rel in MANAGER_REL_PATHS: + path = base / rel + if path.is_file(): + paths.append(path) + return paths + + +def _score_entry(entry: dict[str, Any]) -> tuple[int, int]: + """Prefer entries with URL and shorter save_path.""" + has_url = 0 if entry.get("url") else 1 + save_path = str(entry.get("save_path") or "") + return (has_url, len(save_path)) + + +def load_manager_index(data_root: Path) -> dict[str, dict[str, Any]]: + """Build filename → best matching ComfyUI-Manager entry.""" + index: dict[str, dict[str, Any]] = {} + for list_path in _manager_list_paths(data_root): + try: + with list_path.open(encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + log.warning("Failed to read ComfyUI-Manager list %s: %s", list_path, exc) + continue + models = data.get("models") if isinstance(data, dict) else None + if not isinstance(models, list): + continue + for entry in models: + if not isinstance(entry, dict): + continue + filename = entry.get("filename") + if not isinstance(filename, str) or not filename: + continue + existing = index.get(filename) + if existing is None or _score_entry(entry) < _score_entry(existing): + index[filename] = entry + return index + + +def lookup_manager_entry( + data_root: Path, filename: str +) -> dict[str, Any] | None: + return load_manager_index(data_root).get(filename) diff --git a/stacks/server-ui/comfyui_models.py b/stacks/server-ui/comfyui_models.py new file mode 100644 index 0000000..169ce43 --- /dev/null +++ b/stacks/server-ui/comfyui_models.py @@ -0,0 +1,858 @@ +"""ComfyUI model catalog and download jobs for Server UI.""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +import threading +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import yaml + +from comfyui_config import ( + COMFYUI_CONTAINER, + catalog_path as comfyui_catalog_path, + host_models_root, + host_to_container_model_path, + host_workflows_dir, + scripts_dir as comfyui_scripts_dir, + stack_env_path as comfyui_stack_env_path, +) +from comfyui_manager_catalog import load_manager_index +from workflow_scanner import find_model_file, scan_workflows + +log = logging.getLogger(__name__) + +WORKFLOW_MODEL_PREFIX = "wf:" + +CATEGORY_DIRS: dict[str, str] = { + "checkpoint": "checkpoints", + "lora": "loras", + "vae": "vae", + "controlnet": "controlnet", + "upscale": "upscale_models", + "latent_upscale": "latent_upscale_models", + "text_encoder": "text_encoders", + "diffusion_models": "diffusion_models", + "clip": "clip", + "unet": "unet", + "unknown": "checkpoints", +} + +LOG_BUFFER_MAX = 256 * 1024 +JOB_TTL_SECONDS = 3600 +PROGRESS_RE = re.compile(r"(\d{1,3})%") + +_job_manager: DownloadJobManager | None = None + + +class ComfyUIModelError(Exception): + pass + + +class ModelNotFoundError(ComfyUIModelError): + pass + + +class JobNotFoundError(ComfyUIModelError): + pass + + +class JobConflictError(ComfyUIModelError): + pass + + +class DeleteModelError(ComfyUIModelError): + pass + + +def models_root(data_root: Path) -> Path: + return host_models_root(data_root) + + +def workflows_dir(data_root: Path) -> Path: + return host_workflows_dir(data_root) + + +def workflow_model_id(filename: str) -> str: + return f"{WORKFLOW_MODEL_PREFIX}{filename}" + + +def is_workflow_model_id(model_id: str) -> bool: + return model_id.startswith(WORKFLOW_MODEL_PREFIX) + + +def resolve_relative_model_path(relative_path: str, data_root: Path) -> Path: + rel = relative_path.strip().lstrip("/") + if not rel or ".." in Path(rel).parts: + raise DeleteModelError(f"Invalid relative model path: {relative_path}") + dest = models_root(data_root) / rel + if not _is_under_models_root(dest, data_root): + raise DeleteModelError(f"Path outside models root: {dest}") + return dest + + +def _validate_download_url(url: str) -> None: + parsed = urlparse(url.strip()) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ComfyUIModelError("URL must start with http:// or https://") + + +def _catalog_by_filename(catalog_path: Path) -> dict[str, dict[str, Any]]: + by_filename: dict[str, dict[str, Any]] = {} + for entry in load_catalog(catalog_path): + filename = entry.get("filename") + model_id = entry.get("id") + if filename and model_id: + by_filename[str(filename)] = entry + return by_filename + + +def build_workflow_required( + catalog_path: Path, + data_root: Path, + active_by_model: dict[str, str], + jobs: dict[str, DownloadJob] | None = None, +) -> tuple[list[dict[str, Any]], int, str]: + """Build workflow-required model list, excluding catalog entries (dedup).""" + jobs = jobs or {} + wf_dir = workflows_dir(data_root) + refs, workflows_scanned = scan_workflows(wf_dir) + catalog_filenames = { + str(e.get("filename", "")) for e in load_catalog(catalog_path) if e.get("filename") + } + manager_index = load_manager_index(data_root) + root = models_root(data_root) + items: list[dict[str, Any]] = [] + + for ref in refs: + if ref.filename in catalog_filenames: + continue + + model_id = workflow_model_id(ref.filename) + found = find_model_file(root, ref.filename) + + source = "none" + download_url: str | None = None + save_path: str | None = None + catalog_id: str | None = None + size_gb: Any = None + note = "" + + catalog_entry = _catalog_by_filename(catalog_path).get(ref.filename) + if catalog_entry: + source = "catalog" + catalog_id = str(catalog_entry.get("id")) + download_url = catalog_entry.get("download_url") + size_gb = catalog_entry.get("size_gb") + note = str(catalog_entry.get("note") or "") + dest = resolve_dest_path(catalog_entry, data_root) + else: + mgr = manager_index.get(ref.filename) + if mgr and mgr.get("url"): + source = "manager" + download_url = str(mgr["url"]) + save_path = str(mgr.get("save_path") or category_dir(ref.category)) + size_str = mgr.get("size") + if isinstance(size_str, str) and size_str.upper().endswith("GB"): + try: + size_gb = float(size_str.upper().replace("GB", "").strip()) + except ValueError: + size_gb = None + note = str(mgr.get("description") or mgr.get("name") or "") + dest = root / save_path / ref.filename + else: + dest = root / category_dir(ref.category) / ref.filename + + if found is not None: + dest = found + + try: + relative_path = dest.relative_to(root).as_posix() + except ValueError: + relative_path = None + active_job: DownloadJob | None = None + job_id = active_by_model.get(model_id) + if job_id and job_id in jobs: + active_job = jobs[job_id] + + state = _model_file_state(dest, size_gb, active_job) + items.append( + { + "id": model_id, + "name": ref.filename, + "filename": ref.filename, + "category": ref.category, + "workflows": ref.workflows, + "source": source, + "downloadable": source in ("catalog", "manager"), + "download_url": download_url, + "save_path": save_path, + "catalog_id": catalog_id, + "relative_path": relative_path, + "note": note, + "size_gb": size_gb, + **state, + } + ) + + return items, workflows_scanned, str(wf_dir) + + +def _expected_bytes(size_gb: Any) -> int | None: + if size_gb is None: + return None + try: + return int(float(size_gb) * 1_000_000_000) + except (TypeError, ValueError): + return None + + +def _is_under_models_root(path: Path, data_root: Path) -> bool: + root = models_root(data_root).resolve() + try: + path.resolve().relative_to(root) + return True + except ValueError: + return False + + +def _file_size(path: Path) -> int: + if not path.is_file(): + return 0 + return path.stat().st_size + + +def category_dir(category: str) -> str: + return CATEGORY_DIRS.get(category, category) + + +def resolve_dest_path(entry: dict[str, Any], data_root: Path) -> Path: + local_path = entry.get("local_path") + if local_path: + return Path(local_path) + category = entry.get("category", "") + filename = entry.get("filename", "") + subdir = category_dir(str(category)) + return data_root / "apps" / "comfyui" / "models" / subdir / filename + + +def load_catalog(catalog_path: Path) -> list[dict[str, Any]]: + if not catalog_path.is_file(): + raise ComfyUIModelError(f"Catalog not found: {catalog_path}") + with catalog_path.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + models = data.get("models") or [] + if not isinstance(models, list): + raise ComfyUIModelError("Invalid catalog: models must be a list") + return models + + +def _model_file_state( + dest: Path, + size_gb: Any, + active_job: DownloadJob | None, +) -> dict[str, Any]: + size_bytes = _file_size(dest) + expected = _expected_bytes(size_gb) + download_info: dict[str, Any] | None = None + if active_job and active_job.status == "running": + download_info = { + "job_id": active_job.id, + "status": active_job.status, + "progress": active_job.progress, + } + + installed = False + partial = False + if size_bytes > 0: + if active_job and active_job.status == "running": + partial = True + elif expected is None or size_bytes >= int(expected * 0.95): + installed = True + else: + partial = True + + return { + "path": str(dest), + "size_bytes": size_bytes, + "expected_bytes": expected, + "installed": installed, + "partial": partial, + "download": download_info, + } + + +def _unlink_model_file(dest: Path, data_root: Path) -> bool: + if not dest.is_file(): + return False + if not _is_under_models_root(dest, data_root): + raise DeleteModelError(f"Refusing to delete path outside models root: {dest}") + + try: + dest.unlink() + if not dest.is_file(): + return True + except OSError as exc: + log.warning("Host unlink failed for %s: %s", dest, exc) + + container_path = host_to_container_model_path(dest, data_root) + try: + result = subprocess.run( + ["docker", "exec", COMFYUI_CONTAINER, "rm", "-f", container_path], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + log.warning("docker exec rm failed: %s", result.stderr.strip()) + except (OSError, subprocess.TimeoutExpired) as exc: + log.warning("docker exec rm error for %s: %s", container_path, exc) + + return not dest.is_file() + + +def list_models(catalog_path: Path, data_root: Path) -> list[dict[str, Any]]: + """Basic catalog list without active download jobs (legacy helper).""" + return _build_model_list(catalog_path, data_root, active_by_model={}) + + +def _build_model_list( + catalog_path: Path, + data_root: Path, + active_by_model: dict[str, str], + jobs: dict[str, DownloadJob] | None = None, +) -> list[dict[str, Any]]: + jobs = jobs or {} + items: list[dict[str, Any]] = [] + for entry in load_catalog(catalog_path): + model_id = entry.get("id") + if not model_id: + continue + dest = resolve_dest_path(entry, data_root) + active_job: DownloadJob | None = None + job_id = active_by_model.get(str(model_id)) + if job_id and job_id in jobs: + active_job = jobs[job_id] + state = _model_file_state(dest, entry.get("size_gb"), active_job) + items.append( + { + "id": model_id, + "name": entry.get("name", model_id), + "category": entry.get("category", ""), + "source": entry.get("source", ""), + "size_gb": entry.get("size_gb"), + "note": entry.get("note", ""), + "filename": entry.get("filename", dest.name), + **state, + } + ) + return items + + +def list_installed(data_root: Path) -> list[dict[str, Any]]: + models_root = data_root / "apps" / "comfyui" / "models" + if not models_root.is_dir(): + return [] + installed: list[dict[str, Any]] = [] + for path in sorted(models_root.rglob("*")): + if not path.is_file(): + continue + if path.name.startswith("."): + continue + rel = path.relative_to(models_root) + installed.append( + { + "path": str(path), + "relative_path": str(rel), + "category_dir": rel.parts[0] if rel.parts else "", + "filename": path.name, + "size_bytes": path.stat().st_size, + } + ) + return installed + + +def disk_usage(data_root: Path) -> dict[str, Any]: + usage = shutil.disk_usage(data_root) + return { + "path": str(data_root), + "total_bytes": usage.total, + "used_bytes": usage.used, + "free_bytes": usage.free, + } + + +def _parse_progress(line: str, current: int) -> int: + match = PROGRESS_RE.search(line) + if match: + return max(current, min(100, int(match.group(1)))) + lower = line.lower() + if "already exists" in lower: + return 100 + if line.strip() == "Done.": + return 100 + return current + + +@dataclass +class DownloadJob: + id: str + model_id: str + status: str = "running" + progress: int = 0 + log: str = "" + error: str = "" + started_at: float = field(default_factory=time.time) + finished_at: float | None = None + file_dest: Path | None = None + _log_buffer: list[str] = field(default_factory=list) + _process: subprocess.Popen[str] | None = field(default=None, repr=False) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def append_log(self, chunk: str) -> None: + with self._lock: + self._log_buffer.append(chunk) + combined = "".join(self._log_buffer) + if len(combined) > LOG_BUFFER_MAX: + combined = combined[-LOG_BUFFER_MAX:] + self._log_buffer = [combined] + self.log = combined + for line in chunk.splitlines(): + self.progress = _parse_progress(line, self.progress) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "model_id": self.model_id, + "status": self.status, + "progress": self.progress, + "log": self.log, + "error": self.error, + "started_at": self.started_at, + "finished_at": self.finished_at, + } + + +class DownloadJobManager: + def __init__( + self, + catalog_path: Path, + data_root: Path, + download_script: Path, + download_file_script: Path, + stack_env_path: Path | None = None, + job_ttl: int = JOB_TTL_SECONDS, + ) -> None: + self.catalog_path = catalog_path + self.data_root = data_root + self.download_script = download_script + self.download_file_script = download_file_script + self.stack_env_path = stack_env_path + self.job_ttl = job_ttl + self._jobs: dict[str, DownloadJob] = {} + self._active_by_model: dict[str, str] = {} + self._lock = threading.Lock() + + def _catalog_by_id(self) -> dict[str, dict[str, Any]]: + return {str(m["id"]): m for m in load_catalog(self.catalog_path) if m.get("id")} + + def _cleanup_old_jobs(self) -> None: + cutoff = time.time() - self.job_ttl + with self._lock: + expired = [ + jid + for jid, job in self._jobs.items() + if job.finished_at is not None and job.finished_at < cutoff + ] + for jid in expired: + job = self._jobs.pop(jid, None) + if job and job.model_id in self._active_by_model: + if self._active_by_model.get(job.model_id) == jid: + del self._active_by_model[job.model_id] + + def get_job(self, job_id: str) -> DownloadJob: + self._cleanup_old_jobs() + job = self._jobs.get(job_id) + if not job: + raise JobNotFoundError(f"Unknown job: {job_id}") + return job + + def list_models_with_state(self) -> list[dict[str, Any]]: + self._cleanup_old_jobs() + with self._lock: + active = dict(self._active_by_model) + jobs = dict(self._jobs) + return _build_model_list(self.catalog_path, self.data_root, active, jobs) + + def list_models_response(self) -> dict[str, Any]: + self._cleanup_old_jobs() + with self._lock: + active = dict(self._active_by_model) + jobs = dict(self._jobs) + models = _build_model_list(self.catalog_path, self.data_root, active, jobs) + workflow_required, workflows_scanned, workflows_dir_path = build_workflow_required( + self.catalog_path, self.data_root, active, jobs + ) + return { + "models": models, + "workflow_required": workflow_required, + "workflows_scanned": workflows_scanned, + "workflows_dir": workflows_dir_path, + } + + def _dest_for_model(self, model_id: str) -> Path: + catalog = self._catalog_by_id() + if model_id not in catalog: + raise ModelNotFoundError(f"Unknown model id: {model_id}") + dest = resolve_dest_path(catalog[model_id], self.data_root) + if not _is_under_models_root(dest, self.data_root): + raise DeleteModelError(f"Invalid model path: {dest}") + return dest + + def _remove_model_file(self, model_id: str) -> bool: + dest = self._dest_for_model(model_id) + return _unlink_model_file(dest, self.data_root) + + def delete_model(self, model_id: str) -> dict[str, Any]: + self._cleanup_old_jobs() + with self._lock: + job_id = self._active_by_model.get(model_id) + if job_id: + self.cancel_job(job_id, cleanup=True) + if is_workflow_model_id(model_id): + raise ModelNotFoundError( + f"Workflow model {model_id} must be deleted via relative_path" + ) + removed = self._remove_model_file(model_id) + dest = self._dest_for_model(model_id) + return { + "model_id": model_id, + "removed": removed, + "path": str(dest), + } + + def delete_model_file(self, relative_path: str) -> dict[str, Any]: + self._cleanup_old_jobs() + dest = resolve_relative_model_path(relative_path, self.data_root) + model_id = workflow_model_id(dest.name) + with self._lock: + job_id = self._active_by_model.get(model_id) + if job_id: + self.cancel_job(job_id, cleanup=True) + removed = _unlink_model_file(dest, self.data_root) + return { + "relative_path": relative_path, + "removed": removed, + "path": str(dest), + } + + def start_download(self, model_id: str) -> DownloadJob: + self._cleanup_old_jobs() + catalog = self._catalog_by_id() + if model_id not in catalog: + raise ModelNotFoundError(f"Unknown model id: {model_id}") + + entry = catalog[model_id] + dest = resolve_dest_path(entry, self.data_root) + if dest.is_file() and _model_file_state(dest, entry.get("size_gb"), None)["installed"]: + job = DownloadJob( + id=str(uuid.uuid4()), + model_id=model_id, + status="completed", + progress=100, + log=f"Already exists: {dest}\n", + finished_at=time.time(), + ) + with self._lock: + self._jobs[job.id] = job + return job + + with self._lock: + if model_id in self._active_by_model: + active_id = self._active_by_model[model_id] + active = self._jobs.get(active_id) + if active and active.status == "running": + raise JobConflictError( + f"Download already running for {model_id} (job {active_id})" + ) + + if not self.download_script.is_file(): + raise ComfyUIModelError(f"Download script not found: {self.download_script}") + + job = DownloadJob(id=str(uuid.uuid4()), model_id=model_id) + with self._lock: + self._jobs[job.id] = job + self._active_by_model[model_id] = job.id + + thread = threading.Thread( + target=self._run_download, + args=(job,), + name=f"comfyui-download-{job.id[:8]}", + daemon=True, + ) + thread.start() + return job + + def start_file_download( + self, + url: str, + relative_path: str, + *, + model_id: str | None = None, + size_gb: Any = None, + ) -> DownloadJob: + self._cleanup_old_jobs() + _validate_download_url(url) + dest = resolve_relative_model_path(relative_path, self.data_root) + wf_id = model_id or workflow_model_id(dest.name) + + if dest.is_file() and _model_file_state(dest, size_gb, None)["installed"]: + job = DownloadJob( + id=str(uuid.uuid4()), + model_id=wf_id, + status="completed", + progress=100, + log=f"Already exists: {dest}\n", + finished_at=time.time(), + file_dest=dest, + ) + with self._lock: + self._jobs[job.id] = job + return job + + with self._lock: + if wf_id in self._active_by_model: + active_id = self._active_by_model[wf_id] + active = self._jobs.get(active_id) + if active and active.status == "running": + raise JobConflictError( + f"Download already running for {wf_id} (job {active_id})" + ) + + if not self.download_file_script.is_file(): + raise ComfyUIModelError( + f"Download file script not found: {self.download_file_script}" + ) + + job = DownloadJob( + id=str(uuid.uuid4()), + model_id=wf_id, + file_dest=dest, + ) + with self._lock: + self._jobs[job.id] = job + self._active_by_model[wf_id] = job.id + + thread = threading.Thread( + target=self._run_file_download, + args=(job, url, dest), + name=f"comfyui-file-download-{job.id[:8]}", + daemon=True, + ) + thread.start() + return job + + def start_workflow_download(self, model_id: str) -> DownloadJob: + """Download a workflow-required model using catalog or manager metadata.""" + if not is_workflow_model_id(model_id): + raise ModelNotFoundError(f"Not a workflow model id: {model_id}") + + with self._lock: + active = dict(self._active_by_model) + jobs = dict(self._jobs) + items, _, _ = build_workflow_required( + self.catalog_path, self.data_root, active, jobs + ) + entry = next((m for m in items if m["id"] == model_id), None) + if not entry: + raise ModelNotFoundError(f"Unknown workflow model: {model_id}") + if not entry.get("downloadable"): + raise ComfyUIModelError( + f"No download URL for {entry.get('filename')} — add to catalog or ComfyUI-Manager" + ) + + if entry.get("catalog_id"): + return self.start_download(str(entry["catalog_id"])) + + relative_path = entry.get("relative_path") + download_url = entry.get("download_url") + if not relative_path or not download_url: + raise ComfyUIModelError(f"Missing download metadata for {model_id}") + + return self.start_file_download( + str(download_url), + str(relative_path), + model_id=model_id, + size_gb=entry.get("size_gb"), + ) + + def cancel_job(self, job_id: str, *, cleanup: bool = True) -> DownloadJob: + job = self.get_job(job_id) + if job.status == "running": + proc = job._process + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + job.status = "cancelled" + job.finished_at = time.time() + job.append_log("\nCancelled by user.\n") + with self._lock: + if self._active_by_model.get(job.model_id) == job.id: + del self._active_by_model[job.model_id] + if cleanup: + try: + if job.file_dest: + _unlink_model_file(job.file_dest, self.data_root) + job.append_log("Partial file removed.\n") + else: + self._remove_model_file(job.model_id) + job.append_log("Partial file removed.\n") + except ComfyUIModelError as exc: + job.append_log(f"Cleanup failed: {exc}\n") + return job + + def _run_file_download(self, job: DownloadJob, url: str, dest: Path) -> None: + env = os.environ.copy() + env["DATA_ROOT"] = str(self.data_root) + if self.stack_env_path and self.stack_env_path.is_file(): + for line in self.stack_env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in env: + env[key] = value + + cmd = [ + str(self.download_file_script), + "--url", + url, + "--dest", + str(dest), + ] + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + cwd=str(self.download_file_script.parent), + ) + job._process = proc + assert proc.stdout is not None + for line in proc.stdout: + job.append_log(line) + rc = proc.wait() + if job.status == "cancelled": + return + if rc == 0: + job.status = "completed" + job.progress = 100 + else: + job.status = "failed" + job.error = f"Download script exited with code {rc}" + except Exception as exc: + job.status = "failed" + job.error = str(exc) + job.append_log(f"\nERROR: {exc}\n") + log.exception("ComfyUI file download failed for %s", job.model_id) + finally: + job.finished_at = time.time() + job._process = None + with self._lock: + if self._active_by_model.get(job.model_id) == job.id: + del self._active_by_model[job.model_id] + + def _run_download(self, job: DownloadJob) -> None: + env = os.environ.copy() + env["DATA_ROOT"] = str(self.data_root) + if self.stack_env_path and self.stack_env_path.is_file(): + for line in self.stack_env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in env: + env[key] = value + + cmd = [str(self.download_script), job.model_id] + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + cwd=str(self.download_script.parent), + ) + job._process = proc + assert proc.stdout is not None + for line in proc.stdout: + job.append_log(line) + rc = proc.wait() + if job.status == "cancelled": + return + if rc == 0: + job.status = "completed" + job.progress = 100 + else: + job.status = "failed" + job.error = f"Download script exited with code {rc}" + except Exception as exc: + job.status = "failed" + job.error = str(exc) + job.append_log(f"\nERROR: {exc}\n") + log.exception("ComfyUI download failed for %s", job.model_id) + finally: + job.finished_at = time.time() + job._process = None + with self._lock: + if self._active_by_model.get(job.model_id) == job.id: + del self._active_by_model[job.model_id] + + +def init_download_manager( + repo_root: Path, + data_root: Path, + job_ttl: int = JOB_TTL_SECONDS, +) -> DownloadJobManager: + global _job_manager + ui_root = Path(__file__).resolve().parent + catalog_path = comfyui_catalog_path(ui_root) + scripts = comfyui_scripts_dir(ui_root) + download_script = scripts / "download-model.sh" + download_file_script = scripts / "download-file.sh" + stack_env = comfyui_stack_env_path(repo_root) + _job_manager = DownloadJobManager( + catalog_path=catalog_path, + data_root=Path(data_root), + download_script=download_script, + download_file_script=download_file_script, + stack_env_path=stack_env if stack_env.is_file() else None, + job_ttl=job_ttl, + ) + return _job_manager + + +def get_download_manager() -> DownloadJobManager: + if _job_manager is None: + raise ComfyUIModelError("Download manager not initialized") + return _job_manager diff --git a/stacks/server-ui/compose_runner.py b/stacks/server-ui/compose_runner.py index 8bea5ff..9d43c60 100644 --- a/stacks/server-ui/compose_runner.py +++ b/stacks/server-ui/compose_runner.py @@ -230,6 +230,14 @@ def _parse_stacks_yaml_simple(text: str) -> dict[str, Any]: return {"stacks": stacks} +def find_compose_file(stack_dir: Path) -> Path | None: + for name in ("compose.yaml", "docker-compose.yml", "docker-compose.yaml"): + path = stack_dir / name + if path.is_file(): + return path + return None + + def _run_compose( stack: StackConfig, *args: str, @@ -240,10 +248,17 @@ def _run_compose( "compose", "--project-directory", stack.compose_dir, - "--profile", - stack.profile, - *args, ] + compose_file = find_compose_file(stack.path) + if compose_file is not None: + cmd.extend(["-f", str(compose_file)]) + cmd.extend( + [ + "--profile", + stack.profile, + *args, + ] + ) return subprocess.run( cmd, capture_output=True, @@ -389,6 +404,36 @@ def stack_recreate(stack: StackConfig) -> str: return (proc.stdout + proc.stderr).strip() +def stack_update(stack: StackConfig) -> str: + state = get_container_state(stack) + was_running = state["running"] + + pull = _run_compose(stack, "pull", timeout=600) + if pull.returncode != 0: + raise ComposeError( + pull.stderr.strip() or pull.stdout.strip() or "pull failed", + pull.returncode, + pull.stderr, + ) + output = (pull.stdout + pull.stderr).strip() + + if not was_running: + suffix = "\n[server-ui] Obraz pobrany. Stack zatrzymany — użyj Start." + return (output + suffix).strip() if output else suffix.strip() + + proc = _run_compose(stack, "up", "-d", "--force-recreate", timeout=300) + if proc.returncode != 0: + raise ComposeError( + proc.stderr.strip() or proc.stdout.strip() or "recreate failed", + proc.returncode, + proc.stderr, + ) + recreate_out = (proc.stdout + proc.stderr).strip() + if recreate_out: + output = f"{output}\n{recreate_out}".strip() if output else recreate_out + return output + + def set_stack_port( stack: StackConfig, port: int, diff --git a/stacks/server-ui/env_loader.py b/stacks/server-ui/env_loader.py new file mode 100644 index 0000000..d009a9e --- /dev/null +++ b/stacks/server-ui/env_loader.py @@ -0,0 +1,165 @@ +"""Load unified control-plane environment from file + os.environ.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_ENV_OVERRIDE_KEYS = ( + "API_KEY", + "SERVER_UI_HOST", + "SERVER_UI_PORT", + "REPO_ROOT", + "DOCKER_GID", + "GPU_FAN_AGENT_URL", + "GPU_FAN_API_HOST", + "GPU_FAN_API_PORT", + "GPU_FAN_HOST", + "GPU_FAN_PORT", + "CURVE_PATH", + "POLL_INTERVAL", + "GPU_INDEX", + "DRY_RUN", + "FILE_EXPLORER_ROOT", + "FILE_EXPLORER_MAX_BYTES", + "CLI_ENABLED", + "CLI_SHELL", + "CLI_DEFAULT_CWD", + "CLI_MAX_SESSIONS", +) + + +def _parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.is_file(): + return values + try: + text = path.read_text(encoding="utf-8") + except OSError: + # e.g. /opt/control-plane/.env is root-only; systemd still injects via os.environ + return values + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + values[key.strip()] = val.strip() + return values + + +def _merge_into(target: dict[str, str], source: dict[str, str]) -> None: + for key, val in source.items(): + if val: + target[key] = val + + +def _is_production_stack_dir(stack_dir: Path) -> bool: + try: + return stack_dir.resolve().parts[1:2] == ("opt",) + except IndexError: + return False + + +def control_plane_env_paths(stack_dir: Path) -> list[Path]: + """Candidate .env files in load order (earlier = lower priority among files).""" + paths: list[Path] = [] + custom = os.environ.get("CONTROL_PLANE_ENV", "").strip() + if custom: + paths.append(Path(custom)) + + if _is_production_stack_dir(stack_dir): + # Production (/opt/server-ui, /opt/gpu-fan): single canonical file only. + prod = Path("/opt/control-plane/.env") + if prod not in paths: + paths.append(prod) + docker_repo = Path("/repo/stacks/control-plane/.env") + if docker_repo.is_file() and docker_repo not in paths: + paths.append(docker_repo) + return paths + + # Dev (repo stacks/*): stacks/control-plane/.env only — no legacy per-service .env. + repo_control_plane = stack_dir.parent / "control-plane" / ".env" + if repo_control_plane.is_file() and repo_control_plane not in paths: + paths.append(repo_control_plane) + docker_repo = Path("/repo/stacks/control-plane/.env") + if docker_repo.is_file() and docker_repo not in paths: + paths.append(docker_repo) + return paths + + +def _has_stack_compose(repo_root: Path) -> bool: + stacks = repo_root / "stacks" + if not stacks.is_dir(): + return False + for name in ("compose.yaml", "docker-compose.yml", "docker-compose.yaml"): + if (stacks / "localai" / name).is_file(): + return True + return False + + +def _production_repo_root_candidates() -> list[Path]: + home = Path.home() + return [ + home / "cursor" / "ubuntu-bare-metal", + Path("/data/apps/ubuntu-bare-metal"), + Path("/repo"), + home / "ubuntu-bare-metal", + ] + + +def resolve_repo_root(stack_dir: Path, env: dict[str, str]) -> Path: + """Resolve ubuntu-bare-metal repo root for compose stack paths.""" + for key in ("REPO_ROOT",): + raw = (env.get(key) or os.environ.get(key) or "").strip() + if raw: + candidate = Path(raw).expanduser().resolve() + if _has_stack_compose(candidate): + return candidate + + if _is_production_stack_dir(stack_dir): + for candidate in _production_repo_root_candidates(): + resolved = candidate.expanduser().resolve() + if _has_stack_compose(resolved): + return resolved + + return (stack_dir.parent.parent).resolve() + + +def api_key_source(stack_dir: Path, values: dict[str, str]) -> str: + """Human-readable hint for logs (no secret values).""" + if os.environ.get("API_KEY"): + return "systemd/os.environ" + for path in control_plane_env_paths(stack_dir): + parsed = _parse_env_file(path) + if parsed.get("API_KEY"): + return str(path) + if values.get("API_KEY"): + return "merged env" + return "not configured" + + +def load_control_plane_env(stack_dir: Path) -> dict[str, str]: + """Merge env files then apply os.environ (systemd EnvironmentFile wins).""" + values: dict[str, str] = {} + for path in control_plane_env_paths(stack_dir): + _merge_into(values, _parse_env_file(path)) + + for key in _ENV_OVERRIDE_KEYS: + if key in os.environ: + values[key] = os.environ[key] + + values.setdefault("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090") + return values + + +def ensure_control_plane_import_path() -> None: + """Add /opt/control-plane to sys.path for production imports.""" + import sys + + opt = "/opt/control-plane" + if opt not in sys.path: + sys.path.insert(0, opt) + repo = Path(__file__).resolve().parent + repo_str = str(repo) + if repo_str not in sys.path: + sys.path.insert(0, repo_str) diff --git a/stacks/server-ui/scripts/comfyui/catalog-lib.sh b/stacks/server-ui/scripts/comfyui/catalog-lib.sh new file mode 100755 index 0000000..5796d6e --- /dev/null +++ b/stacks/server-ui/scripts/comfyui/catalog-lib.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Shared helpers for comfyui-models.catalog.yaml (simple parser, no PyYAML required). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_UI_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CATALOG_FILE="${CATALOG_FILE:-${SERVER_UI_DIR}/comfyui-models.catalog.yaml}" + +catalog_list_ids() { + grep '^ - id:' "${CATALOG_FILE}" | awk '{print $3}' +} + +catalog_get_field() { + local model_id="$1" + local field="$2" + awk -v id="${model_id}" -v key="${field}:" ' + $0 ~ "^ - id: " id "$" { found=1; next } + found && $0 ~ "^ - id:" { exit } + found && index($0, key) == 5 { sub(/^ [^:]+: /, ""); print; exit } + ' "${CATALOG_FILE}" +} + +catalog_category_dir() { + local category="$1" + case "${category}" in + checkpoint) echo "checkpoints" ;; + lora) echo "loras" ;; + vae) echo "vae" ;; + controlnet) echo "controlnet" ;; + upscale) echo "upscale_models" ;; + latent_upscale) echo "latent_upscale_models" ;; + text_encoder) echo "text_encoders" ;; + diffusion_models) echo "diffusion_models" ;; + clip) echo "clip" ;; + unet) echo "unet" ;; + *) echo "${category}" ;; + esac +} + +catalog_dest_path() { + local model_id="$1" + local data_root="${2:-${DATA_ROOT:-/data}}" + local local_path category filename subdir + local_path="$(catalog_get_field "${model_id}" local_path)" + if [[ -n "${local_path}" ]]; then + echo "${local_path}" + return + fi + category="$(catalog_get_field "${model_id}" category)" + filename="$(catalog_get_field "${model_id}" filename)" + subdir="$(catalog_category_dir "${category}")" + echo "${data_root}/apps/comfyui/models/${subdir}/${filename}" +} + +catalog_ensure_dirs() { + local data_root="${1:-/data}" + mkdir -p \ + "${data_root}/apps/comfyui/storage" \ + "${data_root}/apps/comfyui/models/checkpoints" \ + "${data_root}/apps/comfyui/models/loras" \ + "${data_root}/apps/comfyui/models/vae" \ + "${data_root}/apps/comfyui/models/controlnet" \ + "${data_root}/apps/comfyui/models/upscale_models" \ + "${data_root}/apps/comfyui/models/latent_upscale_models" \ + "${data_root}/apps/comfyui/models/text_encoders" \ + "${data_root}/apps/comfyui/models/diffusion_models" \ + "${data_root}/apps/comfyui/models/clip" \ + "${data_root}/apps/comfyui/models/unet" \ + "${data_root}/apps/comfyui/cache/hf-hub" \ + "${data_root}/apps/comfyui/cache/torch-hub" \ + "${data_root}/apps/comfyui/input" \ + "${data_root}/apps/comfyui/output" \ + "${data_root}/apps/comfyui/custom_nodes" \ + "${data_root}/apps/comfyui/workflows" +} + +catalog_model_downloaded() { + local model_id="$1" + local dest + dest="$(catalog_dest_path "${model_id}")" + [[ -f "${dest}" ]] +} + +load_comfyui_stack_env() { + local repo_root + repo_root="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + local env_file="${repo_root}/stacks/comfyui/.env" + if [[ -f "${env_file}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${env_file}" + set +a + fi +} diff --git a/stacks/server-ui/scripts/comfyui/download-file.sh b/stacks/server-ui/scripts/comfyui/download-file.sh new file mode 100755 index 0000000..4443c20 --- /dev/null +++ b/stacks/server-ui/scripts/comfyui/download-file.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/download-http-lib.sh" + +URL="" +DEST="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --url) + URL="${2:-}" + shift 2 + ;; + --dest) + DEST="${2:-}" + shift 2 + ;; + *) + echo "Usage: $0 --url URL --dest /absolute/path/to/file" + exit 1 + ;; + esac +done + +if [[ -z "${URL}" || -z "${DEST}" ]]; then + echo "Usage: $0 --url URL --dest /absolute/path/to/file" + exit 1 +fi + +load_comfyui_stack_env + +DATA_ROOT="${DATA_ROOT:-/data}" +MODELS_ROOT="${DATA_ROOT}/apps/comfyui/models" + +if [[ "${DEST}" != "${MODELS_ROOT}"/* ]]; then + echo "ERROR: Destination must be under ${MODELS_ROOT}" + exit 1 +fi + +if [[ -f "${DEST}" ]]; then + echo "Already exists: ${DEST}" + echo "" + echo "Done." + exit 0 +fi + +mkdir -p "$(dirname "${DEST}")" +download_http_file "${URL}" "${DEST}" + +if [[ ! -f "${DEST}" ]]; then + echo "ERROR: Download finished but file missing: ${DEST}" + exit 1 +fi + +echo "" +echo "Done." diff --git a/stacks/server-ui/scripts/comfyui/download-http-lib.sh b/stacks/server-ui/scripts/comfyui/download-http-lib.sh new file mode 100755 index 0000000..645cbb0 --- /dev/null +++ b/stacks/server-ui/scripts/comfyui/download-http-lib.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Shared HTTP download helpers for ComfyUI model scripts. +# shellcheck disable=SC2034 +download_http_file() { + local url="$1" + local dest="$2" + local data_root="${DATA_ROOT:-/data}" + local dest_dir + dest_dir="$(dirname "${dest}")" + echo "Downloading ${url} ..." + echo "Target: ${dest}" + + if [[ -w "${dest_dir}" ]]; then + if [[ -n "${HF_TOKEN:-}" ]]; then + if command -v wget >/dev/null 2>&1; then + wget -c --progress=dot:giga \ + --header="Authorization: Bearer ${HF_TOKEN}" \ + -O "${dest}" "${url}" + else + curl -fL -C - \ + -H "Authorization: Bearer ${HF_TOKEN}" \ + -o "${dest}" "${url}" + fi + else + if command -v wget >/dev/null 2>&1; then + wget -c --progress=dot:giga -O "${dest}" "${url}" + else + curl -fL -C - -o "${dest}" "${url}" + fi + fi + return + fi + + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'comfyui'; then + local container_dest="/root/ComfyUI/models${dest#"${data_root}/apps/comfyui/models"}" + echo "Host directory not writable — using comfyui container: ${container_dest}" + if [[ -n "${HF_TOKEN:-}" ]]; then + docker exec comfyui wget -c --progress=dot:giga \ + --header="Authorization: Bearer ${HF_TOKEN}" \ + -O "${container_dest}" "${url}" + else + docker exec comfyui wget -c --progress=dot:giga \ + -O "${container_dest}" "${url}" + fi + return + fi + + echo "ERROR: Cannot write to ${dest_dir}" + echo "Fix: sudo chown -R \$USER:\$USER ${data_root}/apps/comfyui/models" + echo "Or start ComfyUI: cd stacks/comfyui && ./scripts/start.sh" + exit 1 +} diff --git a/stacks/server-ui/scripts/comfyui/download-model.sh b/stacks/server-ui/scripts/comfyui/download-model.sh new file mode 100755 index 0000000..8c72981 --- /dev/null +++ b/stacks/server-ui/scripts/comfyui/download-model.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + echo "" + echo "Available models:" + catalog_list_ids | sed 's/^/ /' + exit 1 +fi + +MODEL_ID="$1" + +if ! catalog_list_ids | grep -qx "${MODEL_ID}"; then + echo "ERROR: Unknown model id: ${MODEL_ID}" + echo "Run: ${SCRIPT_DIR}/list-models.sh" + exit 1 +fi + +load_comfyui_stack_env + +DATA_ROOT="${DATA_ROOT:-/data}" +catalog_ensure_dirs "${DATA_ROOT}" + +SOURCE="$(catalog_get_field "${MODEL_ID}" source)" +NAME="$(catalog_get_field "${MODEL_ID}" name)" +DEST="$(catalog_dest_path "${MODEL_ID}" "${DATA_ROOT}")" + +echo "=== Download: ${NAME} (${MODEL_ID}) ===" +echo "Source: ${SOURCE}" +echo "Target: ${DEST}" +echo "" + +if [[ -f "${DEST}" ]]; then + echo "Already exists: ${DEST}" + echo "" + echo "Done." + exit 0 +fi + +mkdir -p "$(dirname "${DEST}")" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/download-http-lib.sh" + +case "${SOURCE}" in + huggingface) + HF_REPO="$(catalog_get_field "${MODEL_ID}" hf_repo)" + FILENAME="$(catalog_get_field "${MODEL_ID}" filename)" + DOWNLOAD_URL="$(catalog_get_field "${MODEL_ID}" download_url)" + HF_REVISION="$(catalog_get_field "${MODEL_ID}" hf_revision)" + REVISION="${HF_REVISION:-main}" + + if [[ -n "${DOWNLOAD_URL}" ]]; then + URL="${DOWNLOAD_URL}" + elif [[ -n "${HF_REPO}" && -n "${FILENAME}" ]]; then + URL="https://huggingface.co/${HF_REPO}/resolve/${REVISION}/${FILENAME}" + else + echo "ERROR: Catalog entry missing download_url or hf_repo/filename" + exit 1 + fi + + download_http_file "${URL}" "${DEST}" + ;; + url) + DOWNLOAD_URL="$(catalog_get_field "${MODEL_ID}" download_url)" + if [[ -z "${DOWNLOAD_URL}" ]]; then + echo "ERROR: Catalog entry missing download_url" + exit 1 + fi + download_http_file "${DOWNLOAD_URL}" "${DEST}" + ;; + civitai) + CIVITAI_MODEL_ID="$(catalog_get_field "${MODEL_ID}" civitai_model_id)" + if [[ -z "${CIVITAI_MODEL_ID}" ]]; then + echo "ERROR: Catalog entry missing civitai_model_id" + exit 1 + fi + if [[ -z "${CIVITAI_TOKEN:-}" ]]; then + echo "ERROR: CIVITAI_TOKEN not set in stacks/comfyui/.env" + exit 1 + fi + curl -fL \ + -H "Authorization: Bearer ${CIVITAI_TOKEN}" \ + -o "${DEST}" \ + "https://civitai.com/api/download/models/${CIVITAI_MODEL_ID}" + ;; + *) + echo "ERROR: Unknown source: ${SOURCE}" + exit 1 + ;; +esac + +if [[ ! -f "${DEST}" ]]; then + echo "ERROR: Download finished but file missing: ${DEST}" + exit 1 +fi + +echo "" +echo "Done." diff --git a/stacks/server-ui/scripts/comfyui/list-models.sh b/stacks/server-ui/scripts/comfyui/list-models.sh new file mode 100755 index 0000000..9dedaa4 --- /dev/null +++ b/stacks/server-ui/scripts/comfyui/list-models.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" + +load_comfyui_stack_env + +DATA_ROOT="${DATA_ROOT:-/data}" +catalog_ensure_dirs "${DATA_ROOT}" + +echo "=== ComfyUI model catalog ===" +echo "Catalog: ${CATALOG_FILE}" +echo "Data: ${DATA_ROOT}" +echo "" + +printf "%-24s %-12s %-8s %s\n" "ID" "CATEGORY" "ON DISK" "NAME" +printf "%-24s %-12s %-8s %s\n" "----" "--------" "-------" "----" + +while IFS= read -r model_id; do + name="$(catalog_get_field "${model_id}" name)" + category="$(catalog_get_field "${model_id}" category)" + if catalog_model_downloaded "${model_id}"; then + on_disk="yes" + else + on_disk="no" + fi + printf "%-24s %-12s %-8s %s\n" "${model_id}" "${category}" "${on_disk}" "${name}" +done < <(catalog_list_ids) + +echo "" +echo "Download on demand: ${SCRIPT_DIR}/download-model.sh " +echo "Target root: ${DATA_ROOT}/apps/comfyui/models/" diff --git a/stacks/server-ui/scripts/install.sh b/stacks/server-ui/scripts/install.sh index d685a7b..aaf3b10 100755 --- a/stacks/server-ui/scripts/install.sh +++ b/stacks/server-ui/scripts/install.sh @@ -49,6 +49,8 @@ rsync -a --delete \ --exclude '__pycache__' \ "${STACK_DIR}/" "${INSTALL_DIR}/" +install -m 644 "$(dirname "${STACK_DIR}")/control-plane/env_loader.py" "${INSTALL_DIR}/env_loader.py" + python3 -m venv "${INSTALL_DIR}/.venv" "${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q "${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q diff --git a/stacks/server-ui/scripts/print-api-key-instructions.sh b/stacks/server-ui/scripts/print-api-key-instructions.sh index fd4c0e8..491a0de 100755 --- a/stacks/server-ui/scripts/print-api-key-instructions.sh +++ b/stacks/server-ui/scripts/print-api-key-instructions.sh @@ -2,17 +2,34 @@ # Print API key + browser instructions (shared by install scripts and show-api-key.sh). set -euo pipefail -print_api_key_instructions() { - local env_file="${1:-/opt/control-plane/.env}" - local ui_port="${2:-8091}" +_read_api_key() { + local env_file="$1" local api_key="" - local lan_ip="" - if [[ -r "${env_file}" ]]; then api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)" elif [[ -f "${env_file}" ]]; then api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)" fi + if [[ -z "${api_key}" ]]; then + local pid + pid="$(pgrep -f '/opt/server-ui/app.py' 2>/dev/null | head -1 || true)" + if [[ -n "${pid}" && -r "/proc/${pid}/environ" ]]; then + api_key="$(tr '\0' '\n' < "/proc/${pid}/environ" 2>/dev/null | grep '^API_KEY=' | cut -d= -f2- || true)" + fi + fi + printf '%s' "${api_key}" +} + +print_api_key_instructions() { + local env_file="${1:-/opt/control-plane/.env}" + local ui_port="${2:-8091}" + local dev_env="${3:-}" + local api_key="" + local dev_key="" + local lan_ip="" + local verify_ok=0 + + api_key="$(_read_api_key "${env_file}")" lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" [[ -n "${lan_ip}" ]] || lan_ip="" @@ -30,6 +47,33 @@ print_api_key_instructions() { return 1 fi + if [[ -n "${dev_env}" && -f "${dev_env}" ]]; then + dev_key="$(_read_api_key "${dev_env}")" + if [[ -n "${dev_key}" && "${dev_key}" != "${api_key}" ]]; then + echo "OSTRZEŻENIE: klucz dev różni się od produkcji!" + echo " prod: ${env_file}" + echo " dev: ${dev_env}" + echo " Napraw: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh" + echo "" + fi + fi + + if curl -sf -X POST "http://127.0.0.1:${ui_port}/api/auth/verify" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\":\"${api_key}\"}" >/dev/null 2>&1; then + verify_ok=1 + fi + + echo "Plik klucza: ${env_file}" + if [[ "${verify_ok}" -eq 1 ]]; then + echo "Weryfikacja live (server-ui :${ui_port}): OK" + else + echo "Weryfikacja live (server-ui :${ui_port}): FAIL" + echo " Sprawdź: systemctl status server-ui" + echo " Lub: bash stacks/server-ui/scripts/verify-api-key.sh" + fi + echo "" + echo "1. Twój klucz API (skopiuj):" echo "" echo " ${api_key}" @@ -45,13 +89,20 @@ print_api_key_instructions() { echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)" echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan" echo "" + echo "4. Stary klucz w przeglądarce?" + echo " a) W panelu kliknij „Wyczyść klucz”" + echo " b) Lub F12 → Application → Local Storage → usuń server-ui-api-key" + echo " c) Ctrl+F5, potem kroki 2–3" + echo "" echo "Plik klucza (na przyszłość):" echo " sudo grep ^API_KEY= ${env_file}" echo "" echo "══════════════════════════════════════════════" echo "" + + [[ "${verify_ok}" -eq 1 ]] } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}" + print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}" "${3:-}" fi diff --git a/stacks/server-ui/scripts/show-api-key.sh b/stacks/server-ui/scripts/show-api-key.sh index e995c5d..dd05b95 100755 --- a/stacks/server-ui/scripts/show-api-key.sh +++ b/stacks/server-ui/scripts/show-api-key.sh @@ -9,16 +9,28 @@ PROD_ENV="/opt/control-plane/.env" DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env" UI_PORT=8091 -if [[ -f "${DEV_ENV}" ]]; then +# Production is canonical when installed; dev only as fallback. +if [[ -f "${PROD_ENV}" ]]; then + ENV_FILE="${PROD_ENV}" + UI_PORT="$(grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)" + if [[ -z "${UI_PORT}" ]]; then + UI_PORT="$(sudo grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)" + fi +elif [[ -r "${DEV_ENV}" ]]; then + ENV_FILE="${DEV_ENV}" UI_PORT="$(grep '^SERVER_UI_PORT=' "${DEV_ENV}" 2>/dev/null | cut -d= -f2- || echo 8091)" -fi - -# Prefer dev copy when readable (same key after sync); else production. -ENV_FILE="${DEV_ENV}" -if [[ ! -r "${ENV_FILE}" ]]; then +else ENV_FILE="${PROD_ENV}" fi +[[ -n "${UI_PORT}" ]] || UI_PORT=8091 # shellcheck source=print-api-key-instructions.sh source "${SCRIPT_DIR}/print-api-key-instructions.sh" -print_api_key_instructions "${ENV_FILE}" "${UI_PORT}" +if ! print_api_key_instructions "${ENV_FILE}" "${UI_PORT}" "${DEV_ENV}"; then + if [[ -r "${DEV_ENV}" && "${ENV_FILE}" != "${DEV_ENV}" ]]; then + echo "Fallback: odczyt z dev (prod nieczytelny bez sudo): ${DEV_ENV}" + print_api_key_instructions "${DEV_ENV}" "${UI_PORT}" "" + else + exit 1 + fi +fi diff --git a/stacks/server-ui/scripts/verify-api-key.sh b/stacks/server-ui/scripts/verify-api-key.sh new file mode 100755 index 0000000..54de2c7 --- /dev/null +++ b/stacks/server-ui/scripts/verify-api-key.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Verify API_KEY from /opt/control-plane/.env against live server-ui. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=print-api-key-instructions.sh +source "${SCRIPT_DIR}/print-api-key-instructions.sh" + +PROD_ENV="/opt/control-plane/.env" +UI_PORT=8091 + +if [[ -f "${PROD_ENV}" ]]; then + UI_PORT="$(grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)" + if [[ -z "${UI_PORT}" ]]; then + UI_PORT="$(sudo grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)" + fi +fi +[[ -n "${UI_PORT}" ]] || UI_PORT=8091 + +api_key="$(_read_api_key "${PROD_ENV}")" + +if [[ -z "${api_key}" && -r "${SCRIPT_DIR}/../../control-plane/.env" ]]; then + api_key="$(_read_api_key "${SCRIPT_DIR}/../../control-plane/.env")" +fi + +if [[ -z "${api_key}" ]]; then + echo "FAIL: brak API_KEY w ${PROD_ENV}" + exit 1 +fi + +if curl -sf -X POST "http://127.0.0.1:${UI_PORT}/api/auth/verify" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\":\"${api_key}\"}" >/dev/null; then + echo "OK: klucz z ${PROD_ENV} działa na http://127.0.0.1:${UI_PORT}" + exit 0 +fi + +echo "FAIL: klucz z ${PROD_ENV} odrzucony przez server-ui (:${UI_PORT})" +echo " Uruchom: bash ${SCRIPT_DIR}/show-api-key.sh" +echo " Sync env: sudo bash ${SCRIPT_DIR}/setup-control-plane-env.sh" +echo " Restart: sudo systemctl restart server-ui" +exit 1 diff --git a/stacks/server-ui/static/index.html b/stacks/server-ui/static/index.html index 1643e36..ec1c2b2 100644 --- a/stacks/server-ui/static/index.html +++ b/stacks/server-ui/static/index.html @@ -100,6 +100,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } button.primary { background: var(--accent); color: #0d1117; border-color: var(--accent); } button.danger { border-color: var(--red); color: var(--red); } + button.btn-update { border-color: var(--accent); color: var(--accent); } .btn-link { display: inline-block; text-decoration: none; @@ -517,6 +518,58 @@ #gpu-fan-status.warn { border-color: var(--orange); color: var(--orange); } #gpu-fan-status.error { border-color: var(--red); color: var(--red); } #gpu-fan-status.muted { color: var(--muted); } + .models-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } + .models-table th, .models-table td { + text-align: left; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; + } + .models-table th { color: var(--muted); font-weight: 500; } + .models-row-bar { height: 6px; background: var(--border); border-radius: 3px; margin-top: 4px; overflow: hidden; } + .models-row-bar .fill { height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s ease; width: 0%; } + .models-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; align-items: center; } + .models-url-row { margin-top: 0.35rem; display: flex; gap: 0.35rem; align-items: center; } + .models-url-input { + flex: 1; + min-width: 0; + font-size: 0.75rem; + padding: 0.25rem 0.4rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: inherit; + } + .models-table tbody tr { cursor: pointer; } + .models-table tbody tr.selected { background: rgba(88, 166, 255, 0.12); outline: 1px solid var(--accent); } + .models-table tbody tr:hover:not(.selected) { background: rgba(255, 255, 255, 0.03); } + .models-badge { + display: inline-block; + font-size: 0.65rem; + padding: 0.1rem 0.35rem; + border-radius: 3px; + background: rgba(88, 166, 255, 0.15); + color: var(--accent); + margin-left: 0.35rem; + vertical-align: middle; + } + .models-badge.muted { background: rgba(255, 255, 255, 0.06); color: var(--muted); } + .models-section-title { margin: 1.25rem 0 0.5rem; font-size: 0.95rem; } + .models-logs-card { margin-top: 1rem; } + .models-log-panel { + max-height: 280px; + overflow: auto; + font-family: ui-monospace, monospace; + font-size: 0.75rem; + white-space: pre-wrap; + margin: 0; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted); + } + .models-disk { font-size: 0.85rem; margin-bottom: 1rem; } @@ -533,6 +586,7 @@ + @@ -542,6 +596,7 @@ + @@ -753,20 +808,88 @@ + +
+
+
+

ComfyUI — pobieranie modeli

+
Modele zapisywane na dysku danych hosta (/data/apps/comfyui/models)
+
+ +
+ +
+ Duży checkpoint + LocalAI = konflikt VRAM. Zatrzymaj LocalAI przed generowaniem obrazów w ComfyUI. +
+ +
Ładowanie miejsca na dysku…
+ +
+

Katalog repo

+ + + + + + + + + + + + + +
ModelKategoriaRozmiarStatusAkcje
Ładowanie…
+
+ +
+

Wymagane przez workflow

+
Ładowanie…
+ + + + + + + + + + + + + +
PlikKategoriaWorkflowStatusAkcje
Ładowanie…
+
+ + + + +
diff --git a/stacks/server-ui/workflow_scanner.py b/stacks/server-ui/workflow_scanner.py new file mode 100644 index 0000000..1a25fb8 --- /dev/null +++ b/stacks/server-ui/workflow_scanner.py @@ -0,0 +1,278 @@ +"""Scan ComfyUI workflow JSON files for required model filenames.""" + +from __future__ import annotations + +import json +import logging +import subprocess +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from comfyui_config import COMFYUI_CONTAINER, container_workflow_path + +log = logging.getLogger(__name__) + +# class_type -> list of (input_field, category) +LOADER_NODES: dict[str, list[tuple[str, str]]] = { + "CheckpointLoaderSimple": [("ckpt_name", "checkpoint")], + "CheckpointLoader": [("ckpt_name", "checkpoint")], + "unCLIPCheckpointLoader": [("ckpt_name", "checkpoint")], + "ImageOnlyCheckpointLoader": [("ckpt_name", "checkpoint")], + "VAELoader": [("vae_name", "vae")], + "LoraLoader": [("lora_name", "lora")], + "LoraLoaderModelOnly": [("lora_name", "lora")], + "UNETLoader": [("unet_name", "unet")], + "CLIPLoader": [("clip_name", "clip")], + "DualCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip")], + "ControlNetLoader": [("control_net_name", "controlnet")], + "UpscaleModelLoader": [("model_name", "upscale")], + "StyleModelLoader": [("style_model_name", "style_models")], + "LatentUpscaleModelLoader": [("model_name", "latent_upscale")], + "LTXVAudioVAELoader": [("ckpt_name", "vae")], + "LTXAVTextEncoderLoader": [("gemma_model", "text_encoder"), ("ltx_model", "checkpoint")], + "DiffusersLoader": [("model_path", "diffusion_models")], +} + +# Nodes where every .safetensors in widgets_values should be collected +MULTI_WIDGET_LOADERS = frozenset({"LTXAVTextEncoderLoader"}) + + +@dataclass +class ModelRef: + filename: str + category: str + workflows: list[str] = field(default_factory=list) + + +def _is_model_filename(value: Any) -> bool: + if not isinstance(value, str) or not value.strip(): + return False + lower = value.lower() + return lower.endswith( + (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx", ".sft") + ) + + +def _infer_category_from_node(class_type: str, filename: str) -> str: + ct = class_type.lower() + fn = filename.lower() + if "lora" in ct or "lora" in fn: + return "lora" + if "upscale" in ct or "upscaler" in fn or "upscale" in fn: + return "latent_upscale" + if "vae" in ct: + return "vae" + if "clip" in ct or "text_encoder" in ct or "gemma" in fn: + return "text_encoder" + if "unet" in ct: + return "unet" + if "controlnet" in ct: + return "controlnet" + return "checkpoint" + + +def _read_workflow_json(path: Path, workflows_dir: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except PermissionError: + try: + rel = path.relative_to(workflows_dir.resolve()) + except ValueError: + log.warning("Workflow outside workflows dir, cannot use docker fallback: %s", path) + return None + container_path = container_workflow_path(rel.as_posix()) + try: + result = subprocess.run( + ["docker", "exec", COMFYUI_CONTAINER, "cat", container_path], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + log.warning("Docker fallback failed for workflow %s: %s", path, exc) + return None + if result.returncode != 0: + log.warning( + "Docker fallback failed for workflow %s: %s", + path, + result.stderr.strip(), + ) + return None + log.debug("Read workflow via docker exec: %s", path.name) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as exc: + log.warning("Invalid JSON from docker fallback for %s: %s", path, exc) + return None + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError) as exc: + log.warning("Failed to read workflow %s: %s", path, exc) + return None + + +def _iter_workflow_nodes(data: dict[str, Any]) -> Iterator[dict[str, Any]]: + nodes = data.get("nodes") + if isinstance(nodes, list): + for node in nodes: + if isinstance(node, dict): + yield node + + definitions = data.get("definitions") + if isinstance(definitions, dict): + subgraphs = definitions.get("subgraphs") + if isinstance(subgraphs, list): + for subgraph in subgraphs: + if not isinstance(subgraph, dict): + continue + inner = subgraph.get("nodes") + if isinstance(inner, list): + for node in inner: + if isinstance(node, dict): + yield node + + +def _node_class_type(node: dict[str, Any]) -> str | None: + ct = node.get("class_type") or node.get("type") + if isinstance(ct, str) and not ct.isupper() and ct not in ("INT", "FLOAT", "STRING", "COMBO", "BOOLEAN", "*"): + return ct + return None + + +def _extract_from_widgets( + class_type: str, + widgets: list[Any], +) -> list[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + if class_type in MULTI_WIDGET_LOADERS: + field_specs = LOADER_NODES.get(class_type, []) + for idx, value in enumerate(widgets): + if not _is_model_filename(value): + continue + if idx < len(field_specs): + category = field_specs[idx][1] + else: + category = _infer_category_from_node(class_type, str(value)) + refs.append((str(value), category)) + return refs + + if class_type in LOADER_NODES and widgets: + first = widgets[0] + if _is_model_filename(first): + refs.append((str(first), LOADER_NODES[class_type][0][1])) + return refs + + +def _extract_generic_widgets(class_type: str, widgets: list[Any]) -> list[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + for value in widgets: + if _is_model_filename(value): + refs.append((str(value), _infer_category_from_node(class_type, str(value)))) + return refs + + +def _extract_from_node(node: dict[str, Any]) -> list[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + class_type = _node_class_type(node) + if not class_type: + return refs + + widgets = node.get("widgets_values") + if isinstance(widgets, list): + if class_type in LOADER_NODES: + refs.extend(_extract_from_widgets(class_type, widgets)) + else: + refs.extend(_extract_generic_widgets(class_type, widgets)) + + inputs = node.get("inputs") + if isinstance(inputs, dict) and class_type in LOADER_NODES: + for field_name, category in LOADER_NODES[class_type]: + value = inputs.get(field_name) + if _is_model_filename(value): + refs.append((str(value), category)) + + return refs + + +def _extract_from_api_format(data: dict[str, Any]) -> list[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + for _node_id, node in data.items(): + if not isinstance(node, dict): + continue + class_type = node.get("class_type") + if not isinstance(class_type, str): + continue + if class_type not in LOADER_NODES: + continue + inputs = node.get("inputs") + if not isinstance(inputs, dict): + continue + for field_name, category in LOADER_NODES[class_type]: + value = inputs.get(field_name) + if _is_model_filename(value): + refs.append((str(value), category)) + return refs + + +def _extract_from_ui_format(data: dict[str, Any]) -> list[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for node in _iter_workflow_nodes(data): + for ref in _extract_from_node(node): + if ref not in seen: + seen.add(ref) + refs.append(ref) + return refs + + +def parse_workflow_data(data: dict[str, Any]) -> list[tuple[str, str]]: + if "nodes" in data: + return _extract_from_ui_format(data) + return _extract_from_api_format(data) + + +def parse_workflow_file(path: Path, workflows_dir: Path | None = None) -> list[tuple[str, str]]: + """Return list of (filename, category) referenced in a workflow file.""" + wf_dir = workflows_dir or path.parent + data = _read_workflow_json(path, wf_dir) + if not data: + return [] + return parse_workflow_data(data) + + +def find_model_file(models_root: Path, filename: str) -> Path | None: + """Find a model file by basename anywhere under models_root.""" + if not models_root.is_dir(): + return None + matches: list[Path] = [] + for path in models_root.rglob(filename): + if path.is_file() and path.name == filename: + matches.append(path) + if not matches: + return None + return min(matches, key=lambda p: len(p.parts)) + + +def scan_workflows(workflows_dir: Path) -> tuple[list[ModelRef], int]: + """Scan all JSON workflows and aggregate model references.""" + if not workflows_dir.is_dir(): + return [], 0 + + aggregated: dict[tuple[str, str], ModelRef] = {} + file_count = 0 + resolved_dir = workflows_dir.resolve() + for path in sorted(resolved_dir.rglob("*.json")): + if not path.is_file(): + continue + file_count += 1 + wf_name = path.stem + for filename, category in parse_workflow_file(path, resolved_dir): + key = (filename, category) + if key not in aggregated: + aggregated[key] = ModelRef(filename=filename, category=category) + if wf_name not in aggregated[key].workflows: + aggregated[key].workflows.append(wf_name) + + return sorted(aggregated.values(), key=lambda r: (r.category, r.filename)), file_count diff --git a/stacks/vllm/scripts/pull.sh b/stacks/vllm/scripts/pull.sh new file mode 100755 index 0000000..62c9900 --- /dev/null +++ b/stacks/vllm/scripts/pull.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +echo "=== vLLM — pull image only (no start) ===" +docker compose --profile vllm pull + +echo "" +echo "Done. Start with: ./scripts/start.sh"