import { useEffect, useCallback, useRef } from 'react'; import { useAtom } from 'jotai'; import { useToastContext } from '@librechat/client'; import type { Favorite } from '~/store/favorites'; import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider'; import { favoritesAtom } from '~/store'; import { useLocalize } from '~/hooks'; import { logger } from '~/utils'; /** * Hook for managing user favorites (pinned agents and models). * * Favorites are synchronized with the server via `/api/user/settings/favorites`. * Each favorite is either: * - An agent: `{ agentId: string }` * - A model: `{ model: string, endpoint: string }` * * @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[] => { if (!Array.isArray(favorites)) { return []; } return 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] = useAtom(favoritesAtom); const getFavoritesQuery = useGetFavoritesQuery(); const updateFavoritesMutation = useUpdateFavoritesMutation(); const isMutatingRef = useRef(false); useEffect(() => { // Skip updating local state if a mutation is in progress or just completed // The local state is already optimistically updated by saveFavorites if (isMutatingRef.current || updateFavoritesMutation.isLoading) { return; } if (getFavoritesQuery.data) { if (Array.isArray(getFavoritesQuery.data)) { setFavorites(getFavoritesQuery.data); } else { setFavorites([]); } } }, [getFavoritesQuery.data, setFavorites, updateFavoritesMutation.isLoading]); const saveFavorites = useCallback( async (newFavorites: typeof favorites) => { const cleaned = cleanFavorites(newFavorites); setFavorites(cleaned); isMutatingRef.current = true; 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(); } finally { // Use a small delay to prevent the useEffect from triggering immediately // after the mutation completes but before React has finished processing setTimeout(() => { isMutatingRef.current = false; }, 100); } }, [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery], ); const addFavoriteAgent = (agentId: string) => { if (favorites.some((f) => f.agentId === agentId)) return; const newFavorites = [...favorites, { agentId }]; saveFavorites(newFavorites); }; const removeFavoriteAgent = (agentId: string) => { const newFavorites = favorites.filter((f) => f.agentId !== agentId); saveFavorites(newFavorites); }; const addFavoriteModel = (model: { model: string; endpoint: string }) => { if (favorites.some((f) => f.model === model.model && f.endpoint === model.endpoint)) return; const newFavorites = [...favorites, { model: model.model, endpoint: model.endpoint }]; saveFavorites(newFavorites); }; const removeFavoriteModel = (model: string, endpoint: string) => { const newFavorites = favorites.filter((f) => !(f.model === model && f.endpoint === endpoint)); saveFavorites(newFavorites); }; const isFavoriteAgent = (agentId: string | undefined | null) => { if (!agentId) { return false; } return favorites.some((f) => f.agentId === agentId); }; const isFavoriteModel = (model: string, endpoint: string) => { return favorites.some((f) => f.model === model && f.endpoint === endpoint); }; const toggleFavoriteAgent = (agentId: string) => { if (isFavoriteAgent(agentId)) { removeFavoriteAgent(agentId); } else { addFavoriteAgent(agentId); } }; const toggleFavoriteModel = (model: { model: string; endpoint: string }) => { if (isFavoriteModel(model.model, model.endpoint)) { removeFavoriteModel(model.model, model.endpoint); } else { addFavoriteModel(model); } }; /** * 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 = useCallback( async (newFavorites: typeof favorites, persist = false) => { const cleaned = cleanFavorites(newFavorites); setFavorites(cleaned); if (persist) { isMutatingRef.current = true; 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(); } finally { setTimeout(() => { isMutatingRef.current = false; }, 100); } } }, [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery], ); return { favorites, addFavoriteAgent, removeFavoriteAgent, addFavoriteModel, removeFavoriteModel, isFavoriteAgent, isFavoriteModel, toggleFavoriteAgent, toggleFavoriteModel, reorderFavorites, /** Whether the favorites query is currently loading */ isLoading: getFavoritesQuery.isLoading, /** Whether there was an error fetching favorites */ isError: getFavoritesQuery.isError, /** Whether the update mutation is in progress */ isUpdating: updateFavoritesMutation.isLoading, /** Error from fetching favorites, if any */ fetchError: getFavoritesQuery.error, /** Error from updating favorites, if any */ updateError: updateFavoritesMutation.error, }; }