💬 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:
-208
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 +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 |
@@ -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
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
-1663
File diff suppressed because it is too large
Load Diff
@@ -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 +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 |
@@ -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>
|
||||
@@ -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 +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>
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Vendored
-12
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user