فهرست منبع

feat: implement automatic model selection in web UI (#45)

- Add AutoModelSelector utility for architecture-aware model selection
- Implement ModelSelectionContext for state management
- Create enhanced model selection components with visual indicators
- Update generation pages with automatic VAE selection
- Add TypeScript interfaces for enhanced API integration
- Implement architecture-specific selection logic for all model types
- Significantly improve user experience with intelligent model selection

This enhancement builds on the enhanced /api/models endpoint (issue #44) and provides
a much more intuitive and user-friendly interface for model selection.
Fszontagh 3 ماه پیش
والد
کامیت
ad6bcde855

+ 15 - 0
include/server.h

@@ -438,6 +438,21 @@ private:
      */
     nlohmann::json calculateSpecificRequirements(const std::string& modelType, const std::string& resolution, const std::string& batchSize);
 
+    /**
+     * @brief Convert ModelDetails vector to JSON array
+     */
+    nlohmann::json modelDetailsToJson(const std::vector<ModelManager::ModelDetails>& modelDetails);
+
+    /**
+     * @brief Determine which recommended fields to include based on architecture
+     */
+    std::map<std::string, bool> getRecommendedModelFields(const std::string& architecture);
+
+    /**
+     * @brief Populate recommended models with existence information
+     */
+    void populateRecommendedModels(nlohmann::json& response, const ModelManager::ModelInfo& modelInfo);
+
     /**
      * @brief Server thread function
      */

+ 117 - 5
webui/app/img2img/page.tsx

@@ -14,6 +14,9 @@ import { apiClient, type JobInfo } from '@/lib/api';
 import { Loader2, Download, X } from 'lucide-react';
 import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
 import { useLocalStorage } from '@/lib/hooks';
+import { ModelSelectionProvider, useModelSelection, useCheckpointSelection, useModelTypeSelection } from '@/contexts/model-selection-context';
+import { EnhancedModelSelect, EnhancedModelSelectGroup } from '@/components/enhanced-model-select';
+import { ModelSelectionWarning, AutoSelectionStatus } from '@/components/model-selection-indicator';
 
 type Img2ImgFormData = {
   prompt: string;
@@ -41,14 +44,34 @@ const defaultFormData: Img2ImgFormData = {
   height: 512,
 };
 
-export default function Img2ImgPage() {
+function Img2ImgForm() {
+  const { state, actions } = useModelSelection();
+  const {
+    checkpointModels,
+    selectedCheckpointModel,
+    selectedCheckpoint,
+    setSelectedCheckpoint,
+    isAutoSelecting,
+    warnings,
+    error: checkpointError
+  } = useCheckpointSelection();
+  
+  const {
+    availableModels: vaeModels,
+    selectedModel: selectedVae,
+    isUserOverride: isVaeUserOverride,
+    isAutoSelected: isVaeAutoSelected,
+    setSelectedModel: setSelectedVae,
+    setUserOverride: setVaeUserOverride,
+    clearUserOverride: clearVaeUserOverride,
+  } = useModelTypeSelection('vae');
+
   const [formData, setFormData] = useLocalStorage<Img2ImgFormData>(
     'img2img-form-data',
     defaultFormData
   );
 
   const [loading, setLoading] = useState(false);
-  const [error, setError] = useState<string | null>(null);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
   const [generatedImages, setGeneratedImages] = useState<string[]>([]);
   const [previewImage, setPreviewImage] = useState<string | null>(null);
@@ -58,14 +81,17 @@ export default function Img2ImgPage() {
   const [imageValidation, setImageValidation] = useState<any>(null);
   const [originalImage, setOriginalImage] = useState<string | null>(null);
   const [isResizing, setIsResizing] = useState(false);
+  const [error, setError] = useState<string | null>(null);
 
   useEffect(() => {
     const loadModels = async () => {
       try {
-        const [loras, embeds] = await Promise.all([
+        const [modelsData, loras, embeds] = await Promise.all([
+          apiClient.getModels(), // Get all models with enhanced info
           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) {
@@ -73,7 +99,17 @@ export default function Img2ImgPage() {
       }
     };
     loadModels();
-  }, []);
+  }, [actions]);
+
+  // Update form data when checkpoint changes
+  useEffect(() => {
+    if (selectedCheckpoint) {
+      setFormData(prev => ({
+        ...prev,
+        model: selectedCheckpoint,
+      }));
+    }
+  }, [selectedCheckpoint, setFormData]);
 
   const handleInputChange = (
     e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
@@ -240,7 +276,23 @@ export default function Img2ImgPage() {
     setJobInfo(null);
 
     try {
-      const job = await apiClient.img2img(formData);
+      // 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,
+        model: selectedCheckpoint || undefined,
+        vae: selectedVae || undefined,
+      };
+
+      const job = await apiClient.img2img(requestData);
       setJobInfo(job);
       const jobId = job.request_id || job.id;
       if (jobId) {
@@ -433,6 +485,62 @@ export default function Img2ImgPage() {
                   </select>
                 </div>
 
+                {/* Model Selection Section */}
+                <EnhancedModelSelectGroup
+                  title="Model Selection"
+                  description="Select the checkpoint and additional models for generation"
+                >
+                  {/* 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 */}
+                  <EnhancedModelSelect
+                    modelType="vae"
+                    label="VAE Model"
+                    description="Optional VAE model for improved image quality"
+                    value={selectedVae}
+                    availableModels={vaeModels}
+                    isAutoSelected={isVaeAutoSelected}
+                    isUserOverride={isVaeUserOverride}
+                    isLoading={isAutoSelecting}
+                    onValueChange={setSelectedVae}
+                    onSetUserOverride={setVaeUserOverride}
+                    onClearOverride={clearVaeUserOverride}
+                    placeholder="Use default VAE"
+                  />
+
+                  {/* Auto-selection Status */}
+                  <div className="pt-2">
+                    <AutoSelectionStatus
+                      isAutoSelecting={isAutoSelecting}
+                      hasAutoSelection={Object.keys(state.autoSelectedModels).length > 0}
+                    />
+                  </div>
+
+                  {/* Warnings and Errors */}
+                  <ModelSelectionWarning
+                    warnings={warnings}
+                    errors={error ? [error] : []}
+                    onClearWarnings={actions.clearWarnings}
+                  />
+                </EnhancedModelSelectGroup>
+
                 <div className="flex gap-2">
                   <Button type="submit" disabled={loading || !formData.image || (imageValidation && !imageValidation.isValid)} className="flex-1">
                     {loading ? (
@@ -519,4 +627,8 @@ export default function Img2ImgPage() {
       </div>
     </AppLayout>
   );
+}
+
+export default function Img2ImgPage() {
+  return <Img2ImgForm />;
 }

+ 118 - 6
webui/app/inpainting/page.tsx

@@ -14,6 +14,9 @@ import { apiClient, type JobInfo } from '@/lib/api';
 import { Loader2, X, Download } from 'lucide-react';
 import { downloadAuthenticatedImage } from '@/lib/utils';
 import { useLocalStorage } from '@/lib/hooks';
+import { ModelSelectionProvider, useModelSelection, useCheckpointSelection, useModelTypeSelection } from '@/contexts/model-selection-context';
+import { EnhancedModelSelect, EnhancedModelSelectGroup } from '@/components/enhanced-model-select';
+import { ModelSelectionWarning, AutoSelectionStatus } from '@/components/model-selection-indicator';
 
 type InpaintingFormData = {
   prompt: string;
@@ -43,26 +46,49 @@ const defaultFormData: InpaintingFormData = {
   height: 512,
 };
 
-export default function InpaintingPage() {
+function InpaintingForm() {
+  const { state, actions } = useModelSelection();
+  const {
+    checkpointModels,
+    selectedCheckpointModel,
+    selectedCheckpoint,
+    setSelectedCheckpoint,
+    isAutoSelecting,
+    warnings,
+    error: checkpointError
+  } = useCheckpointSelection();
+  
+  const {
+    availableModels: vaeModels,
+    selectedModel: selectedVae,
+    isUserOverride: isVaeUserOverride,
+    isAutoSelected: isVaeAutoSelected,
+    setSelectedModel: setSelectedVae,
+    setUserOverride: setVaeUserOverride,
+    clearUserOverride: clearVaeUserOverride,
+  } = useModelTypeSelection('vae');
+
   const [formData, setFormData] = useLocalStorage<InpaintingFormData>(
     'inpainting-form-data',
     defaultFormData
   );
 
   const [loading, setLoading] = useState(false);
-  const [error, setError] = useState<string | null>(null);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
   const [generatedImages, setGeneratedImages] = useState<string[]>([]);
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
+  const [error, setError] = useState<string | null>(null);
 
   useEffect(() => {
     const loadModels = async () => {
       try {
-        const [loras, embeds] = await Promise.all([
+        const [modelsData, loras, embeds] = await Promise.all([
+          apiClient.getModels(), // Get all models with enhanced info
           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) {
@@ -70,7 +96,17 @@ export default function InpaintingPage() {
       }
     };
     loadModels();
-  }, []);
+  }, [actions]);
+
+  // Update form data when checkpoint changes
+  useEffect(() => {
+    if (selectedCheckpoint) {
+      setFormData(prev => ({
+        ...prev,
+        model: selectedCheckpoint,
+      }));
+    }
+  }, [selectedCheckpoint, setFormData]);
 
   const handleInputChange = (
     e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
@@ -173,7 +209,23 @@ export default function InpaintingPage() {
     setJobInfo(null);
 
     try {
-      const job = await apiClient.inpainting(formData);
+      // 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,
+        model: selectedCheckpoint || undefined,
+        vae: selectedVae || undefined,
+      };
+
+      const job = await apiClient.inpainting(requestData);
       setJobInfo(job);
       const jobId = job.request_id || job.id;
       if (jobId) {
@@ -351,7 +403,63 @@ export default function InpaintingPage() {
                       <option value="lcm">LCM</option>
                     </select>
                   </div>
-
+  
+                  {/* Model Selection Section */}
+                  <EnhancedModelSelectGroup
+                    title="Model Selection"
+                    description="Select the checkpoint and additional models for generation"
+                  >
+                    {/* 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 */}
+                    <EnhancedModelSelect
+                      modelType="vae"
+                      label="VAE Model"
+                      description="Optional VAE model for improved image quality"
+                      value={selectedVae}
+                      availableModels={vaeModels}
+                      isAutoSelected={isVaeAutoSelected}
+                      isUserOverride={isVaeUserOverride}
+                      isLoading={isAutoSelecting}
+                      onValueChange={setSelectedVae}
+                      onSetUserOverride={setVaeUserOverride}
+                      onClearOverride={clearVaeUserOverride}
+                      placeholder="Use default VAE"
+                    />
+  
+                    {/* Auto-selection Status */}
+                    <div className="pt-2">
+                      <AutoSelectionStatus
+                        isAutoSelecting={isAutoSelecting}
+                        hasAutoSelection={Object.keys(state.autoSelectedModels).length > 0}
+                      />
+                    </div>
+  
+                    {/* Warnings and Errors */}
+                    <ModelSelectionWarning
+                      warnings={warnings}
+                      errors={error ? [error] : []}
+                      onClearWarnings={actions.clearWarnings}
+                    />
+                  </EnhancedModelSelectGroup>
+  
                   <div className="flex gap-2">
                     <Button
                       type="submit"
@@ -442,3 +550,7 @@ export default function InpaintingPage() {
     </AppLayout>
   );
 }
+
+export default function InpaintingPage() {
+  return <InpaintingForm />;
+}

+ 5 - 2
webui/app/layout.tsx

@@ -4,6 +4,7 @@ import "./globals.css";
 import { ThemeProvider } from "@/components/theme-provider";
 import { VersionChecker } from "@/components/version-checker";
 import { AuthProvider } from "@/lib/auth-context";
+import { ModelSelectionProvider } from "@/contexts/model-selection-context";
 
 const inter = Inter({
   subsets: ["latin"],
@@ -35,8 +36,10 @@ export default function RootLayout({
           disableTransitionOnChange
         >
           <AuthProvider>
-            <VersionChecker />
-            {children}
+            <ModelSelectionProvider>
+              <VersionChecker />
+              {children}
+            </ModelSelectionProvider>
           </AuthProvider>
         </ThemeProvider>
       </body>

+ 111 - 23
webui/app/text2img/page.tsx

@@ -13,6 +13,9 @@ import { apiClient, type GenerationRequest, type JobInfo, type ModelInfo } from
 import { Loader2, Download, X, Trash2, RotateCcw, Power } from 'lucide-react';
 import { downloadImage, downloadAuthenticatedImage } from '@/lib/utils';
 import { useLocalStorage } from '@/lib/hooks';
+import { ModelSelectionProvider, useModelSelection, useCheckpointSelection, useModelTypeSelection } from '@/contexts/model-selection-context';
+import { EnhancedModelSelect, EnhancedModelSelectGroup } from '@/components/enhanced-model-select';
+import { ModelSelectionWarning, AutoSelectionStatus } from '@/components/model-selection-indicator';
 
 const defaultFormData: GenerationRequest = {
   prompt: '',
@@ -27,36 +30,55 @@ const defaultFormData: GenerationRequest = {
   batch_count: 1,
 };
 
-export default function Text2ImgPage() {
+function Text2ImgForm() {
+  const { state, actions } = useModelSelection();
+  const {
+    checkpointModels,
+    selectedCheckpointModel,
+    selectedCheckpoint,
+    setSelectedCheckpoint,
+    isAutoSelecting,
+    warnings,
+    error: checkpointError
+  } = useCheckpointSelection();
+  
+  const {
+    availableModels: vaeModels,
+    selectedModel: selectedVae,
+    isUserOverride: isVaeUserOverride,
+    isAutoSelected: isVaeAutoSelected,
+    setSelectedModel: setSelectedVae,
+    setUserOverride: setVaeUserOverride,
+    clearUserOverride: clearVaeUserOverride,
+  } = useModelTypeSelection('vae');
+
   const [formData, setFormData] = useLocalStorage<GenerationRequest>(
     'text2img-form-data',
     defaultFormData
   );
 
   const [loading, setLoading] = useState(false);
-  const [error, setError] = useState<string | null>(null);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
   const [generatedImages, setGeneratedImages] = useState<string[]>([]);
   const [samplers, setSamplers] = useState<Array<{ name: string; description: string }>>([]);
   const [schedulers, setSchedulers] = useState<Array<{ name: string; description: string }>>([]);
-  const [vaeModels, setVaeModels] = useState<ModelInfo[]>([]);
-  const [selectedVae, setSelectedVae] = useState<string>('');
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
+  const [error, setError] = useState<string | null>(null);
 
   useEffect(() => {
     const loadOptions = async () => {
       try {
-        const [samplersData, schedulersData, vaeData, loras, embeds] = await Promise.all([
+        const [samplersData, schedulersData, modelsData, loras, embeds] = await Promise.all([
           apiClient.getSamplers(),
           apiClient.getSchedulers(),
-          apiClient.getModels('vae'),
+          apiClient.getModels(), // Get all models with enhanced info
           apiClient.getModels('lora'),
           apiClient.getModels('embedding'),
         ]);
         setSamplers(samplersData);
         setSchedulers(schedulersData);
-        setVaeModels(vaeData.models);
+        actions.setModels(modelsData.models);
         setLoraModels(loras.models.map(m => m.name));
         setEmbeddings(embeds.models.map(m => m.name));
       } catch (err) {
@@ -64,7 +86,17 @@ export default function Text2ImgPage() {
       }
     };
     loadOptions();
-  }, []);
+  }, [actions]);
+
+  // Update form data when checkpoint changes
+  useEffect(() => {
+    if (selectedCheckpoint) {
+      setFormData(prev => ({
+        ...prev,
+        model: selectedCheckpoint,
+      }));
+    }
+  }, [selectedCheckpoint, setFormData]);
 
   const handleInputChange = (
     e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
@@ -147,8 +179,19 @@ export default function Text2ImgPage() {
     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,
+        model: selectedCheckpoint || undefined,
         vae: selectedVae || undefined,
       };
 
@@ -186,7 +229,9 @@ export default function Text2ImgPage() {
 
   const handleResetToDefaults = () => {
     setFormData(defaultFormData);
+    setSelectedCheckpoint(null);
     setSelectedVae('');
+    actions.resetSelection();
   };
 
   const handleServerRestart = async () => {
@@ -389,22 +434,61 @@ export default function Text2ImgPage() {
                   </select>
                 </div>
 
-                <div className="space-y-2">
-                  <Label htmlFor="vae">VAE (optional)</Label>
-                  <select
-                    id="vae"
+                {/* Model Selection Section */}
+                <EnhancedModelSelectGroup
+                  title="Model Selection"
+                  description="Select the checkpoint and additional models for generation"
+                >
+                  {/* 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 */}
+                  <EnhancedModelSelect
+                    modelType="vae"
+                    label="VAE Model"
+                    description="Optional VAE model for improved image quality"
                     value={selectedVae}
-                    onChange={(e) => setSelectedVae(e.target.value)}
-                    className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
-                  >
-                    <option value="">Default VAE</option>
-                    {vaeModels.map((vae) => (
-                      <option key={vae.id} value={vae.name}>
-                        {vae.name}
-                      </option>
-                    ))}
-                  </select>
-                </div>
+                    availableModels={vaeModels}
+                    isAutoSelected={isVaeAutoSelected}
+                    isUserOverride={isVaeUserOverride}
+                    isLoading={isAutoSelecting}
+                    onValueChange={setSelectedVae}
+                    onSetUserOverride={setVaeUserOverride}
+                    onClearOverride={clearVaeUserOverride}
+                    placeholder="Use default VAE"
+                  />
+
+                  {/* Auto-selection Status */}
+                  <div className="pt-2">
+                    <AutoSelectionStatus
+                      isAutoSelecting={isAutoSelecting}
+                      hasAutoSelection={Object.keys(state.autoSelectedModels).length > 0}
+                    />
+                  </div>
+
+                  {/* Warnings and Errors */}
+                  <ModelSelectionWarning
+                    warnings={warnings}
+                    errors={error ? [error] : []}
+                    onClearWarnings={actions.clearWarnings}
+                  />
+                </EnhancedModelSelectGroup>
 
                 <div className="space-y-2">
                   <Label htmlFor="batch_count">Batch Count</Label>
@@ -506,3 +590,7 @@ export default function Text2ImgPage() {
     </AppLayout>
   );
 }
+
+export default function Text2ImgPage() {
+  return <Text2ImgForm />;
+}

+ 86 - 38
webui/app/upscaler/page.tsx

@@ -11,6 +11,9 @@ import { apiClient, type JobInfo, type ModelInfo } from '@/lib/api';
 import { Loader2, Download, X, Upload } from 'lucide-react';
 import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
 import { useLocalStorage } from '@/lib/hooks';
+import { ModelSelectionProvider, useModelSelection, useModelTypeSelection } from '@/contexts/model-selection-context';
+import { EnhancedModelSelect, EnhancedModelSelectGroup } from '@/components/enhanced-model-select';
+import { ModelSelectionWarning, AutoSelectionStatus } from '@/components/model-selection-indicator';
 
 type UpscalerFormData = {
   image: string;
@@ -24,7 +27,19 @@ const defaultFormData: UpscalerFormData = {
   model: '',
 };
 
-export default function UpscalerPage() {
+function UpscalerForm() {
+  const { state, actions } = useModelSelection();
+  
+  const {
+    availableModels: upscalerModels,
+    selectedModel: selectedUpscalerModel,
+    isUserOverride: isUpscalerUserOverride,
+    isAutoSelected: isUpscalerAutoSelected,
+    setSelectedModel: setSelectedUpscalerModel,
+    setUserOverride: setUpscalerUserOverride,
+    clearUserOverride: clearUpscalerUserOverride,
+  } = useModelTypeSelection('upscaler');
+
   const [formData, setFormData] = useLocalStorage<UpscalerFormData>(
     'upscaler-form-data',
     defaultFormData
@@ -36,28 +51,39 @@ export default function UpscalerPage() {
   const [generatedImages, setGeneratedImages] = useState<string[]>([]);
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
-  const [upscalerModels, setUpscalerModels] = useState<ModelInfo[]>([]);
 
   useEffect(() => {
     const loadModels = async () => {
       try {
-        // Fetch ESRGAN and upscaler models
-        const [esrganModels, upscalerMods] = await Promise.all([
-          apiClient.getModels('esrgan'),
-          apiClient.getModels('upscaler'),
-        ]);
-        const allModels = [...esrganModels.models, ...upscalerMods.models];
-        setUpscalerModels(allModels);
-        // Set first model as default
-        if (allModels.length > 0 && !formData.model) {
-          setFormData(prev => ({ ...prev, model: allModels[0].name }));
+        // Fetch all models with enhanced info
+        const modelsData = await apiClient.getModels();
+        // Filter for upscaler models (ESRGAN and upscaler types)
+        const allUpscalerModels = [
+          ...modelsData.models.filter(m => m.type.toLowerCase() === 'esrgan'),
+          ...modelsData.models.filter(m => m.type.toLowerCase() === 'upscaler')
+        ];
+        actions.setModels(modelsData.models);
+        
+        // Set first model as default if none selected
+        if (allUpscalerModels.length > 0 && !formData.model) {
+          setFormData(prev => ({ ...prev, model: allUpscalerModels[0].name }));
         }
       } catch (err) {
         console.error('Failed to load upscaler models:', err);
       }
     };
     loadModels();
-  }, []);
+  }, [actions, formData.model, setFormData]);
+
+  // Update form data when upscaler model changes
+  useEffect(() => {
+    if (selectedUpscalerModel) {
+      setFormData(prev => ({
+        ...prev,
+        model: selectedUpscalerModel,
+      }));
+    }
+  }, [selectedUpscalerModel, setFormData]);
 
   const handleInputChange = (
     e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
@@ -157,9 +183,17 @@ export default function UpscalerPage() {
     setJobInfo(null);
 
     try {
+      // Validate model selection
+      if (!selectedUpscalerModel) {
+        setError('Please select an upscaler model');
+        setLoading(false);
+        return;
+      }
+
       // Note: You may need to adjust the API endpoint based on your backend implementation
       const job = await apiClient.generateImage({
         prompt: `upscale ${formData.upscale_factor}x`,
+        model: selectedUpscalerModel,
         // Add upscale-specific parameters here based on your API
       } as any);
       setJobInfo(job);
@@ -247,31 +281,41 @@ export default function UpscalerPage() {
                   </p>
                 </div>
 
-                <div className="space-y-2">
-                  <Label htmlFor="model">Upscaling Model</Label>
-                  <select
-                    id="model"
-                    name="model"
-                    value={formData.model}
-                    onChange={handleInputChange}
-                    className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
-                  >
-                    {upscalerModels.length > 0 ? (
-                      upscalerModels.map((model) => (
-                        <option key={model.id} value={model.name}>
-                          {model.name}
-                        </option>
-                      ))
-                    ) : (
-                      <option value="">Loading models...</option>
-                    )}
-                  </select>
-                  {upscalerModels.length === 0 && !loading && (
-                    <p className="text-xs text-yellow-600 dark:text-yellow-400">
-                      No upscaler models found. Please add ESRGAN or upscaler models to your models directory.
-                    </p>
-                  )}
-                </div>
+                {/* Model Selection Section */}
+                <EnhancedModelSelectGroup
+                  title="Model Selection"
+                  description="Select the upscaler model for image enhancement"
+                >
+                  <EnhancedModelSelect
+                    modelType="upscaler"
+                    label="Upscaling Model"
+                    description="Model to use for upscaling the image"
+                    value={selectedUpscalerModel}
+                    availableModels={upscalerModels}
+                    isAutoSelected={isUpscalerAutoSelected}
+                    isUserOverride={isUpscalerUserOverride}
+                    isLoading={state.isLoading}
+                    onValueChange={setSelectedUpscalerModel}
+                    onSetUserOverride={setUpscalerUserOverride}
+                    onClearOverride={clearUpscalerUserOverride}
+                    placeholder="Select an upscaler model"
+                  />
+
+                  {/* Auto-selection Status */}
+                  <div className="pt-2">
+                    <AutoSelectionStatus
+                      isAutoSelecting={state.isAutoSelecting}
+                      hasAutoSelection={Object.keys(state.autoSelectedModels).length > 0}
+                    />
+                  </div>
+
+                  {/* Warnings and Errors */}
+                  <ModelSelectionWarning
+                    warnings={state.warnings}
+                    errors={error ? [error] : []}
+                    onClearWarnings={actions.clearWarnings}
+                  />
+                </EnhancedModelSelectGroup>
 
                 <div className="flex gap-2">
                   <Button type="submit" disabled={loading || !formData.image} className="flex-1">
@@ -367,3 +411,7 @@ export default function UpscalerPage() {
     </AppLayout>
   );
 }
+
+export default function UpscalerPage() {
+  return <UpscalerForm />;
+}

+ 283 - 0
webui/components/enhanced-model-select.tsx

@@ -0,0 +1,283 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { 
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select';
+import { 
+  CheckCircle2, 
+  Zap, 
+  RotateCcw, 
+  AlertCircle,
+  Info,
+  Loader2
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { ModelInfo } from '@/lib/api';
+import { ModelSelectionIndicator, AutoSelectionStatus } from './model-selection-indicator';
+
+interface EnhancedModelSelectProps {
+  modelType: string;
+  label: string;
+  description?: string;
+  value: string | null;
+  availableModels: ModelInfo[];
+  isAutoSelected: boolean;
+  isUserOverride: boolean;
+  isLoading?: boolean;
+  onValueChange: (value: string) => void;
+  onSetUserOverride: (value: string) => void;
+  onClearOverride: () => void;
+  onRevertToAuto?: () => void;
+  placeholder?: string;
+  className?: string;
+  disabled?: boolean;
+}
+
+export function EnhancedModelSelect({
+  modelType,
+  label,
+  description,
+  value,
+  availableModels,
+  isAutoSelected,
+  isUserOverride,
+  isLoading = false,
+  onValueChange,
+  onSetUserOverride,
+  onClearOverride,
+  onRevertToAuto,
+  placeholder = "Select a model...",
+  className,
+  disabled = false,
+}: EnhancedModelSelectProps) {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const selectedModel = value ? availableModels.find(m => m.name === value) : null;
+  const isLoaded = selectedModel?.loaded || false;
+
+  const handleValueChange = (newValue: string) => {
+    if (newValue !== value) {
+      onValueChange(newValue);
+      onSetUserOverride(newValue);
+    }
+  };
+
+  const handleClearOverride = () => {
+    onClearOverride();
+  };
+
+  const handleRevertToAuto = () => {
+    if (onRevertToAuto) {
+      onRevertToAuto();
+    }
+  };
+
+  const getModelIcon = (type: string) => {
+    switch (type.toLowerCase()) {
+      case 'vae':
+        return <Zap className="h-4 w-4" />;
+      case 'checkpoint':
+      case 'stable-diffusion':
+        return <CheckCircle2 className="h-4 w-4" />;
+      default:
+        return <Info className="h-4 w-4" />;
+    }
+  };
+
+  const getModelStatusColor = (model: ModelInfo) => {
+    if (model.loaded) {
+      return 'text-green-600 dark:text-green-400';
+    }
+    return 'text-muted-foreground';
+  };
+
+  return (
+    <div className={cn("space-y-2", className)}>
+      <div className="flex items-center justify-between">
+        <Label htmlFor={`${modelType}-select`} className="text-sm font-medium">
+          {label}
+        </Label>
+        
+        <ModelSelectionIndicator
+          modelName={value}
+          isAutoSelected={isAutoSelected}
+          isUserOverride={isUserOverride}
+          isLoaded={isLoaded}
+          onClearOverride={isUserOverride ? handleClearOverride : undefined}
+          onRevertToAuto={isUserOverride && onRevertToAuto ? handleRevertToAuto : undefined}
+        />
+      </div>
+
+      {description && (
+        <p className="text-xs text-muted-foreground">{description}</p>
+      )}
+
+      <div className="relative">
+        <Select
+          value={value || ''}
+          onValueChange={handleValueChange}
+          disabled={disabled || isLoading}
+          open={isOpen}
+          onOpenChange={setIsOpen}
+        >
+          <SelectTrigger
+            id={`${modelType}-select`}
+            className={cn(
+              "w-full",
+              isAutoSelected && !isUserOverride && "border-green-500 dark:border-green-600",
+              isUserOverride && "border-blue-500 dark:border-blue-600"
+            )}
+          >
+            <div className="flex items-center justify-between w-full">
+              <SelectValue placeholder={placeholder} />
+              {isLoading && (
+                <Loader2 className="h-4 w-4 animate-spin ml-2" />
+              )}
+            </div>
+          </SelectTrigger>
+          
+          <SelectContent>
+            {availableModels.length === 0 ? (
+              <div className="p-2 text-sm text-muted-foreground text-center">
+                No {modelType} models available
+              </div>
+            ) : (
+              <>
+                {availableModels.map((model) => (
+                  <SelectItem key={model.id || model.name} value={model.name}>
+                    <div className="flex items-center justify-between w-full">
+                      <div className="flex items-center gap-2">
+                        {getModelIcon(model.type)}
+                        <span className={cn(getModelStatusColor(model))}>
+                          {model.name}
+                        </span>
+                      </div>
+                      
+                      <div className="flex items-center gap-2">
+                        {model.loaded && (
+                          <Badge variant="secondary" className="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
+                            Loaded
+                          </Badge>
+                        )}
+                        
+                        {model.file_size_mb && (
+                          <span className="text-xs text-muted-foreground">
+                            {model.file_size_mb.toFixed(1)} MB
+                          </span>
+                        )}
+                      </div>
+                    </div>
+                  </SelectItem>
+                ))}
+              </>
+            )}
+          </SelectContent>
+        </Select>
+
+        {/* Auto-selection indicator */}
+        {isAutoSelected && !isUserOverride && (
+          <div className="absolute -top-1 -right-1">
+            <div className="bg-green-500 rounded-full p-1">
+              <Zap className="h-3 w-3 text-white" />
+            </div>
+          </div>
+        )}
+
+        {/* User override indicator */}
+        {isUserOverride && (
+          <div className="absolute -top-1 -right-1">
+            <div className="bg-blue-500 rounded-full p-1">
+              <CheckCircle2 className="h-3 w-3 text-white" />
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* Model info display */}
+      {selectedModel && (
+        <div className="p-2 bg-muted/50 rounded-md">
+          <div className="flex items-center justify-between text-xs">
+            <div className="flex items-center gap-2">
+              <span className="text-muted-foreground">Type:</span>
+              <Badge variant="outline" className="text-xs">
+                {selectedModel.type}
+              </Badge>
+            </div>
+            
+            {selectedModel.file_size_mb && (
+              <div className="flex items-center gap-2">
+                <span className="text-muted-foreground">Size:</span>
+                <span>{selectedModel.file_size_mb.toFixed(1)} MB</span>
+              </div>
+            )}
+          </div>
+          
+          {selectedModel.architecture && (
+            <div className="flex items-center gap-2 text-xs mt-1">
+              <span className="text-muted-foreground">Architecture:</span>
+              <Badge variant="secondary" className="text-xs">
+                {selectedModel.architecture}
+              </Badge>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* No models warning */}
+      {availableModels.length === 0 && !isLoading && (
+        <div className="flex items-center gap-2 p-2 rounded-md bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800">
+          <AlertCircle className="h-4 w-4 text-yellow-500" />
+          <p className="text-sm text-yellow-700 dark:text-yellow-300">
+            No {modelType} models found. Please add {modelType} models to your models directory.
+          </p>
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface EnhancedModelSelectGroupProps {
+  title: string;
+  description?: string;
+  children: React.ReactNode;
+  isLoading?: boolean;
+  className?: string;
+}
+
+export function EnhancedModelSelectGroup({
+  title,
+  description,
+  children,
+  isLoading = false,
+  className,
+}: EnhancedModelSelectGroupProps) {
+  return (
+    <div className={cn("space-y-4", className)}>
+      <div className="space-y-2">
+        <h3 className="text-lg font-semibold">{title}</h3>
+        {description && (
+          <p className="text-sm text-muted-foreground">{description}</p>
+        )}
+      </div>
+      
+      {isLoading ? (
+        <div className="flex items-center justify-center py-8">
+          <Loader2 className="h-6 w-6 animate-spin mr-2" />
+          <span>Loading models...</span>
+        </div>
+      ) : (
+        <div className="space-y-4">
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}

+ 212 - 0
webui/components/model-selection-indicator.tsx

@@ -0,0 +1,212 @@
+'use client';
+
+import React from 'react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { 
+  CheckCircle2, 
+  AlertCircle, 
+  XCircle, 
+  Zap, 
+  RotateCcw,
+  Info,
+  AlertTriangle
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface ModelSelectionIndicatorProps {
+  modelName: string | null;
+  isAutoSelected: boolean;
+  isUserOverride: boolean;
+  isLoaded?: boolean;
+  onClearOverride?: () => void;
+  onRevertToAuto?: () => void;
+  className?: string;
+}
+
+export function ModelSelectionIndicator({
+  modelName,
+  isAutoSelected,
+  isUserOverride,
+  isLoaded = false,
+  onClearOverride,
+  onRevertToAuto,
+  className
+}: ModelSelectionIndicatorProps) {
+  if (!modelName) {
+    return (
+      <div className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)}>
+        <XCircle className="h-4 w-4" />
+        <span>No model selected</span>
+      </div>
+    );
+  }
+
+  const getIndicatorColor = () => {
+    if (isUserOverride) {
+      return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
+    }
+    if (isAutoSelected) {
+      return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
+    }
+    return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
+  };
+
+  const getIndicatorIcon = () => {
+    if (isUserOverride) {
+      return <CheckCircle2 className="h-3 w-3" />;
+    }
+    if (isAutoSelected) {
+      return <Zap className="h-3 w-3" />;
+    }
+    return <Info className="h-3 w-3" />;
+  };
+
+  const getIndicatorText = () => {
+    if (isUserOverride) {
+      return 'Manual';
+    }
+    if (isAutoSelected) {
+      return 'Auto';
+    }
+    return 'Selected';
+  };
+
+  return (
+    <div className={cn("flex items-center gap-2", className)}>
+      <Badge variant="secondary" className={getIndicatorColor()}>
+        {getIndicatorIcon()}
+        <span className="ml-1">{getIndicatorText()}</span>
+      </Badge>
+      
+      <span className={cn(
+        "text-sm",
+        isLoaded ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
+      )}>
+        {modelName}
+      </span>
+
+      {isUserOverride && onClearOverride && (
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={onClearOverride}
+          className="h-6 w-6 p-0"
+          title="Clear manual selection"
+        >
+          <RotateCcw className="h-3 w-3" />
+        </Button>
+      )}
+
+      {isUserOverride && onRevertToAuto && (
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={onRevertToAuto}
+          className="h-6 w-6 p-0"
+          title="Revert to auto-selected model"
+        >
+          <Zap className="h-3 w-3" />
+        </Button>
+      )}
+    </div>
+  );
+}
+
+interface ModelSelectionWarningProps {
+  warnings: string[];
+  errors: string[];
+  onClearWarnings?: () => void;
+  className?: string;
+}
+
+export function ModelSelectionWarning({
+  warnings,
+  errors,
+  onClearWarnings,
+  className
+}: ModelSelectionWarningProps) {
+  if (warnings.length === 0 && errors.length === 0) {
+    return null;
+  }
+
+  return (
+    <div className={cn("space-y-2", className)}>
+      {errors.map((error, index) => (
+        <div key={index} className="flex items-start gap-2 p-2 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800">
+          <XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
+          <p className="text-sm text-red-700 dark:text-red-300">{error}</p>
+        </div>
+      ))}
+      
+      {warnings.map((warning, index) => (
+        <div key={index} className="flex items-start gap-2 p-2 rounded-md bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800">
+          <AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 flex-shrink-0" />
+          <p className="text-sm text-yellow-700 dark:text-yellow-300">{warning}</p>
+        </div>
+      ))}
+
+      {onClearWarnings && warnings.length > 0 && (
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={onClearWarnings}
+          className="text-xs"
+        >
+          Clear warnings
+        </Button>
+      )}
+    </div>
+  );
+}
+
+interface AutoSelectionStatusProps {
+  isAutoSelecting: boolean;
+  hasAutoSelection: boolean;
+  onRetryAutoSelection?: () => void;
+  className?: string;
+}
+
+export function AutoSelectionStatus({
+  isAutoSelecting,
+  hasAutoSelection,
+  onRetryAutoSelection,
+  className
+}: AutoSelectionStatusProps) {
+  if (isAutoSelecting) {
+    return (
+      <div className={cn("flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400", className)}>
+        <div className="animate-spin">
+          <Zap className="h-4 w-4" />
+        </div>
+        <span>Auto-selecting models...</span>
+      </div>
+    );
+  }
+
+  if (!hasAutoSelection) {
+    return (
+      <div className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)}>
+        <AlertCircle className="h-4 w-4" />
+        <span>No automatic selection available</span>
+        {onRetryAutoSelection && (
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={onRetryAutoSelection}
+            className="h-6 px-2 text-xs"
+          >
+            Retry
+          </Button>
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className={cn("flex items-center gap-2 text-sm text-green-600 dark:text-green-400", className)}>
+      <CheckCircle2 className="h-4 w-4" />
+      <span>Models auto-selected</span>
+    </div>
+  );
+}

+ 160 - 0
webui/components/ui/select.tsx

@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+      className
+    )}
+    {...props}
+  >
+    {children}
+    <SelectPrimitive.Icon asChild>
+      <ChevronDown className="h-4 w-4 opacity-50" />
+    </SelectPrimitive.Icon>
+  </SelectPrimitive.Trigger>
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollUpButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className
+    )}
+    {...props}
+  >
+    <ChevronUp className="h-4 w-4" />
+  </SelectPrimitive.ScrollUpButton>
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollDownButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className
+    )}
+    {...props}
+  >
+    <ChevronDown className="h-4 w-4" />
+  </SelectPrimitive.ScrollDownButton>
+))
+SelectScrollDownButton.displayName =
+  SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+  <SelectPrimitive.Portal>
+    <SelectPrimitive.Content
+      ref={ref}
+      className={cn(
+        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+        position === "popper" &&
+          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+        className
+      )}
+      position={position}
+      {...props}
+    >
+      <SelectScrollUpButton />
+      <SelectPrimitive.Viewport
+        className={cn(
+          "p-1",
+          position === "popper" &&
+            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+        )}
+      >
+        {children}
+      </SelectPrimitive.Viewport>
+      <SelectScrollDownButton />
+    </SelectPrimitive.Content>
+  </SelectPrimitive.Portal>
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Label>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Label
+    ref={ref}
+    className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
+    {...props}
+  />
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Item
+    ref={ref}
+    className={cn(
+      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+      className
+    )}
+    {...props}
+  >
+    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+      <SelectPrimitive.ItemIndicator>
+        <Check className="h-4 w-4" />
+      </SelectPrimitive.ItemIndicator>
+    </span>
+
+    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+  </SelectPrimitive.Item>
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Separator>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Separator
+    ref={ref}
+    className={cn("-mx-1 my-1 h-px bg-muted", className)}
+    {...props}
+  />
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+  Select,
+  SelectGroup,
+  SelectValue,
+  SelectTrigger,
+  SelectContent,
+  SelectLabel,
+  SelectItem,
+  SelectSeparator,
+  SelectScrollUpButton,
+  SelectScrollDownButton,
+}

+ 338 - 0
webui/contexts/model-selection-context.tsx

@@ -0,0 +1,338 @@
+'use client';
+
+import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
+import { ModelInfo, AutoSelectionState } from '@/lib/api';
+import { AutoModelSelector } from '@/lib/auto-model-selector';
+
+// Types for the context
+interface ModelSelectionState {
+  selectedCheckpoint: string | null;
+  selectedModels: Record<string, string>; // modelType -> modelName
+  autoSelectedModels: Record<string, string>; // modelType -> modelName
+  userOverrides: Record<string, string>; // modelType -> modelName (user manual selections)
+  autoSelectionState: AutoSelectionState | null;
+  availableModels: ModelInfo[];
+  isLoading: boolean;
+  error: string | null;
+  warnings: string[];
+  isAutoSelecting: boolean;
+}
+
+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_USER_OVERRIDE'; payload: { type: string; name: string } }
+  | { type: 'CLEAR_USER_OVERRIDE'; payload: string }
+  | { type: 'SET_AUTO_SELECTION_STATE'; payload: AutoSelectionState }
+  | { type: 'SET_LOADING'; payload: boolean }
+  | { type: 'SET_ERROR'; payload: string | null }
+  | { type: 'ADD_WARNING'; payload: string }
+  | { type: 'CLEAR_WARNINGS' }
+  | { type: 'SET_AUTO_SELECTING'; payload: boolean }
+  | { type: 'RESET_SELECTION' };
+
+// Initial state
+const initialState: ModelSelectionState = {
+  selectedCheckpoint: null,
+  selectedModels: {},
+  autoSelectedModels: {},
+  userOverrides: {},
+  autoSelectionState: null,
+  availableModels: [],
+  isLoading: false,
+  error: null,
+  warnings: [],
+  isAutoSelecting: false,
+};
+
+// Reducer function
+function modelSelectionReducer(
+  state: ModelSelectionState,
+  action: ModelSelectionAction
+): ModelSelectionState {
+  switch (action.type) {
+    case 'SET_MODELS':
+      return {
+        ...state,
+        availableModels: action.payload,
+        error: null,
+      };
+
+    case 'SET_SELECTED_CHECKPOINT':
+      return {
+        ...state,
+        selectedCheckpoint: action.payload,
+        // Clear auto-selection when checkpoint changes
+        autoSelectedModels: {},
+        autoSelectionState: null,
+        warnings: [],
+      };
+
+    case 'SET_SELECTED_MODEL':
+      return {
+        ...state,
+        selectedModels: {
+          ...state.selectedModels,
+          [action.payload.type]: action.payload.name,
+        },
+      };
+
+    case 'SET_USER_OVERRIDE':
+      return {
+        ...state,
+        userOverrides: {
+          ...state.userOverrides,
+          [action.payload.type]: action.payload.name,
+        },
+        selectedModels: {
+          ...state.selectedModels,
+          [action.payload.type]: action.payload.name,
+        },
+      };
+
+    case 'CLEAR_USER_OVERRIDE':
+      const newUserOverrides = { ...state.userOverrides };
+      delete newUserOverrides[action.payload];
+      
+      // If we had an auto-selected model for this type, restore it
+      const restoredModel = state.autoSelectedModels[action.payload];
+      const newSelectedModels = { ...state.selectedModels };
+      if (restoredModel) {
+        newSelectedModels[action.payload] = restoredModel;
+      } else {
+        delete newSelectedModels[action.payload];
+      }
+
+      return {
+        ...state,
+        userOverrides: newUserOverrides,
+        selectedModels: newSelectedModels,
+      };
+
+    case 'SET_AUTO_SELECTION_STATE':
+      return {
+        ...state,
+        autoSelectionState: action.payload,
+        // Merge auto-selected models with current selections, but don't override user selections
+        selectedModels: {
+          ...action.payload.autoSelectedModels,
+          ...state.userOverrides, // User overrides take precedence
+        },
+        autoSelectedModels: action.payload.autoSelectedModels,
+        warnings: [...state.warnings, ...action.payload.warnings],
+        error: action.payload.errors.length > 0 ? action.payload.errors[0] : state.error,
+      };
+
+    case 'SET_LOADING':
+      return {
+        ...state,
+        isLoading: action.payload,
+      };
+
+    case 'SET_ERROR':
+      return {
+        ...state,
+        error: action.payload,
+      };
+
+    case 'ADD_WARNING':
+      return {
+        ...state,
+        warnings: [...state.warnings, action.payload],
+      };
+
+    case 'CLEAR_WARNINGS':
+      return {
+        ...state,
+        warnings: [],
+      };
+
+    case 'SET_AUTO_SELECTING':
+      return {
+        ...state,
+        isAutoSelecting: action.payload,
+      };
+
+    case 'RESET_SELECTION':
+      return {
+        ...initialState,
+        availableModels: state.availableModels,
+      };
+
+    default:
+      return state;
+  }
+}
+
+// Context type
+interface ModelSelectionContextType {
+  state: ModelSelectionState;
+  actions: {
+    setModels: (models: ModelInfo[]) => void;
+    setSelectedCheckpoint: (checkpointName: string | null) => void;
+    setSelectedModel: (type: string, name: string) => void;
+    setUserOverride: (type: string, name: string) => void;
+    clearUserOverride: (type: string) => void;
+    performAutoSelection: (checkpointModel: ModelInfo) => Promise<void>;
+    clearWarnings: () => void;
+    resetSelection: () => void;
+    validateSelection: (checkpointModel: ModelInfo) => {
+      isValid: boolean;
+      missingRequired: string[];
+      warnings: string[];
+    };
+  };
+}
+
+// Create context
+const ModelSelectionContext = createContext<ModelSelectionContextType | null>(null);
+
+// Provider component
+interface ModelSelectionProviderProps {
+  children: ReactNode;
+}
+
+export function ModelSelectionProvider({ children }: ModelSelectionProviderProps) {
+  const [state, dispatch] = useReducer(modelSelectionReducer, initialState);
+  const autoSelectorRef = React.useRef<AutoModelSelector>(new AutoModelSelector());
+
+  // Update auto selector when models change
+  useEffect(() => {
+    autoSelectorRef.current.updateModels(state.availableModels);
+  }, [state.availableModels]);
+
+  // Actions
+  const actions = {
+    setModels: (models: ModelInfo[]) => {
+      dispatch({ type: 'SET_MODELS', payload: models });
+    },
+
+    setSelectedCheckpoint: (checkpointName: string | null) => {
+      dispatch({ type: 'SET_SELECTED_CHECKPOINT', payload: checkpointName });
+    },
+
+    setSelectedModel: (type: string, name: string) => {
+      dispatch({ type: 'SET_SELECTED_MODEL', payload: { type, name } });
+    },
+
+    setUserOverride: (type: string, name: string) => {
+      dispatch({ type: 'SET_USER_OVERRIDE', payload: { type, name } });
+    },
+
+    clearUserOverride: (type: string) => {
+      dispatch({ type: 'CLEAR_USER_OVERRIDE', payload: type });
+    },
+
+    performAutoSelection: async (checkpointModel: ModelInfo) => {
+      try {
+        dispatch({ type: 'SET_AUTO_SELECTING', payload: true });
+        dispatch({ type: 'SET_LOADING', payload: true });
+        dispatch({ type: 'SET_ERROR', payload: null });
+
+        const autoSelectionState = await autoSelectorRef.current.selectModels(checkpointModel);
+        
+        dispatch({ type: 'SET_AUTO_SELECTION_STATE', payload: autoSelectionState });
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : 'Auto-selection failed';
+        dispatch({ type: 'SET_ERROR', payload: errorMessage });
+      } finally {
+        dispatch({ type: 'SET_AUTO_SELECTING', payload: false });
+        dispatch({ type: 'SET_LOADING', payload: false });
+      }
+    },
+
+    clearWarnings: () => {
+      dispatch({ type: 'CLEAR_WARNINGS' });
+    },
+
+    resetSelection: () => {
+      dispatch({ type: 'RESET_SELECTION' });
+    },
+
+    validateSelection: (checkpointModel: ModelInfo) => {
+      return autoSelectorRef.current.validateSelection(checkpointModel, state.selectedModels);
+    },
+  };
+
+  // Auto-select when checkpoint changes
+  useEffect(() => {
+    if (state.selectedCheckpoint && state.availableModels.length > 0) {
+      const checkpointModel = state.availableModels.find(
+        model => model.name === state.selectedCheckpoint && model.type === 'checkpoint'
+      );
+
+      if (checkpointModel) {
+        actions.performAutoSelection(checkpointModel);
+      }
+    }
+  }, [state.selectedCheckpoint, state.availableModels]);
+
+  const value = {
+    state,
+    actions,
+  };
+
+  return (
+    <ModelSelectionContext.Provider value={value}>
+      {children}
+    </ModelSelectionContext.Provider>
+  );
+}
+
+// Hook to use the context
+export function useModelSelection() {
+  const context = useContext(ModelSelectionContext);
+  if (!context) {
+    throw new Error('useModelSelection must be used within a ModelSelectionProvider');
+  }
+  return context;
+}
+
+// Helper hook for checkpoint selection
+export function useCheckpointSelection() {
+  const { state, actions } = useModelSelection();
+  
+  const checkpointModels = state.availableModels.filter(model => 
+    model.type === 'checkpoint' || model.type === 'stable-diffusion'
+  );
+
+  const selectedCheckpointModel = state.selectedCheckpoint 
+    ? state.availableModels.find(model => model.name === state.selectedCheckpoint)
+    : null;
+
+  return {
+    checkpointModels,
+    selectedCheckpointModel,
+    selectedCheckpoint: state.selectedCheckpoint,
+    setSelectedCheckpoint: actions.setSelectedCheckpoint,
+    autoSelectedModels: state.autoSelectedModels,
+    userOverrides: state.userOverrides,
+    isAutoSelecting: state.isAutoSelecting,
+    warnings: state.warnings,
+    error: state.error,
+  };
+}
+
+// Helper hook for model type selection
+export function useModelTypeSelection(modelType: string) {
+  const { state, actions } = useModelSelection();
+  
+  const availableModels = state.availableModels.filter(model => 
+    model.type.toLowerCase() === modelType.toLowerCase()
+  );
+
+  const selectedModel = state.selectedModels[modelType];
+  const isUserOverride = !!state.userOverrides[modelType];
+  const isAutoSelected = !!state.autoSelectedModels[modelType] && !isUserOverride;
+
+  return {
+    availableModels,
+    selectedModel,
+    isUserOverride,
+    isAutoSelected,
+    setSelectedModel: (name: string) => actions.setSelectedModel(modelType, name),
+    setUserOverride: (name: string) => actions.setUserOverride(modelType, name),
+    clearUserOverride: () => actions.clearUserOverride(modelType),
+  };
+}

+ 107 - 15
webui/lib/api.ts

@@ -186,6 +186,50 @@ export interface ModelInfo {
   sha256?: string | null;
   sha256_short?: string | null;
   loaded?: boolean;
+  architecture?: string;
+  required_models?: RequiredModelInfo[];
+  recommended_vae?: RecommendedModelInfo;
+  recommended_textual_inversions?: RecommendedModelInfo[];
+  recommended_loras?: RecommendedModelInfo[];
+  metadata?: Record<string, any>;
+}
+
+export interface RequiredModelInfo {
+  type: string;
+  name?: string;
+  description?: string;
+  optional?: boolean;
+  priority?: number;
+}
+
+export interface RecommendedModelInfo {
+  type: string;
+  name?: string;
+  description?: string;
+  reason?: string;
+}
+
+export interface AutoSelectionState {
+  selectedModels: Record<string, string>; // modelType -> modelName
+  autoSelectedModels: Record<string, string>; // modelType -> modelName
+  missingModels: string[]; // modelType names
+  warnings: string[];
+  errors: string[];
+  isAutoSelecting: boolean;
+}
+
+export interface EnhancedModelsResponse {
+  models: ModelInfo[];
+  pagination: {
+    page: number;
+    limit: number;
+    total_count: number;
+    total_pages: number;
+    has_next: boolean;
+    has_prev: boolean;
+  };
+  statistics: any;
+  auto_selection?: AutoSelectionState;
 }
 
 export interface QueueStatus {
@@ -567,7 +611,7 @@ class ApiClient {
   }
 
   // Model management
-  async getModels(type?: string, loaded?: boolean, page: number = 1, limit: number = -1, search?: string): Promise<{ models: ModelInfo[]; pagination: any; statistics: any }> {
+  async getModels(type?: string, loaded?: boolean, page: number = 1, limit: number = -1, search?: string): Promise<EnhancedModelsResponse> {
     const cacheKey = `models_${type || 'all'}_${loaded ? 'loaded' : 'all'}_${page}_${limit}_${search || 'all'}`;
     const cachedResult = cache.get(cacheKey);
     if (cachedResult) {
@@ -595,18 +639,7 @@ class ApiClient {
 
     if (params.length > 0) endpoint += '?' + params.join('&');
 
-    const response = await this.request<{
-      models: ModelInfo[];
-      pagination: {
-        page: number;
-        limit: number;
-        total_count: number;
-        total_pages: number;
-        has_next: boolean;
-        has_prev: boolean
-      };
-      statistics: any;
-    }>(endpoint);
+    const response = await this.request<EnhancedModelsResponse>(endpoint);
 
     const models = response.models.map(model => ({
       ...model,
@@ -616,9 +649,8 @@ class ApiClient {
     }));
 
     const result = {
+      ...response,
       models,
-      pagination: response.pagination,
-      statistics: response.statistics || {}
     };
 
     // Cache models for 30 seconds as they don't change frequently
@@ -627,6 +659,66 @@ class ApiClient {
     return result;
   }
 
+  // Get models with automatic selection information
+  async getModelsForAutoSelection(checkpointModel?: string): Promise<EnhancedModelsResponse> {
+    const cacheKey = `models_auto_selection_${checkpointModel || 'none'}`;
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
+    let endpoint = '/models';
+    const params = [];
+    params.push('include_metadata=true');
+    params.push('include_requirements=true');
+    
+    if (checkpointModel) {
+      params.push(`checkpoint=${encodeURIComponent(checkpointModel)}`);
+    }
+    params.push('limit=0'); // Get all models
+
+    if (params.length > 0) endpoint += '?' + params.join('&');
+
+    const response = await this.request<EnhancedModelsResponse>(endpoint);
+
+    const models = response.models.map(model => ({
+      ...model,
+      id: model.sha256_short || model.name,
+      size: model.file_size || model.size,
+      path: model.path || model.name,
+    }));
+
+    const result = {
+      ...response,
+      models,
+    };
+
+    // Cache for 30 seconds
+    cache.set(cacheKey, result, 30000);
+
+    return result;
+  }
+
+  // Utility function to get models by type
+  getModelsByType(models: ModelInfo[], type: string): ModelInfo[] {
+    return models.filter(model =>
+      model.type.toLowerCase() === type.toLowerCase()
+    );
+  }
+
+  // Utility function to find models by name pattern
+  findModelsByName(models: ModelInfo[], namePattern: string): ModelInfo[] {
+    const pattern = namePattern.toLowerCase();
+    return models.filter(model =>
+      model.name.toLowerCase().includes(pattern)
+    );
+  }
+
+  // Utility function to get loaded models by type
+  getLoadedModelsByType(models: ModelInfo[], type: string): ModelInfo[] {
+    return this.getModelsByType(models, type).filter(model => model.loaded);
+  }
+
   // Get all models (for backward compatibility)
   async getAllModels(type?: string, loaded?: boolean): Promise<ModelInfo[]> {
     const allModels: ModelInfo[] = [];

+ 247 - 0
webui/lib/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
+    };
+  }
+}

+ 471 - 0
webui/package-lock.json

@@ -8,6 +8,7 @@
       "name": "webui",
       "version": "0.1.0",
       "dependencies": {
+        "@radix-ui/react-select": "^2.2.6",
         "@radix-ui/react-tabs": "^1.1.13",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
@@ -458,6 +459,44 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.3",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+      "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+      "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.3",
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/react-dom": {
+      "version": "2.1.6",
+      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
+      "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/dom": "^1.7.4"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+      "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+      "license": "MIT"
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1193,12 +1232,41 @@
         "node": ">=12.4.0"
       }
     },
+    "node_modules/@radix-ui/number": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+      "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+      "license": "MIT"
+    },
     "node_modules/@radix-ui/primitive": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
       "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
       "license": "MIT"
     },
+    "node_modules/@radix-ui/react-arrow": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+      "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-collection": {
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1270,6 +1338,73 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-dismissable-layer": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+      "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-escape-keydown": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-focus-guards": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+      "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-focus-scope": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+      "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-id": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@@ -1288,6 +1423,62 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-popper": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+      "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/react-dom": "^2.0.0",
+        "@radix-ui/react-arrow": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-layout-effect": "1.1.1",
+        "@radix-ui/react-use-rect": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1",
+        "@radix-ui/rect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-portal": {
+      "version": "1.1.9",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+      "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-presence": {
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
@@ -1366,6 +1557,49 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-select": {
+      "version": "2.2.6",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+      "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/number": "1.1.1",
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-collection": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-dismissable-layer": "1.1.11",
+        "@radix-ui/react-focus-guards": "1.1.3",
+        "@radix-ui/react-focus-scope": "1.1.7",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-popper": "1.2.8",
+        "@radix-ui/react-portal": "1.1.9",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-slot": "1.2.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-visually-hidden": "1.2.3",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.6.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-slot": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -1466,6 +1700,24 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-use-escape-keydown": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+      "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-callback-ref": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-use-layout-effect": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
@@ -1481,6 +1733,86 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-use-previous": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+      "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-rect": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+      "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/rect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-size": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+      "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-visually-hidden": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+      "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/rect": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+      "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+      "license": "MIT"
+    },
     "node_modules/@rtsao/scc": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2463,6 +2795,18 @@
       "dev": true,
       "license": "Python-2.0"
     },
+    "node_modules/aria-hidden": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+      "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/aria-query": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -3073,6 +3417,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/detect-node-es": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+      "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+      "license": "MIT"
+    },
     "node_modules/doctrine": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3980,6 +4330,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/get-nonce": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+      "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/get-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -5740,6 +6099,75 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/react-remove-scroll": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
+      "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
+      "license": "MIT",
+      "dependencies": {
+        "react-remove-scroll-bar": "^2.3.7",
+        "react-style-singleton": "^2.2.3",
+        "tslib": "^2.1.0",
+        "use-callback-ref": "^1.3.3",
+        "use-sidecar": "^1.1.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-remove-scroll-bar": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+      "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+      "license": "MIT",
+      "dependencies": {
+        "react-style-singleton": "^2.2.2",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-style-singleton": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+      "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "get-nonce": "^1.0.0",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/reflect.getprototypeof": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6708,6 +7136,49 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/use-callback-ref": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+      "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/use-sidecar": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+      "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "detect-node-es": "^1.1.0",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

+ 1 - 0
webui/package.json

@@ -9,6 +9,7 @@
     "lint": "eslint"
   },
   "dependencies": {
+    "@radix-ui/react-select": "^2.2.6",
     "@radix-ui/react-tabs": "^1.1.13",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",