Adds ability to manually enter model name
This commit is contained in:
120
web/src/App.tsx
120
web/src/App.tsx
@@ -119,9 +119,8 @@ function loadStoredModelPreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
||||
if (fallback && options.includes(fallback)) return fallback;
|
||||
if (preferred && options.includes(preferred)) return preferred;
|
||||
function pickProviderModel(options: string[], preferred: string | null) {
|
||||
if (preferred?.trim()) return preferred.trim();
|
||||
return options[0] ?? "";
|
||||
}
|
||||
|
||||
@@ -197,34 +196,46 @@ type ModelComboboxProps = {
|
||||
|
||||
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const normalizedDraftValue = draftValue.trim();
|
||||
const filteredOptions = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const needle = normalizedDraftValue.toLowerCase();
|
||||
if (!needle) return options;
|
||||
return options.filter((option) => option.toLowerCase().includes(needle));
|
||||
}, [options, query]);
|
||||
}, [normalizedDraftValue, options]);
|
||||
const hasExactOption = options.includes(normalizedDraftValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setDraftValue(value);
|
||||
}, [open, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const commitDraftValue = () => {
|
||||
onChange(normalizedDraftValue);
|
||||
setDraftValue(normalizedDraftValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current?.contains(event.target as Node)) return;
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
commitDraftValue();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setDraftValue(value);
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
@@ -233,32 +244,63 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
}, [commitDraftValue, open, value]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm"
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate text-left">{value || "Select model"}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<div className="flex h-9 min-w-56 items-center rounded-md border border-input bg-background px-2 text-sm">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
onFocus={() => {
|
||||
if (disabled) return;
|
||||
setDraftValue(value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onInput={(event) => {
|
||||
setDraftValue(event.currentTarget.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
commitDraftValue();
|
||||
}}
|
||||
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Select or type model"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 shrink-0 text-muted-foreground disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (open) {
|
||||
commitDraftValue();
|
||||
return;
|
||||
}
|
||||
setDraftValue(value);
|
||||
setOpen(true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Toggle model options"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none"
|
||||
placeholder="Filter models"
|
||||
/>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{normalizedDraftValue && !hasExactOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||
onClick={commitDraftValue}
|
||||
>
|
||||
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
|
||||
<span className="truncate">Use "{normalizedDraftValue}"</span>
|
||||
</button>
|
||||
) : null}
|
||||
{filteredOptions.length ? (
|
||||
filteredOptions.map((option) => (
|
||||
<button
|
||||
@@ -268,16 +310,16 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setDraftValue(option);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
|
||||
<span className="truncate">{option}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
) : !normalizedDraftValue ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -545,10 +587,11 @@ export default function App() {
|
||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.trim()) return;
|
||||
setModel((current) => {
|
||||
return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current);
|
||||
return current.trim() || pickProviderModel(providerModelOptions, providerModelPreferences[provider]);
|
||||
});
|
||||
}, [provider, providerModelOptions, providerModelPreferences]);
|
||||
}, [model, provider, providerModelOptions, providerModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -1233,7 +1276,7 @@ export default function App() {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setProvider(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current));
|
||||
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||
}}
|
||||
disabled={isSending}
|
||||
>
|
||||
@@ -1244,12 +1287,13 @@ export default function App() {
|
||||
<ModelCombobox
|
||||
options={providerModelOptions}
|
||||
value={model}
|
||||
disabled={isSending || providerModelOptions.length === 0}
|
||||
disabled={isSending}
|
||||
onChange={(nextModel) => {
|
||||
setModel(nextModel);
|
||||
const normalizedModel = nextModel.trim();
|
||||
setModel(normalizedModel);
|
||||
setProviderModelPreferences((current) => ({
|
||||
...current,
|
||||
[provider]: nextModel,
|
||||
[provider]: normalizedModel || null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user