diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js index ea268fe1fb..e3a9f77ae3 100644 --- a/api/server/controllers/FavoritesController.js +++ b/api/server/controllers/FavoritesController.js @@ -1,5 +1,8 @@ const { updateUser, getUserById } = require('~/models'); +const MAX_FAVORITES = 50; +const MAX_STRING_LENGTH = 256; + const updateFavoritesController = async (req, res) => { try { const { favorites } = req.body; @@ -13,10 +16,30 @@ const updateFavoritesController = async (req, res) => { return res.status(400).json({ message: 'Favorites must be an array' }); } + if (favorites.length > MAX_FAVORITES) { + return res.status(400).json({ message: `Maximum ${MAX_FAVORITES} favorites allowed` }); + } + for (const fav of favorites) { const hasAgent = !!fav.agentId; const hasModel = !!(fav.model && fav.endpoint); + if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) { + return res + .status(400) + .json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` }); + } + if (fav.model && fav.model.length > MAX_STRING_LENGTH) { + return res + .status(400) + .json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` }); + } + if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) { + return res + .status(400) + .json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` }); + } + if (!hasAgent && !hasModel) { return res.status(400).json({ message: 'Each favorite must have either agentId or model+endpoint', diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index cdd88d6799..5ba4e4985a 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -3,8 +3,8 @@ import { EarthIcon, Pin, PinOff } from 'lucide-react'; import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; +import { useFavorites, useLocalize } from '~/hooks'; import type { Endpoint } from '~/common'; -import { useFavorites } from '~/hooks'; import { cn } from '~/utils'; interface EndpointModelItemProps { @@ -14,6 +14,7 @@ interface EndpointModelItemProps { } export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) { + const localize = useLocalize(); const { handleSelectModel } = useModelSelectorContext(); const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); @@ -94,6 +95,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod ); } else if (item.type === 'header') { - rendering = ; + // First date header index depends on whether favorites row is included + // With favorites: [favorites, chats-header, first-header] → index 2 + // Without favorites: [chats-header, first-header] → index 1 + const firstHeaderIndex = shouldShowFavorites ? 2 : 1; + rendering = ; } else if (item.type === 'convo') { rendering = ( @@ -230,7 +256,18 @@ const Conversations: FC = ({ ); }, - [cache, flattenedItems, moveToTop, toggleNav, clearFavoritesCache, isSmallScreen], + [ + cache, + flattenedItems, + moveToTop, + toggleNav, + clearFavoritesCache, + isSmallScreen, + isChatsExpanded, + localize, + setIsChatsExpanded, + shouldShowFavorites, + ], ); const getRowHeight = useCallback( @@ -264,7 +301,7 @@ const Conversations: FC = ({ {({ width, height }) => ( } + ref={containerRef} width={width} height={height} deferredMeasurementCache={cache} diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index e2423bf83d..60486f96c8 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react'; import { useRecoilValue } from 'recoil'; +import { List } from 'react-virtualized'; import { AnimatePresence, motion } from 'framer-motion'; import { Skeleton, useMediaQuery } from '@librechat/client'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; @@ -100,7 +101,7 @@ const Nav = memo( }, [data?.pages]); const outerContainerRef = useRef(null); - const conversationsRef = useRef(null); + const conversationsRef = useRef(null); const { moveToTop } = useNavScrolling({ setShowLoading, diff --git a/client/src/hooks/useFavorites.ts b/client/src/hooks/useFavorites.ts index d3d1b560e8..3decc8ef33 100644 --- a/client/src/hooks/useFavorites.ts +++ b/client/src/hooks/useFavorites.ts @@ -1,7 +1,11 @@ -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { useRecoilState } from 'recoil'; -import store from '~/store'; +import { useToastContext } from '@librechat/client'; +import type { Favorite } from '~/store/favorites'; import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider'; +import { useLocalize } from '~/hooks'; +import { logger } from '~/utils'; +import store from '~/store'; /** * Hook for managing user favorites (pinned agents and models). @@ -14,7 +18,24 @@ import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provide * @returns Object containing favorites state and helper methods for * adding, removing, toggling, reordering, and checking favorites. */ + +/** + * Cleans favorites array to only include canonical shapes (agentId or model+endpoint). + */ +const cleanFavorites = (favorites: Favorite[]): Favorite[] => + favorites.map((f) => { + if (f.agentId) { + return { agentId: f.agentId }; + } + if (f.model && f.endpoint) { + return { model: f.model, endpoint: f.endpoint }; + } + return f; + }); + export default function useFavorites() { + const localize = useLocalize(); + const { showToast } = useToastContext(); const [favorites, setFavorites] = useRecoilState(store.favorites); const getFavoritesQuery = useGetFavoritesQuery(); const updateFavoritesMutation = useUpdateFavoritesMutation(); @@ -29,15 +50,21 @@ export default function useFavorites() { } }, [getFavoritesQuery.data, setFavorites]); - const saveFavorites = (newFavorites: typeof favorites) => { - const cleaned = newFavorites.map((f) => { - if (f.agentId) return { agentId: f.agentId }; - if (f.model && f.endpoint) return { model: f.model, endpoint: f.endpoint }; - return f; - }); - setFavorites(cleaned); - updateFavoritesMutation.mutate(cleaned); - }; + const saveFavorites = useCallback( + async (newFavorites: typeof favorites) => { + const cleaned = cleanFavorites(newFavorites); + setFavorites(cleaned); + try { + await updateFavoritesMutation.mutateAsync(cleaned); + } catch (error) { + logger.error('Error updating favorites:', error); + showToast({ message: localize('com_ui_error'), status: 'error' }); + // Refetch to resync state with server + getFavoritesQuery.refetch(); + } + }, + [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery], + ); const addFavoriteAgent = (agentId: string) => { if (favorites.some((f) => f.agentId === agentId)) return; @@ -89,25 +116,27 @@ export default function useFavorites() { }; /** - * Reorder favorites and persist the new order to the server. + * Reorder favorites and optionally persist the new order to the server. * This combines state update and persistence to avoid race conditions * where the closure captures stale state. */ - const reorderFavorites = (newFavorites: typeof favorites, persist = false) => { - const cleaned = newFavorites.map((f) => { - if (f.agentId) { - return { agentId: f.agentId }; + const reorderFavorites = useCallback( + async (newFavorites: typeof favorites, persist = false) => { + const cleaned = cleanFavorites(newFavorites); + setFavorites(cleaned); + if (persist) { + try { + await updateFavoritesMutation.mutateAsync(cleaned); + } catch (error) { + console.error('Error reordering favorites:', error); + showToast({ message: localize('com_ui_error'), status: 'error' }); + // Refetch to resync state with server + getFavoritesQuery.refetch(); + } } - if (f.model && f.endpoint) { - return { model: f.model, endpoint: f.endpoint }; - } - return f; - }); - setFavorites(cleaned); - if (persist) { - updateFavoritesMutation.mutate(cleaned); - } - }; + }, + [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery], + ); return { favorites,