CoolFace
Apppublic

Leon4gr45/builder

sourceHugging Facemitupdated 2d agoView on Hugging Face
0likes
model-selector.tsx700 linesDownload Raw Back to components
1'use client';2 3import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';4import { Label } from '@/components/ui/label';5import { Badge } from '@/components/ui/badge';6import { logger, cn } from '@/lib/utils';7import { Button } from '@/components/ui/button';8import { Input } from '@/components/ui/input';9import { Switch } from '@/components/ui/switch';10import {11  Popover,12  PopoverContent,13  PopoverTrigger,14} from '@/components/ui/popover';15import {16  Loader2,17  Sparkles,18  Zap,19  Brain,20  Server,21  Cloud,22  Cpu,23  ChevronDown,24  Search,25  X,26  Lightbulb27} from 'lucide-react';28import { configManager } from '@/lib/config/storage';29import { ProviderId, ProviderModel } from '@/lib/llm/providers/types';30import { getProvider } from '@/lib/llm/providers/registry';31import { getAvailableModels } from '@/lib/llm/llm-client';32import {33  fetchAvailableModels,34  formatModelPrice35} from '@/lib/llm/models-api';36import { registerOpenRouterPricingFromApi, registerPricingFromProviderModels } from '@/lib/llm/pricing-cache';37import { toast } from 'sonner';38import { track } from '@/lib/telemetry';39 40interface ModelSelectorProps {41  provider?: ProviderId;42  value?: string;43  onChange?: (modelId: string) => void;44  className?: string;45  hideModelDetails?: boolean;46  mode?: 'popover' | 'inline';47  skipGlobalSync?: boolean;48  autoFocus?: boolean;49}50 51export function ModelSelector({ provider, value: _value, onChange, className, hideModelDetails, mode = 'popover', skipGlobalSync, autoFocus }: ModelSelectorProps) {52  const currentProvider = provider || configManager.getSelectedProvider();53  const providerConfig = getProvider(currentProvider);54  const onChangeRef = useRef(onChange);55  onChangeRef.current = onChange;56  const [models, setModels] = useState<ProviderModel[]>([]);57  const [loading, setLoading] = useState(true);58  const [selectedModel, setSelectedModel] = useState('');59  const [open, setOpen] = useState(false);60  const [searchQuery, setSearchQuery] = useState('');61  const [needsApiKey, setNeedsApiKey] = useState(false);62  const [reasoningEnabled, setReasoningEnabled] = useState(false);63  64  const getModelName = (model: ProviderModel) => {65    return model.name;66  };67 68  // Define loadModels before useEffects that use it69  const loadModels = useCallback(async () => {70    try {71      setLoading(true);72      73      const apiKey = configManager.getProviderApiKey(currentProvider);74      75      if (providerConfig.apiKeyRequired && !apiKey) {76        setNeedsApiKey(true);77        if (providerConfig.models) {78          setModels(providerConfig.models);79        } else {80          setModels([]);81        }82        return;83      }84      85      setNeedsApiKey(false);86      87      // Check cache first88      const cachedModels = configManager.getCachedModels(currentProvider);89      if (cachedModels) {90        const modelsFromCache = cachedModels.models as ProviderModel[];91        setModels(modelsFromCache);92        if (currentProvider === 'openrouter') {93          registerPricingFromProviderModels('openrouter', modelsFromCache);94        }95        return;96      }97      98      let loadedModels: ProviderModel[] = [];99      100      if (currentProvider === 'openrouter') {101        // Use the existing OpenRouter models API102        const availableModels = await fetchAvailableModels();103        registerOpenRouterPricingFromApi(availableModels);104        const norm = (desc: unknown): string => {105          if (typeof desc === 'string') {106            return desc;107          }108 109          if (desc && typeof desc === 'object') {110            const record = desc as Record<string, unknown>;111            const candidate = ['description', 'name', 'summary']112              .map((key) => record[key])113              .find((value): value is string => typeof value === 'string');114 115            if (candidate) {116              return candidate;117            }118 119            try {120              return JSON.stringify(record);121            } catch {122              /* ignore */123            }124          }125 126          if (desc == null) {127            return '';128          }129 130          return String(desc);131        };132        loadedModels = availableModels.map((model) => {133          const promptRate = model.pricing?.prompt ? Number(model.pricing.prompt) : undefined;134          const completionRate = model.pricing?.completion ? Number(model.pricing.completion) : undefined;135          const reasoningRate = model.pricing?.internal_reasoning ? Number(model.pricing.internal_reasoning) : undefined;136 137          const normalizeRate = (value?: number) => {138            if (value === undefined || !Number.isFinite(value)) return undefined;139            return value * 1_000_000;140          };141 142          const normalizedInput = normalizeRate(promptRate);143          const normalizedOutput = normalizeRate(completionRate);144          const normalizedReasoning = normalizeRate(reasoningRate);145 146          const pricing = (normalizedInput !== undefined && normalizedOutput !== undefined)147            ? {148                input: normalizedInput,149                output: normalizedOutput,150                reasoning: normalizedReasoning151              }152            : undefined;153 154          const orModalities = model.architecture?.input_modalities as import('@/lib/llm/providers/types').InputModality[] | undefined;155          const providerModel: ProviderModel = {156            id: model.id,157            name: model.name,158            description: norm(model.description),159            contextLength: model.context_length,160            maxTokens: model.top_provider?.max_completion_tokens,161            supportsFunctions: model.supported_parameters?.includes('tools'),162            supportsVision: orModalities?.includes('image'),163            supportsReasoning: model.supported_parameters?.includes('reasoning'),164            ...(orModalities ? { inputModalities: orModalities } : {}),165            pricing166          };167 168          return providerModel;169        });170      } else if (currentProvider === 'huggingface') {171        try {172          const hfResponse = await fetch('https://router.huggingface.co/v1/models');173          if (hfResponse.ok) {174            const hfData = await hfResponse.json();175            loadedModels = (hfData.data || []).map((model: any) => {176              const hfProviders = model.providers || [];177              const bestProvider = hfProviders.find((p: any) => p.supports_tools && p.status === 'live')178                || hfProviders.find((p: any) => p.status === 'live')179                || hfProviders[0];180 181              const contextLength = bestProvider?.context_length || 32768;182              const supportsFunctions = hfProviders.some((p: any) => p.supports_tools);183              const hfModalities = model.architecture?.input_modalities as import('@/lib/llm/providers/types').InputModality[] | undefined;184              const supportsVision = hfModalities?.includes('image');185 186              let pricing: { input: number; output: number } | undefined;187              if (bestProvider?.pricing?.input != null && bestProvider?.pricing?.output != null) {188                pricing = {189                  input: bestProvider.pricing.input,190                  output: bestProvider.pricing.output,191                };192              }193 194              return {195                id: model.id,196                name: model.id.split('/').pop() || model.id,197                contextLength,198                supportsFunctions,199                supportsVision,200                ...(hfModalities ? { inputModalities: hfModalities } : {}),201                pricing,202              } as ProviderModel;203            });204          }205        } catch (error) {206          logger.error('HuggingFace models fetch error:', error);207        }208        if (loadedModels.length > 0) {209          registerPricingFromProviderModels('huggingface', loadedModels);210        }211      } else if (providerConfig.supportsModelDiscovery) {212        // Try to discover models (we know API key exists at this point)213        const modelEntries = await getAvailableModels(apiKey || undefined, currentProvider);214        loadedModels = modelEntries.map(entry => {215          const id = typeof entry === 'string' ? entry : entry.id;216          const contextLength = typeof entry === 'object' && entry.contextLength ? entry.contextLength : 32000;217          const inputModalities = typeof entry === 'object' && entry.inputModalities218            ? entry.inputModalities as import('@/lib/llm/providers/types').InputModality[]219            : undefined;220          return {221            id,222            name: id.split('/').pop() || id,223            contextLength,224            supportsFunctions: true,225            ...(inputModalities ? { inputModalities, supportsVision: inputModalities.includes('image') } : {}),226          };227        });228      } else if (providerConfig.models) {229        // Use hardcoded models230        loadedModels = providerConfig.models;231      } else {232        loadedModels = [];233      }234      235      setModels(loadedModels);236      237      // Show warning for local providers with no models238      if (providerConfig.isLocal && loadedModels.length === 0) {239        toast.warning(240          `No models found in ${providerConfig.name}. Please load some models in the application.`,241          { duration: 5000 }242        );243      }244      245      // Cache the loaded models246      if (loadedModels.length > 0) {247        configManager.setCachedModels(currentProvider, loadedModels);248        if (currentProvider === 'openrouter') {249          registerPricingFromProviderModels('openrouter', loadedModels);250        }251      }252    } catch (error) {253      logger.error('Failed to load models:', error);254      255      // Show helpful message for local providers256      if (providerConfig.isLocal) {257        toast.error(258          `${providerConfig.name} server not running. Please start the server and load some models.`,259          { duration: 5000 }260        );261      }262      263      // Fall back to hardcoded models if available264      if (providerConfig.models) {265        setModels(providerConfig.models);266      }267    } finally {268      setLoading(false);269    }270  }, [currentProvider, providerConfig]);271 272  // Single effect to handle provider changes and model loading273  useEffect(() => {274    // Immediately clear models array to prevent stale state275    setModels([]);276    setSelectedModel('');277    setLoading(true);278    279    // Clear cache for this provider to get fresh models280    configManager.clearModelCache(currentProvider);281    282    // Load models immediately283    loadModels();284  }, [currentProvider, loadModels]);285 286  // Single effect to initialize selectedModel when models are loaded287  useEffect(() => {288    if (models.length === 0 || loading) return;289 290    // Get the saved model for this provider291    const savedModel = configManager.getProviderModel(currentProvider);292    // Check if saved model exists in loaded models293    if (savedModel && models.some(m => m.id === savedModel)) {294      setSelectedModel(savedModel);295      onChangeRef.current?.(savedModel);296    } else {297      // No saved model or saved model doesn't exist, use first model298      const firstModel = models[0]?.id;299      if (firstModel) {300        setSelectedModel(firstModel);301        if (!skipGlobalSync) {302          configManager.setProviderModel(currentProvider, firstModel);303        }304        onChangeRef.current?.(firstModel);305      }306    }307  // eslint-disable-next-line react-hooks/exhaustive-deps308  }, [models, loading, currentProvider]);309 310  // Method to refresh models (can be called externally)311  const _refreshModels = (forceRefresh = false) => {312    if (forceRefresh) {313      configManager.clearModelCache(currentProvider);314    }315    loadModels();316  };317 318  // Expose refresh method via ref or global method (for when API key is added)319  useEffect(() => {320    const handleApiKeyUpdate = () => {321      // Small delay to ensure config is saved322      setTimeout(() => {323        loadModels();324      }, 100);325    };326 327    // Listen for a custom event when API keys are updated328    window.addEventListener('apiKeyUpdated', handleApiKeyUpdate);329    330    return () => {331      window.removeEventListener('apiKeyUpdated', handleApiKeyUpdate);332    };333  }, [loadModels]);334 335  const handleModelSelect = (modelId: string) => {336    setSelectedModel(modelId);337    if (!skipGlobalSync) {338      configManager.setProviderModel(currentProvider, modelId);339    }340    onChange?.(modelId);341    track('model_selected', {342      provider: currentProvider,343      model: modelId,344      previous_model: selectedModel !== modelId ? selectedModel : undefined,345    });346    if (mode === 'popover') {347      setOpen(false);348      setSearchQuery('');349    }350    // Load reasoning state for the new model351    setReasoningEnabled(configManager.getReasoningEnabled(modelId));352  };353 354  const handleReasoningToggle = (enabled: boolean) => {355    setReasoningEnabled(enabled);356    if (selectedModel) {357      configManager.setReasoningEnabled(selectedModel, enabled);358    }359  };360 361  // Sync reasoning state when selected model changes362  useEffect(() => {363    if (selectedModel) {364      setReasoningEnabled(configManager.getReasoningEnabled(selectedModel));365    }366  }, [selectedModel]);367 368  const getModelIcon = (model: ProviderModel) => {369    const id = model.id.toLowerCase();370    if (id.includes('deepseek')) return <Brain className="h-3 w-3" />;371    if (id.includes('claude')) return <Sparkles className="h-3 w-3" />;372    if (id.includes('gpt')) return <Zap className="h-3 w-3" />;373    if (id.includes('gemini')) return <Cloud className="h-3 w-3" />;374    if (id.includes('llama')) return <Server className="h-3 w-3" />;375    if (id.includes('qwen')) return <Cpu className="h-3 w-3" />;376    return null;377  };378 379  const getProviderColor = (modelId: string) => {380    const id = modelId.toLowerCase();381    if (id.includes('deepseek')) return 'bg-blue-500/10 text-blue-500';382    if (id.includes('claude')) return 'bg-orange-500/10 text-orange-500';383    if (id.includes('openai') || id.includes('gpt')) return 'bg-green-500/10 text-green-500';384    if (id.includes('qwen')) return 'bg-orange-500/10 text-orange-500';385    if (id.includes('google')) return 'bg-red-500/10 text-red-500';386    if (id.includes('meta')) return 'bg-indigo-500/10 text-indigo-500';387    return 'bg-gray-500/10 text-gray-500';388  };389 390  // Filter models based on search query391  const filteredModels = useMemo(() => {392    if (!searchQuery.trim()) return models;393    394    const query = searchQuery.toLowerCase();395    return models.filter(model => {396      const modelId = model.id.toLowerCase();397      const modelName = getModelName(model).toLowerCase();398      const providerName = model.id.split('/')[0].toLowerCase();399      400      return (401        modelId.includes(query) ||402        modelName.includes(query) ||403        providerName.includes(query)404      );405    });406  }, [models, searchQuery]);407 408  const selectedModelData = models.find(m => m.id === selectedModel);409 410  if (loading) {411    return (412      <div className={className}>413        <Label>AI Model</Label>414        <div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted">415          <Loader2 className="h-4 w-4 animate-spin" />416          <span className="text-sm text-muted-foreground">Loading models...</span>417        </div>418      </div>419    );420  }421 422  if (needsApiKey) {423    return (424      <div className={className}>425        <Label>AI Model</Label>426        <div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted/50 border-orange-200 dark:border-orange-800">427          <span className="text-sm text-orange-600 dark:text-orange-400">428            API key required for {providerConfig.name}429          </span>430        </div>431        <p className="text-xs text-muted-foreground mt-1">432          Set your API key in settings to load available models433        </p>434      </div>435    );436  }437 438  const renderModelItem = (model: ProviderModel) => (439    <button440      key={model.id}441      onClick={() => handleModelSelect(model.id)}442      className={cn(443        "w-full text-left px-3 py-2 transition-colors rounded-lg",444        mode === 'inline'445          ? selectedModel === model.id446            ? "bg-primary/10 border border-primary/30"447            : "hover:bg-accent border border-transparent"448          : selectedModel === model.id449            ? "bg-accent"450            : "hover:bg-accent hover:text-accent-foreground"451      )}452    >453      <div className="flex flex-col gap-0.5">454        <div className="flex items-center gap-2">455          {getModelIcon(model)}456          <span className={cn("font-medium text-sm", selectedModel === model.id && mode === 'inline' && "text-primary")}>457            {getModelName(model)}458          </span>459          {currentProvider === 'openrouter' && (460            <Badge variant="secondary" className={`text-xs ${getProviderColor(model.id)}`}>461              {model.id.split('/')[0]}462            </Badge>463          )}464          {model.supportsFunctions === false && (465            <Badge variant="outline" className="text-[9px] px-1 py-0 text-muted-foreground/50">No tools</Badge>466          )}467        </div>468        <div className="flex items-center gap-3 text-xs text-muted-foreground">469          <span>Context: {Math.round(model.contextLength / 1000)}K</span>470          {model.pricing && (471            model.pricing.input === 0 && model.pricing.output === 0 ? (472              <>473                <span>·</span>474                <span>Free</span>475              </>476            ) : (477              <>478                <span>·</span>479                <span>480                  {formatModelPrice(model.pricing.input)}/K | {formatModelPrice(model.pricing.output)}/K481                </span>482              </>483            )484          )}485          {!model.pricing && currentProvider !== 'openrouter' && (486            <>487              <span>·</span>488              <span>Pricing varies</span>489            </>490          )}491        </div>492      </div>493    </button>494  );495 496  const modelDetailsSection = !hideModelDetails && selectedModelData && (497    <div className="mt-1 text-xs text-muted-foreground max-h-[150px] overflow-y-auto pr-2">498      <div className="font-medium mb-1">499        {selectedModelData.pricing ? (500          selectedModelData.pricing.input === 0 && selectedModelData.pricing.output === 0 ?501            'Free' :502            `Input: ${formatModelPrice(selectedModelData.pricing.input)}/K • Output: ${formatModelPrice(selectedModelData.pricing.output)}/K`503        ) : (504          'Pricing varies by provider'505        )}506      </div>507      <div className="flex flex-wrap gap-1 my-1">508        {selectedModelData.supportsFunctions && (509          <Badge variant="outline" className="text-[10px] px-1.5 py-0">Tools</Badge>510        )}511        {selectedModelData.supportsVision && (512          <Badge variant="outline" className="text-[10px] px-1.5 py-0">Vision</Badge>513        )}514        {selectedModelData.supportsReasoning && (515          <Badge variant="outline" className="text-[10px] px-1.5 py-0">Reasoning</Badge>516        )}517        {!selectedModelData.supportsFunctions && (518          <Badge variant="outline" className="text-[10px] px-1.5 py-0 text-muted-foreground/60">No native tools</Badge>519        )}520      </div>521      {selectedModelData.description && (522        <div>{selectedModelData.description}</div>523      )}524    </div>525  );526 527  const reasoningSection = selectedModelData?.supportsReasoning && (528    <div className="mt-3 flex items-center justify-between gap-2 p-2 rounded-md bg-muted/50 border">529      <div className="flex items-center gap-2">530        <Lightbulb className="h-4 w-4 text-amber-500" />531        <div>532          <Label htmlFor="reasoning-toggle" className="text-sm font-medium cursor-pointer">533            Enable Reasoning534          </Label>535          <p className="text-xs text-muted-foreground">536            Show step-by-step thinking process537          </p>538        </div>539      </div>540      <Switch541        id="reasoning-toggle"542        checked={reasoningEnabled}543        onCheckedChange={handleReasoningToggle}544      />545    </div>546  );547 548  // --- Inline mode ---549  if (mode === 'inline') {550    return (551      <div className={className}>552        <div className="border rounded-lg overflow-hidden">553          {/* Search bar */}554          <div className="flex items-center border-b px-3">555            <Search className="h-3.5 w-3.5 shrink-0 opacity-50" />556            <Input557              placeholder="Search models..."558              value={searchQuery}559              onChange={(e) => setSearchQuery(e.target.value)}560              className="h-9 border-0 bg-transparent dark:bg-transparent shadow-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"561              autoFocus={autoFocus}562            />563            {searchQuery && (564              <Button565                variant="ghost"566                size="sm"567                onClick={() => setSearchQuery('')}568                className="h-5 w-5 p-0"569              >570                <X className="h-3 w-3" />571              </Button>572            )}573          </div>574          {/* Model list */}575          <div className="max-h-[240px] overflow-y-auto p-1">576            {filteredModels.length === 0 ? (577              <div className="py-5 text-center text-sm text-muted-foreground">578                No models found579              </div>580            ) : (581              filteredModels.map(renderModelItem)582            )}583          </div>584        </div>585        {modelDetailsSection}586        {reasoningSection}587      </div>588    );589  }590 591  // --- Popover mode (default) ---592  return (593    <div className={className}>594      <Label htmlFor="model-select">AI Model</Label>595      <Popover open={open} onOpenChange={setOpen}>596        <PopoverTrigger asChild>597          <Button598            variant="outline"599            role="combobox"600            aria-expanded={open}601            className="justify-between font-normal min-w-[200px]"602          >603            {selectedModelData ? (604              <div className="flex items-center gap-2 truncate">605                {getModelIcon(selectedModelData)}606                <span className="truncate">{getModelName(selectedModelData)}</span>607              </div>608            ) : (609              <span className="text-muted-foreground">Select a model...</span>610            )}611            <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />612          </Button>613        </PopoverTrigger>614        <PopoverContent615          className="w-[32rem] p-0"616          align="start"617          side="bottom"618          sideOffset={5}619        >620          <div className="flex items-center border-b px-3">621            <Search className="h-4 w-4 shrink-0 opacity-50" />622            <Input623              placeholder="Search models..."624              value={searchQuery}625              onChange={(e) => setSearchQuery(e.target.value)}626              className="h-10 border-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"627            />628            {searchQuery && (629              <Button630                variant="ghost"631                size="sm"632                onClick={() => setSearchQuery('')}633                className="h-5 w-5 p-0"634              >635                <X className="h-3 w-3" />636              </Button>637            )}638          </div>639          <div className="max-h-[400px] min-h-[300px] overflow-y-auto">640            {filteredModels.length === 0 ? (641              <div className="py-6 text-center text-sm text-muted-foreground">642                No models found643              </div>644            ) : (645              filteredModels.map((model) => (646                <button647                  key={model.id}648                  onClick={() => handleModelSelect(model.id)}649                  className={cn(650                    "w-full text-left px-3 py-3 hover:bg-accent hover:text-accent-foreground transition-colors",651                    selectedModel === model.id && "bg-accent"652                  )}653                >654                  <div className="flex flex-col gap-1">655                    <div className="flex items-center gap-2">656                      {getModelIcon(model)}657                      <span className="font-medium">{getModelName(model)}</span>658                      {currentProvider === 'openrouter' && (659                        <Badge variant="secondary" className={`text-xs ${getProviderColor(model.id)}`}>660                          {model.id.split('/')[0]}661                        </Badge>662                      )}663                    </div>664                    <div className="flex items-center gap-3 text-xs text-muted-foreground">665                      <span>Context: {Math.round(model.contextLength / 1000)}K</span>666                      {model.pricing && (667                        model.pricing.input === 0 && model.pricing.output === 0 ? (668                          <>669                            <span>•</span>670                            <span>Free</span>671                          </>672                        ) : (673                          <>674                            <span>•</span>675                            <span>676                              {formatModelPrice(model.pricing.input)}/K | {formatModelPrice(model.pricing.output)}/K677                            </span>678                          </>679                        )680                      )}681                      {!model.pricing && currentProvider !== 'openrouter' && (682                        <>683                          <span>•</span>684                          <span>Pricing varies</span>685                        </>686                      )}687                    </div>688                  </div>689                </button>690              ))691            )}692          </div>693        </PopoverContent>694      </Popover>695      {modelDetailsSection}696      {reasoningSection}697    </div>698  );699}700