From 432d39af15539145bca7f20245e81e546bedeb99 Mon Sep 17 00:00:00 2001 From: Ivy Date: Thu, 26 Dec 2024 09:59:36 -0500 Subject: [PATCH] enhance: combobox with nested groups & use for default alias dropdown (#1013) * enhance: combobox with nested groups & use for default alias dropdown lint fixes * move sort & suggested label out of ComboBox --- ui/admin/app/components/agent/AgentForm.tsx | 26 +-- ui/admin/app/components/composed/ComboBox.tsx | 166 ++++++++++----- .../model/DefaultModelAliasForm.tsx | 199 ++++++++++-------- 3 files changed, 224 insertions(+), 167 deletions(-) diff --git a/ui/admin/app/components/agent/AgentForm.tsx b/ui/admin/app/components/agent/AgentForm.tsx index db7582e3e..e22890d66 100644 --- a/ui/admin/app/components/agent/AgentForm.tsx +++ b/ui/admin/app/components/agent/AgentForm.tsx @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form"; import useSWR from "swr"; import { z } from "zod"; -import { Model, ModelUsage, filterModelsByActive } from "~/lib/model/models"; +import { ModelUsage } from "~/lib/model/models"; import { ModelApiService } from "~/lib/service/api/modelApiService"; import { TypographyH4 } from "~/components/Typography"; @@ -15,6 +15,7 @@ import { ControlledCustomInput, ControlledInput, } from "~/components/form/controlledInputs"; +import { getModelOptionsByModelProvider } from "~/components/model/DefaultModelAliasForm"; import { Form } from "~/components/ui/form"; const formSchema = z.object({ @@ -134,27 +135,4 @@ export function AgentForm({ agent, onSubmit, onChange }: AgentFormProps) { ); - - function getModelOptionsByModelProvider(models: Model[]) { - const byModelProviderGroups = filterModelsByActive(models).reduce( - (acc, model) => { - acc[model.modelProvider] = acc[model.modelProvider] || []; - acc[model.modelProvider].push(model); - return acc; - }, - {} as Record - ); - - return Object.entries(byModelProviderGroups).map( - ([modelProvider, models]) => { - const sorted = models.sort((a, b) => - (a.name ?? "").localeCompare(b.name ?? "") - ); - return { - heading: modelProvider, - value: sorted, - }; - } - ); - } } diff --git a/ui/admin/app/components/composed/ComboBox.tsx b/ui/admin/app/components/composed/ComboBox.tsx index 2dcd414d5..06d819f62 100644 --- a/ui/admin/app/components/composed/ComboBox.tsx +++ b/ui/admin/app/components/composed/ComboBox.tsx @@ -25,15 +25,17 @@ type BaseOption = { type GroupedOption = { heading: string; - value: T[]; + value: (T | GroupedOption)[]; }; type ComboBoxProps = { allowClear?: boolean; clearLabel?: ReactNode; + emptyLabel?: ReactNode; onChange: (option: T | null) => void; - options: T[] | GroupedOption[]; + options: (T | GroupedOption)[]; placeholder?: string; + renderOption?: (option: T) => ReactNode; value?: T | null; }; @@ -41,6 +43,7 @@ export function ComboBox({ disabled, placeholder, value, + renderOption, ...props }: { disabled?: boolean; @@ -50,10 +53,15 @@ export function ComboBox({ if (!isMobile) { return ( - + {renderButtonContent()} - + ); @@ -64,7 +72,12 @@ export function ComboBox({ {renderButtonContent()}
- +
@@ -82,7 +95,9 @@ export function ComboBox({ }} > - {value ? value.name : placeholder} + {renderOption && value + ? renderOption(value) + : (value?.name ?? placeholder)} ); @@ -94,16 +109,69 @@ function ComboBoxList({ clearLabel, onChange, options, - placeholder = "Filter...", setOpen, + renderOption, value, + placeholder = "Filter...", + emptyLabel = "No results found.", }: { setOpen: (open: boolean) => void } & ComboBoxProps) { - const isGrouped = options.every((option) => "heading" in option); + const [filteredOptions, setFilteredOptions] = + useState(options); + + const filterOptions = ( + items: (T | GroupedOption)[], + searchValue: string + ): (T | GroupedOption)[] => + items.reduce( + (acc, option) => { + const isSingleValueMatch = + "name" in option && + option.name + ?.toLowerCase() + .includes(searchValue.toLowerCase()); + const isGroupHeadingMatch = + "heading" in option && + option.heading + .toLowerCase() + .includes(searchValue.toLowerCase()); + + if (isGroupHeadingMatch || isSingleValueMatch) { + return [...acc, option]; + } + + if ("heading" in option) { + const matches = filterOptions(option.value, searchValue); + return matches.length > 0 + ? [ + ...acc, + { + ...option, + value: matches, + }, + ] + : acc; + } + + return acc; + }, + [] as (T | GroupedOption)[] + ); + + const handleValueChange = (value: string) => { + setFilteredOptions(filterOptions(options, value)); + }; + return ( - - + + - No results found. + {emptyLabel} {allowClear && ( ({ )} - {isGrouped - ? renderGroupedOptions(options) - : renderUngroupedOptions(options)} + {filteredOptions.map((option) => + "heading" in option + ? renderGroupedOption(option) + : renderUngroupedOption(option) + )} ); - function renderGroupedOptions(items: GroupedOption[]) { - return items.map((group) => ( + function renderGroupedOption(group: GroupedOption) { + return ( - {group.value.map((option) => ( - { - const match = - group.value.find((opt) => opt.name === name) || - null; - onChange(match); - setOpen(false); - }} - className="justify-between" - > - {option.name || option.id}{" "} - {value?.id === option.id && ( - - )} - - ))} + {group.value.map((option) => + "heading" in option + ? renderGroupedOption(option) + : renderUngroupedOption(option) + )} - )); + ); } - function renderUngroupedOptions(items: T[]) { + function renderUngroupedOption(option: T) { return ( - - {items.map((option) => ( - { - const match = - items.find((opt) => opt.name === name) || null; - onChange(match); - setOpen(false); - }} - className="justify-between" - > - {option.name || option.id}{" "} - {value?.id === option.id && ( - - )} - - ))} - + { + onChange(option); + setOpen(false); + }} + className="justify-between" + > + + {renderOption + ? renderOption(option) + : (option.name ?? option.id)} + + {value?.id === option.id && } + ); } } diff --git a/ui/admin/app/components/model/DefaultModelAliasForm.tsx b/ui/admin/app/components/model/DefaultModelAliasForm.tsx index d3b92f755..b17b52a58 100644 --- a/ui/admin/app/components/model/DefaultModelAliasForm.tsx +++ b/ui/admin/app/components/model/DefaultModelAliasForm.tsx @@ -17,7 +17,7 @@ import { import { DefaultModelAliasApiService } from "~/lib/service/api/defaultModelAliasApiService"; import { ModelApiService } from "~/lib/service/api/modelApiService"; -import { TypographyP } from "~/components/Typography"; +import { ComboBox } from "~/components/composed/ComboBox"; import { SUGGESTED_MODEL_SELECTIONS } from "~/components/model/constants"; import { Button } from "~/components/ui/button"; import { @@ -36,15 +36,6 @@ import { FormLabel, FormMessage, } from "~/components/ui/form"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { useAsync } from "~/hooks/useAsync"; export function DefaultModelAliasForm({ @@ -179,29 +170,39 @@ export function DefaultModelAliasForm({
- + ) + } + value={ + field.value + ? models?.find( + (m) => + m.id === + field.value + ) + : models?.find( + (m) => + m.name === + defaultModel + ) + } + /> @@ -224,67 +225,57 @@ export function DefaultModelAliasForm({ ); - function renderSelectContent( + function renderDisplayOption(option: Model, alias: ModelAlias) { + const suggestion = alias && SUGGESTED_MODEL_SELECTIONS[alias]; + + return ( + + {option.name}{" "} + {suggestion === option.name && ( + (Suggested) + )} + + ); + } + + function getOptionsByUsageAndProvider( modelOptions: Model[] | undefined, - defaultModel: string, usage: ModelUsage, aliasFor: ModelAlias ) { - if (!modelOptions) { - if (!defaultModel) - return ( - - No Models Available. - - ); - return {defaultModel}; - } + if (!modelOptions) return []; - return ( - <> - - {getModelUsageLabel(usage)} - - {modelOptions.map((model) => ( - - {getModelOptionLabel(model, aliasFor)} - - ))} - - - {otherModels.length > 0 && ( - - Other - - {otherModels.map((model) => ( - - {model.name || model.id} - {" - "} - - {model.modelProvider} - - - ))} - - )} - + const suggested = aliasFor && SUGGESTED_MODEL_SELECTIONS[aliasFor]; + const usageGroupName = getModelUsageLabel(usage); + const usageModelProviderGroups = getModelOptionsByModelProvider( + modelOptions, + suggested ? [suggested] : [] ); - } -} -function getModelOptionLabel(model: Model, aliasFor: ModelAlias) { - // if the model name is the same as the suggested model name, show that it's suggested - const suggestionName = SUGGESTED_MODEL_SELECTIONS[aliasFor]; - return ( - <> - {model.name || model.id}{" "} - {suggestionName === model.name && ( - (Suggested) - )} - {" - "} - {model.modelProvider} - - ); + const otherModelProviderGroups = + getModelOptionsByModelProvider(otherModels); + const usageGroup = { + heading: usageGroupName, + value: usageModelProviderGroups, + }; + + if ( + usageModelProviderGroups.length === 0 && + otherModelProviderGroups.length === 0 + ) { + return []; + } + + return otherModelProviderGroups.length > 0 + ? [ + usageGroup, + { + heading: "Other", + value: otherModelProviderGroups, + }, + ] + : [usageGroup]; + } } export function DefaultModelAliasFormDialog({ @@ -316,3 +307,39 @@ export function DefaultModelAliasFormDialog({ ); } + +export function getModelOptionsByModelProvider( + models: Model[], + suggestions?: string[] +) { + const byModelProviderGroups = filterModelsByActive(models).reduce( + (acc, model) => { + acc[model.modelProvider] = acc[model.modelProvider] || []; + acc[model.modelProvider].push(model); + return acc; + }, + {} as Record + ); + + return Object.entries(byModelProviderGroups).map( + ([modelProvider, models]) => { + return { + heading: modelProvider, + value: models.sort((a, b) => { + // First compare by suggestion status if suggestions are provided + const aIsSuggested = + a.name && suggestions?.includes(a.name); + const bIsSuggested = + b.name && suggestions?.includes(b.name); + + if (aIsSuggested !== bIsSuggested) { + return aIsSuggested ? -1 : 1; + } + + // If suggestion status is the same, sort alphabetically + return (a.name ?? "").localeCompare(b.name ?? ""); + }), + }; + } + ); +}