diff --git a/ui/desktop/src/components/BottomMenu.tsx b/ui/desktop/src/components/BottomMenu.tsx index a9e3134b..03336c53 100644 --- a/ui/desktop/src/components/BottomMenu.tsx +++ b/ui/desktop/src/components/BottomMenu.tsx @@ -6,7 +6,7 @@ import { ModelRadioList } from './settings/models/ModelRadioList'; import { Document, ChevronUp, ChevronDown } from './icons'; import type { View } from '../App'; import { BottomMenuModeSelection } from './BottomMenuModeSelection'; -import ModelsBottomBar from './settings_v2/models/subcomponents/ModelsBottomBar'; +import ModelsBottomBar from './settings_v2/models/bottom_bar/ModelsBottomBar'; export default function BottomMenu({ hasMessages, diff --git a/ui/desktop/src/components/settings_v2/models/subcomponents/ModelsBottomBar.tsx b/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx similarity index 97% rename from ui/desktop/src/components/settings_v2/models/subcomponents/ModelsBottomBar.tsx rename to ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx index 59aa7b3a..3391ff7d 100644 --- a/ui/desktop/src/components/settings_v2/models/subcomponents/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx @@ -3,7 +3,7 @@ import { Sliders } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useConfig } from '../../../ConfigContext'; import { getCurrentModelAndProviderForDisplay } from '../index'; -import { AddModelModal } from './AddModelModal'; +import { AddModelModal } from '../subcomponents/AddModelModal'; import type { View } from '../../../../App'; interface ModelsBottomBarProps { diff --git a/ui/desktop/src/components/settings_v2/models/index.ts b/ui/desktop/src/components/settings_v2/models/index.ts index 95a7ac39..18630d10 100644 --- a/ui/desktop/src/components/settings_v2/models/index.ts +++ b/ui/desktop/src/components/settings_v2/models/index.ts @@ -1,6 +1,8 @@ import { initializeAgent } from '../../../agent/index'; import { toastError, toastSuccess } from '../../../toasts'; import { ProviderDetails } from '@/src/api'; +import { getProviderMetadata } from './modelInterface'; +import { ProviderMetadata } from '../../../api'; // titles const CHANGE_MODEL_TOAST_TITLE = 'Model selected'; @@ -137,19 +139,20 @@ export async function getCurrentModelAndProviderForDisplay({ const gooseModel = modelProvider.model; const gooseProvider = modelProvider.provider; - const providers = await getProviders(false); - // lookup display name - const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider); + let metadata: ProviderMetadata; - if (providerDetailsList.length != 1) { + try { + metadata = await getProviderMetadata(gooseProvider, getProviders); + } catch (error) { toastError({ title: UNKNOWN_PROVIDER_TITLE, msg: UNKNOWN_PROVIDER_MSG, + traceback: error, }); return { model: gooseModel, provider: gooseProvider }; } - const providerDisplayName = providerDetailsList[0].metadata.display_name; + const providerDisplayName = metadata.display_name; return { model: gooseModel, provider: providerDisplayName }; } diff --git a/ui/desktop/src/components/settings_v2/models/modelInterface.ts b/ui/desktop/src/components/settings_v2/models/modelInterface.ts new file mode 100644 index 00000000..d82017f0 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/models/modelInterface.ts @@ -0,0 +1,41 @@ +import { ProviderDetails } from '../../../api'; + +export default interface Model { + id?: number; // Make `id` optional to allow user-defined models + name: string; + provider: string; + lastUsed?: string; + alias?: string; // optional model display name + subtext?: string; // goes below model name if not the provider +} + +export function createModelStruct( + modelName: string, + provider: string, + id?: number, // Make `id` optional to allow user-defined models + lastUsed?: string, + alias?: string, // optional model display name + subtext?: string +): Model { + // use the metadata to create a Model + return { + name: modelName, + provider: provider, + alias: alias, + id: id, + lastUsed: lastUsed, + subtext: subtext, + }; +} + +export async function getProviderMetadata( + providerName: string, + getProvidersFunc: (b: boolean) => Promise +) { + const providers = await getProvidersFunc(false); + const matches = providers.find((providerMatch) => providerMatch.name === providerName); + if (!matches) { + throw Error(`No match for provider: ${providerName}`); + } + return matches[0].metadata; +} diff --git a/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx b/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx new file mode 100644 index 00000000..075df280 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState } from 'react'; +import Model from '../modelInterface'; +import { useRecentModels } from './recentModels'; +import { changeModel, getCurrentModelAndProvider } from '../index'; +import { useConfig } from '../../../ConfigContext'; + +interface ModelRadioListProps { + renderItem: (props: { + model: Model; + isSelected: boolean; + onSelect: () => void; + }) => React.ReactNode; + className?: string; + providedModelList?: Model[]; +} + +export function BaseModelsList({ + renderItem, + className = '', + providedModelList, +}: ModelRadioListProps) { + const { recentModels } = useRecentModels(); + + // allow for a custom model list to be passed if you don't want to use recent models + let modelList: Model[]; + if (!providedModelList) { + modelList = recentModels; + } else { + modelList = providedModelList; + } + const { read, upsert } = useConfig(); + const [selectedModel, setSelectedModel] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + + // Load current model/provider once on component mount + useEffect(() => { + let isMounted = true; + + const initializeCurrentModel = async () => { + try { + const result = await getCurrentModelAndProvider({ readFromConfig: read }); + if (isMounted) { + setSelectedModel(result.model); + setSelectedProvider(result.provider); + setIsInitialized(true); + } + } catch (error) { + console.error('Failed to load current model:', error); + if (isMounted) { + setIsInitialized(true); // Still mark as initialized even on error + } + } + }; + + initializeCurrentModel(); + + return () => { + isMounted = false; + }; + }, [read]); + + const handleModelSelection = async (modelName: string, providerName: string) => { + await changeModel({ model: modelName, provider: providerName, writeToConfig: upsert }); + }; + + // Updated to work with CustomRadio + const handleRadioChange = async (model: Model) => { + if (selectedModel === model.name) { + console.log(`Model "${model.name}" is already active.`); + return; + } + + // Update local state immediately for UI feedback + setSelectedModel(model.name); + setSelectedProvider(model.provider); + + try { + await handleModelSelection(model.name, model.provider); + } catch (error) { + console.error('Error selecting model:', error); + } + }; + + // Don't render until we've loaded the initial model/provider + if (!isInitialized) { + return
Loading models...
; + } + + return ( +
+ {modelList.map((model) => + renderItem({ + model, + isSelected: selectedModel === model.name, + onSelect: () => handleRadioChange(model), + }) + )} +
+ ); +} diff --git a/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts b/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts new file mode 100644 index 00000000..07100b78 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from 'react'; +import Model from '../modelInterface'; + +const MAX_RECENT_MODELS = 3; + +export function useRecentModels() { + const [recentModels, setRecentModels] = useState([]); + + useEffect(() => { + const storedModels = localStorage.getItem('recentModels'); + if (storedModels) { + setRecentModels(JSON.parse(storedModels)); + } + }, []); + + const addRecentModel = (model: Model) => { + const modelWithTimestamp = { ...model, lastUsed: new Date().toISOString() }; // Add lastUsed field + setRecentModels((prevModels) => { + const updatedModels = [ + modelWithTimestamp, + ...prevModels.filter((m) => m.name !== model.name), + ].slice(0, MAX_RECENT_MODELS); + + localStorage.setItem('recentModels', JSON.stringify(updatedModels)); + return updatedModels; + }); + }; + + return { recentModels, addRecentModel }; +}