'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 } 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'; // Model capabilities map const MODEL_CAPABILITIES = { 'gpt-4o': { recommended: false, lowQuality: false }, 'sonnet-3.7': { recommended: true, lowQuality: false }, 'qwen3': { recommended: false, lowQuality: true }, 'deepseek': { recommended: false, lowQuality: true }, 'gemini-2.5': { recommended: true, lowQuality: false }, 'gemini-2.5-flash-preview': { recommended: true, lowQuality: false }, 'gemini-2.5-pro-preview': { recommended: true, lowQuality: false }, 'deepseek-chat-v3-0324': { recommended: true, lowQuality: false }, 'google/gemini-2.5-flash-preview': { recommended: false, lowQuality: true }, 'claude-3.5': { recommended: true, lowQuality: false }, 'claude-3.7': { recommended: true, lowQuality: false }, 'claude-3.7-reasoning': { recommended: true, lowQuality: false }, 'gemini-2.5-pro': { recommended: true, lowQuality: false }, 'grok-3-mini': { recommended: false, lowQuality: true }, }; interface CustomModel { id: string; label: string; } interface ModelSelectorProps { selectedModel: string; onModelChange: (modelId: string) => void; modelOptions: ModelOption[]; canAccessModel: (modelId: string) => boolean; subscriptionStatus: SubscriptionStatus; } export const ModelSelector: React.FC = ({ selectedModel, onModelChange, modelOptions, canAccessModel, subscriptionStatus, }) => { 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 const enhancedModelOptions = [...modelOptions, ...currentCustomModels // Only add custom models that aren't already in modelOptions .filter(model => !modelOptions.some(m => m.id === model.id)) .map(model => ({ ...model, requiresSubscription: false, top: false, isCustom: true }))].map(model => { const baseCapabilities = MODEL_CAPABILITIES[model.id] || { recommended: false, lowQuality: false }; return { ...model, capabilities: baseCapabilities }; }); const filteredOptions = enhancedModelOptions.filter((opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || opt.id.toLowerCase().includes(searchQuery.toLowerCase()) ); // Get free models from modelOptions const getFreeModels = () => modelOptions.filter(m => !m.requiresSubscription).map(m => m.id); // Sort models - free first, then premium, then by name const sortedModels = filteredOptions.sort((a, b) => { // First by free/premium status const aIsFree = getFreeModels().some(id => a.id.includes(id)) || !a.requiresSubscription; const bIsFree = getFreeModels().some(id => b.id.includes(id)) || !b.requiresSubscription; if (aIsFree !== bIsFree) { return aIsFree ? -1 : 1; } // Then by "top" status if (a.top !== b.top) { return a.top ? -1 : 1; } // Finally by name return a.label.localeCompare(b.label); }); // 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 isLowQualitySelected = MODEL_CAPABILITIES[selectedModel]?.lowQuality || false; 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(); // Remove openrouter/ prefix when showing in the edit form const displayModelId = model.id.startsWith('openrouter/') ? model.id.replace('openrouter/', '') : model.id; setDialogInitialData({ id: displayModelId, 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) => { // Ensure modelId is properly formatted let modelId = formData.id.trim(); // For add mode, always add the prefix if missing if (dialogMode === 'add') { if (!modelId.startsWith('openrouter/')) { modelId = `openrouter/${modelId}`; } } else { // For edit mode, maintain the prefix status of the original ID const originalHadPrefix = editingModelId?.startsWith('openrouter/') || false; if (originalHadPrefix && !modelId.startsWith('openrouter/')) { modelId = `openrouter/${modelId}`; } } const modelLabel = formData.label.trim() || formatModelName(modelId.replace('openrouter/', '')); 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(); // Update models array (add new or update existing) const updatedModels = dialogMode === 'add' ? [...customModels, { id: modelId, label: modelLabel }] : customModels.map(model => model.id === editingModelId ? { id: modelId, label: modelLabel } : model); // Update state setCustomModels(updatedModels); // Save to storage first try { localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedModels)); } catch (error) { console.error('Failed to save custom models to localStorage:', error); } // Force a UI update cycle then update selection // IMPORTANT: Use requestAnimationFrame to ensure DOM updates before selection changes if (dialogMode === 'add') { // Always select newly added models requestAnimationFrame(() => { // Direct model change for immediate effect 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 requestAnimationFrame(() => { onModelChange(modelId); }); } }; // 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(); // Close dropdown first to force a refresh on next open setIsOpen(false); // Filter out the model to delete const updatedCustomModels = customModels.filter(model => model.id !== modelId); // Update state setCustomModels(updatedCustomModels); // Force a UI update by using requestAnimationFrame requestAnimationFrame(() => { // Update localStorage directly 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); } } // If the deleted model was selected, switch to a default 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); } } }); }; const renderModelOption = (opt: any, index: number) => { // Custom models are always accessible in local mode const isCustomModel = customModels.some(model => model.id === opt.id); const accessible = isCustomModel ? 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 = MODEL_CAPABILITIES[opt.id]?.lowQuality || false; const isRecommended = MODEL_CAPABILITIES[opt.id]?.recommended || false; return (
handleSelect(opt.id)} onMouseEnter={() => setHighlightedIndex(index)} >
{opt.label}
{/* Show capabilities */} {isLowQuality && ( )} {isRecommended && ( )} {isPremium && !accessible && ( )} {/* Custom model actions */} {isLocalMode() && isCustomModel && ( <> )} {selectedModel === opt.id && ( )}
{!accessible ? (

Requires subscription to access premium model

) : isLowQuality ? (

Not recommended for complex tasks

) : isRecommended ? (

Recommended for optimal performance

) : isCustomModel ? (

Custom model

) : null}
); }; // Update filtered options when customModels changes useEffect(() => { // Recalculate filtered options when custom models change const newFilteredOptions = enhancedModelOptions.filter((opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || opt.id.toLowerCase().includes(searchQuery.toLowerCase()) ); // Force the component to re-render with new data setHighlightedIndex(-1); }, [customModels, searchQuery]); 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 */} {MODEL_CAPABILITIES[model.id]?.lowQuality && ( )} {MODEL_CAPABILITIES[model.id]?.recommended && ( )} {selectedModel === model.id && ( )}
{MODEL_CAPABILITIES[model.id]?.lowQuality && (

Basic model with limited capabilities

)}
)) } {/* Premium Models Section */}
Premium Models
{/* Premium models container with paywall overlay */}
{uniqueModels .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 */} {MODEL_CAPABILITIES[model.id]?.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() && ( )}
{uniqueModels.filter(m => m.label.toLowerCase().includes(searchQuery.toLowerCase()) || m.id.toLowerCase().includes(searchQuery.toLowerCase()) ).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" /> )}
); };