import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import Fuse, { FuseResult } from 'fuse.js' import { ChevronDown, ChevronUp, Star, StarOff } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { useSettings } from '../../../contexts/SettingsContext' import { t } from '../../../lang/helpers' import { ApiProvider } from '../../../types/llm/model' import { GetAllProviders, GetProviderModelIds } from "../../../utils/api" type TextSegment = { text: string; isHighlighted: boolean; }; type SearchableItem = { id: string; html: string | TextSegment[]; provider?: string; isCollected?: boolean; }; type HighlightedItem = { id: string; html: TextSegment[]; provider?: string; isCollected?: boolean; }; // Reuse highlight function from ProviderModelsPicker const highlight = (fuseSearchResult: Array>): HighlightedItem[] => { const set = (obj: Record, path: string, value: TextSegment[]): void => { const pathValue = path.split(".") let i: number let current = obj as Record for (i = 0; i < pathValue.length - 1; i++) { const nextValue = current[pathValue[i]] if (typeof nextValue === 'object' && nextValue !== null) { current = nextValue as Record } else { throw new Error(`Invalid path: ${path}`) } } current[pathValue[i]] = value } const mergeRegions = (regions: [number, number][]): [number, number][] => { if (regions.length === 0) return regions regions.sort((a, b) => a[0] - b[0]) const merged: [number, number][] = [regions[0]] for (let i = 1; i < regions.length; i++) { const last = merged[merged.length - 1] const current = regions[i] if (current[0] <= last[1] + 1) { last[1] = Math.max(last[1], current[1]) } else { merged.push(current) } } return merged } const generateHighlightedSegments = (inputText: string, regions: [number, number][] = []): TextSegment[] => { if (regions.length === 0) { return [{ text: inputText, isHighlighted: false }]; } const mergedRegions = mergeRegions(regions); const segments: TextSegment[] = []; let nextUnhighlightedRegionStartingIndex = 0; mergedRegions.forEach((region) => { const start = region[0]; const end = region[1]; const lastRegionNextIndex = end + 1; if (nextUnhighlightedRegionStartingIndex < start) { segments.push({ text: inputText.substring(nextUnhighlightedRegionStartingIndex, start), isHighlighted: false, }); } segments.push({ text: inputText.substring(start, lastRegionNextIndex), isHighlighted: true, }); nextUnhighlightedRegionStartingIndex = lastRegionNextIndex; }); if (nextUnhighlightedRegionStartingIndex < inputText.length) { segments.push({ text: inputText.substring(nextUnhighlightedRegionStartingIndex), isHighlighted: false, }); } return segments; } return fuseSearchResult .filter(({ matches }) => matches && matches.length) .map(({ item, matches }): HighlightedItem => { const highlightedItem: HighlightedItem = { id: item.id, html: typeof item.html === 'string' ? [{ text: item.html, isHighlighted: false }] : [...item.html] } matches?.forEach((match) => { if (match.key && typeof match.value === "string" && match.indices) { const mergedIndices = mergeRegions([...match.indices]) set(highlightedItem, match.key, generateHighlightedSegments(match.value, mergedIndices)) } }) return highlightedItem }) } const HighlightedText: React.FC<{ segments: TextSegment[] }> = ({ segments }) => { return ( <> {segments.map((segment, index) => ( segment.isHighlighted ? ( {segment.text} ) : ( {segment.text} ) ))} ); }; export function ModelSelect() { const { settings, setSettings } = useSettings() const [isOpen, setIsOpen] = useState(false) const [modelProvider, setModelProvider] = useState(settings.chatModelProvider) const [chatModelId, setChatModelId] = useState(settings.chatModelId) const [modelIds, setModelIds] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState("") const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const providers = GetAllProviders() useEffect(() => { const fetchModels = async () => { setIsLoading(true) try { const models = await GetProviderModelIds(modelProvider, settings) setModelIds(models) } catch (error) { console.error('Failed to fetch provider models:', error) setModelIds([]) } finally { setIsLoading(false) } } fetchModels() }, [modelProvider, settings]) // Sync chat model id & chat model provider useEffect(() => { setModelProvider(settings.chatModelProvider) setChatModelId(settings.chatModelId) }, [settings.chatModelProvider, settings.chatModelId]) const searchableItems = useMemo(() => { // 检查是否在收藏列表中 const isInCollected = (id: string) => { return settings.collectedChatModels?.some(item => item.provider === modelProvider && item.modelId === id) || false; }; return modelIds.map((id) => ({ id, html: id, provider: modelProvider, isCollected: isInCollected(id), })) }, [modelIds, modelProvider, settings.collectedChatModels]) const fuse = useMemo(() => { return new Fuse(searchableItems, { keys: ["html"], threshold: 0.6, shouldSort: true, isCaseSensitive: false, ignoreLocation: false, includeMatches: true, minMatchCharLength: 1, }) }, [searchableItems]) const filteredOptions = useMemo(() => { // 首先获取搜索结果 const results: HighlightedItem[] = searchTerm ? highlight(fuse.search(searchTerm)) : searchableItems.map(item => ({ ...item, html: typeof item.html === 'string' ? [{ text: item.html, isHighlighted: false }] : item.html })) // 如果没有搜索关键词,按收藏状态排序(收藏的在前面) if (!searchTerm) { return [...results.filter(item => item.isCollected), ...results.filter(item => !item.isCollected)] } return results }, [searchableItems, searchTerm, fuse]) const toggleCollected = (id: string, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); const isCurrentlyCollected = settings.collectedChatModels?.some( item => item.provider === modelProvider && item.modelId === id ); let newCollectedModels = settings.collectedChatModels || []; if (isCurrentlyCollected) { // remove newCollectedModels = newCollectedModels.filter( item => !(item.provider === modelProvider && item.modelId === id) ); } else { // add newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }]; } setSettings({ ...settings, collectedChatModels: newCollectedModels, }); }; return ( <>
{isOpen ? : }
{chatModelId}
{/* collected models */} {settings.collectedChatModels?.length > 0 && (
{t('chat.input.collectedModels')}
    {settings.collectedChatModels.map((collectedModel, index) => ( { setSettings({ ...settings, chatModelProvider: collectedModel.provider, chatModelId: collectedModel.modelId, }) setChatModelId(collectedModel.modelId) setSearchTerm("") setIsOpen(false) }} className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`} onMouseEnter={() => setSelectedIndex(index)} asChild >
  • {collectedModel.provider} {collectedModel.modelId}
    { e.stopPropagation(); e.preventDefault(); // delete const newCollectedModels = settings.collectedChatModels.filter( item => !(item.provider === collectedModel.provider && item.modelId === collectedModel.modelId) ); setSettings({ ...settings, collectedChatModels: newCollectedModels, }); }} />
  • ))}
)}
{modelIds.length > 0 ? (
{ setSearchTerm(e.target.value) setSelectedIndex(0) // Ensure the input is focused in the next render cycle setTimeout(() => { inputRef.current?.focus() }, 0) }} onKeyDown={(e) => { switch (e.key) { case "ArrowDown": e.preventDefault() setSelectedIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1) ) break case "ArrowUp": e.preventDefault() setSelectedIndex((prev) => Math.max(prev - 1, 0)) break case "Enter": { e.preventDefault() const selectedOption = filteredOptions[selectedIndex] if (selectedOption) { setSettings({ ...settings, chatModelProvider: modelProvider, chatModelId: selectedOption.id, }) setChatModelId(selectedOption.id) setSearchTerm("") setIsOpen(false) } break } case "Escape": e.preventDefault() setIsOpen(false) setSearchTerm("") break } }} />
) : ( { setSearchTerm(e.target.value) // ensure the input is focused in the next render cycle setTimeout(() => { inputRef.current?.focus() }, 0) }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() setSettings({ ...settings, chatModelProvider: modelProvider, chatModelId: searchTerm, }) setChatModelId(searchTerm) setIsOpen(false) } }} /> )}
{isLoading ? (
{t('chat.input.loading')}
) : (
    {filteredOptions.map((option, index) => { // 计算正确的选中索引,考虑搜索模式和非搜索模式 const isSelected = searchTerm ? index === selectedIndex : index + settings.collectedChatModels?.length === selectedIndex; return ( { setSettings({ ...settings, chatModelProvider: modelProvider, chatModelId: option.id, }) setChatModelId(option.id) setSearchTerm("") setIsOpen(false) }} className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`} onMouseEnter={() => { // 计算正确的鼠标悬停索引 const hoverIndex = searchTerm ? index : index + settings.collectedChatModels?.length; setSelectedIndex(hoverIndex); }} asChild >
  • toggleCollected(option.id, e)} title={option.isCollected ? "star" : "unstar"} > {option.isCollected ? : }
  • ); })}
)}
) }