feat: Enhance Favorites functionality with validation, cleanup, and improved error handling

This commit is contained in:
Marco Beretta 2025-12-02 00:15:07 +01:00
parent c006630a45
commit f6881d91f1
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 128 additions and 36 deletions

View file

@ -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',

View file

@ -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
</div>
<button
onClick={handleFavoriteClick}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
className={cn(
'rounded-md p-1 hover:bg-surface-hover',
isFavorite ? 'visible' : 'invisible group-hover:visible',

View file

@ -1,19 +1,21 @@
import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash/throttle';
import { ChevronRight } from 'lucide-react';
import { TConversation } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import { Spinner, useMediaQuery } from '@librechat/client';
import { TConversation, PermissionTypes, Permissions } from 'librechat-data-provider';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { useLocalize, TranslationKeys, useFavorites, useHasAccess } from '~/hooks';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import { useLocalize, TranslationKeys, useFavorites } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils';
import Convo from './Convo';
import store from '~/store';
interface ConversationsProps {
conversations: Array<TConversation | null>;
moveToTop: () => void;
toggleNav: () => void;
containerRef: React.RefObject<HTMLDivElement | List>;
containerRef: React.RefObject<List>;
loadMoreConversations: () => void;
isLoading: boolean;
isSearchLoading: boolean;
@ -88,10 +90,27 @@ const Conversations: FC<ConversationsProps> = ({
setIsChatsExpanded,
}) => {
const localize = useLocalize();
const search = useRecoilValue(store.search);
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const hasAccessToMarketplace = useHasAccess({
permissionType: PermissionTypes.MARKETPLACE,
permission: Permissions.USE,
});
const showAgentMarketplace = hasAccessToAgents && hasAccessToMarketplace;
// Determine if FavoritesList will render content
const shouldShowFavorites =
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
const filteredConversations = useMemo(
() => rawConversations.filter(Boolean) as TConversation[],
[rawConversations],
@ -104,7 +123,10 @@ const Conversations: FC<ConversationsProps> = ({
const flattenedItems = useMemo(() => {
const items: FlattenedItem[] = [];
// Only include favorites row if FavoritesList will render content
if (shouldShowFavorites) {
items.push({ type: 'favorites' });
}
items.push({ type: 'chats-header' });
if (isChatsExpanded) {
@ -118,7 +140,7 @@ const Conversations: FC<ConversationsProps> = ({
}
}
return items;
}, [groupedConversations, isLoading, isChatsExpanded]);
}, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]);
// Store flattenedItems in a ref for keyMapper to access without recreating cache
const flattenedItemsRef = useRef(flattenedItems);
@ -214,7 +236,11 @@ const Conversations: FC<ConversationsProps> = ({
</button>
);
} else if (item.type === 'header') {
rendering = <DateLabel groupName={item.groupName} isFirst={index === 2} />;
// 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 = <DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />;
} else if (item.type === 'convo') {
rendering = (
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
@ -230,7 +256,18 @@ const Conversations: FC<ConversationsProps> = ({
</CellMeasurer>
);
},
[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<ConversationsProps> = ({
<AutoSizer>
{({ width, height }) => (
<List
ref={containerRef as React.RefObject<List>}
ref={containerRef}
width={width}
height={height}
deferredMeasurementCache={cache}

View file

@ -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<HTMLDivElement>(null);
const conversationsRef = useRef<HTMLDivElement>(null);
const conversationsRef = useRef<List | null>(null);
const { moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,

View file

@ -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;
});
const saveFavorites = useCallback(
async (newFavorites: typeof favorites) => {
const cleaned = cleanFavorites(newFavorites);
setFavorites(cleaned);
updateFavoritesMutation.mutate(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 };
}
if (f.model && f.endpoint) {
return { model: f.model, endpoint: f.endpoint };
}
return f;
});
const reorderFavorites = useCallback(
async (newFavorites: typeof favorites, persist = false) => {
const cleaned = cleanFavorites(newFavorites);
setFavorites(cleaned);
if (persist) {
updateFavoritesMutation.mutate(cleaned);
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();
}
};
}
},
[setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery],
);
return {
favorites,