💬 Commit message: Update 2026-02-15 08:34:49, 38 files, 4477 lines

📁 Files changed: 38
📝 Lines changed: 4477

  • deploy.md
  • .coverage
  • deploy.sh
  • cli.py
  • comfy.py
  • __init__.py
  • comfy_routes.py
  • index-BEOoAFTp.css
  • index-Wi7i1Rks.js
  • materialdesignicons-webfont-B7mPwVP_.ttf
  • materialdesignicons-webfont-CSr8KVlo.eot
  • materialdesignicons-webfont-Dp5v-WZN.woff2
  • materialdesignicons-webfont-PXm3-2wK.woff
  • index.html
  • vite.svg
  • .gitignore
  • extensions.json
  • README.md
  • index.html
  • package-lock.json
  • package.json
  • vite.svg
  • App.vue
  • client.ts
  • vue.svg
  • DownloadsPanel.vue
  • GalleryView.vue
  • GenerateView.vue
  • ModelCard.vue
  • SearchView.vue
  • main.ts
  • app.ts
  • index.ts
  • vite-env.d.ts
  • tsconfig.app.json
  • tsconfig.json
  • tsconfig.node.json
  • vite.config.ts
This commit is contained in:
Adam Ladachowski
2026-02-15 08:34:49 +01:00
parent 3638bd7d81
commit 1d1cd36594
38 changed files with 12 additions and 4465 deletions
-208
View File
@@ -8,7 +8,6 @@ from importlib.metadata import version
from pathlib import Path
from typing import Annotated, Any
import httpx
import typer
from rich.console import Console
from rich.table import Table
@@ -654,212 +653,6 @@ def db_stats(
console.print(table)
# =============================================================================
# ComfyUI Commands
# =============================================================================
comfy_app = typer.Typer(
name="comfy",
help="ComfyUI client commands.",
no_args_is_help=True,
)
app.add_typer(comfy_app, name="comfy")
COMFY_DEFAULT_URL = "http://junkpile:8188"
@comfy_app.command("status")
def comfy_status(
url: Annotated[str, typer.Option("--url", "-u", help="ComfyUI server URL")] = COMFY_DEFAULT_URL,
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
) -> None:
"""Show ComfyUI server status."""
try:
resp = httpx.get(f"{url}/system_stats", timeout=10)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
console.print(f"[red]Error: Cannot connect to ComfyUI at {url}: {e}[/red]")
raise typer.Exit(1) from e
if json_output:
console.print_json(data=data)
return
system = data.get("system", {})
devices = data.get("devices", [])
table = Table(title="ComfyUI Status", show_header=True, header_style="bold magenta")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
table.add_row("URL", url)
table.add_row("Version", system.get("comfyui_version", "N/A"))
table.add_row("Python", system.get("python_version", "N/A").split()[0])
table.add_row("PyTorch", system.get("pytorch_version", "N/A"))
table.add_row("RAM Free", f"{system.get('ram_free', 0) / 1024**3:.1f} GB")
for dev in devices:
vram_free = dev.get("vram_free", 0) / 1024**3
vram_total = dev.get("vram_total", 0) / 1024**3
table.add_row(f"GPU {dev.get('index', 0)}", f"{dev.get('name', 'N/A')} ({vram_free:.1f}/{vram_total:.1f} GB free)")
console.print(table)
@comfy_app.command("models")
def comfy_models(
url: Annotated[str, typer.Option("--url", "-u", help="ComfyUI server URL")] = COMFY_DEFAULT_URL,
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
) -> None:
"""List available checkpoints in ComfyUI."""
from tensors.comfy import ComfyClient # noqa: PLC0415
try:
client = ComfyClient(url)
checkpoints = client.get_checkpoints()
except httpx.HTTPError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e
if json_output:
console.print_json(data={"checkpoints": checkpoints})
return
if not checkpoints:
console.print("[yellow]No checkpoints found.[/yellow]")
return
table = Table(title="ComfyUI Checkpoints", show_header=True, header_style="bold magenta")
table.add_column("#", style="dim", width=3)
table.add_column("Name", style="cyan")
for i, ckpt in enumerate(checkpoints, 1):
table.add_row(str(i), ckpt)
console.print(table)
@comfy_app.command("loras")
def comfy_loras(
url: Annotated[str, typer.Option("--url", "-u", help="ComfyUI server URL")] = COMFY_DEFAULT_URL,
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
) -> None:
"""List available LoRAs in ComfyUI."""
from tensors.comfy import ComfyClient # noqa: PLC0415
try:
client = ComfyClient(url)
loras = client.get_loras()
except httpx.HTTPError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e
if json_output:
console.print_json(data={"loras": loras})
return
if not loras:
console.print("[yellow]No LoRAs found.[/yellow]")
return
table = Table(title="ComfyUI LoRAs", show_header=True, header_style="bold magenta")
table.add_column("#", style="dim", width=3)
table.add_column("Name", style="cyan")
for i, lora in enumerate(loras, 1):
table.add_row(str(i), lora)
console.print(table)
@comfy_app.command("generate")
def comfy_generate(
prompt: Annotated[str, typer.Argument(help="Text prompt for generation")],
url: Annotated[str, typer.Option("--url", "-u", help="ComfyUI server URL")] = COMFY_DEFAULT_URL,
checkpoint: Annotated[str | None, typer.Option("-m", "--model", help="Checkpoint model name")] = None,
lora: Annotated[str | None, typer.Option("--lora", "-l", help="LoRA name")] = None,
lora_strength: Annotated[float, typer.Option("--lora-strength", help="LoRA strength")] = 0.8,
negative: Annotated[str, typer.Option("-n", "--negative", help="Negative prompt")] = "",
width: Annotated[int, typer.Option("-W", "--width", help="Image width")] = 512,
height: Annotated[int, typer.Option("-H", "--height", help="Image height")] = 512,
steps: Annotated[int, typer.Option("--steps", help="Sampling steps")] = 20,
cfg: Annotated[float, typer.Option("--cfg", help="CFG scale")] = 7.0,
seed: Annotated[int, typer.Option("-s", "--seed", help="RNG seed (-1 for random)")] = -1,
sampler: Annotated[str, typer.Option("--sampler", help="Sampler name")] = "euler_ancestral",
output: Annotated[Path | None, typer.Option("-o", "--output", help="Output file path")] = None,
no_restart: Annotated[bool, typer.Option("--no-restart", help="Don't auto-restart on model change")] = False,
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
) -> None:
"""Generate an image using ComfyUI."""
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn # noqa: PLC0415
from tensors.comfy import ComfyClient # noqa: PLC0415
progress_task = None
progress_ctx = None
def on_status(msg: str) -> None:
console.print(f"[cyan]{msg}[/cyan]")
def on_progress(current: int, total: int, stage: str) -> None: # noqa: ARG001
nonlocal progress_task, progress_ctx
if progress_ctx is None:
progress_ctx = Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
)
progress_ctx.start()
progress_task = progress_ctx.add_task("Sampling", total=total)
if progress_task is not None:
progress_ctx.update(progress_task, completed=current)
try:
client = ComfyClient(url)
console.print(f"[dim]ComfyUI: {url}[/dim]")
result = client.generate(
prompt=prompt,
negative_prompt=negative,
checkpoint=checkpoint,
lora=lora,
lora_strength=lora_strength,
width=width,
height=height,
steps=steps,
cfg=cfg,
seed=seed,
sampler=sampler,
on_status=on_status,
on_progress=on_progress,
auto_restart=not no_restart,
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e
finally:
if progress_ctx:
progress_ctx.stop()
if json_output:
console.print_json(data=result)
return
lora_info = f", LoRA: {result['lora']}" if result.get("lora") else ""
console.print(f"[green]Generated![/green] Seed: {result['seed']}, Checkpoint: {result['checkpoint']}{lora_info}")
if result["images"]:
img_info = result["images"][0]
console.print(f"[dim]Image: {img_info['filename']}[/dim]")
if output:
img_data = client.get_image(img_info["filename"], img_info["subfolder"], img_info["type"])
output.write_bytes(img_data)
console.print(f"[green]Saved to:[/green] {output}")
def main() -> int:
"""Main entry point."""
# Handle legacy invocation: tsr <file.safetensors> -> tsr info <file>
@@ -872,7 +665,6 @@ def main() -> int:
"config",
"serve",
"db",
"comfy",
)
if len(sys.argv) > 1 and not sys.argv[1].startswith("-"):
arg = sys.argv[1]
-360
View File
@@ -1,360 +0,0 @@
"""Simple ComfyUI client for basic txt2img generation."""
from __future__ import annotations
import json
import subprocess
import time
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any
import httpx
if TYPE_CHECKING:
from collections.abc import Callable
from tensors.config import load_config, save_config
DEFAULT_WORKFLOW = {
"3": {
"class_type": "KSampler",
"inputs": {
"cfg": 7,
"denoise": 1,
"latent_image": ["5", 0],
"model": ["4", 0],
"negative": ["7", 0],
"positive": ["6", 0],
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"seed": -1,
"steps": 20,
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ""},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"batch_size": 1, "height": 512, "width": 512},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["4", 1], "text": ""},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["4", 1], "text": ""},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy", "images": ["8", 0]},
},
}
COMFY_CONTAINER = "comfyui"
COMFY_HOST = "junkpile"
def get_last_checkpoint() -> str | None:
"""Get last used checkpoint from config."""
cfg = load_config()
value = cfg.get("comfy", {}).get("last_checkpoint")
return str(value) if value else None
def save_last_checkpoint(checkpoint: str) -> None:
"""Save last used checkpoint to config."""
cfg = load_config()
if "comfy" not in cfg:
cfg["comfy"] = {}
cfg["comfy"]["last_checkpoint"] = checkpoint
save_config(cfg)
def restart_comfy_container(on_status: Callable[[str], None] | None = None) -> None:
"""Restart the ComfyUI container on junkpile and wait for it to be ready."""
def status(msg: str) -> None:
if on_status:
on_status(msg)
status("Restarting ComfyUI container...")
subprocess.run(
["ssh", COMFY_HOST, f"docker restart {COMFY_CONTAINER}"],
check=True,
capture_output=True,
)
# Wait for ComfyUI to be ready
status("Waiting for ComfyUI to start...")
max_wait = 120
start = time.time()
while time.time() - start < max_wait:
try:
resp = httpx.get(f"http://{COMFY_HOST}:8188/system_stats", timeout=5)
if resp.is_success:
status("ComfyUI is ready!")
return
except httpx.HTTPError:
pass
time.sleep(2)
raise TimeoutError(f"ComfyUI did not start within {max_wait}s")
class ComfyClient:
"""Simple ComfyUI API client."""
def __init__(self, base_url: str = "http://127.0.0.1:8188", timeout: float = 300.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.client_id = str(uuid.uuid4())
def get_checkpoints(self) -> list[str]:
"""List available checkpoint models."""
resp = httpx.get(f"{self.base_url}/object_info/CheckpointLoaderSimple", timeout=10)
resp.raise_for_status()
data = resp.json()
return list(data.get("CheckpointLoaderSimple", {}).get("input", {}).get("required", {}).get("ckpt_name", [[]])[0])
def get_loras(self) -> list[str]:
"""List available LoRAs."""
resp = httpx.get(f"{self.base_url}/object_info/LoraLoader", timeout=10)
resp.raise_for_status()
data = resp.json()
return list(data.get("LoraLoader", {}).get("input", {}).get("required", {}).get("lora_name", [[]])[0])
def get_samplers(self) -> list[str]:
"""List available samplers."""
resp = httpx.get(f"{self.base_url}/object_info/KSampler", timeout=10)
resp.raise_for_status()
data = resp.json()
return list(data.get("KSampler", {}).get("input", {}).get("required", {}).get("sampler_name", [[]])[0])
def queue_prompt(self, workflow: dict[str, Any]) -> str:
"""Queue a prompt and return the prompt_id."""
payload = {"prompt": workflow, "client_id": self.client_id}
resp = httpx.post(f"{self.base_url}/prompt", json=payload, timeout=30)
resp.raise_for_status()
return str(resp.json()["prompt_id"])
def get_history(self, prompt_id: str) -> dict[str, Any] | None:
"""Get history for a prompt_id."""
resp = httpx.get(f"{self.base_url}/history/{prompt_id}", timeout=10)
if resp.is_success:
data = resp.json()
return dict(data.get(prompt_id, {})) if prompt_id in data else None
return None
def wait_for_completion(
self,
prompt_id: str,
on_progress: Callable[[int, int, str], None] | None = None,
) -> dict[str, Any]:
"""Wait for prompt completion with progress updates via websocket."""
import websocket # noqa: PLC0415
ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_url}/ws?clientId={self.client_id}"
result: dict[str, Any] = {}
completed = False
def on_message(ws: Any, message: str) -> None: # noqa: ARG001
nonlocal completed, result
try:
data = json.loads(message)
msg_type = data.get("type")
if msg_type == "progress":
progress_data = data.get("data", {})
current = progress_data.get("value", 0)
total = progress_data.get("max", 1)
if on_progress:
on_progress(current, total, "sampling")
elif msg_type == "executing":
exec_data = data.get("data", {})
if exec_data.get("node") is None and exec_data.get("prompt_id") == prompt_id:
# Execution finished
completed = True
elif msg_type == "executed":
exec_data = data.get("data", {})
if exec_data.get("prompt_id") == prompt_id:
result = exec_data
except json.JSONDecodeError:
pass
def on_error(ws: Any, error: Exception) -> None: # noqa: ARG001
nonlocal completed
completed = True
def on_close(ws: Any, close_status_code: int, close_msg: str) -> None: # noqa: ARG001
nonlocal completed
completed = True
ws = websocket.WebSocketApp(
ws_url,
on_message=on_message,
on_error=on_error,
on_close=on_close,
)
# Run websocket in a thread
import threading # noqa: PLC0415
ws_thread = threading.Thread(target=ws.run_forever)
ws_thread.daemon = True
ws_thread.start()
# Wait for completion
start = time.time()
while not completed and time.time() - start < self.timeout:
time.sleep(0.1)
ws.close()
if not completed:
raise TimeoutError(f"Prompt {prompt_id} did not complete within {self.timeout}s")
# Get final history
history = self.get_history(prompt_id)
if not history:
raise RuntimeError(f"Could not get history for prompt {prompt_id}")
return history
def get_image(self, filename: str, subfolder: str = "", folder_type: str = "output") -> bytes:
"""Download an image from ComfyUI."""
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
resp = httpx.get(f"{self.base_url}/view", params=params, timeout=30)
resp.raise_for_status()
return resp.content
def generate(
self,
prompt: str,
negative_prompt: str = "",
checkpoint: str | None = None,
lora: str | None = None,
lora_strength: float = 0.8,
width: int = 512,
height: int = 512,
steps: int = 20,
cfg: float = 7.0,
seed: int = -1,
sampler: str = "euler_ancestral",
scheduler: str = "normal",
on_progress: Callable[[int, int, str], None] | None = None,
on_status: Callable[[str], None] | None = None,
auto_restart: bool = True,
) -> dict[str, Any]:
"""Generate an image with a simple txt2img workflow."""
# Use first checkpoint if not specified
if not checkpoint:
# Try last used checkpoint first
checkpoint = get_last_checkpoint()
if not checkpoint:
checkpoints = self.get_checkpoints()
if not checkpoints:
raise ValueError("No checkpoints available")
checkpoint = checkpoints[0]
# Check if we need to restart container for model change
if auto_restart:
last_checkpoint = get_last_checkpoint()
if last_checkpoint and last_checkpoint != checkpoint:
if on_status:
on_status(f"Model changed: {last_checkpoint} -> {checkpoint}")
restart_comfy_container(on_status)
# Save checkpoint as last used
save_last_checkpoint(checkpoint)
# Build workflow
workflow = json.loads(json.dumps(DEFAULT_WORKFLOW))
workflow["4"]["inputs"]["ckpt_name"] = checkpoint
workflow["5"]["inputs"]["width"] = width
workflow["5"]["inputs"]["height"] = height
workflow["6"]["inputs"]["text"] = prompt
workflow["7"]["inputs"]["text"] = negative_prompt
workflow["3"]["inputs"]["steps"] = steps
workflow["3"]["inputs"]["cfg"] = cfg
workflow["3"]["inputs"]["seed"] = seed if seed >= 0 else int(time.time() * 1000) % (2**32)
workflow["3"]["inputs"]["sampler_name"] = sampler
workflow["3"]["inputs"]["scheduler"] = scheduler
# Add LoRA if specified
if lora:
workflow["10"] = {
"class_type": "LoraLoader",
"inputs": {
"lora_name": lora,
"strength_model": lora_strength,
"strength_clip": lora_strength,
"model": ["4", 0],
"clip": ["4", 1],
},
}
# Rewire: KSampler uses LoRA output instead of checkpoint
workflow["3"]["inputs"]["model"] = ["10", 0]
# Rewire: CLIP encoders use LoRA output
workflow["6"]["inputs"]["clip"] = ["10", 1]
workflow["7"]["inputs"]["clip"] = ["10", 1]
if on_status:
on_status("Queueing prompt...")
# Queue and wait
prompt_id = self.queue_prompt(workflow)
if on_status:
on_status("Generating...")
history = self.wait_for_completion(prompt_id, on_progress)
# Extract output images
outputs = history.get("outputs", {})
images = []
for _node_id, node_output in outputs.items():
if "images" in node_output:
for img in node_output["images"]:
images.append({
"filename": img["filename"],
"subfolder": img.get("subfolder", ""),
"type": img.get("type", "output"),
})
return {
"prompt_id": prompt_id,
"images": images,
"checkpoint": checkpoint,
"lora": lora,
"seed": workflow["3"]["inputs"]["seed"],
}
def generate_and_save(
self,
prompt: str,
output_path: str | Path,
**kwargs: Any,
) -> Path:
"""Generate an image and save it locally."""
result = self.generate(prompt, **kwargs)
if not result["images"]:
raise RuntimeError("No images generated")
img_info = result["images"][0]
img_data = self.get_image(img_info["filename"], img_info["subfolder"], img_info["type"])
output = Path(output_path)
output.write_bytes(img_data)
return output
-19
View File
@@ -4,15 +4,11 @@ from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
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.download_routes import create_download_router
from tensors.server.gallery_routes import create_gallery_router
@@ -35,26 +31,11 @@ def create_app() -> FastAPI:
app = FastAPI(title="tensors", lifespan=lifespan)
# Serve Vue UI static files
static_dir = Path(__file__).parent / "static"
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/", include_in_schema=False)
async def gallery_ui() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/vite.svg", include_in_schema=False)
async def vite_icon() -> FileResponse:
return FileResponse(static_dir / "vite.svg")
@app.get("/status")
async def status() -> dict[str, str]:
return {"status": "ok"}
app.include_router(create_civitai_router())
app.include_router(create_comfy_router())
app.include_router(create_db_router())
app.include_router(create_gallery_router())
app.include_router(create_download_router())
-252
View File
@@ -1,252 +0,0 @@
"""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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tensors</title>
<script type="module" crossorigin src="/assets/index-Wi7i1Rks.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEOoAFTp.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}
-5
View File
@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tensors</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-1663
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"pinia": "^3.0.4",
"vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.25",
"vuetify": "^3.11.8"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-76
View File
@@ -1,76 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import GenerateView from '@/components/GenerateView.vue'
import SearchView from '@/components/SearchView.vue'
import GalleryView from '@/components/GalleryView.vue'
import DownloadsPanel from '@/components/DownloadsPanel.vue'
const store = useAppStore()
onMounted(() => {
store.loadModels()
store.pollDownloads() // Check for any active downloads on load
})
</script>
<template>
<v-app>
<v-navigation-drawer permanent rail>
<v-list density="compact" nav>
<v-list-item
:active="store.currentView === 'generate'"
@click="store.currentView = 'generate'"
prepend-icon="mdi-auto-fix"
title="Generate"
/>
<v-list-item
:active="store.currentView === 'search'"
@click="store.currentView = 'search'"
prepend-icon="mdi-magnify"
title="Search"
/>
<v-list-item
:active="store.currentView === 'gallery'"
@click="store.currentView = 'gallery'"
prepend-icon="mdi-image-multiple"
title="Gallery"
/>
<v-divider class="my-2" />
<v-list-item
@click="store.showDownloadsPanel = true"
title="Downloads"
>
<template #prepend>
<v-badge
v-if="store.hasActiveDownloads"
:content="store.activeDownloads.length"
color="primary"
offset-x="-2"
offset-y="-2"
>
<v-icon>mdi-download</v-icon>
</v-badge>
<v-icon v-else>mdi-download</v-icon>
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
<DownloadsPanel />
<v-main>
<GenerateView v-if="store.currentView === 'generate'" />
<SearchView v-else-if="store.currentView === 'search'" />
<GalleryView v-else-if="store.currentView === 'gallery'" />
</v-main>
</v-app>
</template>
<style>
html, body {
overflow: hidden;
}
</style>
-156
View File
@@ -1,156 +0,0 @@
import type { Model, LoRA, GeneratedImage, GalleryImage, CivitaiModel } from '@/types'
const BASE_URL = ''
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || response.statusText)
}
return response.json()
}
// Models
export async function getModels(): Promise<{ models: Model[]; total: number }> {
return fetchJson('/api/models')
}
export async function getActiveModel(): Promise<{ loaded: boolean; model: string | null }> {
return fetchJson('/api/models/active')
}
export async function switchModel(model: string): Promise<{ ok: boolean; old_model: string; new_model: string }> {
return fetchJson('/api/models/switch', {
method: 'POST',
body: JSON.stringify({ model }),
})
}
export interface ServerStatus {
service: string
active: boolean
status: string
current_model: string | null
host: string | null
port: string | null
}
export async function getServerStatus(): Promise<ServerStatus> {
return fetchJson('/api/models/status')
}
export async function getLoras(): Promise<{ loras: LoRA[]; total: number }> {
return fetchJson('/api/models/loras')
}
// Generation
export interface LoraConfig {
path: string
multiplier: number
}
export interface GenerateParams {
prompt: string
negative_prompt?: string
width: number
height: number
steps: number
cfg_scale?: number
seed?: number
save_to_gallery?: boolean
lora?: LoraConfig
}
export async function generate(params: GenerateParams): Promise<{ images: GeneratedImage[] }> {
return fetchJson('/api/generate', {
method: 'POST',
body: JSON.stringify(params),
})
}
// Gallery
export async function getImages(limit = 100): Promise<{ images: GalleryImage[] }> {
return fetchJson(`/api/images?limit=${limit}`)
}
export async function deleteImage(id: string): Promise<void> {
await fetchJson(`/api/images/${id}`, { method: 'DELETE' })
}
export function getImageUrl(id: string): string {
return `${BASE_URL}/api/images/${id}`
}
// CivitAI Search
export interface SearchParams {
query?: string
types?: string
baseModels?: string
sort?: string
limit?: number
}
export async function searchCivitai(params: SearchParams): Promise<{ items: CivitaiModel[] }> {
const searchParams = new URLSearchParams()
if (params.query) searchParams.set('query', params.query)
if (params.types) searchParams.set('types', params.types)
if (params.baseModels) searchParams.set('baseModels', params.baseModels)
if (params.sort) searchParams.set('sort', params.sort)
if (params.limit) searchParams.set('limit', String(params.limit))
return fetchJson(`/api/civitai/search?${searchParams}`)
}
export async function getCivitaiModel(id: number): Promise<CivitaiModel> {
return fetchJson(`/api/civitai/model/${id}`)
}
// Download
export interface DownloadResponse {
download_id: string
status: string
version_id: number
destination: string
model_name: string
version_name: string
}
export interface DownloadStatus {
id: string
version_id: number
status: 'queued' | 'downloading' | 'completed' | 'failed'
path: string
filename: string
model_name: string
version_name: string
downloaded?: number
total?: number
progress?: number
speed?: number
downloaded_str?: string
total_str?: string
speed_str?: string
error?: string
}
export async function downloadModel(modelId?: number, versionId?: number): Promise<DownloadResponse> {
return fetchJson('/api/download', {
method: 'POST',
body: JSON.stringify({ model_id: modelId, version_id: versionId }),
})
}
export async function getDownloadStatus(downloadId: string): Promise<DownloadStatus> {
return fetchJson(`/api/download/status/${downloadId}`)
}
export async function getActiveDownloads(): Promise<{ downloads: DownloadStatus[]; total: number }> {
return fetchJson('/api/download/active')
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

@@ -1,130 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
// Start polling when panel opens or when there are active downloads
watch(() => store.showDownloadsPanel, (show) => {
if (show) {
store.startDownloadPolling()
}
})
// Also poll on mount if there might be active downloads
onMounted(() => {
store.pollDownloads()
})
onUnmounted(() => {
// Don't stop polling - let it continue for background tracking
})
// Auto-stop polling when no active downloads for a while
watch(() => store.hasActiveDownloads, (hasActive) => {
if (!hasActive && !store.showDownloadsPanel) {
// Give a grace period before stopping
setTimeout(() => {
if (!store.hasActiveDownloads && !store.showDownloadsPanel) {
store.stopDownloadPolling()
}
}, 5000)
}
})
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
return bytes + ' B'
}
function getStatusColor(status: string): string {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'downloading': return 'primary'
default: return 'grey'
}
}
function getStatusIcon(status: string): string {
switch (status) {
case 'completed': return 'mdi-check-circle'
case 'failed': return 'mdi-alert-circle'
case 'downloading': return 'mdi-download'
default: return 'mdi-clock-outline'
}
}
</script>
<template>
<v-navigation-drawer
v-model="store.showDownloadsPanel"
location="right"
temporary
width="360"
>
<v-toolbar density="compact" color="surface">
<v-toolbar-title class="text-body-1">Downloads</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" variant="text" size="small" @click="store.showDownloadsPanel = false" />
</v-toolbar>
<v-list v-if="store.downloads.length > 0" density="compact">
<v-list-item
v-for="dl in store.downloads"
:key="dl.id"
:class="{ 'bg-surface-light': dl.status === 'downloading' }"
>
<template #prepend>
<v-icon :color="getStatusColor(dl.status)" class="mr-3">
{{ getStatusIcon(dl.status) }}
</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
{{ dl.model_name || dl.filename }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ dl.version_name || '' }}
</v-list-item-subtitle>
<!-- Progress bar for active downloads -->
<div v-if="dl.status === 'downloading'" class="mt-2">
<v-progress-linear
:model-value="dl.progress || 0"
color="primary"
height="6"
rounded
/>
<div class="d-flex justify-space-between text-caption text-grey mt-1">
<span>{{ dl.downloaded_str || formatBytes(dl.downloaded || 0) }} / {{ dl.total_str || formatBytes(dl.total || 0) }}</span>
<span class="text-primary">{{ dl.speed_str || '' }}</span>
</div>
</div>
<!-- Queued status -->
<div v-else-if="dl.status === 'queued'" class="mt-2">
<v-progress-linear indeterminate color="grey" height="4" rounded />
<div class="text-caption text-grey mt-1">Queued...</div>
</div>
<!-- Completed status -->
<div v-else-if="dl.status === 'completed'" class="text-caption text-success mt-1">
Downloaded to {{ dl.filename }}
</div>
<!-- Failed status -->
<div v-else-if="dl.status === 'failed'" class="text-caption text-error mt-1">
{{ dl.error || 'Download failed' }}
</div>
</v-list-item>
</v-list>
<div v-else class="text-center text-grey pa-8">
<v-icon size="48" color="grey-darken-1">mdi-download-off</v-icon>
<p class="mt-4">No downloads</p>
</div>
</v-navigation-drawer>
</template>
@@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as api from '@/api/client'
import type { GalleryImage } from '@/types'
const images = ref<GalleryImage[]>([])
const loading = ref(true)
const selectedImage = ref<string | null>(null)
const showLightbox = ref(false)
async function loadImages() {
loading.value = true
try {
const data = await api.getImages(100)
images.value = data.images || []
} catch (e) {
console.error('Failed to load gallery:', e)
} finally {
loading.value = false
}
}
function openLightbox(id: string) {
selectedImage.value = id
showLightbox.value = true
}
function closeLightbox() {
showLightbox.value = false
selectedImage.value = null
}
function navigate(direction: 'prev' | 'next') {
if (!selectedImage.value) return
const currentIndex = images.value.findIndex(img => img.id === selectedImage.value)
if (currentIndex === -1) return
let newIndex: number
if (direction === 'prev') {
newIndex = currentIndex > 0 ? currentIndex - 1 : images.value.length - 1
} else {
newIndex = currentIndex < images.value.length - 1 ? currentIndex + 1 : 0
}
selectedImage.value = images.value[newIndex]?.id ?? null
}
async function deleteImage(id: string) {
if (!confirm('Delete this image?')) return
try {
await api.deleteImage(id)
images.value = images.value.filter(img => img.id !== id)
if (selectedImage.value === id) {
closeLightbox()
}
} catch (e: any) {
alert('Failed to delete: ' + e.message)
}
}
onMounted(loadImages)
</script>
<template>
<v-container fluid class="fill-height pa-0">
<div v-if="loading" class="d-flex align-center justify-center fill-height w-100">
<v-progress-circular indeterminate color="primary" size="64" />
</div>
<div v-else-if="images.length === 0" class="d-flex flex-column align-center justify-center fill-height w-100 text-grey">
<v-icon size="64" color="grey-darken-1">mdi-image-multiple</v-icon>
<p class="mt-4">No images yet</p>
</div>
<v-container v-else fluid class="pa-4 overflow-y-auto">
<v-row>
<v-col
v-for="img in images"
:key="img.id"
cols="6"
sm="4"
md="3"
lg="2"
>
<v-card
class="gallery-card"
@click="openLightbox(img.id)"
>
<v-img
:src="api.getImageUrl(img.id)"
aspect-ratio="1"
cover
class="bg-grey-darken-3"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" size="24" />
</div>
</template>
</v-img>
<div class="delete-overlay">
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="flat"
@click.stop="deleteImage(img.id)"
/>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- Lightbox -->
<v-dialog
v-model="showLightbox"
fullscreen
transition="fade-transition"
>
<v-card class="bg-black d-flex flex-column">
<v-toolbar color="transparent" density="compact">
<v-spacer />
<v-btn
icon="mdi-delete"
variant="text"
@click="selectedImage && deleteImage(selectedImage)"
/>
<v-btn
icon="mdi-close"
variant="text"
@click="closeLightbox"
/>
</v-toolbar>
<div class="flex-grow-1 d-flex align-center justify-center position-relative">
<v-btn
icon="mdi-chevron-left"
variant="text"
size="x-large"
class="position-absolute"
style="left: 16px"
@click="navigate('prev')"
/>
<v-img
v-if="selectedImage"
:src="api.getImageUrl(selectedImage)"
max-height="90vh"
max-width="90vw"
contain
/>
<v-btn
icon="mdi-chevron-right"
variant="text"
size="x-large"
class="position-absolute"
style="right: 16px"
@click="navigate('next')"
/>
</div>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.gallery-card {
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
border: 1px solid transparent;
position: relative;
}
.gallery-card:hover {
transform: scale(1.03);
border-color: rgb(var(--v-theme-primary));
}
.delete-overlay {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.gallery-card:hover .delete-overlay {
opacity: 1;
}
</style>
@@ -1,379 +0,0 @@
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useAppStore } from '@/stores/app'
import * as api from '@/api/client'
import type { GeneratedImage } from '@/types'
const store = useAppStore()
const prompt = ref('')
const generating = ref(false)
// Snackbar states
const showError = computed({
get: () => !!store.switchError,
set: () => { store.switchError = null }
})
const showSuccess = computed({
get: () => !store.switchingModel && !!store.switchMessage && !store.switchError,
set: () => { store.switchMessage = null }
})
interface ChatMessage {
prompt: string
params: string
images: GeneratedImage[]
error?: string
loading: boolean
}
const messages = ref<ChatMessage[]>([])
const modelItems = computed(() =>
store.models.map(m => ({
title: m.display_name || m.name,
value: m.path,
thumbnail: m.thumbnail_url,
base_model: m.base_model,
}))
)
const loraItems = computed(() => [
{ title: 'None', value: '', thumbnail: null, triggers: [] },
...store.filteredLoras.map(l => ({
title: l.display_name || l.name,
value: l.path,
thumbnail: l.thumbnail_url,
triggers: l.triggers || [],
}))
])
async function handleModelChange(model: string) {
if (model && model !== store.activeModel) {
try {
await store.switchModel(model)
// Reset LoRA if it's not compatible with the new model
const loraStillValid = store.filteredLoras.some(l => l.path === store.selectedLora)
if (!loraStillValid) {
store.selectedLora = ''
}
} catch (e) {
console.error(e)
}
}
}
async function generate() {
if (!prompt.value.trim() || generating.value) return
const currentPrompt = prompt.value.trim()
prompt.value = ''
generating.value = true
// Build final prompt with quality tags
const finalPrompt = `${store.defaultQualityTags}, ${currentPrompt}`
// Get LoRA config if selected
const selectedLoraModel = store.selectedLora ? store.loras.find(l => l.path === store.selectedLora) : null
const loraConfig = selectedLoraModel ? { path: selectedLoraModel.filename, multiplier: store.loraWeight } : undefined
const { width, height } = store.resolution
const paramsStr = `${width}×${height}, ${store.steps} steps${store.batchSize > 1 ? `, batch ${store.batchSize}` : ''}${selectedLoraModel ? `, +${selectedLoraModel.name}` : ''}`
const message = reactive<ChatMessage>({
prompt: currentPrompt,
params: paramsStr,
images: [],
loading: true,
})
messages.value.push(message)
try {
for (let i = 0; i < store.batchSize; i++) {
const result = await api.generate({
prompt: finalPrompt,
negative_prompt: store.defaultNegativePrompt,
width,
height,
steps: store.steps,
seed: -1,
save_to_gallery: true,
lora: loraConfig,
})
message.images.push(...result.images)
}
} catch (e: any) {
console.error('Generate error:', e)
message.error = e.message || 'Generation failed'
} finally {
message.loading = false
generating.value = false
}
}
</script>
<template>
<v-container fluid class="fill-height pa-0 d-flex flex-column">
<!-- Model switch overlay -->
<v-overlay
:model-value="store.switchingModel"
class="align-center justify-center"
persistent
>
<v-card class="pa-6 text-center" min-width="300">
<v-progress-circular indeterminate color="primary" size="48" class="mb-4" />
<div class="text-h6">{{ store.switchMessage || 'Switching model...' }}</div>
<div class="text-caption text-grey mt-2">Model will load on next generation</div>
</v-card>
</v-overlay>
<!-- Error snackbar -->
<v-snackbar v-model="showError" color="error" timeout="5000">
{{ store.switchError }}
</v-snackbar>
<!-- Success snackbar -->
<v-snackbar v-model="showSuccess" color="success" timeout="3000">
{{ store.switchMessage }}
</v-snackbar>
<!-- Chat area -->
<v-container fluid class="flex-grow-1 overflow-y-auto pa-4">
<div v-if="messages.length === 0" class="text-center text-grey mt-16">
<v-icon size="64" color="grey-darken-1">mdi-auto-fix</v-icon>
<p class="mt-4">Enter a prompt to generate images</p>
</div>
<div v-for="(msg, idx) in messages" :key="idx" class="mb-6">
<v-chip color="primary" variant="tonal" class="mb-3">
<span class="font-weight-medium">{{ msg.prompt }}</span>
<span class="text-grey ml-2 text-caption">[{{ msg.params }}]</span>
</v-chip>
<div class="d-flex flex-wrap ga-3">
<template v-if="msg.loading">
<v-card
v-for="i in store.batchSize - msg.images.length"
:key="'loading-' + i"
width="200"
height="200"
class="d-flex align-center justify-center"
>
<v-progress-circular indeterminate color="primary" />
</v-card>
</template>
<v-card
v-for="img in msg.images"
:key="img.id"
width="200"
height="200"
class="overflow-hidden"
>
<v-img
:src="api.getImageUrl(img.id)"
cover
height="200"
/>
</v-card>
<v-alert v-if="msg.error" type="error" density="compact">
{{ msg.error }}
</v-alert>
</div>
</div>
</v-container>
<!-- Controls -->
<v-sheet class="border-t px-4 py-3">
<div class="d-flex flex-wrap align-center justify-center ga-4 mb-3">
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Model</span>
<v-select
v-model="store.selectedModel"
:items="modelItems"
:loading="store.switchingModel"
:disabled="store.switchingModel || generating"
density="compact"
hide-details
style="width: 280px"
@update:model-value="handleModelChange"
>
<template #selection="{ item }">
<div class="d-flex align-center ga-2">
<v-avatar v-if="item.raw.thumbnail" size="24" rounded="sm">
<v-img :src="item.raw.thumbnail" cover />
</v-avatar>
<v-icon v-else size="24" color="grey">mdi-cube-outline</v-icon>
<span class="text-truncate">{{ item.title }}</span>
</div>
</template>
<template #item="{ item, props }">
<v-list-item v-bind="props" :title="undefined">
<template #prepend>
<v-avatar v-if="item.raw.thumbnail" size="32" rounded="sm" class="mr-3">
<v-img :src="item.raw.thumbnail" cover />
</v-avatar>
<v-icon v-else size="32" color="grey" class="mr-3">mdi-cube-outline</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.raw.base_model" class="text-caption">
{{ item.raw.base_model }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">LoRA</span>
<v-select
v-model="store.selectedLora"
:items="loraItems"
:disabled="generating"
density="compact"
hide-details
style="width: 200px"
>
<template #selection="{ item }">
<div class="d-flex align-center ga-2">
<v-avatar v-if="item.raw.thumbnail" size="24" rounded="sm">
<v-img :src="item.raw.thumbnail" cover />
</v-avatar>
<v-icon v-else size="24" color="grey">mdi-shimmer</v-icon>
<span class="text-truncate">{{ item.title }}</span>
</div>
</template>
<template #item="{ item, props }">
<v-list-item v-bind="props" :title="undefined">
<template #prepend>
<v-avatar v-if="item.raw.thumbnail" size="32" rounded="sm" class="mr-3">
<v-img :src="item.raw.thumbnail" cover />
</v-avatar>
<v-icon v-else size="32" color="grey" class="mr-3">mdi-shimmer</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.raw.triggers?.length" class="text-caption text-truncate">
{{ item.raw.triggers.slice(0, 2).join(', ') }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<v-text-field
v-model.number="store.loraWeight"
type="number"
min="0"
max="2"
step="0.1"
density="compact"
hide-details
style="width: 70px"
:disabled="!store.selectedLora || generating"
/>
</div>
<div class="resolution-grid rounded border pa-2">
<div
v-for="group in store.presetGroups"
:key="group.label"
class="d-flex align-center ga-2"
>
<span class="text-caption text-grey text-uppercase resolution-label">{{ group.label }}</span>
<v-btn-toggle
v-model="store.selectedPreset"
mandatory
density="compact"
:disabled="generating"
class="resolution-row"
>
<v-btn v-for="p in group.presets" :key="p.id" :value="p.id" size="small" class="resolution-btn">
{{ p.label }}
</v-btn>
</v-btn-toggle>
</div>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Steps</span>
<v-text-field
v-model.number="store.steps"
type="number"
min="1"
max="50"
density="compact"
hide-details
style="width: 70px"
:disabled="generating"
/>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Batch</span>
<v-text-field
v-model.number="store.batchSize"
type="number"
min="1"
max="8"
density="compact"
hide-details
style="width: 70px"
:disabled="generating"
/>
</div>
</div>
<!-- Prompt input -->
<div class="d-flex ga-3 mx-auto" style="max-width: 800px">
<v-text-field
v-model="prompt"
placeholder="Describe what you want to generate..."
density="comfortable"
hide-details
:disabled="generating"
@keydown.enter="generate"
/>
<v-btn
color="secondary"
size="large"
:loading="generating"
:disabled="!prompt.trim()"
@click="generate"
>
Generate
</v-btn>
</div>
</v-sheet>
</v-container>
</template>
<style scoped>
.border-t {
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.resolution-grid {
display: flex;
flex-direction: column;
gap: 4px;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.resolution-label {
width: 36px;
text-align: left;
}
.resolution-row {
display: grid !important;
grid-template-columns: repeat(3, 100px);
width: 300px;
}
.resolution-btn {
width: 100px !important;
min-width: 100px !important;
max-width: 100px !important;
justify-content: center;
}
</style>
@@ -1,272 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CivitaiModel } from '@/types'
import * as api from '@/api/client'
import { useAppStore } from '@/stores/app'
const props = defineProps<{
model: CivitaiModel
}>()
const store = useAppStore()
const showDialog = ref(false)
const loadingDetails = ref(false)
const modelDetails = ref<CivitaiModel | null>(null)
const previewImage = computed(() => {
const version = props.model.modelVersions?.[0]
return version?.images?.[0]?.url || ''
})
const baseModel = computed(() => {
return props.model.modelVersions?.[0]?.baseModel || ''
})
const trainedWords = computed(() => {
return modelDetails.value?.modelVersions?.[0]?.trainedWords || []
})
function formatNumber(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
async function openDetails() {
showDialog.value = true
loadingDetails.value = true
try {
modelDetails.value = await api.getCivitaiModel(props.model.id)
} catch (e) {
console.error('Failed to load model details:', e)
} finally {
loadingDetails.value = false
}
}
async function downloadVersion(versionId: number) {
if (store.isDownloading(versionId)) return // Already downloading
if (!confirm(`Download "${props.model.name}" to the server?`)) return
const downloadId = await store.startDownload(versionId)
if (!downloadId) {
alert('Failed to start download')
}
}
</script>
<template>
<v-card class="model-card" @click="openDetails">
<v-img
:src="previewImage"
height="180"
cover
class="bg-grey-darken-3"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-icon size="48" color="grey">mdi-image</v-icon>
</div>
</template>
</v-img>
<v-card-text class="pb-2">
<h4 class="text-body-1 font-weight-medium mb-2 text-truncate-2">
{{ model.name }}
</h4>
<div class="d-flex flex-wrap ga-1 mb-2">
<v-chip size="x-small" color="primary" variant="flat">
{{ model.type }}
</v-chip>
<v-chip v-if="baseModel" size="x-small" variant="outlined">
{{ baseModel }}
</v-chip>
<v-chip v-if="model.nsfw" size="x-small" color="error" variant="flat">
NSFW
</v-chip>
</div>
<div class="d-flex ga-3 text-caption text-grey">
<span class="d-flex align-center ga-1">
<v-icon size="14">mdi-download</v-icon>
{{ formatNumber(model.stats?.downloadCount || 0) }}
</span>
<span class="d-flex align-center ga-1">
<v-icon size="14">mdi-thumb-up</v-icon>
{{ formatNumber(model.stats?.thumbsUpCount || 0) }}
</span>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="px-3">
<span class="text-caption text-grey">
by {{ model.creator?.username || 'Unknown' }}
</span>
<v-spacer />
<!-- Download progress or button -->
<template v-if="store.isDownloading(model.modelVersions?.[0]?.id || 0)">
<div class="download-progress">
<v-progress-linear
:model-value="store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.progress || 0"
:indeterminate="store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.status === 'queued'"
color="primary"
height="6"
rounded
/>
<span class="text-caption text-grey">
{{ store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.progress?.toFixed(0) || 0 }}%
</span>
</div>
</template>
<v-btn
v-else
size="small"
color="primary"
variant="flat"
@click.stop="downloadVersion(model.modelVersions?.[0]?.id || 0)"
>
Download
</v-btn>
</v-card-actions>
</v-card>
<!-- Detail Dialog -->
<v-dialog v-model="showDialog" max-width="600">
<v-card>
<v-card-title class="d-flex align-center">
{{ model.name }}
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="showDialog = false" />
</v-card-title>
<v-card-text>
<div v-if="loadingDetails" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
</div>
<template v-else-if="modelDetails">
<!-- Preview images -->
<div v-if="modelDetails.modelVersions?.[0]?.images?.length" class="mb-4">
<div class="d-flex ga-2 overflow-x-auto pb-2">
<v-img
v-for="(img, idx) in modelDetails.modelVersions[0].images.slice(0, 6)"
:key="idx"
:src="img.url"
width="120"
height="120"
cover
class="rounded flex-shrink-0"
/>
</div>
</div>
<!-- Info -->
<v-list density="compact">
<v-list-item>
<template #prepend><span class="text-grey mr-4">Type</span></template>
{{ modelDetails.type }}
</v-list-item>
<v-list-item>
<template #prepend><span class="text-grey mr-4">Creator</span></template>
{{ modelDetails.creator?.username || 'Unknown' }}
</v-list-item>
<v-list-item>
<template #prepend><span class="text-grey mr-4">Downloads</span></template>
{{ formatNumber(modelDetails.stats?.downloadCount || 0) }}
</v-list-item>
<v-list-item>
<template #prepend><span class="text-grey mr-4">Rating</span></template>
{{ formatNumber(modelDetails.stats?.thumbsUpCount || 0) }} likes
</v-list-item>
</v-list>
<!-- Trigger words -->
<div v-if="trainedWords.length" class="mt-4">
<h4 class="text-caption text-grey mb-2">TRIGGER WORDS</h4>
<v-chip
v-for="word in trainedWords"
:key="word"
size="small"
class="mr-1 mb-1"
>
{{ word }}
</v-chip>
</div>
<!-- Versions -->
<div v-if="modelDetails.modelVersions?.length" class="mt-4">
<h4 class="text-caption text-grey mb-2">VERSIONS</h4>
<v-list density="compact" class="bg-transparent">
<v-list-item
v-for="version in modelDetails.modelVersions.slice(0, 5)"
:key="version.id"
class="px-0"
>
<v-list-item-title>{{ version.name }}</v-list-item-title>
<v-list-item-subtitle>{{ version.baseModel }}</v-list-item-subtitle>
<template #append>
<template v-if="store.isDownloading(version.id)">
<div class="download-progress-dialog">
<v-progress-linear
:model-value="store.getDownloadProgress(version.id)?.progress || 0"
:indeterminate="store.getDownloadProgress(version.id)?.status === 'queued'"
color="primary"
height="6"
rounded
/>
<span class="text-caption text-grey">
{{ store.getDownloadProgress(version.id)?.progress?.toFixed(0) || 0 }}%
</span>
</div>
</template>
<v-btn
v-else
size="small"
color="primary"
variant="flat"
@click="downloadVersion(version.id)"
>
Download
</v-btn>
</template>
</v-list-item>
</v-list>
</div>
</template>
</v-card-text>
</v-card>
</v-dialog>
</template>
<style scoped>
.model-card {
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
}
.model-card:hover {
transform: translateY(-2px);
border-color: rgb(var(--v-theme-primary));
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.download-progress {
display: flex;
flex-direction: column;
gap: 2px;
width: 120px;
}
.download-progress-dialog {
display: flex;
align-items: center;
gap: 8px;
width: 100px;
}
</style>
@@ -1,176 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as api from '@/api/client'
import type { CivitaiModel } from '@/types'
import ModelCard from './ModelCard.vue'
const STORAGE_KEY = 'civitai-search-state'
const query = ref('')
const modelType = ref('')
const baseModel = ref('')
const sortOrder = ref('Most Downloaded')
const loading = ref(false)
const results = ref<CivitaiModel[]>([])
const searched = ref(false)
// Restore search state from sessionStorage
onMounted(() => {
const saved = sessionStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const state = JSON.parse(saved)
query.value = state.query || ''
modelType.value = state.modelType || ''
baseModel.value = state.baseModel || ''
sortOrder.value = state.sortOrder || 'Most Downloaded'
results.value = state.results || []
searched.value = state.searched || false
} catch (e) {
console.error('Failed to restore search state:', e)
}
}
})
function saveState() {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
query: query.value,
modelType: modelType.value,
baseModel: baseModel.value,
sortOrder: sortOrder.value,
results: results.value,
searched: searched.value,
}))
}
const modelTypes = [
{ title: 'All Types', value: '' },
{ title: 'Checkpoint', value: 'Checkpoint' },
{ title: 'LoRA', value: 'LORA' },
{ title: 'LoCon', value: 'LoCon' },
{ title: 'Embedding', value: 'TextualInversion' },
{ title: 'VAE', value: 'VAE' },
{ title: 'ControlNet', value: 'Controlnet' },
]
const baseModels = [
{ title: 'All Base Models', value: '' },
{ title: 'SD 1.5', value: 'SD 1.5' },
{ title: 'SDXL', value: 'SDXL 1.0' },
{ title: 'Pony', value: 'Pony' },
{ title: 'Illustrious', value: 'Illustrious' },
{ title: 'Flux', value: 'Flux.1 D' },
]
const sortOptions = [
{ title: 'Most Downloaded', value: 'Most Downloaded' },
{ title: 'Highest Rated', value: 'Highest Rated' },
{ title: 'Newest', value: 'Newest' },
]
async function search() {
loading.value = true
searched.value = true
try {
const data = await api.searchCivitai({
query: query.value || undefined,
types: modelType.value || undefined,
baseModels: baseModel.value || undefined,
sort: sortOrder.value,
limit: 24,
})
results.value = data.items || []
} catch (e: any) {
console.error('Search failed:', e)
results.value = []
} finally {
loading.value = false
saveState()
}
}
</script>
<template>
<v-container fluid class="fill-height pa-0 d-flex flex-column">
<!-- Search header -->
<v-sheet class="border-b pa-4">
<div class="d-flex flex-wrap align-center justify-center ga-3 mx-auto" style="max-width: 1000px">
<v-text-field
v-model="query"
placeholder="Search CivitAI models..."
prepend-inner-icon="mdi-magnify"
density="compact"
hide-details
clearable
style="min-width: 250px; flex: 1"
@keydown.enter="search"
/>
<v-select
v-model="modelType"
:items="modelTypes"
density="compact"
hide-details
style="min-width: 140px"
/>
<v-select
v-model="baseModel"
:items="baseModels"
density="compact"
hide-details
style="min-width: 150px"
/>
<v-select
v-model="sortOrder"
:items="sortOptions"
density="compact"
hide-details
style="min-width: 160px"
/>
<v-btn color="primary" :loading="loading" @click="search">
Search
</v-btn>
</div>
</v-sheet>
<!-- Results -->
<v-container fluid class="flex-grow-1 overflow-y-auto pa-4">
<div v-if="!searched" class="text-center text-grey mt-16">
<v-icon size="64" color="grey-darken-1">mdi-magnify</v-icon>
<p class="mt-4">Search for models on CivitAI</p>
</div>
<div v-else-if="loading" class="text-center mt-16">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-grey">Searching...</p>
</div>
<div v-else-if="results.length === 0" class="text-center text-grey mt-16">
<v-icon size="64" color="grey-darken-1">mdi-magnify-close</v-icon>
<p class="mt-4">No models found</p>
</div>
<v-row v-else>
<v-col
v-for="model in results"
:key="model.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<ModelCard :model="model" />
</v-col>
</v-row>
</v-container>
</v-container>
</template>
<style scoped>
.border-b {
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
</style>
-43
View File
@@ -1,43 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// Vuetify
import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css'
import { createVuetify } from 'vuetify'
const vuetify = createVuetify({
theme: {
defaultTheme: 'dark',
themes: {
dark: {
colors: {
background: '#0f0f0f',
surface: '#1a1a1a',
primary: '#4ade80',
secondary: '#dc2626',
error: '#dc2626',
}
}
}
},
defaults: {
VBtn: {
variant: 'flat',
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
},
}
})
const app = createApp(App)
app.use(createPinia())
app.use(vuetify)
app.mount('#app')
-227
View File
@@ -1,227 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Model, LoRA, ResolutionPreset } from '@/types'
import type { DownloadStatus } from '@/api/client'
import * as api from '@/api/client'
export const useAppStore = defineStore('app', () => {
// Navigation
const currentView = ref<'generate' | 'search' | 'gallery'>('generate')
// Downloads
const downloads = ref<DownloadStatus[]>([])
const showDownloadsPanel = ref(false)
let downloadPollInterval: ReturnType<typeof setInterval> | null = null
// Models
const models = ref<Model[]>([])
const loras = ref<LoRA[]>([])
const activeModel = ref<string | null>(null)
const selectedModel = ref<string>('')
const selectedLora = ref<string>('')
const loraWeight = ref(0.8)
// Generation settings
const selectedPreset = ref('768-3:4')
const steps = ref(20)
const batchSize = ref(1)
// Default quality prompts (applied automatically)
const defaultQualityTags = 'masterpiece, best quality, absurdres, highres'
const defaultNegativePrompt = 'bad anatomy, bad hands, missing fingers, extra fingers, extra digit, fewer digits, extra limbs, missing limbs, fused fingers, too many fingers, mutated hands, poorly drawn hands, poorly drawn face, mutation, deformed, ugly, blurry, bad proportions, gross proportions, malformed limbs, long neck, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, text, error'
// All resolution presets (base size × aspect ratio)
const resolutionPresets: ResolutionPreset[] = [
// 512 base
{ id: '512-3:4', ratio: '3:4', width: 384, height: 512, label: '384×512' },
{ id: '512-1:1', ratio: '1:1', width: 512, height: 512, label: '512×512' },
{ id: '512-4:3', ratio: '4:3', width: 512, height: 384, label: '512×384' },
// 768 base
{ id: '768-3:4', ratio: '3:4', width: 576, height: 768, label: '576×768' },
{ id: '768-1:1', ratio: '1:1', width: 768, height: 768, label: '768×768' },
{ id: '768-4:3', ratio: '4:3', width: 768, height: 576, label: '768×576' },
// 1024 base
{ id: '1024-3:4', ratio: '3:4', width: 768, height: 1024, label: '768×1024' },
{ id: '1024-1:1', ratio: '1:1', width: 1024, height: 1024, label: '1024×1024' },
{ id: '1024-4:3', ratio: '4:3', width: 1024, height: 768, label: '1024×768' },
]
const resolution = computed(() => {
const preset = resolutionPresets.find(p => p.id === selectedPreset.value)
return preset ? { width: preset.width, height: preset.height } : { width: 768, height: 1024 }
})
// Presets grouped by base size for 3-row display
const presetGroups = [
{ label: 'low', presets: resolutionPresets.filter(p => p.id.startsWith('512-')) },
{ label: 'mid', presets: resolutionPresets.filter(p => p.id.startsWith('768-')) },
{ label: 'high', presets: resolutionPresets.filter(p => p.id.startsWith('1024-')) },
]
// Computed: selected model's category
const selectedModelCategory = computed(() => {
const model = models.value.find(m => m.path === selectedModel.value || m.name === selectedModel.value)
return model?.category || 'large'
})
// Computed: LoRAs filtered by selected model category
const filteredLoras = computed(() => {
return loras.value.filter(l => l.category === selectedModelCategory.value)
})
// Loading states
const loadingModels = ref(false)
const switchingModel = ref(false)
const switchMessage = ref<string | null>(null)
const switchError = ref<string | null>(null)
// Actions
async function loadModels() {
loadingModels.value = true
try {
const [modelsRes, lorasRes, activeRes] = await Promise.all([
api.getModels(),
api.getLoras(),
api.getActiveModel(),
])
models.value = modelsRes.models
loras.value = lorasRes.loras
activeModel.value = activeRes.model
if (activeRes.model) {
// Find full path for the active model (API returns model name without extension, v-select uses path)
const activeModelPath = modelsRes.models.find(m =>
m.name === activeRes.model ||
m.filename === activeRes.model ||
m.filename.startsWith(activeRes.model + '.')
)?.path
selectedModel.value = activeModelPath || activeRes.model
}
} catch (error) {
console.error('Failed to load models:', error)
} finally {
loadingModels.value = false
}
}
// Downloads - computed
const activeDownloads = computed(() =>
downloads.value.filter(d => d.status === 'downloading' || d.status === 'queued')
)
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
// Downloads - actions
async function pollDownloads() {
try {
const res = await api.getActiveDownloads()
downloads.value = res.downloads || []
} catch (e) {
console.error('Failed to poll downloads:', e)
}
}
function startDownloadPolling() {
if (downloadPollInterval) return
pollDownloads() // Initial fetch
downloadPollInterval = setInterval(pollDownloads, 1000)
}
function stopDownloadPolling() {
if (downloadPollInterval) {
clearInterval(downloadPollInterval)
downloadPollInterval = null
}
}
async function startDownload(versionId: number): Promise<string | null> {
try {
const response = await api.downloadModel(undefined, versionId)
startDownloadPolling()
showDownloadsPanel.value = true
return response.download_id
} catch (e: any) {
console.error('Failed to start download:', e)
return null
}
}
function isDownloading(versionId: number): boolean {
return downloads.value.some(
d => d.version_id === versionId && (d.status === 'downloading' || d.status === 'queued')
)
}
function getDownloadProgress(versionId: number): DownloadStatus | undefined {
return downloads.value.find(d => d.version_id === versionId)
}
async function switchModel(modelPath: string) {
if (modelPath === activeModel.value) return
switchingModel.value = true
switchMessage.value = 'Switching model...'
switchError.value = null
try {
const result = await api.switchModel(modelPath)
// ComfyUI loads models on-demand, no restart needed
activeModel.value = result.new_model
selectedModel.value = modelPath
switchMessage.value = 'Model selected'
setTimeout(() => { switchMessage.value = null }, 2000)
} catch (error: any) {
console.error('Failed to switch model:', error)
switchError.value = error.message || 'Failed to switch model'
setTimeout(() => { switchError.value = null }, 5000)
throw error
} finally {
switchingModel.value = false
}
}
return {
// Navigation
currentView,
// Models
models,
loras,
filteredLoras,
activeModel,
selectedModel,
selectedModelCategory,
selectedLora,
loraWeight,
// Generation settings
selectedPreset,
resolutionPresets,
presetGroups,
steps,
batchSize,
resolution,
defaultQualityTags,
defaultNegativePrompt,
// Loading states
loadingModels,
switchingModel,
switchMessage,
switchError,
// Downloads
downloads,
activeDownloads,
hasActiveDownloads,
showDownloadsPanel,
// Actions
loadModels,
switchModel,
startDownload,
pollDownloads,
startDownloadPolling,
stopDownloadPolling,
isDownloading,
getDownloadProgress,
}
})
-104
View File
@@ -1,104 +0,0 @@
export interface Model {
name: string
path: string
filename: string
size_mb: number
modified: number
category: 'sd15' | 'large'
// Enriched from CivitAI metadata
display_name?: string
base_model?: string
model_type?: string
civitai_model_id?: number
civitai_version_id?: number
thumbnail_url?: string
triggers?: string[]
}
export interface LoRA {
name: string
path: string
filename: string
size_mb: number
modified: number
category: 'sd15' | 'large'
// Enriched from CivitAI metadata
display_name?: string
base_model?: string
model_type?: string
civitai_model_id?: number
civitai_version_id?: number
thumbnail_url?: string
triggers?: string[]
}
export interface GeneratedImage {
id: string
path: string
seed: number
}
export interface GalleryImage {
id: string
filename: string
created: string
metadata?: ImageMetadata
}
export interface ImageMetadata {
prompt?: string
negative_prompt?: string
width?: number
height?: number
steps?: number
cfg_scale?: number
seed?: number
sampler?: string
}
export interface CivitaiModel {
id: number
name: string
description?: string
type: string
nsfw: boolean
creator?: {
username: string
image?: string
}
stats?: {
downloadCount: number
thumbsUpCount: number
}
modelVersions?: CivitaiVersion[]
}
export interface CivitaiVersion {
id: number
name: string
baseModel?: string
trainedWords?: string[]
images?: CivitaiImage[]
downloadUrl?: string
}
export interface CivitaiImage {
url: string
width: number
height: number
nsfwLevel: number
}
export interface Resolution {
width: number
height: number
label: string
}
export interface ResolutionPreset {
id: string
ratio: string
width: number
height: number
label: string
}
-12
View File
@@ -1,12 +0,0 @@
/// <reference types="vite/client" />
declare module 'vuetify/styles' {
const styles: string
export default styles
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
-20
View File
@@ -1,20 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "vuetify"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-28
View File
@@ -1,28 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
vuetify({ autoImport: true }),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: '../static',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://junkpile:8081',
changeOrigin: true,
}
}
}
})