Переглянути джерело

Fix gallery display and scrolling issues

- Fix gallery showing 'No images' by correctly accessing job outputs from nested job object
- Fix unwanted scrolling by setting proper overflow constraints
- Fix footer text being cut off by adjusting grid layout
- Fix ModelStatusBar overlap with footer by removing fixed positioning
- Add npm script for webui-only builds to avoid full server rebuilds
Fszontagh 3 місяців тому
батько
коміт
8f80961d5a

+ 2 - 1
.gitignore

@@ -41,12 +41,13 @@ models/
 
 # IDE and editor files
 .vscode/
+.zed/
 .idea/
 *.swp
 *.swo
 *~
 .DS_Store
-
+opencode.json
 # CMake files
 CMakeCache.txt
 CMakeFiles/

+ 16 - 1
CRUSH.md → AGENTS.md

@@ -1,4 +1,4 @@
-# CRUSH.md - Agent Guide for stable-diffusion.cpp-rest (fszontagh/stable-diffusion.cpp-rest)
+# AGENTS.md - Agent Guide for stable-diffusion.cpp-rest (fszontagh/stable-diffusion.cpp-rest)
 
 This document contains essential information for agents working with the stable-diffusion.cpp-rest codebase.
 
@@ -6,6 +6,8 @@ This document contains essential information for agents working with the stable-
 
 This is a C++ REST API server that wraps the stable-diffusion.cpp library, providing HTTP endpoints for image generation with Stable Diffusion models. The project includes both a C++ backend and a Next.js frontend (webui).
 
+
+Always use todo tools to track the status
 ## Build Commands
 
 ### Main Build Process
@@ -28,6 +30,17 @@ npm install
 npm run build
 ```
 
+### Web UI Development Workflow
+```bash
+# For faster development when only changing the webui
+cd webui
+npm run build-static
+
+# This builds the webui and copies the static files to build/webui/
+# No need to rebuild the entire server or stop it during webui updates
+# The server will automatically serve the updated static files
+```
+
 ## Project Structure
 
 ### C++ Backend
@@ -98,6 +111,8 @@ Multiple authentication methods supported:
 - **Keep build/_deps folder** - Rebuilding dependencies takes very long time
 - Web UI automatically builds with main project via CMake
 - CMake automatically downloads and builds stable-diffusion.cpp as external project
+- For webui-only changes, use `npm run build-static` to avoid rebuilding the entire server
+- The `build-static` command copies the built webui to build/webui/ for the server to serve
 
 ### Path Management
 - Always use absolute paths when printing to console

+ 0 - 68
ISSUE_49_PROGRESS_CALLBACK_FIX.md

@@ -1,68 +0,0 @@
-# Fix for Segmentation Fault on Second Image Generation
-
-## Issue Summary
-- **Original Problem**: Application crashes with segmentation fault on second image generation
-- **Suspected Cause**: Progress callback memory management issue
-- **Actual Cause**: CUDA error during second generation, NOT progress callback issue
-
-## Root Cause Analysis
-
-### Progress Callback Investigation
-✅ **FIXED**: Progress callback cleanup issue
-- Added `#include <thread>` to stable_diffusion_wrapper.cpp
-- Implemented 10ms delay before deleting callback data
-- This prevents use-after-free conditions in callback cleanup
-- Applied to all generation functions (text2img, img2img, controlnet, inpainting)
-
-### Actual Crash Cause
-❌ **CUDA Error**: `/stable-diffusion.cpp-src/ggml/src/ggml-cuda/ggml-cuda.cu:88: CUDA error`
-- First generation completes successfully (3784ms)
-- Second generation triggers CUDA error and server crash
-- Not related to progress callback mechanism
-
-## Files Modified
-- `src/stable_diffusion_wrapper.cpp`
-  - Added thread header
-  - Modified callback cleanup in generateImage()
-  - Modified callback cleanup in generateImageImg2Img() 
-  - Modified callback cleanup in generateImageControlNet()
-  - Modified callback cleanup in generateImageInpainting()
-
-## Test Results
-- ✅ Progress callback mechanism working correctly
-- ✅ No segfaults from callback cleanup
-- ✅ First image generation successful
-- ❌ Second generation fails with CUDA error
-- ❌ Server crashes due to GPU memory management issue
-
-## Code Changes Applied
-
-```cpp
-// Added header
-#include <thread>
-
-// Modified callback cleanup in all generation functions
-// Clear and clean up progress callback - FIX: Wait for any pending callbacks
-sd_set_progress_callback(nullptr, nullptr);
-
-// Add a small delay to ensure any in-flight callbacks complete before cleanup
-std::this_thread::sleep_for(std::chrono::milliseconds(10));
-
-if (callbackData) {
-    delete callbackData;
-    callbackData = nullptr;
-}
-```
-
-## Conclusion
-The progress callback fix is **WORKING CORRECTLY**. The original issue diagnosis was incorrect. 
-The actual problem is a deeper CUDA/GPU memory management issue that requires investigation at the stable-diffusion.cpp library level.
-
-## Recommendations for Complete Fix
-1. Investigate CUDA error in ggml-cuda.cu:88
-2. Check GPU memory management between generations
-3. Implement proper CUDA context cleanup
-4. Verify GPU resources are released correctly
-5. May need to address at stable-diffusion.cpp library level
-
-The segmentation fault issue related to progress callbacks has been **RESOLVED**.

+ 5 - 0
include/server.h

@@ -233,6 +233,11 @@ private:
      */
     void handleImageCrop(const httplib::Request& req, httplib::Response& res);
 
+    /**
+     * @brief Serve temporary image endpoint handler
+     */
+    void handleTempImage(const httplib::Request& req, httplib::Response& res);
+
     // Specialized generation endpoints
     /**
      * @brief Text-to-image generation endpoint handler

+ 1 - 0
include/utils.h

@@ -54,6 +54,7 @@ inline std::string base64Encode(const std::vector<uint8_t>& data) {
         char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
         char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
         char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
+        char_array_4[3] = char_array_3[2] & 0x3f;
 
         for (j = 0; j < i + 1; j++)
             ret += base64_chars[char_array_4[j]];

+ 26 - 15
src/server.cpp

@@ -242,20 +242,7 @@ void Server::registerEndpoints() {
         handleModelsList(req, res);
     }));
 
-    // Model-specific endpoints
-    m_httpServer->Get("/api/models/(.*)", [this](const httplib::Request& req, httplib::Response& res) {
-        handleModelInfo(req, res);
-    });
-
-    m_httpServer->Post("/api/models/(.*)/load", withAuth([this](const httplib::Request& req, httplib::Response& res) {
-        handleLoadModelById(req, res);
-    }));
-
-    m_httpServer->Post("/api/models/(.*)/unload", withAuth([this](const httplib::Request& req, httplib::Response& res) {
-        handleUnloadModelById(req, res);
-    }));
-
-    // Model management endpoints (now protected - require authentication)
+    // Model management endpoints (now protected - require authentication) - MUST be before catch-all routes
     m_httpServer->Get("/api/models/types", withAuth([this](const httplib::Request& req, httplib::Response& res) {
         handleModelTypes(req, res);
     }));
@@ -284,7 +271,7 @@ void Server::registerEndpoints() {
         handleBatchModels(req, res);
     }));
 
-    // Model validation endpoints (already protected with withAuth)
+    // Model validation endpoints (already protected with withAuth) - MUST be before catch-all routes
     m_httpServer->Post("/api/models/validate", withAuth([this](const httplib::Request& req, httplib::Response& res) {
         handleValidateModel(req, res);
     }));
@@ -297,6 +284,19 @@ void Server::registerEndpoints() {
         handleModelRequirements(req, res);
     }));
 
+    // Model-specific endpoints (catch-all routes) - MUST be after specific routes
+    m_httpServer->Get("/api/models/(.*)", [this](const httplib::Request& req, httplib::Response& res) {
+        handleModelInfo(req, res);
+    });
+
+    m_httpServer->Post("/api/models/(.*)/load", withAuth([this](const httplib::Request& req, httplib::Response& res) {
+        handleLoadModelById(req, res);
+    }));
+
+    m_httpServer->Post("/api/models/(.*)/unload", withAuth([this](const httplib::Request& req, httplib::Response& res) {
+        handleUnloadModelById(req, res);
+    }));
+
     // Queue status endpoint (now protected - require authentication)
     m_httpServer->Get("/api/queue/status", withAuth([this](const httplib::Request& req, httplib::Response& res) {
         handleQueueStatus(req, res);
@@ -4779,6 +4779,17 @@ void Server::handleModelInfo(const httplib::Request& req, httplib::Response& res
 
         // Extract model ID from URL path
         std::string modelId = req.matches[1].str();
+        
+        // DEBUG: Log what model ID we received
+        if (modelId == "types") {
+            nlohmann::json debugResponse = {
+                {"debug", "handleModelInfo called with modelId: " + modelId},
+                {"request_id", requestId}
+            };
+            sendJsonResponse(res, debugResponse);
+            return;
+        }
+        
         if (modelId.empty()) {
             sendErrorResponse(res, "Missing model ID", 400, "MISSING_MODEL_ID", requestId);
             return;

+ 448 - 0
webui/app/gallery/page.tsx

@@ -0,0 +1,448 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Header } from "@/components/layout";
+import { AppLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { apiClient, type JobDetailsResponse } from "@/lib/api";
+import {
+  Loader2,
+  Download,
+  RefreshCw,
+  Calendar,
+  Image as ImageIcon,
+  X,
+  ZoomIn,
+} from "lucide-react";
+import { downloadAuthenticatedImage } from "@/lib/utils";
+
+interface GalleryImage {
+  jobId: string;
+  filename: string;
+  url: string;
+  thumbnailUrl: string;
+  prompt?: string;
+  negativePrompt?: string;
+  width?: number;
+  height?: number;
+  steps?: number;
+  cfgScale?: number;
+  seed?: string;
+  model?: string;
+  createdAt: string;
+  status: string;
+}
+
+interface ImageModalProps {
+  image: GalleryImage | null;
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+function ImageModal({ image, isOpen, onClose }: ImageModalProps) {
+  if (!isOpen || !image) return null;
+
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) {
+      onClose();
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
+      onClick={handleBackdropClick}
+    >
+      <div className="relative max-w-[90vw] max-h-[90vh] flex flex-col">
+        {/* Header with image info */}
+        <div className="bg-background border-b border-border p-4 rounded-t-lg">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <ImageIcon className="h-5 w-5" />
+              <span className="font-medium">Generated Image</span>
+              <Badge variant="secondary">{image.model || "Unknown"}</Badge>
+            </div>
+            <Button
+              size="icon"
+              variant="ghost"
+              onClick={onClose}
+              className="h-8 w-8"
+            >
+              <X className="h-4 w-4" />
+            </Button>
+          </div>
+
+          {/* Image metadata */}
+          <div className="mt-2 text-sm text-muted-foreground grid grid-cols-2 md:grid-cols-4 gap-2">
+            {image.width && image.height && (
+              <span>
+                Size: {image.width}×{image.height}
+              </span>
+            )}
+            {image.steps && <span>Steps: {image.steps}</span>}
+            {image.cfgScale && <span>CFG: {image.cfgScale}</span>}
+            {image.seed && <span>Seed: {image.seed}</span>}
+          </div>
+
+          {image.prompt && (
+            <div className="mt-2">
+              <p className="text-sm font-medium">Prompt:</p>
+              <p className="text-sm text-muted-foreground line-clamp-2">
+                {image.prompt}
+              </p>
+            </div>
+          )}
+        </div>
+
+        {/* Image container */}
+        <div className="flex items-center justify-center bg-muted/50 p-4 rounded-b-lg">
+          <img
+            src={image.url}
+            alt="Generated image"
+            className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-lg"
+          />
+        </div>
+
+        {/* Download button */}
+        <div className="absolute bottom-4 right-4">
+          <Button
+            size="sm"
+            onClick={() => {
+              const authToken = localStorage.getItem("auth_token");
+              const unixUser = localStorage.getItem("unix_user");
+              downloadAuthenticatedImage(
+                image.url,
+                `gallery-${image.jobId}-${image.filename}`,
+                authToken || undefined,
+                unixUser || undefined,
+              );
+            }}
+          >
+            <Download className="h-4 w-4 mr-2" />
+            Download
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function GalleryGrid() {
+  const [images, setImages] = useState<GalleryImage[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [sortBy, setSortBy] = useState<"newest" | "oldest">("newest");
+
+  const loadGalleryImages = async () => {
+    try {
+      setLoading(true);
+      setError(null);
+
+      console.log("Gallery: Loading images...");
+
+      // Get queue status to find all completed jobs
+      const queueStatus = await apiClient.getQueueStatus();
+      console.log("Gallery: Queue status:", queueStatus);
+      const completedJobs = queueStatus.jobs.filter(
+        (job) => job.status === "completed",
+      );
+      console.log("Gallery: Completed jobs:", completedJobs);
+
+      const galleryImages: GalleryImage[] = [];
+
+      // Fetch individual job details to get output information
+      for (const job of completedJobs) {
+        const jobId = job.request_id || job.id || "";
+        if (!jobId) continue;
+
+        try {
+          // Get detailed job information including outputs
+          console.log(`Gallery: Fetching details for job ${jobId}`);
+          const jobDetails: JobDetailsResponse =
+            await apiClient.getJobStatus(jobId);
+          console.log(`Gallery: Job ${jobId} details:`, jobDetails);
+
+          // API response has outputs nested in job object
+          if (
+            jobDetails.job &&
+            jobDetails.job.outputs &&
+            jobDetails.job.outputs.length > 0
+          ) {
+            for (const output of jobDetails.job.outputs) {
+              const filename = output.filename;
+              const url = apiClient.getImageUrl(jobId, filename);
+
+              // Create thumbnail URL (we'll use the same URL but let the browser handle scaling)
+              const thumbnailUrl = url;
+
+              galleryImages.push({
+                jobId,
+                filename,
+                url,
+                thumbnailUrl,
+                prompt: jobDetails.job.prompt || "", // Get prompt from job details
+                negativePrompt: "", // Job info doesn't include negative prompt
+                width: undefined, // Job info doesn't include request details
+                height: undefined, // Job info doesn't include request details
+                steps: undefined, // Job info doesn't include request details
+                cfgScale: undefined, // Job info doesn't include request details
+                seed: undefined, // Job info doesn't include request details
+                model: "Unknown", // Job info doesn't include request details
+                createdAt:
+                  jobDetails.job.created_at || new Date().toISOString(),
+                status: jobDetails.job.status,
+              });
+            }
+          }
+        } catch (err) {
+          console.warn(`Failed to fetch details for job ${jobId}:`, err);
+          // Continue with other jobs even if one fails
+        }
+      }
+
+      // Sort images
+      galleryImages.sort((a, b) => {
+        const dateA = new Date(a.createdAt).getTime();
+        const dateB = new Date(b.createdAt).getTime();
+        return sortBy === "newest" ? dateB - dateA : dateA - dateB;
+      });
+
+      console.log("Gallery: Final images array:", galleryImages);
+      setImages(galleryImages);
+    } catch (err) {
+      console.error("Gallery: Error loading images:", err);
+      setError(
+        err instanceof Error ? err.message : "Failed to load gallery images",
+      );
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    loadGalleryImages();
+  }, [sortBy]);
+
+  const handleImageClick = (image: GalleryImage) => {
+    setSelectedImage(image);
+    setIsModalOpen(true);
+  };
+
+  const handleModalClose = () => {
+    setIsModalOpen(false);
+    setSelectedImage(null);
+  };
+
+  const handleDownload = (image: GalleryImage, e: React.MouseEvent) => {
+    e.stopPropagation();
+    const authToken = localStorage.getItem("auth_token");
+    const unixUser = localStorage.getItem("unix_user");
+    downloadAuthenticatedImage(
+      image.url,
+      `gallery-${image.jobId}-${image.filename}`,
+      authToken || undefined,
+      unixUser || undefined,
+    );
+  };
+
+  const formatDate = (dateString: string) => {
+    return new Date(dateString).toLocaleDateString("en-US", {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  };
+
+  if (loading) {
+    return (
+      <AppLayout>
+        <Header title="Gallery" description="Browse your generated images" />
+        <div className="container mx-auto p-6">
+          <div className="flex items-center justify-center h-96">
+            <div className="flex items-center gap-2">
+              <Loader2 className="h-6 w-6 animate-spin" />
+              <span>Loading gallery...</span>
+            </div>
+          </div>
+        </div>
+      </AppLayout>
+    );
+  }
+
+  if (error) {
+    return (
+      <AppLayout>
+        <Header title="Gallery" description="Browse your generated images" />
+        <div className="container mx-auto p-6">
+          <div className="flex flex-col items-center justify-center h-96 gap-4">
+            <div className="text-destructive text-center">
+              <p className="text-lg font-medium">Error loading gallery</p>
+              <p className="text-sm">{error}</p>
+            </div>
+            <Button onClick={loadGalleryImages} variant="outline">
+              <RefreshCw className="h-4 w-4 mr-2" />
+              Try Again
+            </Button>
+          </div>
+        </div>
+      </AppLayout>
+    );
+  }
+
+  return (
+    <AppLayout>
+      <Header title="Gallery" description="Browse your generated images" />
+      <div className="container mx-auto p-6">
+        {/* Controls */}
+        <div className="flex items-center justify-between mb-6">
+          <div className="flex items-center gap-4">
+            <h2 className="text-2xl font-bold">
+              {images.length} {images.length === 1 ? "Image" : "Images"}
+            </h2>
+            <Button onClick={loadGalleryImages} variant="outline" size="sm">
+              <RefreshCw className="h-4 w-4 mr-2" />
+              Refresh
+            </Button>
+          </div>
+
+          <div className="flex items-center gap-2">
+            <span className="text-sm text-muted-foreground">Sort by:</span>
+            <Button
+              variant={sortBy === "newest" ? "default" : "outline"}
+              size="sm"
+              onClick={() => setSortBy("newest")}
+            >
+              Newest
+            </Button>
+            <Button
+              variant={sortBy === "oldest" ? "default" : "outline"}
+              size="sm"
+              onClick={() => setSortBy("oldest")}
+            >
+              Oldest
+            </Button>
+          </div>
+        </div>
+
+        {/* Gallery Grid */}
+        {images.length === 0 ? (
+          <div className="flex flex-col items-center justify-center h-96 border-2 border-dashed border-border rounded-lg">
+            <ImageIcon className="h-12 w-12 text-muted-foreground mb-4" />
+            <h3 className="text-lg font-medium text-muted-foreground mb-2">
+              No images found
+            </h3>
+            <p className="text-sm text-muted-foreground text-center max-w-md">
+              Generate some images first using the Text to Image, Image to
+              Image, or Inpainting tools.
+            </p>
+          </div>
+        ) : (
+          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
+            {images.map((image, index) => (
+              <Card
+                key={`${image.jobId}-${image.filename}-${index}`}
+                className="group cursor-pointer overflow-hidden hover:shadow-lg transition-all duration-200"
+                onClick={() => handleImageClick(image)}
+              >
+                <CardContent className="p-0">
+                  <div className="relative aspect-square">
+                    <img
+                      src={image.thumbnailUrl}
+                      alt={`Generated image ${index + 1}`}
+                      className="w-full h-full object-cover"
+                      loading="lazy"
+                    />
+
+                    {/* Overlay with actions */}
+                    <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
+                      <Button
+                        size="icon"
+                        variant="secondary"
+                        className="h-8 w-8"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          handleImageClick(image);
+                        }}
+                        title="View full size"
+                      >
+                        <ZoomIn className="h-4 w-4" />
+                      </Button>
+                      <Button
+                        size="icon"
+                        variant="secondary"
+                        className="h-8 w-8"
+                        onClick={(e) => handleDownload(image, e)}
+                        title="Download image"
+                      >
+                        <Download className="h-4 w-4" />
+                      </Button>
+                    </div>
+
+                    {/* Date badge */}
+                    <div className="absolute top-2 left-2">
+                      <Badge variant="secondary" className="text-xs">
+                        {formatDate(image.createdAt)}
+                      </Badge>
+                    </div>
+
+                    {/* Model indicator */}
+                    {image.model && image.model !== "Unknown" && (
+                      <div className="absolute bottom-2 left-2">
+                        <Badge
+                          variant="outline"
+                          className="text-xs max-w-20 truncate"
+                        >
+                          {image.model}
+                        </Badge>
+                      </div>
+                    )}
+                  </div>
+
+                  {/* Image info */}
+                  <div className="p-3">
+                    <div className="flex items-center justify-between text-xs text-muted-foreground">
+                      <span className="truncate">
+                        {image.width && image.height
+                          ? `${image.width}×${image.height}`
+                          : "Unknown size"}
+                      </span>
+                      <div className="flex items-center gap-1">
+                        <Calendar className="h-3 w-3" />
+                        <span>
+                          {new Date(image.createdAt).toLocaleDateString()}
+                        </span>
+                      </div>
+                    </div>
+
+                    {image.prompt && (
+                      <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
+                        {image.prompt}
+                      </p>
+                    )}
+                  </div>
+                </CardContent>
+              </Card>
+            ))}
+          </div>
+        )}
+
+        {/* Image Modal */}
+        <ImageModal
+          image={selectedImage}
+          isOpen={isModalOpen}
+          onClose={handleModalClose}
+        />
+      </div>
+    </AppLayout>
+  );
+}
+
+export default function GalleryPage() {
+  return <GalleryGrid />;
+}

+ 86 - 49
webui/app/globals.css

@@ -1,69 +1,106 @@
 @import "tailwindcss";
 
+/* Prevent horizontal scrolling */
+html,
+body {
+    overflow-x: hidden;
+    width: 100%;
+}
+
+/* Fix container padding to prevent scrolling */
+.container {
+    max-width: 100%;
+    padding-left: 1rem;
+    padding-right: 1rem;
+}
+
 @theme {
-  /* Light mode colors */
-  --color-background: #ffffff;
-  --color-foreground: #09090b;
-  --color-card: #ffffff;
-  --color-card-foreground: #09090b;
-  --color-popover: #ffffff;
-  --color-popover-foreground: #09090b;
-  --color-primary: #18181b;
-  --color-primary-foreground: #fafafa;
-  --color-secondary: #f4f4f5;
-  --color-secondary-foreground: #18181b;
-  --color-muted: #f4f4f5;
-  --color-muted-foreground: #71717a;
-  --color-accent: #f4f4f5;
-  --color-accent-foreground: #18181b;
-  --color-destructive: #ef4444;
-  --color-destructive-foreground: #fafafa;
-  --color-border: #e4e4e7;
-  --color-input: #e4e4e7;
-  --color-ring: #18181b;
+    /* Light mode colors */
+    --color-background: #ffffff;
+    --color-foreground: #09090b;
+    --color-card: #ffffff;
+    --color-card-foreground: #09090b;
+    --color-popover: #ffffff;
+    --color-popover-foreground: #09090b;
+    --color-primary: #18181b;
+    --color-primary-foreground: #fafafa;
+    --color-secondary: #f4f4f5;
+    --color-secondary-foreground: #18181b;
+    --color-muted: #f4f4f5;
+    --color-muted-foreground: #71717a;
+    --color-accent: #f4f4f5;
+    --color-accent-foreground: #18181b;
+    --color-destructive: #ef4444;
+    --color-destructive-foreground: #fafafa;
+    --color-border: #e4e4e7;
+    --color-input: #e4e4e7;
+    --color-ring: #18181b;
 }
 
 @media (prefers-color-scheme: dark) {
-  @theme {
-    --color-background: #09090b;
-    --color-foreground: #fafafa;
-    --color-card: #09090b;
-    --color-card-foreground: #fafafa;
-    --color-popover: #09090b;
-    --color-popover-foreground: #fafafa;
-    --color-primary: #fafafa;
-    --color-primary-foreground: #18181b;
-    --color-secondary: #27272a;
-    --color-secondary-foreground: #fafafa;
-    --color-muted: #27272a;
-    --color-muted-foreground: #a1a1aa;
-    --color-accent: #27272a;
-    --color-accent-foreground: #fafafa;
-    --color-destructive: #7f1d1d;
-    --color-destructive-foreground: #fafafa;
-    --color-border: #27272a;
-    --color-input: #27272a;
-    --color-ring: #d4d4d8;
-  }
+    @theme {
+        --color-background: #09090b;
+        --color-foreground: #fafafa;
+        --color-card: #09090b;
+        --color-card-foreground: #fafafa;
+        --color-popover: #09090b;
+        --color-popover-foreground: #fafafa;
+        --color-primary: #fafafa;
+        --color-primary-foreground: #18181b;
+        --color-secondary: #27272a;
+        --color-secondary-foreground: #fafafa;
+        --color-muted: #27272a;
+        --color-muted-foreground: #a1a1aa;
+        --color-accent: #27272a;
+        --color-accent-foreground: #fafafa;
+        --color-destructive: #7f1d1d;
+        --color-destructive-foreground: #fafafa;
+        --color-border: #27272a;
+        --color-input: #27272a;
+        --color-ring: #d4d4d8;
+    }
+}
+
+body {
+    background-color: var(--color-background);
+    color: var(--color-foreground);
+    font-feature-settings:
+        "rlig" 1,
+        "calt" 1;
+    margin: 0;
+    padding: 0;
+    overflow: hidden;
+    width: 100vw;
+    height: 100vh;
 }
 
+/* Prevent scrolling on html and body elements */
+html,
 body {
-  background-color: var(--color-background);
-  color: var(--color-foreground);
-  font-feature-settings: "rlig" 1, "calt" 1;
+    overflow: hidden;
+    width: 100%;
+    height: 100%;
 }
 
 /* Prompt textarea with syntax highlighting */
 .prompt-textarea-input {
-  caret-color: var(--color-foreground) !important;
+    caret-color: var(--color-foreground) !important;
 }
 
 .prompt-textarea-input::selection {
-  background-color: rgba(59, 130, 246, 0.3);
-  color: transparent;
+    background-color: rgba(59, 130, 246, 0.3);
+    color: transparent;
 }
 
 .prompt-textarea-input::-moz-selection {
-  background-color: rgba(59, 130, 246, 0.3);
-  color: transparent;
+    background-color: rgba(59, 130, 246, 0.3);
+    color: transparent;
+}
+
+/* Gallery utilities */
+.line-clamp-2 {
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
 }

+ 246 - 118
webui/app/img2img/page.tsx

@@ -1,18 +1,23 @@
-'use client';
-
-import { useState, useRef, useEffect } from 'react';
-import { Header, AppLayout } from '@/components/layout';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { PromptTextarea } from '@/components/forms';
-import { Label } from '@/components/ui/label';
-import { Card, CardContent } from '@/components/ui/card';
-import { ImageInput } from '@/components/ui/image-input';
-import { apiClient, type JobInfo } from '@/lib/api';
-import { Loader2, Download, X } from 'lucide-react';
-import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
-import { useLocalStorage, useMemoryStorage } from '@/lib/storage';
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { Header, AppLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { PromptTextarea } from "@/components/forms";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent } from "@/components/ui/card";
+import { ImageInput } from "@/components/ui/image-input";
+import { apiClient, type JobInfo, type JobDetailsResponse } from "@/lib/api";
+import {
+  downloadImage,
+  downloadAuthenticatedImage,
+  fileToBase64,
+} from "@/lib/utils";
+import { useLocalStorage, useMemoryStorage } from "@/lib/storage";
+import { type ImageValidationResult } from "@/lib/image-validation";
+import { Loader2, Download, X } from "lucide-react";
 
 type Img2ImgFormData = {
   prompt: string;
@@ -28,30 +33,28 @@ type Img2ImgFormData = {
 };
 
 const defaultFormData: Img2ImgFormData = {
-  prompt: '',
-  negative_prompt: '',
-  image: '',
+  prompt: "",
+  negative_prompt: "",
+  image: "",
   strength: 0.75,
   steps: 20,
   cfg_scale: 7.5,
-  seed: '',
-  sampling_method: 'euler_a',
+  seed: "",
+  sampling_method: "euler_a",
   width: 512,
   height: 512,
 };
 
 function Img2ImgForm() {
-
   // Store form data without the image to avoid localStorage quota issues
   const { image: _, ...formDataWithoutImage } = defaultFormData;
-  const [formData, setFormData] = useLocalStorage<Omit<Img2ImgFormData, 'image'>>(
-    'img2img-form-data',
-    formDataWithoutImage
-  );
-  
+  const [formData, setFormData] = useLocalStorage<
+    Omit<Img2ImgFormData, "image">
+  >("img2img-form-data", formDataWithoutImage);
+
   // Store image separately in memory
-  const [imageData, setImageData] = useMemoryStorage<string>('');
-  
+  const [imageData, setImageData] = useMemoryStorage<string>("");
+
   // Combined form data with image
   const fullFormData = { ...formData, image: imageData };
 
@@ -61,12 +64,16 @@ function Img2ImgForm() {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
-  const [selectedImage, setSelectedImage] = useState<File | string | null>(null);
-  const [imageValidation, setImageValidation] = useState<any>(null);
+  const [selectedImage, setSelectedImage] = useState<File | string | null>(
+    null,
+  );
+  const [imageValidation, setImageValidation] =
+    useState<ImageValidationResult | null>(null);
   const [originalImage, setOriginalImage] = useState<string | null>(null);
   const [isResizing, setIsResizing] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
+  const [imageLoadingFromUrl, setImageLoadingFromUrl] = useState(false);
 
   // Cleanup polling on unmount
   useEffect(() => {
@@ -81,28 +88,33 @@ function Img2ImgForm() {
     const loadModels = async () => {
       try {
         const [loras, embeds] = await Promise.all([
-          apiClient.getModels('lora'),
-          apiClient.getModels('embedding'),
+          apiClient.getModels("lora"),
+          apiClient.getModels("embedding"),
         ]);
-        setLoraModels(loras.models.map(m => m.name));
-        setEmbeddings(embeds.models.map(m => m.name));
+        setLoraModels(loras.models.map((m) => m.name));
+        setEmbeddings(embeds.models.map((m) => m.name));
       } catch (err) {
-        console.error('Failed to load models:', err);
+        console.error("Failed to load models:", err);
       }
     };
     loadModels();
   }, []);
 
-
   const handleInputChange = (
-    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
+    e: React.ChangeEvent<
+      HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+    >,
   ) => {
     const { name, value } = e.target;
     setFormData((prev) => ({
       ...prev,
-      [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method'
-        ? value
-        : Number(value),
+      [name]:
+        name === "prompt" ||
+        name === "negative_prompt" ||
+        name === "seed" ||
+        name === "sampling_method"
+          ? value
+          : Number(value),
     }));
   };
 
@@ -111,8 +123,8 @@ function Img2ImgForm() {
     setError(null);
 
     if (!image) {
-      setImageData('');
-      setFormData(prev => ({ ...prev, image: '' }));
+      setImageData("");
+      setFormData((prev) => ({ ...prev, image: "" }));
       setPreviewImage(null);
       setImageValidation(null);
       setOriginalImage(null);
@@ -121,76 +133,154 @@ function Img2ImgForm() {
 
     try {
       let imageBase64: string;
-      let previewUrl: string;
+      let previewUrl: string | null;
 
       if (image instanceof File) {
         // Convert File to base64
         imageBase64 = await fileToBase64(image);
         previewUrl = imageBase64;
       } else {
-        // Use URL directly
-        imageBase64 = image;
-        previewUrl = image;
+        // For URLs, don't set preview immediately - wait for validation
+        // The validation will provide base64 data for preview and processing
+        imageBase64 = ""; // Will be set by validation
+        previewUrl = null; // Will be set by validation
+        setImageLoadingFromUrl(true);
+        console.log(
+          "Image URL provided, waiting for validation to process:",
+          image,
+        );
       }
 
-      // Store original image for resizing
-      setOriginalImage(imageBase64);
-      setImageData(imageBase64);
-      setFormData(prev => ({ ...prev })); // Don't store image in localStorage
-      setPreviewImage(previewUrl);
+      // Store original image for resizing (will be updated by validation if URL)
+      if (image instanceof File) {
+        setOriginalImage(imageBase64);
+        setImageData(imageBase64);
+        setPreviewImage(previewUrl);
+      }
+      setFormData((prev) => ({ ...prev })); // Don't store image in localStorage
     } catch (err) {
-      setError('Failed to process image');
-      console.error('Image processing error:', err);
+      setError("Failed to process image");
+      console.error("Image processing error:", err);
     }
   };
 
-  // Auto-resize image when width or height changes
+  // Auto-resize image when width or height changes, but only if we have valid image data
   useEffect(() => {
     const resizeImage = async () => {
       if (!originalImage || !formData.width || !formData.height) {
         return;
       }
 
-      // Don't resize if we're already resizing
-      if (isResizing) {
+      // Don't resize if we're already resizing or still loading from URL
+      if (isResizing || imageLoadingFromUrl) {
         return;
       }
 
+      // Check if we have valid image data (data URL or HTTP URL)
+      const isValidImageData =
+        originalImage.startsWith("data:image/") ||
+        originalImage.startsWith("http://") ||
+        originalImage.startsWith("https://");
+
+      if (!isValidImageData) {
+        console.warn(
+          "Invalid image data format for resizing:",
+          originalImage.substring(0, 50),
+        );
+        return; // Don't show error for timing issues, just skip resize
+      }
+
       try {
         setIsResizing(true);
-        
-        // Validate image data before sending
-        if (!originalImage.startsWith('data:image/')) {
-          console.warn('Invalid image data for resizing');
-          return;
-        }
 
-        const result = await apiClient.resizeImage(originalImage, formData.width, formData.height);
+        console.log(
+          "Attempting to resize image, data type:",
+          originalImage.startsWith("data:image/") ? "data URL" : "HTTP URL",
+        );
+        console.log("Image data length:", originalImage.length);
+        console.log("Resize dimensions:", formData.width, "x", formData.height);
+
+        const result = await apiClient.resizeImage(
+          originalImage,
+          formData.width,
+          formData.height,
+        );
         setImageData(result.image);
-        setFormData(prev => ({ ...prev })); // Don't store image in localStorage
+        setFormData((prev) => ({ ...prev })); // Don't store image in localStorage
         setPreviewImage(result.image);
       } catch (err) {
-        console.error('Failed to resize image:', err);
-        setError('Failed to resize image - you may need to resize manually');
+        console.error("Failed to resize image:", err);
+        const errorMessage =
+          err instanceof Error ? err.message : "Unknown error";
+        setError(
+          `Failed to resize image: ${errorMessage}. You may need to resize the image manually or use different dimensions.`,
+        );
       } finally {
         setIsResizing(false);
       }
     };
 
     resizeImage();
-  }, [formData.width, formData.height, originalImage]);
-
-  const handleImageValidation = (result: any) => {
+  }, [
+    formData.width,
+    formData.height,
+    originalImage,
+    imageLoadingFromUrl,
+    isResizing,
+  ]);
+
+  const handleImageValidation = (result: ImageValidationResult) => {
     setImageValidation(result);
+    setImageLoadingFromUrl(false);
     if (!result.isValid) {
-      setError(result.error || 'Invalid image');
+      setError(result.error || "Invalid image");
     } else {
       setError(null);
-      // If we have base64 data from URL download, use it for preview
-      if (result.base64Data && selectedImage && typeof selectedImage === 'string') {
-        setPreviewImage(result.base64Data);
-        setFormData(prev => ({ ...prev, image: result.base64Data }));
-        setOriginalImage(result.base64Data);
+      // If we have temporary URL or base64 data from URL download, use it for preview and processing
+      if (selectedImage && typeof selectedImage === "string") {
+        if (result.tempUrl) {
+          // Use temporary URL for preview and processing
+          const fullTempUrl = result.tempUrl.startsWith("/")
+            ? `${window.location.origin}${result.tempUrl}`
+            : result.tempUrl;
+
+          setPreviewImage(fullTempUrl);
+          setOriginalImage(fullTempUrl);
+
+          // For processing, we still need to convert to base64 or use the URL directly
+          // The resize endpoint can handle URLs, so we can use the temp URL
+          setImageData(fullTempUrl);
+
+          console.log(
+            "Using temporary URL from URL validation for image processing:",
+            fullTempUrl,
+          );
+          console.log("Temporary filename:", result.tempFilename);
+        } else if (result.base64Data) {
+          // Fallback to base64 data if no temporary URL
+          if (
+            result.base64Data.startsWith("data:image/") &&
+            result.base64Data.includes("base64,")
+          ) {
+            setPreviewImage(result.base64Data);
+            setImageData(result.base64Data);
+            setOriginalImage(result.base64Data);
+            console.log(
+              "Using base64 data from URL validation for image processing",
+            );
+            console.log("Base64 data length:", result.base64Data.length);
+            console.log(
+              "Base64 data preview:",
+              result.base64Data.substring(0, 100) + "...",
+            );
+          } else {
+            console.error(
+              "Invalid base64 data format received from server:",
+              result.base64Data.substring(0, 100),
+            );
+            setError("Invalid image data format received from server");
+          }
+        }
       }
     }
   };
@@ -199,32 +289,36 @@ function Img2ImgForm() {
     const maxAttempts = 300;
     let attempts = 0;
     let isPolling = true;
+    let timeoutId: NodeJS.Timeout | null = null;
 
     const poll = async () => {
       if (!isPolling) return;
-      
+
       try {
-        const status = await apiClient.getJobStatus(jobId);
-        setJobInfo(status);
+        const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
+        setJobInfo(status.job);
 
-        if (status.status === 'completed') {
+        if (status.job.status === "completed") {
           let imageUrls: string[] = [];
 
           // Handle both old format (result.images) and new format (outputs)
-          if (status.outputs && status.outputs.length > 0) {
+          if (status.job.outputs && status.job.outputs.length > 0) {
             // New format: convert output URLs to authenticated image URLs with cache-busting
-            imageUrls = status.outputs.map((output: any) => {
+            imageUrls = status.job.outputs.map((output: any) => {
               const filename = output.filename;
               return apiClient.getImageUrl(jobId, filename);
             });
-          } else if (status.result?.images && status.result.images.length > 0) {
+          } else if (
+            status.job.result?.images &&
+            status.job.result.images.length > 0
+          ) {
             // Old format: convert image URLs to authenticated URLs
-            imageUrls = status.result.images.map((imageUrl: string) => {
+            imageUrls = status.job.result.images.map((imageUrl: string) => {
               // Extract filename from URL if it's already a full URL
-              if (imageUrl.includes('/output/')) {
-                const parts = imageUrl.split('/output/');
+              if (imageUrl.includes("/output/")) {
+                const parts = imageUrl.split("/output/");
                 if (parts.length === 2) {
-                  const filename = parts[1].split('?')[0]; // Remove query params
+                  const filename = parts[1].split("?")[0]; // Remove query params
                   return apiClient.getImageUrl(jobId, filename);
                 }
               }
@@ -237,25 +331,27 @@ function Img2ImgForm() {
           setGeneratedImages([...imageUrls]);
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'failed') {
-          setError(status.error || 'Generation failed');
+        } else if (status.job.status === "failed") {
+          setError(status.job.error || "Generation failed");
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'cancelled') {
-          setError('Generation was cancelled');
+        } else if (status.job.status === "cancelled") {
+          setError("Generation was cancelled");
           setLoading(false);
           isPolling = false;
         } else if (attempts < maxAttempts) {
           attempts++;
-          setTimeout(poll, 2000);
+          timeoutId = setTimeout(poll, 2000);
         } else {
-          setError('Job polling timeout');
+          setError("Job polling timeout");
           setLoading(false);
           isPolling = false;
         }
       } catch (err) {
         if (isPolling) {
-          setError(err instanceof Error ? err.message : 'Failed to check job status');
+          setError(
+            err instanceof Error ? err.message : "Failed to check job status",
+          );
           setLoading(false);
           isPolling = false;
         }
@@ -267,6 +363,9 @@ function Img2ImgForm() {
     // Return cleanup function
     return () => {
       isPolling = false;
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
     };
   };
 
@@ -274,13 +373,13 @@ function Img2ImgForm() {
     e.preventDefault();
 
     if (!fullFormData.image) {
-      setError('Please upload or select an image first');
+      setError("Please upload or select an image first");
       return;
     }
 
     // Check if image validation passed
     if (imageValidation && !imageValidation.isValid) {
-      setError('Please fix the image validation errors before generating');
+      setError("Please fix the image validation errors before generating");
       return;
     }
 
@@ -301,11 +400,11 @@ function Img2ImgForm() {
         const cleanup = pollJobStatus(jobId);
         setPollCleanup(() => cleanup);
       } else {
-        setError('No job ID returned from server');
+        setError("No job ID returned from server");
         setLoading(false);
       }
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to generate image');
+      setError(err instanceof Error ? err.message : "Failed to generate image");
       setLoading(false);
     }
   };
@@ -316,21 +415,24 @@ function Img2ImgForm() {
       try {
         await apiClient.cancelJob(jobId);
         setLoading(false);
-        setError('Generation cancelled');
+        setError("Generation cancelled");
         // Cleanup polling
         if (pollCleanup) {
           pollCleanup();
           setPollCleanup(null);
         }
       } catch (err) {
-        console.error('Failed to cancel job:', err);
+        console.error("Failed to cancel job:", err);
       }
     }
   };
 
   return (
     <AppLayout>
-      <Header title="Image to Image" description="Transform images with AI using text prompts" />
+      <Header
+        title="Image to Image"
+        description="Transform images with AI using text prompts"
+      />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
           {/* Left Panel - Form */}
@@ -355,22 +457,27 @@ function Img2ImgForm() {
                   <Label htmlFor="prompt">Prompt *</Label>
                   <PromptTextarea
                     value={formData.prompt}
-                    onChange={(value) => setFormData({ ...formData, prompt: value })}
+                    onChange={(value) =>
+                      setFormData({ ...formData, prompt: value })
+                    }
                     placeholder="Describe the transformation you want..."
                     rows={3}
                     loras={loraModels}
                     embeddings={embeddings}
                   />
                   <p className="text-xs text-muted-foreground">
-                    Tip: Use &lt;lora:name:weight&gt; for LoRAs and embedding names directly
+                    Tip: Use &lt;lora:name:weight&gt; for LoRAs and embedding
+                    names directly
                   </p>
                 </div>
 
                 <div className="space-y-2">
                   <Label htmlFor="negative_prompt">Negative Prompt</Label>
                   <PromptTextarea
-                    value={formData.negative_prompt || ''}
-                    onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
+                    value={formData.negative_prompt || ""}
+                    onChange={(value) =>
+                      setFormData({ ...formData, negative_prompt: value })
+                    }
                     placeholder="What to avoid..."
                     rows={2}
                     loras={loraModels}
@@ -493,20 +600,31 @@ function Img2ImgForm() {
                   </select>
                 </div>
 
-
                 <div className="flex gap-2">
-                  <Button type="submit" disabled={loading || !imageData || (imageValidation && !imageValidation.isValid)} className="flex-1">
+                  <Button
+                    type="submit"
+                    disabled={
+                      loading ||
+                      !imageData ||
+                      imageValidation?.isValid === false
+                    }
+                    className="flex-1"
+                  >
                     {loading ? (
                       <>
                         <Loader2 className="h-4 w-4 animate-spin" />
                         Generating...
                       </>
                     ) : (
-                      'Generate'
+                      "Generate"
                     )}
                   </Button>
                   {loading && (
-                    <Button type="button" variant="destructive" onClick={handleCancel}>
+                    <Button
+                      type="button"
+                      variant="destructive"
+                      onClick={handleCancel}
+                    >
                       <X className="h-4 w-4" />
                       Cancel
                     </Button>
@@ -530,7 +648,9 @@ function Img2ImgForm() {
                 {generatedImages.length === 0 ? (
                   <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
                     <p className="text-muted-foreground">
-                      {loading ? 'Generating...' : 'Generated images will appear here'}
+                      {loading
+                        ? "Generating..."
+                        : "Generated images will appear here"}
                     </p>
                   </div>
                 ) : (
@@ -547,14 +667,22 @@ function Img2ImgForm() {
                           variant="secondary"
                           className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
                           onClick={() => {
-                            const authToken = localStorage.getItem('auth_token');
-                            const unixUser = localStorage.getItem('unix_user');
-                            downloadAuthenticatedImage(image, `img2img-${Date.now()}-${index}.png`, authToken || undefined, unixUser || undefined)
-                              .catch(err => {
-                                console.error('Failed to download image:', err);
-                                // Fallback to regular download if authenticated download fails
-                                downloadImage(image, `img2img-${Date.now()}-${index}.png`);
-                              });
+                            const authToken =
+                              localStorage.getItem("auth_token");
+                            const unixUser = localStorage.getItem("unix_user");
+                            downloadAuthenticatedImage(
+                              image,
+                              `img2img-${Date.now()}-${index}.png`,
+                              authToken || undefined,
+                              unixUser || undefined,
+                            ).catch((err) => {
+                              console.error("Failed to download image:", err);
+                              // Fallback to regular download if authenticated download fails
+                              downloadImage(
+                                image,
+                                `img2img-${Date.now()}-${index}.png`,
+                              );
+                            });
                           }}
                         >
                           <Download className="h-4 w-4" />
@@ -574,4 +702,4 @@ function Img2ImgForm() {
 
 export default function Img2ImgPage() {
   return <Img2ImgForm />;
-}
+}

+ 175 - 102
webui/app/inpainting/page.tsx

@@ -1,21 +1,38 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import { Header } from '@/components/layout';
-import { AppLayout } from '@/components/layout';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { PromptTextarea } from '@/components/forms';
-import { Label } from '@/components/ui/label';
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
-import { InpaintingCanvas } from '@/components/features/image-generation';
-import { apiClient, type JobInfo } from '@/lib/api';
-import { Loader2, X, Download } from 'lucide-react';
-import { downloadAuthenticatedImage } from '@/lib/utils';
-import { useLocalStorage } from '@/lib/storage';
-import { ModelSelectionProvider, useModelSelection, useCheckpointSelection, useModelTypeSelection } from '@/contexts/model-selection-context';
-import { EnhancedModelSelect } from '@/components/features/models';
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Header } from "@/components/layout";
+import { AppLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { PromptTextarea } from "@/components/forms";
+import { Label } from "@/components/ui/label";
+import {
+  Card,
+  CardContent,
+  CardHeader,
+  CardTitle,
+  CardDescription,
+} from "@/components/ui/card";
+import { InpaintingCanvas } from "@/components/features/image-generation";
+import { apiClient, type JobInfo, type JobDetailsResponse } from "@/lib/api";
+import { Loader2, X, Download } from "lucide-react";
+import { downloadAuthenticatedImage } from "@/lib/utils";
+import { useLocalStorage } from "@/lib/storage";
+import {
+  ModelSelectionProvider,
+  useModelSelection,
+  useCheckpointSelection,
+  useModelTypeSelection,
+} from "@/contexts/model-selection-context";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 // import { AutoSelectionStatus } from '@/components/features/models';
 
 type InpaintingFormData = {
@@ -31,12 +48,12 @@ type InpaintingFormData = {
 };
 
 const defaultFormData: InpaintingFormData = {
-  prompt: '',
-  negative_prompt: '',
+  prompt: "",
+  negative_prompt: "",
   steps: 20,
   cfg_scale: 7.5,
-  seed: '',
-  sampling_method: 'euler_a',
+  seed: "",
+  sampling_method: "euler_a",
   strength: 0.75,
   width: 512,
   height: 512,
@@ -51,9 +68,9 @@ function InpaintingForm() {
     setSelectedCheckpoint,
     isAutoSelecting,
     warnings,
-    error: checkpointError
+    error: checkpointError,
   } = useCheckpointSelection();
-  
+
   const {
     availableModels: vaeModels,
     selectedModel: selectedVae,
@@ -62,17 +79,17 @@ function InpaintingForm() {
     setSelectedModel: setSelectedVae,
     setUserOverride: setVaeUserOverride,
     clearUserOverride: clearVaeUserOverride,
-  } = useModelTypeSelection('vae');
+  } = useModelTypeSelection("vae");
 
   const [formData, setFormData] = useLocalStorage<InpaintingFormData>(
-    'inpainting-form-data',
+    "inpainting-form-data",
     defaultFormData,
-    { excludeLargeData: true, maxSize: 512 * 1024 }
+    { excludeLargeData: true, maxSize: 512 * 1024 },
   );
 
   // Separate state for image data (not stored in localStorage)
-  const [sourceImage, setSourceImage] = useState<string>('');
-  const [maskImage, setMaskImage] = useState<string>('');
+  const [sourceImage, setSourceImage] = useState<string>("");
+  const [maskImage, setMaskImage] = useState<string>("");
 
   const [loading, setLoading] = useState(false);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
@@ -96,14 +113,14 @@ function InpaintingForm() {
       try {
         const [modelsData, loras, embeds] = await Promise.all([
           apiClient.getModels(), // Get all models with enhanced info
-          apiClient.getModels('lora'),
-          apiClient.getModels('embedding'),
+          apiClient.getModels("lora"),
+          apiClient.getModels("embedding"),
         ]);
         actions.setModels(modelsData.models);
-        setLoraModels(loras.models.map(m => m.name));
-        setEmbeddings(embeds.models.map(m => m.name));
+        setLoraModels(loras.models.map((m) => m.name));
+        setEmbeddings(embeds.models.map((m) => m.name));
       } catch (err) {
-        console.error('Failed to load models:', err);
+        console.error("Failed to load models:", err);
       }
     };
     loadModels();
@@ -112,7 +129,7 @@ function InpaintingForm() {
   // Update form data when checkpoint changes
   useEffect(() => {
     if (selectedCheckpoint) {
-      setFormData(prev => ({
+      setFormData((prev) => ({
         ...prev,
         model: selectedCheckpoint,
       }));
@@ -120,14 +137,20 @@ function InpaintingForm() {
   }, [selectedCheckpoint, setFormData]);
 
   const handleInputChange = (
-    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
+    e: React.ChangeEvent<
+      HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+    >,
   ) => {
     const { name, value } = e.target;
     setFormData((prev) => ({
       ...prev,
-      [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method'
-        ? value
-        : Number(value),
+      [name]:
+        name === "prompt" ||
+        name === "negative_prompt" ||
+        name === "seed" ||
+        name === "sampling_method"
+          ? value
+          : Number(value),
     }));
   };
 
@@ -145,37 +168,40 @@ function InpaintingForm() {
     const maxAttempts = 300; // 5 minutes with 2 second interval
     let attempts = 0;
     let isPolling = true;
+    let timeoutId: NodeJS.Timeout | null = null;
 
     const poll = async () => {
       if (!isPolling) return;
-      
+
       try {
-        const status = await apiClient.getJobStatus(jobId);
-        setJobInfo(status);
+        const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
+        setJobInfo(status.job);
 
-        if (status.status === 'completed') {
+        if (status.job.status === "completed") {
           let imageUrls: string[] = [];
 
           // Handle both old format (result.images) and new format (outputs)
-          if (status.outputs && status.outputs.length > 0) {
+          if (status.job.outputs && status.job.outputs.length > 0) {
             // New format: convert output URLs to authenticated image URLs with cache-busting
-            imageUrls = status.outputs.map((output: any) => {
+            imageUrls = status.job.outputs.map((output: any) => {
               const filename = output.filename;
               return apiClient.getImageUrl(jobId, filename);
             });
-          } else if (status.result?.images && status.result.images.length > 0) {
+          } else if (
+            status.job.result?.images &&
+            status.job.result.images.length > 0
+          ) {
             // Old format: convert image URLs to authenticated URLs
-            imageUrls = status.result.images.map((imageUrl: string) => {
+            imageUrls = status.job.result.images.map((imageUrl: string) => {
               // Extract filename from URL if it's already a full URL
-              if (imageUrl.includes('/output/')) {
-                const parts = imageUrl.split('/output/');
+              if (imageUrl.includes("/output/")) {
+                const parts = imageUrl.split("/output/");
                 if (parts.length === 2) {
-                  const filename = parts[1].split('?')[0]; // Remove query params
+                  const filename = parts[1].split("?")[0]; // Remove query params
                   return apiClient.getImageUrl(jobId, filename);
                 }
               }
-              // If it's just a filename, convert it directly
-              return apiClient.getImageUrl(jobId, imageUrl);
+              return imageUrl; // Already a full URL
             });
           }
 
@@ -183,25 +209,27 @@ function InpaintingForm() {
           setGeneratedImages([...imageUrls]);
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'failed') {
-          setError(status.error || 'Generation failed');
+        } else if (status.job.status === "failed") {
+          setError(status.job.error || "Generation failed");
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'cancelled') {
-          setError('Generation was cancelled');
+        } else if (status.job.status === "cancelled") {
+          setError("Generation was cancelled");
           setLoading(false);
           isPolling = false;
         } else if (attempts < maxAttempts) {
           attempts++;
-          setTimeout(poll, 2000);
+          timeoutId = setTimeout(poll, 2000);
         } else {
-          setError('Job polling timeout');
+          setError("Job polling timeout");
           setLoading(false);
           isPolling = false;
         }
       } catch (err) {
         if (isPolling) {
-          setError(err instanceof Error ? err.message : 'Failed to check job status');
+          setError(
+            err instanceof Error ? err.message : "Failed to check job status",
+          );
           setLoading(false);
           isPolling = false;
         }
@@ -213,6 +241,9 @@ function InpaintingForm() {
     // Return cleanup function
     return () => {
       isPolling = false;
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
     };
   };
 
@@ -220,12 +251,12 @@ function InpaintingForm() {
     e.preventDefault();
 
     if (!sourceImage) {
-      setError('Please upload a source image first');
+      setError("Please upload a source image first");
       return;
     }
 
     if (!maskImage) {
-      setError('Please create a mask first');
+      setError("Please create a mask first");
       return;
     }
 
@@ -239,7 +270,9 @@ function InpaintingForm() {
       if (selectedCheckpointModel) {
         const validation = actions.validateSelection(selectedCheckpointModel);
         if (!validation.isValid) {
-          setError(`Missing required models: ${validation.missingRequired.join(', ')}`);
+          setError(
+            `Missing required models: ${validation.missingRequired.join(", ")}`,
+          );
           setLoading(false);
           return;
         }
@@ -260,11 +293,11 @@ function InpaintingForm() {
         const cleanup = pollJobStatus(jobId);
         setPollCleanup(() => cleanup);
       } else {
-        setError('No job ID returned from server');
+        setError("No job ID returned from server");
         setLoading(false);
       }
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to generate image');
+      setError(err instanceof Error ? err.message : "Failed to generate image");
       setLoading(false);
     }
   };
@@ -275,21 +308,24 @@ function InpaintingForm() {
       try {
         await apiClient.cancelJob(jobId);
         setLoading(false);
-        setError('Generation cancelled');
+        setError("Generation cancelled");
         // Cleanup polling
         if (pollCleanup) {
           pollCleanup();
           setPollCleanup(null);
         }
       } catch (err) {
-        console.error('Failed to cancel job:', err);
+        console.error("Failed to cancel job:", err);
       }
     }
   };
 
   return (
     <AppLayout>
-      <Header title="Inpainting" description="Edit images by masking areas and regenerating with AI" />
+      <Header
+        title="Inpainting"
+        description="Edit images by masking areas and regenerating with AI"
+      />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
           {/* Left Panel - Canvas and Form */}
@@ -308,22 +344,27 @@ function InpaintingForm() {
                     <Label htmlFor="prompt">Prompt *</Label>
                     <PromptTextarea
                       value={formData.prompt}
-                      onChange={(value) => setFormData({ ...formData, prompt: value })}
+                      onChange={(value) =>
+                        setFormData({ ...formData, prompt: value })
+                      }
                       placeholder="Describe what to generate in the masked areas..."
                       rows={3}
                       loras={loraModels}
                       embeddings={embeddings}
                     />
                     <p className="text-xs text-muted-foreground">
-                      Tip: Use {'<lora:name:weight>'} for LoRAs and embedding names directly
+                      Tip: Use {"<lora:name:weight>"} for LoRAs and embedding
+                      names directly
                     </p>
                   </div>
 
                   <div className="space-y-2">
                     <Label htmlFor="negative_prompt">Negative Prompt</Label>
                     <PromptTextarea
-                      value={formData.negative_prompt || ''}
-                      onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
+                      value={formData.negative_prompt || ""}
+                      onChange={(value) =>
+                        setFormData({ ...formData, negative_prompt: value })
+                      }
                       placeholder="What to avoid in the generated areas..."
                       rows={2}
                       loras={loraModels}
@@ -437,50 +478,71 @@ function InpaintingForm() {
                       <option value="lcm">LCM</option>
                     </select>
                   </div>
-  
+
                   {/* Model Selection Section */}
                   <Card>
                     <CardHeader>
                       <CardTitle>Model Selection</CardTitle>
-                      <CardDescription>Select checkpoint and additional models for generation</CardDescription>
+                      <CardDescription>
+                        Select checkpoint and additional models for generation
+                      </CardDescription>
                     </CardHeader>
                     {/* Checkpoint Selection */}
                     <div className="space-y-2">
                       <Label htmlFor="checkpoint">Checkpoint Model *</Label>
                       <select
                         id="checkpoint"
-                        value={selectedCheckpoint || ''}
-                        onChange={(e) => setSelectedCheckpoint(e.target.value || null)}
+                        value={selectedCheckpoint || ""}
+                        onChange={(e) =>
+                          setSelectedCheckpoint(e.target.value || null)
+                        }
                         className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                         disabled={isAutoSelecting}
                       >
                         <option value="">Select a checkpoint model...</option>
                         {checkpointModels.map((model) => (
                           <option key={model.id} value={model.name}>
-                            {model.name} {model.loaded ? '(Loaded)' : ''}
+                            {model.name} {model.loaded ? "(Loaded)" : ""}
                           </option>
                         ))}
                       </select>
                     </div>
-  
+
                     {/* VAE Selection */}
-                    <EnhancedModelSelect
-                      modelType="vae"
-                      label="VAE Model"
-                      description="Optional VAE model for improved image quality"
-                      value={selectedVae}
-                      availableModels={vaeModels}
-                      isAutoSelected={isVaeAutoSelected}
-                      isUserOverride={isVaeUserOverride}
-                      isLoading={isAutoSelecting}
-                      onValueChange={setSelectedVae}
-                      onSetUserOverride={setVaeUserOverride}
-                      onClearOverride={clearVaeUserOverride}
-                      placeholder="Use default VAE"
-                    />
-  
+                    <div className="space-y-2">
+                      <Label>VAE Model (Optional)</Label>
+                      <Select
+                        value={selectedVae || ""}
+                        onValueChange={(value) => {
+                          if (value) {
+                            setSelectedVae(value);
+                            setVaeUserOverride(value);
+                          } else {
+                            clearVaeUserOverride();
+                          }
+                        }}
+                        disabled={isAutoSelecting}
+                      >
+                        <SelectTrigger>
+                          <SelectValue placeholder="Use default VAE" />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="">Use default VAE</SelectItem>
+                          {vaeModels.map((model) => (
+                            <SelectItem key={model.name} value={model.name}>
+                              {model.name}
+                            </SelectItem>
+                          ))}
+                        </SelectContent>
+                      </Select>
+                      {isVaeAutoSelected && (
+                        <p className="text-xs text-muted-foreground">
+                          Auto-selected VAE model
+                        </p>
+                      )}
+                    </div>
                   </Card>
-  
+
                   <div className="flex gap-2">
                     <Button
                       type="submit"
@@ -493,11 +555,15 @@ function InpaintingForm() {
                           Generating...
                         </>
                       ) : (
-                        'Generate'
+                        "Generate"
                       )}
                     </Button>
                     {loading && (
-                      <Button type="button" variant="destructive" onClick={handleCancel}>
+                      <Button
+                        type="button"
+                        variant="destructive"
+                        onClick={handleCancel}
+                      >
                         <X className="h-4 w-4" />
                         Cancel
                       </Button>
@@ -522,7 +588,9 @@ function InpaintingForm() {
                 {generatedImages.length === 0 ? (
                   <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
                     <p className="text-muted-foreground">
-                      {loading ? 'Generating...' : 'Generated images will appear here'}
+                      {loading
+                        ? "Generating..."
+                        : "Generated images will appear here"}
                     </p>
                   </div>
                 ) : (
@@ -539,12 +607,17 @@ function InpaintingForm() {
                           variant="secondary"
                           className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
                           onClick={() => {
-                            const authToken = localStorage.getItem('auth_token');
-                            const unixUser = localStorage.getItem('unix_user');
-                            downloadAuthenticatedImage(image, `inpainting-${Date.now()}-${index}.png`, authToken || undefined, unixUser || undefined)
-                              .catch(err => {
-                                console.error('Failed to download image:', err);
-                              });
+                            const authToken =
+                              localStorage.getItem("auth_token");
+                            const unixUser = localStorage.getItem("unix_user");
+                            downloadAuthenticatedImage(
+                              image,
+                              `inpainting-${Date.now()}-${index}.png`,
+                              authToken || undefined,
+                              unixUser || undefined,
+                            ).catch((err) => {
+                              console.error("Failed to download image:", err);
+                            });
                           }}
                         >
                           <Download className="h-4 w-4" />

+ 152 - 91
webui/app/text2img/page.tsx

@@ -1,89 +1,107 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import { Header } from '@/components/layout';
-import { AppLayout } from '@/components/layout';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { PromptTextarea } from '@/components/forms';
-import { Label } from '@/components/ui/label';
-import { Card, CardContent } from '@/components/ui/card';
-import { apiClient, type GenerationRequest, type JobInfo, type ModelInfo } from '@/lib/api';
-import { Loader2, Download, X, Trash2, RotateCcw, Power } from 'lucide-react';
-import { downloadImage, downloadAuthenticatedImage } from '@/lib/utils';
-import { useLocalStorage } from '@/lib/storage';
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Header } from "@/components/layout";
+import { AppLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { PromptTextarea } from "@/components/forms";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+  apiClient,
+  type GenerationRequest,
+  type JobInfo,
+  type ModelInfo,
+  type JobDetailsResponse,
+} from "@/lib/api";
+import { Loader2, Download, X, Trash2, RotateCcw, Power } from "lucide-react";
+import { downloadImage, downloadAuthenticatedImage } from "@/lib/utils";
+import { useLocalStorage } from "@/lib/storage";
 
 const defaultFormData: GenerationRequest = {
-  prompt: '',
-  negative_prompt: '',
+  prompt: "",
+  negative_prompt: "",
   width: 512,
   height: 512,
   steps: 20,
   cfg_scale: 7.5,
-  seed: '',
-  sampling_method: 'euler_a',
-  scheduler: 'default',
+  seed: "",
+  sampling_method: "euler_a",
+  scheduler: "default",
   batch_count: 1,
 };
 
 function Text2ImgForm() {
-
   const [formData, setFormData] = useLocalStorage<GenerationRequest>(
-    'text2img-form-data',
+    "text2img-form-data",
     defaultFormData,
-    { excludeLargeData: true, maxSize: 512 * 1024 } // 512KB limit
+    { excludeLargeData: true, maxSize: 512 * 1024 }, // 512KB limit
   );
 
   const [loading, setLoading] = useState(false);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
   const [generatedImages, setGeneratedImages] = useState<string[]>([]);
-  const [samplers, setSamplers] = useState<Array<{ name: string; description: string }>>([]);
-  const [schedulers, setSchedulers] = useState<Array<{ name: string; description: string }>>([]);
+  const [samplers, setSamplers] = useState<
+    Array<{ name: string; description: string }>
+  >([]);
+  const [schedulers, setSchedulers] = useState<
+    Array<{ name: string; description: string }>
+  >([]);
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
   const [error, setError] = useState<string | null>(null);
-  const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
+  const pollCleanupRef = useRef<(() => void) | null>(null);
 
   // Cleanup polling on unmount
   useEffect(() => {
     return () => {
-      if (pollCleanup) {
-        pollCleanup();
+      if (pollCleanupRef.current) {
+        pollCleanupRef.current();
+        pollCleanupRef.current = null;
       }
     };
-  }, [pollCleanup]);
+  }, []);
 
   useEffect(() => {
     const loadOptions = async () => {
       try {
-        const [samplersData, schedulersData, loras, embeds] = await Promise.all([
-          apiClient.getSamplers(),
-          apiClient.getSchedulers(),
-          apiClient.getModels('lora'),
-          apiClient.getModels('embedding'),
-        ]);
+        const [samplersData, schedulersData, loras, embeds] = await Promise.all(
+          [
+            apiClient.getSamplers(),
+            apiClient.getSchedulers(),
+            apiClient.getModels("lora"),
+            apiClient.getModels("embedding"),
+          ],
+        );
         setSamplers(samplersData);
         setSchedulers(schedulersData);
-        setLoraModels(loras.models.map(m => m.name));
-        setEmbeddings(embeds.models.map(m => m.name));
+        setLoraModels(loras.models.map((m) => m.name));
+        setEmbeddings(embeds.models.map((m) => m.name));
       } catch (err) {
-        console.error('Failed to load options:', err);
+        console.error("Failed to load options:", err);
       }
     };
     loadOptions();
   }, []);
 
-
   const handleInputChange = (
-    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
+    e: React.ChangeEvent<
+      HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+    >,
   ) => {
     const { name, value } = e.target;
     setFormData((prev) => ({
       ...prev,
-      [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method' || name === 'scheduler'
-        ? value
-        : Number(value),
+      [name]:
+        name === "prompt" ||
+        name === "negative_prompt" ||
+        name === "seed" ||
+        name === "sampling_method" ||
+        name === "scheduler"
+          ? value
+          : Number(value),
     }));
   };
 
@@ -91,30 +109,41 @@ function Text2ImgForm() {
     const maxAttempts = 300; // 5 minutes with 2 second interval
     let attempts = 0;
     let isPolling = true;
+    let timeoutId: NodeJS.Timeout | null = null;
 
     const poll = async () => {
       if (!isPolling) return;
-      
+
       try {
-        const status = await apiClient.getJobStatus(jobId);
-        setJobInfo(status);
+        const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
+        setJobInfo(status.job);
 
-        if (status.status === 'completed') {
+        if (status.job.status === "completed") {
           let imageUrls: string[] = [];
 
           // Handle both old format (result.images) and new format (outputs)
-          if (status.outputs && status.outputs.length > 0) {
+          if (status.job.outputs && status.job.outputs.length > 0) {
             // New format: convert output URLs to authenticated image URLs with cache-busting
-            imageUrls = status.outputs.map((output: any) => {
+            imageUrls = status.job.outputs.map((output: any) => {
               const filename = output.filename;
               return apiClient.getImageUrl(jobId, filename);
             });
-          } else if (status.result?.images && status.result.images.length > 0) {
+          } else if (
+            status.job.result?.images &&
+            status.job.result.images.length > 0
+          ) {
             // Old format: convert image URLs to authenticated URLs
-            imageUrls = status.result.images.map((imageUrl: string) => {
+            imageUrls = status.job.result.images.map((imageUrl: string) => {
               // Extract filename from URL if it's a full URL
-              const filename = imageUrl.split('/').pop() || imageUrl;
-              return apiClient.getImageUrl(jobId, filename);
+              if (imageUrl.includes("/output/")) {
+                const parts = imageUrl.split("/output/");
+                if (parts.length === 2) {
+                  const filename = parts[1].split("?")[0]; // Remove query params
+                  return apiClient.getImageUrl(jobId, filename);
+                }
+              }
+              // If it's just a filename, convert it directly
+              return apiClient.getImageUrl(jobId, imageUrl);
             });
           }
 
@@ -122,25 +151,27 @@ function Text2ImgForm() {
           setGeneratedImages([...imageUrls]);
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'failed') {
-          setError(status.error || 'Generation failed');
+        } else if (status.job.status === "failed") {
+          setError(status.job.error || "Generation failed");
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'cancelled') {
-          setError('Generation was cancelled');
+        } else if (status.job.status === "cancelled") {
+          setError("Generation was cancelled");
           setLoading(false);
           isPolling = false;
         } else if (attempts < maxAttempts) {
           attempts++;
-          setTimeout(poll, 2000);
+          timeoutId = setTimeout(poll, 2000);
         } else {
-          setError('Job polling timeout');
+          setError("Job polling timeout");
           setLoading(false);
           isPolling = false;
         }
       } catch (err) {
         if (isPolling) {
-          setError(err instanceof Error ? err.message : 'Failed to check job status');
+          setError(
+            err instanceof Error ? err.message : "Failed to check job status",
+          );
           setLoading(false);
           isPolling = false;
         }
@@ -152,6 +183,9 @@ function Text2ImgForm() {
     // Return cleanup function
     return () => {
       isPolling = false;
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
     };
   };
 
@@ -172,14 +206,15 @@ function Text2ImgForm() {
       setJobInfo(job);
       const jobId = job.request_id || job.id;
       if (jobId) {
-        const cleanup = pollJobStatus(jobId);
-        setPollCleanup(() => cleanup);
+        pollJobStatus(jobId).then((cleanup) => {
+          pollCleanupRef.current = cleanup;
+        });
       } else {
-        setError('No job ID returned from server');
+        setError("No job ID returned from server");
         setLoading(false);
       }
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to generate image');
+      setError(err instanceof Error ? err.message : "Failed to generate image");
       setLoading(false);
     }
   };
@@ -190,20 +225,20 @@ function Text2ImgForm() {
       try {
         await apiClient.cancelJob(jobId);
         setLoading(false);
-        setError('Generation cancelled');
+        setError("Generation cancelled");
         // Cleanup polling
-        if (pollCleanup) {
-          pollCleanup();
-          setPollCleanup(null);
+        if (pollCleanupRef.current) {
+          pollCleanupRef.current();
+          pollCleanupRef.current = null;
         }
       } catch (err) {
-        console.error('Failed to cancel job:', err);
+        console.error("Failed to cancel job:", err);
       }
     }
   };
 
   const handleClearPrompts = () => {
-    setFormData({ ...formData, prompt: '', negative_prompt: '' });
+    setFormData({ ...formData, prompt: "", negative_prompt: "" });
   };
 
   const handleResetToDefaults = () => {
@@ -211,25 +246,32 @@ function Text2ImgForm() {
   };
 
   const handleServerRestart = async () => {
-    if (!confirm('Are you sure you want to restart the server? This will cancel all running jobs.')) {
+    if (
+      !confirm(
+        "Are you sure you want to restart the server? This will cancel all running jobs.",
+      )
+    ) {
       return;
     }
     try {
       setLoading(true);
       await apiClient.restartServer();
-      setError('Server restart initiated. Please wait...');
+      setError("Server restart initiated. Please wait...");
       setTimeout(() => {
         window.location.reload();
       }, 3000);
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to restart server');
+      setError(err instanceof Error ? err.message : "Failed to restart server");
       setLoading(false);
     }
   };
 
   return (
     <AppLayout>
-      <Header title="Text to Image" description="Generate images from text prompts" />
+      <Header
+        title="Text to Image"
+        description="Generate images from text prompts"
+      />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
           {/* Left Panel - Form */}
@@ -240,22 +282,27 @@ function Text2ImgForm() {
                   <Label htmlFor="prompt">Prompt *</Label>
                   <PromptTextarea
                     value={formData.prompt}
-                    onChange={(value) => setFormData({ ...formData, prompt: value })}
+                    onChange={(value) =>
+                      setFormData({ ...formData, prompt: value })
+                    }
                     placeholder="a beautiful landscape with mountains and a lake, sunset, highly detailed..."
                     rows={4}
                     loras={loraModels}
                     embeddings={embeddings}
                   />
                   <p className="text-xs text-muted-foreground">
-                    Tip: Use &lt;lora:name:weight&gt; for LoRAs (e.g., &lt;lora:myLora:0.8&gt;) and embedding names directly
+                    Tip: Use &lt;lora:name:weight&gt; for LoRAs (e.g.,
+                    &lt;lora:myLora:0.8&gt;) and embedding names directly
                   </p>
                 </div>
 
                 <div className="space-y-2">
                   <Label htmlFor="negative_prompt">Negative Prompt</Label>
                   <PromptTextarea
-                    value={formData.negative_prompt || ''}
-                    onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
+                    value={formData.negative_prompt || ""}
+                    onChange={(value) =>
+                      setFormData({ ...formData, negative_prompt: value })
+                    }
                     placeholder="blurry, low quality, distorted..."
                     rows={2}
                     loras={loraModels}
@@ -401,7 +448,8 @@ function Text2ImgForm() {
                     {schedulers.length > 0 ? (
                       schedulers.map((scheduler) => (
                         <option key={scheduler.name} value={scheduler.name}>
-                          {scheduler.name.toUpperCase()} - {scheduler.description}
+                          {scheduler.name.toUpperCase()} -{" "}
+                          {scheduler.description}
                         </option>
                       ))
                     ) : (
@@ -410,7 +458,6 @@ function Text2ImgForm() {
                   </select>
                 </div>
 
-
                 <div className="space-y-2">
                   <Label htmlFor="batch_count">Batch Count</Label>
                   <Input
@@ -432,11 +479,15 @@ function Text2ImgForm() {
                         Generating...
                       </>
                     ) : (
-                      'Generate'
+                      "Generate"
                     )}
                   </Button>
                   {loading && (
-                    <Button type="button" variant="destructive" onClick={handleCancel}>
+                    <Button
+                      type="button"
+                      variant="destructive"
+                      onClick={handleCancel}
+                    >
                       <X className="h-4 w-4" />
                       Cancel
                     </Button>
@@ -460,7 +511,9 @@ function Text2ImgForm() {
                 {generatedImages.length === 0 ? (
                   <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
                     <p className="text-muted-foreground">
-                      {loading ? 'Generating...' : 'Generated images will appear here'}
+                      {loading
+                        ? "Generating..."
+                        : "Generated images will appear here"}
                     </p>
                   </div>
                 ) : (
@@ -477,14 +530,22 @@ function Text2ImgForm() {
                           variant="secondary"
                           className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
                           onClick={() => {
-                            const authToken = localStorage.getItem('auth_token');
-                            const unixUser = localStorage.getItem('unix_user');
-                            downloadAuthenticatedImage(image, `generated-${Date.now()}-${index}.png`, authToken || undefined, unixUser || undefined)
-                              .catch(err => {
-                                console.error('Failed to download image:', err);
-                                // Fallback to regular download if authenticated download fails
-                                downloadImage(image, `generated-${Date.now()}-${index}.png`);
-                              });
+                            const authToken =
+                              localStorage.getItem("auth_token");
+                            const unixUser = localStorage.getItem("unix_user");
+                            downloadAuthenticatedImage(
+                              image,
+                              `generated-${Date.now()}-${index}.png`,
+                              authToken || undefined,
+                              unixUser || undefined,
+                            ).catch((err) => {
+                              console.error("Failed to download image:", err);
+                              // Fallback to regular download if authenticated download fails
+                              downloadImage(
+                                image,
+                                `generated-${Date.now()}-${index}.png`,
+                              );
+                            });
                           }}
                         >
                           <Download className="h-4 w-4" />

+ 156 - 84
webui/app/upscaler/page.tsx

@@ -1,18 +1,43 @@
-'use client';
-
-import { useState, useRef, useEffect } from 'react';
-import { Header } from '@/components/layout';
-import { AppLayout } from '@/components/layout';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
-import { apiClient, type JobInfo, type ModelInfo } from '@/lib/api';
-import { Loader2, Download, X, Upload } from 'lucide-react';
-import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
-import { useLocalStorage } from '@/lib/storage';
-import { ModelSelectionProvider, useModelSelection, useModelTypeSelection } from '@/contexts/model-selection-context';
-import { EnhancedModelSelect } from '@/components/features/models';
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { Header } from "@/components/layout";
+import { AppLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+  Card,
+  CardContent,
+  CardHeader,
+  CardTitle,
+  CardDescription,
+} from "@/components/ui/card";
+import {
+  apiClient,
+  type JobInfo,
+  type ModelInfo,
+  type JobDetailsResponse,
+} from "@/lib/api";
+import { Loader2, Download, X, Upload } from "lucide-react";
+import {
+  downloadImage,
+  downloadAuthenticatedImage,
+  fileToBase64,
+} from "@/lib/utils";
+import { useLocalStorage } from "@/lib/storage";
+import {
+  ModelSelectionProvider,
+  useModelSelection,
+  useModelTypeSelection,
+} from "@/contexts/model-selection-context";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 // import { AutoSelectionStatus } from '@/components/features/models';
 
 type UpscalerFormData = {
@@ -22,12 +47,12 @@ type UpscalerFormData = {
 
 const defaultFormData: UpscalerFormData = {
   upscale_factor: 2,
-  model: '',
+  model: "",
 };
 
 function UpscalerForm() {
   const { state, actions } = useModelSelection();
-  
+
   const {
     availableModels: upscalerModels,
     selectedModel: selectedUpscalerModel,
@@ -36,16 +61,16 @@ function UpscalerForm() {
     setSelectedModel: setSelectedUpscalerModel,
     setUserOverride: setUpscalerUserOverride,
     clearUserOverride: clearUpscalerUserOverride,
-  } = useModelTypeSelection('upscaler');
+  } = useModelTypeSelection("upscaler");
 
   const [formData, setFormData] = useLocalStorage<UpscalerFormData>(
-    'upscaler-form-data',
+    "upscaler-form-data",
     defaultFormData,
-    { excludeLargeData: true, maxSize: 512 * 1024 }
+    { excludeLargeData: true, maxSize: 512 * 1024 },
   );
 
   // Separate state for image data (not stored in localStorage)
-  const [uploadedImage, setUploadedImage] = useState<string>('');
+  const [uploadedImage, setUploadedImage] = useState<string>("");
   const [previewImage, setPreviewImage] = useState<string | null>(null);
 
   const [loading, setLoading] = useState(false);
@@ -71,17 +96,22 @@ function UpscalerForm() {
         const modelsData = await apiClient.getModels();
         // Filter for upscaler models (ESRGAN and upscaler types)
         const allUpscalerModels = [
-          ...modelsData.models.filter(m => m.type.toLowerCase() === 'esrgan'),
-          ...modelsData.models.filter(m => m.type.toLowerCase() === 'upscaler')
+          ...modelsData.models.filter((m) => m.type.toLowerCase() === "esrgan"),
+          ...modelsData.models.filter(
+            (m) => m.type.toLowerCase() === "upscaler",
+          ),
         ];
         actions.setModels(modelsData.models);
-        
+
         // Set first model as default if none selected
         if (allUpscalerModels.length > 0 && !formData.model) {
-          setFormData(prev => ({ ...prev, model: allUpscalerModels[0].name }));
+          setFormData((prev) => ({
+            ...prev,
+            model: allUpscalerModels[0].name,
+          }));
         }
       } catch (err) {
-        console.error('Failed to load upscaler models:', err);
+        console.error("Failed to load upscaler models:", err);
       }
     };
     loadModels();
@@ -90,7 +120,7 @@ function UpscalerForm() {
   // Update form data when upscaler model changes
   useEffect(() => {
     if (selectedUpscalerModel) {
-      setFormData(prev => ({
+      setFormData((prev) => ({
         ...prev,
         model: selectedUpscalerModel,
       }));
@@ -98,12 +128,12 @@ function UpscalerForm() {
   }, [selectedUpscalerModel, setFormData]);
 
   const handleInputChange = (
-    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
+    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
   ) => {
     const { name, value } = e.target;
     setFormData((prev) => ({
       ...prev,
-      [name]: name === 'upscale_factor' ? Number(value) : value,
+      [name]: name === "upscale_factor" ? Number(value) : value,
     }));
   };
 
@@ -117,7 +147,7 @@ function UpscalerForm() {
       setPreviewImage(base64);
       setError(null);
     } catch (err) {
-      setError('Failed to load image');
+      setError("Failed to load image");
     }
   };
 
@@ -125,32 +155,36 @@ function UpscalerForm() {
     const maxAttempts = 300;
     let attempts = 0;
     let isPolling = true;
+    let timeoutId: NodeJS.Timeout | null = null;
 
     const poll = async () => {
       if (!isPolling) return;
-      
+
       try {
-        const status = await apiClient.getJobStatus(jobId);
-        setJobInfo(status);
+        const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
+        setJobInfo(status.job);
 
-        if (status.status === 'completed') {
+        if (status.job.status === "completed") {
           let imageUrls: string[] = [];
 
           // Handle both old format (result.images) and new format (outputs)
-          if (status.outputs && status.outputs.length > 0) {
+          if (status.job.outputs && status.job.outputs.length > 0) {
             // New format: convert output URLs to authenticated image URLs with cache-busting
-            imageUrls = status.outputs.map((output: any) => {
+            imageUrls = status.job.outputs.map((output: any) => {
               const filename = output.filename;
               return apiClient.getImageUrl(jobId, filename);
             });
-          } else if (status.result?.images && status.result.images.length > 0) {
+          } else if (
+            status.job.result?.images &&
+            status.job.result.images.length > 0
+          ) {
             // Old format: convert image URLs to authenticated URLs
-            imageUrls = status.result.images.map((imageUrl: string) => {
+            imageUrls = status.job.result.images.map((imageUrl: string) => {
               // Extract filename from URL if it's already a full URL
-              if (imageUrl.includes('/output/')) {
-                const parts = imageUrl.split('/output/');
+              if (imageUrl.includes("/output/")) {
+                const parts = imageUrl.split("/output/");
                 if (parts.length === 2) {
-                  const filename = parts[1].split('?')[0]; // Remove query params
+                  const filename = parts[1].split("?")[0]; // Remove query params
                   return apiClient.getImageUrl(jobId, filename);
                 }
               }
@@ -163,25 +197,27 @@ function UpscalerForm() {
           setGeneratedImages([...imageUrls]);
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'failed') {
-          setError(status.error || 'Upscaling failed');
+        } else if (status.job.status === "failed") {
+          setError(status.job.error || "Upscaling failed");
           setLoading(false);
           isPolling = false;
-        } else if (status.status === 'cancelled') {
-          setError('Upscaling was cancelled');
+        } else if (status.job.status === "cancelled") {
+          setError("Upscaling was cancelled");
           setLoading(false);
           isPolling = false;
         } else if (attempts < maxAttempts) {
           attempts++;
-          setTimeout(poll, 2000);
+          timeoutId = setTimeout(poll, 2000);
         } else {
-          setError('Job polling timeout');
+          setError("Job polling timeout");
           setLoading(false);
           isPolling = false;
         }
       } catch (err) {
         if (isPolling) {
-          setError(err instanceof Error ? err.message : 'Failed to check job status');
+          setError(
+            err instanceof Error ? err.message : "Failed to check job status",
+          );
           setLoading(false);
           isPolling = false;
         }
@@ -193,6 +229,9 @@ function UpscalerForm() {
     // Return cleanup function
     return () => {
       isPolling = false;
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
     };
   };
 
@@ -200,7 +239,7 @@ function UpscalerForm() {
     e.preventDefault();
 
     if (!uploadedImage) {
-      setError('Please upload an image first');
+      setError("Please upload an image first");
       return;
     }
 
@@ -212,7 +251,7 @@ function UpscalerForm() {
     try {
       // Validate model selection
       if (!selectedUpscalerModel) {
-        setError('Please select an upscaler model');
+        setError("Please select an upscaler model");
         setLoading(false);
         return;
       }
@@ -230,11 +269,11 @@ function UpscalerForm() {
         const cleanup = pollJobStatus(jobId);
         setPollCleanup(() => cleanup);
       } else {
-        setError('No job ID returned from server');
+        setError("No job ID returned from server");
         setLoading(false);
       }
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to upscale image');
+      setError(err instanceof Error ? err.message : "Failed to upscale image");
       setLoading(false);
     }
   };
@@ -245,21 +284,24 @@ function UpscalerForm() {
       try {
         await apiClient.cancelJob(jobId);
         setLoading(false);
-        setError('Upscaling cancelled');
+        setError("Upscaling cancelled");
         // Cleanup polling
         if (pollCleanup) {
           pollCleanup();
           setPollCleanup(null);
         }
       } catch (err) {
-        console.error('Failed to cancel job:', err);
+        console.error("Failed to cancel job:", err);
       }
     }
   };
 
   return (
     <AppLayout>
-      <Header title="Upscaler" description="Enhance and upscale your images with AI" />
+      <Header
+        title="Upscaler"
+        description="Enhance and upscale your images with AI"
+      />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
           {/* Left Panel - Form */}
@@ -271,9 +313,9 @@ function UpscalerForm() {
                   <div className="space-y-4">
                     {previewImage && (
                       <div className="relative">
-                        <img 
-                          src={previewImage} 
-                          alt="Preview" 
+                        <img
+                          src={previewImage}
+                          alt="Preview"
                           className="h-64 w-full rounded-lg object-cover"
                         />
                         <Button
@@ -283,14 +325,14 @@ function UpscalerForm() {
                           className="absolute top-2 right-2 h-8 w-8"
                           onClick={() => {
                             setPreviewImage(null);
-                            setUploadedImage('');
+                            setUploadedImage("");
                           }}
                         >
                           <X className="h-4 w-4" />
                         </Button>
                       </div>
                     )}
-                    
+
                     <div className="space-y-2">
                       <div className="flex items-center justify-center">
                         <input
@@ -317,7 +359,12 @@ function UpscalerForm() {
                   <Label>Upscaling Factor</Label>
                   <select
                     value={formData.upscale_factor}
-                    onChange={(e) => setFormData(prev => ({ ...prev, upscale_factor: Number(e.target.value) }))}
+                    onChange={(e) =>
+                      setFormData((prev) => ({
+                        ...prev,
+                        upscale_factor: Number(e.target.value),
+                      }))
+                    }
                     className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                   >
                     <option value={2}>2x (Double)</option>
@@ -328,33 +375,48 @@ function UpscalerForm() {
 
                 <div className="space-y-2">
                   <Label>Upscaler Model</Label>
-                  <EnhancedModelSelect
-                    modelType="upscaler"
-                    label="Upscaling Model"
+                  <Select
                     value={formData.model}
-                    onValueChange={(value) => setFormData(prev => ({ ...prev, model: value }))}
-                    onSetUserOverride={(value) => {/* User override logic */}}
-                    onClearOverride={clearUpscalerUserOverride}
-                    availableModels={upscalerModels}
-                    isAutoSelected={isUpscalerAutoSelected}
-                    isUserOverride={isUpscalerUserOverride}
-                    placeholder="Select an upscaler model"
-                  />
+                    onValueChange={(value) => {
+                      setFormData((prev) => ({ ...prev, model: value }));
+                      setSelectedUpscalerModel(value);
+                      setUpscalerUserOverride(value);
+                    }}
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select an upscaler model" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {upscalerModels.map((model) => (
+                        <SelectItem key={model.name} value={model.name}>
+                          {model.name}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
                 </div>
 
                 <div className="flex gap-2">
-                  <Button type="submit" disabled={loading || !uploadedImage} className="flex-1">
+                  <Button
+                    type="submit"
+                    disabled={loading || !uploadedImage}
+                    className="flex-1"
+                  >
                     {loading ? (
                       <>
                         <Loader2 className="h-4 w-4 animate-spin" />
                         Upscaling...
                       </>
                     ) : (
-                      'Upscale'
+                      "Upscale"
                     )}
                   </Button>
                   {loading && (
-                    <Button type="button" variant="destructive" onClick={handleCancel}>
+                    <Button
+                      type="button"
+                      variant="destructive"
+                      onClick={handleCancel}
+                    >
                       <X className="h-4 w-4" />
                       Cancel
                     </Button>
@@ -376,7 +438,9 @@ function UpscalerForm() {
                 {generatedImages.length === 0 ? (
                   <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
                     <p className="text-muted-foreground">
-                      {loading ? 'Upscaling...' : 'Upscaled image will appear here'}
+                      {loading
+                        ? "Upscaling..."
+                        : "Upscaled image will appear here"}
                     </p>
                   </div>
                 ) : (
@@ -393,14 +457,22 @@ function UpscalerForm() {
                           variant="secondary"
                           className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
                           onClick={() => {
-                            const authToken = localStorage.getItem('auth_token');
-                            const unixUser = localStorage.getItem('unix_user');
-                            downloadAuthenticatedImage(image, `upscaled-${Date.now()}-${formData.upscale_factor}x.png`, authToken || undefined, unixUser || undefined)
-                              .catch(err => {
-                                console.error('Failed to download image:', err);
-                                // Fallback to regular download if authenticated download fails
-                                downloadImage(image, `upscaled-${Date.now()}-${formData.upscale_factor}x.png`);
-                              });
+                            const authToken =
+                              localStorage.getItem("auth_token");
+                            const unixUser = localStorage.getItem("unix_user");
+                            downloadAuthenticatedImage(
+                              image,
+                              `upscaled-${Date.now()}-${formData.upscale_factor}x.png`,
+                              authToken || undefined,
+                              unixUser || undefined,
+                            ).catch((err) => {
+                              console.error("Failed to download image:", err);
+                              // Fallback to regular download if authenticated download fails
+                              downloadImage(
+                                image,
+                                `upscaled-${Date.now()}-${formData.upscale_factor}x.png`,
+                              );
+                            });
                           }}
                         >
                           <Download className="h-4 w-4" />

+ 0 - 3
webui/components/features/index.ts

@@ -5,8 +5,5 @@
 // Image Generation Components
 export * from './image-generation';
 
-// Model Components  
-export * from './models';
-
 // Queue Components
 export * from './queue';

+ 52 - 44
webui/components/layout/app-layout.tsx

@@ -1,10 +1,10 @@
-'use client';
+"use client";
 
-import { ReactNode } from 'react';
-import { Sidebar } from './sidebar';
-import { ModelStatusBar } from '../features/models';
-import { VersionChecker } from './version-checker';
-import { LayoutProvider } from './layout-context';
+import { ReactNode } from "react";
+import { Sidebar } from "./sidebar";
+import { ModelStatusBar } from "../features/models";
+import { VersionChecker } from "./version-checker";
+import { LayoutProvider } from "./layout-context";
 
 /**
  * AppLayout - Pure inline style layout (no CSS modules)
@@ -18,50 +18,58 @@ interface AppLayoutProps {
 export function AppLayout({ children }: AppLayoutProps) {
   return (
     <LayoutProvider>
-      <div style={{
-        display: 'grid',
-        gridTemplateColumns: '16rem 1fr',
-        gridTemplateRows: '1fr auto',
-        gridTemplateAreas: '"sidebar main" "sidebar footer"',
-        minHeight: '100vh',
-        width: '100vw',
-        overflowX: 'hidden',
-      }}>
-        <aside style={{
-          gridArea: 'sidebar',
-          position: 'sticky',
-          top: 0,
-          height: '100vh',
-          overflowY: 'auto',
-          borderRight: '1px solid var(--color-border)',
-          backgroundColor: 'var(--color-card)',
-          zIndex: 10000,
-        }}>
+      <div
+        style={{
+          display: "grid",
+          gridTemplateColumns: "16rem 1fr",
+          gridTemplateRows: "1fr 3rem",
+          gridTemplateAreas: '"sidebar main" "sidebar footer"',
+          height: "100vh",
+          width: "100vw",
+          overflow: "hidden",
+        }}
+      >
+        <aside
+          style={{
+            gridArea: "sidebar",
+            height: "100vh",
+            overflowY: "auto",
+            borderRight: "1px solid var(--color-border)",
+            backgroundColor: "var(--color-card)",
+            zIndex: 10,
+          }}
+        >
           <Sidebar />
         </aside>
-        <main style={{
-          gridArea: 'main',
-          minHeight: '100vh',
-          maxWidth: 'calc(100vw - 16rem)',
-          overflowX: 'hidden',
-          backgroundColor: 'var(--color-background)',
-        }}>
+        <main
+          style={{
+            gridArea: "main",
+            overflowY: "auto",
+            overflowX: "hidden",
+            backgroundColor: "var(--color-background)",
+            maxWidth: "100%",
+            boxSizing: "border-box",
+          }}
+        >
           {children}
         </main>
-        <footer style={{
-          gridArea: 'footer',
-          minHeight: '3rem',
-          display: 'flex',
-          justifyContent: 'space-between',
-          alignItems: 'center',
-          padding: '0 1rem',
-          borderTop: '1px solid var(--color-border)',
-          backgroundColor: 'var(--color-card)',
-        }}>
-          <div style={{ flex: 1 }}>
+        <footer
+          style={{
+            gridArea: "footer",
+            height: "3rem",
+            display: "flex",
+            justifyContent: "space-between",
+            alignItems: "center",
+            padding: "0 1rem",
+            borderTop: "1px solid var(--color-border)",
+            backgroundColor: "var(--color-card)",
+            overflow: "hidden",
+          }}
+        >
+          <div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
             <ModelStatusBar />
           </div>
-          <div style={{ flex: 'none', marginLeft: '1rem' }}>
+          <div style={{ flex: "none", marginLeft: "1rem" }}>
             <VersionChecker />
           </div>
         </footer>

+ 2 - 1
webui/components/layout/sidebar.tsx

@@ -2,7 +2,7 @@
 
 import Link from 'next/link';
 import { usePathname } from 'next/navigation';
-import { Image, ImagePlus, Sparkles, Settings, Activity, Edit3 } from 'lucide-react';
+import { Image, ImagePlus, Sparkles, Settings, Activity, Edit3, Grid3X3 } from 'lucide-react';
 import { cn } from '@/lib/utils';
 
 /**
@@ -26,6 +26,7 @@ const navigation: NavigationItem[] = [
   { name: 'Image to Image', href: '/img2img', icon: Image },
   { name: 'Inpainting', href: '/inpainting', icon: Edit3 },
   { name: 'Upscaler', href: '/upscaler', icon: Sparkles },
+  { name: 'Gallery', href: '/gallery', icon: Grid3X3 },
   { name: 'Models', href: '/models', icon: Settings },
   { name: 'Queue', href: '/queue', icon: Activity },
 ];

+ 15 - 0
webui/components/ui/error-boundary.tsx

@@ -24,6 +24,21 @@ export class ErrorBoundary extends Component<Props, State> {
 
   componentDidCatch(error: Error, errorInfo: ErrorInfo) {
     console.error('Error caught by boundary:', error, errorInfo);
+    
+    // Additional debugging for function reference errors
+    if (error.message.includes('is not a function')) {
+      console.warn('Function reference error detected - possible race condition or minification issue');
+      console.warn('Error stack:', error.stack);
+      console.warn('Component stack:', errorInfo.componentStack);
+      
+      // Try to identify if this is related to polling/cleanup
+      if (errorInfo.componentStack && (
+          errorInfo.componentStack.includes('poll') || 
+          errorInfo.componentStack.includes('setTimeout') ||
+          errorInfo.componentStack.includes('useEffect'))) {
+        console.warn('This error may be related to async polling or cleanup functions');
+      }
+    }
   }
 
   render() {

+ 2 - 2
webui/components/ui/image-input.tsx

@@ -173,8 +173,8 @@ export function ImageInput({
     try {
       const result = await validateImageUrlWithBase64(url);
       
-      // Use base64 data for preview if available, otherwise use original URL
-      const previewUrl = result.isValid ? (result.base64Data || url) : null;
+      // Use temporary URL for preview if available, otherwise fall back to base64 data or original URL
+      const previewUrl = result.isValid ? (result.tempUrl || result.base64Data || url) : null;
       
       setState(prev => ({
         ...prev,

Різницю між файлами не показано, бо вона завелика
+ 297 - 178
webui/lib/api.ts


+ 5 - 1
webui/lib/image-validation.ts

@@ -9,6 +9,8 @@ export interface ImageValidationResult {
   filename?: string;
   isCorsBlocked?: boolean;
   base64Data?: string;
+  tempUrl?: string;
+  tempFilename?: string;
 }
 
 export interface ImageInputMode {
@@ -148,7 +150,9 @@ export async function validateImageUrlWithBase64(url: string): Promise<ImageVali
       isValid: true,
       detectedType: result.mimeType,
       filename: result.filename,
-      base64Data: result.base64Data
+      base64Data: result.base64Data,
+      tempUrl: result.tempUrl,
+      tempFilename: result.tempFilename
     };
 
   } catch (error) {

+ 0 - 13
webui/package-lock.json

@@ -73,7 +73,6 @@
       "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.27.1",
         "@babel/generator": "^7.28.5",
@@ -2149,7 +2148,6 @@
       "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
       "devOptional": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "csstype": "^3.0.2"
       }
@@ -2160,7 +2158,6 @@
       "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
       "devOptional": true,
       "license": "MIT",
-      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -2211,7 +2208,6 @@
       "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.46.2",
         "@typescript-eslint/types": "8.46.2",
@@ -2742,7 +2738,6 @@
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -3096,7 +3091,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.8.19",
         "caniuse-lite": "^1.0.30001751",
@@ -3691,7 +3685,6 @@
       "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.1",
@@ -3877,7 +3870,6 @@
       "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@rtsao/scc": "^1.1.0",
         "array-includes": "^3.1.9",
@@ -6084,7 +6076,6 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
       "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -6094,7 +6085,6 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
       "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -6850,7 +6840,6 @@
       "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -7013,7 +7002,6 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "dev": true,
       "license": "Apache-2.0",
-      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -7332,7 +7320,6 @@
       "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }

+ 3 - 1
webui/package.json

@@ -5,8 +5,10 @@
   "scripts": {
     "dev": "next dev",
     "build": "next build",
+    "build-static": "next build && npm run copy-to-build",
+    "copy-to-build": "mv -fv out/* ../build/webui/",
     "start": "next start",
-    "lint": "eslint"
+    "lint": "next lint"
   },
   "dependencies": {
     "@radix-ui/react-select": "^2.2.6",

Деякі файли не було показано, через те що забагато файлів було змінено