feat: Implement Favorites functionality with controllers, hooks, and UI components

This commit is contained in:
Marco Beretta 2025-11-23 00:27:41 +01:00
parent 28cdc06209
commit d2faf9c67d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
19 changed files with 588 additions and 76 deletions

View file

@ -0,0 +1,52 @@
const { User } = require('~/db/models');
const updateFavoritesController = async (req, res) => {
try {
const { favorites } = req.body;
const userId = req.user.id;
if (!favorites) {
return res.status(400).json({ message: 'Favorites data is required' });
}
const user = await User.findByIdAndUpdate(
userId,
{ $set: { favorites } },
{ new: true, select: 'favorites' },
);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json(user.favorites);
} catch (error) {
console.error('Error updating favorites:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
const getFavoritesController = async (req, res) => {
try {
const userId = req.user.id;
const user = await User.findById(userId).select('favorites').lean();
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const favorites = user.favorites || {};
res.status(200).json({
agents: favorites.agents || [],
models: favorites.models || [],
});
} catch (error) {
console.error('Error fetching favorites:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = {
updateFavoritesController,
getFavoritesController,
};

View file

@ -0,0 +1,13 @@
const express = require('express');
const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
router.get('/favorites', requireJwtAuth, getFavoritesController);
router.post('/favorites', requireJwtAuth, updateFavoritesController);
module.exports = router;

View file

@ -15,8 +15,11 @@ const {
requireJwtAuth,
} = require('~/server/middleware');
const settings = require('./settings');
const router = express.Router();
router.use('/settings', settings);
router.get('/', requireJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);

View file

@ -1,7 +1,8 @@
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 } from '~/hooks';
import { useLocalize, TranslationKeys, useAgentCategories, useFavorites } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
interface AgentCardProps {
@ -16,6 +17,13 @@ interface AgentCardProps {
const AgentCard: React.FC<AgentCardProps> = ({ 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 '';
@ -48,59 +56,64 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
aria-describedby={`agent-${agent.id}-description`}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{/* Two column layout */}
<div className="flex h-full items-start gap-3">
{/* Left column: Avatar and Category */}
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
<div className="absolute right-2 top-2 z-10">
<button
onClick={handleFavoriteClick}
className="rounded-full p-1 hover:bg-surface-hover"
>
<Star
className={cn('h-5 w-5', isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary')}
/>
</button>
</div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{/* Left column: Avatar and Category */}
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
{/* Category tag */}
{agent.category && (
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
</div>
)}
</div>
{/* Right column: Name, description, and other content */}
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
<div className="space-y-1">
{/* Agent name */}
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
{agent.name}
</Label>
{/* Agent description */}
<p
id={`agent-${agent.id}-description`}
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
>
{agent.description ?? ''}
</p>
{/* Category tag */}
{agent.category && (
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
</div>
)}
</div>
{/* Owner info - moved to bottom right */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex justify-end">
<div className="flex items-center text-sm text-text-secondary">
<Label>{displayName}</Label>
{/* Right column: Name, description, and other content */}
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
<div className="space-y-1">
{/* Agent name */}
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
{agent.name}
</Label>
{/* Agent description */}
<p
id={`agent-${agent.id}-description`}
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
>
{agent.description ?? ''}
</p>
</div>
{/* Owner info - moved to bottom right */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex justify-end">
<div className="flex items-center text-sm text-text-secondary">
<Label>{displayName}</Label>
</div>
</div>
</div>
);
}
return null;
})()}
);
}
return null;
})()}
</div>
</div>
</div>
</div>

View file

@ -1,7 +1,9 @@
import React from 'react';
import { EarthIcon } from 'lucide-react';
import { EarthIcon, Star } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { useFavorites } from '~/hooks';
import { cn } from '~/utils';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
@ -11,8 +13,9 @@ interface EndpointModelItemProps {
isSelected: boolean;
}
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) {
const { handleSelectModel } = useModelSelectorContext();
const { isFavoriteModel, toggleFavoriteModel } = useFavorites();
let isGlobal = false;
let modelName = modelId;
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
@ -32,11 +35,20 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
modelName = endpoint.assistantNames[modelId];
}
const isFavorite = isFavoriteModel(modelId ?? '', endpoint.value);
const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (modelId) {
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value, label: modelName ?? undefined });
}
};
return (
<MenuItem
key={modelId}
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
className="group flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
>
<div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
{avatarUrl ? (
@ -48,31 +60,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
) : null}
<span className="truncate text-left">{modelName}</span>
{isGlobal && (
<EarthIcon className="ml-auto size-4 flex-shrink-0 self-center text-green-400" />
) : (
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full" />
)}
<span className="truncate">{modelName}</span>
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
</div>
{isSelected && (
<div className="flex-shrink-0 self-center">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="block"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
</div>
)}
<button
onClick={handleFavoriteClick}
className={cn(
'rounded-full p-1 hover:bg-surface-hover',
isFavorite ? 'visible' : 'invisible group-hover:visible',
)}
>
<Star
className={cn(
'h-4 w-4',
isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary',
)}
/>
</button>
</MenuItem>
);
}

View file

@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Menu from '@ariakit/react/menu';
import { Ellipsis, Trash } from 'lucide-react';
import { DropdownPopup } from '@librechat/client';
import { useNewConvo, useFavorites, useLocalize } from '~/hooks';
import { renderAgentAvatar, cn } from '~/utils';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import type t from 'librechat-data-provider';
import type { FavoriteModel } from '~/store/favorites';
type FavoriteItemProps = {
item: t.Agent | FavoriteModel;
type: 'agent' | 'model';
};
export default function FavoriteItem({ item, type }: FavoriteItemProps) {
const navigate = useNavigate();
const localize = useLocalize();
const { newConversation } = useNewConvo();
const { removeFavoriteAgent, removeFavoriteModel } = useFavorites();
const [isPopoverActive, setIsPopoverActive] = useState(false);
const handleClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-testid="favorite-options-button"]')) {
return;
}
if (type === 'agent') {
const agent = item as t.Agent;
newConversation({
template: {
...agent,
agent_id: agent.id,
},
preset: {
...agent,
agent_id: agent.id,
},
});
navigate(`/c/new`);
} else {
const model = item as FavoriteModel;
newConversation({
template: {
endpoint: model.endpoint,
model: model.model,
},
preset: {
endpoint: model.endpoint,
model: model.model,
},
});
navigate(`/c/new`);
}
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
if (type === 'agent') {
removeFavoriteAgent((item as t.Agent).id);
} else {
const model = item as FavoriteModel;
removeFavoriteModel(model.model, model.endpoint);
}
setIsPopoverActive(false);
};
const renderIcon = () => {
if (type === 'agent') {
return renderAgentAvatar(item as t.Agent, { size: 20, className: 'mr-2' });
}
const model = item as FavoriteModel;
return (
<div className="mr-2 h-5 w-5">
<EndpointIcon
conversation={{ endpoint: model.endpoint, model: model.model } as any}
endpoint={model.endpoint}
model={model.model}
size={20}
/>
</div>
);
};
const getName = () => {
if (type === 'agent') {
return (item as t.Agent).name;
}
return (item as FavoriteModel).label || (item as FavoriteModel).model;
};
const menuId = React.useId();
const dropdownItems = [
{
label: localize('com_ui_remove'),
onClick: handleRemove,
icon: <Trash className="h-4 w-4 text-text-primary" />,
},
];
return (
<div
className="group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary hover:bg-surface-hover"
onClick={handleClick}
data-testid="favorite-item"
>
<div className="flex flex-1 items-center truncate pr-6">
{renderIcon()}
<span className="truncate">{getName()}</span>
</div>
<div
className={cn(
'absolute right-2 flex items-center',
isPopoverActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
>
<DropdownPopup
portal={true}
mountByState={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Menu.MenuButton
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md hover:bg-surface-hover focus:bg-surface-hover',
isPopoverActive ? 'bg-surface-hover' : '',
)}
aria-label={localize('com_ui_options')}
data-testid="favorite-options-button"
>
<Ellipsis className="h-4 w-4 text-text-secondary hover:text-text-primary" />
</Menu.MenuButton>
}
items={dropdownItems}
menuId={menuId}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import React from 'react';
import { useQueries } from '@tanstack/react-query';
import { ChevronRight } from 'lucide-react';
import * as Collapsible from '@radix-ui/react-collapsible';
import { QueryKeys, dataService } from 'librechat-data-provider';
import { useFavorites, useLocalStorage } from '~/hooks';
import FavoriteItem from './FavoriteItem';
export default function FavoritesList() {
const { favorites } = useFavorites();
const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true);
const agentQueries = useQueries({
queries: (favorites.agents || []).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 favoriteModels = favorites.models || [];
if ((favorites.agents || []).length === 0 && (favorites.models || []).length === 0) {
return null;
}
return (
<Collapsible.Root
open={isExpanded}
onOpenChange={setIsExpanded}
className="flex flex-col py-2"
>
<Collapsible.Trigger className="group flex w-full items-center gap-2 px-3 py-1 text-xs font-bold text-text-secondary hover:text-text-primary">
<ChevronRight className="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-90" />
<span className="select-none">Favorites</span>
</Collapsible.Trigger>
<Collapsible.Content className="collapsible-content">
<div className="mt-1 flex flex-col gap-1">
{favoriteAgents.map((agent) => (
<FavoriteItem key={agent!.id} item={agent!} type="agent" />
))}
{favoriteModels.map((model) => (
<FavoriteItem key={`${model.endpoint}-${model.model}`} item={model} type="model" />
))}
</div>
</Collapsible.Content>
</Collapsible.Root>
);
}

View file

@ -22,6 +22,7 @@ import store from '~/store';
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
const FavoritesList = lazy(() => import('./Favorites/FavoritesList'));
const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px';
@ -152,7 +153,14 @@ const Nav = memo(
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
const subHeaders = useMemo(
() => search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />,
() => (
<>
<Suspense fallback={null}>
<FavoritesList />
</Suspense>
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
</>
),
[search.enabled, isSmallScreen],
);

View file

@ -0,0 +1,24 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService } from 'librechat-data-provider';
import type { FavoritesState } from '~/store/favorites';
export const useGetFavoritesQuery = (config?: any) => {
return useQuery<FavoritesState>(['favorites'], () => dataService.getFavorites() as Promise<FavoritesState>, {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
});
};
export const useUpdateFavoritesMutation = () => {
const queryClient = useQueryClient();
return useMutation(
(favorites: FavoritesState) => dataService.updateFavorites(favorites) as Promise<FavoritesState>,
{
onSuccess: (data) => {
queryClient.setQueryData(['favorites'], data);
},
},
);
};

View file

@ -8,6 +8,7 @@ export * from './Messages';
export * from './Misc';
export * from './Tools';
export * from './connection';
export * from './Favorites';
export * from './mutations';
export * from './prompts';
export * from './queries';

View file

@ -32,4 +32,5 @@ export { default as useDocumentTitle } from './useDocumentTitle';
export { default as useSpeechToText } from './Input/useSpeechToText';
export { default as useTextToSpeech } from './Input/useTextToSpeech';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
export { default as useFavorites } from './useFavorites';
export { useResourcePermissions } from './useResourcePermissions';

View file

@ -0,0 +1,99 @@
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() {
const [favorites, setFavorites] = useRecoilState(store.favorites);
const getFavoritesQuery = useGetFavoritesQuery();
const updateFavoritesMutation = useUpdateFavoritesMutation();
useEffect(() => {
if (getFavoritesQuery.data) {
setFavorites({
agents: getFavoritesQuery.data.agents || [],
models: getFavoritesQuery.data.models || [],
});
}
}, [getFavoritesQuery.data, setFavorites]);
const saveFavorites = (newFavorites: typeof favorites) => {
setFavorites(newFavorites);
updateFavoritesMutation.mutate(newFavorites);
};
const addFavoriteAgent = (id: string) => {
const agents = favorites?.agents || [];
if (agents.includes(id)) return;
const newFavorites = {
...favorites,
agents: [...agents, id],
};
saveFavorites(newFavorites);
};
const removeFavoriteAgent = (id: string) => {
const agents = favorites?.agents || [];
const newFavorites = {
...favorites,
agents: agents.filter((item) => item !== id),
};
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],
};
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)),
};
saveFavorites(newFavorites);
};
const isFavoriteAgent = (id: string) => {
return (favorites?.agents || []).includes(id);
};
const isFavoriteModel = (model: string, endpoint: string) => {
return (favorites?.models || []).some((m) => m.model === model && m.endpoint === endpoint);
};
const toggleFavoriteAgent = (id: string) => {
if (isFavoriteAgent(id)) {
removeFavoriteAgent(id);
} else {
addFavoriteAgent(id);
}
};
const toggleFavoriteModel = (model: FavoriteModel) => {
if (isFavoriteModel(model.model, model.endpoint)) {
removeFavoriteModel(model.model, model.endpoint);
} else {
addFavoriteModel(model);
}
};
return {
favorites,
addFavoriteAgent,
removeFavoriteAgent,
addFavoriteModel,
removeFavoriteModel,
isFavoriteAgent,
isFavoriteModel,
toggleFavoriteAgent,
toggleFavoriteModel,
};
}

View file

@ -851,6 +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_delete": "Delete",
"com_ui_delete_action": "Delete Action",
"com_ui_delete_action_confirm": "Are you sure you want to delete this action?",

View file

@ -0,0 +1,24 @@
import { atom } from 'recoil';
export type FavoriteModel = {
model: string;
endpoint: string;
label?: string;
};
export type FavoritesState = {
agents: string[];
models: FavoriteModel[];
};
const favorites = atom<FavoritesState>({
key: 'favorites',
default: {
agents: [],
models: [],
},
});
export default {
favorites,
};

View file

@ -12,6 +12,7 @@ import lang from './language';
import settings from './settings';
import misc from './misc';
import isTemporary from './temporary';
import favorites from './favorites';
export * from './agents';
export * from './mcp';
@ -30,4 +31,5 @@ export default {
...settings,
...misc,
...isTemporary,
...favorites,
};

View file

@ -2836,3 +2836,32 @@ html {
.sharepoint-picker-bg{
background-color: #F5F5F5;
}
/* Collapsible Animation */
.collapsible-content {
overflow: hidden;
}
.collapsible-content[data-state='open'] {
animation: slideDown 300ms ease-out;
}
.collapsible-content[data-state='closed'] {
animation: slideUp 300ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
}
to {
height: 0;
}
}

View file

@ -25,6 +25,14 @@ export function deleteUser(): Promise<s.TPreset> {
return request.delete(endpoints.deleteUser());
}
export function getFavorites(): Promise<unknown> {
return request.get('/api/user/settings/favorites');
}
export function updateFavorites(favorites: unknown): Promise<unknown> {
return request.post('/api/user/settings/favorites', { favorites });
}
export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesResponse> {
return request.get(endpoints.shareMessages(shareId));
}

View file

@ -141,6 +141,28 @@ const userSchema = new Schema<IUser>(
},
default: {},
},
favorites: {
type: {
agents: {
type: [String],
default: [],
},
models: {
type: [
{
model: String,
endpoint: String,
label: String,
},
],
default: [],
},
},
default: {
agents: [],
models: [],
},
},
/** Field for external source identification (for consistency with TPrincipal schema) */
idOnTheSource: {
type: String,

View file

@ -34,6 +34,14 @@ export interface IUser extends Document {
personalization?: {
memories?: boolean;
};
favorites?: {
agents: string[];
models: Array<{
model: string;
endpoint: string;
label?: string;
}>;
};
createdAt?: Date;
updatedAt?: Date;
/** Field for external source identification (for consistency with TPrincipal schema) */