feat: Enhance favorites management with validation, update data structure, and improve UI interactions

This commit is contained in:
Marco Beretta 2025-11-24 21:18:16 +01:00
parent bfeae91a96
commit 960e2ee527
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
10 changed files with 117 additions and 31 deletions

View file

@ -9,6 +9,28 @@ const updateFavoritesController = async (req, res) => {
return res.status(400).json({ message: 'Favorites data is required' });
}
// Validate favorites structure
if (!Array.isArray(favorites)) {
return res.status(400).json({ message: 'Favorites must be an array' });
}
for (const fav of favorites) {
const hasAgent = !!fav.agentId;
const hasModel = !!(fav.model && fav.endpoint);
if (!hasAgent && !hasModel) {
return res.status(400).json({
message: 'Each favorite must have either agentId or model+endpoint',
});
}
if (hasAgent && hasModel) {
return res.status(400).json({
message: 'Favorite cannot have both agentId and model/endpoint',
});
}
}
const user = await User.findByIdAndUpdate(
userId,
{ $set: { favorites } },

View file

@ -48,6 +48,12 @@ 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();
}
}}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">

View file

@ -175,7 +175,7 @@ const Conversations: FC<ConversationsProps> = ({
}
let rendering: JSX.Element;
if (item.type === 'favorites') {
rendering = <FavoritesList />;
rendering = <FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />;
} else if (item.type === 'chats-header') {
rendering = (
<button

View file

@ -77,7 +77,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
return (
<div className="mr-2 h-5 w-5">
<EndpointIcon
conversation={{ endpoint: model.endpoint, model: model.model } as any}
conversation={{ endpoint: model.endpoint, model: model.model } as t.TConversation}
endpoint={model.endpoint}
model={model.model}
size={20}

View file

@ -1,10 +1,12 @@
import React, { useRef, useCallback, useMemo } from 'react';
import React, { useRef, useCallback, useMemo, useContext } from 'react';
import { LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useDrag, useDrop } from 'react-dnd';
import { QueryKeys, dataService } from 'librechat-data-provider';
import { QueryKeys, dataService, PermissionTypes, Permissions } from 'librechat-data-provider';
import { useQueries, useQueryClient } from '@tanstack/react-query';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { useFavorites } from '~/hooks';
import { useFavorites, useLocalize, useHasAccess, AuthContext } from '~/hooks';
import FavoriteItem from './FavoriteItem';
interface DraggableFavoriteItemProps {
@ -45,7 +47,10 @@ const DraggableFavoriteItem = ({
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 (!clientOffset) {
return;
}
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
@ -84,10 +89,44 @@ const DraggableFavoriteItem = ({
);
};
export default function FavoritesList() {
export default function FavoritesList({
isSmallScreen,
toggleNav,
}: {
isSmallScreen?: boolean;
toggleNav?: () => void;
}) {
const navigate = useNavigate();
const localize = useLocalize();
const queryClient = useQueryClient();
const authContext = useContext(AuthContext);
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const hasAccessToMarketplace = useHasAccess({
permissionType: PermissionTypes.MARKETPLACE,
permission: Permissions.USE,
});
// Check if auth is ready (avoid race conditions)
const authReady =
authContext?.isAuthenticated !== undefined &&
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
const handleAgentMarketplace = useCallback(() => {
navigate('/agents');
if (isSmallScreen && toggleNav) {
toggleNav();
}
}, [navigate, isSmallScreen, toggleNav]);
const agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[];
const agentQueries = useQueries({
@ -146,13 +185,29 @@ export default function FavoritesList() {
persistFavorites(favorites);
}, [favorites, persistFavorites]);
if (favorites.length === 0) {
// If no favorites and no marketplace to show, return null
if (favorites.length === 0 && !showAgentMarketplace) {
return null;
}
return (
<div className="mb-2 flex flex-col pb-2">
<div className="mt-1 flex flex-col gap-1">
{/* Agent Marketplace button - identical styling to favorite items */}
{showAgentMarketplace && (
<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-active-alt"
onClick={handleAgentMarketplace}
data-testid="nav-agents-marketplace-button"
>
<div className="flex flex-1 items-center truncate pr-6">
<div className="mr-2 h-5 w-5">
<LayoutGrid className="h-5 w-5 text-text-primary" />
</div>
<span className="truncate">{localize('com_agents_marketplace')}</span>
</div>
</div>
)}
{favorites.map((fav, index) => {
if (fav.agentId) {
const agent = agentsMap[fav.agentId];
@ -177,7 +232,7 @@ export default function FavoritesList() {
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={fav as any} type="model" />
<FavoriteItem item={{ model: fav.model, endpoint: fav.endpoint }} type="model" />
</DraggableFavoriteItem>
);
}

View file

@ -21,7 +21,6 @@ import store from '~/store';
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px';
@ -160,9 +159,6 @@ const Nav = memo(
const headerButtons = useMemo(
() => (
<>
<Suspense fallback={null}>
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
</Suspense>
{hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
@ -173,7 +169,7 @@ const Nav = memo(
)}
</>
),
[hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible],
[hasAccessToBookmarks, tags, isSmallScreen],
);
const [isSearchLoading, setIsSearchLoading] = useState(

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { Favorite } from '~/store/favorites';
import store from '~/store';
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
@ -11,13 +12,15 @@ export default function useFavorites() {
useEffect(() => {
if (getFavoritesQuery.data) {
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;
});
const mapped = getFavoritesQuery.data.map(
(f: Favorite & { type?: string; id?: string }) => {
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

View file

@ -71,7 +71,7 @@
"com_agents_link_copy_failed": "Failed to copy link",
"com_agents_load_more_label": "Load more agents from {{category}} category",
"com_agents_loading": "Loading...",
"com_agents_marketplace": "Agent Marketplace",
"com_agents_marketplace": "Explore Agents",
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",

View file

@ -25,11 +25,17 @@ export function deleteUser(): Promise<s.TPreset> {
return request.delete(endpoints.deleteUser());
}
export function getFavorites(): Promise<unknown> {
export type FavoriteItem = {
agentId?: string;
model?: string;
endpoint?: string;
};
export function getFavorites(): Promise<FavoriteItem[]> {
return request.get('/api/user/settings/favorites');
}
export function updateFavorites(favorites: unknown): Promise<unknown> {
export function updateFavorites(favorites: FavoriteItem[]): Promise<FavoriteItem[]> {
return request.post('/api/user/settings/favorites', { favorites });
}

View file

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