From 3f6d7ab7c78f498aa34d07cecdcba96be284fd08 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 23 Jun 2025 11:44:12 -0400 Subject: [PATCH] - Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/ - Replace manual pagination in AgentGrid with infinite query pattern - Update imports to use local data provider instead of librechat-data-provider - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants - Improve agent access control by adding requiredPermission validation in backend - Remove manual cursor/state management in favor of infinite query built-ins - Maintain existing search and category filtering functionality --- api/server/controllers/agents/v1.js | 10 +++ .../components/SidePanel/Agents/AgentGrid.tsx | 89 +++++++------------ .../SidePanel/Agents/AgentMarketplace.tsx | 3 +- .../SidePanel/Agents/AgentSelect.tsx | 33 ++++--- client/src/data-provider/Agents/index.ts | 3 + client/src/data-provider/Agents/queries.ts | 66 +++++++++++++- .../src/hooks/Agents/useAgentCategories.tsx | 2 +- client/src/hooks/Agents/useAgentsMap.ts | 15 ++-- .../src/react-query/react-query-service.ts | 50 ----------- 9 files changed, 143 insertions(+), 128 deletions(-) diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 4734ccf697..b7540a5da2 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -389,6 +389,16 @@ const deleteAgentHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => { try { const userId = req.user.id; + if (!req.query.requiredPermission) { + req.query.requiredPermission = PermissionBits.VIEW; + } else if (typeof req.query.requiredPermission === 'string') { + req.query.requiredPermission = parseInt(req.query.requiredPermission, 10); + if (isNaN(req.query.requiredPermission)) { + req.query.requiredPermission = PermissionBits.VIEW; + } + } else if (typeof req.query.requiredPermission !== 'number') { + req.query.requiredPermission = PermissionBits.VIEW; + } const requiredPermission = req.query.requiredPermission || PermissionBits.VIEW; // Get agent IDs the user has VIEW access to via ACL const accessibleIds = await findAccessibleResources({ diff --git a/client/src/components/SidePanel/Agents/AgentGrid.tsx b/client/src/components/SidePanel/Agents/AgentGrid.tsx index f2ff2ae7e6..3277a566b9 100644 --- a/client/src/components/SidePanel/Agents/AgentGrid.tsx +++ b/client/src/components/SidePanel/Agents/AgentGrid.tsx @@ -1,8 +1,8 @@ -import React, { useState } from 'react'; +import React, { useMemo } from 'react'; import type t from 'librechat-data-provider'; -import { useGetMarketplaceAgentsQuery } from 'librechat-data-provider/react-query'; +import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; import { useAgentCategories } from '~/hooks/Agents'; import useLocalize from '~/hooks/useLocalize'; import { Button } from '~/components/ui'; @@ -11,7 +11,7 @@ import { SmartLoader, useHasData } from './SmartLoader'; import ErrorDisplay from './ErrorDisplay'; import AgentCard from './AgentCard'; import { cn } from '~/utils'; - +import { PERMISSION_BITS } from 'librechat-data-provider'; interface AgentGridProps { category: string; // Currently selected category searchQuery: string; // Current search query @@ -23,30 +23,23 @@ interface AgentGridProps { */ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAgent }) => { const localize = useLocalize(); - const [cursor, setCursor] = useState(undefined); - const [allAgents, setAllAgents] = useState([]); // Get category data from API const { categories } = useAgentCategories(); // Build query parameters based on current state - const queryParams = React.useMemo(() => { + const queryParams = useMemo(() => { const params: { requiredPermission: number; category?: string; search?: string; limit: number; - cursor?: string; promoted?: 0 | 1; } = { - requiredPermission: 1, // Read permission for marketplace viewing + requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing limit: 6, }; - if (cursor) { - params.cursor = cursor; - } - // Handle search if (searchQuery) { params.search = searchQuery; @@ -65,29 +58,28 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg } return params; - }, [category, searchQuery, cursor]); + }, [category, searchQuery]); - // Single unified query that handles all cases - const { data, isLoading, error, isFetching, refetch } = useGetMarketplaceAgentsQuery(queryParams); + // Use infinite query for marketplace agents + const { + data, + isLoading, + error, + isFetching, + fetchNextPage, + hasNextPage, + refetch, + isFetchingNextPage, + } = useMarketplaceAgentsInfiniteQuery(queryParams); - // Handle data accumulation for pagination - React.useEffect(() => { - if (data?.data) { - if (cursor) { - // Append new data for pagination - setAllAgents((prev) => [...prev, ...data.data]); - } else { - // Replace data for new queries - setAllAgents(data.data); - } - } - }, [data, cursor]); - - // Get current agents to display - const currentAgents = cursor ? allAgents : data?.data || []; + // Flatten all pages into a single array of agents + const currentAgents = useMemo(() => { + if (!data?.pages) return []; + return data.pages.flatMap((page) => page.data || []); + }, [data?.pages]); // Check if we have meaningful data to prevent unnecessary loading states - const hasData = useHasData(data); + const hasData = useHasData(data?.pages?.[0]); /** * Get category display name from API data or use fallback @@ -114,19 +106,11 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg * Load more agents when "See More" button is clicked */ const handleLoadMore = () => { - if (data?.after) { - setCursor(data.after); + if (hasNextPage && !isFetching) { + fetchNextPage(); } }; - /** - * Reset cursor and agents when category or search changes - */ - React.useEffect(() => { - setCursor(undefined); - setAllAgents([]); - }, [category, searchQuery]); - /** * Get the appropriate title for the agents grid based on current state */ @@ -257,7 +241,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg )} {/* Loading indicator when fetching more with accessibility */} - {isFetching && cursor && ( + {isFetching && hasNextPage && (
= ({ category, searchQuery, onSelectAg )} {/* Load more button with enhanced accessibility */} - {data?.has_more && !isFetching && ( + {hasNextPage && !isFetching && (
); - - // Use SmartLoader to prevent unnecessary loading flashes - return ( - - {mainContent} - - ); + console.log('isLoading', isLoading); + console.log('isFetching', isFetching); + console.log('isFetchingNextPage', isFetchingNextPage); + if (isLoading || (isFetching && !isFetchingNextPage)) { + return loadingSkeleton; + } + return mainContent; }; export default AgentGrid; diff --git a/client/src/components/SidePanel/Agents/AgentMarketplace.tsx b/client/src/components/SidePanel/Agents/AgentMarketplace.tsx index e7f114f7ae..78740c7edd 100644 --- a/client/src/components/SidePanel/Agents/AgentMarketplace.tsx +++ b/client/src/components/SidePanel/Agents/AgentMarketplace.tsx @@ -6,8 +6,7 @@ import { useSetRecoilState, useRecoilValue } from 'recoil'; import type t from 'librechat-data-provider'; import type { ContextType } from '~/common'; -import { useGetEndpointsQuery } from '~/data-provider'; -import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query'; +import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider'; import { useDocumentTitle } from '~/hooks'; import useLocalize from '~/hooks/useLocalize'; import { TooltipAnchor, Button } from '~/components/ui'; diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 820e489e23..bd8ce58601 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -1,7 +1,11 @@ import { EarthIcon } from 'lucide-react'; import { useCallback, useEffect, useRef } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; -import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider'; +import { + AgentCapabilities, + defaultAgentFormValues, + PERMISSION_BITS, +} from 'librechat-data-provider'; import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query'; import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { TAgentCapabilities, AgentForm } from '~/common'; @@ -28,18 +32,21 @@ export default function AgentSelect({ const { control, reset } = useFormContext(); const { data: startupConfig } = useGetStartupConfig(); - const { data: agents = null } = useListAgentsQuery(undefined, { - select: (res) => - res.data.map((agent) => - processAgentOption({ - agent: { - ...agent, - name: agent.name || agent.id, - }, - instanceProjectId: startupConfig?.instanceProjectId, - }), - ), - }); + const { data: agents = null } = useListAgentsQuery( + { requiredPermission: PERMISSION_BITS.EDIT }, + { + select: (res) => + res.data.map((agent) => + processAgentOption({ + agent: { + ...agent, + name: agent.name || agent.id, + }, + instanceProjectId: startupConfig?.instanceProjectId, + }), + ), + }, + ); const resetAgentForm = useCallback( (fullAgent: Agent) => { diff --git a/client/src/data-provider/Agents/index.ts b/client/src/data-provider/Agents/index.ts index d0720956a0..b91a65fc3f 100644 --- a/client/src/data-provider/Agents/index.ts +++ b/client/src/data-provider/Agents/index.ts @@ -1,2 +1,5 @@ export * from './queries'; export * from './mutations'; + +// Re-export specific marketplace queries for easier imports +export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries'; diff --git a/client/src/data-provider/Agents/queries.ts b/client/src/data-provider/Agents/queries.ts index 5a46652e67..cec72c03a8 100644 --- a/client/src/data-provider/Agents/queries.ts +++ b/client/src/data-provider/Agents/queries.ts @@ -1,6 +1,11 @@ import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import type { + QueryObserverResult, + UseQueryOptions, + UseInfiniteQueryOptions, + InfiniteData, +} from '@tanstack/react-query'; import type t from 'librechat-data-provider'; /** @@ -98,3 +103,60 @@ export const useGetExpandedAgentByIdQuery = ( }, ); }; + +/** + * MARKETPLACE + */ +/** + * Hook for getting agent categories for marketplace tabs + */ +export const useGetAgentCategoriesQuery = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.agentCategories], + () => dataService.getAgentCategories(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + ...config, + }, + ); +}; + +/** + * Hook for infinite loading of marketplace agents with cursor-based pagination + */ +export const useMarketplaceAgentsInfiniteQuery = ( + params: { + requiredPermission: number; + category?: string; + search?: string; + limit?: number; + promoted?: 0 | 1; + cursor?: string; // For pagination + }, + config?: UseInfiniteQueryOptions, +) => { + return useInfiniteQuery({ + queryKey: [QueryKeys.marketplaceAgents, params], + queryFn: ({ pageParam }) => { + const queryParams = { ...params }; + if (pageParam) { + queryParams.cursor = pageParam.toString(); + } + return dataService.getMarketplaceAgents(queryParams); + }, + getNextPageParam: (lastPage) => lastPage?.after ?? undefined, + enabled: !!params.requiredPermission, + keepPreviousData: true, + staleTime: 2 * 60 * 1000, // 2 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }); +}; diff --git a/client/src/hooks/Agents/useAgentCategories.tsx b/client/src/hooks/Agents/useAgentCategories.tsx index 8399375f2a..77c0a224c9 100644 --- a/client/src/hooks/Agents/useAgentCategories.tsx +++ b/client/src/hooks/Agents/useAgentCategories.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import useLocalize from '~/hooks/useLocalize'; -import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query'; +import { useGetAgentCategoriesQuery } from '~/data-provider/Agents'; import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; // This interface matches the structure used by the ControlCombobox component diff --git a/client/src/hooks/Agents/useAgentsMap.ts b/client/src/hooks/Agents/useAgentsMap.ts index 0b5222c290..8b594d15ef 100644 --- a/client/src/hooks/Agents/useAgentsMap.ts +++ b/client/src/hooks/Agents/useAgentsMap.ts @@ -1,4 +1,4 @@ -import { TAgentsMap } from 'librechat-data-provider'; +import { PERMISSION_BITS, TAgentsMap } from 'librechat-data-provider'; import { useMemo } from 'react'; import { useListAgentsQuery } from '~/data-provider'; import { mapAgents } from '~/utils'; @@ -8,10 +8,15 @@ export default function useAgentsMap({ }: { isAuthenticated: boolean; }): TAgentsMap | undefined { - const { data: agentsList = null } = useListAgentsQuery(undefined, { - select: (res) => mapAgents(res.data), - enabled: isAuthenticated, - }); + const { data: agentsList = null } = useListAgentsQuery( + { + requiredPermission: PERMISSION_BITS.EDIT, + }, + { + select: (res) => mapAgents(res.data), + enabled: isAuthenticated, + }, + ); const agents = useMemo(() => { return agentsList !== null ? agentsList : undefined; diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index f4f507aa56..682bde2cde 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -12,7 +12,6 @@ import * as q from '../types/queries'; import { QueryKeys } from '../keys'; import * as s from '../schemas'; import * as t from '../types'; -import * as a from '../types/assistants'; import * as permissions from '../accessPermissions'; export { hasPermissions } from '../accessPermissions'; @@ -451,52 +450,3 @@ export const useGetEffectivePermissionsQuery = ( ...config, }); }; - -/* Marketplace */ - -/** - * Get agent categories with counts for marketplace tabs - */ -export const useGetAgentCategoriesQuery = ( - config?: UseQueryOptions, -): QueryObserverResult => { - return useQuery( - [QueryKeys.agentCategories], - () => dataService.getAgentCategories(), - { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - ...config, - }, - ); -}; - -/** - * Unified marketplace agents query with query string controls - */ -export const useGetMarketplaceAgentsQuery = ( - params: { - requiredPermission: number; - category?: string; - search?: string; - limit?: number; - cursor?: string; - promoted?: 0 | 1; - }, - config?: UseQueryOptions, -): QueryObserverResult => { - return useQuery( - [QueryKeys.marketplaceAgents, params], - () => dataService.getMarketplaceAgents(params), - { - enabled: !!params.requiredPermission, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - staleTime: 2 * 60 * 1000, // Cache for 2 minutes - ...config, - }, - ); -};