Browse Source

Fix const-correctness in logHttpAccess function

- Modified logHttpAccess function signature to make httplib::Response parameter const
- Updated function declaration in include/server.h (line 103)
- Updated function definition in src/server.cpp (line 5157)
- Fixes GCC compilation error: 'binding reference of type 'httplib::Response&' to 'const httplib::Response' discards qualifiers'
Fszontagh 3 tháng trước cách đây
mục cha
commit
6ffd564e76

+ 3 - 1
AGENTS.md

@@ -22,7 +22,7 @@ cmake --build . -j
 cd build
 cmake --build . -j
 ```
-Always use the build directory for compiling the project.
+Always use the build directory for compiling the project. Use ninja instead make
 
 ### Web UI Only
 ```bash
@@ -105,6 +105,8 @@ Multiple authentication methods supported:
 
 ### Server Testing
 - Start server in background during testing: `cd build; ./src/stable-diffusion-rest-server --models-dir /data/SD_MODELS --port 8082 --host 0.0.0.0 --ui-dir ./webui --verbose`
+- **IMPORTANT**: Never kill running server processes - the user manages server lifecycle
+- Agents should not start/stop servers unless explicitly requested
 
 ## Important Gotchas
 

+ 0 - 1
auth/api_keys.json

@@ -1 +0,0 @@
-{}

+ 0 - 1
auth/users.json

@@ -1 +0,0 @@
-{}

+ 92 - 0
docs/REST_API_BEST_PRACTICES.md

@@ -0,0 +1,92 @@
+# Best Practices for Developing REST APIs
+
+This documentation outlines modern, forward-thinking rules and best practices for designing and implementing RESTful APIs. These guidelines are programming language-independent and focus on creating scalable, maintainable, and user-friendly APIs that adhere to REST principles while incorporating emerging trends like hypermedia-driven designs, security-first approaches, and API evolution strategies.
+
+## 1. Core REST Principles
+REST (Representational State Transfer) APIs should treat resources as the central abstraction. Follow these foundational rules:
+
+- **Model Resources Properly**: Represent domain entities as resources (e.g., `/users`, `/orders`). Use nouns for endpoints, avoiding verbs (e.g., prefer `/users` over `/getUsers`).
+- **Use HTTP Methods Semantically**:
+  - `GET`: Retrieve resources (idempotent, safe).
+  - `POST`: Create new resources.
+  - `PUT`: Update or replace existing resources (idempotent).
+  - `PATCH`: Partially update resources.
+  - `DELETE`: Remove resources (idempotent).
+  - Avoid overloading methods; e.g., don't use `GET` for mutations.
+- **Statelessness**: Each request must contain all necessary information. No server-side session state.
+- **Cacheability**: Use headers like `Cache-Control`, `ETag`, and `Last-Modified` to enable client-side caching for `GET` responses.
+- **Layered System**: Design for intermediaries (e.g., proxies, gateways) without affecting the API contract.
+
+## 2. Endpoint Design
+Design URLs that are intuitive, consistent, and future-proof.
+
+- **Hierarchical Structure**: Use nested resources for relationships (e.g., `/users/{userId}/orders` for a user's orders).
+- **Plural Nouns**: Prefer plural forms for collections (e.g., `/products` instead of `/product`).
+- **Query Parameters for Filtering/Sorting**: Use query strings for non-hierarchical operations (e.g., `/products?category=electronics&sort=price:asc`).
+- **Avoid Deep Nesting**: Limit nesting to 2-3 levels to prevent complexity; use query params for deeper relations.
+- **Idempotency Keys**: For `POST` and `PUT`, support idempotency via headers (e.g., `Idempotency-Key`) to handle retries safely.
+
+## 3. Versioning
+APIs evolve; plan for changes without breaking clients.
+
+- **URI Versioning**: Include version in the path (e.g., `/v1/users`). Preferred for simplicity.
+- **Header Versioning**: Use custom headers (e.g., `Accept: application/vnd.myapi.v1+json`) for finer control.
+- **Deprecation Strategy**: Announce deprecations via headers (e.g., `Deprecation: sunset=2026-01-01`) and provide migration guides.
+- **Forward Compatibility**: Design responses to allow new fields without breaking old clients (e.g., ignore unknown fields).
+
+## 4. Request and Response Formats
+Standardize data exchange for interoperability.
+
+- **JSON as Default**: Use JSON for payloads; support XML or other formats via `Accept`/`Content-Type` headers if needed.
+- **Consistent Schemas**: Define clear schemas for requests/responses. Use tools like JSON Schema for validation.
+- **Pagination for Collections**: For large lists, use offset/limit or cursor-based pagination (e.g., `{ "data": [...], "links": { "next": "/products?cursor=abc" } }`).
+- **Sorting and Filtering**: Support query params for dynamic querying (e.g., `?filter=price>100&sort=name`).
+- **HATEOAS (Hypermedia Controls)**: Include links in responses for discoverability (e.g., `{ "id": 1, "_links": { "self": { "href": "/users/1" }, "orders": { "href": "/users/1/orders" } } }`). This makes APIs self-describing and evolvable.
+
+## 5. Error Handling
+Provide meaningful, consistent error responses to aid debugging.
+
+- **HTTP Status Codes**: Use appropriate codes (e.g., 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 404 Not Found, 429 Too Many Requests, 500 Internal Server Error).
+- **Error Payloads**: Return JSON with details (e.g., `{ "error": "Invalid input", "code": "INVALID_EMAIL", "details": { "field": "email" } }`).
+- **Problem Details Standard**: Adopt RFC 7807 for structured errors (e.g., `Content-Type: application/problem+json`).
+- **Validation Errors**: Group field-specific errors in arrays for batch operations.
+
+## 6. Security
+Prioritize security from the outset, following zero-trust principles.
+
+- **HTTPS Only**: Enforce TLS/HTTPS for all endpoints.
+- **Authentication**: Use standards like OAuth 2.0, JWT, or API keys. Prefer token-based over basic auth.
+- **Authorization**: Implement fine-grained access control (e.g., RBAC or ABAC) at the resource level.
+- **Input Validation**: Sanitize all inputs to prevent injection attacks; use schema validation.
+- **CORS Management**: Configure Cross-Origin Resource Sharing headers appropriately.
+- **Rate Limiting and Throttling**: Implement via headers (e.g., `X-Rate-Limit-Remaining`) to prevent abuse.
+- **Audit Logging**: Log requests/responses without sensitive data for compliance.
+
+## 7. Performance and Scalability
+Design for high load and future growth.
+
+- **Asynchronous Operations**: For long-running tasks, return 202 Accepted and provide polling or webhook callbacks.
+- **Compression**: Use `Accept-Encoding: gzip` for responses.
+- **ETags and Conditional Requests**: Support `If-None-Match` for efficient updates.
+- **GraphQL Consideration**: If REST limitations arise (e.g., over-fetching), evaluate hybrid approaches, but stick to REST for resource-oriented designs.
+- **Microservices Integration**: Use service discovery and API gateways for composed APIs.
+
+## 8. Documentation and Testing
+Make APIs easy to use and reliable.
+
+- **OpenAPI/Swagger**: Generate interactive docs using OpenAPI 3.x specifications.
+- **Examples**: Include request/response examples in docs.
+- **Automated Testing**: Write unit/integration tests for endpoints, covering happy paths, errors, and edge cases.
+- **Contract Testing**: Use tools like Pact for consumer-driven contracts in distributed systems.
+- **Monitoring**: Integrate metrics (e.g., response times, error rates) with tools like Prometheus.
+
+## 9. Forward-Thinking Considerations
+Embrace emerging trends to future-proof your API:
+
+- **Event-Driven Extensions**: Integrate with webhooks or Server-Sent Events for real-time updates.
+- **API-First Design**: Design APIs before implementation, involving stakeholders early.
+- **Sustainability**: Optimize for energy efficiency (e.g., minimize data transfer).
+- **AI Integration**: Prepare for AI-driven clients by providing machine-readable metadata (e.g., via JSON-LD).
+- **Privacy by Design**: Comply with GDPR/CCPA; support data minimization and consent headers.
+
+By following these practices, your REST API will be robust, evolvable, and aligned with modern web standards. Regularly review and iterate based on usage feedback and industry advancements.

+ 10 - 0
include/model_manager.h

@@ -8,6 +8,7 @@
 #include <vector>
 #include <filesystem>
 #include <cstdint>
+#include <functional>
 
 // Forward declarations
 class StableDiffusionWrapper;
@@ -154,6 +155,15 @@ public:
      */
     bool loadModel(const std::string& name);
 
+    /**
+     * @brief Load a model by name with progress callback
+     *
+     * @param name The name of the model to load
+     * @param progressCallback Callback function for progress updates (0.0-1.0)
+     * @return true if the model was loaded successfully, false otherwise
+     */
+    bool loadModel(const std::string& name, std::function<void(float)> progressCallback);
+
     /**
      * @brief Unload a model
      *

+ 15 - 3
include/server.h

@@ -100,7 +100,7 @@ private:
     /**
      * @brief Log HTTP access request
      */
-    void logHttpAccess(const httplib::Request& req, httplib::Response& res, const std::string& endpoint = "");
+    void logHttpAccess(const httplib::Request& req, const httplib::Response& res, const std::string& endpoint = "");
 
     /**
      * @brief Health check endpoint handler
@@ -489,9 +489,21 @@ private:
     int m_port;                                        ///< Port number
     std::string m_outputDir;                           ///< Output directory for generated files
     std::string m_uiDir;                               ///< Directory containing static web UI files
-    std::string m_currentlyLoadedModel;                ///< Currently loaded model name
-    mutable std::mutex m_currentModelMutex;            ///< Mutex for thread-safe access to current model
+    struct LoadedModels {
+        std::string checkpoint;    ///< Currently loaded checkpoint model (for text2img, img2img, etc.)
+        std::string esrgan;        ///< Currently loaded ESRGAN/upscaler model
+    };
+
+    LoadedModels m_loadedModels;                        ///< Currently loaded models by type
+    mutable std::mutex m_loadedModelsMutex;            ///< Mutex for thread-safe access to loaded models
     std::shared_ptr<UserManager> m_userManager;        ///< User manager instance
+
+    /**
+     * @brief Get reference to the appropriate model field based on model type
+     * @param type The model type
+     * @return Reference to the model field
+     */
+    std::string& getModelField(ModelType type);
     std::shared_ptr<AuthMiddleware> m_authMiddleware;  ///< Authentication middleware instance
     ServerConfig m_config;                             ///< Server configuration
 

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 212 - 272
src/server.cpp


+ 73 - 53
src/stable_diffusion_wrapper.cpp

@@ -3,8 +3,8 @@
 #include <chrono>
 #include <cstring>
 #include <filesystem>
-#include <iostream>
 #include <thread>
+#include "logger.h"
 #include "model_detector.h"
 
 extern "C" {
@@ -17,6 +17,8 @@ public:
     std::string lastError;
     std::mutex contextMutex;
     bool verbose = false;
+    std::string currentModelPath;
+    StableDiffusionWrapper::GenerationParams currentModelParams;
 
     Impl() {
         // Initialize any required resources
@@ -46,7 +48,7 @@ public:
         // Get absolute path for logging
         std::filesystem::path absModelPath = std::filesystem::absolute(modelPath);
         if (params.verbose) {
-            std::cout << "Loading model from absolute path: " << absModelPath << std::endl;
+            LOG_DEBUG("Loading model from absolute path: " + std::filesystem::absolute(modelPath).string());
         }
 
         // Create persistent string copies to fix lifetime issues
@@ -83,7 +85,7 @@ public:
         if (modelFileName.find("qwen") != std::string::npos) {
             isQwenModel = true;
             if (params.verbose) {
-                std::cout << "Detected Qwen model from filename: " << modelFileName << std::endl;
+                LOG_DEBUG("Detected Qwen model from filename: " + modelFileName);
             }
         }
 
@@ -91,12 +93,12 @@ public:
         if (parentDirName == "diffusion_models" || parentDirName == "diffusion") {
             useDiffusionModelPath = true;
             if (params.verbose) {
-                std::cout << "Model is in " << parentDirName << " directory, using diffusion_model_path" << std::endl;
+                LOG_DEBUG("Model is in " + parentDirName + " directory, using diffusion_model_path");
             }
         } else if (parentDirName == "checkpoints" || parentDirName == "stable-diffusion") {
             useDiffusionModelPath = false;
             if (params.verbose) {
-                std::cout << "Model is in " << parentDirName << " directory, using model_path" << std::endl;
+                LOG_DEBUG("Model is in " + parentDirName + " directory, using model_path");
             }
         } else if (parentDirName == "sd_models" || parentDirName.empty()) {
             // Handle models in root /data/SD_MODELS/ directory
@@ -105,12 +107,12 @@ public:
                 useDiffusionModelPath = true;
                 detectionSource       = "qwen_root_detection";
                 if (params.verbose) {
-                    std::cout << "Qwen model in root directory, preferring diffusion_model_path" << std::endl;
+                    LOG_DEBUG("Qwen model in root directory, preferring diffusion_model_path");
                 }
             } else {
                 // For non-Qwen models in root, try architecture detection
                 if (params.verbose) {
-                    std::cout << "Model is in root directory '" << parentDirName << "', attempting architecture detection" << std::endl;
+                    LOG_DEBUG("Model is in root directory '" + parentDirName + "', attempting architecture detection");
                 }
                 detectionSource = "architecture_fallback";
 
@@ -118,10 +120,10 @@ public:
                     detectionResult     = ModelDetector::detectModel(modelPath);
                     detectionSuccessful = true;
                     if (params.verbose) {
-                        std::cout << "Architecture detection found: " << detectionResult.architectureName << std::endl;
+                        LOG_DEBUG("Architecture detection found: " + detectionResult.architectureName);
                     }
                 } catch (const std::exception& e) {
-                    std::cerr << "Warning: Architecture detection failed: " << e.what() << ". Using default loading method." << std::endl;
+                    LOG_ERROR("Warning: Architecture detection failed: " + std::string(e.what()) + ". Using default loading method.");
                     detectionResult.architecture     = ModelArchitecture::UNKNOWN;
                     detectionResult.architectureName = "Unknown";
                 }
@@ -148,7 +150,7 @@ public:
                             // Unknown architectures fall back to model_path for backward compatibility
                             useDiffusionModelPath = false;
                             if (params.verbose) {
-                                std::cout << "Warning: Unknown model architecture detected, using default model_path for backward compatibility" << std::endl;
+                                LOG_WARNING("Warning: Unknown model architecture detected, using default model_path for backward compatibility");
                             }
                             break;
                     }
@@ -160,7 +162,7 @@ public:
         } else {
             // Unknown directory - try architecture detection
             if (params.verbose) {
-                std::cout << "Model is in unknown directory '" << parentDirName << "', attempting architecture detection as fallback" << std::endl;
+                LOG_DEBUG("Model is in unknown directory '" + parentDirName + "', attempting architecture detection as fallback");
             }
             detectionSource = "architecture_fallback";
 
@@ -168,10 +170,10 @@ public:
                 detectionResult     = ModelDetector::detectModel(modelPath);
                 detectionSuccessful = true;
                 if (params.verbose) {
-                    std::cout << "Fallback detection found architecture: " << detectionResult.architectureName << std::endl;
+                    LOG_DEBUG("Fallback detection found architecture: " + detectionResult.architectureName);
                 }
             } catch (const std::exception& e) {
-                std::cerr << "Warning: Fallback model detection failed: " << e.what() << ". Using default loading method." << std::endl;
+                LOG_ERROR("Warning: Fallback model detection failed: " + std::string(e.what()) + ". Using default loading method.");
                 detectionResult.architecture     = ModelArchitecture::UNKNOWN;
                 detectionResult.architectureName = "Unknown";
             }
@@ -198,7 +200,7 @@ public:
                         // Unknown architectures fall back to model_path for backward compatibility
                         useDiffusionModelPath = false;
                         if (params.verbose) {
-                            std::cout << "Warning: Unknown model architecture detected, using default model_path for backward compatibility" << std::endl;
+                            LOG_WARNING("Warning: Unknown model architecture detected, using default model_path for backward compatibility");
                         }
                         break;
                 }
@@ -213,13 +215,13 @@ public:
             ctxParams.diffusion_model_path = persistentModelPath.c_str();
             ctxParams.model_path           = nullptr;  // Clear the traditional path
             if (params.verbose) {
-                std::cout << "Using diffusion_model_path (source: " << detectionSource << ")" << std::endl;
+                LOG_DEBUG("Using diffusion_model_path (source: " + detectionSource + ")");
             }
         } else {
             ctxParams.model_path           = persistentModelPath.c_str();
             ctxParams.diffusion_model_path = nullptr;  // Clear the modern path
             if (params.verbose) {
-                std::cout << "Using model_path (source: " << detectionSource << ")" << std::endl;
+                LOG_DEBUG("Using model_path (source: " + detectionSource + ")");
             }
         }
 
@@ -227,13 +229,13 @@ public:
         if (!persistentClipLPath.empty()) {
             ctxParams.clip_l_path = persistentClipLPath.c_str();
             if (params.verbose) {
-                std::cout << "Using CLIP-L path: " << std::filesystem::absolute(persistentClipLPath) << std::endl;
+                LOG_DEBUG("Using CLIP-L path: " + std::filesystem::absolute(persistentClipLPath).string());
             }
         }
         if (!persistentClipGPath.empty()) {
             ctxParams.clip_g_path = persistentClipGPath.c_str();
             if (params.verbose) {
-                std::cout << "Using CLIP-G path: " << std::filesystem::absolute(persistentClipGPath) << std::endl;
+                LOG_DEBUG("Using CLIP-G path: " + std::filesystem::absolute(persistentClipGPath).string());
             }
         }
         if (!persistentVaePath.empty()) {
@@ -241,12 +243,11 @@ public:
             if (std::filesystem::exists(persistentVaePath)) {
                 ctxParams.vae_path = persistentVaePath.c_str();
                 if (params.verbose) {
-                    std::cout << "Using VAE path: " << std::filesystem::absolute(persistentVaePath) << std::endl;
+                    LOG_DEBUG("Using VAE path: " + std::filesystem::absolute(persistentVaePath).string());
                 }
             } else {
                 if (params.verbose) {
-                    std::cout << "VAE file not found: " << std::filesystem::absolute(persistentVaePath)
-                              << " - continuing without VAE" << std::endl;
+                    LOG_DEBUG("VAE file not found: " + std::filesystem::absolute(persistentVaePath).string() + " - continuing without VAE");
                 }
                 ctxParams.vae_path = nullptr;
             }
@@ -254,25 +255,25 @@ public:
         if (!persistentTaesdPath.empty()) {
             ctxParams.taesd_path = persistentTaesdPath.c_str();
             if (params.verbose) {
-                std::cout << "Using TAESD path: " << std::filesystem::absolute(persistentTaesdPath) << std::endl;
+                LOG_DEBUG("Using TAESD path: " + std::filesystem::absolute(persistentTaesdPath).string());
             }
         }
         if (!persistentControlNetPath.empty()) {
             ctxParams.control_net_path = persistentControlNetPath.c_str();
             if (params.verbose) {
-                std::cout << "Using ControlNet path: " << std::filesystem::absolute(persistentControlNetPath) << std::endl;
+                LOG_DEBUG("Using ControlNet path: " + std::filesystem::absolute(persistentControlNetPath).string());
             }
         }
         if (!persistentLoraModelDir.empty()) {
             ctxParams.lora_model_dir = persistentLoraModelDir.c_str();
             if (params.verbose) {
-                std::cout << "Using LoRA model directory: " << std::filesystem::absolute(persistentLoraModelDir) << std::endl;
+                LOG_DEBUG("Using LoRA model directory: " + std::filesystem::absolute(persistentLoraModelDir).string());
             }
         }
         if (!persistentEmbeddingDir.empty()) {
             ctxParams.embedding_dir = persistentEmbeddingDir.c_str();
             if (params.verbose) {
-                std::cout << "Using embedding directory: " << std::filesystem::absolute(persistentEmbeddingDir) << std::endl;
+                LOG_DEBUG("Using embedding directory: " + std::filesystem::absolute(persistentEmbeddingDir).string());
             }
         }
 
@@ -290,17 +291,17 @@ public:
 
         // Create the stable-diffusion context
         if (params.verbose) {
-            std::cout << "Attempting to create stable-diffusion context with selected parameters..." << std::endl;
+            LOG_DEBUG("Attempting to create stable-diffusion context with selected parameters...");
         }
         sdContext = new_sd_ctx(&ctxParams);
         if (!sdContext) {
             lastError = "Failed to create stable-diffusion context";
-            std::cerr << "Error: " << lastError << " with initial attempt" << std::endl;
+            LOG_ERROR("Error: " + lastError + " with initial attempt");
 
             // If we used diffusion_model_path and it failed, try fallback to model_path
             if (useDiffusionModelPath) {
                 if (params.verbose) {
-                    std::cout << "Warning: Failed to load with diffusion_model_path. Attempting fallback to model_path..." << std::endl;
+                    LOG_WARNING("Warning: Failed to load with diffusion_model_path. Attempting fallback to model_path...");
                 }
 
                 // Re-initialize context parameters
@@ -351,18 +352,18 @@ public:
                 ctxParams.wtype = StableDiffusionWrapper::stringToModelType(params.modelType);
 
                 if (params.verbose) {
-                    std::cout << "Attempting to create context with fallback model_path..." << std::endl;
+                    LOG_DEBUG("Attempting to create context with fallback model_path...");
                 }
                 // Try creating context again with fallback
                 sdContext = new_sd_ctx(&ctxParams);
                 if (!sdContext) {
                     lastError = "Failed to create stable-diffusion context with both diffusion_model_path and model_path fallback";
-                    std::cerr << "Error: " << lastError << std::endl;
+                    LOG_ERROR("Error: " + lastError);
 
                     // Additional fallback: try with minimal parameters for GGUF models
                     if (modelFileName.find(".gguf") != std::string::npos || modelFileName.find(".ggml") != std::string::npos) {
                         if (params.verbose) {
-                            std::cout << "Detected GGUF/GGML model, attempting minimal parameter fallback..." << std::endl;
+                            LOG_DEBUG("Detected GGUF/GGML model, attempting minimal parameter fallback...");
                         }
 
                         // Re-initialize with minimal parameters
@@ -375,32 +376,32 @@ public:
                         ctxParams.wtype     = StableDiffusionWrapper::stringToModelType(params.modelType);
 
                         if (params.verbose) {
-                            std::cout << "Attempting to create context with minimal GGUF parameters..." << std::endl;
+                            LOG_DEBUG("Attempting to create context with minimal GGUF parameters...");
                         }
                         sdContext = new_sd_ctx(&ctxParams);
 
                         if (!sdContext) {
                             lastError = "Failed to create stable-diffusion context even with minimal GGUF parameters";
-                            std::cerr << "Error: " << lastError << std::endl;
+                            LOG_ERROR("Error: " + lastError);
                             return false;
                         }
 
                         if (params.verbose) {
-                            std::cout << "Successfully loaded GGUF model with minimal parameters: " << absModelPath << std::endl;
+                            LOG_DEBUG("Successfully loaded GGUF model with minimal parameters: " + absModelPath.string());
                         }
                     } else {
                         return false;
                     }
                 } else {
                     if (params.verbose) {
-                        std::cout << "Successfully loaded model with fallback to model_path: " << absModelPath << std::endl;
+                        LOG_DEBUG("Successfully loaded model with fallback to model_path: " + absModelPath.string());
                     }
                 }
             } else {
                 // Try minimal fallback for non-diffusion_model_path failures
                 if (modelFileName.find(".gguf") != std::string::npos || modelFileName.find(".ggml") != std::string::npos) {
                     if (params.verbose) {
-                        std::cout << "Detected GGUF/GGML model, attempting minimal parameter fallback..." << std::endl;
+                        LOG_DEBUG("Detected GGUF/GGML model, attempting minimal parameter fallback...");
                     }
 
                     // Re-initialize with minimal parameters
@@ -412,21 +413,21 @@ public:
                     ctxParams.wtype     = StableDiffusionWrapper::stringToModelType(params.modelType);
 
                     if (params.verbose) {
-                        std::cout << "Attempting to create context with minimal GGUF parameters..." << std::endl;
+                        LOG_DEBUG("Attempting to create context with minimal GGUF parameters...");
                     }
                     sdContext = new_sd_ctx(&ctxParams);
 
                     if (!sdContext) {
                         lastError = "Failed to create stable-diffusion context even with minimal GGUF parameters";
-                        std::cerr << "Error: " << lastError << std::endl;
+                        LOG_ERROR("Error: " + lastError);
                         return false;
                     }
 
                     if (params.verbose) {
-                        std::cout << "Successfully loaded GGUF model with minimal parameters: " << absModelPath << std::endl;
+                        LOG_DEBUG("Successfully loaded GGUF model with minimal parameters: " + absModelPath.string());
                     }
                 } else {
-                    std::cerr << "Error: " << lastError << std::endl;
+                    LOG_ERROR("Error: " + lastError);
                     return false;
                 }
             }
@@ -434,24 +435,28 @@ public:
 
         // Log successful loading with detection information
         if (params.verbose) {
-            std::cout << "Successfully loaded model: " << absModelPath << std::endl;
-            std::cout << "  Detection source: " << detectionSource << std::endl;
-            std::cout << "  Loading method: " << (useDiffusionModelPath ? "diffusion_model_path" : "model_path") << std::endl;
-            std::cout << "  Parent directory: " << parentDirName << std::endl;
-            std::cout << "  Model filename: " << modelFileName << std::endl;
+            LOG_DEBUG("Successfully loaded model: " + absModelPath.string());
+            LOG_DEBUG("  Detection source: " + detectionSource);
+            LOG_DEBUG("  Loading method: " + std::string(useDiffusionModelPath ? "diffusion_model_path" : "model_path"));
+            LOG_DEBUG("  Parent directory: " + parentDirName);
+            LOG_DEBUG("  Model filename: " + modelFileName);
         }
 
         // Log additional model properties if architecture detection was performed
         if (detectionSuccessful && params.verbose) {
-            std::cout << "  Architecture: " << detectionResult.architectureName << std::endl;
+            LOG_DEBUG("  Architecture: " + detectionResult.architectureName);
             if (detectionResult.textEncoderDim > 0) {
-                std::cout << "  Text encoder dimension: " << detectionResult.textEncoderDim << std::endl;
+                LOG_DEBUG("  Text encoder dimension: " + std::to_string(detectionResult.textEncoderDim));
             }
             if (detectionResult.needsVAE) {
-                std::cout << "  Requires VAE: " << (detectionResult.recommendedVAE.empty() ? "Yes" : detectionResult.recommendedVAE) << std::endl;
+                LOG_DEBUG("  Requires VAE: " + (detectionResult.recommendedVAE.empty() ? std::string("Yes") : detectionResult.recommendedVAE));
             }
         }
 
+        // Store current model info for potential reload after upscaling
+        currentModelPath = modelPath;
+        currentModelParams = params;
+
         return true;
     }
 
@@ -461,9 +466,12 @@ public:
             free_sd_ctx(sdContext);
             sdContext = nullptr;
             if (verbose) {
-                std::cout << "Unloaded stable-diffusion model" << std::endl;
+                LOG_DEBUG("Unloaded stable-diffusion model");
             }
         }
+        // Clear stored model info
+        currentModelPath.clear();
+        currentModelParams = StableDiffusionWrapper::GenerationParams();
     }
 
     bool isModelLoaded() const {
@@ -520,14 +528,14 @@ public:
         }
 
         // Generate the image
-        std::cout << "[TIMING_ANALYSIS] Starting generate_image() call" << std::endl;
+        LOG_DEBUG("[TIMING_ANALYSIS] Starting generate_image() call");
         auto generationCallStart = std::chrono::high_resolution_clock::now();
-        
+
         sd_image_t* sdImages = generate_image(sdContext, &genParams);
-        
+
         auto generationCallEnd = std::chrono::high_resolution_clock::now();
         auto generationCallTime = std::chrono::duration_cast<std::chrono::milliseconds>(generationCallEnd - generationCallStart).count();
-        std::cout << "[TIMING_ANALYSIS] generate_image() call completed in " << generationCallTime << "ms" << std::endl;
+        LOG_DEBUG("[TIMING_ANALYSIS] generate_image() call completed in " + std::to_string(generationCallTime) + "ms");
 
         // Clear and clean up progress callback - FIX: Wait for any pending callbacks
         sd_set_progress_callback(nullptr, nullptr);
@@ -951,6 +959,18 @@ public:
 
         auto startTime = std::chrono::high_resolution_clock::now();
 
+        // Unload stable diffusion checkpoint before loading upscaler to prevent memory conflicts
+        {
+            std::lock_guard<std::mutex> lock(contextMutex);
+            if (sdContext) {
+                if (verbose) {
+                    LOG_DEBUG("Unloading stable diffusion checkpoint before loading upscaler model");
+                }
+                free_sd_ctx(sdContext);
+                sdContext = nullptr;
+            }
+        }
+
         // Create upscaler context
         upscaler_ctx_t* upscalerCtx = new_upscaler_ctx(
             esrganPath.c_str(),

+ 51 - 33
webui/app/gallery/page.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { useState, useEffect, useCallback } from "react";
+import { useRouter } from "next/navigation";
 import { Header } from "@/components/layout";
 import { AppLayout } from "@/components/layout";
 import { Button } from "@/components/ui/button";
@@ -13,9 +14,8 @@ import {
   RefreshCw,
   Calendar,
   Image as ImageIcon,
-  X,
-  ZoomIn,
   Maximize2,
+  ArrowUp,
 } from "lucide-react";
 import { downloadAuthenticatedImage } from "@/lib/utils";
 
@@ -79,6 +79,7 @@ function ImageModal({ image, isOpen, onClose }: ImageModalProps) {
 }
 
 function GalleryGrid() {
+  const router = useRouter();
   const [images, setImages] = useState<GalleryImage[]>([]);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
@@ -198,6 +199,12 @@ function GalleryGrid() {
     );
   };
 
+  const handleUpscale = (image: GalleryImage, e: React.MouseEvent) => {
+    e.stopPropagation();
+    // Navigate to upscaler page with image URL as query parameter
+    router.push(`/upscaler?imageUrl=${encodeURIComponent(image.url)}`);
+  };
+
   const formatDate = (dateString: string) => {
     return new Date(dateString).toLocaleDateString("en-US", {
       year: "numeric",
@@ -310,30 +317,7 @@ function GalleryGrid() {
                         loading="lazy"
                       />
                       
-                      {/* Hover overlay with actions */}
-                      <div className="absolute inset-0 bg-black/60 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 bg-white/90 hover:bg-white text-black"
-                          onClick={(e: React.MouseEvent) => {
-                            e.stopPropagation();
-                            handleImageClick(image);
-                          }}
-                          title="View full size"
-                        >
-                          <Maximize2 className="h-4 w-4" />
-                        </Button>
-                        <Button
-                          size="icon"
-                          variant="secondary"
-                          className="h-8 w-8 bg-white/90 hover:bg-white text-black"
-                          onClick={(e: React.MouseEvent) => handleDownload(image, e)}
-                          title="Download image"
-                        >
-                          <Download className="h-4 w-4" />
-                        </Button>
-                      </div>
+
                     </div>
 
                     {/* Date badge */}
@@ -375,13 +359,47 @@ function GalleryGrid() {
                       </div>
                     </div>
 
-                    {image.prompt && (
-                      <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
-                        {image.prompt}
-                      </p>
-                    )}
-                  </div>
-                </CardContent>
+                     {image.prompt && (
+                       <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
+                         {image.prompt}
+                       </p>
+                     )}
+
+                     {/* Action buttons */}
+                     <div className="grid grid-cols-3 gap-1 mt-2">
+                       <Button
+                         size="sm"
+                         variant="outline"
+                         className="h-7 text-xs px-1"
+                         onClick={(e: React.MouseEvent) => {
+                           e.stopPropagation();
+                           handleImageClick(image);
+                         }}
+                         title="View full size"
+                       >
+                         <Maximize2 className="h-3 w-3" />
+                       </Button>
+                       <Button
+                         size="sm"
+                         variant="outline"
+                         className="h-7 text-xs px-1"
+                         onClick={(e: React.MouseEvent) => handleDownload(image, e)}
+                         title="Download image"
+                       >
+                         <Download className="h-3 w-3" />
+                       </Button>
+                       <Button
+                         size="sm"
+                         variant="outline"
+                         className="h-7 text-xs px-1"
+                         onClick={(e: React.MouseEvent) => handleUpscale(image, e)}
+                         title="Upscale image"
+                       >
+                         <ArrowUp className="h-3 w-3" />
+                       </Button>
+                     </div>
+                   </div>
+                 </CardContent>
               </Card>
             ))}
           </div>

+ 65 - 3
webui/app/img2img/page.tsx

@@ -9,6 +9,13 @@ 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 {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import { apiClient, type JobInfo, type JobDetailsResponse } from "@/lib/api";
 import {
   downloadImage,
@@ -16,6 +23,7 @@ import {
   fileToBase64,
 } from "@/lib/utils";
 import { useLocalStorage, useMemoryStorage, useGeneratedImages } from "@/lib/storage";
+import { useModelTypeSelection } from "@/contexts/model-selection-context";
 import { type ImageValidationResult } from "@/lib/image-validation";
 import { Loader2, Download, X } from "lucide-react";
 
@@ -46,6 +54,18 @@ const defaultFormData: Img2ImgFormData = {
 };
 
 function Img2ImgForm() {
+  const {
+    availableModels: vaeModels,
+    selectedModel: selectedVae,
+    setSelectedModel: setSelectedVae,
+  } = useModelTypeSelection("vae");
+
+  const {
+    availableModels: taesdModels,
+    selectedModel: selectedTaesd,
+    setSelectedModel: setSelectedTaesd,
+  } = useModelTypeSelection("taesd");
+
   // Store form data without the image to avoid localStorage quota issues
   const { ...formDataWithoutImage } = defaultFormData;
   const [formData, setFormData] = useLocalStorage<
@@ -395,6 +415,8 @@ function Img2ImgForm() {
     try {
       const requestData = {
         ...fullFormData,
+        vae: selectedVae || undefined,
+        taesd: selectedTaesd || undefined,
       };
 
       const job = await apiClient.img2img(requestData);
@@ -602,9 +624,49 @@ function Img2ImgForm() {
                     <option value="dpm++2mv2">DPM++ 2M V2</option>
                     <option value="lcm">LCM</option>
                   </select>
-                </div>
-
-                <div className="flex gap-2">
+                 </div>
+
+                 <div className="space-y-2">
+                   <Label>VAE Model (Optional)</Label>
+                   <Select
+                     value={selectedVae || "none"}
+                     onValueChange={(value) => setSelectedVae(value === "none" ? undefined : value)}
+                   >
+                     <SelectTrigger>
+                       <SelectValue placeholder="Select VAE model" />
+                     </SelectTrigger>
+                     <SelectContent>
+                       <SelectItem value="none">None</SelectItem>
+                       {vaeModels.map((model) => (
+                         <SelectItem key={model.name} value={model.name}>
+                           {model.name}
+                         </SelectItem>
+                       ))}
+                     </SelectContent>
+                   </Select>
+                 </div>
+
+                 <div className="space-y-2">
+                   <Label>TAESD Model (Optional)</Label>
+                   <Select
+                     value={selectedTaesd || "none"}
+                     onValueChange={(value) => setSelectedTaesd(value === "none" ? undefined : value)}
+                   >
+                     <SelectTrigger>
+                       <SelectValue placeholder="Select TAESD model" />
+                     </SelectTrigger>
+                     <SelectContent>
+                       <SelectItem value="none">None</SelectItem>
+                       {taesdModels.map((model) => (
+                         <SelectItem key={model.name} value={model.name}>
+                           {model.name}
+                         </SelectItem>
+                       ))}
+                     </SelectContent>
+                   </Select>
+                 </div>
+
+                 <div className="flex gap-2">
                   <Button
                     type="submit"
                     disabled={

+ 171 - 201
webui/app/inpainting/page.tsx

@@ -21,8 +21,6 @@ import { Loader2, X, Download } from "lucide-react";
 import { downloadAuthenticatedImage } from "@/lib/utils";
 import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
 import {
-  useModelSelection,
-  useCheckpointSelection,
   useModelTypeSelection,
 } from "@/contexts/model-selection-context";
 import {
@@ -59,14 +57,6 @@ const defaultFormData: InpaintingFormData = {
 };
 
 function InpaintingForm() {
-  const { actions } = useModelSelection();
-const {
-    checkpointModels,
-    selectedCheckpointModel,
-    selectedCheckpoint,
-    setSelectedCheckpoint,
-    isAutoSelecting,
-  } = useCheckpointSelection();
 
   const {
     availableModels: vaeModels,
@@ -76,7 +66,14 @@ const {
     setSelectedModel: setSelectedVae,
     setUserOverride: setVaeUserOverride,
     clearUserOverride: clearVaeUserOverride,
-  } = useModelTypeSelection("vae");
+   } = useModelTypeSelection("vae");
+
+   const {
+     availableModels: taesdModels,
+     selectedModel: selectedTaesd,
+
+     setSelectedModel: setSelectedTaesd,
+   } = useModelTypeSelection("taesd");
 
   const [formData, setFormData] = useLocalStorage<InpaintingFormData>(
     "inpainting-form-data",
@@ -109,12 +106,10 @@ const {
   useEffect(() => {
     const loadModels = async () => {
       try {
-        const [modelsData, loras, embeds] = await Promise.all([
-          apiClient.getModels(), // Get all models with enhanced info
+        const [loras, embeds] = await Promise.all([
           apiClient.getModels("lora"),
           apiClient.getModels("embedding"),
         ]);
-        actions.setModels(modelsData.models);
         setLoraModels(loras.models.map((m) => m.name));
         setEmbeddings(embeds.models.map((m) => m.name));
       } catch (err) {
@@ -122,17 +117,9 @@ const {
       }
     };
     loadModels();
-  }, [actions]);
+  }, []);
+
 
-  // Update form data when checkpoint changes
-  useEffect(() => {
-    if (selectedCheckpoint) {
-      setFormData((prev) => ({
-        ...prev,
-        model: selectedCheckpoint,
-      }));
-    }
-  }, [selectedCheckpoint, setFormData]);
 
   const handleInputChange = (
     e: React.ChangeEvent<
@@ -265,24 +252,12 @@ const {
     setJobInfo(null);
 
     try {
-      // Validate model selection
-      if (selectedCheckpointModel) {
-        const validation = actions.validateSelection(selectedCheckpointModel);
-        if (!validation.isValid) {
-          setError(
-            `Missing required models: ${validation.missingRequired.join(", ")}`,
-          );
-          setLoading(false);
-          return;
-        }
-      }
-
       const requestData = {
         ...formData,
         source_image: sourceImage,
         mask_image: maskImage,
-        model: selectedCheckpoint || undefined,
         vae: selectedVae || undefined,
+        taesd: selectedTaesd || undefined,
       };
 
       const job = await apiClient.inpainting(requestData);
@@ -327,15 +302,8 @@ const {
       />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
-          {/* Left Panel - Canvas and Form */}
+          {/* Left Panel - Form Parameters */}
           <div className="space-y-6">
-            <InpaintingCanvas
-              onSourceImageChange={handleSourceImageChange}
-              onMaskImageChange={handleMaskImageChange}
-              targetWidth={formData.width}
-              targetHeight={formData.height}
-            />
-
             <Card>
               <CardContent className="pt-6">
                 <form onSubmit={handleGenerate} className="space-y-4">
@@ -373,88 +341,97 @@ const {
 
                   <div className="space-y-2">
                     <Label htmlFor="strength">
-                      Strength: {formData.strength.toFixed(2)}
+                      Strength *
+                      <span className="text-xs text-muted-foreground ml-1">
+                        ({formData.strength})
+                      </span>
                     </Label>
-                    <Input
+                    <input
                       id="strength"
                       name="strength"
                       type="range"
+                      min="0.1"
+                      max="1.0"
+                      step="0.1"
                       value={formData.strength}
                       onChange={handleInputChange}
-                      min={0}
-                      max={1}
-                      step={0.05}
+                      className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
                     />
-                    <p className="text-xs text-muted-foreground">
-                      Lower values preserve more of the original image
-                    </p>
                   </div>
 
                   <div className="grid grid-cols-2 gap-4">
                     <div className="space-y-2">
-                      <Label htmlFor="width">Width</Label>
-                      <Input
-                        id="width"
-                        name="width"
+                      <Label htmlFor="steps">Steps</Label>
+                      <input
+                        id="steps"
+                        name="steps"
                         type="number"
-                        value={formData.width}
+                        min="1"
+                        max="100"
+                        value={formData.steps}
                         onChange={handleInputChange}
-                        step={64}
-                        min={256}
-                        max={2048}
+                        className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                       />
                     </div>
+
                     <div className="space-y-2">
-                      <Label htmlFor="height">Height</Label>
-                      <Input
-                        id="height"
-                        name="height"
+                      <Label htmlFor="cfg_scale">CFG Scale</Label>
+                      <input
+                        id="cfg_scale"
+                        name="cfg_scale"
                         type="number"
-                        value={formData.height}
+                        min="1"
+                        max="30"
+                        step="0.5"
+                        value={formData.cfg_scale}
                         onChange={handleInputChange}
-                        step={64}
-                        min={256}
-                        max={2048}
+                        className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                       />
                     </div>
                   </div>
 
                   <div className="grid grid-cols-2 gap-4">
                     <div className="space-y-2">
-                      <Label htmlFor="steps">Steps</Label>
-                      <Input
-                        id="steps"
-                        name="steps"
+                      <Label htmlFor="width">Width</Label>
+                      <input
+                        id="width"
+                        name="width"
                         type="number"
-                        value={formData.steps}
+                        min="64"
+                        max="2048"
+                        step="64"
+                        value={formData.width || 512}
                         onChange={handleInputChange}
-                        min={1}
-                        max={150}
+                        className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                       />
                     </div>
+
                     <div className="space-y-2">
-                      <Label htmlFor="cfg_scale">CFG Scale</Label>
-                      <Input
-                        id="cfg_scale"
-                        name="cfg_scale"
+                      <Label htmlFor="height">Height</Label>
+                      <input
+                        id="height"
+                        name="height"
                         type="number"
-                        value={formData.cfg_scale}
+                        min="64"
+                        max="2048"
+                        step="64"
+                        value={formData.height || 512}
                         onChange={handleInputChange}
-                        step={0.5}
-                        min={1}
-                        max={30}
+                        className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                       />
                     </div>
                   </div>
 
                   <div className="space-y-2">
-                    <Label htmlFor="seed">Seed (optional)</Label>
-                    <Input
+                    <Label htmlFor="seed">Seed</Label>
+                    <input
                       id="seed"
                       name="seed"
+                      type="text"
                       value={formData.seed}
                       onChange={handleInputChange}
-                      placeholder="Leave empty for random"
+                      placeholder="Random"
+                      className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
                     />
                   </div>
 
@@ -478,93 +455,77 @@ const {
                     </select>
                   </div>
 
-                  {/* Model Selection Section */}
+                  {/* Additional Models Section */}
                   <Card>
                     <CardHeader>
-                      <CardTitle>Model Selection</CardTitle>
+                      <CardTitle>Additional Models</CardTitle>
                       <CardDescription>
-                        Select checkpoint and additional models for generation
+                        Select 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)
-                        }
-                        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)" : ""}
-                          </option>
-                        ))}
-                      </select>
-                    </div>
 
-                    {/* VAE Selection */}
-                    <div className="space-y-2">
-                      <Label>VAE Model (Optional)</Label>
-                      <Select
-                        value={selectedVae || "default"}
-                        onValueChange={(value) => {
-                          if (value && value !== "default") {
-                            setSelectedVae(value);
-                            setVaeUserOverride(value);
-                          } else {
-                            clearVaeUserOverride();
-                          }
-                        }}
-                        disabled={isAutoSelecting}
-                      >
-                        <SelectTrigger>
-                          <SelectValue placeholder="Use default VAE" />
-                        </SelectTrigger>
-                        <SelectContent>
-                          <SelectItem value="default">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>
+                    <CardContent className="space-y-4">
+                      <div className="space-y-2">
+                        <Label>VAE Model (Optional)</Label>
+                        <Select
+                          value={selectedVae || "none"}
+                          onValueChange={(value) => setSelectedVae(value === "none" ? undefined : value)}
+                        >
+                          <SelectTrigger>
+                            <SelectValue placeholder="Select VAE model" />
+                          </SelectTrigger>
+                          <SelectContent>
+                            <SelectItem value="none">None</SelectItem>
+                            {vaeModels.map((model) => (
+                              <SelectItem key={model.name} value={model.name}>
+                                {model.name}
+                              </SelectItem>
+                            ))}
+                          </SelectContent>
+                        </Select>
+                      </div>
+
+                      <div className="space-y-2">
+                        <Label>TAESD Model (Optional)</Label>
+                        <Select
+                          value={selectedTaesd || "none"}
+                          onValueChange={(value) => setSelectedTaesd(value === "none" ? undefined : value)}
+                        >
+                          <SelectTrigger>
+                            <SelectValue placeholder="Select TAESD model" />
+                          </SelectTrigger>
+                          <SelectContent>
+                            <SelectItem value="none">None</SelectItem>
+                            {taesdModels.map((model) => (
+                              <SelectItem key={model.name} value={model.name}>
+                                {model.name}
+                              </SelectItem>
+                            ))}
+                          </SelectContent>
+                        </Select>
+                      </div>
+                    </CardContent>
                   </Card>
 
                   <div className="flex gap-2">
-                    <Button
-                      type="submit"
-                      disabled={loading || !sourceImage || !maskImage}
-                      className="flex-1"
-                    >
+                    <Button type="submit" disabled={loading} className="flex-1">
                       {loading ? (
                         <>
-                          <Loader2 className="h-4 w-4 animate-spin" />
+                          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                           Generating...
                         </>
                       ) : (
                         "Generate"
                       )}
                     </Button>
-                    {loading && (
+                    {jobInfo && (
                       <Button
                         type="button"
-                        variant="destructive"
+                        variant="outline"
                         onClick={handleCancel}
+                        disabled={!loading}
                       >
                         <X className="h-4 w-4" />
-                        Cancel
                       </Button>
                     )}
                   </div>
@@ -579,55 +540,64 @@ const {
             </Card>
           </div>
 
-          {/* Right Panel - Generated Images */}
-          <Card>
-            <CardContent className="pt-6">
-              <div className="space-y-4">
-                <h3 className="text-lg font-semibold">Generated Images</h3>
-                {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"}
-                    </p>
-                  </div>
-                ) : (
-                  <div className="grid gap-4">
-                    {generatedImages.map((image, index) => (
-                      <div key={index} className="relative group">
-                        <img
-                          src={image}
-                          alt={`Generated ${index + 1}`}
-                          className="w-full rounded-lg border border-border"
-                        />
-                        <Button
-                          size="icon"
-                          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);
-                            });
-                          }}
-                        >
-                          <Download className="h-4 w-4" />
-                        </Button>
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </div>
-            </CardContent>
-          </Card>
+          {/* Right Panel - Source Image and Results */}
+          <div className="space-y-6">
+            <InpaintingCanvas
+              onSourceImageChange={handleSourceImageChange}
+              onMaskImageChange={handleMaskImageChange}
+              targetWidth={formData.width}
+              targetHeight={formData.height}
+            />
+
+            <Card>
+              <CardContent className="pt-6">
+                <div className="space-y-4">
+                  <h3 className="text-lg font-semibold">Generated Images</h3>
+                  {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"}
+                      </p>
+                    </div>
+                  ) : (
+                    <div className="grid gap-4">
+                      {generatedImages.map((image, index) => (
+                        <div key={index} className="relative group">
+                          <img
+                            src={image}
+                            alt={`Generated ${index + 1}`}
+                            className="w-full rounded-lg border border-border"
+                          />
+                          <Button
+                            size="icon"
+                            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);
+                              });
+                            }}
+                          >
+                            <Download className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+          </div>
         </div>
       </div>
     </AppLayout>

+ 66 - 4
webui/app/text2img/page.tsx

@@ -9,6 +9,13 @@ import { Input } from "@/components/ui/input";
 import { PromptTextarea } from "@/components/forms";
 import { Label } from "@/components/ui/label";
 import { Card, CardContent } from "@/components/ui/card";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import {
   apiClient,
   type GenerationRequest,
@@ -18,6 +25,7 @@ import {
 import { Loader2, Download, X, Trash2, RotateCcw, Power } from "lucide-react";
 import { downloadImage, downloadAuthenticatedImage } from "@/lib/utils";
 import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
+import { useModelTypeSelection } from "@/contexts/model-selection-context";
 
 const defaultFormData: GenerationRequest = {
   prompt: "",
@@ -33,6 +41,18 @@ const defaultFormData: GenerationRequest = {
 };
 
 function Text2ImgForm() {
+  const {
+    availableModels: vaeModels,
+    selectedModel: selectedVae,
+    setSelectedModel: setSelectedVae,
+  } = useModelTypeSelection("vae");
+
+  const {
+    availableModels: taesdModels,
+    selectedModel: selectedTaesd,
+    setSelectedModel: setSelectedTaesd,
+  } = useModelTypeSelection("taesd");
+
   const [formData, setFormData] = useLocalStorage<GenerationRequest>(
     "text2img-form-data",
     defaultFormData,
@@ -215,6 +235,8 @@ function Text2ImgForm() {
     try {
       const requestData = {
         ...formData,
+        vae: selectedVae || undefined,
+        taesd: selectedTaesd || undefined,
       };
 
       const job = await apiClient.text2img(requestData);
@@ -471,10 +493,50 @@ function Text2ImgForm() {
                       <option value="default">Loading...</option>
                     )}
                   </select>
-                </div>
-
-                <div className="space-y-2">
-                  <Label htmlFor="batch_count">Batch Count</Label>
+                 </div>
+
+                 <div className="space-y-2">
+                   <Label>VAE Model (Optional)</Label>
+                   <Select
+                     value={selectedVae || "none"}
+                     onValueChange={(value) => setSelectedVae(value === "none" ? undefined : value)}
+                   >
+                     <SelectTrigger>
+                       <SelectValue placeholder="Select VAE model" />
+                     </SelectTrigger>
+                     <SelectContent>
+                       <SelectItem value="none">None</SelectItem>
+                       {vaeModels.map((model) => (
+                         <SelectItem key={model.name} value={model.name}>
+                           {model.name}
+                         </SelectItem>
+                       ))}
+                     </SelectContent>
+                   </Select>
+                 </div>
+
+                 <div className="space-y-2">
+                   <Label>TAESD Model (Optional)</Label>
+                   <Select
+                     value={selectedTaesd || "none"}
+                     onValueChange={(value) => setSelectedTaesd(value === "none" ? undefined : value)}
+                   >
+                     <SelectTrigger>
+                       <SelectValue placeholder="Select TAESD model" />
+                     </SelectTrigger>
+                     <SelectContent>
+                       <SelectItem value="none">None</SelectItem>
+                       {taesdModels.map((model) => (
+                         <SelectItem key={model.name} value={model.name}>
+                           {model.name}
+                         </SelectItem>
+                       ))}
+                     </SelectContent>
+                   </Select>
+                 </div>
+
+                 <div className="space-y-2">
+                   <Label htmlFor="batch_count">Batch Count</Label>
                   <Input
                     id="batch_count"
                     name="batch_count"

+ 318 - 201
webui/app/upscaler/page.tsx

@@ -1,10 +1,9 @@
 "use client";
 
-import { useState, useRef, useEffect } from "react";
-import { Header } from "@/components/layout";
-import { AppLayout } from "@/components/layout";
+import { useState, useRef, useEffect, Suspense } from "react";
+import { useSearchParams } from "next/navigation";
 import { Button } from "@/components/ui/button";
-
+import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import {
   Card,
@@ -14,18 +13,14 @@ import {
   apiClient,
   type JobInfo,
   type JobDetailsResponse,
+  type ModelInfo,
+  type EnhancedModelsResponse,
 } from "@/lib/api";
 import { Loader2, Download, X, Upload } from "lucide-react";
 import {
-  downloadImage,
   downloadAuthenticatedImage,
   fileToBase64,
 } from "@/lib/utils";
-import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
-import {
-  useModelSelection,
-  useModelTypeSelection,
-} from "@/contexts/model-selection-context";
 import {
   Select,
   SelectContent,
@@ -33,7 +28,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from "@/components/ui/select";
-// import { AutoSelectionStatus } from '@/components/features/models';
+import { AppLayout, Header } from "@/components/layout";
 
 type UpscalerFormData = {
   upscale_factor: number;
@@ -45,20 +40,13 @@ const defaultFormData: UpscalerFormData = {
   model: "",
 };
 
+
+
 function UpscalerForm() {
-  const { actions } = useModelSelection();
-
-  const {
-    availableModels: upscalerModels,
-    selectedModel: selectedUpscalerModel,
-    setSelectedModel: setSelectedUpscalerModel,
-  } = useModelTypeSelection("upscaler");
-
-  const [formData, setFormData] = useLocalStorage<UpscalerFormData>(
-    "upscaler-form-data",
-    defaultFormData,
-    { excludeLargeData: true, maxSize: 512 * 1024 },
-  );
+  const searchParams = useSearchParams();
+
+  // Simple state management - no complex hooks initially
+  const [formData, setFormData] = useState<UpscalerFormData>(defaultFormData);
 
   // Separate state for image data (not stored in localStorage)
   const [uploadedImage, setUploadedImage] = useState<string>("");
@@ -67,11 +55,17 @@ function UpscalerForm() {
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
-  const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('upscaler');
-  const [generatedImages, setGeneratedImages] = useState<string[]>(() => getLatestImages());
+  const [generatedImages, setGeneratedImages] = useState<string[]>([]);
   const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
+  // URL input state
+  const [urlInput, setUrlInput] = useState('');
+
+  // Local state for upscaler models - no global context to avoid performance issues
+  const [upscalerModels, setUpscalerModels] = useState<ModelInfo[]>([]);
+  const [modelsLoading, setModelsLoading] = useState(false);
+
   // Cleanup polling on unmount
   useEffect(() => {
     return () => {
@@ -81,42 +75,86 @@ function UpscalerForm() {
     };
   }, [pollCleanup]);
 
+  // Load image from URL parameter on mount
+  useEffect(() => {
+    const imageUrl = searchParams.get('imageUrl');
+    if (imageUrl) {
+      loadImageFromUrl(imageUrl);
+    }
+  }, [searchParams]);
+
+  // Load upscaler models on mount
   useEffect(() => {
+    let isComponentMounted = true;
+
     const loadModels = async () => {
       try {
-        // Fetch all models with enhanced info
-        const modelsData = await apiClient.getModels();
-        // Filter for upscaler models
-        const upscalerModels = modelsData.models.filter(
-          (m) => m.type.toLowerCase() === "upscaler",
+        setModelsLoading(true);
+        setError(null);
+
+        // Set up timeout for API call
+        const timeoutPromise = new Promise((_, reject) =>
+          setTimeout(() => reject(new Error('API call timeout')), 5000)
         );
-        actions.setModels(modelsData.models);
 
-        // Set first upscaler model as default if none selected
-        if (upscalerModels.length > 0 && !formData.model) {
+        const apiPromise = apiClient.getModels("esrgan");
+        const modelsData = await Promise.race([apiPromise, timeoutPromise]) as EnhancedModelsResponse;
+
+        console.log("API call completed, models:", modelsData.models?.length || 0);
+
+        if (!isComponentMounted) return;
+
+        // Set models locally - no global state updates
+        setUpscalerModels(modelsData.models || []);
+
+        // Set first model as default if none selected
+        if (modelsData.models?.length > 0 && !formData.model) {
           setFormData((prev) => ({
             ...prev,
-            model: upscalerModels[0].name,
+            model: modelsData.models[0].name,
           }));
         }
       } catch (err) {
         console.error("Failed to load upscaler models:", err);
+        if (isComponentMounted) {
+          setError(`Failed to load upscaler models: ${err instanceof Error ? err.message : 'Unknown error'}`);
+        }
+      } finally {
+        if (isComponentMounted) {
+          setModelsLoading(false);
+        }
       }
     };
+
     loadModels();
-  }, [actions, setFormData]);
 
-  // Update form data when upscaler model changes
-  useEffect(() => {
-    if (selectedUpscalerModel) {
-      setFormData((prev) => ({
-        ...prev,
-        model: selectedUpscalerModel,
-      }));
-    }
-  }, [selectedUpscalerModel, setFormData]);
+    return () => {
+      isComponentMounted = false;
+    };
+  }, []); // eslint-disable-line react-hooks/exhaustive-deps
 
-  
+  const loadImageFromUrl = async (url: string) => {
+    try {
+      setError(null);
+      // Fetch the image and convert to base64
+      const response = await fetch(url);
+      if (!response.ok) {
+        throw new Error('Failed to fetch image');
+      }
+      const blob = await response.blob();
+      const base64 = await new Promise<string>((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => resolve(reader.result as string);
+        reader.onerror = reject;
+        reader.readAsDataURL(blob);
+      });
+      setUploadedImage(base64);
+      setPreviewImage(base64);
+    } catch (err) {
+      console.error('Failed to load image from URL:', err);
+      setError('Failed to load image from gallery');
+    }
+  };
 
   const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
     const file = e.target.files?.[0];
@@ -176,11 +214,10 @@ function UpscalerForm() {
 
           // Create a new array to trigger React re-render
           setGeneratedImages([...imageUrls]);
-          addImages(imageUrls, jobId);
           setLoading(false);
           isPolling = false;
         } else if (status.job.status === "failed") {
-          setError(status.job.error || "Upscaling failed");
+          setError(status.job.error_message || status.job.error || "Upscaling failed");
           setLoading(false);
           isPolling = false;
         } else if (status.job.status === "cancelled") {
@@ -230,15 +267,55 @@ function UpscalerForm() {
 
     try {
       // Validate model selection
-      if (!selectedUpscalerModel) {
+      if (!formData.model) {
         setError("Please select an upscaler model");
         setLoading(false);
         return;
       }
 
+      // Unload all currently loaded models and load the selected upscaler model
+      const selectedModel = upscalerModels.find(m => m.name === formData.model);
+      const modelId = selectedModel?.id || selectedModel?.sha256;
+      if (!selectedModel) {
+        setError("Selected upscaler model not found.");
+        setLoading(false);
+        return;
+      }
+      if (!modelId) {
+        setError("Selected upscaler model does not have a hash. Please compute the hash on the models page.");
+        setLoading(false);
+        return;
+      }
+
+      try {
+        // Get all loaded models
+        const loadedModels = await apiClient.getAllModels(undefined, true);
+
+        // Unload all loaded models
+        for (const model of loadedModels) {
+          const unloadId = model.id || model.sha256;
+          if (unloadId) {
+            try {
+              await apiClient.unloadModel(unloadId);
+            } catch (unloadErr) {
+              console.warn(`Failed to unload model ${model.name}:`, unloadErr);
+              // Continue with others
+            }
+          }
+        }
+
+        // Load the selected upscaler model
+        await apiClient.loadModel(modelId);
+      } catch (modelErr) {
+        console.error("Failed to prepare upscaler model:", modelErr);
+        setError("Failed to prepare upscaler model. Please try again.");
+        setLoading(false);
+        return;
+      }
+
       const job = await apiClient.upscale({
         image: uploadedImage,
-        model: selectedUpscalerModel,
+        model: formData.model,
         upscale_factor: formData.upscale_factor,
       });
       setJobInfo(job);
@@ -282,185 +359,221 @@ function UpscalerForm() {
       />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
-          {/* Left Panel - Form */}
-          <Card>
-            <CardContent className="pt-6">
-              <form onSubmit={handleUpscale} className="space-y-4">
-                <div className="space-y-2">
-                  <Label>Source Image *</Label>
-                  <div className="space-y-4">
-                    {previewImage && (
-                      <div className="relative">
-                        <img
-                          src={previewImage}
-                          alt="Preview"
-                          className="h-64 w-full rounded-lg object-cover"
-                        />
-                        <Button
-                          type="button"
-                          variant="destructive"
-                          size="icon"
-                          className="absolute top-2 right-2 h-8 w-8"
-                          onClick={() => {
-                            setPreviewImage(null);
-                            setUploadedImage("");
-                          }}
-                        >
-                          <X className="h-4 w-4" />
-                        </Button>
-                      </div>
-                    )}
-
+          {/* Left Panel - Form Parameters */}
+          <div className="space-y-6">
+            <Card>
+              <CardContent className="pt-6">
+                <form onSubmit={handleUpscale} className="space-y-4">
+                  {/* Image Upload Section */}
+                  <div className="space-y-2">
+                    <Label htmlFor="image-upload">Image *</Label>
                     <div className="space-y-2">
-                      <div className="flex items-center justify-center">
-                        <input
-                          ref={fileInputRef}
-                          type="file"
-                          accept="image/*"
-                          onChange={handleImageUpload}
-                          className="hidden"
+                      <input
+                        id="image-upload"
+                        type="file"
+                        accept="image/*"
+                        onChange={handleImageUpload}
+                        ref={fileInputRef}
+                        className="hidden"
+                      />
+                      <Button
+                        type="button"
+                        variant="outline"
+                        onClick={() => fileInputRef.current?.click()}
+                        className="w-full"
+                      >
+                        <Upload className="mr-2 h-4 w-4" />
+                        Choose Image File
+                      </Button>
+
+                      <div className="flex gap-2">
+                        <Input
+                          type="url"
+                          placeholder="Or paste image URL"
+                          value={urlInput}
+                          onChange={(e) => setUrlInput(e.target.value)}
+                          className="flex-1"
                         />
                         <Button
                           type="button"
                           variant="outline"
-                          onClick={() => fileInputRef.current?.click()}
+                          onClick={() => loadImageFromUrl(urlInput)}
+                          disabled={!urlInput}
                         >
-                          <Upload className="mr-2 h-4 w-4" />
-                          Choose Image
+                          Load
                         </Button>
                       </div>
                     </div>
                   </div>
-                </div>
 
-                <div className="space-y-2">
-                  <Label>Upscaling Factor</Label>
-                  <select
-                    value={formData.upscale_factor}
-                    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>
-                    <option value={3}>3x (Triple)</option>
-                    <option value={4}>4x (Quadruple)</option>
-                  </select>
-                </div>
+                  {/* Model Selection */}
+                  <div className="space-y-2">
+                    <Label htmlFor="model">Upscaler Model</Label>
+                    <Select
+                      value={formData.model}
+                      onValueChange={(value) =>
+                        setFormData((prev) => ({ ...prev, model: value }))
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue placeholder="Select upscaler model" />
+                      </SelectTrigger>
+                      <SelectContent>
+                        {upscalerModels.map((model) => (
+                          <SelectItem key={model.name} value={model.name}>
+                            {model.name}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                    {modelsLoading && (
+                      <p className="text-sm text-muted-foreground">Loading models...</p>
+                    )}
+                  </div>
 
-                <div className="space-y-2">
-                  <Label>Upscaler Model</Label>
-                  <Select
-                    value={formData.model}
-                    onValueChange={(value) => {
-                      setFormData((prev) => ({ ...prev, model: value }));
-                      setSelectedUpscalerModel(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>
+                  {/* Upscale Factor */}
+                  <div className="space-y-2">
+                    <Label htmlFor="upscale_factor">
+                      Upscale Factor *
+                      <span className="text-xs text-muted-foreground ml-1">
+                        ({formData.upscale_factor}x)
+                      </span>
+                    </Label>
+                    <input
+                      id="upscale_factor"
+                      name="upscale_factor"
+                      type="range"
+                      min="2"
+                      max="8"
+                      step="1"
+                      value={formData.upscale_factor}
+                      onChange={(e) =>
+                        setFormData((prev) => ({
+                          ...prev,
+                          upscale_factor: Number(e.target.value),
+                        }))
+                      }
+                      className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
+                    />
+                    <div className="flex justify-between text-xs text-muted-foreground">
+                      <span>2x</span>
+                      <span>8x</span>
+                    </div>
+                  </div>
 
-                <div className="flex gap-2">
-                  <Button
-                    type="submit"
-                    disabled={loading || !uploadedImage}
-                    className="flex-1"
-                  >
+                  {/* Generate Button */}
+                  <Button type="submit" disabled={loading || !uploadedImage} className="w-full">
                     {loading ? (
                       <>
-                        <Loader2 className="h-4 w-4 animate-spin" />
+                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                         Upscaling...
                       </>
                     ) : (
-                      "Upscale"
+                      "Upscale Image"
                     )}
                   </Button>
-                  {loading && (
+
+                  {/* Cancel Button */}
+                  {jobInfo && (
                     <Button
                       type="button"
-                      variant="destructive"
+                      variant="outline"
                       onClick={handleCancel}
+                      disabled={!loading}
+                      className="w-full"
                     >
-                      <X className="h-4 w-4" />
+                      <X className="h-4 w-4 mr-2" />
                       Cancel
                     </Button>
                   )}
-                </div>
 
-                {error && (
-                  <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
-                    {error}
-                  </div>
-                )}
-              </form>
-            </CardContent>
-          </Card>
-          <Card>
-            <CardContent className="pt-6">
-              <div className="space-y-4">
-                <h3 className="text-lg font-semibold">Upscaled Image</h3>
-                {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"}
-                    </p>
-                  </div>
-                ) : (
-                  <div className="grid gap-4">
-                    {generatedImages.map((image, index) => (
-                      <div key={index} className="relative group">
-                        <img
-                          src={image}
-                          alt={`Upscaled ${index + 1}`}
-                          className="w-full rounded-lg border border-border"
-                        />
-                        <Button
-                          size="icon"
-                          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(
+                  {/* Error Display */}
+                  {error && (
+                    <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
+                      {error}
+                    </div>
+                  )}
+                </form>
+              </CardContent>
+            </Card>
+          </div>
+
+          {/* Right Panel - Image Preview and Results */}
+          <div className="space-y-6">
+            {/* Image Preview */}
+            <Card>
+              <CardContent className="pt-6">
+                <div className="space-y-4">
+                  <h3 className="text-lg font-semibold">Image Preview</h3>
+                  {previewImage ? (
+                    <div className="relative">
+                      <img
+                        src={previewImage}
+                        alt="Preview"
+                        className="w-full rounded-lg border border-border"
+                      />
+                    </div>
+                  ) : (
+                    <div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-border">
+                      <p className="text-muted-foreground">
+                        Upload an image to see preview
+                      </p>
+                    </div>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+
+            {/* Results */}
+            <Card>
+              <CardContent className="pt-6">
+                <div className="space-y-4">
+                  <h3 className="text-lg font-semibold">Upscaled Images</h3>
+                  {generatedImages.length === 0 ? (
+                    <div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-border">
+                      <p className="text-muted-foreground">
+                        {loading
+                          ? "Upscaling in progress..."
+                          : "Upscaled images will appear here"}
+                      </p>
+                    </div>
+                  ) : (
+                    <div className="grid gap-4">
+                      {generatedImages.map((image, index) => (
+                        <div key={index} className="relative group">
+                          <img
+                            src={image}
+                            alt={`Upscaled ${index + 1}`}
+                            className="w-full rounded-lg border border-border"
+                          />
+                          <Button
+                            size="icon"
+                            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`,
-                              );
-                            });
-                          }}
-                        >
-                          <Download className="h-4 w-4" />
-                        </Button>
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </div>
-            </CardContent>
-          </Card>
+                                `upscaled-${Date.now()}-${index}.png`,
+                                authToken || undefined,
+                                unixUser || undefined,
+                              ).catch((err) => {
+                                console.error("Failed to download image:", err);
+                              });
+                            }}
+                          >
+                            <Download className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+          </div>
         </div>
       </div>
     </AppLayout>
@@ -468,5 +581,9 @@ function UpscalerForm() {
 }
 
 export default function UpscalerPage() {
-  return <UpscalerForm />;
+  return (
+    <Suspense fallback={<div>Loading...</div>}>
+      <UpscalerForm />
+    </Suspense>
+  );
 }

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

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

+ 89 - 16
webui/components/features/queue/enhanced-queue-list.tsx

@@ -234,6 +234,7 @@ export function EnhancedQueueList({
     throttledJobs.forEach((job) => {
       switch (job.status) {
         case "processing":
+        case "loading":
           stats.active++;
           break;
         case "queued":
@@ -314,6 +315,7 @@ export function EnhancedQueueList({
         failed: <XCircle className="h-4 w-4 text-red-500" />,
         processing: <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />,
         queued: <Clock className="h-4 w-4 text-yellow-500" />,
+        loading: <Activity className="h-4 w-4 text-orange-500 animate-pulse" />,
         cancelled: <XCircle className="h-4 w-4 text-gray-500" />,
         pending: <AlertCircle className="h-4 w-4 text-gray-500" />,
       },
@@ -322,6 +324,7 @@ export function EnhancedQueueList({
         failed: "text-red-600 dark:text-red-400",
         processing: "text-blue-600 dark:text-blue-400",
         queued: "text-yellow-600 dark:text-yellow-400",
+        loading: "text-orange-600 dark:text-orange-400",
         cancelled: "text-gray-600 dark:text-gray-400",
         pending: "text-gray-600 dark:text-gray-400",
       },
@@ -330,6 +333,7 @@ export function EnhancedQueueList({
         failed: "destructive" as const,
         processing: "secondary" as const,
         queued: "outline" as const,
+        loading: "outline" as const,
         cancelled: "outline" as const,
         pending: "outline" as const,
       },
@@ -582,9 +586,6 @@ export function EnhancedQueueList({
     ({ job }: { job: JobInfo }) => {
       if (job.progress === undefined) return null;
 
-      // Ensure progress is properly clamped between 0-100
-      const progressValue = Math.max(0, Math.min(100, job.progress * 100));
-
       // Get appropriate status message based on actual job status
       const getStatusMessage = () => {
         switch (job.status) {
@@ -596,27 +597,68 @@ export function EnhancedQueueList({
             return "Cancelled";
           case "queued":
             return `Queued${job.queue_position ? ` (Position: ${job.queue_position})` : ""}`;
+          case "loading":
+            // Extract model loading progress from message if present
+            if (job.message && job.message.includes("[Loading model:")) {
+              const match = job.message.match(/\[Loading model: (\d+)%\]/);
+              if (match) {
+                return `Loading model: ${match[1]}%`;
+              }
+            }
+            return "Loading model...";
           case "processing":
-            return job.message || "Processing...";
+            return job.message || "Generating...";
           default:
             return job.message || "Pending...";
         }
       };
 
+      const showProgress = job.status === "processing" || job.status === "loading";
+
       return (
-        <div className="space-y-2">
+        <div className="space-y-3">
           <div className="flex items-center justify-between text-sm">
             <span className={cn("font-medium", getStatusColor(job.status))}>
               {getStatusMessage()}
             </span>
-            {job.status === "processing" && (
+            {showProgress && (
               <span className="text-muted-foreground">
-                {Math.round(progressValue)}%
+                {Math.round((job.progress || 0) * 100)}%
               </span>
             )}
           </div>
-          {job.status === "processing" && (
-            <Progress value={progressValue} className="h-2" />
+
+          {/* Model Loading Progress Bar */}
+          {job.status === "loading" && (
+            <div className="space-y-1">
+              <div className="flex items-center justify-between text-xs text-muted-foreground">
+                <span>Model Loading</span>
+                <span>{Math.round((job.model_load_progress || 0) * 100)}%</span>
+              </div>
+              <Progress value={(job.model_load_progress || 0) * 100} className="h-1.5" />
+            </div>
+          )}
+
+          {/* Generation Progress Bar */}
+          {(job.status === "processing" || (job.status === "loading" && job.model_load_progress === 1)) && (
+            <div className="space-y-1">
+              <div className="flex items-center justify-between text-xs text-muted-foreground">
+                <span>Generation</span>
+                <span>{Math.round((job.generation_progress || 0) * 100)}%</span>
+              </div>
+              <Progress value={(job.generation_progress || 0) * 100} className="h-1.5" />
+            </div>
+          )}
+
+          {/* Overall Progress Bar */}
+          {showProgress && (
+            <div className="space-y-1">
+              <div className="flex items-center justify-between text-xs text-muted-foreground">
+                <span>Overall</span>
+                <span>{Math.round((job.progress || 0) * 100)}%</span>
+              </div>
+              <Progress value={(job.progress || 0) * 100} className="h-2" />
+            </div>
           )}
         </div>
       );
@@ -783,6 +825,8 @@ export function EnhancedQueueList({
                 "transition-all hover:shadow-md",
                 job.status === "processing" &&
                 "border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20",
+                job.status === "loading" &&
+                "border-orange-200 bg-orange-50/50 dark:border-orange-800 dark:bg-orange-950/20",
                 job.status === "completed" &&
                 "border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20",
                 job.status === "failed" &&
@@ -812,6 +856,7 @@ export function EnhancedQueueList({
                           {job.status}
                         </Badge>
                         {(job.status === "queued" ||
+                          job.status === "loading" ||
                           job.status === "processing") && (
                             <Button
                               variant="outline"
@@ -1261,20 +1306,48 @@ export function EnhancedQueueList({
                     </div>
                     <div className="flex items-center gap-4 flex-shrink-0">
                       {job.progress !== undefined &&
-                        job.status === "processing" && (
-                          <div className="text-right">
+                        (job.status === "processing" || job.status === "loading") && (
+                          <div className="text-right space-y-1">
                             <div className="text-sm font-medium">
                               {Math.max(0, Math.min(100, Math.round(job.progress * 100)))}%
                             </div>
-                            <div className="w-24 h-1.5 bg-gray-200 rounded-full overflow-hidden">
-                              <div
-                                className="h-full bg-blue-500 transition-all duration-300"
-                                style={{ width: `${Math.max(0, Math.min(100, job.progress * 100))}%` }}
-                              />
+
+                            {/* Compact dual progress bars */}
+                            <div className="space-y-1">
+                              {/* Overall progress */}
+                              <div className="w-24 h-1.5 bg-gray-200 rounded-full overflow-hidden">
+                                <div
+                                  className={`h-full transition-all duration-300 ${
+                                    job.status === "loading" ? "bg-orange-500" : "bg-blue-500"
+                                  }`}
+                                  style={{ width: `${Math.max(0, Math.min(100, job.progress * 100))}%` }}
+                                />
+                              </div>
+
+                              {/* Individual progress indicators */}
+                              {(job.status === "loading" || job.status === "processing") && (
+                                <div className="flex gap-1">
+                                  {/* Model loading progress */}
+                                  <div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
+                                    <div
+                                      className="h-full bg-orange-400 transition-all duration-300"
+                                      style={{ width: `${Math.max(0, Math.min(100, (job.model_load_progress || 0) * 100))}%` }}
+                                    />
+                                  </div>
+                                  {/* Generation progress */}
+                                  <div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
+                                    <div
+                                      className="h-full bg-blue-400 transition-all duration-300"
+                                      style={{ width: `${Math.max(0, Math.min(100, (job.generation_progress || 0) * 100))}%` }}
+                                    />
+                                  </div>
+                                </div>
+                              )}
                             </div>
                           </div>
                         )}
                       {(job.status === "queued" ||
+                        job.status === "loading" ||
                         job.status === "processing") && (
                           <Button
                             variant="outline"

+ 121 - 24
webui/components/forms/prompt-textarea.tsx

@@ -3,6 +3,10 @@
 import React, { useState, useRef, useEffect, useCallback } from 'react';
 import { cn } from '@/lib/utils';
 
+
+
+
+
 interface PromptTextareaProps {
   value: string;
   onChange: (value: string) => void;
@@ -15,7 +19,7 @@ interface PromptTextareaProps {
 
 interface Suggestion {
   text: string;
-  type: 'lora' | 'embedding';
+  type: 'lora' | 'embedding' | 'break';
   displayText: string;
 }
 
@@ -102,6 +106,20 @@ export function PromptTextarea({
     setShowSuggestions(false);
   }, [loras, embeddings]);
 
+  const removeHighlightedText = (start: number, end: number) => {
+    const newText = value.substring(0, start) + value.substring(end);
+    onChange(newText);
+    
+    // Reset cursor position to where the removed text was
+    setTimeout(() => {
+      if (textareaRef.current) {
+        textareaRef.current.selectionStart = start;
+        textareaRef.current.selectionEnd = start;
+        textareaRef.current.focus();
+      }
+    }, 0);
+  };
+
   const insertSuggestion = (suggestion: Suggestion) => {
     if (!textareaRef.current) return;
 
@@ -183,18 +201,20 @@ export function PromptTextarea({
     const parts: React.ReactNode[] = [];
     let lastIndex = 0;
 
+    // Create sets for faster lookup
     const loraNames = new Set(
       loras.map(name => name.replace(/\.(safetensors|ckpt|pt)$/i, ''))
     );
     const loraFullNames = new Set(loras);
-    // const embeddingNames = new Set(
-    //   embeddings.map(name => name.replace(/\.(safetensors|pt)$/i, ''))
-    // );
+    const embeddingNames = new Set(
+      embeddings.map(name => name.replace(/\.(safetensors|pt)$/i, ''))
+    );
 
+    const matches: Array<{ start: number; end: number; type: 'lora' | 'embedding' | 'break'; text: string; valid: boolean }> = [];
+    
+    // Find LoRA tags: <lora:name:weight>
     const loraRegex = /<lora:([^:>]+):([^>]+)>/g;
     let match;
-    const matches: Array<{ start: number; end: number; type: 'lora' | 'embedding'; text: string; valid: boolean }> = [];
-    
     while ((match = loraRegex.exec(text)) !== null) {
       const loraName = match[1];
       const isValid = loraNames.has(loraName) || loraFullNames.has(loraName);
@@ -207,22 +227,60 @@ export function PromptTextarea({
       });
     }
 
-    embeddings.forEach(embedding => {
-      const embeddingBase = embedding.replace(/\.(safetensors|pt)$/i, '');
+    // Find incomplete LoRA tags: <lora:name (missing closing > or weight)
+    const incompleteLoraRegex = /<lora:([^:>]*)(?::([^>]*))?(?<!>)$/g;
+    while ((match = incompleteLoraRegex.exec(text)) !== null) {
+      // Only highlight if it's at the end of text or followed by whitespace
+      const afterMatch = text.substring(match.index + match[0].length);
+      if (afterMatch === '' || /^\s/.test(afterMatch)) {
+        matches.push({
+          start: match.index,
+          end: match.index + match[0].length,
+          type: 'lora',
+          text: match[0],
+          valid: false,
+        });
+      }
+    }
+
+    // Find embeddings (word boundaries to avoid partial matches)
+    embeddingNames.forEach(embeddingBase => {
       const embeddingRegex = new RegExp(`\\b${escapeRegex(embeddingBase)}\\b`, 'g');
       while ((match = embeddingRegex.exec(text)) !== null) {
+        // Don't highlight if it's part of a LoRA tag
+        const beforeMatch = text.substring(0, match.index);
+        if (!beforeMatch.match(/<lora:[^>]*$/)) {
+          matches.push({
+            start: match.index,
+            end: match.index + match[0].length,
+            type: 'embedding',
+            text: match[0],
+            valid: true,
+          });
+        }
+      }
+    });
+
+    // Find BREAK keywords (word boundaries to avoid partial matches)
+    const breakRegex = /\bBREAK\b/g;
+    while ((match = breakRegex.exec(text)) !== null) {
+      // Don't highlight if it's part of a LoRA tag
+      const beforeMatch = text.substring(0, match.index);
+      if (!beforeMatch.match(/<lora:[^>]*$/)) {
         matches.push({
           start: match.index,
           end: match.index + match[0].length,
-          type: 'embedding',
+          type: 'break',
           text: match[0],
           valid: true,
         });
       }
-    });
+    }
 
+    // Sort matches by start position
     matches.sort((a, b) => a.start - b.start);
 
+    // Build highlighted parts, avoiding overlaps
     matches.forEach((match) => {
       if (match.start > lastIndex) {
         parts.push(
@@ -232,24 +290,49 @@ export function PromptTextarea({
 
       const highlightClass = match.type === 'lora'
         ? match.valid
-          ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300 font-medium rounded px-0.5'
-          : 'bg-red-500/20 text-red-700 dark:text-red-300 font-medium rounded px-0.5'
-        : 'bg-blue-500/20 text-blue-700 dark:text-blue-300 font-medium rounded px-0.5';
+          ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300 font-medium rounded px-0.5 border border-purple-500/30'
+          : 'bg-red-500/20 text-red-700 dark:text-red-300 font-medium rounded px-0.5 border border-red-500/30'
+        : match.type === 'break'
+          ? 'bg-orange-500/20 text-orange-700 dark:text-orange-300 font-bold rounded px-0.5 border border-orange-500/30'
+          : 'bg-blue-500/20 text-blue-700 dark:text-blue-300 font-medium rounded px-0.5 border border-blue-500/30';
+
+      const tooltip = match.type === 'lora' 
+        ? (match.valid ? `LoRA: ${match.text}` : 'LoRA not found')
+        : match.type === 'break'
+          ? 'BREAK: Token separator for prompt chunks'
+          : `Embedding: ${match.text}`;
+
+      const matchIndex = matches.indexOf(match);
 
       parts.push(
         <span 
-          key={`highlight-${match.start}`} 
-          className={highlightClass} 
-          title={match.type === 'lora' ? (match.valid ? 'LoRA' : 'LoRA not found') : 'Embedding'}
+          key={`highlight-${match.start}-${match.type}`} 
+          className={cn(
+            highlightClass,
+            'relative group inline-flex items-center gap-1'
+          )} 
+          title={tooltip}
           style={{ whiteSpace: 'pre-wrap' }}
         >
           {match.text}
+          <button
+            className="ml-1 opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center hover:bg-red-600 transition-all duration-200 cursor-pointer pointer-events-auto"
+            onClick={(e) => {
+              e.preventDefault();
+              e.stopPropagation();
+              removeHighlightedText(match.start, match.end);
+            }}
+            title={`Remove ${match.type}`}
+          >
+            ×
+          </button>
         </span>
       );
 
       lastIndex = match.end;
     });
 
+    // Add remaining text
     if (lastIndex < text.length) {
       parts.push(
         text.substring(lastIndex)
@@ -279,13 +362,29 @@ export function PromptTextarea({
   return (
     <div className="relative w-full">
       <div
-        className="absolute inset-0 pointer-events-none px-3 py-2 text-sm font-mono whitespace-pre-wrap break-words overflow-hidden rounded-md text-foreground"
+        ref={(el) => {
+          if (el && textareaRef.current) {
+            // Sync height and scroll properties
+            el.style.height = textareaRef.current.style.height || 'auto';
+            el.style.minHeight = textareaRef.current.style.minHeight || '80px';
+            el.scrollTop = textareaRef.current.scrollTop;
+            el.scrollLeft = textareaRef.current.scrollLeft;
+          }
+        }}
+        className="absolute inset-0 px-3 py-2 text-sm font-mono whitespace-pre-wrap break-words overflow-auto rounded-md text-foreground"
         style={{
           zIndex: 1,
           WebkitFontSmoothing: 'antialiased',
-          MozOsxFontSmoothing: 'grayscale'
+          MozOsxFontSmoothing: 'grayscale',
+          scrollbarWidth: 'none', // Hide scrollbar for Firefox
+          msOverflowStyle: 'none' // Hide scrollbar for IE/Edge
         }}
       >
+        <style jsx>{`
+          ::-webkit-scrollbar {
+            display: none; /* Hide scrollbar for Chrome, Safari, Opera */
+          }
+        `}</style>
         {highlighted.length > 0 ? (
           <div className="relative" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
             {highlighted.map((part, index) => (
@@ -308,14 +407,11 @@ export function PromptTextarea({
         onKeyDown={handleKeyDown}
         onClick={(e) => setCursorPosition(e.currentTarget.selectionStart)}
         onScroll={(e) => {
-          // Sync scroll between textarea and highlight
+          // Sync scroll between textarea and highlight overlay
           const highlightDiv = e.currentTarget.previousElementSibling as HTMLElement;
           if (highlightDiv) {
-            const highlightContent = highlightDiv.querySelector('div');
-            if (highlightContent) {
-              highlightContent.scrollTop = e.currentTarget.scrollTop;
-              highlightContent.scrollLeft = e.currentTarget.scrollLeft;
-            }
+            highlightDiv.scrollTop = e.currentTarget.scrollTop;
+            highlightDiv.scrollLeft = e.currentTarget.scrollLeft;
           }
         }}
         placeholder={placeholder}
@@ -324,6 +420,7 @@ export function PromptTextarea({
           'prompt-textarea-input flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono resize-none relative',
           className
         )}
+        spellCheck={false}
         style={{
           background: 'transparent',
           color: 'transparent',

+ 5 - 5
webui/contexts/model-selection-context.tsx

@@ -7,7 +7,7 @@ import { AutoModelSelector } from '@/lib/services/auto-model-selector';
 // Types for the context
 interface ModelSelectionState {
   selectedCheckpoint: string | null;
-  selectedModels: Record<string, string>; // modelType -> modelName
+  selectedModels: Record<string, string | undefined>; // modelType -> modelName
   autoSelectedModels: Record<string, string>; // modelType -> modelName
   userOverrides: Record<string, string>; // modelType -> modelName (user manual selections)
   autoSelectionState: AutoSelectionState | null;
@@ -21,7 +21,7 @@ interface ModelSelectionState {
 type ModelSelectionAction =
   | { type: 'SET_MODELS'; payload: ModelInfo[] }
   | { type: 'SET_SELECTED_CHECKPOINT'; payload: string | null }
-  | { type: 'SET_SELECTED_MODEL'; payload: { type: string; name: string } }
+  | { type: 'SET_SELECTED_MODEL'; payload: { type: string; name: string | undefined } }
   | { type: 'SET_USER_OVERRIDE'; payload: { type: string; name: string } }
   | { type: 'CLEAR_USER_OVERRIDE'; payload: string }
   | { type: 'SET_AUTO_SELECTION_STATE'; payload: AutoSelectionState }
@@ -171,7 +171,7 @@ interface ModelSelectionContextType {
   actions: {
     setModels: (models: ModelInfo[]) => void;
     setSelectedCheckpoint: (checkpointName: string | null) => void;
-    setSelectedModel: (type: string, name: string) => void;
+    setSelectedModel: (type: string, name: string | undefined) => void;
     setUserOverride: (type: string, name: string) => void;
     clearUserOverride: (type: string) => void;
     performAutoSelection: (checkpointModel: ModelInfo) => Promise<void>;
@@ -212,7 +212,7 @@ export function ModelSelectionProvider({ children }: ModelSelectionProviderProps
       dispatch({ type: 'SET_SELECTED_CHECKPOINT', payload: checkpointName });
     },
 
-    setSelectedModel: (type: string, name: string) => {
+    setSelectedModel: (type: string, name: string | undefined) => {
       dispatch({ type: 'SET_SELECTED_MODEL', payload: { type, name } });
     },
 
@@ -331,7 +331,7 @@ export function useModelTypeSelection(modelType: string) {
     selectedModel,
     isUserOverride,
     isAutoSelected,
-    setSelectedModel: (name: string) => actions.setSelectedModel(modelType, name),
+    setSelectedModel: (name: string | undefined) => actions.setSelectedModel(modelType, name),
     setUserOverride: (name: string) => actions.setUserOverride(modelType, name),
     clearUserOverride: () => actions.clearUserOverride(modelType),
   };

+ 20 - 3
webui/lib/api.ts

@@ -172,12 +172,15 @@ export interface JobInfo {
   request_id?: string;
   status:
     | "pending"
+    | "loading"
     | "processing"
     | "completed"
     | "failed"
     | "cancelled"
     | "queued";
   progress?: number;
+  model_load_progress?: number;
+  generation_progress?: number;
   result?: {
     images: string[];
   };
@@ -522,9 +525,15 @@ class ApiClient {
       upscale_factor: number;
     }
   ): Promise<JobInfo> {
+    // Map 'model' to 'esrgan_model' as expected by the API
+    const apiParams = {
+      image: params.image,
+      esrgan_model: params.model,
+      upscale_factor: params.upscale_factor,
+    };
     return this.request<JobInfo>("/generate/upscale", {
       method: "POST",
-      body: JSON.stringify(params),
+      body: JSON.stringify(apiParams),
     });
   }
 
@@ -848,13 +857,21 @@ class ApiClient {
 
   async unloadModel(modelId: string): Promise<void> {
     // Clear model cache when unloading
-    cache.delete(`model_info_${modelId}`);
-
+    cache.delete(`models_*`);
     return this.request<void>(`/models/${modelId}/unload`, {
       method: "POST",
     });
   }
 
+  async computeModelHash(modelId: string): Promise<{request_id: string, status: string, message: string}> {
+    // Clear model cache when computing hash
+    cache.delete(`models_*`);
+    return this.request<{request_id: string, status: string, message: string}>(`/models/hash`, {
+      method: "POST",
+      body: JSON.stringify({ models: [modelId] }),
+    });
+  }
+
   async scanModels(): Promise<void> {
     // Clear all model caches when scanning
     cache.clear();

+ 1 - 1
webui/lib/services/auto-model-selector.ts

@@ -223,7 +223,7 @@ export class AutoModelSelector {
   }
 
   // Validate model selection
-  validateSelection(checkpointModel: ModelInfo, selectedModels: Record<string, string>): {
+  validateSelection(checkpointModel: ModelInfo, selectedModels: Record<string, string | undefined>): {
     isValid: boolean;
     missingRequired: string[];
     warnings: string[];

+ 13 - 0
webui/package-lock.json

@@ -74,6 +74,7 @@
       "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",
@@ -2205,6 +2206,7 @@
       "integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==",
       "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "csstype": "^3.0.2"
       }
@@ -2215,6 +2217,7 @@
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
       "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -2271,6 +2274,7 @@
       "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.46.4",
         "@typescript-eslint/types": "8.46.4",
@@ -2801,6 +2805,7 @@
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -3154,6 +3159,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.8.25",
         "caniuse-lite": "^1.0.30001754",
@@ -3788,6 +3794,7 @@
       "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.1",
@@ -3973,6 +3980,7 @@
       "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@rtsao/scc": "^1.1.0",
         "array-includes": "^3.1.9",
@@ -6340,6 +6348,7 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
       "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -6349,6 +6358,7 @@
       "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"
       },
@@ -7157,6 +7167,7 @@
       "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -7319,6 +7330,7 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "dev": true,
       "license": "Apache-2.0",
+      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -7646,6 +7658,7 @@
       "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác