- 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
This commit is contained in:
Danny Avila 2025-06-23 11:44:12 -04:00
parent 83122f160f
commit d549a64317
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
9 changed files with 143 additions and 128 deletions

View file

@ -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({

View file

@ -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<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
const localize = useLocalize();
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [allAgents, setAllAgents] = useState<t.Agent[]>([]);
// 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<AgentGridProps> = ({ 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<AgentGridProps> = ({ 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<AgentGridProps> = ({ category, searchQuery, onSelectAg
)}
{/* Loading indicator when fetching more with accessibility */}
{isFetching && cursor && (
{isFetching && hasNextPage && (
<div
className="flex justify-center py-4"
role="status"
@ -270,7 +254,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)}
{/* Load more button with enhanced accessibility */}
{data?.has_more && !isFetching && (
{hasNextPage && !isFetching && (
<div className="mt-8 flex justify-center">
<Button
variant="outline"
@ -294,18 +278,13 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)}
</div>
);
// Use SmartLoader to prevent unnecessary loading flashes
return (
<SmartLoader
isLoading={isLoading}
hasData={hasData}
delay={200} // Show loading only after 200ms delay
loadingComponent={loadingSkeleton}
>
{mainContent}
</SmartLoader>
);
console.log('isLoading', isLoading);
console.log('isFetching', isFetching);
console.log('isFetchingNextPage', isFetchingNextPage);
if (isLoading || (isFetching && !isFetchingNextPage)) {
return loadingSkeleton;
}
return mainContent;
};
export default AgentGrid;

View file

@ -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';

View file

@ -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) => {

View file

@ -1,2 +1,5 @@
export * from './queries';
export * from './mutations';
// Re-export specific marketplace queries for easier imports
export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries';

View file

@ -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<t.TMarketplaceCategory[]>,
): QueryObserverResult<t.TMarketplaceCategory[]> => {
return useQuery<t.TMarketplaceCategory[]>(
[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<t.AgentListResponse, unknown>,
) => {
return useInfiniteQuery<t.AgentListResponse>({
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,
});
};

View file

@ -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

View file

@ -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<TAgentsMap | undefined>(() => {
return agentsList !== null ? agentsList : undefined;

View file

@ -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<t.TMarketplaceCategory[]>,
): QueryObserverResult<t.TMarketplaceCategory[]> => {
return useQuery<t.TMarketplaceCategory[]>(
[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<a.AgentListResponse>,
): QueryObserverResult<a.AgentListResponse> => {
return useQuery<a.AgentListResponse>(
[QueryKeys.marketplaceAgents, params],
() => dataService.getMarketplaceAgents(params),
{
enabled: !!params.requiredPermission,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 2 * 60 * 1000, // Cache for 2 minutes
...config,
},
);
};