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
|
## Modele
|
||||||
|
|
||||||
- Brak auto-download w `compose pull`
|
- 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)
|
- Szacunki VRAM: SDXL ~8–12 GB; Flux ~12–20 GB (mieści się na 24 GB przy zatrzymanym LocalAI)
|
||||||
|
|
||||||
## Dostęp sieciowy
|
## Dostęp sieciowy
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Server UI — CLI (terminal PTY)
|
# Server UI — CLI (terminal PTY)
|
||||||
|
|
||||||
**Data:** 2026-07-05
|
**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).
|
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` |
|
| Ścieżka | `/api/cli/ws` |
|
||||||
| Auth | `?api_key=<API_KEY>` (query string) |
|
| Auth | `?api_key=<API_KEY>` (query string) |
|
||||||
|
| Reattach | `?session_id=<uuid>` (opcjonalnie, z `sessionStorage`) |
|
||||||
| Protokół I/O | surowe bajty terminala (text/binary frames) |
|
| Protokół I/O | surowe bajty terminala (text/binary frames) |
|
||||||
| Resize | JSON: `{"type":"resize","cols":120,"rows":40}` |
|
| 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)
|
### Przykład (wscat)
|
||||||
|
|
||||||
@@ -59,7 +68,36 @@ W [`/opt/control-plane/.env`](/opt/control-plane/.env) (opcjonalnie):
|
|||||||
| `CLI_ENABLED` | `1` | `0` = WebSocket odrzucany |
|
| `CLI_ENABLED` | `1` | `0` = WebSocket odrzucany |
|
||||||
| `CLI_SHELL` | `/bin/bash` | Shell PTY (np. `/bin/bash -l`) |
|
| `CLI_SHELL` | `/bin/bash` | Shell PTY (np. `/bin/bash -l`) |
|
||||||
| `CLI_DEFAULT_CWD` | `$HOME` usługi | Katalog startowy |
|
| `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
|
## Uprawnienia POSIX
|
||||||
|
|
||||||
@@ -73,7 +111,7 @@ Server UI działa jako **`tomasz-syn-grzegorza`** (`server-ui.service`):
|
|||||||
|
|
||||||
| Plik | Rola |
|
| 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/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/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()`) |
|
| [`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`
|
- 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_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)
|
- 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
|
## Deploy
|
||||||
|
|
||||||
|
|||||||
@@ -38,24 +38,26 @@ Na serwerze produkcyjnym (systemd):
|
|||||||
|
|
||||||
## 3. Wyświetl klucz na serwerze
|
## 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
|
```bash
|
||||||
sudo grep ^API_KEY= /opt/control-plane/.env
|
sudo grep ^API_KEY= /opt/control-plane/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Przykład wyniku:
|
|
||||||
|
|
||||||
```
|
|
||||||
API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
Skopiuj **tylko** część po `=` (bez słowa `API_KEY=`).
|
Skopiuj **tylko** część po `=` (bez słowa `API_KEY=`).
|
||||||
|
|
||||||
Alternatywa — gotowa instrukcja z linkiem:
|
Diagnostyka (exit 0 = OK):
|
||||||
|
|
||||||
```bash
|
```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
|
### Krok B — wklej klucz
|
||||||
|
|
||||||
1. U góry strony znajdź pole **API Key**
|
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**
|
3. Kliknij **Zapisz**
|
||||||
|
|
||||||
### Krok C — sprawdź
|
### Krok C — sprawdź
|
||||||
@@ -84,6 +86,8 @@ Przykład: `http://192.168.100.90:8091/`
|
|||||||
2. Powinno pojawić się: **Klucz poprawny** (zielony tekst)
|
2. Powinno pojawić się: **Klucz poprawny** (zielony tekst)
|
||||||
3. Dopiero teraz używaj Start/Stop, CLI itd.
|
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
|
## 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
|
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ć |
|
| # | Sprawdź | Co zrobić |
|
||||||
|---|---------|-----------|
|
|---|---------|-----------|
|
||||||
| 1 | Kliknąłeś **Zapisz**? | Wklej klucz → **Zapisz** → **Sprawdź klucz** |
|
| 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ę |
|
| 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? | Tylko `sudo grep ^API_KEY= /opt/control-plane/.env` |
|
| 3 | Klucz z właściwego pliku? | `bash stacks/server-ui/scripts/show-api-key.sh` (nie dev ręcznie) |
|
||||||
| 4 | Panel po restarcie? | `sudo systemctl restart server-ui` |
|
| 4 | Klucz działa na serwerze? | `bash stacks/server-ui/scripts/verify-api-key.sh` |
|
||||||
| 5 | Właściwy proces na porcie? | `ss -tlnp \| grep 8091` — powinno być `/opt/server-ui` |
|
| 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ź |
|
| Pytanie | Odpowiedź |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| Gdzie klucz? | `/opt/control-plane/.env` → `API_KEY` |
|
| 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 |
|
| Jak wpisać w UI? | Pole API Key → Zapisz → Sprawdź klucz |
|
||||||
|
| Stary klucz? | **Wyczyść klucz** w toolbarze |
|
||||||
| Gotowy link? | `http://IP:8091/?api_key=KLUCZ` |
|
| Gotowy link? | `http://IP:8091/?api_key=KLUCZ` |
|
||||||
|
|
||||||
Powrót: [08 — Server UI](08-server-ui-install.md) · [README](README.md)
|
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.
|
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/`
|
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):
|
Szacunki VRAM (przy zatrzymanym LocalAI):
|
||||||
|
|
||||||
| Model | VRAM (orientacyjnie) |
|
| Model | VRAM (orientacyjnie) |
|
||||||
|
|||||||
@@ -76,8 +76,11 @@ sudo ls /root
|
|||||||
| Co | Wyjaśnienie |
|
| Co | Wyjaśnienie |
|
||||||
|----|-------------|
|
|----|-------------|
|
||||||
| Nie jesteś rootem | Panel działa jako Twój użytkownik — jak zwykłe SSH |
|
| 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 |
|
| Sesja żyje między zakładkami | Przełączenie na Stacki/Pliki **nie** zabija basha |
|
||||||
| Limit sesji | Domyślnie max 5 równoległych terminali (wszystkie karty) |
|
| 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` |
|
| 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`
|
2. `echo test-cli-ok` → widzisz `test-cli-ok`
|
||||||
3. `docker ps` → lista kontenerów
|
3. `docker ps` → lista kontenerów
|
||||||
4. Zmień rozmiar okna — prompt nie „łamie się” dziwnie
|
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)
|
# Extra CLI args passed to ComfyUI (e.g. --fast)
|
||||||
CLI_ARGS=
|
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 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).
|
[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**).
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## Porty
|
## Porty
|
||||||
|
|
||||||
| Serwis | Port | URL |
|
| Serwis | Port | URL |
|
||||||
|--------|------|-----|
|
|--------|------|-----|
|
||||||
| ComfyUI web UI | **8188** | `http://HOST:8188` |
|
| 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
|
## Struktura (repo)
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
stacks/comfyui/
|
stacks/comfyui/
|
||||||
├── README.md
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── .gitignore
|
|
||||||
└── scripts/
|
└── scripts/
|
||||||
├── ensure-dirs.sh
|
├── ensure-dirs.sh
|
||||||
├── pull.sh
|
├── pull.sh
|
||||||
└── start.sh
|
└── start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Na dysku `/data`:
|
## Dane na hoście (`/data/apps/comfyui/`)
|
||||||
|
|
||||||
```
|
| Katalog | Zawartość |
|
||||||
/data/apps/comfyui/
|
|---------|-----------|
|
||||||
├── storage/ # kopia ComfyUI z obrazu (pierwszy start)
|
| `storage/` | Kopia ComfyUI z obrazu (pierwszy start) |
|
||||||
├── models/ # checkpoints, LoRA, VAE, …
|
| `models/` | Checkpointy, LoRA, VAE, … |
|
||||||
├── cache/
|
| `custom_nodes/` | ComfyUI-Manager i wtyczki |
|
||||||
│ ├── hf-hub/
|
| `workflows/` | JSON workflow (bind → `user/default/workflows`) |
|
||||||
│ └── torch-hub/
|
| `input/`, `output/`, `cache/` | Wejścia, wyniki, cache HF/torch |
|
||||||
├── input/
|
|
||||||
├── output/
|
|
||||||
├── custom_nodes/
|
|
||||||
└── workflows/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow (bez modelu)
|
Update kontenera (`docker compose pull` + recreate) **nie usuwa** tych danych — wszystko to bind-mounty.
|
||||||
|
|
||||||
|
## Szybki start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd stacks/comfyui
|
cd stacks/comfyui
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
./scripts/pull.sh
|
|
||||||
./scripts/start.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
|
```bash
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/
|
./scripts/pull.sh # tylko obraz
|
||||||
# UI: http://<IP-serwera>:8188
|
./scripts/start.sh # start z nowym obrazem
|
||||||
```
|
```
|
||||||
|
|
||||||
## Zmienne `.env`
|
### Checklist po update obrazu
|
||||||
|
|
||||||
| Zmienna | Opis | Domyślnie |
|
1. `curl -f http://127.0.0.1:8188/` — UI ComfyUI działa
|
||||||
|---------|------|-----------|
|
2. Server UI → **ComfyUI Modele** → odśwież — skan workflow OK
|
||||||
| `DATA_ROOT` | Mount dysku danych | `/data` |
|
3. Test pobrania modelu (katalog lub Custom URL) — job `completed`
|
||||||
| `COMFYUI_PORT` | Port na hoście | `8188` |
|
4. Healthcheck fail → sprawdź, czy obraz ma `curl` na porcie 8188
|
||||||
| `COMFYUI_IMAGE` | Obraz Docker | `yanwk/comfyui-boot:cu126-slim` |
|
5. Pusty skan workflow → zweryfikuj mount `workflows` → `user/default/workflows` w [docs yanwk](https://github.com/YanWenKun/ComfyUI-Docker)
|
||||||
| `CUDA_VISIBLE_DEVICES` | GPU | `0` |
|
|
||||||
| `CLI_ARGS` | Dodatkowe flagi ComfyUI | puste |
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
cd ../localai
|
cp manual-tutorial/examples/comfyui/example-sdxl-refiner.api.json /data/apps/comfyui/workflows/
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dokumentacja
|
## Dokumentacja
|
||||||
|
|
||||||
- Tutorial: [manual-tutorial/07-comfyui-stack.md](../../manual-tutorial/07-comfyui-stack.md)
|
- 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)
|
||||||
- Wdrożenie: [`coding-agent/COMFYUI-DEPLOYMENT.md`](../../coding-agent/COMFYUI-DEPLOYMENT.md)
|
|
||||||
|
|
||||||
Upstream: [github.com/comfyanonymous/ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
docker-compose.yml
|
|
||||||
@@ -5,13 +5,25 @@ ensure_comfyui_dirs() {
|
|||||||
local data_root="${1:-/data}"
|
local data_root="${1:-/data}"
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
"${data_root}/apps/comfyui/storage" \
|
"${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/hf-hub" \
|
||||||
"${data_root}/apps/comfyui/cache/torch-hub" \
|
"${data_root}/apps/comfyui/cache/torch-hub" \
|
||||||
"${data_root}/apps/comfyui/input" \
|
"${data_root}/apps/comfyui/input" \
|
||||||
"${data_root}/apps/comfyui/output" \
|
"${data_root}/apps/comfyui/output" \
|
||||||
"${data_root}/apps/comfyui/custom_nodes" \
|
"${data_root}/apps/comfyui/custom_nodes" \
|
||||||
"${data_root}/apps/comfyui/workflows"
|
"${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
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
|||||||
@@ -87,6 +87,44 @@ def control_plane_env_paths(stack_dir: Path) -> list[Path]:
|
|||||||
return paths
|
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:
|
def api_key_source(stack_dir: Path, values: dict[str, str]) -> str:
|
||||||
"""Human-readable hint for logs (no secret values)."""
|
"""Human-readable hint for logs (no secret values)."""
|
||||||
if os.environ.get("API_KEY"):
|
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` |
|
| Server UI | **8091** | `http://HOST:8091` |
|
||||||
| CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` |
|
| CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` |
|
||||||
| Pliki (zakładka) | — | ten sam URL, zakładka **Pliki** lub `#files` |
|
| 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 (zakładka) | — | ten sam URL, zakładka **GPU Fan** |
|
||||||
| gpu-fan agent API | **18090** | tylko localhost (proxy z Server UI) |
|
| 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)
|
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
|
## Bezpieczeństwo
|
||||||
|
|
||||||
- Odczyt statusu stacków/logów/GPU — bez klucza (LAN).
|
- 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.
|
- Przy `SERVER_UI_HOST=0.0.0.0` klucz jest **wymagany** przy starcie usługi.
|
||||||
|
|
||||||
## Tryb dev (z repo)
|
## Tryb dev (z repo)
|
||||||
@@ -88,6 +130,9 @@ cp ../control-plane/.env.example ../control-plane/.env
|
|||||||
```
|
```
|
||||||
stacks/server-ui/
|
stacks/server-ui/
|
||||||
├── app.py
|
├── app.py
|
||||||
|
├── comfyui_config.py # stałe ścieżek ComfyUI (container, mounty)
|
||||||
|
├── comfyui_models.py
|
||||||
|
├── comfyui-models.catalog.yaml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── compose_runner.py
|
├── compose_runner.py
|
||||||
@@ -97,6 +142,10 @@ stacks/server-ui/
|
|||||||
├── static/vendor/xterm/ # xterm.js dla zakładki CLI
|
├── static/vendor/xterm/ # xterm.js dla zakładki CLI
|
||||||
├── cli_pty.py
|
├── cli_pty.py
|
||||||
└── scripts/
|
└── 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-control-plane.sh # gpu-fan + server-ui (menu)
|
||||||
├── install.sh # native only
|
├── install.sh # native only
|
||||||
├── install-docker.sh # docker only
|
├── install-docker.sh # docker only
|
||||||
|
|||||||
+213
-10
@@ -20,6 +20,7 @@ from compose_runner import (
|
|||||||
ComposeError,
|
ComposeError,
|
||||||
PortConfigError,
|
PortConfigError,
|
||||||
StackConfig,
|
StackConfig,
|
||||||
|
find_compose_file,
|
||||||
find_running_gpu_stacks,
|
find_running_gpu_stacks,
|
||||||
get_container_state,
|
get_container_state,
|
||||||
get_logs,
|
get_logs,
|
||||||
@@ -28,8 +29,26 @@ from compose_runner import (
|
|||||||
stack_restart,
|
stack_restart,
|
||||||
stack_start,
|
stack_start,
|
||||||
stack_stop,
|
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 file_explorer import FileExplorer, FileExplorerError
|
||||||
from gpu_info import GpuInfoError, query_gpu
|
from gpu_info import GpuInfoError, query_gpu
|
||||||
from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, resolve_agent_url
|
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
|
STACK_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
# Unified control-plane env (see stacks/control-plane/env_loader.py)
|
# 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")):
|
_local_env_loader = STACK_DIR / "env_loader.py"
|
||||||
if _cp not in sys.path and Path(_cp).exists():
|
if _local_env_loader.is_file():
|
||||||
sys.path.insert(0, _cp)
|
if str(STACK_DIR) not in sys.path:
|
||||||
from env_loader import api_key_source, load_control_plane_env # noqa: E402
|
sys.path.insert(0, str(STACK_DIR))
|
||||||
|
else:
|
||||||
|
for _cp in (
|
||||||
|
"/opt/control-plane",
|
||||||
|
"/repo/stacks/control-plane",
|
||||||
|
str(STACK_DIR.parent / "control-plane"),
|
||||||
|
):
|
||||||
|
if _cp not in sys.path and Path(_cp).joinpath("env_loader.py").is_file():
|
||||||
|
sys.path.insert(0, _cp)
|
||||||
|
from env_loader import api_key_source, load_control_plane_env, resolve_repo_root # noqa: E402
|
||||||
STATIC_DIR = STACK_DIR / "static"
|
STATIC_DIR = STACK_DIR / "static"
|
||||||
CONFIG_PATH = STACK_DIR / "stacks.yaml"
|
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"),
|
ENV.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090"),
|
||||||
API_KEY,
|
API_KEY,
|
||||||
)
|
)
|
||||||
REPO_ROOT = Path(
|
REPO_ROOT = resolve_repo_root(STACK_DIR, ENV)
|
||||||
ENV.get("REPO_ROOT", str(STACK_DIR.parent.parent))
|
|
||||||
).resolve()
|
|
||||||
STACKS_BASE = REPO_ROOT / "stacks"
|
STACKS_BASE = REPO_ROOT / "stacks"
|
||||||
|
|
||||||
STACKS: list[StackConfig] = load_stacks(CONFIG_PATH, STACKS_BASE)
|
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_SHELL = ENV.get("CLI_SHELL", "/bin/bash")
|
||||||
CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~"))
|
CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~"))
|
||||||
CLI_MAX_SESSIONS = int(ENV.get("CLI_MAX_SESSIONS", "5"))
|
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 = FastAPI(title="Server UI", version="1.0.0")
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
@@ -104,6 +137,15 @@ class AuthVerifyBody(BaseModel):
|
|||||||
api_key: str = ""
|
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:
|
def get_stack(stack_id: str) -> StackConfig:
|
||||||
stack = STACK_BY_ID.get(stack_id)
|
stack = STACK_BY_ID.get(stack_id)
|
||||||
if not stack:
|
if not stack:
|
||||||
@@ -135,6 +177,22 @@ def require_file_auth(request: Request) -> None:
|
|||||||
require_mutation_auth(request)
|
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:
|
def _cli_ws_auth_ok(websocket: WebSocket) -> bool:
|
||||||
if not _is_lan_bind() or not API_KEY:
|
if not _is_lan_bind() or not API_KEY:
|
||||||
return True
|
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}
|
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")
|
@app.patch("/api/stacks/{stack_id}/port")
|
||||||
def api_set_port(
|
def api_set_port(
|
||||||
stack_id: str,
|
stack_id: str,
|
||||||
@@ -313,6 +381,109 @@ async def api_gpu_fan_proxy(
|
|||||||
return Response(content=payload, status_code=status, media_type=media_type)
|
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")
|
@app.get("/api/files")
|
||||||
def api_files_list(
|
def api_files_list(
|
||||||
path: str = Query(default="/"),
|
path: str = Query(default="/"),
|
||||||
@@ -400,7 +571,13 @@ async def api_cli_ws(websocket: WebSocket) -> None:
|
|||||||
await websocket.close(code=1008, reason="CLI disabled")
|
await websocket.close(code=1008, reason="CLI disabled")
|
||||||
return
|
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(
|
await websocket.close(
|
||||||
code=1008,
|
code=1008,
|
||||||
reason=f"Limit sesji CLI ({CLI_MAX_SESSIONS}) — spróbuj później",
|
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,
|
cwd=CLI_DEFAULT_CWD,
|
||||||
env=os.environ.copy(),
|
env=os.environ.copy(),
|
||||||
max_sessions=CLI_MAX_SESSIONS,
|
max_sessions=CLI_MAX_SESSIONS,
|
||||||
|
session_id=session_id,
|
||||||
|
session_ttl=CLI_SESSION_TTL,
|
||||||
)
|
)
|
||||||
except CliSessionLimitError:
|
except CliSessionLimitError:
|
||||||
await websocket.close(code=1008, reason="Session limit")
|
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:
|
def main() -> None:
|
||||||
if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY:
|
if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY:
|
||||||
log.error(
|
log.error(
|
||||||
@@ -433,7 +626,11 @@ def main() -> None:
|
|||||||
log.error("No stacks loaded from %s", CONFIG_PATH)
|
log.error("No stacks loaded from %s", CONFIG_PATH)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
_validate_stacks(STACKS)
|
||||||
|
|
||||||
log.info("Repo root: %s", REPO_ROOT)
|
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("Stacks: %s", ", ".join(s.id for s in STACKS))
|
||||||
log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL)
|
log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL)
|
||||||
if API_KEY:
|
if API_KEY:
|
||||||
@@ -442,7 +639,13 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
log.warning("API_KEY not set (proxy may fail agent auth on LAN)")
|
log.warning("API_KEY not set (proxy may fail agent auth on LAN)")
|
||||||
if CLI_ENABLED:
|
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:
|
else:
|
||||||
log.info("CLI: disabled (CLI_ENABLED=0)")
|
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)
|
log.info("Web UI at http://%s:%d", HOST if HOST != "0.0.0.0" else "0.0.0.0", PORT)
|
||||||
|
|||||||
+286
-97
@@ -12,6 +12,8 @@ import shlex
|
|||||||
import signal
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import termios
|
import termios
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
@@ -19,15 +21,17 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
TIOCSWINSZ = termios.TIOCSWINSZ
|
TIOCSWINSZ = termios.TIOCSWINSZ
|
||||||
|
OUTPUT_BUFFER_MAX = 256 * 1024
|
||||||
_active_sessions = 0
|
|
||||||
_session_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class CliSessionLimitError(Exception):
|
class CliSessionLimitError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CliSessionNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
||||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||||
fcntl.ioctl(fd, TIOCSWINSZ, winsize)
|
fcntl.ioctl(fd, TIOCSWINSZ, winsize)
|
||||||
@@ -38,25 +42,6 @@ def _parse_shell(shell: str) -> list[str]:
|
|||||||
return parts if parts else ["/bin/bash"]
|
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:
|
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
||||||
if process.returncode is not None:
|
if process.returncode is not None:
|
||||||
return
|
return
|
||||||
@@ -74,34 +59,71 @@ async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
|||||||
await process.wait()
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
def sessions_available(max_sessions: int) -> bool:
|
@dataclass
|
||||||
return _active_sessions < max_sessions
|
class CliSession:
|
||||||
|
id: str
|
||||||
|
master_fd: int
|
||||||
async def run_pty_session(
|
process: asyncio.subprocess.Process
|
||||||
websocket: WebSocket,
|
cols: int = 80
|
||||||
*,
|
rows: int = 24
|
||||||
shell: str,
|
ws: WebSocket | None = None
|
||||||
cwd: str,
|
output_buffer: bytearray = field(default_factory=bytearray)
|
||||||
env: dict[str, str],
|
running: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
max_sessions: int,
|
|
||||||
) -> None:
|
|
||||||
global _active_sessions
|
|
||||||
|
|
||||||
async with _session_lock:
|
|
||||||
if _active_sessions >= max_sessions:
|
|
||||||
raise CliSessionLimitError(
|
|
||||||
f"Limit sesji CLI ({max_sessions}) — spróbuj później"
|
|
||||||
)
|
|
||||||
_active_sessions += 1
|
|
||||||
|
|
||||||
master_fd: int | None = None
|
|
||||||
process: asyncio.subprocess.Process | None = None
|
|
||||||
read_task: asyncio.Task[None] | None = None
|
read_task: asyncio.Task[None] | None = None
|
||||||
running = asyncio.Event()
|
detach_timer: asyncio.Task[None] | None = None
|
||||||
running.set()
|
closed: bool = False
|
||||||
|
|
||||||
|
def append_output(self, data: bytes) -> None:
|
||||||
|
self.output_buffer.extend(data)
|
||||||
|
overflow = len(self.output_buffer) - OUTPUT_BUFFER_MAX
|
||||||
|
if overflow > 0:
|
||||||
|
del self.output_buffer[:overflow]
|
||||||
|
|
||||||
|
async def send_to_ws(self, data: bytes) -> bool:
|
||||||
|
if not self.ws:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await self.ws.send_bytes(data)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_json(self, obj: dict[str, Any]) -> bool:
|
||||||
|
if not self.ws:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await self.ws.send_text(json.dumps(obj))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CliSessionManager:
|
||||||
|
def __init__(self, *, session_ttl: int = 300, max_sessions: int = 5) -> None:
|
||||||
|
self.session_ttl = session_ttl
|
||||||
|
self.max_sessions = max_sessions
|
||||||
|
self._sessions: dict[str, CliSession] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def sessions_available(self) -> bool:
|
||||||
|
return len(self._sessions) < self.max_sessions
|
||||||
|
|
||||||
|
def get(self, session_id: str) -> CliSession | None:
|
||||||
|
return self._sessions.get(session_id)
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
shell: str,
|
||||||
|
cwd: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
) -> CliSession:
|
||||||
|
async with self._lock:
|
||||||
|
if len(self._sessions) >= self.max_sessions:
|
||||||
|
raise CliSessionLimitError(
|
||||||
|
f"Limit sesji CLI ({self.max_sessions}) — spróbuj później"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
master_fd, slave_fd = pty.openpty()
|
||||||
shell_cmd = _parse_shell(shell)
|
shell_cmd = _parse_shell(shell)
|
||||||
proc_env = {**env, "TERM": "xterm-256color"}
|
proc_env = {**env, "TERM": "xterm-256color"}
|
||||||
@@ -116,59 +138,226 @@ async def run_pty_session(
|
|||||||
preexec_fn=os.setsid,
|
preexec_fn=os.setsid,
|
||||||
)
|
)
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
slave_fd = -1
|
|
||||||
|
|
||||||
read_task = asyncio.create_task(_read_pty_loop(master_fd, websocket, running))
|
session = CliSession(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
master_fd=master_fd,
|
||||||
|
process=process,
|
||||||
|
)
|
||||||
|
session.running.set()
|
||||||
|
|
||||||
while True:
|
async with self._lock:
|
||||||
|
self._sessions[session.id] = session
|
||||||
|
|
||||||
|
session.read_task = asyncio.create_task(self._read_pty_loop(session))
|
||||||
|
log.info("CLI session created: %s", session.id)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _read_pty_loop(self, session: CliSession) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
while session.running.is_set() and not session.closed:
|
||||||
|
try:
|
||||||
|
data = await loop.run_in_executor(None, os.read, session.master_fd, 4096)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
session.append_output(data)
|
||||||
|
await session.send_to_ws(data)
|
||||||
|
if session.process.returncode is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not session.closed:
|
||||||
|
log.info("CLI session ended (shell exit): %s", session.id)
|
||||||
|
await self.close_session(session.id)
|
||||||
|
|
||||||
|
async def attach_session(self, session_id: str, websocket: WebSocket) -> CliSession:
|
||||||
|
session = self.get(session_id)
|
||||||
|
if session is None or session.closed:
|
||||||
|
raise CliSessionNotFoundError(session_id)
|
||||||
|
|
||||||
|
await self._cancel_detach_timer(session)
|
||||||
|
|
||||||
|
if session.ws is not None and session.ws is not websocket:
|
||||||
|
try:
|
||||||
|
await session.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
session.ws = websocket
|
||||||
|
|
||||||
|
if session.output_buffer:
|
||||||
|
await session.send_to_ws(bytes(session.output_buffer))
|
||||||
|
|
||||||
|
await session.send_json({"type": "replay", "complete": True})
|
||||||
|
_set_winsize(session.master_fd, session.rows, session.cols)
|
||||||
|
log.info("CLI session reattached: %s", session.id)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def detach_session(self, session_id: str) -> None:
|
||||||
|
session = self.get(session_id)
|
||||||
|
if session is None or session.closed:
|
||||||
|
return
|
||||||
|
session.ws = None
|
||||||
|
await self._schedule_detach_timer(session)
|
||||||
|
log.info("CLI session detached (TTL %ds): %s", self.session_ttl, session.id)
|
||||||
|
|
||||||
|
async def close_session(self, session_id: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
session = self._sessions.pop(session_id, None)
|
||||||
|
if session is None or session.closed:
|
||||||
|
return
|
||||||
|
|
||||||
|
session.closed = True
|
||||||
|
session.running.clear()
|
||||||
|
await self._cancel_detach_timer(session)
|
||||||
|
|
||||||
|
if session.read_task is not None:
|
||||||
|
session.read_task.cancel()
|
||||||
|
try:
|
||||||
|
await session.read_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await _kill_process(session.process)
|
||||||
|
try:
|
||||||
|
os.close(session.master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if session.ws is not None:
|
||||||
|
try:
|
||||||
|
await session.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log.info("CLI session closed: %s", session_id)
|
||||||
|
|
||||||
|
async def _cancel_detach_timer(self, session: CliSession) -> None:
|
||||||
|
if session.detach_timer is not None:
|
||||||
|
session.detach_timer.cancel()
|
||||||
|
try:
|
||||||
|
await session.detach_timer
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
session.detach_timer = None
|
||||||
|
|
||||||
|
async def _schedule_detach_timer(self, session: CliSession) -> None:
|
||||||
|
await self._cancel_detach_timer(session)
|
||||||
|
|
||||||
|
async def _expire() -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.session_ttl)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
log.info("CLI session expired after detach TTL: %s", session.id)
|
||||||
|
await self.close_session(session.id)
|
||||||
|
|
||||||
|
session.detach_timer = asyncio.create_task(_expire())
|
||||||
|
|
||||||
|
async def handle_resize(self, session: CliSession, rows: int, cols: int) -> None:
|
||||||
|
session.rows = rows
|
||||||
|
session.cols = cols
|
||||||
|
_set_winsize(session.master_fd, rows, cols)
|
||||||
|
|
||||||
|
async def write_input(self, session: CliSession, payload: bytes) -> None:
|
||||||
|
try:
|
||||||
|
os.write(session.master_fd, payload)
|
||||||
|
except OSError:
|
||||||
|
await self.close_session(session.id)
|
||||||
|
|
||||||
|
|
||||||
|
_manager: CliSessionManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_session_manager(*, session_ttl: int = 300, max_sessions: int = 5) -> CliSessionManager:
|
||||||
|
global _manager
|
||||||
|
_manager = CliSessionManager(session_ttl=session_ttl, max_sessions=max_sessions)
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_manager() -> CliSessionManager:
|
||||||
|
if _manager is None:
|
||||||
|
raise RuntimeError("CLI session manager not initialized")
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def sessions_available(max_sessions: int) -> bool:
|
||||||
|
if _manager is not None:
|
||||||
|
return _manager.sessions_available()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ws_input(session: CliSession, message: dict[str, Any], mgr: CliSessionManager) -> bool:
|
||||||
|
"""Process one WS message. Returns False to end the connection loop."""
|
||||||
|
msg_type = message.get("type")
|
||||||
|
if msg_type == "websocket.disconnect":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if message.get("bytes"):
|
||||||
|
await mgr.write_input(session, message["bytes"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
if message.get("text"):
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
try:
|
||||||
|
obj: dict[str, Any] = json.loads(text)
|
||||||
|
cmd = obj.get("type")
|
||||||
|
if cmd == "resize":
|
||||||
|
rows = int(obj.get("rows", 24))
|
||||||
|
cols = int(obj.get("cols", 80))
|
||||||
|
await mgr.handle_resize(session, rows, cols)
|
||||||
|
return True
|
||||||
|
if cmd == "close":
|
||||||
|
await mgr.close_session(session.id)
|
||||||
|
return False
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
await mgr.write_input(session, text.encode("utf-8"))
|
||||||
|
|
||||||
|
if session.closed or session.process.returncode is not None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def run_pty_session(
|
||||||
|
websocket: WebSocket,
|
||||||
|
*,
|
||||||
|
shell: str,
|
||||||
|
cwd: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
max_sessions: int,
|
||||||
|
session_id: str | None = None,
|
||||||
|
session_ttl: int = 300,
|
||||||
|
) -> None:
|
||||||
|
if _manager is None:
|
||||||
|
init_session_manager(session_ttl=session_ttl, max_sessions=max_sessions)
|
||||||
|
mgr = get_session_manager()
|
||||||
|
|
||||||
|
session: CliSession
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
try:
|
||||||
|
session = await mgr.attach_session(session_id, websocket)
|
||||||
|
except CliSessionNotFoundError:
|
||||||
|
await websocket.close(code=1008, reason="Session expired")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
session = await mgr.create_session(shell=shell, cwd=cwd, env=env)
|
||||||
|
session.ws = websocket
|
||||||
|
await websocket.send_text(json.dumps({"type": "session", "id": session.id}))
|
||||||
|
_set_winsize(session.master_fd, session.rows, session.cols)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not session.closed:
|
||||||
message = await websocket.receive()
|
message = await websocket.receive()
|
||||||
msg_type = message.get("type")
|
if not await _handle_ws_input(session, message, mgr):
|
||||||
if msg_type == "websocket.disconnect":
|
|
||||||
break
|
break
|
||||||
|
|
||||||
payload: bytes | None = None
|
|
||||||
if message.get("bytes"):
|
|
||||||
payload = message["bytes"]
|
|
||||||
elif message.get("text"):
|
|
||||||
text = message["text"]
|
|
||||||
if text.startswith("{"):
|
|
||||||
try:
|
|
||||||
obj: dict[str, Any] = json.loads(text)
|
|
||||||
if obj.get("type") == "resize":
|
|
||||||
rows = int(obj.get("rows", 24))
|
|
||||||
cols = int(obj.get("cols", 80))
|
|
||||||
if master_fd is not None:
|
|
||||||
_set_winsize(master_fd, rows, cols)
|
|
||||||
continue
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
payload = text.encode("utf-8")
|
|
||||||
|
|
||||||
if payload and master_fd is not None:
|
|
||||||
try:
|
|
||||||
os.write(master_fd, payload)
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
|
|
||||||
if process.returncode is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
running.clear()
|
if not session.closed:
|
||||||
if read_task is not None:
|
if session.ws is websocket:
|
||||||
read_task.cancel()
|
session.ws = None
|
||||||
try:
|
await mgr.detach_session(session.id)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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}
|
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(
|
def _run_compose(
|
||||||
stack: StackConfig,
|
stack: StackConfig,
|
||||||
*args: str,
|
*args: str,
|
||||||
@@ -240,10 +248,17 @@ def _run_compose(
|
|||||||
"compose",
|
"compose",
|
||||||
"--project-directory",
|
"--project-directory",
|
||||||
stack.compose_dir,
|
stack.compose_dir,
|
||||||
"--profile",
|
|
||||||
stack.profile,
|
|
||||||
*args,
|
|
||||||
]
|
]
|
||||||
|
compose_file = find_compose_file(stack.path)
|
||||||
|
if compose_file is not None:
|
||||||
|
cmd.extend(["-f", str(compose_file)])
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"--profile",
|
||||||
|
stack.profile,
|
||||||
|
*args,
|
||||||
|
]
|
||||||
|
)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -389,6 +404,36 @@ def stack_recreate(stack: StackConfig) -> str:
|
|||||||
return (proc.stdout + proc.stderr).strip()
|
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(
|
def set_stack_port(
|
||||||
stack: StackConfig,
|
stack: StackConfig,
|
||||||
port: int,
|
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__' \
|
--exclude '__pycache__' \
|
||||||
"${STACK_DIR}/" "${INSTALL_DIR}/"
|
"${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"
|
python3 -m venv "${INSTALL_DIR}/.venv"
|
||||||
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
|
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
|
||||||
"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -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).
|
# Print API key + browser instructions (shared by install scripts and show-api-key.sh).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
print_api_key_instructions() {
|
_read_api_key() {
|
||||||
local env_file="${1:-/opt/control-plane/.env}"
|
local env_file="$1"
|
||||||
local ui_port="${2:-8091}"
|
|
||||||
local api_key=""
|
local api_key=""
|
||||||
local lan_ip=""
|
|
||||||
|
|
||||||
if [[ -r "${env_file}" ]]; then
|
if [[ -r "${env_file}" ]]; then
|
||||||
api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
|
api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
|
||||||
elif [[ -f "${env_file}" ]]; then
|
elif [[ -f "${env_file}" ]]; then
|
||||||
api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
|
api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
|
||||||
fi
|
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}')"
|
lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
[[ -n "${lan_ip}" ]] || lan_ip="<IP-serwera>"
|
[[ -n "${lan_ip}" ]] || lan_ip="<IP-serwera>"
|
||||||
@@ -30,6 +47,33 @@ print_api_key_instructions() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
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 "1. Twój klucz API (skopiuj):"
|
||||||
echo ""
|
echo ""
|
||||||
echo " ${api_key}"
|
echo " ${api_key}"
|
||||||
@@ -45,13 +89,20 @@ print_api_key_instructions() {
|
|||||||
echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)"
|
echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)"
|
||||||
echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan"
|
echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan"
|
||||||
echo ""
|
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 "Plik klucza (na przyszłość):"
|
||||||
echo " sudo grep ^API_KEY= ${env_file}"
|
echo " sudo grep ^API_KEY= ${env_file}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "══════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
[[ "${verify_ok}" -eq 1 ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
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
|
fi
|
||||||
|
|||||||
@@ -9,16 +9,28 @@ PROD_ENV="/opt/control-plane/.env"
|
|||||||
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
|
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
|
||||||
UI_PORT=8091
|
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)"
|
UI_PORT="$(grep '^SERVER_UI_PORT=' "${DEV_ENV}" 2>/dev/null | cut -d= -f2- || echo 8091)"
|
||||||
fi
|
else
|
||||||
|
|
||||||
# Prefer dev copy when readable (same key after sync); else production.
|
|
||||||
ENV_FILE="${DEV_ENV}"
|
|
||||||
if [[ ! -r "${ENV_FILE}" ]]; then
|
|
||||||
ENV_FILE="${PROD_ENV}"
|
ENV_FILE="${PROD_ENV}"
|
||||||
fi
|
fi
|
||||||
|
[[ -n "${UI_PORT}" ]] || UI_PORT=8091
|
||||||
|
|
||||||
# shellcheck source=print-api-key-instructions.sh
|
# shellcheck source=print-api-key-instructions.sh
|
||||||
source "${SCRIPT_DIR}/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