diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js index a3ef3a8a8f..4da84ff914 100644 --- a/api/server/controllers/FavoritesController.js +++ b/api/server/controllers/FavoritesController.js @@ -35,11 +35,16 @@ const getFavoritesController = async (req, res) => { return res.status(404).json({ message: 'User not found' }); } - const favorites = user.favorites || {}; - res.status(200).json({ - agents: favorites.agents || [], - models: favorites.models || [], - }); + let favorites = user.favorites || []; + + // Ensure favorites is an array (migration/dev fix) + if (!Array.isArray(favorites)) { + favorites = []; + // Optionally update the DB to fix it permanently + await User.findByIdAndUpdate(userId, { $set: { favorites: [] } }); + } + + res.status(200).json(favorites); } catch (error) { console.error('Error fetching favorites:', error); res.status(500).json({ message: 'Internal server error' }); diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx index aefc00f42f..5d9f13eeb6 100644 --- a/client/src/components/Agents/AgentCard.tsx +++ b/client/src/components/Agents/AgentCard.tsx @@ -1,8 +1,7 @@ import React, { useMemo } from 'react'; -import { Star } from 'lucide-react'; import { Label } from '@librechat/client'; import type t from 'librechat-data-provider'; -import { useLocalize, TranslationKeys, useAgentCategories, useFavorites } from '~/hooks'; +import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks'; import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils'; interface AgentCardProps { @@ -17,13 +16,6 @@ interface AgentCardProps { const AgentCard: React.FC = ({ agent, onClick, className = '' }) => { const localize = useLocalize(); const { categories } = useAgentCategories(); - const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); - const isFavorite = isFavoriteAgent(agent.id); - - const handleFavoriteClick = (e: React.MouseEvent) => { - e.stopPropagation(); - toggleFavoriteAgent(agent.id); - }; const categoryLabel = useMemo(() => { if (!agent.category) return ''; @@ -57,16 +49,6 @@ const AgentCard: React.FC = ({ agent, onClick, className = '' }) tabIndex={0} role="button" > -
- -
{/* Left column: Avatar and Category */} @@ -93,7 +75,9 @@ const AgentCard: React.FC = ({ agent, onClick, className = '' })

{agent.description ?? ''}

diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index ef77734e30..4bd84df180 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -1,5 +1,5 @@ import React, { useRef } from 'react'; -import { Link } from 'lucide-react'; +import { Link, Pin, PinOff } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client'; import { @@ -11,8 +11,8 @@ import { AgentListResponse, } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; +import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks'; import { renderAgentAvatar, clearMessagesCache } from '~/utils'; -import { useLocalize, useDefaultConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; interface SupportContact { @@ -39,6 +39,14 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => const dialogRef = useRef(null); const getDefaultConversation = useDefaultConvo(); const { conversation, newConversation } = useChatContext(); + const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); + const isFavorite = isFavoriteAgent(agent?.id); + + const handleFavoriteClick = () => { + if (agent) { + toggleFavoriteAgent(agent.id); + } + }; /** * Navigate to chat with the selected agent @@ -133,18 +141,6 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => return ( !open && onClose()}> - {/* Copy link button - positioned next to close button */} - - {/* Agent avatar - top center */}
{renderAgentAvatar(agent, { size: 'xl' })}
@@ -168,7 +164,25 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) =>
{/* Action button */} -
+
+ + diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 344b4a7b53..8b588e121c 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { EarthIcon, Star } from 'lucide-react'; +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 type { Endpoint } from '~/common'; import { useFavorites } from '~/hooks'; import { cn } from '~/utils'; -import { useModelSelectorContext } from '../ModelSelectorContext'; -import { CustomMenuItem as MenuItem } from '../CustomMenu'; interface EndpointModelItemProps { modelId: string | null; @@ -15,7 +15,8 @@ interface EndpointModelItemProps { export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) { const { handleSelectModel } = useModelSelectorContext(); - const { isFavoriteModel, toggleFavoriteModel } = useFavorites(); + const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = + useFavorites(); let isGlobal = false; let modelName = modelId; const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null; @@ -35,15 +36,45 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) modelName = endpoint.assistantNames[modelId]; } - const isFavorite = isFavoriteModel(modelId ?? '', endpoint.value); + const isAgent = isAgentsEndpoint(endpoint.value); + const isFavorite = isAgent + ? isFavoriteAgent(modelId ?? '') + : isFavoriteModel(modelId ?? '', endpoint.value); const handleFavoriteClick = (e: React.MouseEvent) => { e.stopPropagation(); if (modelId) { - toggleFavoriteModel({ model: modelId, endpoint: endpoint.value, label: modelName ?? undefined }); + if (isAgent) { + toggleFavoriteAgent(modelId); + } else { + toggleFavoriteModel({ model: modelId, endpoint: endpoint.value }); + } } }; + const renderAvatar = () => { + if (avatarUrl) { + return ( +
+ {modelName +
+ ); + } + if ( + (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && + endpoint.icon + ) { + return ( +
+ {endpoint.icon} +
+ ); + } + return ( +
+ ); + }; + return (
- {avatarUrl ? ( -
- {modelName -
- ) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && - endpoint.icon ? ( -
- {endpoint.icon} -
- ) : ( -
- )} + {renderAvatar()} {modelName} {isGlobal && }
); diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index d4808089c4..f264785c9a 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -132,8 +132,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co return (
void; isSmallScreen: boolean; @@ -12,6 +13,7 @@ interface ConvoLinkProps { const ConvoLink: React.FC = ({ isActiveConvo, + isPopoverActive, title, onRename, isSmallScreen, @@ -22,7 +24,7 @@ const ConvoLink: React.FC = ({
= ({
{ if (type === 'agent') { - return renderAgentAvatar(item as t.Agent, { size: 20, className: 'mr-2' }); + return renderAgentAvatar(item as t.Agent, { size: 'icon', className: 'mr-2' }); } const model = item as FavoriteModel; return ( @@ -87,22 +87,25 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) { if (type === 'agent') { return (item as t.Agent).name; } - return (item as FavoriteModel).label || (item as FavoriteModel).model; + return (item as FavoriteModel).model; }; const menuId = React.useId(); const dropdownItems = [ { - label: localize('com_ui_remove'), + label: localize('com_ui_unpin'), onClick: handleRemove, - icon: , + icon: , }, ]; return (
@@ -110,7 +113,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) { {renderIcon()} {getName()}
- +
- + } items={dropdownItems} diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index a868f1f961..2b31bf5cae 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -1,51 +1,195 @@ -import React from 'react'; -import { useQueries } from '@tanstack/react-query'; +import React, { useRef, useCallback, useMemo } from 'react'; import { ChevronRight } from 'lucide-react'; +import { useDrag, useDrop } from 'react-dnd'; import * as Collapsible from '@radix-ui/react-collapsible'; import { QueryKeys, dataService } from 'librechat-data-provider'; -import { useFavorites, useLocalStorage } from '~/hooks'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import type t from 'librechat-data-provider'; +import { useFavorites, useLocalStorage, useLocalize } from '~/hooks'; import FavoriteItem from './FavoriteItem'; +interface DraggableFavoriteItemProps { + id: string; + index: number; + moveItem: (dragIndex: number, hoverIndex: number) => void; + onDrop: () => void; + children: React.ReactNode; +} + +const DraggableFavoriteItem = ({ + id, + index, + moveItem, + onDrop, + children, +}: DraggableFavoriteItemProps) => { + const ref = useRef(null); + const [{ handlerId }, drop] = useDrop({ + accept: 'favorite-item', + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: { index: number; id: string }, monitor) { + if (!ref.current) { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + if (dragIndex === hoverIndex) { + return; + } + + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = (clientOffset as any).y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + moveItem(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + + const [{ isDragging }, drag] = useDrag({ + type: 'favorite-item', + item: () => { + return { id, index }; + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + end: () => { + onDrop(); + }, + }); + + const opacity = isDragging ? 0 : 1; + drag(drop(ref)); + + return ( +
+ {children} +
+ ); +}; + export default function FavoritesList() { - const { favorites } = useFavorites(); + const localize = useLocalize(); + const queryClient = useQueryClient(); + const { favorites, reorderFavorites, persistFavorites } = useFavorites(); const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true); + const agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[]; + const agentQueries = useQueries({ - queries: (favorites.agents || []).map((agentId) => ({ + queries: agentIds.map((agentId) => ({ queryKey: [QueryKeys.agent, agentId], queryFn: () => dataService.getAgentById({ agent_id: agentId }), staleTime: 1000 * 60 * 5, })), }); - const favoriteAgents = agentQueries - .map((query) => query.data) - .filter((agent) => agent !== undefined); + const agentsMap = useMemo(() => { + const map: Record = {}; - const favoriteModels = favorites.models || []; + const addToMap = (agent: t.Agent) => { + if (agent && agent.id && !map[agent.id]) { + map[agent.id] = agent; + } + }; - if ((favorites.agents || []).length === 0 && (favorites.models || []).length === 0) { + const marketplaceData = queryClient.getQueriesData>([ + QueryKeys.marketplaceAgents, + ]); + marketplaceData.forEach(([_, data]) => { + data?.pages.forEach((page) => { + page.data.forEach(addToMap); + }); + }); + + const agentsListData = queryClient.getQueriesData([QueryKeys.agents]); + agentsListData.forEach(([_, data]) => { + if (data && Array.isArray(data.data)) { + data.data.forEach(addToMap); + } + }); + + agentQueries.forEach((query) => { + if (query.data) { + map[query.data.id] = query.data; + } + }); + + return map; + }, [agentQueries, queryClient]); + + const moveItem = useCallback( + (dragIndex: number, hoverIndex: number) => { + const newFavorites = [...favorites]; + const [draggedItem] = newFavorites.splice(dragIndex, 1); + newFavorites.splice(hoverIndex, 0, draggedItem); + reorderFavorites(newFavorites); + }, + [favorites, reorderFavorites], + ); + + const handleDrop = useCallback(() => { + persistFavorites(favorites); + }, [favorites, persistFavorites]); + + if (favorites.length === 0) { return null; } return ( - + - Favorites + {localize('com_ui_pinned')}
- {favoriteAgents.map((agent) => ( - - ))} - {favoriteModels.map((model) => ( - - ))} + {favorites.map((fav, index) => { + if (fav.agentId) { + const agent = agentsMap[fav.agentId]; + if (!agent) return null; + return ( + + + + ); + } else if (fav.model && fav.endpoint) { + return ( + + + + ); + } + return null; + })}
diff --git a/client/src/hooks/useFavorites.ts b/client/src/hooks/useFavorites.ts index 3a453228b9..d7024e70b6 100644 --- a/client/src/hooks/useFavorites.ts +++ b/client/src/hooks/useFavorites.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { useRecoilState } from 'recoil'; import store from '~/store'; -import type { FavoriteModel } from '~/store/favorites'; import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider'; export default function useFavorites() { @@ -11,73 +10,71 @@ export default function useFavorites() { useEffect(() => { if (getFavoritesQuery.data) { - setFavorites({ - agents: getFavoritesQuery.data.agents || [], - models: getFavoritesQuery.data.models || [], - }); + if (Array.isArray(getFavoritesQuery.data)) { + const mapped = getFavoritesQuery.data.map((f: any) => { + if (f.agentId || (f.model && f.endpoint)) return f; + if (f.type === 'agent' && f.id) return { agentId: f.id }; + // Drop label and map legacy model format + if (f.type === 'model') return { model: f.model, endpoint: f.endpoint }; + return f; + }); + setFavorites(mapped); + } else { + // Handle legacy format or invalid data + setFavorites([]); + } } }, [getFavoritesQuery.data, setFavorites]); const saveFavorites = (newFavorites: typeof favorites) => { - setFavorites(newFavorites); - updateFavoritesMutation.mutate(newFavorites); + 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 addFavoriteAgent = (id: string) => { - const agents = favorites?.agents || []; - if (agents.includes(id)) return; - const newFavorites = { - ...favorites, - agents: [...agents, id], - }; + const addFavoriteAgent = (agentId: string) => { + if (favorites.some((f) => f.agentId === agentId)) return; + const newFavorites = [...favorites, { agentId }]; saveFavorites(newFavorites); }; - const removeFavoriteAgent = (id: string) => { - const agents = favorites?.agents || []; - const newFavorites = { - ...favorites, - agents: agents.filter((item) => item !== id), - }; + const removeFavoriteAgent = (agentId: string) => { + const newFavorites = favorites.filter((f) => f.agentId !== agentId); saveFavorites(newFavorites); }; - const addFavoriteModel = (model: FavoriteModel) => { - const models = favorites?.models || []; - if (models.some((m) => m.model === model.model && m.endpoint === model.endpoint)) return; - const newFavorites = { - ...favorites, - models: [...models, model], - }; + 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 models = favorites?.models || []; - const newFavorites = { - ...favorites, - models: models.filter((m) => !(m.model === model && m.endpoint === endpoint)), - }; + const newFavorites = favorites.filter((f) => !(f.model === model && f.endpoint === endpoint)); saveFavorites(newFavorites); }; - const isFavoriteAgent = (id: string) => { - return (favorites?.agents || []).includes(id); + const isFavoriteAgent = (agentId: string) => { + return favorites.some((f) => f.agentId === agentId); }; const isFavoriteModel = (model: string, endpoint: string) => { - return (favorites?.models || []).some((m) => m.model === model && m.endpoint === endpoint); + return favorites.some((f) => f.model === model && f.endpoint === endpoint); }; - const toggleFavoriteAgent = (id: string) => { - if (isFavoriteAgent(id)) { - removeFavoriteAgent(id); + const toggleFavoriteAgent = (agentId: string) => { + if (isFavoriteAgent(agentId)) { + removeFavoriteAgent(agentId); } else { - addFavoriteAgent(id); + addFavoriteAgent(agentId); } }; - const toggleFavoriteModel = (model: FavoriteModel) => { + const toggleFavoriteModel = (model: { model: string; endpoint: string }) => { if (isFavoriteModel(model.model, model.endpoint)) { removeFavoriteModel(model.model, model.endpoint); } else { @@ -85,6 +82,14 @@ export default function useFavorites() { } }; + const reorderFavorites = (newFavorites: typeof favorites) => { + setFavorites(newFavorites); + }; + + const persistFavorites = (newFavorites: typeof favorites) => { + updateFavoritesMutation.mutate(newFavorites); + }; + return { favorites, addFavoriteAgent, @@ -95,5 +100,7 @@ export default function useFavorites() { isFavoriteModel, toggleFavoriteAgent, toggleFavoriteModel, + reorderFavorites, + persistFavorites, }; } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 6c3410b0c2..b0e1a2fb3a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -851,7 +851,7 @@ "com_ui_date_yesterday": "Yesterday", "com_ui_decline": "I do not accept", "com_ui_default_post_request": "Default (POST request)", - "com_ui_remove": "Remove", + "com_ui_unpin": "Unpin", "com_ui_delete": "Delete", "com_ui_delete_action": "Delete Action", "com_ui_delete_action_confirm": "Are you sure you want to delete this action?", @@ -1129,6 +1129,8 @@ "com_ui_prev": "Prev", "com_ui_preview": "Preview", "com_ui_privacy_policy": "Privacy policy", + "com_ui_pin": "Pin", + "com_ui_pinned": "Pinned", "com_ui_privacy_policy_url": "Privacy Policy URL", "com_ui_prompt": "Prompt", "com_ui_prompt_group_button": "{{name}} prompt, {{category}} category", diff --git a/client/src/store/favorites.ts b/client/src/store/favorites.ts index ace76c355e..feb1832263 100644 --- a/client/src/store/favorites.ts +++ b/client/src/store/favorites.ts @@ -1,22 +1,21 @@ import { atom } from 'recoil'; +export type Favorite = { + agentId?: string; + model?: string; + endpoint?: string; +}; + export type FavoriteModel = { model: string; endpoint: string; - label?: string; }; -export type FavoritesState = { - agents: string[]; - models: FavoriteModel[]; -}; +export type FavoritesState = Favorite[]; const favorites = atom({ key: 'favorites', - default: { - agents: [], - models: [], - }, + default: [], }); export default { diff --git a/client/src/utils/agents.tsx b/client/src/utils/agents.tsx index abea8b7fe9..b1686d7434 100644 --- a/client/src/utils/agents.tsx +++ b/client/src/utils/agents.tsx @@ -29,7 +29,7 @@ export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | n export const renderAgentAvatar = ( agent: t.Agent | null | undefined, options: { - size?: 'sm' | 'md' | 'lg' | 'xl'; + size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'; className?: string; showBorder?: boolean; } = {}, @@ -40,6 +40,7 @@ export const renderAgentAvatar = ( // Size mappings for responsive design const sizeClasses = { + icon: 'h-5 w-5', sm: 'h-12 w-12 sm:h-14 sm:w-14', md: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24', lg: 'h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28', @@ -47,6 +48,7 @@ export const renderAgentAvatar = ( }; const iconSizeClasses = { + icon: 'h-4 w-4', sm: 'h-6 w-6 sm:h-7 sm:w-7', md: 'h-6 w-6 sm:h-8 sm:w-8 md:h-10 md:w-10', lg: 'h-8 w-8 sm:h-10 sm:w-10 md:h-12 md:w-12', @@ -54,6 +56,7 @@ export const renderAgentAvatar = ( }; const placeholderSizeClasses = { + icon: 'h-5 w-5', sm: 'h-10 w-10 sm:h-12 sm:w-12', md: 'h-12 w-12 sm:h-16 sm:w-16 md:h-20 md:w-20', lg: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24', diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 0bdcd0912e..c2bdc6fd34 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -142,26 +142,15 @@ const userSchema = new Schema( default: {}, }, favorites: { - type: { - agents: { - type: [String], - default: [], + type: [ + { + _id: false, + agentId: String, // for agent + model: String, // for model + endpoint: String, // for model }, - models: { - type: [ - { - model: String, - endpoint: String, - label: String, - }, - ], - default: [], - }, - }, - default: { - agents: [], - models: [], - }, + ], + default: [], }, /** Field for external source identification (for consistency with TPrincipal schema) */ idOnTheSource: {