model-selector.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. 'use client';
  2. import { useState, useCallback, useEffect, useRef } from 'react';
  3. import {
  4. Check,
  5. Search,
  6. Sparkles,
  7. Wrench,
  8. Zap,
  9. Box,
  10. Loader2,
  11. CheckCircle,
  12. XCircle,
  13. FileText,
  14. Send,
  15. } from 'lucide-react';
  16. import { Button } from '@/components/ui/button';
  17. import { Input } from '@/components/ui/input';
  18. import { cn } from '@/lib/utils';
  19. import { useI18n } from '@/lib/hooks/use-i18n';
  20. import type { ProviderId } from '@/lib/ai/providers';
  21. import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
  22. import type { ProvidersConfig } from '@/lib/types/settings';
  23. import { formatContextWindow } from './utils';
  24. interface ModelSelectorProps {
  25. providerId: ProviderId;
  26. modelId: string;
  27. onModelChange: (providerId: ProviderId, modelId: string) => void;
  28. providersConfig: ProvidersConfig;
  29. }
  30. export function ModelSelector({
  31. providerId,
  32. modelId,
  33. onModelChange,
  34. providersConfig,
  35. }: ModelSelectorProps) {
  36. const { t } = useI18n();
  37. const [activeProvider, setActiveProvider] = useState<ProviderId>(providerId);
  38. const [searchQuery, setSearchQuery] = useState('');
  39. const [searchExpanded, setSearchExpanded] = useState(false);
  40. const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
  41. const [testMessage, setTestMessage] = useState('');
  42. const [testingModelId, setTestingModelId] = useState<string | null>(null);
  43. const selectedModelRef = useRef<HTMLButtonElement>(null);
  44. const searchInputRef = useRef<HTMLInputElement>(null);
  45. // Helper function to get translated provider name
  46. const getProviderDisplayName = (pid: ProviderId, name: string) => {
  47. const translationKey = `settings.providerNames.${pid}`;
  48. const translated = t(translationKey);
  49. // If translation exists (not equal to key), use it; otherwise fallback to name
  50. return translated !== translationKey ? translated : name;
  51. };
  52. // Helper function for model count with proper plural form
  53. const getModelCountText = (count: number) => {
  54. const key = count === 1 ? 'settings.modelSingular' : 'settings.modelCount';
  55. return `${count} ${t(key)}`;
  56. };
  57. const getFilteredModelCountText = (filtered: number, total: number) => {
  58. const key = total === 1 ? 'settings.modelSingular' : 'settings.modelCount';
  59. return `${filtered}/${total} ${t(key)}`;
  60. };
  61. // Get all providers that are ready to use:
  62. // - (Doesn't require API key OR has API key configured OR server has key)
  63. // - Has at least one model
  64. // - Has baseUrl or defaultBaseUrl configured
  65. const configuredProviders = Object.entries(providersConfig)
  66. .filter(
  67. ([, config]) =>
  68. (!config.requiresApiKey || config.apiKey || config.isServerConfigured) &&
  69. config.models.length >= 1 &&
  70. (config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl),
  71. )
  72. .map(([id, config]) => ({
  73. id: id as ProviderId,
  74. name: config.name,
  75. icon: config.icon,
  76. isServerConfigured: config.isServerConfigured,
  77. }));
  78. const handleSelect = (pid: ProviderId, mid: string) => {
  79. onModelChange(pid, mid);
  80. };
  81. // Filter models across all providers by search query and server model restrictions
  82. const getFilteredModelsForProvider = (pid: ProviderId) => {
  83. const config = providersConfig[pid];
  84. let models = config?.models || [];
  85. // When using server config without own key, restrict to server-allowed models
  86. if (config?.isServerConfigured && !config.apiKey && config.serverModels?.length) {
  87. const allowed = new Set(config.serverModels);
  88. models = models.filter((m) => allowed.has(m.id));
  89. }
  90. if (!searchQuery) return models;
  91. return models.filter(
  92. (model) =>
  93. model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
  94. model.id.toLowerCase().includes(searchQuery.toLowerCase()),
  95. );
  96. };
  97. // Sync activeProvider with providerId prop changes
  98. useEffect(() => {
  99. setActiveProvider(providerId);
  100. }, [providerId]);
  101. // Fallback: if activeProvider is not in configured providers, use the first configured one
  102. const effectiveProvider = configuredProviders.some((p) => p.id === activeProvider)
  103. ? activeProvider
  104. : (configuredProviders[0]?.id ?? activeProvider);
  105. const filteredModels = getFilteredModelsForProvider(effectiveProvider);
  106. // Auto scroll to selected model when opening
  107. useEffect(() => {
  108. if (selectedModelRef.current) {
  109. selectedModelRef.current.scrollIntoView({
  110. block: 'nearest',
  111. behavior: 'smooth',
  112. });
  113. }
  114. }, [effectiveProvider]);
  115. // Auto focus search input when expanded
  116. useEffect(() => {
  117. if (searchExpanded && searchInputRef.current) {
  118. searchInputRef.current.focus();
  119. }
  120. }, [searchExpanded]);
  121. // Test model function
  122. const handleTestModel = useCallback(
  123. async (pid: ProviderId, mid: string) => {
  124. const providerConfig = providersConfig[pid];
  125. if (!providerConfig) return;
  126. const apiKey = providerConfig.apiKey;
  127. // Only send user-entered baseUrl; let server resolve fallback
  128. const baseUrl = providerConfig.baseUrl;
  129. if (providerConfig.requiresApiKey && !apiKey && !providerConfig.isServerConfigured) {
  130. setTestStatus('error');
  131. setTestMessage(t('settings.apiKeyRequired'));
  132. setTestingModelId(mid);
  133. return;
  134. }
  135. setTestStatus('testing');
  136. setTestMessage('');
  137. setTestingModelId(mid);
  138. try {
  139. const response = await fetch('/api/verify-model', {
  140. method: 'POST',
  141. headers: { 'Content-Type': 'application/json' },
  142. body: JSON.stringify({
  143. apiKey,
  144. baseUrl,
  145. model: `${pid}:${mid}`,
  146. providerType: providerConfig.type,
  147. requiresApiKey: providerConfig.requiresApiKey,
  148. }),
  149. });
  150. const data = await response.json();
  151. if (data.success) {
  152. setTestStatus('success');
  153. setTestMessage(t('settings.connectionSuccess'));
  154. } else {
  155. setTestStatus('error');
  156. setTestMessage(data.error || t('settings.connectionFailed'));
  157. }
  158. } catch {
  159. setTestStatus('error');
  160. setTestMessage(t('settings.connectionFailed'));
  161. }
  162. },
  163. [providersConfig, t],
  164. );
  165. if (configuredProviders.length === 0) {
  166. return (
  167. <div className="p-4 border-2 border-dashed rounded-lg text-center text-sm text-muted-foreground">
  168. {t('settings.configureProvidersFirst')}
  169. </div>
  170. );
  171. }
  172. return (
  173. <div className="border rounded-lg overflow-hidden flex flex-col h-[420px] relative">
  174. <div className="flex flex-1 min-h-0 overflow-hidden">
  175. {/* Left: Provider List */}
  176. <div className="w-48 border-r bg-muted/30 overflow-y-auto shrink-0">
  177. {configuredProviders.map((provider) => {
  178. const filteredCount = getFilteredModelsForProvider(provider.id).length;
  179. const totalCount = providersConfig[provider.id]?.models?.length || 0;
  180. const isActive = effectiveProvider === provider.id;
  181. return (
  182. <button
  183. key={provider.id}
  184. onClick={() => setActiveProvider(provider.id)}
  185. className={cn(
  186. 'w-full flex items-center gap-2 px-3 py-2.5 text-left transition-colors border-b',
  187. isActive ? 'bg-primary text-primary-foreground' : 'hover:bg-muted/50',
  188. )}
  189. >
  190. {provider.icon ? (
  191. <img
  192. src={provider.icon}
  193. alt={getProviderDisplayName(provider.id, provider.name)}
  194. className={cn(
  195. 'w-5 h-5 shrink-0',
  196. MONO_LOGO_PROVIDERS.has(provider.id) && 'dark:invert',
  197. )}
  198. onError={(e) => {
  199. (e.target as HTMLImageElement).style.display = 'none';
  200. }}
  201. />
  202. ) : (
  203. <Box className="w-5 h-5 shrink-0 text-muted-foreground" />
  204. )}
  205. <div className="flex-1 min-w-0">
  206. <div className="font-medium text-sm truncate flex items-center gap-1">
  207. {getProviderDisplayName(provider.id, provider.name)}
  208. {provider.isServerConfigured && (
  209. <span
  210. className={cn(
  211. 'text-[10px] px-1 py-0 h-4 leading-4 rounded shrink-0 inline-block',
  212. isActive
  213. ? 'bg-white/20 text-primary-foreground'
  214. : 'bg-muted text-muted-foreground',
  215. )}
  216. >
  217. {t('settings.serverConfigured')}
  218. </span>
  219. )}
  220. </div>
  221. <div className={cn('text-xs', isActive ? 'opacity-90' : 'text-muted-foreground')}>
  222. {searchQuery && filteredCount !== totalCount
  223. ? getFilteredModelCountText(filteredCount, totalCount)
  224. : getModelCountText(totalCount)}
  225. </div>
  226. </div>
  227. </button>
  228. );
  229. })}
  230. </div>
  231. {/* Right: Model List */}
  232. <div className="flex-1 flex flex-col relative">
  233. {/* Floating Search Button - Bottom Right */}
  234. <div className="absolute bottom-4 right-4 z-10">
  235. {searchExpanded ? (
  236. <div className="relative w-64 animate-in fade-in slide-in-from-bottom-2 duration-200">
  237. <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
  238. <Input
  239. ref={searchInputRef}
  240. placeholder={t('settings.searchModels')}
  241. value={searchQuery}
  242. onChange={(e) => setSearchQuery(e.target.value)}
  243. onBlur={() => {
  244. if (!searchQuery) {
  245. setSearchExpanded(false);
  246. }
  247. }}
  248. className="pl-9 h-9 pr-3 shadow-lg border-primary/20 bg-card dark:bg-card"
  249. />
  250. </div>
  251. ) : (
  252. <Button
  253. variant="outline"
  254. size="sm"
  255. className="h-10 w-10 rounded-full p-0 shadow-md hover:shadow-lg transition-shadow bg-card hover:bg-card dark:bg-card dark:hover:bg-card"
  256. onClick={() => setSearchExpanded(true)}
  257. >
  258. <Search className="h-4 w-4" />
  259. </Button>
  260. )}
  261. </div>
  262. {/* Model Items */}
  263. <div className="flex-1 overflow-y-auto">
  264. {filteredModels.length === 0 ? (
  265. <div className="p-6 text-center text-sm text-muted-foreground">
  266. {searchQuery ? t('settings.noModelsFound') : t('settings.noModelsAvailable')}
  267. </div>
  268. ) : (
  269. filteredModels.map((model) => {
  270. const isSelected = providerId === effectiveProvider && modelId === model.id;
  271. const isTesting = testingModelId === model.id;
  272. const showTestResult = isTesting && testMessage;
  273. return (
  274. <div
  275. key={model.id}
  276. className={cn(
  277. 'border-b transition-colors',
  278. isSelected ? 'bg-primary/5' : 'hover:bg-muted/50',
  279. )}
  280. >
  281. <div className="flex items-center gap-2 px-3 py-2.5">
  282. <button
  283. ref={isSelected ? selectedModelRef : null}
  284. onClick={() => handleSelect(effectiveProvider, model.id)}
  285. className="flex-1 flex items-center gap-2 text-left"
  286. >
  287. <div className="flex-1 min-w-0">
  288. <div className="font-mono text-sm font-medium mb-1.5 truncate">
  289. {model.name}
  290. </div>
  291. {(model.capabilities || model.contextWindow || model.outputWindow) && (
  292. <div className="flex items-center gap-2 text-xs text-muted-foreground">
  293. {/* Capabilities */}
  294. <div className="flex items-center gap-1">
  295. {model.capabilities?.vision && (
  296. <div title={t('settings.capabilities.vision')}>
  297. <Sparkles className="h-3 w-3" />
  298. </div>
  299. )}
  300. {model.capabilities?.tools && (
  301. <div title={t('settings.capabilities.tools')}>
  302. <Wrench className="h-3 w-3" />
  303. </div>
  304. )}
  305. {model.capabilities?.streaming && (
  306. <div title={t('settings.capabilities.streaming')}>
  307. <Zap className="h-3 w-3" />
  308. </div>
  309. )}
  310. </div>
  311. {/* Context Window */}
  312. {model.contextWindow && (
  313. <span className="flex items-center gap-0.5">
  314. <FileText className="h-3 w-3" />
  315. <span className="text-[10px]">
  316. {formatContextWindow(model.contextWindow)}
  317. </span>
  318. </span>
  319. )}
  320. {/* Output Window */}
  321. {model.outputWindow && (
  322. <span className="flex items-center gap-0.5">
  323. <Send className="h-3 w-3" />
  324. <span className="text-[10px]">
  325. {formatContextWindow(model.outputWindow)}
  326. </span>
  327. </span>
  328. )}
  329. </div>
  330. )}
  331. </div>
  332. {isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
  333. </button>
  334. <Button
  335. variant="ghost"
  336. size="sm"
  337. onClick={(e) => {
  338. e.stopPropagation();
  339. handleTestModel(effectiveProvider, model.id);
  340. }}
  341. disabled={testStatus === 'testing' && isTesting}
  342. className={cn(
  343. 'h-7 px-2 shrink-0',
  344. isTesting && testStatus === 'success' && 'text-green-600',
  345. isTesting && testStatus === 'error' && 'text-red-600',
  346. )}
  347. >
  348. {testStatus === 'testing' && isTesting ? (
  349. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  350. ) : testStatus === 'success' && isTesting ? (
  351. <CheckCircle className="h-3.5 w-3.5" />
  352. ) : testStatus === 'error' && isTesting ? (
  353. <XCircle className="h-3.5 w-3.5" />
  354. ) : (
  355. <Zap className="h-3.5 w-3.5" />
  356. )}
  357. </Button>
  358. </div>
  359. {showTestResult && (
  360. <div
  361. className={cn(
  362. 'mx-3 mb-2 rounded-lg p-2 text-xs overflow-hidden',
  363. testStatus === 'success' &&
  364. 'bg-green-50 text-green-700 border border-green-200',
  365. testStatus === 'error' && 'bg-red-50 text-red-700 border border-red-200',
  366. )}
  367. >
  368. <div className="flex items-start gap-2 min-w-0">
  369. {testStatus === 'success' && (
  370. <CheckCircle className="h-3 w-3 mt-0.5 shrink-0" />
  371. )}
  372. {testStatus === 'error' && (
  373. <XCircle className="h-3 w-3 mt-0.5 shrink-0" />
  374. )}
  375. <p className="flex-1 min-w-0 break-all">{testMessage}</p>
  376. </div>
  377. </div>
  378. )}
  379. </div>
  380. );
  381. })
  382. )}
  383. </div>
  384. </div>
  385. </div>
  386. </div>
  387. );
  388. }