legends810/testingnew
0
1import type { ProviderInfo } from '~/types/model';2import { useEffect, useState, useRef } from 'react';3import type { KeyboardEvent } from 'react';4import type { ModelInfo } from '~/lib/modules/llm/types';5import { classNames } from '~/utils/classNames';6import * as React from 'react';7 8interface ModelSelectorProps {9 model?: string;10 setModel?: (model: string) => void;11 provider?: ProviderInfo;12 setProvider?: (provider: ProviderInfo) => void;13 modelList: ModelInfo[];14 providerList: ProviderInfo[];15 apiKeys: Record<string, string>;16 modelLoading?: string;17}18 19export const ModelSelector = ({20 model,21 setModel,22 provider,23 setProvider,24 modelList,25 providerList,26 modelLoading,27}: ModelSelectorProps) => {28 const [modelSearchQuery, setModelSearchQuery] = useState('');29 const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);30 const [focusedIndex, setFocusedIndex] = useState(-1);31 const searchInputRef = useRef<HTMLInputElement>(null);32 const optionsRef = useRef<(HTMLDivElement | null)[]>([]);33 const dropdownRef = useRef<HTMLDivElement>(null);34 35 useEffect(() => {36 const handleClickOutside = (event: MouseEvent) => {37 if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {38 setIsModelDropdownOpen(false);39 setModelSearchQuery('');40 }41 };42 43 document.addEventListener('mousedown', handleClickOutside);44 45 return () => document.removeEventListener('mousedown', handleClickOutside);46 }, []);47 48 // Filter models based on search query49 const filteredModels = [...modelList]50 .filter((e) => e.provider === provider?.name && e.name)51 .filter(52 (model) =>53 model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||54 model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),55 );56 57 // Reset focused index when search query changes or dropdown opens/closes58 useEffect(() => {59 setFocusedIndex(-1);60 }, [modelSearchQuery, isModelDropdownOpen]);61 62 // Focus search input when dropdown opens63 useEffect(() => {64 if (isModelDropdownOpen && searchInputRef.current) {65 searchInputRef.current.focus();66 }67 }, [isModelDropdownOpen]);68 69 // Handle keyboard navigation70 const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {71 if (!isModelDropdownOpen) {72 return;73 }74 75 switch (e.key) {76 case 'ArrowDown':77 e.preventDefault();78 setFocusedIndex((prev) => {79 const next = prev + 1;80 81 if (next >= filteredModels.length) {82 return 0;83 }84 85 return next;86 });87 break;88 89 case 'ArrowUp':90 e.preventDefault();91 setFocusedIndex((prev) => {92 const next = prev - 1;93 94 if (next < 0) {95 return filteredModels.length - 1;96 }97 98 return next;99 });100 break;101 102 case 'Enter':103 e.preventDefault();104 105 if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {106 const selectedModel = filteredModels[focusedIndex];107 setModel?.(selectedModel.name);108 setIsModelDropdownOpen(false);109 setModelSearchQuery('');110 }111 112 break;113 114 case 'Escape':115 e.preventDefault();116 setIsModelDropdownOpen(false);117 setModelSearchQuery('');118 break;119 120 case 'Tab':121 if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {122 setIsModelDropdownOpen(false);123 }124 125 break;126 }127 };128 129 // Focus the selected option130 useEffect(() => {131 if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {132 optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });133 }134 }, [focusedIndex]);135 136 // Update enabled providers when cookies change137 useEffect(() => {138 // If current provider is disabled, switch to first enabled provider139 if (providerList.length === 0) {140 return;141 }142 143 if (provider && !providerList.map((p) => p.name).includes(provider.name)) {144 const firstEnabledProvider = providerList[0];145 setProvider?.(firstEnabledProvider);146 147 // Also update the model to the first available one for the new provider148 const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);149 150 if (firstModel) {151 setModel?.(firstModel.name);152 }153 }154 }, [providerList, provider, setProvider, modelList, setModel]);155 156 if (providerList.length === 0) {157 return (158 <div className="mb-2 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary">159 <p className="text-center">160 No providers are currently enabled. Please enable at least one provider in the settings to start using the161 chat.162 </p>163 </div>164 );165 }166 167 return (168 <div className="mb-2 flex gap-2 flex-col sm:flex-row">169 <select170 value={provider?.name ?? ''}171 onChange={(e) => {172 const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);173 174 if (newProvider && setProvider) {175 setProvider(newProvider);176 }177 178 const firstModel = [...modelList].find((m) => m.provider === e.target.value);179 180 if (firstModel && setModel) {181 setModel(firstModel.name);182 }183 }}184 className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"185 >186 {providerList.map((provider: ProviderInfo) => (187 <option key={provider.name} value={provider.name}>188 {provider.name}189 </option>190 ))}191 </select>192 193 <div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>194 <div195 className={classNames(196 'w-full p-2 rounded-lg border border-bolt-elements-borderColor',197 'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',198 'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',199 'transition-all cursor-pointer',200 isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,201 )}202 onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}203 onKeyDown={(e) => {204 if (e.key === 'Enter' || e.key === ' ') {205 e.preventDefault();206 setIsModelDropdownOpen(!isModelDropdownOpen);207 }208 }}209 role="combobox"210 aria-expanded={isModelDropdownOpen}211 aria-controls="model-listbox"212 aria-haspopup="listbox"213 tabIndex={0}214 >215 <div className="flex items-center justify-between">216 <div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>217 <div218 className={classNames(219 'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',220 isModelDropdownOpen ? 'rotate-180' : undefined,221 )}222 />223 </div>224 </div>225 226 {isModelDropdownOpen && (227 <div228 className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"229 role="listbox"230 id="model-listbox"231 >232 <div className="px-2 pb-2">233 <div className="relative">234 <input235 ref={searchInputRef}236 type="text"237 value={modelSearchQuery}238 onChange={(e) => setModelSearchQuery(e.target.value)}239 placeholder="Search models..."240 className={classNames(241 'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',242 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',243 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',244 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',245 'transition-all',246 )}247 onClick={(e) => e.stopPropagation()}248 role="searchbox"249 aria-label="Search models"250 />251 <div className="absolute left-2.5 top-1/2 -translate-y-1/2">252 <span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />253 </div>254 </div>255 </div>256 257 <div258 className={classNames(259 'max-h-60 overflow-y-auto',260 'sm:scrollbar-none',261 '[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',262 '[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',263 '[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',264 '[&::-webkit-scrollbar-thumb]:rounded-full',265 '[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',266 '[&::-webkit-scrollbar-track]:rounded-full',267 'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',268 'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',269 'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',270 'sm:[&::-webkit-scrollbar-track]:bg-transparent',271 )}272 >273 {modelLoading === 'all' || modelLoading === provider?.name ? (274 <div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>275 ) : filteredModels.length === 0 ? (276 <div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No models found</div>277 ) : (278 filteredModels.map((modelOption, index) => (279 <div280 ref={(el) => (optionsRef.current[index] = el)}281 key={index}282 role="option"283 aria-selected={model === modelOption.name}284 className={classNames(285 'px-3 py-2 text-sm cursor-pointer',286 'hover:bg-bolt-elements-background-depth-3',287 'text-bolt-elements-textPrimary',288 'outline-none',289 model === modelOption.name || focusedIndex === index290 ? 'bg-bolt-elements-background-depth-2'291 : undefined,292 focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,293 )}294 onClick={(e) => {295 e.stopPropagation();296 setModel?.(modelOption.name);297 setIsModelDropdownOpen(false);298 setModelSearchQuery('');299 }}300 tabIndex={focusedIndex === index ? 0 : -1}301 >302 {modelOption.label}303 </div>304 ))305 )}306 </div>307 </div>308 )}309 </div>310 </div>311 );312};313 