'use client'; import React, { useState, useRef, useEffect } from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash } from 'lucide-react'; import { ModelOption, SubscriptionStatus, STORAGE_KEY_MODEL, STORAGE_KEY_CUSTOM_MODELS, DEFAULT_FREE_MODEL_ID, DEFAULT_PREMIUM_MODEL_ID, formatModelName, getCustomModels, MODELS // Import the centralized MODELS constant } from './_use-model-selection'; import { PaywallDialog } from '@/components/payment/paywall-dialog'; import { cn } from '@/lib/utils'; import { useRouter } from 'next/navigation'; import { isLocalMode } from '@/lib/config'; import { CustomModelDialog, CustomModelFormData } from './custom-model-dialog'; interface CustomModel { id: string; label: string; } interface ModelSelectorProps { selectedModel: string; onModelChange: (modelId: string) => void; modelOptions: ModelOption[]; canAccessModel: (modelId: string) => boolean; subscriptionStatus: SubscriptionStatus; refreshCustomModels?: () => void; } export const ModelSelector: React.FC = ({ selectedModel, onModelChange, modelOptions, canAccessModel, subscriptionStatus, refreshCustomModels, }) => { const [paywallOpen, setPaywallOpen] = useState(false); const [lockedModel, setLockedModel] = useState(null); const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(-1); const searchInputRef = useRef(null); const router = useRouter(); // Custom models state const [customModels, setCustomModels] = useState([]); const [isCustomModelDialogOpen, setIsCustomModelDialogOpen] = useState(false); const [dialogInitialData, setDialogInitialData] = useState({ id: '', label: '' }); const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add'); const [editingModelId, setEditingModelId] = useState(null); // Load custom models from localStorage on component mount useEffect(() => { if (isLocalMode()) { setCustomModels(getCustomModels()); } }, []); // Save custom models to localStorage whenever they change useEffect(() => { if (isLocalMode() && customModels.length > 0) { localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(customModels)); } }, [customModels]); // Get current custom models from state const currentCustomModels = customModels || []; // Enhance model options with capabilities - using a Map to ensure uniqueness const modelMap = new Map(); // First add all standard models to the map modelOptions.forEach(model => { modelMap.set(model.id, { ...model, isCustom: false }); }); // Then add custom models from the current customModels state (not from props) // This ensures we're using the most up-to-date list of custom models if (isLocalMode()) { // Get current custom models from state (not from storage) customModels.forEach(model => { // Only add if it doesn't exist or mark it as a custom model if it does if (!modelMap.has(model.id)) { modelMap.set(model.id, { id: model.id, label: model.label || formatModelName(model.id), requiresSubscription: false, top: false, isCustom: true }); } else { // If it already exists (rare case), mark it as a custom model const existingModel = modelMap.get(model.id); modelMap.set(model.id, { ...existingModel, isCustom: true }); } }); } // Convert map back to array const enhancedModelOptions = Array.from(modelMap.values()); // Filter models based on search query const filteredOptions = enhancedModelOptions.filter((opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || opt.id.toLowerCase().includes(searchQuery.toLowerCase()) ); // Get free models from modelOptions (helper function) const getFreeModels = () => modelOptions.filter(m => !m.requiresSubscription).map(m => m.id); // No sorting needed - models are already sorted in the hook const sortedModels = filteredOptions; // Simplified premium models function - just filter without sorting const getPremiumModels = () => { return modelOptions .filter(m => m.requiresSubscription) .map((m, index) => ({ ...m, uniqueKey: getUniqueModelKey(m, index) })); } // Make sure model IDs are unique for rendering const getUniqueModelKey = (model: any, index: number): string => { return `model-${model.id}-${index}`; }; // Map models to ensure unique IDs for React keys const uniqueModels = sortedModels.map((model, index) => ({ ...model, uniqueKey: getUniqueModelKey(model, index) })); useEffect(() => { if (isOpen && searchInputRef.current) { setTimeout(() => { searchInputRef.current?.focus(); }, 50); } else { setSearchQuery(''); setHighlightedIndex(-1); } }, [isOpen]); const selectedLabel = enhancedModelOptions.find((o) => o.id === selectedModel)?.label || 'Select model'; const handleSelect = (id: string) => { // Check if it's a custom model const isCustomModel = customModels.some(model => model.id === id); // Custom models are always accessible in local mode if (isCustomModel && isLocalMode()) { onModelChange(id); setIsOpen(false); return; } // Otherwise use the regular canAccessModel check if (canAccessModel(id)) { onModelChange(id); setIsOpen(false); } else { setLockedModel(id); setPaywallOpen(true); } }; const handleUpgradeClick = () => { router.push('/settings/billing'); }; const closeDialog = () => { setPaywallOpen(false); setLockedModel(null); }; const handleSearchInputKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : 0 ); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedIndex((prev) => prev > 0 ? prev - 1 : filteredOptions.length - 1 ); } else if (e.key === 'Enter' && highlightedIndex >= 0) { e.preventDefault(); const selectedOption = filteredOptions[highlightedIndex]; if (selectedOption) { handleSelect(selectedOption.id); } } }; const premiumModels = sortedModels.filter(m => !getFreeModels().some(id => m.id.includes(id))); const shouldDisplayAll = (!isLocalMode() && subscriptionStatus === 'no_subscription') && premiumModels.length > 0; // Handle opening the custom model dialog const openAddCustomModelDialog = (e?: React.MouseEvent) => { e?.stopPropagation(); setDialogInitialData({ id: '', label: '' }); setDialogMode('add'); setIsCustomModelDialogOpen(true); setIsOpen(false); // Close dropdown when opening modal }; // Handle opening the edit model dialog const openEditCustomModelDialog = (model: CustomModel, e?: React.MouseEvent) => { e?.stopPropagation(); setDialogInitialData({ id: model.id, label: model.label }); setEditingModelId(model.id); // Keep the original ID with prefix for reference setDialogMode('edit'); setIsCustomModelDialogOpen(true); setIsOpen(false); // Close dropdown when opening modal }; // Handle saving a custom model const handleSaveCustomModel = (formData: CustomModelFormData) => { // Get model ID without automatically adding prefix const modelId = formData.id.trim(); // Generate display name based on model ID (remove prefix if present for display name) const displayId = modelId.startsWith('openrouter/') ? modelId.replace('openrouter/', '') : modelId; const modelLabel = formData.label.trim() || formatModelName(displayId); if (!modelId) return; // Check for duplicates - only for new models or if ID changed during edit const checkId = modelId; if (customModels.some(model => model.id === checkId && (dialogMode === 'add' || model.id !== editingModelId))) { console.error('A model with this ID already exists'); return; } // First close the dialog to prevent UI issues closeCustomModelDialog(); // Create the new model object const newModel = { id: modelId, label: modelLabel }; // Update models array (add new or update existing) const updatedModels = dialogMode === 'add' ? [...customModels, newModel] : customModels.map(model => model.id === editingModelId ? newModel : model); // Save to localStorage first try { localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedModels)); } catch (error) { console.error('Failed to save custom models to localStorage:', error); } // Update state with new models setCustomModels(updatedModels); // Refresh custom models in the parent hook if the function is available if (refreshCustomModels) { refreshCustomModels(); } // Handle model selection changes if (dialogMode === 'add') { // Always select newly added models onModelChange(modelId); // Also save the selection to localStorage try { localStorage.setItem(STORAGE_KEY_MODEL, modelId); } catch (error) { console.warn('Failed to save selected model to localStorage:', error); } } else if (selectedModel === editingModelId) { // For edits, only update if the edited model was selected onModelChange(modelId); try { localStorage.setItem(STORAGE_KEY_MODEL, modelId); } catch (error) { console.warn('Failed to save selected model to localStorage:', error); } } // Force dropdown to close to ensure fresh data on next open setIsOpen(false); // Force a UI refresh by delaying the state update setTimeout(() => { setHighlightedIndex(-1); }, 0); }; // Handle closing the custom model dialog const closeCustomModelDialog = () => { setIsCustomModelDialogOpen(false); setDialogInitialData({ id: '', label: '' }); setEditingModelId(null); // Improved fix for pointer-events issue: ensure dialog closes properly document.body.classList.remove('overflow-hidden'); const bodyStyle = document.body.style; setTimeout(() => { bodyStyle.pointerEvents = ''; bodyStyle.removeProperty('pointer-events'); }, 150); }; // Handle deleting a custom model const handleDeleteCustomModel = (modelId: string, e?: React.MouseEvent) => { e?.stopPropagation(); e?.preventDefault(); // Filter out the model to delete const updatedCustomModels = customModels.filter(model => model.id !== modelId); // Update localStorage first to ensure data consistency if (isLocalMode() && typeof window !== 'undefined') { try { localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedCustomModels)); } catch (error) { console.error('Failed to update custom models in localStorage:', error); } } // Update state with the new list setCustomModels(updatedCustomModels); // Refresh custom models in the parent hook if the function is available if (refreshCustomModels) { refreshCustomModels(); } // Check if we need to change the selected model if (selectedModel === modelId) { const defaultModel = isLocalMode() ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; onModelChange(defaultModel); try { localStorage.setItem(STORAGE_KEY_MODEL, defaultModel); } catch (error) { console.warn('Failed to update selected model in localStorage:', error); } } // Force dropdown to close setIsOpen(false); // Update the modelMap and recreate enhancedModelOptions on next render // This will force a complete refresh of the model list setTimeout(() => { // Force React to fully re-evaluate the component with fresh data setHighlightedIndex(-1); // Reopen dropdown with fresh data if it was open if (isOpen) { setIsOpen(false); setTimeout(() => setIsOpen(true), 50); } }, 10); }; const renderModelOption = (opt: any, index: number) => { // More accurate check for custom models - use the actual customModels array // from both the opt.isCustom flag and by checking if it exists in customModels const isCustom = Boolean(opt.isCustom) || (isLocalMode() && customModels.some(model => model.id === opt.id)); const accessible = isCustom ? true : canAccessModel(opt.id); // Fix the highlighting logic to use the index parameter instead of searching in filteredOptions const isHighlighted = index === highlightedIndex; const isPremium = opt.requiresSubscription; const isLowQuality = MODELS[opt.id]?.lowQuality || false; const isRecommended = MODELS[opt.id]?.recommended || false; return (
handleSelect(opt.id)} onMouseEnter={() => setHighlightedIndex(index)} >
{opt.label}
{/* Show capabilities */} {isLowQuality && ( )} {isRecommended && ( Recommended )} {isPremium && !accessible && ( )} {/* Custom model actions */} {isLocalMode() && isCustom && ( <> )} {selectedModel === opt.id && ( )}
{!accessible ? (

Requires subscription to access premium model

) : isLowQuality ? (

Not recommended for complex tasks

) : isRecommended ? (

Recommended for optimal performance

) : isCustom ? (

Custom model

) : null}
); }; // Update filtered options when customModels or search query changes useEffect(() => { // Force reset of enhancedModelOptions whenever customModels change // The next render will regenerate enhancedModelOptions with the updated modelMap setHighlightedIndex(-1); setSearchQuery(''); // Force React to fully re-evaluate the component rendering if (isOpen) { // If dropdown is open, briefly close and reopen to force refresh setIsOpen(false); setTimeout(() => setIsOpen(true), 10); } }, [customModels, modelOptions]); // Also depend on modelOptions to refresh when parent changes return (
{/* Completely separate views for subscribers and non-subscribers */} {shouldDisplayAll ? ( /* No Subscription View */
{/* Available Models Section - ONLY hardcoded free models */}
Available Models
{/* Only show free models */} {uniqueModels .filter(m => !m.requiresSubscription && (m.label.toLowerCase().includes(searchQuery.toLowerCase()) || m.id.toLowerCase().includes(searchQuery.toLowerCase())) ) .map((model, index) => (
onModelChange(model.id)} onMouseEnter={() => setHighlightedIndex(filteredOptions.indexOf(model))} >
{model.label}
{/* Show capabilities */} {(MODELS[model.id]?.lowQuality || false) && ( )} {(MODELS[model.id]?.recommended || false) && ( Recommended )} {selectedModel === model.id && ( )}
{MODELS[model.id]?.lowQuality && (

Basic model with limited capabilities

)}
)) } {/* Premium Models Section */}
Premium Models
{/* Premium models container with paywall overlay */}
{getPremiumModels() .filter(m => m.requiresSubscription && (m.label.toLowerCase().includes(searchQuery.toLowerCase()) || m.id.toLowerCase().includes(searchQuery.toLowerCase())) ) .slice(0, 3) .map((model, index) => (
{model.label}
{/* Show capabilities */} {MODELS[model.id]?.recommended && ( Recommended )}

Requires subscription to access premium model

)) } {/* Absolute positioned paywall overlay with gradient fade */}

Unlock all models + higher limits

) : ( /* Subscription or other status view */
All Models {isLocalMode() && ( Add a custom model )}
{uniqueModels .filter(m => m.label.toLowerCase().includes(searchQuery.toLowerCase()) || m.id.toLowerCase().includes(searchQuery.toLowerCase()) ) // Sort to prioritize recommended paid models first .sort((a, b) => { const aRecommendedPaid = MODELS[a.id]?.recommended && a.requiresSubscription; const bRecommendedPaid = MODELS[b.id]?.recommended && b.requiresSubscription; if (aRecommendedPaid && !bRecommendedPaid) return -1; if (!aRecommendedPaid && bRecommendedPaid) return 1; // Secondary sorting: recommended free models next const aRecommended = MODELS[a.id]?.recommended; const bRecommended = MODELS[b.id]?.recommended; if (aRecommended && !bRecommended) return -1; if (!aRecommended && bRecommended) return 1; // Paid models next if (a.requiresSubscription && !b.requiresSubscription) return -1; if (!a.requiresSubscription && b.requiresSubscription) return 1; // Default to alphabetical order return a.label.localeCompare(b.label); }) .map((model, index) => renderModelOption(model, index))} {uniqueModels.length === 0 && (
No models match your search
)}
)}
{!shouldDisplayAll &&
setSearchQuery(e.target.value)} onKeyDown={handleSearchInputKeyDown} className="w-full h-8 px-8 py-1 rounded-lg text-sm focus:outline-none bg-muted" />
}
{/* Custom Model Dialog - moved to separate component */} {paywallOpen && ( m.id === lockedModel )?.label}` : 'Subscribe to access premium models with enhanced capabilities' } ctaText="Subscribe Now" cancelText="Maybe Later" /> )}
); };