Skip to content
Open
Binary file added gui/public/logos/openrouter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
262 changes: 183 additions & 79 deletions gui/src/components/modelSelection/ModelSelectionListbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import {
CheckIcon,
ChevronUpDownIcon,
CubeIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
import { Fragment } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import {
Listbox,
ListboxButton,
Expand All @@ -19,16 +20,86 @@ interface ModelSelectionListboxProps {
setSelectedProvider: (val: DisplayInfo) => void;
topOptions?: DisplayInfo[];
otherOptions?: DisplayInfo[];
searchPlaceholder?: string;
}

/**
* Simple fuzzy search algorithm
* Returns a score based on how well the query matches the text
*/
function fuzzyScore(query: string, text: string): number {
const q = query.toLowerCase();
const t = text.toLowerCase();

if (!q) return 1; // Empty query matches everything
if (!t) return 0;

let score = 0;
let queryIdx = 0;
let lastMatchIdx = -1;

for (let i = 0; i < t.length && queryIdx < q.length; i++) {
if (t[i] === q[queryIdx]) {
score += 1 + (lastMatchIdx === i - 1 ? 5 : 0); // Bonus for consecutive matches
lastMatchIdx = i;
queryIdx++;
}
}

// Return 0 if not all query characters were found
return queryIdx === q.length ? score / t.length : 0;
}

function ModelSelectionListbox({
selectedProvider,
setSelectedProvider,
topOptions = [],
otherOptions = [],
searchPlaceholder = "Search models...",
}: ModelSelectionListboxProps) {
const [searchQuery, setSearchQuery] = useState("");

// Clear search query when provider changes
useEffect(() => {
setSearchQuery("");
}, [selectedProvider]);

// Combine and filter options based on fuzzy search
const filteredTopOptions = useMemo(() => {
if (!searchQuery) return topOptions;
return topOptions
.map((opt) => ({
option: opt,
score: fuzzyScore(searchQuery, opt.title),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ option }) => option);
}, [searchQuery, topOptions]);

const filteredOtherOptions = useMemo(() => {
if (!searchQuery) return otherOptions;
return otherOptions
.map((opt) => ({
option: opt,
score: fuzzyScore(searchQuery, opt.title),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ option }) => option);
}, [searchQuery, otherOptions]);

const hasResults =
filteredTopOptions.length > 0 || filteredOtherOptions.length > 0;

return (
<Listbox value={selectedProvider} onChange={setSelectedProvider}>
<Listbox
value={selectedProvider}
onChange={(value) => {
setSelectedProvider(value);
setSearchQuery("");
}}
>
<div className="relative mb-2 mt-1">
<ListboxButton className="bg-background border-border text-foreground hover:bg-input relative m-0 grid h-full w-full cursor-pointer grid-cols-[1fr_auto] items-center rounded-lg border border-solid py-2 pl-3 pr-10 text-left focus:outline-none">
<span className="flex items-center">
Expand All @@ -54,87 +125,120 @@ function ModelSelectionListbox({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="bg-input rounded-default absolute left-0 top-full z-10 mt-1 h-fit w-3/5 overflow-y-auto p-0 focus:outline-none [&]:!max-h-[30vh]">
{topOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Popular
</div>
{topOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-default cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4`
}
value={option}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
)}
<span className="text-xs">{option.title}</span>
</div>
{selected && (
<CheckIcon className="h-3 w-3" aria-hidden="true" />
)}
</>
)}
</ListboxOption>
))}
<ListboxOptions className="bg-input rounded-default absolute left-0 top-full z-10 mt-1 flex h-fit w-3/5 flex-col overflow-y-auto p-0 focus:outline-none [&]:!max-h-[30vh]">
{/* Search Box */}
<div className="border-border sticky top-0 border-b p-2">
<div className="bg-background border-border flex items-center rounded border pl-2">
<MagnifyingGlassIcon className="text-description-muted h-4 w-4" />
<input
type="text"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background text-foreground placeholder-description-muted w-full border-0 px-2 py-1.5 outline-none"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{topOptions.length > 0 && otherOptions.length > 0 && (
<div className="bg-border my-1 h-px min-h-px" />
)}
{otherOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Additional providers
</div>

{/* Results */}
<div className="flex-1 overflow-y-auto">
{!hasResults ? (
<div className="text-description-muted px-3 py-4 text-center text-xs">
No models found matching "{searchQuery}"
</div>
{otherOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-default cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4`
}
value={option}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
) : (
<>
{filteredTopOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Popular
</div>
{filteredTopOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4`
}
value={option}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
)}
<span className="text-xs">{option.title}</span>
</div>
{selected && (
<CheckIcon
className="h-3 w-3"
aria-hidden="true"
/>
)}
</>
)}
<span className="text-xs">{option.title}</span>
</div>

{selected && (
<CheckIcon className="h-3 w-3" aria-hidden="true" />
)}
</>
</ListboxOption>
))}
</div>
)}
{filteredTopOptions.length > 0 &&
filteredOtherOptions.length > 0 && (
<div className="bg-border my-1 h-px min-h-px" />
)}
</ListboxOption>
))}
</div>
)}
{filteredOtherOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Additional providers
</div>
{filteredOtherOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4`
}
value={option}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
)}
<span className="text-xs">{option.title}</span>
</div>

{selected && (
<CheckIcon
className="h-3 w-3"
aria-hidden="true"
/>
)}
</>
)}
</ListboxOption>
))}
</div>
)}
</>
)}
</div>
</ListboxOptions>
</Transition>
</div>
Expand Down
17 changes: 12 additions & 5 deletions gui/src/forms/AddModelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { IdeMessengerContext } from "../context/IdeMessenger";
import { completionParamsInputs } from "../pages/AddNewModel/configs/completionParamsInputs";
import { DisplayInfo } from "../pages/AddNewModel/configs/models";
import {
initializeOpenRouterModels,
ProviderInfo,
providers,
} from "../pages/AddNewModel/configs/providers";
Expand Down Expand Up @@ -40,13 +41,19 @@ export function AddModelForm({
const formMethods = useForm();
const ideMessenger = useContext(IdeMessengerContext);

// Initialize OpenRouter models from API on component mount
useEffect(() => {
void initializeOpenRouterModels();
}, []);

const popularProviderTitles = [
providers["openai"]?.title || "",
providers["anthropic"]?.title || "",
providers["mistral"]?.title || "",
providers["gemini"]?.title || "",
providers["azure"]?.title || "",
providers["ollama"]?.title || "",
providers["openrouter"]?.title || "",
];

const allProviders = Object.entries(providers)
Expand All @@ -63,11 +70,10 @@ export function AddModelForm({
.filter((provider) => !popularProviderTitles.includes(provider.title))
.sort((a, b) => a.title.localeCompare(b.title));

const selectedProviderApiKeyUrl = selectedModel.params.model.startsWith(
"codestral",
)
? CODESTRAL_URL
: selectedProvider.apiKeyUrl;
const selectedProviderApiKeyUrl =
selectedModel && selectedModel.params.model.startsWith("codestral")
? CODESTRAL_URL
: selectedProvider.apiKeyUrl;

function isDisabled() {
if (selectedProvider.downloadUrl) {
Expand Down Expand Up @@ -149,6 +155,7 @@ export function AddModelForm({
}}
topOptions={popularProviders}
otherOptions={otherProviders}
searchPlaceholder="Search providers..."
/>
<span className="text-description-muted mt-1 block text-xs">
Don't see your provider?{" "}
Expand Down
Loading