Просмотр исходного кода

refactor: reorganize webui component structure for better maintainability

- Move components to organized subdirectories (auth, features, forms, layout)
- Create proper index files for clean imports
- Restructure lib directory with hooks, services, types, and utils
- Update component exports and imports for new structure
- Add inpainting canvas to features directory
- Reorganize layout components for better separation of concerns
Fszontagh 3 месяцев назад
Родитель
Сommit
d3633093fc

+ 21 - 20
webui/app/page.tsx

@@ -2,8 +2,7 @@
 
 import { useEffect, useState } from 'react';
 import Link from 'next/link';
-import { Header } from '@/components/header';
-import { AppLayout } from '@/components/layout';
+import { Header, AppLayout } from '@/components/layout';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
 import { Button } from '@/components/ui/button';
 import { apiClient } from '@/lib/api';
@@ -50,7 +49,7 @@ const features = [
 ];
 
 export default function HomePage() {
-  const { user, logout, isAuthenticated, isLoading } = useAuth();
+  const { user, logout, isAuthenticated, isLoading, authEnabled } = useAuth();
   const [health, setHealth] = useState<'checking' | 'healthy' | 'error'>('checking');
   const [systemInfo, setSystemInfo] = useState<{ version?: string; cuda_available?: boolean } | null>(null);
 
@@ -78,7 +77,7 @@ export default function HomePage() {
     checkHealth();
     loadSystemInfo();
 
-    // Set up periodic health checks
+    // Set up periodic health checks with proper cleanup
     const healthInterval = setInterval(checkHealth, 30000); // Check every 30 seconds
     const systemInfoInterval = setInterval(loadSystemInfo, 60000); // Check every minute
 
@@ -107,23 +106,25 @@ export default function HomePage() {
           title="Stable Diffusion REST"
           description="Modern web interface for AI image generation"
           actions={
-            <div className="flex items-center gap-4">
-              <div className="flex items-center gap-2 text-sm">
-                <User className="h-4 w-4" />
-                <span>{user?.username}</span>
-                <span className="text-muted-foreground">({user?.role})</span>
-              </div>
-              <Button variant="outline" size="sm" onClick={logout}>
-                <LogOut className="h-4 w-4 mr-2" />
-                Sign Out
-              </Button>
-              <Link href="/settings">
-                <Button variant="outline" size="sm">
-                  <Settings className="h-4 w-4 mr-2" />
-                  Settings
+            authEnabled && (
+              <div className="flex items-center gap-4">
+                <div className="flex items-center gap-2 text-sm">
+                  <User className="h-4 w-4" />
+                  <span>{user?.username}</span>
+                  <span className="text-muted-foreground">({user?.role})</span>
+                </div>
+                <Button variant="outline" size="sm" onClick={logout}>
+                  <LogOut className="h-4 w-4 mr-2" />
+                  Sign Out
                 </Button>
-              </Link>
-            </div>
+                <Link href="/settings">
+                  <Button variant="outline" size="sm">
+                    <Settings className="h-4 w-4 mr-2" />
+                    Settings
+                  </Button>
+                </Link>
+              </div>
+            )
           }
         />
       <div className="container mx-auto p-6">

+ 8 - 0
webui/components/auth/index.ts

@@ -0,0 +1,8 @@
+/**
+ * Authentication Components
+ */
+
+export { LoginForm } from './login-form';
+export { ApiKeyManager } from './api-key-manager';
+export { ProtectedRoute } from './protected-route';
+export { UserManagement } from './user-management';

+ 5 - 0
webui/components/features/image-generation/index.ts

@@ -0,0 +1,5 @@
+/**
+ * Image Generation Components
+ */
+
+export { InpaintingCanvas } from './inpainting-canvas';

+ 497 - 0
webui/components/features/image-generation/inpainting-canvas.tsx

@@ -0,0 +1,497 @@
+'use client';
+
+import { useRef, useEffect, useState, useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Upload, Download, Eraser, Brush, RotateCcw, Link as LinkIcon, Loader2 } from 'lucide-react';
+import { fileToBase64 } from '@/lib/utils';
+import { validateImageUrlWithBase64 } from '@/lib/image-validation';
+import { apiClient } from '@/lib/api';
+
+interface InpaintingCanvasProps {
+  onSourceImageChange: (image: string) => void;
+  onMaskImageChange: (image: string) => void;
+  className?: string;
+  targetWidth?: number;
+  targetHeight?: number;
+}
+
+export function InpaintingCanvas({
+  onSourceImageChange,
+  onMaskImageChange,
+  className,
+  targetWidth,
+  targetHeight
+}: InpaintingCanvasProps) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const maskCanvasRef = useRef<HTMLCanvasElement>(null); // Keep for mask generation
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const [sourceImage, setSourceImage] = useState<string | null>(null);
+  const [originalSourceImage, setOriginalSourceImage] = useState<string | null>(null);
+  const [isDrawing, setIsDrawing] = useState(false);
+  const [brushSize, setBrushSize] = useState(20);
+  const [isEraser, setIsEraser] = useState(false);
+  const [canvasSize, setCanvasSize] = useState({ width: 512, height: 512 });
+  const [inputMode, setInputMode] = useState<'file' | 'url'>('file');
+  const [urlInput, setUrlInput] = useState('');
+  const [isLoadingUrl, setIsLoadingUrl] = useState(false);
+  const [urlError, setUrlError] = useState<string | null>(null);
+  const [isResizing, setIsResizing] = useState(false);
+
+  // Initialize canvases
+  useEffect(() => {
+    const canvas = canvasRef.current;
+    const maskCanvas = maskCanvasRef.current;
+
+    if (!canvas || !maskCanvas) return;
+
+    const ctx = canvas.getContext('2d');
+    const maskCtx = maskCanvas.getContext('2d');
+
+    if (!ctx || !maskCtx) return;
+
+    // Set canvas size
+    canvas.width = canvasSize.width;
+    canvas.height = canvasSize.height;
+    maskCanvas.width = canvasSize.width;
+    maskCanvas.height = canvasSize.height;
+
+    // Initialize mask canvas with black (no inpainting)
+    maskCtx.fillStyle = 'black';
+    maskCtx.fillRect(0, 0, canvasSize.width, canvasSize.height);
+
+    // Update mask image
+    updateMaskImage();
+  }, [canvasSize]);
+
+  const updateMaskImage = useCallback(() => {
+    const maskCanvas = maskCanvasRef.current;
+    if (!maskCanvas) return;
+
+    const maskDataUrl = maskCanvas.toDataURL();
+    onMaskImageChange(maskDataUrl);
+  }, [onMaskImageChange]);
+
+  const loadImageToCanvas = useCallback((base64Image: string) => {
+    // Store original image for resizing
+    setOriginalSourceImage(base64Image);
+    setSourceImage(base64Image);
+    onSourceImageChange(base64Image);
+
+    // Load image to get dimensions and update canvas size
+    const img = new Image();
+    img.onload = () => {
+      // Use target dimensions if provided, otherwise fit within 512x512
+      let width: number;
+      let height: number;
+
+      if (targetWidth && targetHeight) {
+        width = targetWidth;
+        height = targetHeight;
+      } else {
+        // Calculate scaled dimensions to fit within 512x512 while maintaining aspect ratio
+        const maxSize = 512;
+        width = img.width;
+        height = img.height;
+
+        if (width > maxSize || height > maxSize) {
+          const aspectRatio = width / height;
+          if (width > height) {
+            width = maxSize;
+            height = maxSize / aspectRatio;
+          } else {
+            height = maxSize;
+            width = maxSize * aspectRatio;
+          }
+        }
+      }
+
+      const newCanvasSize = { width: Math.round(width), height: Math.round(height) };
+      setCanvasSize(newCanvasSize);
+
+      // Draw image on main canvas
+      const canvas = canvasRef.current;
+      if (!canvas) return;
+
+      const ctx = canvas.getContext('2d');
+      if (!ctx) return;
+
+      canvas.width = width;
+      canvas.height = height;
+      ctx.drawImage(img, 0, 0, width, height);
+
+      // Update mask canvas size
+      const maskCanvas = maskCanvasRef.current;
+      if (!maskCanvas) return;
+
+      const maskCtx = maskCanvas.getContext('2d');
+      if (!maskCtx) return;
+
+      maskCanvas.width = width;
+      maskCanvas.height = height;
+      maskCtx.fillStyle = 'black';
+      maskCtx.fillRect(0, 0, width, height);
+
+      updateMaskImage();
+    };
+    img.src = base64Image;
+  }, [onSourceImageChange, updateMaskImage, targetWidth, targetHeight]);
+
+  // Auto-resize image when target dimensions change
+  useEffect(() => {
+    const resizeImage = async () => {
+      if (!originalSourceImage || !targetWidth || !targetHeight) {
+        return;
+      }
+
+      // Don't resize if we're already resizing
+      if (isResizing) {
+        return;
+      }
+
+      try {
+        setIsResizing(true);
+        const result = await apiClient.resizeImage(originalSourceImage, targetWidth, targetHeight);
+        loadImageToCanvas(result.image);
+      } catch (err) {
+        console.error('Failed to resize image:', err);
+      } finally {
+        setIsResizing(false);
+      }
+    };
+
+    resizeImage();
+  }, [targetWidth, targetHeight, originalSourceImage]);
+
+  const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    try {
+      const base64 = await fileToBase64(file);
+      loadImageToCanvas(base64);
+    } catch (err) {
+      console.error('Failed to load image:', err);
+    }
+  };
+
+  const handleUrlLoad = async () => {
+    if (!urlInput.trim()) {
+      setUrlError('Please enter a URL');
+      return;
+    }
+
+    setIsLoadingUrl(true);
+    setUrlError(null);
+
+    try {
+      const result = await validateImageUrlWithBase64(urlInput);
+
+      if (!result.isValid) {
+        setUrlError(result.error || 'Failed to load image from URL');
+        setIsLoadingUrl(false);
+        return;
+      }
+
+      // Use base64 data if available, otherwise use the URL directly
+      const imageData = result.base64Data || urlInput;
+      loadImageToCanvas(imageData);
+      setIsLoadingUrl(false);
+    } catch (err) {
+      setUrlError(err instanceof Error ? err.message : 'Failed to load image from URL');
+      setIsLoadingUrl(false);
+    }
+  };
+
+  const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!sourceImage) return;
+
+    setIsDrawing(true);
+    draw(e);
+  };
+
+  const stopDrawing = () => {
+    setIsDrawing(false);
+  };
+
+  const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isDrawing || !sourceImage) return;
+
+    const canvas = canvasRef.current;
+    const maskCanvas = maskCanvasRef.current;
+    if (!canvas || !maskCanvas) return;
+
+    const ctx = canvas.getContext('2d');
+    const maskCtx = maskCanvas.getContext('2d');
+    if (!ctx || !maskCtx) return;
+
+    const rect = canvas.getBoundingClientRect();
+    const scaleX = canvas.width / rect.width;
+    const scaleY = canvas.height / rect.height;
+
+    const x = (e.clientX - rect.left) * scaleX;
+    const y = (e.clientY - rect.top) * scaleY;
+
+    // Draw on mask canvas (for API)
+    maskCtx.globalCompositeOperation = 'source-over';
+    maskCtx.fillStyle = isEraser ? 'black' : 'white';
+    maskCtx.beginPath();
+    maskCtx.arc(x, y, brushSize, 0, Math.PI * 2);
+    maskCtx.fill();
+
+    // Draw visual overlay directly on main canvas
+    ctx.save();
+    ctx.globalCompositeOperation = 'source-over';
+
+    if (isEraser) {
+      // For eraser, just redraw the image at that position
+      const img = new Image();
+      img.onload = () => {
+        // Clear the area and redraw
+        ctx.save();
+        ctx.globalCompositeOperation = 'destination-out';
+        ctx.beginPath();
+        ctx.arc(x, y, brushSize, 0, Math.PI * 2);
+        ctx.fill();
+        ctx.restore();
+
+        // Redraw the image in the cleared area
+        ctx.save();
+        ctx.globalCompositeOperation = 'destination-over';
+        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+        ctx.restore();
+
+        updateMaskImage();
+      };
+      img.src = sourceImage;
+    } else {
+      // For brush, draw a visible overlay
+      ctx.globalAlpha = 0.6;
+      ctx.fillStyle = 'rgba(255, 105, 180, 0.8)'; // Bright pink for visibility
+      ctx.beginPath();
+      ctx.arc(x, y, brushSize, 0, Math.PI * 2);
+      ctx.fill();
+
+      // Also draw a border for better visibility
+      ctx.globalAlpha = 1.0;
+      ctx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; // Red border
+      ctx.lineWidth = 2;
+      ctx.beginPath();
+      ctx.arc(x, y, brushSize, 0, Math.PI * 2);
+      ctx.stroke();
+
+      ctx.restore();
+      updateMaskImage();
+    }
+  };
+
+  const clearMask = () => {
+    const canvas = canvasRef.current;
+    const maskCanvas = maskCanvasRef.current;
+    if (!canvas || !maskCanvas) return;
+
+    const ctx = canvas.getContext('2d');
+    const maskCtx = maskCanvas.getContext('2d');
+    if (!ctx || !maskCtx) return;
+
+    // Clear mask canvas
+    maskCtx.fillStyle = 'black';
+    maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
+
+    // Redraw source image on main canvas
+    if (sourceImage) {
+      const img = new Image();
+      img.onload = () => {
+        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+      };
+      img.src = sourceImage;
+    }
+
+    updateMaskImage();
+  };
+
+  const downloadMask = () => {
+    const canvas = maskCanvasRef.current;
+    if (!canvas) return;
+
+    const link = document.createElement('a');
+    link.download = 'inpainting-mask.png';
+    link.href = canvas.toDataURL();
+    link.click();
+  };
+
+  return (
+    <div className={`space-y-4 ${className}`}>
+      <Card>
+        <CardContent className="pt-6">
+          <div className="space-y-4">
+            <div className="space-y-2">
+              <Label>Source Image</Label>
+              <Tabs value={inputMode} onValueChange={(value) => setInputMode(value as 'file' | 'url')}>
+                <TabsList className="grid w-full grid-cols-2">
+                  <TabsTrigger value="file">
+                    <Upload className="w-4 h-4 mr-2" />
+                    Upload File
+                  </TabsTrigger>
+                  <TabsTrigger value="url">
+                    <LinkIcon className="w-4 h-4 mr-2" />
+                    From URL
+                  </TabsTrigger>
+                </TabsList>
+
+                <TabsContent value="file" className="space-y-4 mt-4">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={() => fileInputRef.current?.click()}
+                    className="w-full"
+                  >
+                    <Upload className="h-4 w-4 mr-2" />
+                    {sourceImage ? 'Change Image' : 'Upload Image'}
+                  </Button>
+                  <input
+                    ref={fileInputRef}
+                    type="file"
+                    accept="image/*"
+                    onChange={handleImageUpload}
+                    className="hidden"
+                  />
+                </TabsContent>
+
+                <TabsContent value="url" className="space-y-4 mt-4">
+                  <div className="space-y-2">
+                    <Input
+                      type="url"
+                      value={urlInput}
+                      onChange={(e) => {
+                        setUrlInput(e.target.value);
+                        setUrlError(null);
+                      }}
+                      placeholder="https://example.com/image.png"
+                      disabled={isLoadingUrl}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Enter a URL that ends with an image extension (.jpg, .png, .gif, etc.)
+                    </p>
+                  </div>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={handleUrlLoad}
+                    disabled={isLoadingUrl || !urlInput.trim()}
+                    className="w-full"
+                  >
+                    {isLoadingUrl ? (
+                      <>
+                        <Download className="h-4 w-4 mr-2 animate-spin" />
+                        Loading...
+                      </>
+                    ) : (
+                      <>
+                        <Download className="h-4 w-4 mr-2" />
+                        Load from URL
+                      </>
+                    )}
+                  </Button>
+                  {urlError && (
+                    <p className="text-sm text-destructive">{urlError}</p>
+                  )}
+                </TabsContent>
+              </Tabs>
+            </div>
+
+            {isResizing && (
+              <div className="text-sm text-muted-foreground flex items-center gap-2">
+                <Loader2 className="h-4 w-4 animate-spin" />
+                Resizing image...
+              </div>
+            )}
+
+            {sourceImage && (
+              <>
+                <div className="space-y-2">
+                  <Label>Mask Editor</Label>
+                  <div className="relative">
+                    <canvas
+                      ref={canvasRef}
+                      className="rounded-lg border border-border cursor-crosshair"
+                      style={{ maxWidth: '512px', height: 'auto' }}
+                      onMouseDown={startDrawing}
+                      onMouseUp={stopDrawing}
+                      onMouseMove={draw}
+                      onMouseLeave={stopDrawing}
+                    />
+                  </div>
+                  <p className="text-xs text-muted-foreground">
+                    Draw on the image to mark areas for inpainting. White areas will be inpainted, black areas will be preserved.
+                  </p>
+                </div>
+
+                <div className="space-y-2">
+                  <Label htmlFor="brush_size">
+                    Brush Size: {brushSize}px
+                  </Label>
+                  <input
+                    id="brush_size"
+                    type="range"
+                    value={brushSize}
+                    onChange={(e) => setBrushSize(Number(e.target.value))}
+                    min={1}
+                    max={100}
+                    className="w-full"
+                  />
+                </div>
+
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant={isEraser ? "default" : "outline"}
+                    onClick={() => setIsEraser(true)}
+                    className="flex-1"
+                  >
+                    <Eraser className="h-4 w-4 mr-2" />
+                    Eraser
+                  </Button>
+                  <Button
+                    type="button"
+                    variant={!isEraser ? "default" : "outline"}
+                    onClick={() => setIsEraser(false)}
+                    className="flex-1"
+                  >
+                    <Brush className="h-4 w-4 mr-2" />
+                    Brush
+                  </Button>
+                </div>
+
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={clearMask}
+                    className="flex-1"
+                  >
+                    <RotateCcw className="h-4 w-4 mr-2" />
+                    Clear Mask
+                  </Button>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={downloadMask}
+                    className="flex-1"
+                  >
+                    <Download className="h-4 w-4 mr-2" />
+                    Download Mask
+                  </Button>
+                </div>
+              </>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

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

@@ -0,0 +1,12 @@
+/**
+ * Feature Components - Business logic organized by feature
+ */
+
+// Image Generation Components
+export * from './image-generation';
+
+// Model Components  
+export * from './models';
+
+// Queue Components
+export * from './queue';

+ 5 - 0
webui/components/forms/index.ts

@@ -0,0 +1,5 @@
+/**
+ * Form Components - Input and form-related components
+ */
+
+export { PromptTextarea } from './prompt-textarea';

+ 352 - 0
webui/components/forms/prompt-textarea.tsx

@@ -0,0 +1,352 @@
+'use client';
+
+import React, { useState, useRef, useEffect } from 'react';
+import { cn } from '@/lib/utils';
+
+interface PromptTextareaProps {
+  value: string;
+  onChange: (value: string) => void;
+  placeholder?: string;
+  className?: string;
+  rows?: number;
+  loras?: string[];
+  embeddings?: string[];
+}
+
+interface Suggestion {
+  text: string;
+  type: 'lora' | 'embedding';
+  displayText: string;
+}
+
+export function PromptTextarea({
+  value,
+  onChange,
+  placeholder,
+  className,
+  rows = 3,
+  loras = [],
+  embeddings = [],
+}: PromptTextareaProps) {
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+  const [highlighted, setHighlighted] = useState<React.ReactNode[]>([]);
+  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
+  const [showSuggestions, setShowSuggestions] = useState(false);
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const [cursorPosition, setCursorPosition] = useState(0);
+  const suggestionsRef = useRef<HTMLDivElement>(null);
+  const justInsertedRef = useRef(false);
+
+  useEffect(() => {
+    highlightSyntax(value);
+
+    // Skip suggestions if we just inserted one
+    if (justInsertedRef.current) {
+      justInsertedRef.current = false;
+      return;
+    }
+
+    updateSuggestions(value, cursorPosition);
+  }, [value, loras, embeddings, cursorPosition]);
+
+  const updateSuggestions = (text: string, position: number) => {
+    // Get text before cursor
+    const textBeforeCursor = text.substring(0, position);
+    const textAfterCursor = text.substring(position);
+
+    // Check if we're typing a LoRA name (but not in the weight part)
+    // Match: <lora:name| but NOT <lora:name:weight|
+    const loraMatch = textBeforeCursor.match(/<lora:([^:>]*)$/);
+    if (loraMatch) {
+      const searchTerm = loraMatch[1].toLowerCase();
+      const filtered = loras
+        .filter(lora => {
+          const loraBase = lora.replace(/\.(safetensors|ckpt|pt)$/i, '');
+          return loraBase.toLowerCase().includes(searchTerm);
+        })
+        .slice(0, 10)
+        .map(lora => ({
+          text: lora,
+          type: 'lora' as const,
+          displayText: lora.replace(/\.(safetensors|ckpt|pt)$/i, ''),
+        }));
+
+      if (filtered.length > 0) {
+        setSuggestions(filtered);
+        setShowSuggestions(true);
+        setSelectedIndex(0);
+        return;
+      }
+    }
+
+    // Check if we're typing an embedding (word boundary)
+    const words = textBeforeCursor.split(/\s+/);
+    const currentWord = words[words.length - 1];
+
+    // Only show embedding suggestions if we've typed at least 2 characters
+    // and we're not inside a lora tag
+    if (currentWord.length >= 2 && !textBeforeCursor.match(/<lora:[^>]*$/)) {
+      const searchTerm = currentWord.toLowerCase();
+      const filtered = embeddings
+        .filter(emb => {
+          const embBase = emb.replace(/\.(safetensors|pt)$/i, '');
+          return embBase.toLowerCase().startsWith(searchTerm);
+        })
+        .slice(0, 10)
+        .map(emb => ({
+          text: emb,
+          type: 'embedding' as const,
+          displayText: emb.replace(/\.(safetensors|pt)$/i, ''),
+        }));
+
+      if (filtered.length > 0) {
+        setSuggestions(filtered);
+        setShowSuggestions(true);
+        setSelectedIndex(0);
+        return;
+      }
+    }
+
+    setShowSuggestions(false);
+  };
+
+  const insertSuggestion = (suggestion: Suggestion) => {
+    if (!textareaRef.current) return;
+
+    const position = textareaRef.current.selectionStart;
+    const textBefore = value.substring(0, position);
+    let textAfter = value.substring(position);
+
+    let newText = '';
+    let newPosition = position;
+
+    if (suggestion.type === 'lora') {
+      // Find the <lora: part
+      const loraMatch = textBefore.match(/<lora:([^:>]*)$/);
+      if (loraMatch) {
+        const beforeLora = textBefore.substring(0, textBefore.length - loraMatch[0].length);
+
+        // Check if we're editing an existing tag
+        // Remove everything until the closing > (rest of name, weight, closing bracket)
+        const afterLoraMatch = textAfter.match(/^[^<>]*>/);
+        if (afterLoraMatch) {
+          // Remove the old tag remainder
+          textAfter = textAfter.substring(afterLoraMatch[0].length);
+        }
+
+        const loraText = `<lora:${suggestion.displayText}:0.8>`;
+        newText = beforeLora + loraText + textAfter;
+        newPosition = beforeLora.length + loraText.length;
+      }
+    } else {
+      // Embedding - replace current word
+      const words = textBefore.split(/\s+/);
+      const currentWord = words[words.length - 1];
+      const beforeWord = textBefore.substring(0, textBefore.length - currentWord.length);
+      newText = beforeWord + suggestion.displayText + textAfter;
+      newPosition = beforeWord.length + suggestion.displayText.length;
+    }
+
+    // Mark that we just inserted a suggestion to prevent retriggering
+    justInsertedRef.current = true;
+
+    onChange(newText);
+    setShowSuggestions(false);
+
+    // Restore cursor position
+    setTimeout(() => {
+      if (textareaRef.current) {
+        textareaRef.current.selectionStart = newPosition;
+        textareaRef.current.selectionEnd = newPosition;
+        textareaRef.current.focus();
+      }
+    }, 0);
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (!showSuggestions) return;
+
+    if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      setSelectedIndex(prev => Math.max(prev - 1, 0));
+    } else if (e.key === 'Enter' || e.key === 'Tab') {
+      if (suggestions.length > 0) {
+        e.preventDefault();
+        insertSuggestion(suggestions[selectedIndex]);
+      }
+    } else if (e.key === 'Escape') {
+      setShowSuggestions(false);
+    }
+  };
+
+  const highlightSyntax = (text: string) => {
+    if (!text) {
+      setHighlighted([]);
+      return;
+    }
+
+    const parts: React.ReactNode[] = [];
+    let lastIndex = 0;
+
+    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 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);
+      matches.push({
+        start: match.index,
+        end: match.index + match[0].length,
+        type: 'lora',
+        text: match[0],
+        valid: isValid,
+      });
+    }
+
+    embeddings.forEach(embedding => {
+      const embeddingBase = embedding.replace(/\.(safetensors|pt)$/i, '');
+      const embeddingRegex = new RegExp(`\\b${escapeRegex(embeddingBase)}\\b`, 'g');
+      while ((match = embeddingRegex.exec(text)) !== null) {
+        const isInsideLora = matches.some(
+          m => m.type === 'lora' && match!.index >= m.start && match!.index < m.end
+        );
+        if (!isInsideLora) {
+          matches.push({
+            start: match.index,
+            end: match.index + match[0].length,
+            type: 'embedding',
+            text: match[0],
+            valid: true,
+          });
+        }
+      }
+    });
+
+    matches.sort((a, b) => a.start - b.start);
+
+    matches.forEach((match, index) => {
+      if (match.start > lastIndex) {
+        parts.push(
+          <span key={`text-${lastIndex}`}>
+            {text.substring(lastIndex, match.start)}
+          </span>
+        );
+      }
+
+      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';
+
+      parts.push(
+        <span key={`highlight-${match.start}`} className={highlightClass} title={match.type === 'lora' ? (match.valid ? 'LoRA' : 'LoRA not found') : 'Embedding'}>
+          {match.text}
+        </span>
+      );
+
+      lastIndex = match.end;
+    });
+
+    if (lastIndex < text.length) {
+      parts.push(
+        <span key={`text-${lastIndex}`}>
+          {text.substring(lastIndex)}
+        </span>
+      );
+    }
+
+    setHighlighted(parts);
+  };
+
+  const escapeRegex = (str: string) => {
+    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  };
+
+  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"
+        style={{
+          zIndex: 1,
+          WebkitFontSmoothing: 'antialiased',
+          MozOsxFontSmoothing: 'grayscale'
+        }}
+      >
+        {highlighted.length > 0 ? highlighted : value}
+      </div>
+      <textarea
+        ref={textareaRef}
+        value={value}
+        onChange={(e) => {
+          onChange(e.target.value);
+          setCursorPosition(e.target.selectionStart);
+        }}
+        onKeyDown={handleKeyDown}
+        onClick={(e) => setCursorPosition(e.currentTarget.selectionStart)}
+        placeholder={placeholder}
+        rows={rows}
+        className={cn(
+          '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
+        )}
+        style={{
+          background: 'transparent',
+          color: 'transparent',
+          WebkitTextFillColor: 'transparent',
+          caretColor: 'hsl(var(--foreground))',
+          zIndex: 2,
+          textShadow: 'none'
+        } as React.CSSProperties & {
+          caretColor: string;
+        }}
+      />
+
+      {/* Autocomplete Suggestions */}
+      {showSuggestions && suggestions.length > 0 && (
+        <div
+          ref={suggestionsRef}
+          className="absolute mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-popover shadow-lg z-30 pointer-events-auto"
+        >
+          {suggestions.map((suggestion, index) => (
+            <div
+              key={`${suggestion.type}-${suggestion.text}`}
+              className={cn(
+                'px-3 py-2 cursor-pointer text-sm flex items-center justify-between',
+                index === selectedIndex
+                  ? 'bg-accent text-accent-foreground'
+                  : 'hover:bg-accent/50'
+              )}
+              onClick={() => insertSuggestion(suggestion)}
+              onMouseEnter={() => setSelectedIndex(index)}
+            >
+              <span className="font-mono">{suggestion.displayText}</span>
+              <span
+                className={cn(
+                  'text-xs px-2 py-0.5 rounded',
+                  suggestion.type === 'lora'
+                    ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300'
+                    : 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
+                )}
+              >
+                {suggestion.type}
+              </span>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 25 - 0
webui/components/index.ts

@@ -0,0 +1,25 @@
+/**
+ * Components - Well-organized React components
+ *
+ * Structure:
+ * - ui/         - Reusable UI primitives (shadcn/ui style)
+ * - features/   - Feature-specific components
+ * - layout/     - Layout and navigation components
+ * - forms/      - Form components
+ * - auth/       - Authentication components
+ */
+
+// UI Components - Reusable primitives
+export * from './ui';
+
+// Feature Components - Business logic components
+export * from './features';
+
+// Layout Components
+export * from './layout';
+
+// Form Components
+export * from './forms';
+
+// Auth Components
+export * from './auth';

+ 26 - 0
webui/components/layout/header.tsx

@@ -0,0 +1,26 @@
+'use client';
+
+import { ThemeToggle } from './theme-toggle';
+
+interface HeaderProps {
+  title: string;
+  description?: string;
+  actions?: React.ReactNode;
+}
+
+export function Header({ title, description, actions }: HeaderProps) {
+  return (
+    <header className="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-border bg-background px-6">
+      <div className="flex-1">
+        <h1 className="text-2xl font-semibold">{title}</h1>
+        {description && (
+          <p className="text-sm text-muted-foreground">{description}</p>
+        )}
+      </div>
+      <div className="flex items-center gap-4">
+        {actions}
+        <ThemeToggle />
+      </div>
+    </header>
+  );
+}

+ 7 - 6
webui/components/layout/index.ts

@@ -1,11 +1,12 @@
 /**
- * Layout Module - Modern component-based layout system
- *
- * Exports:
- * - AppLayout: Main grid-based layout component
- * - LayoutProvider: Context provider for layout state
- * - useLayout: Hook for accessing layout context
+ * Layout Components - Navigation and layout structure
  */
 
 export { AppLayout } from './app-layout';
 export { LayoutProvider, useLayout } from './layout-context';
+export { Header } from './header';
+export { Sidebar } from './sidebar';
+export { MainLayout } from './main-layout';
+export { ThemeProvider } from './theme-provider';
+export { ThemeToggle } from './theme-toggle';
+export { VersionChecker } from './version-checker';

+ 19 - 0
webui/components/layout/main-layout.tsx

@@ -0,0 +1,19 @@
+import { ReactNode } from 'react';
+import { Sidebar } from './sidebar';
+import { ModelStatusBar } from '../features/models';
+
+interface MainLayoutProps {
+  children: ReactNode;
+}
+
+export function MainLayout({ children }: MainLayoutProps) {
+  return (
+    <div className="min-h-screen">
+      <Sidebar />
+      <main className="ml-64 pb-12 overflow-x-hidden" style={{ width: 'calc(100% - 16rem)', pointerEvents: 'auto' }}>
+        {children}
+      </main>
+      <ModelStatusBar />
+    </div>
+  );
+}

+ 76 - 0
webui/components/layout/sidebar.tsx

@@ -0,0 +1,76 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { Image, ImagePlus, Sparkles, Settings, Activity, Edit3 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Sidebar - Navigation component
+ *
+ * Modern architecture:
+ * - Simple, focused component with single responsibility
+ * - No complex positioning logic - handled by parent grid layout
+ * - TypeScript interfaces for navigation items
+ * - Proper semantic HTML
+ */
+
+interface NavigationItem {
+  name: string;
+  href: string;
+  icon: React.ComponentType<{ className?: string }>;
+}
+
+const navigation: NavigationItem[] = [
+  { name: 'Text to Image', href: '/text2img', icon: ImagePlus },
+  { name: 'Image to Image', href: '/img2img', icon: Image },
+  { name: 'Inpainting', href: '/inpainting', icon: Edit3 },
+  { name: 'Upscaler', href: '/upscaler', icon: Sparkles },
+  { name: 'Models', href: '/models', icon: Settings },
+  { name: 'Queue', href: '/queue', icon: Activity },
+];
+
+export function Sidebar() {
+  const pathname = usePathname();
+
+  return (
+    <div className="flex h-full flex-col gap-2">
+      {/* Logo */}
+      <div className="flex h-16 items-center border-b border-border px-6">
+        <Link href="/" className="flex items-center gap-2 font-semibold">
+          <ImagePlus className="h-6 w-6" />
+          <span>SD REST UI</span>
+        </Link>
+      </div>
+
+      {/* Navigation */}
+      <nav className="flex-1 space-y-1 px-3 py-4">
+        {navigation.map((item) => {
+          const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
+          return (
+            <Link
+              key={item.name}
+              href={item.href}
+              className={cn(
+                'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
+                isActive
+                  ? 'bg-primary text-primary-foreground'
+                  : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
+              )}
+            >
+              <item.icon className="h-5 w-5" />
+              {item.name}
+            </Link>
+          );
+        })}
+      </nav>
+
+      {/* Footer */}
+      <div className="border-t border-border p-4">
+        <p className="text-xs text-muted-foreground text-center">
+          Stable Diffusion REST API
+        </p>
+      </div>
+    </div>
+  );
+}

+ 11 - 0
webui/components/layout/theme-provider.tsx

@@ -0,0 +1,11 @@
+'use client';
+
+import * as React from 'react';
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+
+export function ThemeProvider({
+  children,
+  ...props
+}: React.ComponentProps<typeof NextThemesProvider>) {
+  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
+}

+ 34 - 0
webui/components/layout/theme-toggle.tsx

@@ -0,0 +1,34 @@
+'use client';
+
+import * as React from 'react';
+import { Moon, Sun } from 'lucide-react';
+import { useTheme } from 'next-themes';
+import { cn } from '@/lib/utils';
+
+export function ThemeToggle() {
+  const { theme, setTheme } = useTheme();
+  const [mounted, setMounted] = React.useState(false);
+
+  React.useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  if (!mounted) {
+    return null;
+  }
+
+  return (
+    <button
+      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
+      className={cn(
+        'relative inline-flex h-10 w-10 items-center justify-center rounded-lg',
+        'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+        'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
+      )}
+      aria-label="Toggle theme"
+    >
+      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
+      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
+    </button>
+  );
+}

+ 19 - 0
webui/components/ui/index.ts

@@ -0,0 +1,19 @@
+/**
+ * UI Components - Reusable UI primitives
+ */
+
+// Core UI components
+export { Button } from './button';
+export { Input } from './input';
+export { Label } from './label';
+export { Textarea } from './textarea';
+export { Select } from './select';
+export { Card, CardContent, CardHeader, CardTitle, CardDescription } from './card';
+export { Badge } from './badge';
+export { Alert } from './alert';
+export { Progress } from './progress';
+export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs';
+
+// Image input components
+export { ImageInput, type ImageInputProps } from './image-input';
+export { type ImageInputState, type ImageValidationResult } from './image-input.types';

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

@@ -2,7 +2,7 @@
 
 import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
 import { ModelInfo, AutoSelectionState } from '@/lib/api';
-import { AutoModelSelector } from '@/lib/auto-model-selector';
+import { AutoModelSelector } from '@/lib/services/auto-model-selector';
 
 // Types for the context
 interface ModelSelectionState {

+ 86 - 0
webui/lib/hooks/hooks.ts

@@ -0,0 +1,86 @@
+import { useState, useEffect, useCallback } from 'react';
+
+/**
+ * Custom hook for persisting form state to localStorage
+ * @param key - Unique key for localStorage
+ * @param initialValue - Initial value for the state
+ * @returns [state, setState, clearState] tuple
+ */
+export function useLocalStorage<T>(
+  key: string,
+  initialValue: T
+): [T, (value: T | ((prevValue: T) => T)) => void, () => void] {
+  // Always start with initialValue to prevent hydration mismatch
+  // Load from localStorage only after client-side hydration
+  const [storedValue, setStoredValue] = useState<T>(initialValue);
+
+  // Load value from localStorage after component mounts (client-side only)
+  useEffect(() => {
+    try {
+      const item = window.localStorage.getItem(key);
+      if (item) {
+        setStoredValue(JSON.parse(item));
+      }
+    } catch (error) {
+      console.warn(`Error loading localStorage key "${key}":`, error);
+    }
+  }, [key]);
+
+  // Return a wrapped version of useState's setter function that ...
+  // ... persists the new value to localStorage.
+  const setValue = useCallback(
+    (value: T | ((prevValue: T) => T)) => {
+      try {
+        // Allow value to be a function so we have same API as useState
+        const valueToStore =
+          value instanceof Function ? value(storedValue) : value;
+        // Save state
+        setStoredValue(valueToStore);
+        // Save to local storage
+        if (typeof window !== 'undefined') {
+          window.localStorage.setItem(key, JSON.stringify(valueToStore));
+        }
+      } catch (error) {
+        // A more advanced implementation would handle the error case
+        console.error(`Error saving localStorage key "${key}":`, error);
+      }
+    },
+    [key, storedValue]
+  );
+
+  // Function to clear the stored value
+  const clearValue = useCallback(() => {
+    try {
+      setStoredValue(initialValue);
+      if (typeof window !== 'undefined') {
+        window.localStorage.removeItem(key);
+      }
+    } catch (error) {
+      console.error(`Error clearing localStorage key "${key}":`, error);
+    }
+  }, [key, initialValue]);
+
+  return [storedValue, setValue, clearValue];
+}
+
+/**
+ * Hook for auto-saving form state with debouncing
+ * @param key - Unique key for localStorage
+ * @param value - Current form value
+ * @param delay - Debounce delay in milliseconds (default: 500ms)
+ */
+export function useAutoSave<T>(key: string, value: T, delay = 500) {
+  useEffect(() => {
+    const timeoutId = setTimeout(() => {
+      if (typeof window !== 'undefined') {
+        try {
+          window.localStorage.setItem(key, JSON.stringify(value));
+        } catch (error) {
+          console.error(`Error auto-saving localStorage key "${key}":`, error);
+        }
+      }
+    }, delay);
+
+    return () => clearTimeout(timeoutId);
+  }, [key, value, delay]);
+}

+ 8 - 0
webui/lib/hooks/index.ts

@@ -0,0 +1,8 @@
+/**
+ * Custom React Hooks
+ *
+ * Collection of reusable hooks for form state management,
+ * localStorage integration, and other common patterns.
+ */
+
+export { useLocalStorage, useAutoSave } from './hooks';

+ 23 - 0
webui/lib/index.ts

@@ -0,0 +1,23 @@
+/**
+ * Library - Core utilities, hooks, and services
+ *
+ * Structure:
+ * - hooks/     - Custom React hooks
+ * - types/     - TypeScript type definitions  
+ * - services/  - API and data services
+ * - utils/     - Utility functions
+ */
+
+// API and Services
+export * from './api';
+
+// Hooks
+export * from './hooks';
+
+// Types
+export * from './types';
+
+// Utilities
+export * from './utils';
+export * from './image-validation';
+export * from './services/auto-model-selector';

+ 247 - 0
webui/lib/services/auto-model-selector.ts

@@ -0,0 +1,247 @@
+import { ModelInfo, RequiredModelInfo, RecommendedModelInfo, AutoSelectionState } from '../api';
+
+export class AutoModelSelector {
+  private models: ModelInfo[] = [];
+  private cache: Map<string, AutoSelectionState> = new Map();
+
+  constructor(models: ModelInfo[] = []) {
+    this.models = models;
+  }
+
+  // Update the models list
+  updateModels(models: ModelInfo[]): void {
+    this.models = models;
+    this.cache.clear(); // Clear cache when models change
+  }
+
+  // Get architecture-specific required models for a checkpoint
+  getRequiredModels(checkpointModel: ModelInfo): RequiredModelInfo[] {
+    if (!checkpointModel.architecture) {
+      return [];
+    }
+
+    const architecture = checkpointModel.architecture.toLowerCase();
+    
+    switch (architecture) {
+      case 'sd3':
+      case 'sd3.5':
+        return [
+          { type: 'vae', description: 'VAE for SD3', optional: true, priority: 1 },
+          { type: 'clip-l', description: 'CLIP-L for SD3', optional: false, priority: 2 },
+          { type: 'clip-g', description: 'CLIP-G for SD3', optional: false, priority: 3 },
+          { type: 't5xxl', description: 'T5XXL for SD3', optional: false, priority: 4 }
+        ];
+      
+      case 'sdxl':
+        return [
+          { type: 'vae', description: 'VAE for SDXL', optional: true, priority: 1 }
+        ];
+      
+      case 'sd1.x':
+      case 'sd2.x':
+        return [
+          { type: 'vae', description: 'VAE for SD1.x/2.x', optional: true, priority: 1 }
+        ];
+      
+      case 'flux':
+        return [
+          { type: 'vae', description: 'VAE for FLUX', optional: true, priority: 1 },
+          { type: 'clip-l', description: 'CLIP-L for FLUX', optional: false, priority: 2 },
+          { type: 't5xxl', description: 'T5XXL for FLUX', optional: false, priority: 3 }
+        ];
+      
+      case 'kontext':
+        return [
+          { type: 'vae', description: 'VAE for Kontext', optional: true, priority: 1 },
+          { type: 'clip-l', description: 'CLIP-L for Kontext', optional: false, priority: 2 },
+          { type: 't5xxl', description: 'T5XXL for Kontext', optional: false, priority: 3 }
+        ];
+      
+      case 'chroma':
+        return [
+          { type: 'vae', description: 'VAE for Chroma', optional: true, priority: 1 },
+          { type: 't5xxl', description: 'T5XXL for Chroma', optional: false, priority: 2 }
+        ];
+      
+      case 'wan':
+        return [
+          { type: 'vae', description: 'VAE for Wan', optional: true, priority: 1 },
+          { type: 't5xxl', description: 'T5XXL for Wan', optional: false, priority: 2 },
+          { type: 'clip-vision', description: 'CLIP-Vision for Wan', optional: false, priority: 3 }
+        ];
+      
+      case 'qwen':
+        return [
+          { type: 'vae', description: 'VAE for Qwen', optional: true, priority: 1 },
+          { type: 'qwen2vl', description: 'Qwen2VL for Qwen', optional: false, priority: 2 }
+        ];
+      
+      default:
+        return [];
+    }
+  }
+
+  // Find available models by type
+  findModelsByType(type: string): ModelInfo[] {
+    return this.models.filter(model => 
+      model.type.toLowerCase() === type.toLowerCase()
+    );
+  }
+
+  // Find models by name pattern
+  findModelsByName(pattern: string): ModelInfo[] {
+    const lowerPattern = pattern.toLowerCase();
+    return this.models.filter(model => 
+      model.name.toLowerCase().includes(lowerPattern)
+    );
+  }
+
+  // Get best match for a required model type
+  getBestModelForType(type: string, preferredName?: string): ModelInfo | null {
+    const modelsOfType = this.findModelsByType(type);
+    
+    if (modelsOfType.length === 0) {
+      return null;
+    }
+
+    // If preferred name is specified, try to find it first
+    if (preferredName) {
+      const preferred = modelsOfType.find(model => 
+        model.name.toLowerCase().includes(preferredName.toLowerCase())
+      );
+      if (preferred) {
+        return preferred;
+      }
+    }
+
+    // Prefer loaded models
+    const loadedModels = modelsOfType.filter(model => model.loaded);
+    if (loadedModels.length > 0) {
+      return loadedModels[0];
+    }
+
+    // Return first available model
+    return modelsOfType[0];
+  }
+
+  // Perform automatic model selection for a checkpoint
+  async selectModels(checkpointModel: ModelInfo): Promise<AutoSelectionState> {
+    const cacheKey = checkpointModel.id || checkpointModel.name;
+    
+    // Check cache first
+    const cached = this.cache.get(cacheKey);
+    if (cached) {
+      return cached;
+    }
+
+    const state: AutoSelectionState = {
+      selectedModels: {},
+      autoSelectedModels: {},
+      missingModels: [],
+      warnings: [],
+      errors: [],
+      isAutoSelecting: false
+    };
+
+    try {
+      state.isAutoSelecting = true;
+
+      // Get required models for this architecture
+      const requiredModels = this.getRequiredModels(checkpointModel);
+      
+      // Sort by priority
+      requiredModels.sort((a, b) => (a.priority || 0) - (b.priority || 0));
+
+      for (const required of requiredModels) {
+        const bestModel = this.getBestModelForType(required.type);
+        
+        if (bestModel) {
+          state.autoSelectedModels[required.type] = bestModel.name;
+          state.selectedModels[required.type] = bestModel.name;
+          
+          if (!bestModel.loaded && !required.optional) {
+            state.warnings.push(
+              `Selected ${required.type} model "${bestModel.name}" is not loaded. Consider loading it for better performance.`
+            );
+          }
+        } else if (!required.optional) {
+          state.missingModels.push(required.type);
+          state.errors.push(
+            `Required ${required.type} model not found: ${required.description || required.type}`
+          );
+        } else {
+          state.warnings.push(
+            `Optional ${required.type} model not found: ${required.description || required.type}`
+          );
+        }
+      }
+
+      // Check for recommended models
+      if (checkpointModel.recommended_vae) {
+        const vae = this.getBestModelForType('vae', checkpointModel.recommended_vae.name);
+        if (vae && vae.name !== state.selectedModels['vae']) {
+          state.autoSelectedModels['vae'] = vae.name;
+          state.selectedModels['vae'] = vae.name;
+          state.warnings.push(
+            `Using recommended VAE: ${vae.name} (${checkpointModel.recommended_vae.reason})`
+          );
+        }
+      }
+
+    } catch (error) {
+      state.errors.push(`Auto-selection failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+    } finally {
+      state.isAutoSelecting = false;
+    }
+
+    // Cache the result
+    this.cache.set(cacheKey, state);
+    
+    return state;
+  }
+
+  // Get model selection state for multiple checkpoints
+  async selectModelsForCheckpoints(checkpoints: ModelInfo[]): Promise<Record<string, AutoSelectionState>> {
+    const results: Record<string, AutoSelectionState> = {};
+    
+    for (const checkpoint of checkpoints) {
+      const key = checkpoint.id || checkpoint.name;
+      results[key] = await this.selectModels(checkpoint);
+    }
+    
+    return results;
+  }
+
+  // Clear the cache
+  clearCache(): void {
+    this.cache.clear();
+  }
+
+  // Get cached selection state
+  getCachedState(checkpointId: string): AutoSelectionState | null {
+    return this.cache.get(checkpointId) || null;
+  }
+
+  // Validate model selection
+  validateSelection(checkpointModel: ModelInfo, selectedModels: Record<string, string>): {
+    isValid: boolean;
+    missingRequired: string[];
+    warnings: string[];
+  } {
+    const requiredModels = this.getRequiredModels(checkpointModel);
+    const missingRequired: string[] = [];
+    const warnings: string[] = [];
+
+    for (const required of requiredModels) {
+      if (!required.optional && !selectedModels[required.type]) {
+        missingRequired.push(required.type);
+      }
+    }
+
+    return {
+      isValid: missingRequired.length === 0,
+      missingRequired,
+      warnings
+    };
+  }
+}

+ 6 - 0
webui/lib/services/index.ts

@@ -0,0 +1,6 @@
+/**
+ * API Services - All external API interactions
+ */
+
+export { apiClient } from '../api';
+export { AutoModelSelector } from './auto-model-selector';

+ 102 - 0
webui/lib/types/index.ts

@@ -0,0 +1,102 @@
+/**
+ * Type Definitions - Central TypeScript types and interfaces
+ *
+ * This file contains all shared types used across the application.
+ * Feature-specific types should be in their respective feature directories.
+ */
+
+// API Response Types
+export interface ApiResponse<T = any> {
+  success: boolean;
+  data?: T;
+  error?: string;
+}
+
+// Model Types
+export interface ModelDetails {
+  name: string;
+  path: string;
+  exists: boolean;
+  size?: number;
+  sha256?: string;
+}
+
+export interface ModelArchitecture {
+  type: 'sd15' | 'sd20' | 'sdxl' | 'flux' | 'sd3' | 'svd' | 'qwen2vl' | 'unknown';
+  requires_vae: boolean;
+  requires_clip_l: boolean;
+  requires_clip_g: boolean;
+  requires_t5xxl: boolean;
+}
+
+// Generation Types
+export interface GenerationParams {
+  prompt: string;
+  negative_prompt?: string;
+  width: number;
+  height: number;
+  steps: number;
+  cfg_scale: number;
+  sampler_name: string;
+  seed?: number;
+  model?: string;
+  vae?: string;
+}
+
+export interface QueueItem {
+  id: string;
+  type: 'text2img' | 'img2img' | 'inpainting' | 'upscaler';
+  params: GenerationParams;
+  status: 'pending' | 'processing' | 'completed' | 'failed';
+  progress: number;
+  created_at: string;
+  updated_at: string;
+  result_path?: string;
+  error?: string;
+}
+
+// System Types
+export interface SystemInfo {
+  version: string;
+  cuda_available: boolean;
+  gpu_memory?: number;
+  cpu_cores?: number;
+  memory?: number;
+}
+
+// Authentication Types
+export interface User {
+  id: number;
+  username: string;
+  role: string;
+  permissions: string[];
+}
+
+export interface AuthState {
+  user: User | null;
+  token: string | null;
+  isAuthenticated: boolean;
+  isLoading: boolean;
+}
+
+// UI Types
+export interface Theme {
+  name: string;
+  colors: {
+    primary: string;
+    secondary: string;
+    background: string;
+    foreground: string;
+  };
+}
+
+// Component Props Types
+export interface BaseComponentProps {
+  className?: string;
+  children?: React.ReactNode;
+}
+
+export interface LoadingState {
+  isLoading: boolean;
+  error?: string;
+}

+ 7 - 0
webui/lib/utils/index.ts

@@ -0,0 +1,7 @@
+/**
+ * Utility Functions - Common helper functions
+ *
+ * Collection of pure functions and utilities used throughout the application.
+ */
+
+export * from '../utils';