💬 Commit message: Update 2026-02-15 08:03:38, 10 files, 372 lines
📁 Files changed: 10 📝 Lines changed: 372 • deploy.md • .coverage • deploy.sh • __init__.py • comfy_routes.py • index-BEOoAFTp.css • index-Wi7i1Rks.js • index.html • GenerateView.vue • app.ts
This commit is contained in:
@@ -11,13 +11,21 @@ Run the deploy script:
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
1. **Build UI** - Runs `npm run build` in `tensors/server/ui/`
|
1. **Build UI** - Runs `npm run build` in `tensors/server/ui/`
|
||||||
2. **Sync code** - Rsyncs Python code and static files to junkpile
|
2. **Sync code** - Rsyncs Python code to `/opt/tensors/app/`
|
||||||
3. **Restart tensors** - Runs `sudo systemctl restart tensors`
|
3. **Fix permissions** - Sets ownership to `tensors:tensors`
|
||||||
4. **Verify tensors** - Checks `/api/models/status` responds
|
4. **Restart tensors** - Runs `sudo systemctl restart tensors`
|
||||||
5. **Verify sd-server** - Checks sd-server is active
|
5. **Verify tensors** - Checks service is running
|
||||||
6. **Verify external** - Checks `sd-api.saiden.dev` responds
|
|
||||||
|
|
||||||
## Endpoints
|
## Service Structure
|
||||||
|
|
||||||
- **UI**: https://tensors.saiden.dev
|
| Item | Value |
|
||||||
- **API**: https://sd-api.saiden.dev (requires `X-API-Key` header)
|
|------|-------|
|
||||||
|
| User/Group | `tensors:tensors` |
|
||||||
|
| Install path | `/opt/tensors/app` |
|
||||||
|
| Venv | `/opt/tensors/venv` |
|
||||||
|
| Service | `tensors.service` |
|
||||||
|
| Port | 8081 |
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
- **Local**: http://junkpile:8081
|
||||||
|
|||||||
+13
-31
@@ -5,7 +5,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
REMOTE="chi@junkpile"
|
REMOTE="chi@junkpile"
|
||||||
REMOTE_DIR="~/Projects/tensors"
|
REMOTE_DIR="/opt/tensors/app"
|
||||||
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
echo "==> Building UI..."
|
echo "==> Building UI..."
|
||||||
@@ -23,8 +23,13 @@ rsync -av --delete \
|
|||||||
--exclude='.mypy_cache' \
|
--exclude='.mypy_cache' \
|
||||||
--exclude='.pytest_cache' \
|
--exclude='.pytest_cache' \
|
||||||
--exclude='*.egg-info' \
|
--exclude='*.egg-info' \
|
||||||
|
--rsync-path="sudo rsync" \
|
||||||
"$LOCAL_DIR/tensors/" "$REMOTE:$REMOTE_DIR/tensors/"
|
"$LOCAL_DIR/tensors/" "$REMOTE:$REMOTE_DIR/tensors/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Fixing permissions..."
|
||||||
|
ssh "$REMOTE" "sudo chown -R tensors:tensors $REMOTE_DIR && sudo chmod -R g+w $REMOTE_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> Restarting tensors service..."
|
echo "==> Restarting tensors service..."
|
||||||
ssh "$REMOTE" "sudo systemctl restart tensors"
|
ssh "$REMOTE" "sudo systemctl restart tensors"
|
||||||
@@ -34,39 +39,16 @@ echo "==> Waiting for tensors to start..."
|
|||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> Verifying tensors API..."
|
echo "==> Verifying tensors service..."
|
||||||
TENSORS_STATUS=$(ssh "$REMOTE" "curl -s localhost:8081/api/models/status" 2>/dev/null)
|
SERVICE_STATUS=$(ssh "$REMOTE" "systemctl is-active tensors" 2>/dev/null)
|
||||||
if echo "$TENSORS_STATUS" | grep -q '"active":true'; then
|
if [ "$SERVICE_STATUS" = "active" ]; then
|
||||||
echo "✓ tensors API responding"
|
echo "✓ tensors service running"
|
||||||
echo " Current model: $(echo "$TENSORS_STATUS" | jq -r '.current_model' | xargs basename)"
|
|
||||||
else
|
else
|
||||||
echo "✗ tensors API not responding"
|
echo "✗ tensors service not running"
|
||||||
echo "$TENSORS_STATUS"
|
ssh "$REMOTE" "journalctl -u tensors -n 10 --no-pager"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Verifying sd-server..."
|
|
||||||
SD_STATUS=$(ssh "$REMOTE" "curl -s localhost:1234/sdapi/v1/sd-models" 2>/dev/null)
|
|
||||||
if echo "$SD_STATUS" | grep -q 'model_name'; then
|
|
||||||
echo "✓ sd-server responding"
|
|
||||||
echo " Models available: $(echo "$SD_STATUS" | jq length)"
|
|
||||||
else
|
|
||||||
echo "✗ sd-server not responding"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Verifying external access..."
|
|
||||||
EXT_STATUS=$(curl -s -H "X-API-Key: v00YKDdHzLmwTLUJ07iMn4umLvcsKa9i" https://sd-api.saiden.dev/sdapi/v1/sd-models 2>/dev/null)
|
|
||||||
if echo "$EXT_STATUS" | grep -q 'model_name'; then
|
|
||||||
echo "✓ sd-api.saiden.dev responding"
|
|
||||||
else
|
|
||||||
echo "✗ sd-api.saiden.dev not responding"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> Deploy complete!"
|
echo "==> Deploy complete!"
|
||||||
echo " UI: https://tensors.saiden.dev"
|
echo " Access: http://junkpile:8081"
|
||||||
echo " API: https://sd-api.saiden.dev"
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from tensors.server.civitai_routes import create_civitai_router
|
from tensors.server.civitai_routes import create_civitai_router
|
||||||
|
from tensors.server.comfy_routes import create_comfy_router
|
||||||
from tensors.server.db_routes import create_db_router
|
from tensors.server.db_routes import create_db_router
|
||||||
from tensors.server.download_routes import create_download_router
|
from tensors.server.download_routes import create_download_router
|
||||||
from tensors.server.gallery_routes import create_gallery_router
|
from tensors.server.gallery_routes import create_gallery_router
|
||||||
@@ -53,6 +54,7 @@ def create_app() -> FastAPI:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
app.include_router(create_civitai_router())
|
app.include_router(create_civitai_router())
|
||||||
|
app.include_router(create_comfy_router())
|
||||||
app.include_router(create_db_router())
|
app.include_router(create_db_router())
|
||||||
app.include_router(create_gallery_router())
|
app.include_router(create_gallery_router())
|
||||||
app.include_router(create_download_router())
|
app.include_router(create_download_router())
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""ComfyUI integration routes for model management and generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from tensors.comfy import ComfyClient, get_last_checkpoint, save_last_checkpoint
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COMFY_URL = "http://junkpile:8188"
|
||||||
|
|
||||||
|
|
||||||
|
def get_comfy_client() -> ComfyClient:
|
||||||
|
"""Get a ComfyUI client instance."""
|
||||||
|
return ComfyClient(base_url=COMFY_URL)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelInfo(BaseModel):
|
||||||
|
"""Model information."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
filename: str
|
||||||
|
size_mb: float = 0
|
||||||
|
modified: int = 0
|
||||||
|
category: str = "sd15"
|
||||||
|
|
||||||
|
|
||||||
|
class LoRAInfo(BaseModel):
|
||||||
|
"""LoRA information."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
filename: str
|
||||||
|
size_mb: float = 0
|
||||||
|
modified: int = 0
|
||||||
|
category: str = "sd15"
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchModelRequest(BaseModel):
|
||||||
|
"""Request to switch models."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoraConfig(BaseModel):
|
||||||
|
"""LoRA configuration for generation."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
multiplier: float = 0.8
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
"""Image generation request."""
|
||||||
|
|
||||||
|
prompt: str
|
||||||
|
negative_prompt: str = ""
|
||||||
|
width: int = 512
|
||||||
|
height: int = 512
|
||||||
|
steps: int = 20
|
||||||
|
cfg_scale: float = 7.0
|
||||||
|
seed: int = -1
|
||||||
|
sampler: str = "euler_ancestral"
|
||||||
|
save_to_gallery: bool = True
|
||||||
|
lora: LoraConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedImage(BaseModel):
|
||||||
|
"""Generated image info."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
data: str # base64 encoded
|
||||||
|
seed: int
|
||||||
|
|
||||||
|
|
||||||
|
def create_comfy_router() -> APIRouter: # noqa: PLR0915
|
||||||
|
"""Create the ComfyUI integration router."""
|
||||||
|
router = APIRouter(prefix="/api", tags=["comfy"])
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def list_models() -> dict[str, Any]:
|
||||||
|
"""List available checkpoint models."""
|
||||||
|
try:
|
||||||
|
client = get_comfy_client()
|
||||||
|
checkpoints = client.get_checkpoints()
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for ckpt in checkpoints:
|
||||||
|
# Determine category based on filename
|
||||||
|
lower = ckpt.lower()
|
||||||
|
category = "large" if "xl" in lower or "pony" in lower else "sd15"
|
||||||
|
|
||||||
|
models.append(
|
||||||
|
ModelInfo(
|
||||||
|
name=ckpt.replace(".safetensors", "").replace(".ckpt", ""),
|
||||||
|
path=ckpt,
|
||||||
|
filename=ckpt,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"models": [m.model_dump() for m in models], "total": len(models)}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.exception("Failed to connect to ComfyUI")
|
||||||
|
raise HTTPException(status_code=503, detail=f"ComfyUI not available: {e}") from e
|
||||||
|
|
||||||
|
@router.get("/models/active")
|
||||||
|
async def get_active_model() -> dict[str, Any]:
|
||||||
|
"""Get the currently active/selected model."""
|
||||||
|
last = get_last_checkpoint()
|
||||||
|
return {"loaded": last is not None, "model": last}
|
||||||
|
|
||||||
|
@router.post("/models/switch")
|
||||||
|
async def switch_model(request: SwitchModelRequest) -> dict[str, Any]:
|
||||||
|
"""Switch to a different model (saves preference, actual load happens on generation)."""
|
||||||
|
old_model = get_last_checkpoint()
|
||||||
|
save_last_checkpoint(request.model)
|
||||||
|
return {"ok": True, "old_model": old_model, "new_model": request.model}
|
||||||
|
|
||||||
|
@router.get("/models/status")
|
||||||
|
async def get_status() -> dict[str, Any]:
|
||||||
|
"""Get ComfyUI server status."""
|
||||||
|
try:
|
||||||
|
resp = httpx.get(f"{COMFY_URL}/system_stats", timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
stats = resp.json()
|
||||||
|
|
||||||
|
device = stats.get("devices", [{}])[0]
|
||||||
|
return {
|
||||||
|
"service": "comfyui",
|
||||||
|
"active": True,
|
||||||
|
"status": "running",
|
||||||
|
"current_model": get_last_checkpoint(),
|
||||||
|
"host": "junkpile",
|
||||||
|
"port": "8188",
|
||||||
|
"version": stats.get("system", {}).get("comfyui_version"),
|
||||||
|
"gpu": device.get("name"),
|
||||||
|
"vram_total": device.get("vram_total"),
|
||||||
|
"vram_free": device.get("vram_free"),
|
||||||
|
}
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {
|
||||||
|
"service": "comfyui",
|
||||||
|
"active": False,
|
||||||
|
"status": "offline",
|
||||||
|
"current_model": None,
|
||||||
|
"host": "junkpile",
|
||||||
|
"port": "8188",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/models/loras")
|
||||||
|
async def list_loras() -> dict[str, Any]:
|
||||||
|
"""List available LoRAs."""
|
||||||
|
try:
|
||||||
|
client = get_comfy_client()
|
||||||
|
loras = client.get_loras()
|
||||||
|
|
||||||
|
lora_list = []
|
||||||
|
for lora in loras:
|
||||||
|
lower = lora.lower()
|
||||||
|
category = "large" if "xl" in lower or "pony" in lower else "sd15"
|
||||||
|
|
||||||
|
lora_list.append(
|
||||||
|
LoRAInfo(
|
||||||
|
name=lora.replace(".safetensors", ""),
|
||||||
|
path=lora,
|
||||||
|
filename=lora,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"loras": [lo.model_dump() for lo in lora_list], "total": len(lora_list)}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.exception("Failed to connect to ComfyUI")
|
||||||
|
raise HTTPException(status_code=503, detail=f"ComfyUI not available: {e}") from e
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_image(request: GenerateRequest) -> dict[str, Any]:
|
||||||
|
"""Generate an image using ComfyUI."""
|
||||||
|
try:
|
||||||
|
client = get_comfy_client()
|
||||||
|
|
||||||
|
# Use last checkpoint or first available
|
||||||
|
checkpoint = get_last_checkpoint()
|
||||||
|
if not checkpoint:
|
||||||
|
checkpoints = client.get_checkpoints()
|
||||||
|
if not checkpoints:
|
||||||
|
raise HTTPException(status_code=400, detail="No checkpoints available")
|
||||||
|
checkpoint = checkpoints[0]
|
||||||
|
save_last_checkpoint(checkpoint)
|
||||||
|
|
||||||
|
# Generate random seed if not specified
|
||||||
|
seed = request.seed if request.seed >= 0 else random.randint(0, 2**32 - 1)
|
||||||
|
|
||||||
|
# Build generation params
|
||||||
|
gen_kwargs: dict[str, Any] = {
|
||||||
|
"prompt": request.prompt,
|
||||||
|
"negative_prompt": request.negative_prompt,
|
||||||
|
"checkpoint": checkpoint,
|
||||||
|
"width": request.width,
|
||||||
|
"height": request.height,
|
||||||
|
"steps": request.steps,
|
||||||
|
"cfg": request.cfg_scale,
|
||||||
|
"seed": seed,
|
||||||
|
"sampler": request.sampler,
|
||||||
|
"auto_restart": False, # Don't restart container from web UI
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add LoRA if specified
|
||||||
|
if request.lora:
|
||||||
|
gen_kwargs["lora"] = request.lora.path
|
||||||
|
gen_kwargs["lora_strength"] = request.lora.multiplier
|
||||||
|
|
||||||
|
result = client.generate(**gen_kwargs)
|
||||||
|
|
||||||
|
# Get image data
|
||||||
|
images = []
|
||||||
|
for img_info in result.get("images", []):
|
||||||
|
img_data = client.get_image(
|
||||||
|
img_info["filename"],
|
||||||
|
img_info.get("subfolder", ""),
|
||||||
|
img_info.get("type", "output"),
|
||||||
|
)
|
||||||
|
images.append(
|
||||||
|
GeneratedImage(
|
||||||
|
id=img_info["filename"],
|
||||||
|
data=base64.b64encode(img_data).decode(),
|
||||||
|
seed=result.get("seed", seed),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"images": [img.model_dump() for img in images]}
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.exception("Failed to connect to ComfyUI")
|
||||||
|
raise HTTPException(status_code=503, detail=f"ComfyUI not available: {e}") from e
|
||||||
|
except TimeoutError as e:
|
||||||
|
logger.exception("Generation timed out")
|
||||||
|
raise HTTPException(status_code=504, detail=str(e)) from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Generation failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
|
||||||
|
return router
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tensors</title>
|
<title>Tensors</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DEHUU-Zz.js"></script>
|
<script type="module" crossorigin src="/assets/index-Wi7i1Rks.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Ljwp9hgM.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BEOoAFTp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async function generate() {
|
|||||||
// Build final prompt with quality tags
|
// Build final prompt with quality tags
|
||||||
const finalPrompt = `${store.defaultQualityTags}, ${currentPrompt}`
|
const finalPrompt = `${store.defaultQualityTags}, ${currentPrompt}`
|
||||||
|
|
||||||
// Get LoRA config if selected (sd-server expects LoRA as separate param with filename, not in prompt)
|
// Get LoRA config if selected
|
||||||
const selectedLoraModel = store.selectedLora ? store.loras.find(l => l.path === store.selectedLora) : null
|
const selectedLoraModel = store.selectedLora ? store.loras.find(l => l.path === store.selectedLora) : null
|
||||||
const loraConfig = selectedLoraModel ? { path: selectedLoraModel.filename, multiplier: store.loraWeight } : undefined
|
const loraConfig = selectedLoraModel ? { path: selectedLoraModel.filename, multiplier: store.loraWeight } : undefined
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ async function generate() {
|
|||||||
<v-card class="pa-6 text-center" min-width="300">
|
<v-card class="pa-6 text-center" min-width="300">
|
||||||
<v-progress-circular indeterminate color="primary" size="48" class="mb-4" />
|
<v-progress-circular indeterminate color="primary" size="48" class="mb-4" />
|
||||||
<div class="text-h6">{{ store.switchMessage || 'Switching model...' }}</div>
|
<div class="text-h6">{{ store.switchMessage || 'Switching model...' }}</div>
|
||||||
<div class="text-caption text-grey mt-2">sd-server is restarting</div>
|
<div class="text-caption text-grey mt-2">Model will load on next generation</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-overlay>
|
</v-overlay>
|
||||||
|
|
||||||
|
|||||||
@@ -163,29 +163,11 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.switchModel(modelPath)
|
const result = await api.switchModel(modelPath)
|
||||||
switchMessage.value = 'Restarting sd-server...'
|
// ComfyUI loads models on-demand, no restart needed
|
||||||
|
|
||||||
// Poll for server to come back online (up to 60 seconds)
|
|
||||||
let attempts = 0
|
|
||||||
const maxAttempts = 30
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
||||||
try {
|
|
||||||
const status = await api.getServerStatus()
|
|
||||||
if (status.active && status.current_model?.includes(modelPath.split('/').pop() || '')) {
|
|
||||||
activeModel.value = result.new_model
|
activeModel.value = result.new_model
|
||||||
selectedModel.value = result.new_model
|
selectedModel.value = modelPath
|
||||||
switchMessage.value = 'Model switched successfully'
|
switchMessage.value = 'Model selected'
|
||||||
setTimeout(() => { switchMessage.value = null }, 3000)
|
setTimeout(() => { switchMessage.value = null }, 2000)
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server still restarting, continue polling
|
|
||||||
}
|
|
||||||
attempts++
|
|
||||||
switchMessage.value = `Waiting for sd-server... (${attempts}/${maxAttempts})`
|
|
||||||
}
|
|
||||||
throw new Error('Timeout waiting for sd-server to restart')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to switch model:', error)
|
console.error('Failed to switch model:', error)
|
||||||
switchError.value = error.message || 'Failed to switch model'
|
switchError.value = error.message || 'Failed to switch model'
|
||||||
|
|||||||
Reference in New Issue
Block a user