diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx new file mode 100644 index 000000000..34c95bf6d --- /dev/null +++ b/web-app/src/containers/ModelCombobox.tsx @@ -0,0 +1,367 @@ +import { useState, useMemo, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { IconChevronDown, IconLoader2, IconRefresh } from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' + +type ModelComboboxProps = { + value: string + onChange: (value: string) => void + models: string[] + loading?: boolean + error?: string | null + onRefresh?: () => void + placeholder?: string + disabled?: boolean + className?: string +} + +export function ModelCombobox({ + value, + onChange, + models, + loading = false, + error = null, + onRefresh, + placeholder = 'Type or select a model...', + disabled = false, + className, +}: ModelComboboxProps) { + const [open, setOpen] = useState(false) + const [inputValue, setInputValue] = useState(value) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const containerRef = useRef(null) + const dropdownRef = useRef(null) + const keyRepeatTimeoutRef = useRef(null) + const { t } = useTranslation() + + // Sync input value with prop value + useEffect(() => { + setInputValue(value) + }, [value]) + + // Simple position calculation + const updateDropdownPosition = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + width: rect.width, + }) + } + }, []) + + // Update position when opening + useEffect(() => { + if (open) { + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + updateDropdownPosition() + }) + } + }, [open, updateDropdownPosition]) + + // Close dropdown when clicking outside + useEffect(() => { + if (!open) return + + const handleClickOutside = (event: Event) => { + const target = event.target as Node + // Check if click is inside our container or dropdown + const isInsideContainer = containerRef.current?.contains(target) + const isInsideDropdown = dropdownRef.current?.contains(target) + + // Only close if click is outside both container and dropdown + if (!isInsideContainer && !isInsideDropdown) { + setOpen(false) + setDropdownPosition({ top: 0, left: 0, width: 0 }) + setHighlightedIndex(-1) + } + } + + // Use multiple event types to ensure we catch all interactions + const events = ['mousedown', 'touchstart'] + events.forEach(eventType => { + document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true }) + }) + + return () => { + events.forEach(eventType => { + document.removeEventListener(eventType, handleClickOutside, { capture: true }) + }) + } + }, [open]) + + // Cleanup: close dropdown when component unmounts + useEffect(() => { + const timeoutId = keyRepeatTimeoutRef.current + return () => { + setOpen(false) + setDropdownPosition({ top: 0, left: 0, width: 0 }) + setHighlightedIndex(-1) + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }, []) + + // Filter models based on input + const filteredModels = useMemo(() => { + if (!inputValue.trim()) return models + + return models.filter((model) => + model.toLowerCase().includes(inputValue.toLowerCase()) + ) + }, [models, inputValue]) + + // Reset highlighted index when filtered models change + useEffect(() => { + setHighlightedIndex(-1) + }, [filteredModels]) + + // Scroll to highlighted item with debouncing to handle key repeat + useEffect(() => { + if (highlightedIndex >= 0 && dropdownRef.current && !loading && !error) { + // Use requestAnimationFrame to ensure smooth scrolling and avoid conflicts + requestAnimationFrame(() => { + // Find all model elements (they have the data-model attribute) + const modelElements = dropdownRef.current?.querySelectorAll('[data-model]') + const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement + if (highlightedElement) { + highlightedElement.scrollIntoView({ + block: 'nearest', + behavior: 'auto' + }) + } + }) + } + }, [highlightedIndex, error, loading]) + + // Handle input change + const handleInputChange = (newValue: string) => { + setInputValue(newValue) + onChange(newValue) + + // Only open dropdown if user is actively typing and there are models + if (newValue.trim() && models.length > 0) { + setOpen(true) + } else { + // Don't auto-open on empty input - wait for user interaction + setOpen(false) + } + } + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + // Open dropdown on arrow keys if there are models + if (models.length > 0) { + e.preventDefault() + setOpen(true) + setHighlightedIndex(0) + } + return + } + + if (!open) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + if (keyRepeatTimeoutRef.current) { + clearTimeout(keyRepeatTimeoutRef.current) + } + setHighlightedIndex((prev) => + filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0) + ) + break + case 'ArrowUp': + e.preventDefault() + if (keyRepeatTimeoutRef.current) { + clearTimeout(keyRepeatTimeoutRef.current) + } + setHighlightedIndex((prev) => + filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1) + ) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) { + handleModelSelect(filteredModels[highlightedIndex]) + } + break + case 'ArrowRight': + case 'ArrowLeft': + setOpen(false) + setHighlightedIndex(-1) + break + case 'PageUp': + setHighlightedIndex(0) + break + case 'PageDown': + setHighlightedIndex(filteredModels.length - 1) + break + } + } + + // Handle model selection from dropdown + const handleModelSelect = (model: string) => { + setInputValue(model) + onChange(model) + setOpen(false) + setDropdownPosition({ top: 0, left: 0, width: 0 }) + setHighlightedIndex(-1) + inputRef.current?.focus() + } + + return ( +
+
+ handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onClick={() => { + // Open dropdown on click if models are available + if (models.length > 0) { + setOpen(true) + } + }} + placeholder={placeholder} + disabled={disabled} + className="pr-8" + /> + + {/* Dropdown trigger button */} + + + {/* Custom dropdown rendered as portal */} + {open && dropdownPosition.width > 0 && createPortal( +
{ + // Prevent interaction with underlying elements + e.stopPropagation() + }} + onClick={(e) => { + // Prevent click from bubbling up and closing modal + e.stopPropagation() + }} + onMouseDown={(e) => { + // Allow default behavior for scrolling and selection + e.stopPropagation() + }} + onWheel={(e) => { + // Allow wheel events for scrolling + e.stopPropagation() + }} + > + {/* Error state */} + {error && ( +
+
+ {t('common:failedToLoadModels')} + {onRefresh && ( + + )} +
+
{error}
+
+ )} + + {/* Loading state */} + {loading && ( +
+ + {t('common:loading')} +
+ )} + + {/* Models list */} + {!loading && !error && ( + <> + {filteredModels.length === 0 ? ( +
+ {inputValue.trim() ? ( + {t('common:noModelsFoundFor', { searchValue: inputValue })} + ) : ( + {t('common:noModels')} + )} +
+ ) : ( + <> + {/* Available models */} + {filteredModels.map((model, index) => ( +
{ + e.stopPropagation() + handleModelSelect(model) + }} + onMouseEnter={() => setHighlightedIndex(index)} + className={cn( + 'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg', + value === model && 'bg-main-view-fg/12 shadow-sm', + highlightedIndex === index && 'bg-main-view-fg/20 shadow-md' + )} + > + {model} +
+ ))} + + )} + + )} +
, + document.body + )} +
+
+ ) +} diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index 248600212..dc0eac244 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -8,8 +8,9 @@ import { DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { useModelProvider } from '@/hooks/useModelProvider' +import { useProviderModels } from '@/hooks/useProviderModels' +import { ModelCombobox } from '@/containers/ModelCombobox' import { IconPlus } from '@tabler/icons-react' import { useState } from 'react' import { getProviderTitle } from '@/lib/utils' @@ -26,6 +27,11 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { const [modelId, setModelId] = useState('') const [open, setOpen] = useState(false) + // Fetch models from provider API (API key is optional) + const { models, loading, error, refetch } = useProviderModels( + provider.base_url ? provider : undefined + ) + // Handle form submission const handleSubmit = () => { if (!modelId.trim()) { @@ -72,7 +78,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { - {/* Model ID field - required */} + {/* Model selection field - required */}
- setModelId(e.target.value)} + onChange={setModelId} + models={models} + loading={loading} + error={error} + onRefresh={refetch} placeholder={t('providers:addModel.enterModelId')} - required />
diff --git a/web-app/src/hooks/useProviderModels.ts b/web-app/src/hooks/useProviderModels.ts new file mode 100644 index 000000000..5c984d1ca --- /dev/null +++ b/web-app/src/hooks/useProviderModels.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { fetchModelsFromProvider } from '@/services/providers' +import type { ModelProvider } from '@/types/providers' + +type UseProviderModelsState = { + models: string[] + loading: boolean + error: string | null + refetch: () => void +} + +const modelsCache = new Map() +const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes + +export const useProviderModels = (provider?: ModelProvider): UseProviderModelsState => { + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const prevProviderKey = useRef('') + + const fetchModels = useCallback(async () => { + if (!provider || !provider.base_url) { + // Clear models if provider is invalid (base_url is required, api_key is optional) + setModels([]) + setError(null) + setLoading(false) + return + } + + // Clear any previous state when starting a new fetch for a different provider + const currentProviderKey = `${provider.provider}-${provider.base_url}` + if (currentProviderKey !== prevProviderKey.current) { + setModels([]) + setError(null) + setLoading(false) + prevProviderKey.current = currentProviderKey + } + + const cacheKey = `${provider.provider}-${provider.base_url}` + const cached = modelsCache.get(cacheKey) + + // Check cache first + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + setModels(cached.models) + return + } + + setLoading(true) + setError(null) + + try { + const fetchedModels = await fetchModelsFromProvider(provider) + const sortedModels = fetchedModels.sort((a, b) => a.localeCompare(b)) + + setModels(sortedModels) + + // Cache the results + modelsCache.set(cacheKey, { + models: sortedModels, + timestamp: Date.now(), + }) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch models' + setError(errorMessage) + console.error(`Error fetching models from ${provider.provider}:`, err) + } finally { + setLoading(false) + } + }, [provider]) + + const refetch = useCallback(() => { + if (provider) { + const cacheKey = `${provider.provider}-${provider.base_url}` + modelsCache.delete(cacheKey) + fetchModels() + } + }, [provider, fetchModels]) + + useEffect(() => { + fetchModels() + }, [fetchModels]) + + return { + models, + loading, + error, + refetch, + } +} diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index 46f2d5a8a..e5f5aa9f7 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -75,6 +75,8 @@ "selectAModel": "Select a model", "noToolsAvailable": "No tools available", "noModelsFoundFor": "No models found for \"{{searchValue}}\"", + "failedToLoadModels": "Failed to load models", + "noModels": "No models found", "customAvatar": "Custom avatar", "editAssistant": "Edit Assistant", "jan": "Jan", diff --git a/web-app/src/services/providers.ts b/web-app/src/services/providers.ts index e9f05fd09..748aed322 100644 --- a/web-app/src/services/providers.ts +++ b/web-app/src/services/providers.ts @@ -135,9 +135,24 @@ export const fetchModelsFromProvider = async ( }) if (!response.ok) { - throw new Error( - `Failed to fetch models: ${response.status} ${response.statusText}` - ) + // Provide more specific error messages based on status code + if (response.status === 401) { + throw new Error( + `Authentication failed: API key is required or invalid for ${provider.provider}` + ) + } else if (response.status === 403) { + throw new Error( + `Access forbidden: Check your API key permissions for ${provider.provider}` + ) + } else if (response.status === 404) { + throw new Error( + `Models endpoint not found for ${provider.provider}. Check the base URL configuration.` + ) + } else { + throw new Error( + `Failed to fetch models from ${provider.provider}: ${response.status} ${response.statusText}` + ) + } } const data = await response.json() @@ -167,14 +182,26 @@ export const fetchModelsFromProvider = async ( } catch (error) { console.error('Error fetching models from provider:', error) - // Provide helpful error message + if (error instanceof Error && ( + error.message.includes('Authentication failed') || + error.message.includes('Access forbidden') || + error.message.includes('Models endpoint not found') || + error.message.includes('Failed to fetch models from') + )) { + throw error + } + + // Provide helpful error message for network issues if (error instanceof Error && error.message.includes('fetch')) { throw new Error( `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` ) } - throw error + // Generic fallback + throw new Error( + `Unexpected error while fetching models from ${provider.provider}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } }