refactor: unify agent marketplace to single endpoint with cursor pagination

- Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests
This commit is contained in:
Danny Avila 2025-06-23 11:42:24 -04:00
parent be7476d530
commit 2eef94d58d
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
22 changed files with 458 additions and 1128 deletions

View file

@ -486,7 +486,7 @@ const getListAgentsByAccess = async ({
const cursorCondition = { const cursorCondition = {
$or: [ $or: [
{ updatedAt: { $lt: new Date(updatedAt) } }, { updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: mongoose.Types.ObjectId(_id) } }, { updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
], ],
}; };
@ -514,6 +514,9 @@ const getListAgentsByAccess = async ({
projectIds: 1, projectIds: 1,
description: 1, description: 1,
updatedAt: 1, updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 }); }).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested // Only apply limit if pagination is requested

View file

@ -1,175 +1,132 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { logger } = require('~/config'); const { logger } = require('~/config');
const { findCategoryByValue, getCategoriesWithCounts } = require('~/models'); const { findCategoryByValue, getCategoriesWithCounts } = require('~/models');
const { getListAgentsByAccess } = require('~/models/Agent');
const {
findAccessibleResources,
findPubliclyAccessibleResources,
} = require('~/server/services/PermissionService');
// Get the Agent model // Get the Agent model
const Agent = mongoose.model('Agent'); const Agent = mongoose.model('Agent');
// Default page size for agent browsing // Default page size for agent browsing
const DEFAULT_PAGE_SIZE = 6; const DEFAULT_PAGE_SIZE = 6;
/** const getAgentsPagedByAccess = async (
* Common pagination utility for agent queries userId,
* requiredPermission,
* @param {Object} filter - MongoDB filter object filter,
* @param {number} page - Page number (1-based) limit = DEFAULT_PAGE_SIZE,
* @param {number} limit - Items per page cursor,
* @returns {Promise<Object>} Paginated results with agents and pagination info ) => {
*/ const accessibleIds = await findAccessibleResources({
const paginateAgents = async (filter, page = 1, limit = DEFAULT_PAGE_SIZE) => { userId,
const skip = (page - 1) * limit; resourceType: 'agent',
requiredPermissions: requiredPermission,
// Get total count for pagination });
const total = await Agent.countDocuments(filter); const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent',
// Get agents with pagination requiredPermissions: requiredPermission,
const agents = await Agent.find(filter) });
.select('id name description avatar category support_contact authorName') // Use the new ACL-aware function
.sort({ updatedAt: -1 }) const data = await getListAgentsByAccess({
.skip(skip) accessibleIds,
.limit(limit) otherParams: filter,
.lean(); limit,
after: cursor,
// Calculate if there are more agents to load });
const hasMore = total > page * limit; if (data?.data?.length) {
data.data = data.data.map((agent) => {
return { if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
agents, agent.isPublic = true;
pagination: { }
current: page, return agent;
hasMore,
total,
},
};
};
/**
* Get promoted/top picks agents with pagination
* Can also return all agents when showAll=true parameter is provided
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getPromotedAgents = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
// Check if this is a request for "all" agents via query parameter
const showAllAgents = req.query.showAll === 'true';
// Base filter for shared agents only
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
// Only add promoted filter if not requesting all agents
if (!showAllAgents) {
filter.is_promoted = true; // Only get promoted agents
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json(result);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching promoted agents:', error);
res.status(500).json({
error: 'Failed to fetch promoted agents',
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
}); });
} }
return data;
}; };
/** /**
* Get agents by category with pagination * Unified marketplace agents endpoint with query string controls
* Query parameters:
* - category: string (filter by specific category - if undefined, no category filter applied)
* - search: string (search term for name/description)
* - limit: number (page size, default 6)
* - cursor: base64 string (for cursor-based pagination)
* - promoted: 0|1 (filter promoted agents, 1=promoted only, 0=exclude promoted)
* - requiredPermission: number (permission level required to access agents, default 1)
* *
* @param {Object} req - Express request object * @param {Object} req - Express request object
* @param {Object} res - Express response object * @param {Object} res - Express response object
*/ */
const getAgentsByCategory = async (req, res) => { const getMarketplaceAgents = async (req, res) => {
try { try {
const { category } = req.params; const {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
const filter = {
category, category,
projectIds: { $exists: true, $ne: [] }, // Only get shared agents search,
}; limit = DEFAULT_PAGE_SIZE,
cursor,
promoted,
requiredPermission = 1,
} = req.query;
const result = await paginateAgents(filter, page, limit); const parsedLimit = parseInt(limit) || DEFAULT_PAGE_SIZE;
const parsedRequiredPermission = parseInt(requiredPermission) || 1;
// Get category description from database // Base filter
const categoryDoc = await findCategoryByValue(category); const filter = {};
const categoryInfo = {
name: category,
description: categoryDoc?.description || '',
total: result.pagination.total,
};
res.status(200).json({ // Handle category filter - only apply if category is defined
...result, if (category !== undefined && category.trim() !== '') {
category: categoryInfo,
});
} catch (error) {
logger.error(
`[/Agents/Marketplace] Error fetching agents for category ${req.params.category}:`,
error,
);
res.status(500).json({
error: 'Failed to fetch agents by category',
userMessage: `Unable to load agents for this category. Please try a different category.`,
suggestion: 'Try selecting a different category or refresh the page',
});
}
};
/**
* Search agents with filters
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const searchAgents = async (req, res) => {
try {
const { q, category } = req.query;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
if (!q || q.trim() === '') {
return res.status(400).json({
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
});
}
// Build search filter
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
$or: [
{ name: { $regex: q, $options: 'i' } }, // Case-insensitive name search
{ description: { $regex: q, $options: 'i' } },
],
};
// Add category filter if provided
if (category && category !== 'all') {
filter.category = category; filter.category = category;
} }
const result = await paginateAgents(filter, page, limit); // Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
res.status(200).json({ // Handle search filter
...result, if (search && search.trim() !== '') {
query: q, filter.$or = [
}); { name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Use ACL-aware function for proper permission handling
const result = await getAgentsPagedByAccess(
req.user.id,
parsedRequiredPermission, // Required permission as number
filter,
parsedLimit,
cursor,
);
// Add category info if category was specified
if (category !== undefined && category.trim() !== '') {
const categoryDoc = await findCategoryByValue(category);
result.category = {
name: category,
description: categoryDoc?.description || '',
total: result.pagination?.total || result.data?.length || 0,
};
}
// Add search info if search was performed
if (search && search.trim() !== '') {
result.query = search.trim();
}
res.status(200).json(result);
} catch (error) { } catch (error) {
logger.error('[/Agents/Marketplace] Error searching agents:', error); logger.error('[/Agents/Marketplace] Error fetching marketplace agents:', error);
res.status(500).json({ res.status(500).json({
error: 'Failed to search agents', error: 'Failed to fetch marketplace agents',
userMessage: 'Search is temporarily unavailable. Please try again.', userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try a different search term or check your network connection', suggestion: 'Try refreshing the page or check your network connection',
}); });
} }
}; };
@ -187,7 +144,6 @@ const getAgentCategories = async (_req, res) => {
// Get count of promoted agents for Top Picks // Get count of promoted agents for Top Picks
const promotedCount = await Agent.countDocuments({ const promotedCount = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
is_promoted: true, is_promoted: true,
}); });
@ -233,23 +189,7 @@ const getAgentCategories = async (_req, res) => {
} }
}; };
/**
* Get all agents with pagination (for "all" category)
* This is an alias for getPromotedAgents with showAll=true for backwards compatibility
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAllAgents = async (req, res) => {
// Set showAll parameter and delegate to getPromotedAgents
req.query.showAll = 'true';
return getPromotedAgents(req, res);
};
module.exports = { module.exports = {
getPromotedAgents, getMarketplaceAgents,
getAgentsByCategory,
searchAgents,
getAgentCategories, getAgentCategories,
getAllAgents,
}; };

View file

@ -389,16 +389,16 @@ const deleteAgentHandler = async (req, res) => {
const getListAgentsHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const requiredPermission = req.query.requiredPermission || PermissionBits.VIEW;
// Get agent IDs the user has VIEW access to via ACL // Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({ const accessibleIds = await findAccessibleResources({
userId, userId,
resourceType: 'agent', resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW, requiredPermissions: requiredPermission,
}); });
const publiclyAccessibleIds = await findPubliclyAccessibleResources({ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent', resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW, requiredPermissions: requiredPermission,
}); });
// Use the new ACL-aware function // Use the new ACL-aware function
const data = await getListAgentsByAccess({ const data = await getListAgentsByAccess({

View file

@ -20,6 +20,8 @@ router.use(requireJwtAuth);
router.use(checkBan); router.use(checkBan);
router.use(uaParser); router.use(uaParser);
router.use('/marketplace', marketplace);
router.use('/', v1); router.use('/', v1);
const chatRouter = express.Router(); const chatRouter = express.Router();
@ -39,6 +41,5 @@ chatRouter.use('/', chat);
router.use('/chat', chatRouter); router.use('/chat', chatRouter);
// Add marketplace routes // Add marketplace routes
router.use('/marketplace', marketplace);
module.exports = router; module.exports = router;

View file

@ -13,34 +13,23 @@ router.use(checkBan);
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
router.use(checkAgentAccess); router.use(checkAgentAccess);
/**
* Unified marketplace agents endpoint with query string controls
* Query parameters:
* - category: string (filter by category, or 'all' for all agents, 'promoted' for promoted)
* - search: string (search term for name/description)
* - limit: number (page size, default 6)
* - cursor: base64 string (for cursor-based pagination)
* - promoted: 0|1 (filter promoted agents, 1=promoted only, 0=exclude promoted)
* - requiredPermission: number (permission level required to access agents, default 1)
* @route GET /agents/marketplace
*/
router.get('/', marketplace.getMarketplaceAgents);
/** /**
* Get all agent categories with counts * Get all agent categories with counts
* @route GET /agents/marketplace/categories * @route GET /agents/marketplace/categories
*/ */
router.get('/categories', marketplace.getAgentCategories); router.get('/categories', marketplace.getAgentCategories);
/**
* Get promoted/top picks agents with pagination
* @route GET /agents/marketplace/promoted
*/
router.get('/promoted', marketplace.getPromotedAgents);
/**
* Get all agents with pagination (for "all" category)
* @route GET /agents/marketplace/all
*/
router.get('/all', marketplace.getAllAgents);
/**
* Search agents with filters
* @route GET /agents/marketplace/search
*/
router.get('/search', marketplace.searchAgents);
/**
* Get agents by category with pagination
* @route GET /agents/marketplace/category/:category
*/
router.get('/category/:category', marketplace.getAgentsByCategory);
module.exports = router; module.exports = router;

View file

@ -4,6 +4,7 @@
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit",
"data-provider": "cd .. && npm run build:data-provider", "data-provider": "cd .. && npm run build:data-provider",
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1", "build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs", "build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",

View file

@ -1,5 +1,10 @@
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider'; import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import type { Agent, AgentProvider, AgentModelParameters } from 'librechat-data-provider'; import type {
Agent,
AgentProvider,
AgentModelParameters,
SupportContact,
} from 'librechat-data-provider';
import type { OptionWithIcon, ExtendedFile } from './types'; import type { OptionWithIcon, ExtendedFile } from './types';
export type TAgentOption = OptionWithIcon & export type TAgentOption = OptionWithIcon &
@ -18,11 +23,6 @@ export type TAgentCapabilities = {
[AgentCapabilities.hide_sequential_outputs]?: boolean; [AgentCapabilities.hide_sequential_outputs]?: boolean;
}; };
export type SupportContact = {
name?: string;
email?: string;
};
export type AgentForm = { export type AgentForm = {
agent?: TAgentOption; agent?: TAgentOption;
id: string; id: string;
@ -37,4 +37,5 @@ export type AgentForm = {
[AgentCapabilities.artifacts]?: ArtifactModes | string; [AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number; recursion_limit?: number;
support_contact?: SupportContact; support_contact?: SupportContact;
category: string;
} & TAgentCapabilities; } & TAgentCapabilities;

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
useFormContext, useFormContext,
@ -10,7 +10,6 @@ import {
} from 'react-hook-form'; } from 'react-hook-form';
import ControlCombobox from '~/components/ui/ControlCombobox'; import ControlCombobox from '~/components/ui/ControlCombobox';
import { useAgentCategories } from '~/hooks/Agents'; import { useAgentCategories } from '~/hooks/Agents';
import { OptionWithIcon } from '~/common/types';
import { cn } from '~/utils'; import { cn } from '~/utils';
/** /**
@ -20,7 +19,9 @@ const useCategorySync = (agent_id: string | null) => {
const [handled, setHandled] = useState(false); const [handled, setHandled] = useState(false);
return { return {
syncCategory: (field: ControllerRenderProps<FieldValues, FieldPath<FieldValues>>) => { syncCategory: <T extends FieldPath<FieldValues>>(
field: ControllerRenderProps<FieldValues, T>,
) => {
// Only run once and only for new agents // Only run once and only for new agents
if (!handled && agent_id === '' && !field.value) { if (!handled && agent_id === '' && !field.value) {
field.onChange('general'); field.onChange('general');
@ -33,7 +34,7 @@ const useCategorySync = (agent_id: string | null) => {
/** /**
* A component for selecting agent categories with form validation * A component for selecting agent categories with form validation
*/ */
const AgentCategorySelector: React.FC = () => { const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const formContext = useFormContext(); const formContext = useFormContext();
const { categories } = useAgentCategories(); const { categories } = useAgentCategories();
@ -81,7 +82,7 @@ const AgentCategorySelector: React.FC = () => {
field.onChange(value); field.onChange(value);
}} }}
items={comboboxItems} items={comboboxItems}
className="" className={cn(className)}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
isCollapsed={false} isCollapsed={false}
showCarat={true} showCarat={true}

View file

@ -2,7 +2,8 @@ import React, { useState } from 'react';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useDynamicAgentQuery, useAgentCategories } from '~/hooks/Agents'; import { useGetMarketplaceAgentsQuery } from 'librechat-data-provider/react-query';
import { useAgentCategories } from '~/hooks/Agents';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { Button } from '~/components/ui'; import { Button } from '~/components/ui';
import { Spinner } from '~/components/svg'; import { Spinner } from '~/components/svg';
@ -17,42 +18,73 @@ interface AgentGridProps {
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
} }
// Interface for the actual data structure returned by the API
interface AgentGridData {
agents: t.Agent[];
pagination?: {
hasMore: boolean;
current: number;
total: number;
};
}
/** /**
* Component for displaying a grid of agent cards * Component for displaying a grid of agent cards
*/ */
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => { const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
const localize = useLocalize(); const localize = useLocalize();
const [page, setPage] = useState(1); const [cursor, setCursor] = useState<string | undefined>(undefined);
const [allAgents, setAllAgents] = useState<t.Agent[]>([]);
// Get category data from API // Get category data from API
const { categories } = useAgentCategories(); const { categories } = useAgentCategories();
// Single dynamic query that handles all cases - much cleaner! // Build query parameters based on current state
const { const queryParams = React.useMemo(() => {
data: rawData, const params: {
isLoading, requiredPermission: number;
error, category?: string;
isFetching, search?: string;
refetch, limit: number;
} = useDynamicAgentQuery({ cursor?: string;
category, promoted?: 0 | 1;
searchQuery, } = {
page, requiredPermission: 1, // Read permission for marketplace viewing
limit: 6, limit: 6,
}); };
// Type the data properly if (cursor) {
const data = rawData as AgentGridData | undefined; params.cursor = cursor;
}
// Handle search
if (searchQuery) {
params.search = searchQuery;
// Include category filter for search if it's not 'all' or 'promoted'
if (category !== 'all' && category !== 'promoted') {
params.category = category;
}
} else {
// Handle category-based queries
if (category === 'promoted') {
params.promoted = 1;
} else if (category !== 'all') {
params.category = category;
}
// For 'all' category, no additional filters needed
}
return params;
}, [category, searchQuery, cursor]);
// Single unified query that handles all cases
const { data, isLoading, error, isFetching, refetch } = useGetMarketplaceAgentsQuery(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 || [];
// Check if we have meaningful data to prevent unnecessary loading states // Check if we have meaningful data to prevent unnecessary loading states
const hasData = useHasData(data); const hasData = useHasData(data);
@ -82,14 +114,17 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
* Load more agents when "See More" button is clicked * Load more agents when "See More" button is clicked
*/ */
const handleLoadMore = () => { const handleLoadMore = () => {
setPage((prevPage) => prevPage + 1); if (data?.after) {
setCursor(data.after);
}
}; };
/** /**
* Reset page when category or search changes * Reset cursor and agents when category or search changes
*/ */
React.useEffect(() => { React.useEffect(() => {
setPage(1); setCursor(undefined);
setAllAgents([]);
}, [category, searchQuery]); }, [category, searchQuery]);
/** /**
@ -163,7 +198,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
<h2 <h2
className="text-xl font-bold text-gray-900 dark:text-white" className="text-xl font-bold text-gray-900 dark:text-white"
id={`category-heading-${category}`} id={`category-heading-${category}`}
aria-label={`${getGridTitle()}, ${data?.agents?.length || 0} agents available`} aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
> >
{getGridTitle()} {getGridTitle()}
</h2> </h2>
@ -171,7 +206,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)} )}
{/* Handle empty results with enhanced accessibility */} {/* Handle empty results with enhanced accessibility */}
{(!data?.agents || data.agents.length === 0) && !isLoading && !isFetching ? ( {(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
<div <div
className="py-12 text-center text-gray-500" className="py-12 text-center text-gray-500"
role="status" role="status"
@ -198,22 +233,22 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
{/* Announcement for screen readers */} {/* Announcement for screen readers */}
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true"> <div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
{localize('com_agents_grid_announcement', { {localize('com_agents_grid_announcement', {
count: data?.agents?.length || 0, count: currentAgents?.length || 0,
category: getCategoryDisplayName(category), category: getCategoryDisplayName(category),
})} })}
</div> </div>
{/* Agent grid - 2 per row with proper semantic structure */} {/* Agent grid - 2 per row with proper semantic structure */}
{data?.agents && data.agents.length > 0 && ( {currentAgents && currentAgents.length > 0 && (
<div <div
className="grid grid-cols-1 gap-6 md:grid-cols-2" className="grid grid-cols-1 gap-6 md:grid-cols-2"
role="grid" role="grid"
aria-label={localize('com_agents_grid_announcement', { aria-label={localize('com_agents_grid_announcement', {
count: data.agents.length, count: currentAgents.length,
category: getCategoryDisplayName(category), category: getCategoryDisplayName(category),
})} })}
> >
{data.agents.map((agent: t.Agent, index: number) => ( {currentAgents.map((agent: t.Agent, index: number) => (
<div key={`${agent.id}-${index}`} role="gridcell"> <div key={`${agent.id}-${index}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} /> <AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
</div> </div>
@ -222,7 +257,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)} )}
{/* Loading indicator when fetching more with accessibility */} {/* Loading indicator when fetching more with accessibility */}
{isFetching && page > 1 && ( {isFetching && cursor && (
<div <div
className="flex justify-center py-4" className="flex justify-center py-4"
role="status" role="status"
@ -235,7 +270,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)} )}
{/* Load more button with enhanced accessibility */} {/* Load more button with enhanced accessibility */}
{data?.pagination?.hasMore && !isFetching && ( {data?.has_more && !isFetching && (
<div className="mt-8 flex justify-center"> <div className="mt-8 flex justify-center">
<Button <Button
variant="outline" variant="outline"

View file

@ -6,7 +6,8 @@ import { useSetRecoilState, useRecoilValue } from 'recoil';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import type { ContextType } from '~/common'; import type { ContextType } from '~/common';
import { useGetAgentCategoriesQuery, useGetEndpointsQuery } from '~/data-provider'; import { useGetEndpointsQuery } from '~/data-provider';
import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query';
import { useDocumentTitle } from '~/hooks'; import { useDocumentTitle } from '~/hooks';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { TooltipAnchor, Button } from '~/components/ui'; import { TooltipAnchor, Button } from '~/components/ui';

View file

@ -1,7 +1,6 @@
import { AgentListResponse } from 'librechat-data-provider';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { cn } from '~/utils';
interface SmartLoaderProps { interface SmartLoaderProps {
/** Whether the content is currently loading */ /** Whether the content is currently loading */
isLoading: boolean; isLoading: boolean;
@ -69,7 +68,7 @@ export const SmartLoader: React.FC<SmartLoaderProps> = ({
* Hook to determine if we have meaningful data to show * Hook to determine if we have meaningful data to show
* Helps prevent loading states when we already have cached content * Helps prevent loading states when we already have cached content
*/ */
export const useHasData = (data: unknown): boolean => { export const useHasData = (data: AgentListResponse | undefined): boolean => {
if (!data) return false; if (!data) return false;
// Type guard for object data // Type guard for object data

View file

@ -2,12 +2,17 @@ import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid'; import AgentGrid from '../AgentGrid';
import { useDynamicAgentQuery } from '~/hooks/Agents'; import { useGetMarketplaceAgentsQuery } from 'librechat-data-provider/react-query';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
// Mock the dynamic agent query hook // Mock the marketplace agent query hook
jest.mock('~/hooks/Agents', () => ({ jest.mock('~/hooks/Agents', () => ({
useDynamicAgentQuery: jest.fn(), useGetMarketplaceAgentsQuery: jest.fn(),
useAgentCategories: jest.fn(() => ({
categories: [],
isLoading: false,
error: null,
})),
})); }));
// Mock useLocalize hook // Mock useLocalize hook
@ -22,59 +27,83 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
com_agents_error_searching: 'Error searching agents', com_agents_error_searching: 'Error searching agents',
com_agents_no_results: 'No agents found. Try another search term.', com_agents_no_results: 'No agents found. Try another search term.',
com_agents_none_in_category: 'No agents found in this category', com_agents_none_in_category: 'No agents found in this category',
com_agents_search_empty_heading: 'No results found',
com_agents_empty_state_heading: 'No agents available',
com_agents_loading: 'Loading...',
com_agents_grid_announcement: '{{count}} agents in {{category}}',
com_agents_load_more_label: 'Load more agents from {{category}}',
}; };
return mockTranslations[key] || key; return mockTranslations[key] || key.replace(/{{(\w+)}}/g, (match, key) => `[${key}]`);
}); });
// Mock getCategoryDisplayName and getCategoryDescription // Mock SmartLoader components
jest.mock('~/utils/agents', () => ({ jest.mock('../SmartLoader', () => ({
getCategoryDisplayName: (category: string) => { SmartLoader: ({ children, isLoading }: { children: React.ReactNode; isLoading: boolean }) =>
const names: Record<string, string> = { isLoading ? <div>Loading...</div> : <div>{children}</div>,
promoted: 'Top Picks', useHasData: (data: any) => !!data?.agents?.length,
all: 'All',
general: 'General',
hr: 'HR',
finance: 'Finance',
};
return names[category] || category;
},
getCategoryDescription: (category: string) => {
const descriptions: Record<string, string> = {
promoted: 'Our recommended agents',
all: 'Browse all available agents',
general: 'General purpose agents',
hr: 'HR agents',
finance: 'Finance agents',
};
return descriptions[category] || '';
},
})); }));
const mockUseDynamicAgentQuery = useDynamicAgentQuery as jest.MockedFunction< // Mock ErrorDisplay component
typeof useDynamicAgentQuery jest.mock('../ErrorDisplay', () => ({
__esModule: true,
default: ({ error, onRetry }: { error: string; onRetry: () => void }) => (
<div>
<div>Error: {error}</div>
<button onClick={onRetry}>Retry</button>
</div>
),
}));
// Mock AgentCard component
jest.mock('../AgentCard', () => ({
__esModule: true,
default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => (
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>
),
}));
const mockUseGetMarketplaceAgentsQuery = useGetMarketplaceAgentsQuery as jest.MockedFunction<
typeof useGetMarketplaceAgentsQuery
>; >;
describe('AgentGrid Integration with useDynamicAgentQuery', () => { describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
const mockOnSelectAgent = jest.fn(); const mockOnSelectAgent = jest.fn();
const mockAgents: Partial<t.Agent>[] = [ const mockAgents: t.Agent[] = [
{ {
id: '1', id: '1',
name: 'Test Agent 1', name: 'Test Agent 1',
description: 'First test agent', description: 'First test agent',
avatar: '/avatar1.png', avatar: { filepath: '/avatar1.png', source: 'local' },
category: 'finance',
authorName: 'Author 1',
created_at: 1672531200000,
instructions: null,
provider: 'custom',
model: 'gpt-4',
model_parameters: {},
}, },
{ {
id: '2', id: '2',
name: 'Test Agent 2', name: 'Test Agent 2',
description: 'Second test agent', description: 'Second test agent',
avatar: { filepath: '/avatar2.png' }, avatar: { filepath: '/avatar2.png', source: 'local' },
category: 'finance',
authorName: 'Author 2',
created_at: 1672531200000,
instructions: null,
provider: 'custom',
model: 'gpt-4',
model_parameters: {},
}, },
]; ];
const defaultMockQueryResult = { const defaultMockQueryResult = {
data: { data: {
agents: mockAgents, data: mockAgents,
pagination: { pagination: {
current: 1, current: 1,
hasMore: true, hasMore: true,
@ -84,256 +113,168 @@ describe('AgentGrid Integration with useDynamicAgentQuery', () => {
isLoading: false, isLoading: false,
error: null, error: null,
isFetching: false, isFetching: false,
queryType: 'promoted' as const, refetch: jest.fn(),
isSuccess: true,
isError: false,
status: 'success' as const,
}; };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult); mockUseGetMarketplaceAgentsQuery.mockReturnValue(defaultMockQueryResult);
}); });
describe('Query Integration', () => { describe('Query Integration', () => {
it('should call useDynamicAgentQuery with correct parameters', () => { it('should call useGetMarketplaceAgentsQuery with correct parameters for category search', () => {
render( render(
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />, <AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
); );
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({ expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({
requiredPermission: 1,
category: 'finance', category: 'finance',
searchQuery: 'test query', search: 'test query',
page: 1,
limit: 6, limit: 6,
}); });
}); });
it('should update page when "See More" is clicked', async () => { it('should call useGetMarketplaceAgentsQuery with promoted=1 for promoted category', () => {
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
const seeMoreButton = screen.getByText('See more');
fireEvent.click(seeMoreButton);
await waitFor(() => {
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({
category: 'hr',
searchQuery: '',
page: 2,
limit: 6,
});
});
});
it('should reset page when category changes', () => {
const { rerender } = render(
<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
);
// Simulate clicking "See More" to increment page
const seeMoreButton = screen.getByText('See more');
fireEvent.click(seeMoreButton);
// Change category - should reset page to 1
rerender(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
category: 'finance',
searchQuery: '',
page: 1,
limit: 6,
});
});
it('should reset page when search query changes', () => {
const { rerender } = render(
<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
);
// Change search query - should reset page to 1
rerender(
<AgentGrid category="hr" searchQuery="new search" onSelectAgent={mockOnSelectAgent} />,
);
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
category: 'hr',
searchQuery: 'new search',
page: 1,
limit: 6,
});
});
});
describe('Different Query Types Display', () => {
it('should display correct title for promoted category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'promoted',
});
render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />); render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Top Picks')).toBeInTheDocument(); expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({
expect(screen.getByText('Our recommended agents')).toBeInTheDocument(); requiredPermission: 1,
promoted: 1,
limit: 6,
});
}); });
it('should display correct title for search results', () => { it('should call useGetMarketplaceAgentsQuery without category filter for "all" category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'search',
});
render(
<AgentGrid category="all" searchQuery="test search" onSelectAgent={mockOnSelectAgent} />,
);
expect(screen.getByText('Results for "test search"')).toBeInTheDocument();
});
it('should display correct title for specific category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'category',
});
render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Finance')).toBeInTheDocument();
expect(screen.getByText('Finance agents')).toBeInTheDocument();
});
it('should display correct title for all category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'all',
});
render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />); render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('All')).toBeInTheDocument(); expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({
expect(screen.getByText('Browse all available agents')).toBeInTheDocument(); requiredPermission: 1,
limit: 6,
});
});
it('should not include category in search when category is "all" or "promoted"', () => {
render(<AgentGrid category="all" searchQuery="test" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({
requiredPermission: 1,
search: 'test',
limit: 6,
});
}); });
}); });
describe('Loading and Error States', () => { describe('Agent Display', () => {
it('should show loading skeleton when isLoading is true and no data', () => { it('should render agent cards when data is available', () => {
mockUseDynamicAgentQuery.mockReturnValue({ render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
...defaultMockQueryResult,
data: undefined,
isLoading: true,
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
expect(screen.getByTestId('agent-card-2')).toBeInTheDocument();
// Should show loading skeletons
const loadingElements = screen.getAllByRole('generic');
const hasLoadingClass = loadingElements.some((el) => el.className.includes('animate-pulse'));
expect(hasLoadingClass).toBe(true);
});
it('should show error message when there is an error', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: undefined,
error: new Error('Test error'),
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Error loading agents')).toBeInTheDocument();
});
it('should show loading spinner when fetching more data', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
isFetching: true,
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
// Should show agents and loading spinner for pagination
expect(screen.getByText('Test Agent 1')).toBeInTheDocument(); expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
expect(screen.getByText('Test Agent 2')).toBeInTheDocument(); expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
}); });
});
describe('Agent Interaction', () => {
it('should call onSelectAgent when agent card is clicked', () => { it('should call onSelectAgent when agent card is clicked', () => {
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
const agentCard = screen.getByLabelText('Test Agent 1 agent card');
fireEvent.click(agentCard);
fireEvent.click(screen.getByTestId('agent-card-1'));
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]); expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
}); });
}); });
describe('Pagination', () => { describe('Loading States', () => {
it('should show "See More" button when hasMore is true', () => { it('should show loading state when isLoading is true', () => {
mockUseDynamicAgentQuery.mockReturnValue({ mockUseGetMarketplaceAgentsQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { isLoading: true,
agents: mockAgents, data: undefined,
pagination: {
current: 1,
hasMore: true,
total: 10,
},
},
}); });
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('See more')).toBeInTheDocument(); expect(screen.getByText('Loading...')).toBeInTheDocument();
}); });
it('should not show "See More" button when hasMore is false', () => { it('should show empty state when no agents are available', () => {
mockUseDynamicAgentQuery.mockReturnValue({ mockUseGetMarketplaceAgentsQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { data: { data: [], pagination: { current: 1, hasMore: false, total: 0 } },
agents: mockAgents,
pagination: {
current: 1,
hasMore: false,
total: 2,
},
},
}); });
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.queryByText('See more')).not.toBeInTheDocument(); expect(screen.getByText('No agents available')).toBeInTheDocument();
}); });
}); });
describe('Empty States', () => { describe('Error Handling', () => {
it('should show empty state for search results', () => { it('should show error display when query has error', () => {
mockUseDynamicAgentQuery.mockReturnValue({ const mockError = new Error('Failed to fetch agents');
mockUseGetMarketplaceAgentsQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { error: mockError,
agents: [], isError: true,
pagination: { current: 1, hasMore: false, total: 0 }, data: undefined,
}, });
queryType: 'search',
render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Error: Failed to fetch agents')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});
describe('Search Results', () => {
it('should show search results title when searching', () => {
render(
<AgentGrid category="finance" searchQuery="automation" onSelectAgent={mockOnSelectAgent} />,
);
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
});
it('should show empty search results message', () => {
mockUseGetMarketplaceAgentsQuery.mockReturnValue({
...defaultMockQueryResult,
data: { data: [], pagination: { current: 1, hasMore: false, total: 0 } },
}); });
render( render(
<AgentGrid category="all" searchQuery="no results" onSelectAgent={mockOnSelectAgent} />, <AgentGrid
category="finance"
searchQuery="nonexistent"
onSelectAgent={mockOnSelectAgent}
/>,
); );
expect(screen.getByText('No results found')).toBeInTheDocument();
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument(); expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
}); });
});
it('should show empty state for category with no agents', () => { describe('Load More Functionality', () => {
mockUseDynamicAgentQuery.mockReturnValue({ it('should show "See more" button when hasMore is true', () => {
render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByRole('button', { name: 'See more' })).toBeInTheDocument();
});
it('should not show "See more" button when hasMore is false', () => {
mockUseGetMarketplaceAgentsQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { data: {
agents: [], ...defaultMockQueryResult.data,
pagination: { current: 1, hasMore: false, total: 0 }, pagination: { current: 1, hasMore: false, total: 2 },
}, },
queryType: 'category',
}); });
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />); render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('No agents found in this category')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'See more' })).not.toBeInTheDocument();
}); });
}); });
}); });

View file

@ -76,143 +76,6 @@ export const useGetAgentByIdQuery = (
); );
}; };
/**
* MARKETPLACE QUERIES
*/
/**
* Hook for getting all agent categories with counts
*/
export const useGetAgentCategoriesQuery = <TData = t.TMarketplaceCategory[]>(
config?: UseQueryOptions<t.TMarketplaceCategory[], unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.TMarketplaceCategory[], unknown, TData>(
[QueryKeys.agents, 'categories'],
() => dataService.getAgentCategories(),
{
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for getting promoted/top picks agents with pagination
*/
export const useGetPromotedAgentsQuery = <TData = t.AgentListResponse>(
params: { page?: number; limit?: number; showAll?: string } = { page: 1, limit: 6 },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'promoted', params],
() => dataService.getPromotedAgents(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for getting all agents with pagination (for "all" category)
*/
export const useGetAllAgentsQuery = <TData = t.AgentListResponse>(
params: { page?: number; limit?: number } = { page: 1, limit: 6 },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'all', params],
() => dataService.getAllAgents(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for getting agents by category with pagination
*/
export const useGetAgentsByCategoryQuery = <TData = t.AgentListResponse>(
params: { category: string; page?: number; limit?: number },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'category', params],
() => dataService.getAgentsByCategory(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for searching agents with pagination and filtering
*/
export const useSearchAgentsQuery = <TData = t.AgentListResponse>(
params: { q: string; category?: string; page?: number; limit?: number },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents] && !!params.q;
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'search', params],
() => dataService.searchAgents(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/** /**
* Hook for retrieving full agent details including sensitive configuration (EDIT permission) * Hook for retrieving full agent details including sensitive configuration (EDIT permission)
*/ */

View file

@ -1,360 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useDynamicAgentQuery } from '../useDynamicAgentQuery';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
// Mock the data provider queries
jest.mock('~/data-provider', () => ({
useGetPromotedAgentsQuery: jest.fn(),
useGetAgentsByCategoryQuery: jest.fn(),
useSearchAgentsQuery: jest.fn(),
}));
const mockUseGetPromotedAgentsQuery = useGetPromotedAgentsQuery as jest.MockedFunction<
typeof useGetPromotedAgentsQuery
>;
const mockUseGetAgentsByCategoryQuery = useGetAgentsByCategoryQuery as jest.MockedFunction<
typeof useGetAgentsByCategoryQuery
>;
const mockUseSearchAgentsQuery = useSearchAgentsQuery as jest.MockedFunction<
typeof useSearchAgentsQuery
>;
describe('useDynamicAgentQuery', () => {
const defaultMockQueryResult = {
data: undefined,
isLoading: false,
error: null,
isFetching: false,
refetch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Set default mock returns
mockUseGetPromotedAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseGetAgentsByCategoryQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseSearchAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
});
describe('Search Query Type', () => {
it('should use search query when searchQuery is provided', () => {
const mockSearchResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseSearchAgentsQuery.mockReturnValue(mockSearchResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
// Should call search query with correct parameters
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
category: 'hr',
page: 1,
limit: 6,
},
expect.objectContaining({
enabled: true,
staleTime: 120000,
refetchOnWindowFocus: false,
keepPreviousData: true,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 1,
}),
);
// Should return search query result
expect(result.current.data).toBe(mockSearchResult.data);
expect(result.current.queryType).toBe('search');
});
it('should not include category in search when category is "all" or "promoted"', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
page: 1,
limit: 6,
// No category parameter should be included
},
expect.any(Object),
);
});
});
describe('Promoted Query Type', () => {
it('should use promoted query when category is "promoted" and no search', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '',
page: 2,
limit: 8,
}),
);
// Should call promoted query with correct parameters (no showAll)
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 2,
limit: 8,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockPromotedResult.data);
expect(result.current.queryType).toBe('promoted');
});
});
describe('All Agents Query Type', () => {
it('should use promoted query with showAll when category is "all" and no search', () => {
const mockAllResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
// Mock the second call to useGetPromotedAgentsQuery (for "all" category)
mockUseGetPromotedAgentsQuery
.mockReturnValueOnce(defaultMockQueryResult as any) // First call for promoted
.mockReturnValueOnce(mockAllResult as any); // Second call for all
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should call promoted query with showAll parameter
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 1,
limit: 6,
showAll: 'true',
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.queryType).toBe('all');
});
});
describe('Category Query Type', () => {
it('should use category query for specific categories', () => {
const mockCategoryResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockCategoryResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'finance',
searchQuery: '',
page: 3,
limit: 10,
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'finance',
page: 3,
limit: 10,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockCategoryResult.data);
expect(result.current.queryType).toBe('category');
});
});
describe('Query Configuration', () => {
it('should apply correct query configuration to all queries', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
const expectedConfig = expect.objectContaining({
staleTime: 120000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
});
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expectedConfig,
);
});
it('should enable only the correct query based on query type', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Category query should be enabled
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
// Other queries should be disabled
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
});
describe('Default Parameters', () => {
it('should use default page and limit when not provided', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'general',
searchQuery: '',
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'general',
page: 1,
limit: 6,
},
expect.any(Object),
);
});
});
describe('Return Values', () => {
it('should return all necessary query properties', () => {
const mockResult = {
data: { agents: [{ id: '1', name: 'Test Agent' }] },
isLoading: true,
error: null,
isFetching: false,
refetch: jest.fn(),
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'it',
searchQuery: '',
page: 1,
limit: 6,
}),
);
expect(result.current).toEqual({
data: mockResult.data,
isLoading: mockResult.isLoading,
error: mockResult.error,
isFetching: mockResult.isFetching,
refetch: mockResult.refetch,
queryType: 'category',
});
});
});
describe('Edge Cases', () => {
it('should handle empty search query as no search', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '', // Empty string should not trigger search
page: 1,
limit: 6,
}),
);
// Should use promoted query, not search query
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
it('should fallback to promoted query for unknown query types', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [] },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'unknown-category',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should determine this as 'category' type and use category query
expect(result.current.queryType).toBe('category');
});
});
});

View file

@ -1,5 +1,4 @@
export { default as useAgentsMap } from './useAgentsMap'; export { default as useAgentsMap } from './useAgentsMap';
export { default as useSelectAgent } from './useSelectAgent'; export { default as useSelectAgent } from './useSelectAgent';
export { default as useAgentCategories } from './useAgentCategories'; export { default as useAgentCategories } from './useAgentCategories';
export { useDynamicAgentQuery } from './useDynamicAgentQuery';
export type { ProcessedAgentCategory } from './useAgentCategories'; export type { ProcessedAgentCategory } from './useAgentCategories';

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { useGetAgentCategoriesQuery } from '~/data-provider'; import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query';
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// This interface matches the structure used by the ControlCombobox component // This interface matches the structure used by the ControlCombobox component

View file

@ -1,112 +0,0 @@
import { useMemo } from 'react';
import type { UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
interface UseDynamicAgentQueryParams {
category: string;
searchQuery: string;
page?: number;
limit?: number;
}
/**
* Single dynamic query hook that replaces 4 separate conditional queries
* Determines the appropriate query based on category and search state
*/
export const useDynamicAgentQuery = ({
category,
searchQuery,
page = 1,
limit = 6,
}: UseDynamicAgentQueryParams) => {
// Shared query configuration optimized to prevent unnecessary loading states
const queryConfig: UseQueryOptions<t.AgentListResponse> = useMemo(
() => ({
staleTime: 1000 * 60 * 2, // 2 minutes - agents don't change frequently
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
// Removed placeholderData due to TypeScript compatibility - keepPreviousData is sufficient
}),
[],
);
// Determine query type and parameters based on current state
const queryType = useMemo(() => {
if (searchQuery) return 'search';
if (category === 'promoted') return 'promoted';
if (category === 'all') return 'all';
return 'category';
}, [category, searchQuery]);
// Search query - when user is searching
const searchQuery_result = useSearchAgentsQuery(
{
q: searchQuery,
...(category !== 'all' && category !== 'promoted' && { category }),
page,
limit,
},
{
...queryConfig,
enabled: queryType === 'search',
},
);
// Promoted agents query - for "Top Picks" tab
const promotedQuery = useGetPromotedAgentsQuery(
{ page, limit },
{
...queryConfig,
enabled: queryType === 'promoted',
},
);
// All agents query - for "All" tab (promoted endpoint with showAll parameter)
const allAgentsQuery = useGetPromotedAgentsQuery(
{ page, limit, showAll: 'true' },
{
...queryConfig,
enabled: queryType === 'all',
},
);
// Category-specific query - for individual categories
const categoryQuery = useGetAgentsByCategoryQuery(
{ category, page, limit },
{
...queryConfig,
enabled: queryType === 'category',
},
);
// Return the active query based on current state
const activeQuery = useMemo(() => {
switch (queryType) {
case 'search':
return searchQuery_result;
case 'promoted':
return promotedQuery;
case 'all':
return allAgentsQuery;
case 'category':
return categoryQuery;
default:
return promotedQuery; // fallback
}
}, [queryType, searchQuery_result, promotedQuery, allAgentsQuery, categoryQuery]);
return {
...activeQuery,
queryType, // Expose query type for debugging/logging
};
};

View file

@ -455,65 +455,19 @@ export const getAgentCategories = (): Promise<t.TMarketplaceCategory[]> => {
}; };
/** /**
* Get promoted/top picks agents with pagination * Unified marketplace agents endpoint with query string controls
*/ */
export const getPromotedAgents = (params: { export const getMarketplaceAgents = (params: {
page?: number; requiredPermission: number;
limit?: number;
showAll?: string; // Add showAll parameter to get all shared agents instead of just promoted
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/promoted',
options: params,
}),
);
};
/**
* Get all agents with pagination (for "all" category)
*/
export const getAllAgents = (params: {
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/all',
options: params,
}),
);
};
/**
* Get agents by category with pagination
*/
export const getAgentsByCategory = (params: {
category: string;
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
const { category, ...options } = params;
return request.get(
endpoints.agents({
path: `marketplace/category/${category}`,
options,
}),
);
};
/**
* Search agents in marketplace
*/
export const searchAgents = (params: {
q: string;
category?: string; category?: string;
page?: number; search?: string;
limit?: number; limit?: number;
cursor?: string;
promoted?: 0 | 1;
}): Promise<a.AgentListResponse> => { }): Promise<a.AgentListResponse> => {
return request.get( return request.get(
endpoints.agents({ endpoints.agents({
path: 'marketplace/search', path: 'marketplace',
options: params, options: params,
}), }),
); );

View file

@ -44,6 +44,8 @@ import * as dataService from './data-service';
export * from './utils'; export * from './utils';
export * from './actions'; export * from './actions';
export { default as createPayload } from './createPayload'; export { default as createPayload } from './createPayload';
// /* react query hooks */
// export * from './react-query/react-query-service';
/* feedback */ /* feedback */
export * from './feedback'; export * from './feedback';
export * from './parameterSettings'; export * from './parameterSettings';

View file

@ -41,6 +41,8 @@ export enum QueryKeys {
promptGroup = 'promptGroup', promptGroup = 'promptGroup',
categories = 'categories', categories = 'categories',
randomPrompts = 'randomPrompts', randomPrompts = 'randomPrompts',
agentCategories = 'agentCategories',
marketplaceAgents = 'marketplaceAgents',
roles = 'roles', roles = 'roles',
conversationTags = 'conversationTags', conversationTags = 'conversationTags',
health = 'health', health = 'health',

View file

@ -12,6 +12,7 @@ import * as q from '../types/queries';
import { QueryKeys } from '../keys'; import { QueryKeys } from '../keys';
import * as s from '../schemas'; import * as s from '../schemas';
import * as t from '../types'; import * as t from '../types';
import * as a from '../types/assistants';
import * as permissions from '../accessPermissions'; import * as permissions from '../accessPermissions';
export { hasPermissions } from '../accessPermissions'; export { hasPermissions } from '../accessPermissions';
@ -450,3 +451,52 @@ export const useGetEffectivePermissionsQuery = (
...config, ...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,
},
);
};

View file

@ -196,6 +196,10 @@ export interface AgentFileResource extends AgentBaseResource {
*/ */
vector_store_ids?: Array<string>; vector_store_ids?: Array<string>;
} }
export type SupportContact = {
name?: string;
email?: string;
};
export type Agent = { export type Agent = {
_id?: string; _id?: string;
@ -228,6 +232,8 @@ export type Agent = {
recursion_limit?: number; recursion_limit?: number;
isPublic?: boolean; isPublic?: boolean;
version?: number; version?: number;
category?: string;
support_contact?: SupportContact;
}; };
export type TAgentsMap = Record<string, Agent | undefined>; export type TAgentsMap = Record<string, Agent | undefined>;
@ -244,7 +250,13 @@ export type AgentCreateParams = {
model_parameters: AgentModelParameters; model_parameters: AgentModelParameters;
} & Pick< } & Pick<
Agent, Agent,
'agent_ids' | 'end_after_tools' | 'hide_sequential_outputs' | 'artifacts' | 'recursion_limit' | 'agent_ids'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'artifacts'
| 'recursion_limit'
| 'category'
| 'support_contact'
>; >;
export type AgentUpdateParams = { export type AgentUpdateParams = {
@ -263,7 +275,13 @@ export type AgentUpdateParams = {
isCollaborative?: boolean; isCollaborative?: boolean;
} & Pick< } & Pick<
Agent, Agent,
'agent_ids' | 'end_after_tools' | 'hide_sequential_outputs' | 'artifacts' | 'recursion_limit' | 'agent_ids'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'artifacts'
| 'recursion_limit'
| 'category'
| 'support_contact'
>; >;
export type AgentListParams = { export type AgentListParams = {
@ -272,6 +290,7 @@ export type AgentListParams = {
after?: string | null; after?: string | null;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
provider?: AgentProvider; provider?: AgentProvider;
requiredPermission?: number;
}; };
export type AgentListResponse = { export type AgentListResponse = {
@ -280,6 +299,7 @@ export type AgentListResponse = {
first_id: string; first_id: string;
last_id: string; last_id: string;
has_more: boolean; has_more: boolean;
after?: string;
}; };
export type AgentFile = { export type AgentFile = {