feat: integrate fuzzy search into model dropdown (#5197)

* feat: integrate fuzzy search into model dropdown

- Replace DropdownMenu with Popover for better search UX
- Include search input with clear functionality
- Reorganize layout with capabilities at end of row
- Maintain provider grouping and model selection functionality

Improves model discovery and selection with instant search across
model names, providers, and capabilities.

* chore: enhance input search style

* feat: enhance model dropdown with search highlighting and fixed positioning

- Add FZF search highlighting with text-accent color for matched characters
- Fix dropdown to only appear below (prevent upward positioning)
- Import highlightFzfMatch utility for search result highlighting
- Update SearchableModel interface to include highlightedId property
- Modify FZF selector to target model.id for more accurate highlighting
- Use dangerouslySetInnerHTML to render highlighted search matches
- Add avoidCollisions=false to PopoverContent for consistent positioning

---------

Co-authored-by: Faisal Amir <urmauur@gmail.com>
This commit is contained in:
Sam Hoang Van 2025-06-05 00:30:38 +07:00 committed by GitHub
parent fb7dc21135
commit 9c9a9cb521
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,26 +1,33 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { import {
DropdownMenu, Popover,
DropdownMenuContent, PopoverContent,
DropdownMenuGroup, PopoverTrigger,
DropdownMenuItem, } from '@/components/ui/popover'
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderTitle } from '@/lib/utils' import { cn, getProviderTitle } from '@/lib/utils'
import { useEffect, useState } from 'react' import { highlightFzfMatch } from '@/utils/highlight'
import Capabilities from './Capabilities' import Capabilities from './Capabilities'
import { IconSettings } from '@tabler/icons-react' import { IconSettings, IconX, IconCheck } from '@tabler/icons-react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import { ModelSetting } from '@/containers/ModelSetting' import { ModelSetting } from '@/containers/ModelSetting'
import ProvidersAvatar from '@/containers/ProvidersAvatar' import ProvidersAvatar from '@/containers/ProvidersAvatar'
import { Fzf } from 'fzf'
type DropdownModelProviderProps = { type DropdownModelProviderProps = {
model?: ThreadModel model?: ThreadModel
} }
interface SearchableModel {
provider: ModelProvider
model: Model
searchStr: string
value: string
highlightedId?: string
}
const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => { const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => {
const { const {
providers, providers,
@ -34,6 +41,11 @@ const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => {
const { updateCurrentThreadModel } = useThreads() const { updateCurrentThreadModel } = useThreads()
const navigate = useNavigate() const navigate = useNavigate()
// Search state
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// Initialize model provider only once // Initialize model provider only once
useEffect(() => { useEffect(() => {
// Auto select model when existing thread is passed // Auto select model when existing thread is passed
@ -43,7 +55,7 @@ const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => {
// default model, we should add from setting // default model, we should add from setting
selectModelProvider('llama.cpp', 'llama3.2:3b') selectModelProvider('llama.cpp', 'llama3.2:3b')
} }
}, [model, selectModelProvider, updateCurrentThreadModel]) // Only run when threadData changes }, [model, selectModelProvider, updateCurrentThreadModel])
// Update display model when selection changes // Update display model when selection changes
useEffect(() => { useEffect(() => {
@ -54,6 +66,116 @@ const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => {
} }
}, [selectedProvider, selectedModel]) }, [selectedProvider, selectedModel])
// Reset search value when dropdown closes
const onOpenChange = useCallback((open: boolean) => {
setOpen(open)
if (!open) {
requestAnimationFrame(() => setSearchValue(''))
} else {
// Focus search input when opening
setTimeout(() => {
searchInputRef.current?.focus()
}, 100)
}
}, [])
// Clear search and focus input
const onClearSearch = useCallback(() => {
setSearchValue('')
searchInputRef.current?.focus()
}, [])
// Create searchable items from all models
const searchableItems = useMemo(() => {
const items: SearchableModel[] = []
providers.forEach((provider) => {
if (!provider.active) return
provider.models.forEach((modelItem) => {
// Skip models that require API key but don't have one (except llama.cpp)
if (provider.provider !== 'llama.cpp' && !provider.api_key?.length) {
return
}
const capabilities = modelItem.capabilities || []
const capabilitiesString = capabilities.join(' ')
const providerTitle = getProviderTitle(provider.provider)
// Create search string with model id, provider, and capabilities
const searchStr =
`${modelItem.id} ${providerTitle} ${provider.provider} ${capabilitiesString}`.toLowerCase()
items.push({
provider,
model: modelItem,
searchStr,
value: `${provider.provider}:${modelItem.id}`,
})
})
})
return items
}, [providers])
// Create Fzf instance for fuzzy search
const fzfInstance = useMemo(() => {
return new Fzf(searchableItems, {
selector: (item) => item.model.id,
})
}, [searchableItems])
// Filter models based on search value
const filteredItems = useMemo(() => {
if (!searchValue) return searchableItems
return fzfInstance.find(searchValue).map((result) => {
const item = result.item
const positions = Array.from(result.positions) || []
const highlightedId = highlightFzfMatch(
item.model.id,
positions,
'text-accent'
)
return {
...item,
highlightedId,
}
})
}, [searchableItems, searchValue, fzfInstance])
// Group filtered items by provider
const groupedItems = useMemo(() => {
const groups: Record<string, SearchableModel[]> = {}
filteredItems.forEach((item) => {
const providerKey = item.provider.provider
if (!groups[providerKey]) {
groups[providerKey] = []
}
groups[providerKey].push(item)
})
return groups
}, [filteredItems])
const handleSelect = useCallback(
(searchableModel: SearchableModel) => {
selectModelProvider(
searchableModel.provider.provider,
searchableModel.model.id
)
updateCurrentThreadModel({
id: searchableModel.model.id,
provider: searchableModel.provider.provider,
})
setSearchValue('')
setOpen(false)
},
[selectModelProvider, updateCurrentThreadModel]
)
const currentModel = selectedModel?.id const currentModel = selectedModel?.id
? getModelBy(selectedModel?.id) ? getModelBy(selectedModel?.id)
: undefined : undefined
@ -63,114 +185,159 @@ const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => {
const provider = getProviderByName(selectedProvider) const provider = getProviderByName(selectedProvider)
return ( return (
<> <Popover open={open} onOpenChange={onOpenChange}>
<DropdownMenu> <div className="bg-main-view-fg/5 hover:bg-main-view-fg/8 px-2 py-1 flex items-center gap-1.5 rounded-sm max-h-[32px]">
<div className="bg-main-view-fg/5 hover:bg-main-view-fg/8 px-2 py-1 flex items-center gap-1.5 rounded-sm max-h-[32px]"> <PopoverTrigger asChild>
<DropdownMenuTrigger asChild> <button
<button title={displayModel}
title={displayModel} className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-38"
className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-38" >
> {provider && (
{provider && ( <div className="shrink-0">
<div className="shrink-0"> <ProvidersAvatar provider={provider} />
<ProvidersAvatar provider={provider} /> </div>
</div> )}
<span
className={cn(
'text-main-view-fg/80 truncate leading-normal',
!selectedModel?.id && 'text-main-view-fg/50'
)} )}
<span >
className={cn( {displayModel}
'text-main-view-fg/80 truncate leading-normal', </span>
!selectedModel?.id && 'text-main-view-fg/50' </button>
)} </PopoverTrigger>
> {currentModel?.settings && provider && (
{displayModel} <ModelSetting model={currentModel as Model} provider={provider} />
</span> )}
</button> </div>
</DropdownMenuTrigger>
{currentModel?.settings && provider && (
<ModelSetting model={currentModel as Model} provider={provider} />
)}
</div>
<DropdownMenuContent
className="w-60 max-h-[320px]"
side="bottom"
align="start"
sideOffset={10}
alignOffset={-8}
>
<DropdownMenuGroup>
{providers.map((provider, index) => {
// Only show active providers
if (!provider.active) return null
return ( <PopoverContent
<div className="w-80 p-0 max-h-[400px] overflow-hidden"
className={cn( side="bottom"
'bg-main-view-fg/4 first:mt-0 rounded-sm my-1.5 first:mb-0 ' align="start"
)} sideOffset={10}
key={`provider-${index}`} alignOffset={-8}
> avoidCollisions={false}
<div className="flex items-center justify-between"> >
<DropdownMenuLabel className="flex items-center gap-1.5"> <div className="flex flex-col w-full">
<ProvidersAvatar provider={provider} /> {/* Search input */}
<span className="capitalize truncate text-sm"> <div className="relative px-2 py-1.5 border-b border-main-view-fg/10">
{getProviderTitle(provider.provider)} <input
</span> ref={searchInputRef}
</DropdownMenuLabel> value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Search models..."
className="text-sm font-normal outline-0"
/>
{searchValue.length > 0 && (
<div className="absolute right-2 top-0 bottom-0 flex items-center justify-center">
<IconX
size={16}
className="text-main-view-fg/50 hover:text-main-view-fg cursor-pointer"
onClick={onClearSearch}
/>
</div>
)}
</div>
{/* Model list */}
<div className="max-h-[320px] overflow-y-auto">
{Object.keys(groupedItems).length === 0 && searchValue ? (
<div className="py-3 px-4 text-sm text-main-view-fg/60">
No models found for "{searchValue}"
</div>
) : (
<div className="py-1">
{Object.entries(groupedItems).map(([providerKey, models]) => {
const providerInfo = providers.find(
(p) => p.provider === providerKey
)
if (!providerInfo) return null
return (
<div <div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-2" key={providerKey}
onClick={() => className="bg-main-view-fg/4 first:mt-0 rounded-sm my-1.5 mx-1.5 first:mb-0"
navigate({
to: route.settings.providers,
params: { providerName: provider.provider },
})
}
> >
<IconSettings {/* Provider header */}
size={18} <div className="flex items-center justify-between px-2 py-1">
className="text-main-view-fg/50" <div className="flex items-center gap-1.5">
/> <ProvidersAvatar provider={providerInfo} />
</div> <span className="capitalize truncate text-sm font-medium text-main-view-fg/80">
</div> {getProviderTitle(providerInfo.provider)}
{provider.models.map((model, modelIndex) => {
const capabilities = model.capabilities || []
return (
<DropdownMenuItem
className={cn(
'h-8 mx-1',
provider.provider !== 'llama.cpp' &&
!provider.api_key?.length &&
'hidden'
)}
title={model.id}
key={`model-${modelIndex}`}
onClick={() => {
selectModelProvider(provider.provider, model.id)
updateCurrentThreadModel({
id: model.id,
provider: provider.provider,
})
}}
>
<div className="flex items-center gap-1.5 w-full">
<span className="truncate text-main-view-fg/70">
{model.id}
</span> </span>
<div className="-mr-1.5">
<Capabilities capabilities={capabilities} />
</div>
</div> </div>
</DropdownMenuItem> <div
) className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
})} onClick={(e) => {
</div> e.stopPropagation()
) navigate({
})} to: route.settings.providers,
</DropdownMenuGroup> params: { providerName: providerInfo.provider },
</DropdownMenuContent> })
</DropdownMenu> setOpen(false)
</> }}
>
<IconSettings
size={16}
className="text-main-view-fg/50"
/>
</div>
</div>
{/* Models for this provider */}
{models.map((searchableModel) => {
const isSelected =
selectedModel?.id === searchableModel.model.id &&
selectedProvider === searchableModel.provider.provider
const capabilities =
searchableModel.model.capabilities || []
return (
<div
key={searchableModel.value}
onClick={() => handleSelect(searchableModel)}
className={cn(
'mx-1 mb-1 px-2 py-1.5 rounded cursor-pointer flex items-center gap-2 transition-all duration-200',
'hover:bg-main-view-fg/10',
isSelected && 'bg-main-view-fg/15'
)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span
className="truncate text-main-view-fg/80 text-sm"
dangerouslySetInnerHTML={{
__html:
searchableModel.highlightedId ||
searchableModel.model.id,
}}
/>
{isSelected && (
<IconCheck
size={16}
className="text-accent shrink-0"
/>
)}
<div className="flex-1"></div>
{capabilities.length > 0 && (
<div className="flex-shrink-0">
<Capabilities capabilities={capabilities} />
</div>
)}
</div>
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
) )
} }