From 2eef94d58d412ba4a21073822203a364bf249545 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 23 Jun 2025 11:42:24 -0400 Subject: [PATCH] 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 --- api/models/Agent.js | 5 +- api/server/controllers/agents/marketplace.js | 262 +++++------- api/server/controllers/agents/v1.js | 6 +- api/server/routes/agents/index.js | 3 +- api/server/routes/agents/marketplace.js | 37 +- client/package.json | 1 + client/src/common/agents-types.ts | 13 +- .../Agents/AgentCategorySelector.tsx | 11 +- .../components/SidePanel/Agents/AgentGrid.tsx | 111 +++-- .../SidePanel/Agents/AgentMarketplace.tsx | 3 +- .../SidePanel/Agents/SmartLoader.tsx | 5 +- .../__tests__/AgentGrid.integration.spec.tsx | 379 ++++++++---------- client/src/data-provider/Agents/queries.ts | 137 ------- .../__tests__/useDynamicAgentQuery.spec.ts | 360 ----------------- client/src/hooks/Agents/index.ts | 1 - .../src/hooks/Agents/useAgentCategories.tsx | 2 +- .../src/hooks/Agents/useDynamicAgentQuery.ts | 112 ------ packages/data-provider/src/data-service.ts | 60 +-- packages/data-provider/src/index.ts | 2 + packages/data-provider/src/keys.ts | 2 + .../src/react-query/react-query-service.ts | 50 +++ .../data-provider/src/types/assistants.ts | 24 +- 22 files changed, 458 insertions(+), 1128 deletions(-) delete mode 100644 client/src/hooks/Agents/__tests__/useDynamicAgentQuery.spec.ts delete mode 100644 client/src/hooks/Agents/useDynamicAgentQuery.ts diff --git a/api/models/Agent.js b/api/models/Agent.js index ce6d41b76e..cfc010f886 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -486,7 +486,7 @@ const getListAgentsByAccess = async ({ const cursorCondition = { $or: [ { 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, description: 1, updatedAt: 1, + category: 1, + support_contact: 1, + is_promoted: 1, }).sort({ updatedAt: -1, _id: 1 }); // Only apply limit if pagination is requested diff --git a/api/server/controllers/agents/marketplace.js b/api/server/controllers/agents/marketplace.js index a6ae22bc96..ce348ee4cd 100644 --- a/api/server/controllers/agents/marketplace.js +++ b/api/server/controllers/agents/marketplace.js @@ -1,175 +1,132 @@ const mongoose = require('mongoose'); const { logger } = require('~/config'); const { findCategoryByValue, getCategoriesWithCounts } = require('~/models'); - +const { getListAgentsByAccess } = require('~/models/Agent'); +const { + findAccessibleResources, + findPubliclyAccessibleResources, +} = require('~/server/services/PermissionService'); // Get the Agent model const Agent = mongoose.model('Agent'); // Default page size for agent browsing const DEFAULT_PAGE_SIZE = 6; -/** - * Common pagination utility for agent queries - * - * @param {Object} filter - MongoDB filter object - * @param {number} page - Page number (1-based) - * @param {number} limit - Items per page - * @returns {Promise} Paginated results with agents and pagination info - */ -const paginateAgents = async (filter, page = 1, limit = DEFAULT_PAGE_SIZE) => { - const skip = (page - 1) * limit; - - // Get total count for pagination - const total = await Agent.countDocuments(filter); - - // Get agents with pagination - const agents = await Agent.find(filter) - .select('id name description avatar category support_contact authorName') - .sort({ updatedAt: -1 }) - .skip(skip) - .limit(limit) - .lean(); - - // Calculate if there are more agents to load - const hasMore = total > page * limit; - - return { - agents, - pagination: { - current: page, - 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', +const getAgentsPagedByAccess = async ( + userId, + requiredPermission, + filter, + limit = DEFAULT_PAGE_SIZE, + cursor, +) => { + const accessibleIds = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: requiredPermission, + }); + const publiclyAccessibleIds = await findPubliclyAccessibleResources({ + resourceType: 'agent', + requiredPermissions: requiredPermission, + }); + // Use the new ACL-aware function + const data = await getListAgentsByAccess({ + accessibleIds, + otherParams: filter, + limit, + after: cursor, + }); + if (data?.data?.length) { + data.data = data.data.map((agent) => { + if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) { + agent.isPublic = true; + } + return agent; }); } + 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} res - Express response object */ -const getAgentsByCategory = async (req, res) => { +const getMarketplaceAgents = async (req, res) => { try { - const { category } = req.params; - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE; - - const filter = { + const { 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 - const categoryDoc = await findCategoryByValue(category); - const categoryInfo = { - name: category, - description: categoryDoc?.description || '', - total: result.pagination.total, - }; + // Base filter + const filter = {}; - res.status(200).json({ - ...result, - 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') { + // Handle category filter - only apply if category is defined + if (category !== undefined && category.trim() !== '') { 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({ - ...result, - query: q, - }); + // Handle search filter + if (search && search.trim() !== '') { + 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) { - logger.error('[/Agents/Marketplace] Error searching agents:', error); + logger.error('[/Agents/Marketplace] Error fetching marketplace agents:', error); res.status(500).json({ - error: 'Failed to search agents', - userMessage: 'Search is temporarily unavailable. Please try again.', - suggestion: 'Try a different search term or check your network connection', + error: 'Failed to fetch marketplace agents', + userMessage: 'Unable to load agents. Please try refreshing the page.', + 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 const promotedCount = await Agent.countDocuments({ - projectIds: { $exists: true, $ne: [] }, 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 = { - getPromotedAgents, - getAgentsByCategory, - searchAgents, + getMarketplaceAgents, getAgentCategories, - getAllAgents, }; diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index ebdf329343..4734ccf697 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -389,16 +389,16 @@ const deleteAgentHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => { try { const userId = req.user.id; - + const requiredPermission = req.query.requiredPermission || PermissionBits.VIEW; // Get agent IDs the user has VIEW access to via ACL const accessibleIds = await findAccessibleResources({ userId, resourceType: 'agent', - requiredPermissions: PermissionBits.VIEW, + requiredPermissions: requiredPermission, }); const publiclyAccessibleIds = await findPubliclyAccessibleResources({ resourceType: 'agent', - requiredPermissions: PermissionBits.VIEW, + requiredPermissions: requiredPermission, }); // Use the new ACL-aware function const data = await getListAgentsByAccess({ diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 7c9423cf18..5427128a38 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -20,6 +20,8 @@ router.use(requireJwtAuth); router.use(checkBan); router.use(uaParser); +router.use('/marketplace', marketplace); + router.use('/', v1); const chatRouter = express.Router(); @@ -39,6 +41,5 @@ chatRouter.use('/', chat); router.use('/chat', chatRouter); // Add marketplace routes -router.use('/marketplace', marketplace); module.exports = router; diff --git a/api/server/routes/agents/marketplace.js b/api/server/routes/agents/marketplace.js index 5733ffed03..0f563b7add 100644 --- a/api/server/routes/agents/marketplace.js +++ b/api/server/routes/agents/marketplace.js @@ -13,34 +13,23 @@ router.use(checkBan); const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); 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 * @route GET /agents/marketplace/categories */ 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; diff --git a/client/package.json b/client/package.json index 67cbec2820..5cc80a40b0 100644 --- a/client/package.json +++ b/client/package.json @@ -4,6 +4,7 @@ "description": "", "type": "module", "scripts": { + "typecheck": "tsc --noEmit", "data-provider": "cd .. && npm run build:data-provider", "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", diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 320538fc27..a49586b8a0 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -1,5 +1,10 @@ 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'; export type TAgentOption = OptionWithIcon & @@ -18,11 +23,6 @@ export type TAgentCapabilities = { [AgentCapabilities.hide_sequential_outputs]?: boolean; }; -export type SupportContact = { - name?: string; - email?: string; -}; - export type AgentForm = { agent?: TAgentOption; id: string; @@ -37,4 +37,5 @@ export type AgentForm = { [AgentCapabilities.artifacts]?: ArtifactModes | string; recursion_limit?: number; support_contact?: SupportContact; + category: string; } & TAgentCapabilities; diff --git a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx index 7aacf22d31..3547be46ae 100644 --- a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx +++ b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useFormContext, @@ -10,7 +10,6 @@ import { } from 'react-hook-form'; import ControlCombobox from '~/components/ui/ControlCombobox'; import { useAgentCategories } from '~/hooks/Agents'; -import { OptionWithIcon } from '~/common/types'; import { cn } from '~/utils'; /** @@ -20,7 +19,9 @@ const useCategorySync = (agent_id: string | null) => { const [handled, setHandled] = useState(false); return { - syncCategory: (field: ControllerRenderProps>) => { + syncCategory: >( + field: ControllerRenderProps, + ) => { // Only run once and only for new agents if (!handled && agent_id === '' && !field.value) { field.onChange('general'); @@ -33,7 +34,7 @@ const useCategorySync = (agent_id: string | null) => { /** * A component for selecting agent categories with form validation */ -const AgentCategorySelector: React.FC = () => { +const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => { const { t } = useTranslation(); const formContext = useFormContext(); const { categories } = useAgentCategories(); @@ -81,7 +82,7 @@ const AgentCategorySelector: React.FC = () => { field.onChange(value); }} items={comboboxItems} - className="" + className={cn(className)} ariaLabel={ariaLabel} isCollapsed={false} showCarat={true} diff --git a/client/src/components/SidePanel/Agents/AgentGrid.tsx b/client/src/components/SidePanel/Agents/AgentGrid.tsx index d9915c4c2f..f2ff2ae7e6 100644 --- a/client/src/components/SidePanel/Agents/AgentGrid.tsx +++ b/client/src/components/SidePanel/Agents/AgentGrid.tsx @@ -2,7 +2,8 @@ import React, { useState } from 'react'; 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 { Button } from '~/components/ui'; import { Spinner } from '~/components/svg'; @@ -17,42 +18,73 @@ interface AgentGridProps { 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 */ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAgent }) => { const localize = useLocalize(); - const [page, setPage] = useState(1); + const [cursor, setCursor] = useState(undefined); + const [allAgents, setAllAgents] = useState([]); // Get category data from API const { categories } = useAgentCategories(); - // Single dynamic query that handles all cases - much cleaner! - const { - data: rawData, - isLoading, - error, - isFetching, - refetch, - } = useDynamicAgentQuery({ - category, - searchQuery, - page, - limit: 6, - }); + // Build query parameters based on current state + const queryParams = React.useMemo(() => { + const params: { + requiredPermission: number; + category?: string; + search?: string; + limit: number; + cursor?: string; + promoted?: 0 | 1; + } = { + requiredPermission: 1, // Read permission for marketplace viewing + limit: 6, + }; - // Type the data properly - const data = rawData as AgentGridData | undefined; + if (cursor) { + 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 const hasData = useHasData(data); @@ -82,14 +114,17 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg * Load more agents when "See More" button is clicked */ 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(() => { - setPage(1); + setCursor(undefined); + setAllAgents([]); }, [category, searchQuery]); /** @@ -163,7 +198,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg

{getGridTitle()}

@@ -171,7 +206,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg )} {/* Handle empty results with enhanced accessibility */} - {(!data?.agents || data.agents.length === 0) && !isLoading && !isFetching ? ( + {(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
= ({ category, searchQuery, onSelectAg {/* Announcement for screen readers */}
{localize('com_agents_grid_announcement', { - count: data?.agents?.length || 0, + count: currentAgents?.length || 0, category: getCategoryDisplayName(category), })}
{/* Agent grid - 2 per row with proper semantic structure */} - {data?.agents && data.agents.length > 0 && ( + {currentAgents && currentAgents.length > 0 && (
- {data.agents.map((agent: t.Agent, index: number) => ( + {currentAgents.map((agent: t.Agent, index: number) => (
onSelectAgent(agent)} />
@@ -222,7 +257,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg )} {/* Loading indicator when fetching more with accessibility */} - {isFetching && page > 1 && ( + {isFetching && cursor && (
= ({ category, searchQuery, onSelectAg )} {/* Load more button with enhanced accessibility */} - {data?.pagination?.hasMore && !isFetching && ( + {data?.has_more && !isFetching && (
+
+ ), +})); + +// Mock AgentCard component +jest.mock('../AgentCard', () => ({ + __esModule: true, + default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => ( +
+

{agent.name}

+

{agent.description}

+
+ ), +})); + +const mockUseGetMarketplaceAgentsQuery = useGetMarketplaceAgentsQuery as jest.MockedFunction< + typeof useGetMarketplaceAgentsQuery >; -describe('AgentGrid Integration with useDynamicAgentQuery', () => { +describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { const mockOnSelectAgent = jest.fn(); - const mockAgents: Partial[] = [ + const mockAgents: t.Agent[] = [ { id: '1', name: 'Test Agent 1', 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', name: 'Test Agent 2', 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 = { data: { - agents: mockAgents, + data: mockAgents, pagination: { current: 1, hasMore: true, @@ -84,256 +113,168 @@ describe('AgentGrid Integration with useDynamicAgentQuery', () => { isLoading: false, error: null, isFetching: false, - queryType: 'promoted' as const, + refetch: jest.fn(), + isSuccess: true, + isError: false, + status: 'success' as const, }; beforeEach(() => { jest.clearAllMocks(); - mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult); + mockUseGetMarketplaceAgentsQuery.mockReturnValue(defaultMockQueryResult); }); describe('Query Integration', () => { - it('should call useDynamicAgentQuery with correct parameters', () => { + it('should call useGetMarketplaceAgentsQuery with correct parameters for category search', () => { render( , ); - expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({ + expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({ + requiredPermission: 1, category: 'finance', - searchQuery: 'test query', - page: 1, + search: 'test query', limit: 6, }); }); - it('should update page when "See More" is clicked', async () => { - render(); - - 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( - , - ); - - // Simulate clicking "See More" to increment page - const seeMoreButton = screen.getByText('See more'); - fireEvent.click(seeMoreButton); - - // Change category - should reset page to 1 - rerender(); - - expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({ - category: 'finance', - searchQuery: '', - page: 1, - limit: 6, - }); - }); - - it('should reset page when search query changes', () => { - const { rerender } = render( - , - ); - - // Change search query - should reset page to 1 - rerender( - , - ); - - 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', - }); - + it('should call useGetMarketplaceAgentsQuery with promoted=1 for promoted category', () => { render(); - expect(screen.getByText('Top Picks')).toBeInTheDocument(); - expect(screen.getByText('Our recommended agents')).toBeInTheDocument(); + expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({ + requiredPermission: 1, + promoted: 1, + limit: 6, + }); }); - it('should display correct title for search results', () => { - mockUseDynamicAgentQuery.mockReturnValue({ - ...defaultMockQueryResult, - queryType: 'search', - }); - - render( - , - ); - - expect(screen.getByText('Results for "test search"')).toBeInTheDocument(); - }); - - it('should display correct title for specific category', () => { - mockUseDynamicAgentQuery.mockReturnValue({ - ...defaultMockQueryResult, - queryType: 'category', - }); - - render(); - - expect(screen.getByText('Finance')).toBeInTheDocument(); - expect(screen.getByText('Finance agents')).toBeInTheDocument(); - }); - - it('should display correct title for all category', () => { - mockUseDynamicAgentQuery.mockReturnValue({ - ...defaultMockQueryResult, - queryType: 'all', - }); - + it('should call useGetMarketplaceAgentsQuery without category filter for "all" category', () => { render(); - expect(screen.getByText('All')).toBeInTheDocument(); - expect(screen.getByText('Browse all available agents')).toBeInTheDocument(); + expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({ + requiredPermission: 1, + limit: 6, + }); + }); + + it('should not include category in search when category is "all" or "promoted"', () => { + render(); + + expect(mockUseGetMarketplaceAgentsQuery).toHaveBeenCalledWith({ + requiredPermission: 1, + search: 'test', + limit: 6, + }); }); }); - describe('Loading and Error States', () => { - it('should show loading skeleton when isLoading is true and no data', () => { - mockUseDynamicAgentQuery.mockReturnValue({ - ...defaultMockQueryResult, - data: undefined, - isLoading: true, - }); + describe('Agent Display', () => { + it('should render agent cards when data is available', () => { + render(); - render(); - - // 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(); - - expect(screen.getByText('Error loading agents')).toBeInTheDocument(); - }); - - it('should show loading spinner when fetching more data', () => { - mockUseDynamicAgentQuery.mockReturnValue({ - ...defaultMockQueryResult, - isFetching: true, - }); - - render(); - - // Should show agents and loading spinner for pagination + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); expect(screen.getByText('Test Agent 1')).toBeInTheDocument(); expect(screen.getByText('Test Agent 2')).toBeInTheDocument(); }); - }); - describe('Agent Interaction', () => { it('should call onSelectAgent when agent card is clicked', () => { - render(); - - const agentCard = screen.getByLabelText('Test Agent 1 agent card'); - fireEvent.click(agentCard); + render(); + fireEvent.click(screen.getByTestId('agent-card-1')); expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]); }); }); - describe('Pagination', () => { - it('should show "See More" button when hasMore is true', () => { - mockUseDynamicAgentQuery.mockReturnValue({ + describe('Loading States', () => { + it('should show loading state when isLoading is true', () => { + mockUseGetMarketplaceAgentsQuery.mockReturnValue({ ...defaultMockQueryResult, - data: { - agents: mockAgents, - pagination: { - current: 1, - hasMore: true, - total: 10, - }, - }, + isLoading: true, + data: undefined, }); - render(); + render(); - expect(screen.getByText('See more')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); - it('should not show "See More" button when hasMore is false', () => { - mockUseDynamicAgentQuery.mockReturnValue({ + it('should show empty state when no agents are available', () => { + mockUseGetMarketplaceAgentsQuery.mockReturnValue({ ...defaultMockQueryResult, - data: { - agents: mockAgents, - pagination: { - current: 1, - hasMore: false, - total: 2, - }, - }, + data: { data: [], pagination: { current: 1, hasMore: false, total: 0 } }, }); - render(); + render(); - expect(screen.queryByText('See more')).not.toBeInTheDocument(); + expect(screen.getByText('No agents available')).toBeInTheDocument(); }); }); - describe('Empty States', () => { - it('should show empty state for search results', () => { - mockUseDynamicAgentQuery.mockReturnValue({ + describe('Error Handling', () => { + it('should show error display when query has error', () => { + const mockError = new Error('Failed to fetch agents'); + mockUseGetMarketplaceAgentsQuery.mockReturnValue({ ...defaultMockQueryResult, - data: { - agents: [], - pagination: { current: 1, hasMore: false, total: 0 }, - }, - queryType: 'search', + error: mockError, + isError: true, + data: undefined, + }); + + render(); + + 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( + , + ); + + 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( - , + , ); + expect(screen.getByText('No results found')).toBeInTheDocument(); expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument(); }); + }); - it('should show empty state for category with no agents', () => { - mockUseDynamicAgentQuery.mockReturnValue({ + describe('Load More Functionality', () => { + it('should show "See more" button when hasMore is true', () => { + render(); + + expect(screen.getByRole('button', { name: 'See more' })).toBeInTheDocument(); + }); + + it('should not show "See more" button when hasMore is false', () => { + mockUseGetMarketplaceAgentsQuery.mockReturnValue({ ...defaultMockQueryResult, data: { - agents: [], - pagination: { current: 1, hasMore: false, total: 0 }, + ...defaultMockQueryResult.data, + pagination: { current: 1, hasMore: false, total: 2 }, }, - queryType: 'category', }); - render(); + render(); - expect(screen.getByText('No agents found in this category')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'See more' })).not.toBeInTheDocument(); }); }); }); diff --git a/client/src/data-provider/Agents/queries.ts b/client/src/data-provider/Agents/queries.ts index a00ba93b1a..5a46652e67 100644 --- a/client/src/data-provider/Agents/queries.ts +++ b/client/src/data-provider/Agents/queries.ts @@ -76,143 +76,6 @@ export const useGetAgentByIdQuery = ( ); }; -/** - * MARKETPLACE QUERIES - */ - -/** - * Hook for getting all agent categories with counts - */ -export const useGetAgentCategoriesQuery = ( - config?: UseQueryOptions, -): QueryObserverResult => { - const queryClient = useQueryClient(); - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - - const enabled = !!endpointsConfig?.[EModelEndpoint.agents]; - return useQuery( - [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 = ( - params: { page?: number; limit?: number; showAll?: string } = { page: 1, limit: 6 }, - config?: UseQueryOptions, -): QueryObserverResult => { - const queryClient = useQueryClient(); - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - - const enabled = !!endpointsConfig?.[EModelEndpoint.agents]; - return useQuery( - [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 = ( - params: { page?: number; limit?: number } = { page: 1, limit: 6 }, - config?: UseQueryOptions, -): QueryObserverResult => { - const queryClient = useQueryClient(); - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - - const enabled = !!endpointsConfig?.[EModelEndpoint.agents]; - return useQuery( - [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 = ( - params: { category: string; page?: number; limit?: number }, - config?: UseQueryOptions, -): QueryObserverResult => { - const queryClient = useQueryClient(); - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - - const enabled = !!endpointsConfig?.[EModelEndpoint.agents]; - return useQuery( - [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 = ( - params: { q: string; category?: string; page?: number; limit?: number }, - config?: UseQueryOptions, -): QueryObserverResult => { - const queryClient = useQueryClient(); - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - - const enabled = !!endpointsConfig?.[EModelEndpoint.agents] && !!params.q; - return useQuery( - [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) */ diff --git a/client/src/hooks/Agents/__tests__/useDynamicAgentQuery.spec.ts b/client/src/hooks/Agents/__tests__/useDynamicAgentQuery.spec.ts deleted file mode 100644 index 3391636364..0000000000 --- a/client/src/hooks/Agents/__tests__/useDynamicAgentQuery.spec.ts +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/client/src/hooks/Agents/index.ts b/client/src/hooks/Agents/index.ts index 145554806a..a4f970cd8c 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -1,5 +1,4 @@ export { default as useAgentsMap } from './useAgentsMap'; export { default as useSelectAgent } from './useSelectAgent'; export { default as useAgentCategories } from './useAgentCategories'; -export { useDynamicAgentQuery } from './useDynamicAgentQuery'; export type { ProcessedAgentCategory } from './useAgentCategories'; diff --git a/client/src/hooks/Agents/useAgentCategories.tsx b/client/src/hooks/Agents/useAgentCategories.tsx index 5f921458a9..08d024e359 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 '~/data-provider'; +import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query'; import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; // This interface matches the structure used by the ControlCombobox component diff --git a/client/src/hooks/Agents/useDynamicAgentQuery.ts b/client/src/hooks/Agents/useDynamicAgentQuery.ts deleted file mode 100644 index 0e957d4168..0000000000 --- a/client/src/hooks/Agents/useDynamicAgentQuery.ts +++ /dev/null @@ -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 = 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 - }; -}; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 56e709cb2f..3c538b69bc 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -455,65 +455,19 @@ export const getAgentCategories = (): Promise => { }; /** - * Get promoted/top picks agents with pagination + * Unified marketplace agents endpoint with query string controls */ -export const getPromotedAgents = (params: { - page?: number; - limit?: number; - showAll?: string; // Add showAll parameter to get all shared agents instead of just promoted -}): Promise => { - 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 => { - 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 => { - const { category, ...options } = params; - return request.get( - endpoints.agents({ - path: `marketplace/category/${category}`, - options, - }), - ); -}; - -/** - * Search agents in marketplace - */ -export const searchAgents = (params: { - q: string; +export const getMarketplaceAgents = (params: { + requiredPermission: number; category?: string; - page?: number; + search?: string; limit?: number; + cursor?: string; + promoted?: 0 | 1; }): Promise => { return request.get( endpoints.agents({ - path: 'marketplace/search', + path: 'marketplace', options: params, }), ); diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 8173a7b878..d77cf6fab7 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -44,6 +44,8 @@ import * as dataService from './data-service'; export * from './utils'; export * from './actions'; export { default as createPayload } from './createPayload'; +// /* react query hooks */ +// export * from './react-query/react-query-service'; /* feedback */ export * from './feedback'; export * from './parameterSettings'; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index e87250caf8..f02717dc4f 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -41,6 +41,8 @@ export enum QueryKeys { promptGroup = 'promptGroup', categories = 'categories', randomPrompts = 'randomPrompts', + agentCategories = 'agentCategories', + marketplaceAgents = 'marketplaceAgents', roles = 'roles', conversationTags = 'conversationTags', health = 'health', 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 682bde2cde..f4f507aa56 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -12,6 +12,7 @@ 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'; @@ -450,3 +451,52 @@ 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, + }, + ); +}; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index ce33b65450..63239c92b3 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -196,6 +196,10 @@ export interface AgentFileResource extends AgentBaseResource { */ vector_store_ids?: Array; } +export type SupportContact = { + name?: string; + email?: string; +}; export type Agent = { _id?: string; @@ -228,6 +232,8 @@ export type Agent = { recursion_limit?: number; isPublic?: boolean; version?: number; + category?: string; + support_contact?: SupportContact; }; export type TAgentsMap = Record; @@ -244,7 +250,13 @@ export type AgentCreateParams = { model_parameters: AgentModelParameters; } & Pick< 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 = { @@ -263,7 +275,13 @@ export type AgentUpdateParams = { isCollaborative?: boolean; } & Pick< 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 = { @@ -272,6 +290,7 @@ export type AgentListParams = { after?: string | null; order?: 'asc' | 'desc'; provider?: AgentProvider; + requiredPermission?: number; }; export type AgentListResponse = { @@ -280,6 +299,7 @@ export type AgentListResponse = { first_id: string; last_id: string; has_more: boolean; + after?: string; }; export type AgentFile = {