diff --git a/web-app/src/constants/localStorage.ts b/web-app/src/constants/localStorage.ts index cef01b0fd..b7e33b006 100644 --- a/web-app/src/constants/localStorage.ts +++ b/web-app/src/constants/localStorage.ts @@ -19,4 +19,5 @@ export const localStorageKey = { mcpGlobalPermissions: 'mcp-global-permissions', lastUsedModel: 'last-used-model', lastUsedAssistant: 'last-used-assistant', + favoriteModels: 'favorite-models', } diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index 7767599ea..9deac9711 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -17,6 +17,8 @@ import ProvidersAvatar from '@/containers/ProvidersAvatar' import { Fzf } from 'fzf' import { localStorageKey } from '@/constants/localStorage' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useFavoriteModel } from '@/hooks/useFavoriteModel' +import { predefinedProviders } from '@/consts/providers' type DropdownModelProviderProps = { model?: ThreadModel @@ -69,6 +71,7 @@ const DropdownModelProvider = ({ const { updateCurrentThreadModel } = useThreads() const navigate = useNavigate() const { t } = useTranslation() + const { favoriteModels } = useFavoriteModel() // Search state const [open, setOpen] = useState(false) @@ -151,8 +154,15 @@ const DropdownModelProvider = ({ provider.models.forEach((modelItem) => { // Skip models that require API key but don't have one (except llamacpp) - if (provider.provider !== 'llamacpp' && !provider.api_key?.length) { - return + if ( + provider && + predefinedProviders.some((e) => + e.provider.includes(provider.provider) + ) + ) { + if (provider.provider !== 'llamacpp' && !provider.api_key?.length) { + return + } } const capabilities = modelItem.capabilities || [] @@ -182,6 +192,13 @@ const DropdownModelProvider = ({ }) }, [searchableItems]) + // Get favorite models that are currently available + const favoriteItems = useMemo(() => { + return searchableItems.filter((item) => + favoriteModels.some((fav) => fav.id === item.model.id) + ) + }, [searchableItems, favoriteModels]) + // Filter models based on search value const filteredItems = useMemo(() => { if (!searchValue) return searchableItems @@ -202,7 +219,7 @@ const DropdownModelProvider = ({ }) }, [searchableItems, searchValue, fzfInstance]) - // Group filtered items by provider + // Group filtered items by provider, excluding favorites when not searching const groupedItems = useMemo(() => { const groups: Record = {} @@ -221,11 +238,18 @@ const DropdownModelProvider = ({ if (!groups[providerKey]) { groups[providerKey] = [] } + + // When not searching, exclude favorite models from regular provider sections + const isFavorite = favoriteModels.some((fav) => fav.id === item.model.id) + if (!searchValue && isFavorite) { + return // Skip adding this item to regular provider section + } + groups[providerKey].push(item) }) return groups - }, [filteredItems, providers, searchValue]) + }, [filteredItems, providers, searchValue, favoriteModels]) const handleSelect = useCallback( (searchableModel: SearchableModel) => { @@ -330,6 +354,64 @@ const DropdownModelProvider = ({ ) : (
+ {/* Favorites section - only show when not searching */} + {!searchValue && favoriteItems.length > 0 && ( +
+ {/* Favorites header */} +
+ + {t('common:favorites')} + +
+ + {/* Favorite models */} + {favoriteItems.map((searchableModel) => { + const isSelected = + selectedModel?.id === searchableModel.model.id && + selectedProvider === searchableModel.provider.provider + const capabilities = + searchableModel.model.capabilities || [] + + return ( +
handleSelect(searchableModel)} + className={cn( + 'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200', + 'hover:bg-main-view-fg/4', + isSelected && + 'bg-main-view-fg/8 hover:bg-main-view-fg/8' + )} + > +
+
+ +
+ + {searchableModel.model.id} + +
+ {capabilities.length > 0 && ( +
+ +
+ )} +
+
+ ) + })} +
+ )} + + {/* Divider between favorites and regular providers */} + {favoriteItems.length > 0 && ( +
+ )} + + {/* Regular provider sections */} {Object.entries(groupedItems).map(([providerKey, models]) => { const providerInfo = providers.find( (p) => p.provider === providerKey @@ -340,7 +422,7 @@ const DropdownModelProvider = ({ return (
{/* Provider header */}
@@ -384,11 +466,13 @@ const DropdownModelProvider = ({ return (
handleSelect(searchableModel)} className={cn( 'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200', - 'hover:bg-main-view-fg/10', - isSelected && 'bg-main-view-fg/15' + 'hover:bg-main-view-fg/4', + isSelected && + 'bg-main-view-fg/8 hover:bg-main-view-fg/8' )} >
diff --git a/web-app/src/containers/FavoriteModelAction.tsx b/web-app/src/containers/FavoriteModelAction.tsx new file mode 100644 index 000000000..9b54ff4fa --- /dev/null +++ b/web-app/src/containers/FavoriteModelAction.tsx @@ -0,0 +1,24 @@ +import { IconHeart, IconHeartFilled } from '@tabler/icons-react' +import { useFavoriteModel } from '@/hooks/useFavoriteModel' + +interface FavoriteModelActionProps { + model: Model +} + +export function FavoriteModelAction({ model }: FavoriteModelActionProps) { + const { isFavorite, toggleFavorite } = useFavoriteModel() + const isModelFavorite = isFavorite(model.id) + + return ( +
toggleFavorite(model)} + > + {isModelFavorite ? ( + + ) : ( + + )} +
+ ) +} diff --git a/web-app/src/containers/dialogs/DeleteModel.tsx b/web-app/src/containers/dialogs/DeleteModel.tsx index 3485dd284..c847cafc0 100644 --- a/web-app/src/containers/dialogs/DeleteModel.tsx +++ b/web-app/src/containers/dialogs/DeleteModel.tsx @@ -18,6 +18,7 @@ import { IconTrash } from '@tabler/icons-react' import { useState, useEffect } from 'react' import { toast } from 'sonner' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useFavoriteModel } from '@/hooks/useFavoriteModel' type DialogDeleteModelProps = { provider: ModelProvider @@ -31,8 +32,12 @@ export const DialogDeleteModel = ({ const { t } = useTranslation() const [selectedModelId, setSelectedModelId] = useState('') const { setProviders, deleteModel: deleteModelCache } = useModelProvider() + const { removeFavorite } = useFavoriteModel() const removeModel = async () => { + // Remove model from favorites if it exists + removeFavorite(selectedModelId) + deleteModelCache(selectedModelId) deleteModel(selectedModelId).then(() => { getProviders().then((providers) => { diff --git a/web-app/src/containers/dialogs/DeleteProvider.tsx b/web-app/src/containers/dialogs/DeleteProvider.tsx index fede8e97d..1c3a7c651 100644 --- a/web-app/src/containers/dialogs/DeleteProvider.tsx +++ b/web-app/src/containers/dialogs/DeleteProvider.tsx @@ -18,6 +18,7 @@ import { useRouter } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useTranslation } from '@/i18n/react-i18next-compat' import { predefinedProviders } from '@/consts/providers' +import { useFavoriteModel } from '@/hooks/useFavoriteModel' type Props = { provider?: ProviderObject @@ -25,6 +26,7 @@ type Props = { const DeleteProvider = ({ provider }: Props) => { const { t } = useTranslation() const { deleteProvider, providers } = useModelProvider() + const { favoriteModels, removeFavorite } = useFavoriteModel() const router = useRouter() if ( !provider || @@ -34,6 +36,14 @@ const DeleteProvider = ({ provider }: Props) => { return null const removeProvider = async () => { + // Remove favorite models that belong to this provider + const providerModelIds = provider.models.map((model) => model.id) + favoriteModels.forEach((favoriteModel) => { + if (providerModelIds.includes(favoriteModel.id)) { + removeFavorite(favoriteModel.id) + } + }) + deleteProvider(provider.provider) toast.success(t('providers:deleteProvider.title'), { id: `delete-provider-${provider.provider}`, diff --git a/web-app/src/hooks/useFavoriteModel.ts b/web-app/src/hooks/useFavoriteModel.ts new file mode 100644 index 000000000..979c1400e --- /dev/null +++ b/web-app/src/hooks/useFavoriteModel.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' +import { persist, createJSONStorage } from 'zustand/middleware' +import { localStorageKey } from '@/constants/localStorage' + +interface FavoriteModelState { + favoriteModels: Model[] + addFavorite: (model: Model) => void + removeFavorite: (modelId: string) => void + isFavorite: (modelId: string) => boolean + toggleFavorite: (model: Model) => void +} + +export const useFavoriteModel = create()( + persist( + (set, get) => ({ + favoriteModels: [], + + addFavorite: (model: Model) => { + set((state) => { + if (!state.favoriteModels.some((fav) => fav.id === model.id)) { + return { + favoriteModels: [...state.favoriteModels, model], + } + } + return state + }) + }, + + removeFavorite: (modelId: string) => { + set((state) => ({ + favoriteModels: state.favoriteModels.filter((model) => model.id !== modelId), + })) + }, + + isFavorite: (modelId: string) => { + return get().favoriteModels.some((model) => model.id === modelId) + }, + + toggleFavorite: (model: Model) => { + const { isFavorite, addFavorite, removeFavorite } = get() + if (isFavorite(model.id)) { + removeFavorite(model.id) + } else { + addFavorite(model) + } + }, + }), + { + name: localStorageKey.favoriteModels, + storage: createJSONStorage(() => localStorage), + } + ) +) \ No newline at end of file diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 326500273..c44654a9d 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -27,6 +27,7 @@ import { DialogEditModel } from '@/containers/dialogs/EditModel' import { DialogAddModel } from '@/containers/dialogs/AddModel' import { ModelSetting } from '@/containers/ModelSetting' import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel' +import { FavoriteModelAction } from '@/containers/FavoriteModelAction' import Joyride, { CallBackProps, STATUS } from 'react-joyride' import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide' import { route } from '@/constants/routes' @@ -539,7 +540,7 @@ function ProviderDetail() {
} actions={ -
+
)} + {((provider && + !predefinedProviders.some( + (p) => p.provider === provider.provider + )) || + (provider && + predefinedProviders.some( + (p) => p.provider === provider.provider + ) && + Boolean(provider.api_key?.length))) && ( + + )}