Add stack Update, ComfyUI model manager, and slim ComfyUI stack.
Server UI gains Update on stack cards, ComfyUI Models tab with workflow scan and downloads, and centralized comfyui_config. Model catalog and download scripts move from stacks/comfyui to server-ui so ComfyUI stays a minimal Docker wrapper for easier image updates.
This commit is contained in:
@@ -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 <id>` — 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
|
||||
|
||||
@@ -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=<API_KEY>` (query string) |
|
||||
| Reattach | `?session_id=<uuid>` (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":"<uuid>"}` | 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
|
||||
|
||||
|
||||
@@ -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://<IP-serwera>: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)
|
||||
|
||||
@@ -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://<IP>: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 <catalog-id>
|
||||
```
|
||||
|
||||
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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
+40
-90
@@ -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://<IP-serwera>: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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
docker-compose.yml
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Executable
+25
@@ -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"
|
||||
@@ -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/<name>/.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
|
||||
|
||||
+212
-9
@@ -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():
|
||||
_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 # noqa: E402
|
||||
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)
|
||||
|
||||
+266
-77
@@ -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
|
||||
@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
|
||||
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
|
||||
|
||||
|
||||
async def run_pty_session(
|
||||
websocket: WebSocket,
|
||||
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],
|
||||
max_sessions: int,
|
||||
) -> None:
|
||||
global _active_sessions
|
||||
|
||||
async with _session_lock:
|
||||
if _active_sessions >= max_sessions:
|
||||
) -> CliSession:
|
||||
async with self._lock:
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise CliSessionLimitError(
|
||||
f"Limit sesji CLI ({max_sessions}) — spróbuj później"
|
||||
f"Limit sesji CLI ({self.max_sessions}) — spróbuj później"
|
||||
)
|
||||
_active_sessions += 1
|
||||
|
||||
master_fd: int | None = None
|
||||
process: asyncio.subprocess.Process | None = None
|
||||
read_task: asyncio.Task[None] | None = None
|
||||
running = asyncio.Event()
|
||||
running.set()
|
||||
|
||||
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:
|
||||
message = await websocket.receive()
|
||||
msg_type = message.get("type")
|
||||
if msg_type == "websocket.disconnect":
|
||||
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
|
||||
|
||||
payload: bytes | None = None
|
||||
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"):
|
||||
payload = message["bytes"]
|
||||
elif message.get("text"):
|
||||
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)
|
||||
if obj.get("type") == "resize":
|
||||
cmd = obj.get("type")
|
||||
if cmd == "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
|
||||
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
|
||||
payload = text.encode("utf-8")
|
||||
await mgr.write_input(session, text.encode("utf-8"))
|
||||
|
||||
if payload and master_fd is not None:
|
||||
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:
|
||||
os.write(master_fd, payload)
|
||||
except OSError:
|
||||
break
|
||||
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)
|
||||
|
||||
if process.returncode is not None:
|
||||
try:
|
||||
while not session.closed:
|
||||
message = await websocket.receive()
|
||||
if not await _handle_ws_input(session, message, mgr):
|
||||
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)
|
||||
|
||||
@@ -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 <id>
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
+93
@@ -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
|
||||
}
|
||||
+62
@@ -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."
|
||||
+52
@@ -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
|
||||
}
|
||||
+105
@@ -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 <catalog-model-id>"
|
||||
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."
|
||||
+35
@@ -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 <id>"
|
||||
echo "Target root: ${DATA_ROOT}/apps/comfyui/models/"
|
||||
@@ -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
|
||||
|
||||
@@ -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="<IP-serwera>"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+42
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Executable
+25
@@ -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"
|
||||
Reference in New Issue
Block a user