Leon4gr45/builder
0
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 