From e62dc97de1160c2fb269aa9f94adc34c85400226 Mon Sep 17 00:00:00 2001 From: Ivy Date: Fri, 20 Dec 2024 13:39:18 -0500 Subject: [PATCH 1/2] enhance: combobox with nested groups & use for default alias dropdown lint fixes --- ui/admin/app/components/agent/AgentForm.tsx | 26 +-- ui/admin/app/components/composed/ComboBox.tsx | 191 ++++++++++++------ .../model/DefaultModelAliasForm.tsx | 174 ++++++++-------- 3 files changed, 221 insertions(+), 170 deletions(-) diff --git a/ui/admin/app/components/agent/AgentForm.tsx b/ui/admin/app/components/agent/AgentForm.tsx index db7582e3..e22890d6 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 2dcd414d..5c2b34c1 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; + suggested?: string[]; value?: T | null; }; @@ -41,6 +43,7 @@ export function ComboBox({ disabled, placeholder, value, + suggested, ...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,12 @@ export function ComboBox({ }} > - {value ? value.name : placeholder} + {value ? value.name : placeholder}{" "} + {value?.name && suggested?.includes(value.name) && ( + + (Suggested) + + )} ); @@ -94,16 +112,85 @@ function ComboBoxList({ clearLabel, onChange, options, - placeholder = "Filter...", setOpen, + suggested, 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 sortBySuggested = ( + a: T | GroupedOption, + b: T | GroupedOption + ) => { + // Handle nested groups - keep original order + if ("heading" in a || "heading" in b) return 0; + + const aIsSuggested = a.name && suggested?.includes(a.name); + const bIsSuggested = b.name && suggested?.includes(b.name); + + // If both or neither are suggested, maintain original order + if (aIsSuggested === bIsSuggested) return 0; + // Sort suggested items first + return aIsSuggested ? -1 : 1; + }; + + 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 + .slice() // Create a copy to avoid mutating original array + .sort(sortBySuggested) + .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" + > + + {option.name || option.id}{" "} + {option?.name && suggested?.includes(option.name) && ( + + (Suggested) + + )} + + {value?.id === option.id && } + ); } } diff --git a/ui/admin/app/components/model/DefaultModelAliasForm.tsx b/ui/admin/app/components/model/DefaultModelAliasForm.tsx index d3b92f75..e5abfa0b 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,33 @@ export function DefaultModelAliasForm({
- + + field.onChange( + value?.id ?? "" + ) + } + options={getOptionsByUsageAndProvider( + activeModelOptions, + usage + )} + suggested={getSuggested(alias)} + value={ + field.value + ? models?.find( + (m) => + m.id === + field.value + ) + : models?.find( + (m) => + m.name === + defaultModel + ) + } + /> @@ -224,69 +219,47 @@ export function DefaultModelAliasForm({ ); - function renderSelectContent( + function getSuggested(alias: ModelAlias) { + return alias && SUGGESTED_MODEL_SELECTIONS[alias] + ? [SUGGESTED_MODEL_SELECTIONS[alias]] + : []; + } + + function getOptionsByUsageAndProvider( modelOptions: Model[] | undefined, - defaultModel: string, - usage: ModelUsage, - aliasFor: ModelAlias + usage: ModelUsage ) { - if (!modelOptions) { - if (!defaultModel) - return ( - - No Models Available. - - ); - return {defaultModel}; + if (!modelOptions) return []; + + const usageGroupName = getModelUsageLabel(usage); + const usageModelProviderGroups = + getModelOptionsByModelProvider(modelOptions); + const otherModelProviderGroups = + getModelOptionsByModelProvider(otherModels); + const usageGroup = { + heading: usageGroupName, + value: usageModelProviderGroups, + }; + + if ( + usageModelProviderGroups.length === 0 && + otherModelProviderGroups.length === 0 + ) { + return []; } - return ( - <> - - {getModelUsageLabel(usage)} - - {modelOptions.map((model) => ( - - {getModelOptionLabel(model, aliasFor)} - - ))} - - - {otherModels.length > 0 && ( - - Other - - {otherModels.map((model) => ( - - {model.name || model.id} - {" - "} - - {model.modelProvider} - - - ))} - - )} - - ); + return otherModelProviderGroups.length > 0 + ? [ + usageGroup, + { + heading: "Other", + value: otherModelProviderGroups, + }, + ] + : [usageGroup]; } } -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} - - ); -} - export function DefaultModelAliasFormDialog({ disabled, }: { @@ -316,3 +289,26 @@ export function DefaultModelAliasFormDialog({ ); } + +export 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, + }; + } + ); +} From 74b0719a160aa9e4c57ce259fd3b985f22bb62b3 Mon Sep 17 00:00:00 2001 From: Ivy Date: Fri, 20 Dec 2024 15:56:07 -0500 Subject: [PATCH 2/2] move sort & suggested label out of ComboBox --- ui/admin/app/components/composed/ComboBox.tsx | 57 +++++------------- .../model/DefaultModelAliasForm.tsx | 59 ++++++++++++++----- 2 files changed, 61 insertions(+), 55 deletions(-) diff --git a/ui/admin/app/components/composed/ComboBox.tsx b/ui/admin/app/components/composed/ComboBox.tsx index 5c2b34c1..06d819f6 100644 --- a/ui/admin/app/components/composed/ComboBox.tsx +++ b/ui/admin/app/components/composed/ComboBox.tsx @@ -35,7 +35,7 @@ type ComboBoxProps = { onChange: (option: T | null) => void; options: (T | GroupedOption)[]; placeholder?: string; - suggested?: string[]; + renderOption?: (option: T) => ReactNode; value?: T | null; }; @@ -43,7 +43,7 @@ export function ComboBox({ disabled, placeholder, value, - suggested, + renderOption, ...props }: { disabled?: boolean; @@ -58,7 +58,7 @@ export function ComboBox({ @@ -74,7 +74,7 @@ export function ComboBox({
@@ -95,12 +95,9 @@ export function ComboBox({ }} > - {value ? value.name : placeholder}{" "} - {value?.name && suggested?.includes(value.name) && ( - - (Suggested) - - )} + {renderOption && value + ? renderOption(value) + : (value?.name ?? placeholder)} ); @@ -113,7 +110,7 @@ function ComboBoxList({ onChange, options, setOpen, - suggested, + renderOption, value, placeholder = "Filter...", emptyLabel = "No results found.", @@ -160,22 +157,6 @@ function ComboBoxList({ [] as (T | GroupedOption)[] ); - const sortBySuggested = ( - a: T | GroupedOption, - b: T | GroupedOption - ) => { - // Handle nested groups - keep original order - if ("heading" in a || "heading" in b) return 0; - - const aIsSuggested = a.name && suggested?.includes(a.name); - const bIsSuggested = b.name && suggested?.includes(b.name); - - // If both or neither are suggested, maintain original order - if (aIsSuggested === bIsSuggested) return 0; - // Sort suggested items first - return aIsSuggested ? -1 : 1; - }; - const handleValueChange = (value: string) => { setFilteredOptions(filterOptions(options, value)); }; @@ -215,14 +196,11 @@ function ComboBoxList({ function renderGroupedOption(group: GroupedOption) { return ( - {group.value - .slice() // Create a copy to avoid mutating original array - .sort(sortBySuggested) - .map((option) => - "heading" in option - ? renderGroupedOption(option) - : renderUngroupedOption(option) - )} + {group.value.map((option) => + "heading" in option + ? renderGroupedOption(option) + : renderUngroupedOption(option) + )} ); } @@ -239,12 +217,9 @@ function ComboBoxList({ className="justify-between" > - {option.name || option.id}{" "} - {option?.name && suggested?.includes(option.name) && ( - - (Suggested) - - )} + {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 e5abfa0b..b17b52a5 100644 --- a/ui/admin/app/components/model/DefaultModelAliasForm.tsx +++ b/ui/admin/app/components/model/DefaultModelAliasForm.tsx @@ -180,9 +180,15 @@ export function DefaultModelAliasForm({ } options={getOptionsByUsageAndProvider( activeModelOptions, - usage + usage, + alias )} - suggested={getSuggested(alias)} + renderOption={(option) => + renderDisplayOption( + option, + alias + ) + } value={ field.value ? models?.find( @@ -219,21 +225,33 @@ export function DefaultModelAliasForm({ ); - function getSuggested(alias: ModelAlias) { - return alias && SUGGESTED_MODEL_SELECTIONS[alias] - ? [SUGGESTED_MODEL_SELECTIONS[alias]] - : []; + 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, - usage: ModelUsage + usage: ModelUsage, + aliasFor: ModelAlias ) { if (!modelOptions) return []; + const suggested = aliasFor && SUGGESTED_MODEL_SELECTIONS[aliasFor]; const usageGroupName = getModelUsageLabel(usage); - const usageModelProviderGroups = - getModelOptionsByModelProvider(modelOptions); + const usageModelProviderGroups = getModelOptionsByModelProvider( + modelOptions, + suggested ? [suggested] : [] + ); + const otherModelProviderGroups = getModelOptionsByModelProvider(otherModels); const usageGroup = { @@ -290,7 +308,10 @@ export function DefaultModelAliasFormDialog({ ); } -export function getModelOptionsByModelProvider(models: Model[]) { +export function getModelOptionsByModelProvider( + models: Model[], + suggestions?: string[] +) { const byModelProviderGroups = filterModelsByActive(models).reduce( (acc, model) => { acc[model.modelProvider] = acc[model.modelProvider] || []; @@ -302,12 +323,22 @@ export function getModelOptionsByModelProvider(models: Model[]) { return Object.entries(byModelProviderGroups).map( ([modelProvider, models]) => { - const sorted = models.sort((a, b) => - (a.name ?? "").localeCompare(b.name ?? "") - ); return { heading: modelProvider, - value: sorted, + 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 ?? ""); + }), }; } );