feat(model-manager): generated-photo Gallery + device-routing landing

- Gallery view: grid of generated photos from ComfyUI's output/, full-size
  lightbox, and permanent delete (with confirm). Paginated ("Load more").
- Backend: GET /api/gallery, GET /gallery/file (path-guarded image serve),
  DELETE /api/gallery (path-guarded; clear error on permission denial).
- Mount ./output read-write into model-manager so the gallery can delete.
- Device-routing landing at /start: phones -> ComfyUIMini, desktops ->
  the Gallery; ?force=mobile|desktop overrides. Ports come from the new
  /api/ui-config (COMFYUI_PORT / COMFYUIMINI_PORT env).
- Responsive tweaks so the gallery is usable if opened directly on a phone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:41:46 -04:00
parent 0b606721dd
commit c9fa3fcab5
8 changed files with 388 additions and 1 deletions
+77
View File
@@ -13,11 +13,16 @@ from pydantic import BaseModel
from . import db, downloader, registries
from .config import (
COMFYUI_PORT,
COMFYUIMINI_PORT,
IMAGE_EXTS,
KEY_BY_FOLDER,
MODEL_TYPES,
MODELS_DIR,
OUTPUT_DIR,
TYPE_BY_KEY,
ensure_dirs,
safe_output_path,
)
STATIC_DIR = Path(__file__).parent / "static"
@@ -214,6 +219,78 @@ def delete_download(download_id: int) -> dict:
return {"removed": download_id}
# ---- gallery (generated photos) -------------------------------------------
@app.get("/api/gallery")
def list_gallery(limit: int = 60, offset: int = 0) -> dict:
"""List generated images under OUTPUT_DIR, newest first, paginated."""
files: list[dict] = []
if OUTPUT_DIR.is_dir():
for path in OUTPUT_DIR.rglob("*"):
if not path.is_file() or path.suffix.lower() not in IMAGE_EXTS:
continue
rel = path.relative_to(OUTPUT_DIR).as_posix()
stat = path.stat()
files.append({
"path": rel,
"name": path.name,
"subfolder": path.parent.relative_to(OUTPUT_DIR).as_posix()
if path.parent != OUTPUT_DIR else "",
"size": stat.st_size,
"mtime": stat.st_mtime,
})
files.sort(key=lambda f: f["mtime"], reverse=True)
total = len(files)
page = files[offset:offset + max(1, min(limit, 500))]
return {"items": page, "total": total,
"offset": offset, "returned": len(page)}
@app.get("/gallery/file")
def gallery_file(path: str):
try:
target = safe_output_path(path)
except ValueError:
raise HTTPException(400, "Invalid path")
if not target.is_file() or target.suffix.lower() not in IMAGE_EXTS:
raise HTTPException(404, "Image not found")
return FileResponse(target)
class DeletePhotoIn(BaseModel):
path: str
@app.delete("/api/gallery")
def delete_photo(body: DeletePhotoIn) -> dict:
try:
target = safe_output_path(body.path)
except ValueError:
raise HTTPException(400, "Invalid path")
if not target.is_file():
raise HTTPException(404, "Image not found")
try:
target.unlink()
except PermissionError:
raise HTTPException(
403,
"Permission denied (file is in a folder owned by another user). "
"Run ComfyUI as the same UID, or remove it from a host shell.")
return {"deleted": body.path}
# ---- device routing / ui config -------------------------------------------
@app.get("/api/ui-config")
def ui_config() -> dict:
return {"comfyui_port": COMFYUI_PORT, "comfyuimini_port": COMFYUIMINI_PORT}
@app.get("/start")
def start() -> FileResponse:
return FileResponse(STATIC_DIR / "start.html")
# ---- static UI ------------------------------------------------------------
@app.get("/")