diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 521ccac3..b80bfc8b 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -1,6 +1,9 @@ import type { ProviderInfo } from '~/types/model'; -import { useEffect } from 'react'; +import { useEffect, useState, useRef } from 'react'; +import type { KeyboardEvent } from 'react'; import type { ModelInfo } from '~/lib/modules/llm/types'; +import { classNames } from '~/utils/classNames'; +import * as React from 'react'; interface ModelSelectorProps { model?: string; @@ -22,12 +25,118 @@ export const ModelSelector = ({ providerList, modelLoading, }: ModelSelectorProps) => { - // Load enabled providers from cookies + const [modelSearchQuery, setModelSearchQuery] = useState(''); + const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const searchInputRef = useRef(null); + const optionsRef = useRef<(HTMLDivElement | null)[]>([]); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Filter models based on search query + const filteredModels = [...modelList] + .filter((e) => e.provider === provider?.name && e.name) + .filter( + (model) => + model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) || + model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()), + ); + + // Reset focused index when search query changes or dropdown opens/closes + useEffect(() => { + setFocusedIndex(-1); + }, [modelSearchQuery, isModelDropdownOpen]); + + // Focus search input when dropdown opens + useEffect(() => { + if (isModelDropdownOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isModelDropdownOpen]); + + // Handle keyboard navigation + const handleKeyDown = (e: KeyboardEvent) => { + if (!isModelDropdownOpen) { + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => { + const next = prev + 1; + + if (next >= filteredModels.length) { + return 0; + } + + return next; + }); + break; + + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => { + const next = prev - 1; + + if (next < 0) { + return filteredModels.length - 1; + } + + return next; + }); + break; + + case 'Enter': + e.preventDefault(); + + if (focusedIndex >= 0 && focusedIndex < filteredModels.length) { + const selectedModel = filteredModels[focusedIndex]; + setModel?.(selectedModel.name); + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + } + + break; + + case 'Escape': + e.preventDefault(); + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + break; + + case 'Tab': + if (!e.shiftKey && focusedIndex === filteredModels.length - 1) { + setIsModelDropdownOpen(false); + } + + break; + } + }; + + // Focus the selected option + useEffect(() => { + if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) { + optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex]); // Update enabled providers when cookies change useEffect(() => { // If current provider is disabled, switch to first enabled provider - if (providerList.length == 0) { + if (providerList.length === 0) { return; } @@ -80,27 +189,124 @@ export const ModelSelector = ({ ))} - setModelSearchQuery(e.target.value)} + placeholder="Search models..." + className={classNames( + 'w-full pl-8 pr-3 py-1.5 rounded-md text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus', + 'transition-all', + )} + onClick={(e) => e.stopPropagation()} + role="searchbox" + aria-label="Search models" + /> +
+ +
+ + + +
+ {modelLoading === 'all' || modelLoading === provider?.name ? ( +
Loading...
+ ) : filteredModels.length === 0 ? ( +
No models found
+ ) : ( + filteredModels.map((modelOption, index) => ( +
(optionsRef.current[index] = el)} + key={index} + role="option" + aria-selected={model === modelOption.name} + className={classNames( + 'px-3 py-2 text-sm cursor-pointer', + 'hover:bg-bolt-elements-background-depth-3', + 'text-bolt-elements-textPrimary', + 'outline-none', + model === modelOption.name || focusedIndex === index + ? 'bg-bolt-elements-background-depth-2' + : undefined, + focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined, + )} + onClick={(e) => { + e.stopPropagation(); + setModel?.(modelOption.name); + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + }} + tabIndex={focusedIndex === index ? 0 : -1} + > + {modelOption.label} +
+ )) + )} +
+ )} - + ); }; diff --git a/app/lib/modules/llm/providers/amazon-bedrock.ts b/app/lib/modules/llm/providers/amazon-bedrock.ts index f01b13ac..6a4cbc96 100644 --- a/app/lib/modules/llm/providers/amazon-bedrock.ts +++ b/app/lib/modules/llm/providers/amazon-bedrock.ts @@ -20,6 +20,12 @@ export default class AmazonBedrockProvider extends BaseProvider { }; staticModels: ModelInfo[] = [ + { + name: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + label: 'Claude 3.5 Sonnet v2 (Bedrock)', + provider: 'AmazonBedrock', + maxTokenAllowed: 200000, + }, { name: 'anthropic.claude-3-5-sonnet-20240620-v1:0', label: 'Claude 3.5 Sonnet (Bedrock)',