Skip to content

Commit d3ddf72

Browse files
authored
[litellm] Display model IDs to match search string (#1445)
1 parent 24e7f2d commit d3ddf72

File tree

2 files changed

+371
-28
lines changed

2 files changed

+371
-28
lines changed
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import React, {
2+
useState,
3+
useRef,
4+
useEffect,
5+
useMemo,
6+
useCallback
7+
} from 'react';
8+
import {
9+
TextField,
10+
MenuItem,
11+
Paper,
12+
Popper,
13+
ClickAwayListener,
14+
TextFieldProps,
15+
IconButton,
16+
InputAdornment
17+
} from '@mui/material';
18+
import { styled } from '@mui/material/styles';
19+
import ClearIcon from '@mui/icons-material/Clear';
20+
21+
const StyledPopper = styled(Popper)(({ theme }) => ({
22+
zIndex: theme.zIndex.modal,
23+
'& .MuiPaper-root': {
24+
maxHeight: '200px',
25+
overflow: 'auto',
26+
border: `1px solid ${theme.palette.divider}`,
27+
boxShadow: theme.shadows[8]
28+
}
29+
}));
30+
31+
export type AutocompleteOption = {
32+
label: string;
33+
value: string;
34+
};
35+
36+
export type SimpleAutocompleteProps = {
37+
/**
38+
* List of options to show. Each option value should be unique.
39+
*/
40+
options: AutocompleteOption[];
41+
/**
42+
* (optional) Controls the value of the `Autocomplete` component.
43+
*/
44+
value?: string;
45+
/**
46+
* (optional) Callback fired when the input changes.
47+
*/
48+
onChange?: (value: string) => void;
49+
/**
50+
* (optional) Placeholder string shown in the text input while it is empty.
51+
* This can be used to provide a short example blurb.
52+
*/
53+
placeholder?: string;
54+
/**
55+
* (optional) Function that filters the list of options based on the input
56+
* value. By default, options whose labels do not contain the input value as a
57+
* substring are filtered and hidden. The default filter only filters the list
58+
* of options if the input contains >1 non-whitespace character.
59+
*/
60+
optionsFilter?: (
61+
options: AutocompleteOption[],
62+
inputValue: string
63+
) => AutocompleteOption[];
64+
/**
65+
* (optional) Additional props passed directly to the `TextField` child
66+
* component.
67+
*/
68+
textFieldProps?: Omit<TextFieldProps, 'value' | 'onChange'>;
69+
/**
70+
* (optional) Controls the number of options shown in the autocomplete menu.
71+
* Defaults to unlimited.
72+
*/
73+
maxOptions?: number;
74+
/**
75+
* (optional) If true, the component will treat options as case-sensitive when
76+
* the default options filter is used (i.e. `props.optionsFilter` is unset).
77+
*/
78+
caseSensitive?: boolean;
79+
/**
80+
* (optional) If true, the component will bold the substrings matching the
81+
* current input on each option. The input must contain >1 non-whitespace
82+
* character for this prop to take effect.
83+
*/
84+
boldMatches?: boolean;
85+
86+
/**
87+
* (optional) If true, shows a clear button when the input has a value.
88+
*/
89+
showClearButton?: boolean;
90+
};
91+
92+
function defaultOptionsFilter(
93+
options: AutocompleteOption[],
94+
inputValue: string,
95+
caseSensitive = false
96+
): AutocompleteOption[] {
97+
// Do nothing if the input contains <=1 non-whitespace character
98+
if (inputValue.trim().length <= 1) {
99+
return options;
100+
}
101+
102+
const searchValue = caseSensitive ? inputValue : inputValue.toLowerCase();
103+
104+
return options.filter(option => {
105+
const optionLabel = caseSensitive
106+
? option.label
107+
: option.label.toLowerCase();
108+
return optionLabel.includes(searchValue);
109+
});
110+
}
111+
112+
function highlightMatches(
113+
text: string,
114+
searchValue: string,
115+
caseSensitive = false
116+
): React.ReactNode {
117+
// Do nothing if the input contains <=1 non-whitespace character
118+
if (searchValue.trim().length <= 1) {
119+
return text;
120+
}
121+
122+
const searchText = caseSensitive ? searchValue : searchValue.toLowerCase();
123+
const targetText = caseSensitive ? text : text.toLowerCase();
124+
125+
const parts: React.ReactNode[] = [];
126+
let lastIndex = 0;
127+
let matchIndex = targetText.indexOf(searchText);
128+
129+
while (matchIndex !== -1) {
130+
if (matchIndex > lastIndex) {
131+
parts.push(text.slice(lastIndex, matchIndex));
132+
}
133+
134+
parts.push(
135+
<strong key={`${matchIndex}-${searchText}`}>
136+
{text.slice(matchIndex, matchIndex + searchText.length)}
137+
</strong>
138+
);
139+
140+
lastIndex = matchIndex + searchText.length;
141+
matchIndex = targetText.indexOf(searchText, lastIndex);
142+
}
143+
144+
if (lastIndex < text.length) {
145+
parts.push(text.slice(lastIndex));
146+
}
147+
148+
return parts.length > 0 ? <>{parts}</> : text;
149+
}
150+
151+
/**
152+
* A simple `Autocomplete` component with an emphasis on being bug-free and
153+
* performant. Notes:
154+
*
155+
* - By default, options are filtered using case-insensitive substring matching.
156+
*
157+
* - Clicking an option sets the value of this component and fires
158+
* `props.onChange()` if passed. It is treated identically to a user typing the
159+
* option literally.
160+
*
161+
* - Matched substrings will be shown in bold on each option when the
162+
* `boldMatches` prop is set.
163+
*/
164+
export function SimpleAutocomplete(
165+
props: SimpleAutocompleteProps
166+
): React.ReactElement {
167+
const [inputValue, setInputValue] = useState(props.value || '');
168+
const [isOpen, setIsOpen] = useState(false);
169+
const [focusedIndex, setFocusedIndex] = useState(-1);
170+
const textFieldRef = useRef<HTMLDivElement>(null);
171+
const inputRef = useRef<HTMLInputElement>(null);
172+
173+
// Filter and limit options
174+
const filteredOptions = useMemo(() => {
175+
const filterFn = props.optionsFilter || defaultOptionsFilter;
176+
const filtered = filterFn(props.options, inputValue, props.caseSensitive);
177+
return filtered.slice(0, props.maxOptions ?? props.options.length);
178+
}, [
179+
props.options,
180+
inputValue,
181+
props.optionsFilter,
182+
props.maxOptions,
183+
props.caseSensitive
184+
]);
185+
186+
// Sync external value changes
187+
useEffect(() => {
188+
setInputValue(props.value || '');
189+
}, [props.value]);
190+
191+
// Determine if menu should be open
192+
const shouldShowMenu = isOpen && filteredOptions.length > 0;
193+
194+
const handleInputChange = useCallback(
195+
(event: React.ChangeEvent<HTMLInputElement>): void => {
196+
const newValue = event.target.value;
197+
setInputValue(newValue);
198+
setFocusedIndex(-1);
199+
200+
if (!isOpen && newValue.trim() !== '') {
201+
setIsOpen(true);
202+
}
203+
204+
if (props.onChange) {
205+
props.onChange(newValue);
206+
}
207+
},
208+
[isOpen, props.onChange]
209+
);
210+
211+
const handleInputFocus = useCallback((): void => {
212+
setIsOpen(true);
213+
}, []);
214+
215+
const handleOptionClick = useCallback(
216+
(option: AutocompleteOption): void => {
217+
setInputValue(option.value);
218+
setIsOpen(false);
219+
setFocusedIndex(-1);
220+
221+
if (props.onChange) {
222+
props.onChange(option.value);
223+
}
224+
225+
if (inputRef.current) {
226+
inputRef.current.blur();
227+
}
228+
},
229+
[props.onChange]
230+
);
231+
232+
const handleKeyDown = useCallback(
233+
(event: React.KeyboardEvent): void => {
234+
if (!shouldShowMenu) {
235+
return;
236+
}
237+
238+
switch (event.key) {
239+
case 'ArrowDown':
240+
event.preventDefault();
241+
setFocusedIndex(prev => {
242+
return prev < filteredOptions.length - 1 ? prev + 1 : 0;
243+
});
244+
break;
245+
246+
case 'ArrowUp':
247+
event.preventDefault();
248+
setFocusedIndex(prev => {
249+
return prev > 0 ? prev - 1 : filteredOptions.length - 1;
250+
});
251+
break;
252+
253+
case 'Enter':
254+
event.preventDefault();
255+
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
256+
handleOptionClick(filteredOptions[focusedIndex]);
257+
}
258+
break;
259+
260+
case 'Escape':
261+
setIsOpen(false);
262+
setFocusedIndex(-1);
263+
break;
264+
}
265+
},
266+
[shouldShowMenu, filteredOptions, focusedIndex, handleOptionClick]
267+
);
268+
269+
const handleClickAway = useCallback((): void => {
270+
setIsOpen(false);
271+
setFocusedIndex(-1);
272+
}, []);
273+
274+
return (
275+
<ClickAwayListener onClickAway={handleClickAway}>
276+
<div style={{ position: 'relative', width: '100%' }}>
277+
<TextField
278+
{...props.textFieldProps}
279+
ref={textFieldRef}
280+
inputRef={inputRef}
281+
value={inputValue}
282+
onChange={handleInputChange}
283+
onFocus={handleInputFocus}
284+
onKeyDown={handleKeyDown}
285+
placeholder={props.placeholder}
286+
fullWidth
287+
InputProps={{
288+
...props.textFieldProps?.InputProps,
289+
endAdornment:
290+
props.showClearButton && inputValue ? (
291+
<InputAdornment position="end">
292+
<IconButton
293+
aria-label="clear input"
294+
onClick={() => {
295+
setInputValue('');
296+
if (props.onChange) {
297+
props.onChange('');
298+
}
299+
if (inputRef.current) {
300+
inputRef.current.focus();
301+
}
302+
}}
303+
edge="end"
304+
size="small"
305+
>
306+
<ClearIcon />
307+
</IconButton>
308+
</InputAdornment>
309+
) : (
310+
props.textFieldProps?.InputProps?.endAdornment
311+
)
312+
}}
313+
/>
314+
315+
<StyledPopper
316+
open={shouldShowMenu}
317+
anchorEl={textFieldRef.current}
318+
placement="bottom-start"
319+
style={{ width: textFieldRef.current?.offsetWidth }}
320+
>
321+
<Paper>
322+
{filteredOptions.map((option, index) => {
323+
const displayLabel = props.boldMatches
324+
? highlightMatches(
325+
option.label,
326+
inputValue,
327+
props.caseSensitive
328+
)
329+
: option.label;
330+
331+
return (
332+
<MenuItem
333+
key={`${option.value}-${index}`}
334+
selected={index === focusedIndex}
335+
onClick={() => {
336+
handleOptionClick(option);
337+
}}
338+
sx={{
339+
'&.Mui-selected': {
340+
backgroundColor: 'action.hover'
341+
},
342+
'&.Mui-selected:hover': {
343+
backgroundColor: 'action.selected'
344+
}
345+
}}
346+
>
347+
{displayLabel}
348+
</MenuItem>
349+
);
350+
})}
351+
</Paper>
352+
</StyledPopper>
353+
</div>
354+
</ClickAwayListener>
355+
);
356+
}

0 commit comments

Comments
 (0)