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