Enhance ModelSelect component with searchable dropdown and improved styling

This commit is contained in:
Spenquatch 2025-04-09 22:33:39 -04:00
parent 3999c916a5
commit cbd30a91ef
2 changed files with 273 additions and 14 deletions

View File

@ -1,33 +1,192 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import Fuse, { FuseResult } from 'fuse.js'
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSettings } from '../../../contexts/SettingsContext' import { useSettings } from '../../../contexts/SettingsContext'
import { GetProviderModelIds } from "../../../utils/api" 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[];
};
type HighlightedItem = {
id: string;
html: TextSegment[];
};
// Reuse highlight function from ProviderModelsPicker
const highlight = (fuseSearchResult: Array<FuseResult<SearchableItem>>): HighlightedItem[] => {
const set = (obj: Record<string, unknown>, path: string, value: TextSegment[]): void => {
const pathValue = path.split(".")
let i: number
let current = obj as Record<string, unknown>
for (i = 0; i < pathValue.length - 1; i++) {
const nextValue = current[pathValue[i]]
if (typeof nextValue === 'object' && nextValue !== null) {
current = nextValue as Record<string, unknown>
} 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 ? (
<span key={index} className="infio-llm-setting-model-item-highlight">{segment.text}</span>
) : (
<span key={index}>{segment.text}</span>
)
))}
</>
);
};
export function ModelSelect() { export function ModelSelect() {
const { settings, setSettings } = useSettings() const { settings, setSettings } = useSettings()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [modelProvider, setModelProvider] = useState(settings.chatModelProvider)
const [chatModelId, setChatModelId] = useState(settings.chatModelId) const [chatModelId, setChatModelId] = useState(settings.chatModelId)
const [providerModels, setProviderModels] = useState<string[]>([]) const [modelIds, setModelIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const providers = GetAllProviders()
useEffect(() => { useEffect(() => {
const fetchModels = async () => { const fetchModels = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const models = await GetProviderModelIds(settings.chatModelProvider) const models = await GetProviderModelIds(modelProvider)
setProviderModels(models) setModelIds(models)
setChatModelId(settings.chatModelId) setChatModelId(settings.chatModelId)
} catch (error) { } catch (error) {
console.error('Failed to fetch provider models:', error) console.error('Failed to fetch provider models:', error)
setModelIds([])
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
fetchModels() fetchModels()
}, [settings]) }, [modelProvider, settings.chatModelId])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse<SearchableItem>(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
}))
return results
}, [searchableItems, searchTerm, fuse])
return ( return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
@ -36,30 +195,126 @@ export function ModelSelect() {
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />} {isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div> </div>
<div className="infio-chat-input-model-select__model-name"> <div className="infio-chat-input-model-select__model-name">
{chatModelId} [{modelProvider}] {chatModelId}
</div> </div>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
className="infio-popover"> <div className="infio-llm-setting-search-container">
<select
className="infio-llm-setting-provider-switch"
value={modelProvider}
onChange={(e) => {
const newProvider = e.target.value as ApiProvider
setModelProvider(newProvider)
setSearchTerm("")
setSelectedIndex(0)
}}
>
{providers.map((provider) => (
<option
key={provider}
value={provider}
className={`infio-llm-setting-provider-option ${provider === modelProvider ? 'is-active' : ''}`}
>
{provider}
</option>
))}
</select>
{modelIds.length > 0 ? (
<input
type="text"
className="infio-llm-setting-item-search"
placeholder="search model..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setSelectedIndex(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
}
}}
/>
) : (
<input
type="text"
className="infio-llm-setting-item-search"
placeholder="input custom model name"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: searchTerm,
})
setChatModelId(searchTerm)
setIsOpen(false)
}
}}
/>
)}
</div>
<ul> <ul>
{isLoading ? ( {isLoading ? (
<li>Loading...</li> <li>Loading...</li>
) : ( ) : (
providerModels.map((modelId) => ( filteredOptions.map((option, index) => (
<DropdownMenu.Item <DropdownMenu.Item
key={modelId} key={option.id}
onSelect={() => { onSelect={() => {
setChatModelId(modelId)
setSettings({ setSettings({
...settings, ...settings,
chatModelId: modelId, chatModelProvider: modelProvider,
chatModelId: option.id,
}) })
setChatModelId(option.id)
setSearchTerm("")
setIsOpen(false)
}} }}
className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`}
onMouseEnter={() => setSelectedIndex(index)}
asChild asChild
> >
<li>{modelId}</li> <li>
<HighlightedText segments={option.html} />
</li>
</DropdownMenu.Item> </DropdownMenu.Item>
)) ))
)} )}

View File

@ -478,6 +478,10 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
max-width: 240px; max-width: 240px;
} }
.infio-popover.infio-llm-setting-combobox-dropdown {
max-width: 340px;
}
.infio-popover ul { .infio-popover ul {
padding: 0; padding: 0;
list-style: none; list-style: none;