diff --git a/api/models/Agent.js b/api/models/Agent.js index ce6d41b76e..8a08f6ea2b 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -178,54 +178,116 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul let isMatch = true; for (const field of importantFields) { - if (!wouldBeVersion[field] && !lastVersion[field]) { + const wouldBeValue = wouldBeVersion[field]; + const lastVersionValue = lastVersion[field]; + + // Skip if both are undefined/null + if (!wouldBeValue && !lastVersionValue) { continue; } - if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) { - if (wouldBeVersion[field].length !== lastVersion[field].length) { + // Handle arrays + if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) { + // Normalize: treat undefined/null as empty array for comparison + let wouldBeArr; + if (Array.isArray(wouldBeValue)) { + wouldBeArr = wouldBeValue; + } else if (wouldBeValue == null) { + wouldBeArr = []; + } else { + wouldBeArr = [wouldBeValue]; + } + + let lastVersionArr; + if (Array.isArray(lastVersionValue)) { + lastVersionArr = lastVersionValue; + } else if (lastVersionValue == null) { + lastVersionArr = []; + } else { + lastVersionArr = [lastVersionValue]; + } + + if (wouldBeArr.length !== lastVersionArr.length) { isMatch = false; break; } // Special handling for projectIds (MongoDB ObjectIds) if (field === 'projectIds') { - const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort(); - const versionIds = lastVersion[field].map((id) => id.toString()).sort(); + const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort(); + const versionIds = lastVersionArr.map((id) => id.toString()).sort(); if (!wouldBeIds.every((id, i) => id === versionIds[i])) { isMatch = false; break; } } - // Handle arrays of objects like tool_kwargs - else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) { - const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort(); - const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort(); + // Handle arrays of objects + else if ( + wouldBeArr.length > 0 && + typeof wouldBeArr[0] === 'object' && + wouldBeArr[0] !== null + ) { + const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort(); + const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort(); if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) { isMatch = false; break; } } else { - const sortedWouldBe = [...wouldBeVersion[field]].sort(); - const sortedVersion = [...lastVersion[field]].sort(); + const sortedWouldBe = [...wouldBeArr].sort(); + const sortedVersion = [...lastVersionArr].sort(); if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) { isMatch = false; break; } } - } else if (field === 'model_parameters') { - const wouldBeParams = wouldBeVersion[field] || {}; - const lastVersionParams = lastVersion[field] || {}; - if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) { + } + // Handle objects + else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) { + const lastVersionObj = + typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {}; + + // For empty objects, normalize the comparison + const wouldBeKeys = Object.keys(wouldBeValue); + const lastVersionKeys = Object.keys(lastVersionObj); + + // If both are empty objects, they're equal + if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) { + continue; + } + + // Otherwise do a deep comparison + if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) { + isMatch = false; + break; + } + } + // Handle primitive values + else { + // For primitives, handle the case where one is undefined and the other is a default value + if (wouldBeValue !== lastVersionValue) { + // Special handling for boolean false vs undefined + if ( + typeof wouldBeValue === 'boolean' && + wouldBeValue === false && + lastVersionValue === undefined + ) { + continue; + } + // Special handling for empty string vs undefined + if ( + typeof wouldBeValue === 'string' && + wouldBeValue === '' && + lastVersionValue === undefined + ) { + continue; + } isMatch = false; break; } - } else if (wouldBeVersion[field] !== lastVersion[field]) { - isMatch = false; - break; } } @@ -486,7 +548,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 +576,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 @@ -748,6 +813,14 @@ const generateActionMetadataHash = async (actionIds, actions) => { return hashHex; }; +/** + * Counts the number of promoted agents. + * @returns {Promise} - The count of promoted agents + */ +const countPromotedAgents = async () => { + const count = await Agent.countDocuments({ is_promoted: true }); + return count; +}; /** * Load a default agent based on the endpoint @@ -768,4 +841,5 @@ module.exports = { getListAgentsByAccess, removeAgentResourceFiles, generateActionMetadataHash, + countPromotedAgents, }; diff --git a/api/models/AgentCategory.js b/api/models/AgentCategory.js deleted file mode 100644 index e26e7f3f07..0000000000 --- a/api/models/AgentCategory.js +++ /dev/null @@ -1,121 +0,0 @@ -const mongoose = require('mongoose'); - -/** - * AgentCategory Schema - Dynamic agent category management - * Focused implementation for core features only - */ -const agentCategorySchema = new mongoose.Schema( - { - // Unique identifier for the category (e.g., 'general', 'hr', 'finance') - value: { - type: String, - required: true, - unique: true, - trim: true, - lowercase: true, - index: true, - }, - - // Display label for the category - label: { - type: String, - required: true, - trim: true, - }, - - // Description of the category - description: { - type: String, - trim: true, - default: '', - }, - - // Display order for sorting categories - order: { - type: Number, - default: 0, - index: true, - }, - - // Whether the category is active and should be displayed - isActive: { - type: Boolean, - default: true, - index: true, - }, - }, - { - timestamps: true, - }, -); - -// Indexes for performance -agentCategorySchema.index({ isActive: 1, order: 1 }); - -/** - * Get all active categories sorted by order - * @returns {Promise} Array of active categories - */ -agentCategorySchema.statics.getActiveCategories = function () { - return this.find({ isActive: true }).sort({ order: 1, label: 1 }).lean(); -}; - -/** - * Get categories with agent counts - * @returns {Promise} Categories with agent counts - */ -agentCategorySchema.statics.getCategoriesWithCounts = async function () { - const Agent = mongoose.model('Agent'); - - // Aggregate to get agent counts per category - const categoryCounts = await Agent.aggregate([ - { $match: { category: { $exists: true, $ne: null } } }, - { $group: { _id: '$category', count: { $sum: 1 } } }, - ]); - - // Create a map for quick lookup - const countMap = new Map(categoryCounts.map((c) => [c._id, c.count])); - - // Get all active categories and add counts - const categories = await this.getActiveCategories(); - - return categories.map((category) => ({ - ...category, - agentCount: countMap.get(category.value) || 0, - })); -}; - -/** - * Get valid category values for Agent model validation - * @returns {Promise} Array of valid category values - */ -agentCategorySchema.statics.getValidCategoryValues = function () { - return this.find({ isActive: true }).distinct('value').lean(); -}; - -/** - * Seed initial categories from existing constants - */ -agentCategorySchema.statics.seedCategories = async function (categories) { - const operations = categories.map((category, index) => ({ - updateOne: { - filter: { value: category.value }, - update: { - $setOnInsert: { - value: category.value, - label: category.label || category.value, - description: category.description || '', - order: category.order || index, - isActive: true, - }, - }, - upsert: true, - }, - })); - - return this.bulkWrite(operations); -}; - -const AgentCategory = mongoose.model('AgentCategory', agentCategorySchema); - -module.exports = AgentCategory; diff --git a/api/models/Role.js b/api/models/Role.js index d7f1c0f9cf..8f9e8810f9 100644 --- a/api/models/Role.js +++ b/api/models/Role.js @@ -2,7 +2,6 @@ const { CacheKeys, SystemRoles, roleDefaults, - PermissionTypes, permissionsSchema, removeNullishValues, } = require('librechat-data-provider'); diff --git a/api/server/controllers/agents/marketplace.js b/api/server/controllers/agents/marketplace.js deleted file mode 100644 index a4d12f3263..0000000000 --- a/api/server/controllers/agents/marketplace.js +++ /dev/null @@ -1,255 +0,0 @@ -const AgentCategory = require('~/models/AgentCategory'); -const mongoose = require('mongoose'); -const { logger } = require('~/config'); - -// 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', - }); - } -}; - -/** - * Get agents by category with pagination - * - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -const getAgentsByCategory = 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 = { - category, - projectIds: { $exists: true, $ne: [] }, // Only get shared agents - }; - - const result = await paginateAgents(filter, page, limit); - - // Get category description from database - const categoryDoc = await AgentCategory.findOne({ value: category, isActive: true }); - const categoryInfo = { - name: category, - description: categoryDoc?.description || '', - total: result.pagination.total, - }; - - 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') { - filter.category = category; - } - - const result = await paginateAgents(filter, page, limit); - - res.status(200).json({ - ...result, - query: q, - }); - } catch (error) { - logger.error('[/Agents/Marketplace] Error searching 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', - }); - } -}; - -/** - * Get all agent categories with counts - * - * @param {Object} _req - Express request object (unused) - * @param {Object} res - Express response object - */ -const getAgentCategories = async (_req, res) => { - try { - // Get categories with agent counts from database - const categories = await AgentCategory.getCategoriesWithCounts(); - - // Get count of promoted agents for Top Picks - const promotedCount = await Agent.countDocuments({ - projectIds: { $exists: true, $ne: [] }, - is_promoted: true, - }); - - // Convert to marketplace format (TCategory structure) - const formattedCategories = categories.map((category) => ({ - value: category.value, - label: category.label, - count: category.agentCount, - description: category.description, - })); - - // Add promoted category if agents exist - if (promotedCount > 0) { - formattedCategories.unshift({ - value: 'promoted', - label: 'Promoted', - count: promotedCount, - description: 'Our recommended agents', - }); - } - - // Get total count of all shared agents for "All" category - const totalAgents = await Agent.countDocuments({ - projectIds: { $exists: true, $ne: [] }, - }); - - // Add "All" category at the end - formattedCategories.push({ - value: 'all', - label: 'All', - count: totalAgents, - description: 'All available agents', - }); - - res.status(200).json(formattedCategories); - } catch (error) { - logger.error('[/Agents/Marketplace] Error fetching agent categories:', error); - res.status(500).json({ - error: 'Failed to fetch agent categories', - userMessage: 'Unable to load categories. Please refresh the page.', - suggestion: 'Try refreshing the page or check your network connection', - }); - } -}; - -/** - * 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, - getAgentCategories, - getAllAgents, -}; diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index c96eb01c70..c9a4080ea3 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -14,6 +14,8 @@ const { updateAgent, deleteAgent, getListAgentsByAccess, + countPromotedAgents, + revertAgentVersion, } = require('~/models/Agent'); const { grantPermission, @@ -27,8 +29,8 @@ const { refreshS3Url } = require('~/server/services/Files/S3/crud'); const { filterFile } = require('~/server/services/Files/process'); const { updateAction, getActions } = require('~/models/Action'); const { getCachedTools } = require('~/server/services/Config'); -const { revertAgentVersion } = require('~/models/Agent'); const { deleteFileByFilter } = require('~/models/File'); +const { getCategoriesWithCounts } = require('~/models'); const systemTools = { [Tools.execute_code]: true, @@ -391,12 +393,43 @@ const deleteAgentHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => { try { const userId = req.user.id; + const { category, search, limit, cursor, promoted } = req.query; + let requiredPermission = req.query.requiredPermission; + if (typeof requiredPermission === 'string') { + requiredPermission = parseInt(requiredPermission, 10); + if (isNaN(requiredPermission)) { + requiredPermission = PermissionBits.VIEW; + } + } else if (typeof requiredPermission !== 'number') { + requiredPermission = PermissionBits.VIEW; + } + // Base filter + const filter = {}; + // Handle category filter - only apply if category is defined + if (category !== undefined && category.trim() !== '') { + filter.category = category; + } + + // Handle promoted filter - only from query param + if (promoted === '1') { + filter.is_promoted = true; + } else if (promoted === '0') { + filter.is_promoted = { $ne: true }; + } + + // Handle search filter + if (search && search.trim() !== '') { + filter.$or = [ + { name: { $regex: search.trim(), $options: 'i' } }, + { description: { $regex: search.trim(), $options: 'i' } }, + ]; + } // 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', @@ -405,7 +438,9 @@ const getListAgentsHandler = async (req, res) => { // Use the new ACL-aware function const data = await getListAgentsByAccess({ accessibleIds, - otherParams: {}, // Can add query params here if needed + otherParams: filter, + limit, + after: cursor, }); if (data?.data?.length) { data.data = data.data.map((agent) => { @@ -572,7 +607,48 @@ const revertAgentVersionHandler = async (req, res) => { res.status(500).json({ error: error.message }); } }; +/** + * Get all agent categories with counts + * + * @param {Object} _req - Express request object (unused) + * @param {Object} res - Express response object + */ +const getAgentCategories = async (_req, res) => { + try { + const categories = await getCategoriesWithCounts(); + const promotedCount = await countPromotedAgents(); + const formattedCategories = categories.map((category) => ({ + value: category.value, + label: category.label, + count: category.agentCount, + description: category.description, + })); + if (promotedCount > 0) { + formattedCategories.unshift({ + value: 'promoted', + label: 'Promoted', + count: promotedCount, + description: 'Our recommended agents', + }); + } + + formattedCategories.push({ + value: 'all', + label: 'All', + description: 'All available agents', + }); + + res.status(200).json(formattedCategories); + } catch (error) { + logger.error('[/Agents/Marketplace] Error fetching agent categories:', error); + res.status(500).json({ + error: 'Failed to fetch agent categories', + userMessage: 'Unable to load categories. Please refresh the page.', + suggestion: 'Try refreshing the page or check your network connection', + }); + } +}; module.exports = { createAgent: createAgentHandler, getAgent: getAgentHandler, @@ -582,4 +658,5 @@ module.exports = { getListAgents: getListAgentsHandler, uploadAgentAvatar: uploadAgentAvatarHandler, revertAgentVersion: revertAgentVersionHandler, + getAgentCategories, }; diff --git a/api/server/middleware/roles/access.spec.js b/api/server/middleware/roles/access.spec.js index 435b20e9fb..66ae5e0fe2 100644 --- a/api/server/middleware/roles/access.spec.js +++ b/api/server/middleware/roles/access.spec.js @@ -1,17 +1,31 @@ const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { checkAccess, generateCheckAccess } = require('./access'); +const { checkAccess, generateCheckAccess } = require('@librechat/api'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); +const { getRoleByName } = require('~/models/Role'); const { Role } = require('~/db/models'); -// Mock only the logger -jest.mock('~/config', () => ({ +// Mock the logger from @librechat/data-schemas +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), logger: { warn: jest.fn(), error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), }, })); +// Mock the cache to use a simple in-memory implementation +const mockCache = new Map(); +jest.mock('~/cache/getLogStores', () => { + return jest.fn(() => ({ + get: jest.fn(async (key) => mockCache.get(key)), + set: jest.fn(async (key, value) => mockCache.set(key, value)), + clear: jest.fn(async () => mockCache.clear()), + })); +}); + describe('Access Middleware', () => { let mongoServer; let req, res, next; @@ -29,33 +43,86 @@ describe('Access Middleware', () => { beforeEach(async () => { await mongoose.connection.dropDatabase(); + mockCache.clear(); // Clear the cache between tests // Create test roles await Role.create({ name: 'user', permissions: { + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.PROMPTS]: { + [Permissions.SHARED_GLOBAL]: false, + [Permissions.USE]: true, + [Permissions.CREATE]: true, + }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.UPDATE]: true, + [Permissions.READ]: true, + [Permissions.OPT_OUT]: true, + }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, [Permissions.CREATE]: false, [Permissions.SHARED_GLOBAL]: false, }, + [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, + [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, + [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, + [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, }, }); await Role.create({ name: 'admin', permissions: { + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.PROMPTS]: { + [Permissions.SHARED_GLOBAL]: true, + [Permissions.USE]: true, + [Permissions.CREATE]: true, + }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.UPDATE]: true, + [Permissions.READ]: true, + [Permissions.OPT_OUT]: true, + }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARED_GLOBAL]: true, }, + [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, + [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, + [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, + [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, + }, + }); + + // Create limited role with no AGENTS permissions + await Role.create({ + name: 'limited', + permissions: { + // Explicitly set AGENTS permissions to false + [PermissionTypes.AGENTS]: { + [Permissions.USE]: false, + [Permissions.CREATE]: false, + [Permissions.SHARED_GLOBAL]: false, + }, + // Has permissions for other types + [PermissionTypes.PROMPTS]: { + [Permissions.USE]: true, + }, }, }); req = { user: { id: 'user123', role: 'user' }, body: {}, + originalUrl: '/test', }; res = { status: jest.fn().mockReturnThis(), @@ -67,92 +134,114 @@ describe('Access Middleware', () => { describe('checkAccess', () => { test('should return false if user is not provided', async () => { - const result = await checkAccess(null, PermissionTypes.AGENTS, [Permissions.USE]); + const result = await checkAccess({ + user: null, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, + }); expect(result).toBe(false); }); test('should return true if user has required permission', async () => { - const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); + const result = await checkAccess({ + req: {}, + user: { id: 'user123', role: 'user' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, + }); expect(result).toBe(true); }); test('should return false if user lacks required permission', async () => { - const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.CREATE]); + const result = await checkAccess({ + req: {}, + user: { id: 'user123', role: 'user' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.CREATE], + getRoleByName, + }); expect(result).toBe(false); }); test('should return true if user has any of multiple permissions', async () => { - const result = await checkAccess(req.user, PermissionTypes.AGENTS, [ - Permissions.USE, - Permissions.CREATE, - ]); + const result = await checkAccess({ + req: {}, + user: { id: 'user123', role: 'user' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.CREATE, Permissions.USE], + getRoleByName, + }); expect(result).toBe(true); }); test('should check body properties when permission is not directly granted', async () => { - // User role doesn't have CREATE permission, but bodyProps allows it - const bodyProps = { - [Permissions.CREATE]: ['agentId', 'name'], - }; - - const checkObject = { agentId: 'agent123' }; - - const result = await checkAccess( - req.user, - PermissionTypes.AGENTS, - [Permissions.CREATE], - bodyProps, - checkObject, - ); + const req = { body: { id: 'agent123' } }; + const result = await checkAccess({ + req, + user: { id: 'user123', role: 'user' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.UPDATE], + bodyProps: { + [Permissions.UPDATE]: ['id'], + }, + checkObject: req.body, + getRoleByName, + }); expect(result).toBe(true); }); test('should return false if role is not found', async () => { - req.user.role = 'nonexistent'; - const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); + const result = await checkAccess({ + req: {}, + user: { id: 'user123', role: 'nonexistent' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, + }); expect(result).toBe(false); }); test('should return false if role has no permissions for the requested type', async () => { - await Role.create({ - name: 'limited', - permissions: { - // Explicitly set AGENTS permissions to false - [PermissionTypes.AGENTS]: { - [Permissions.USE]: false, - [Permissions.CREATE]: false, - [Permissions.SHARED_GLOBAL]: false, - }, - // Has permissions for other types - [PermissionTypes.PROMPTS]: { - [Permissions.USE]: true, - }, - }, + const result = await checkAccess({ + req: {}, + user: { id: 'user123', role: 'limited' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, }); - req.user.role = 'limited'; - - const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); expect(result).toBe(false); }); test('should handle admin role with all permissions', async () => { - req.user.role = 'admin'; - - const createResult = await checkAccess(req.user, PermissionTypes.AGENTS, [ - Permissions.CREATE, - ]); + const createResult = await checkAccess({ + req: {}, + user: { id: 'admin123', role: 'admin' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.CREATE], + getRoleByName, + }); expect(createResult).toBe(true); - const shareResult = await checkAccess(req.user, PermissionTypes.AGENTS, [ - Permissions.SHARED_GLOBAL, - ]); + const shareResult = await checkAccess({ + req: {}, + user: { id: 'admin123', role: 'admin' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.SHARED_GLOBAL], + getRoleByName, + }); expect(shareResult).toBe(true); }); }); describe('generateCheckAccess', () => { test('should call next() when user has required permission', async () => { - const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, + }); await middleware(req, res, next); expect(next).toHaveBeenCalled(); @@ -160,7 +249,11 @@ describe('Access Middleware', () => { }); test('should return 403 when user lacks permission', async () => { - const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.CREATE]); + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.CREATE], + getRoleByName, + }); await middleware(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -175,11 +268,12 @@ describe('Access Middleware', () => { [Permissions.CREATE]: ['agentId'], }; - const middleware = generateCheckAccess( - PermissionTypes.AGENTS, - [Permissions.CREATE], + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.CREATE], bodyProps, - ); + getRoleByName, + }); await middleware(req, res, next); expect(next).toHaveBeenCalled(); @@ -187,10 +281,16 @@ describe('Access Middleware', () => { }); test('should handle database errors gracefully', async () => { - // Create a user with an invalid role that will cause getRoleByName to fail - req.user.role = { invalid: 'object' }; // This will cause an error when querying + // Mock getRoleByName to throw an error + const mockGetRoleByName = jest + .fn() + .mockRejectedValue(new Error('Database connection failed')); - const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName: mockGetRoleByName, + }); await middleware(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -203,11 +303,11 @@ describe('Access Middleware', () => { test('should work with multiple permission types', async () => { req.user.role = 'admin'; - const middleware = generateCheckAccess(PermissionTypes.AGENTS, [ - Permissions.USE, - Permissions.CREATE, - Permissions.SHARED_GLOBAL, - ]); + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL], + getRoleByName, + }); await middleware(req, res, next); expect(next).toHaveBeenCalled(); @@ -216,14 +316,16 @@ describe('Access Middleware', () => { test('should handle missing user gracefully', async () => { req.user = null; - const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, + }); await middleware(req, res, next); expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: expect.stringContaining('Server error:'), - }); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' }); }); test('should handle role with no AGENTS permissions', async () => { @@ -240,7 +342,11 @@ describe('Access Middleware', () => { }); req.user.role = 'noaccess'; - const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + const middleware = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, + }); await middleware(req, res, next); expect(next).not.toHaveBeenCalled(); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 7c9423cf18..1c4f69d9ac 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -10,7 +10,6 @@ const { const { isEnabled } = require('~/server/utils'); const { v1 } = require('./v1'); const chat = require('./chat'); -const marketplace = require('./marketplace'); const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; @@ -39,6 +38,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 deleted file mode 100644 index 5733ffed03..0000000000 --- a/api/server/routes/agents/marketplace.js +++ /dev/null @@ -1,46 +0,0 @@ -const express = require('express'); -const { requireJwtAuth, checkBan, generateCheckAccess } = require('~/server/middleware'); -const { PermissionTypes, Permissions } = require('librechat-data-provider'); -const marketplace = require('~/server/controllers/agents/marketplace'); - -const router = express.Router(); - -// Apply middleware for authentication and authorization -router.use(requireJwtAuth); -router.use(checkBan); - -// Check if user has permission to use agents -const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); -router.use(checkAgentAccess); - -/** - * 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/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 253e800edf..9073decd5d 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -45,6 +45,11 @@ router.use('/actions', actions); */ router.use('/tools', tools); +/** + * Get all agent categories with counts + * @route GET /agents/marketplace/categories + */ +router.get('/categories', v1.getAgentCategories); /** * Creates an agent. * @route POST /agents diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js index f35e706a2c..a2e19ea91d 100644 --- a/api/server/services/AppService.interface.spec.js +++ b/api/server/services/AppService.interface.spec.js @@ -1,6 +1,7 @@ jest.mock('~/models', () => ({ initializeRoles: jest.fn(), seedDefaultRoles: jest.fn(), + ensureDefaultCategories: jest.fn(), })); jest.mock('~/models/Role', () => ({ updateAccessPermissions: jest.fn(), diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index da984b2c3e..cb912d6c9e 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -17,7 +17,7 @@ const { const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants'); const { initializeAzureBlobService } = require('./Files/Azure/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize'); -const { seedDefaultRoles, initializeRoles } = require('~/models'); +const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models'); const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); const { loadDefaultInterface } = require('./start/interface'); @@ -38,6 +38,7 @@ const paths = require('~/config/paths'); const AppService = async (app) => { await initializeRoles(); await seedDefaultRoles(); + await ensureDefaultCategories(); /** @type {TCustomConfig} */ const config = (await loadCustomConfig()) ?? {}; const configDefaults = getConfigDefaults(); diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 70460574af..0bcd98c782 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -29,6 +29,7 @@ jest.mock('./Files/Firebase/initialize', () => ({ jest.mock('~/models', () => ({ initializeRoles: jest.fn(), seedDefaultRoles: jest.fn(), + ensureDefaultCategories: jest.fn(), })); jest.mock('~/models/Role', () => ({ updateAccessPermissions: jest.fn(), 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/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index 944033fcf3..4348a3bad7 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -1,17 +1,9 @@ import React, { useCallback, useContext } from 'react'; -import { useRecoilValue } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { - QueryKeys, - Constants, - EModelEndpoint, - PermissionTypes, - Permissions, -} from 'librechat-data-provider'; -import type { TMessage, TStartupConfig } from 'librechat-data-provider'; +import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg'; -import { getDefaultModelSpec, getModelSpecPreset } from '~/utils'; import { TooltipAnchor, Button } from '~/components/ui'; import { useLocalize, useNewConvo, useHasAccess } from '~/hooks'; import { AuthContext } from '~/hooks/AuthContext'; @@ -37,7 +29,6 @@ export default function NewChat({ const navigate = useNavigate(); const localize = useLocalize(); const { conversation } = store.useCreateConversationAtom(index); - const endpointsConfig = useRecoilValue(store.endpointsConfig); const authContext = useContext(AuthContext); const hasAccessToAgents = useHasAccess({ permissionType: PermissionTypes.AGENTS, diff --git a/client/src/components/SidePanel/Agents/AgentAvatar.tsx b/client/src/components/SidePanel/Agents/AgentAvatar.tsx index 80b58e787b..2c85d4853c 100644 --- a/client/src/components/SidePanel/Agents/AgentAvatar.tsx +++ b/client/src/components/SidePanel/Agents/AgentAvatar.tsx @@ -14,7 +14,12 @@ import type { AgentCreateParams, AgentListResponse, } from 'librechat-data-provider'; -import { useUploadAgentAvatarMutation, useGetFileConfig } from '~/data-provider'; +import { + useUploadAgentAvatarMutation, + useGetFileConfig, + allAgentViewAndEditQueryKeys, + invalidateAgentMarketplaceQueries, +} from '~/data-provider'; import { AgentAvatarRender, NoImage, AvatarMenu } from './Images'; import { useToastContext } from '~/Providers'; import { useLocalize } from '~/hooks'; @@ -57,30 +62,31 @@ function Avatar({ const newUrl = data.avatar?.filepath ?? ''; setPreviewUrl(newUrl); - const res = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); + ((keys) => { + keys.forEach((key) => { + const res = queryClient.getQueryData([QueryKeys.agents, key]); - if (!res?.data) { - return; - } + if (!res?.data) { + return; + } - const agents = res.data.map((agent) => { - if (agent.id === agent_id) { - return { - ...agent, - ...data, - }; - } - return agent; - }); - - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...res, - data: agents, - }); + const agents = res.data.map((agent) => { + if (agent.id === agent_id) { + return { + ...agent, + ...data, + }; + } + return agent; + }); + queryClient.setQueryData([QueryKeys.agents, key], { + ...res, + data: agents, + }); + }); + })(allAgentViewAndEditQueryKeys); + invalidateAgentMarketplaceQueries(queryClient); setProgress(1); }, onError: (error) => { 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/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index ed483825b6..6007125b9e 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -39,7 +39,10 @@ export default function AgentConfig({ const [showToolDialog, setShowToolDialog] = useState(false); const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext(); - const { control } = methods; + const { + control, + formState: { errors }, + } = methods; const provider = useWatch({ control, name: 'provider' }); const model = useWatch({ control, name: 'model' }); const agent = useWatch({ control, name: 'agent' }); @@ -190,21 +193,33 @@ export default function AgentConfig({ /> ( - + <> + +
+ {errors.name ? errors.name.message : ' '} +
+ )} /> = ({ agent, isOpen, onClose }) => const dialogRef = useRef(null); const dropdownRef = useRef(null); const [dropdownOpen, setDropdownOpen] = useState(false); - + const queryClient = useQueryClient(); // Close dropdown when clicking outside the dropdown menu useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -66,6 +59,14 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => */ const handleStartChat = () => { if (agent) { + const keys = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }]; + const listResp = queryClient.getQueryData(keys); + if (listResp != null) { + if (!listResp.data.some((a) => a.id === agent.id)) { + const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))]; + queryClient.setQueryData(keys, { ...listResp, data: currentAgents }); + } + } navigate(`/c/new?agent_id=${agent.id}`); } }; @@ -80,7 +81,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => .writeText(chatUrl) .then(() => { showToast({ - message: 'Link copied', + message: localize('com_agents_link_copied'), }); }) .catch(() => { @@ -130,7 +131,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => variant="ghost" size="icon" className="h-8 w-8 rounded-lg text-text-secondary hover:bg-surface-hover hover:text-text-primary dark:hover:bg-surface-hover" - aria-label="More options" + aria-label={localize('com_agents_more_options')} aria-expanded={dropdownOpen} aria-haspopup="menu" onClick={(e) => { diff --git a/client/src/components/SidePanel/Agents/AgentGrid.tsx b/client/src/components/SidePanel/Agents/AgentGrid.tsx index d9915c4c2f..93626f177f 100644 --- a/client/src/components/SidePanel/Agents/AgentGrid.tsx +++ b/client/src/components/SidePanel/Agents/AgentGrid.tsx @@ -1,61 +1,85 @@ -import React, { useState } from 'react'; +import React, { useMemo } from 'react'; import type t from 'librechat-data-provider'; -import { useDynamicAgentQuery, useAgentCategories } from '~/hooks/Agents'; +import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; +import { useAgentCategories } from '~/hooks/Agents'; import useLocalize from '~/hooks/useLocalize'; import { Button } from '~/components/ui'; import { Spinner } from '~/components/svg'; -import { SmartLoader, useHasData } from './SmartLoader'; +import { useHasData } from './SmartLoader'; import ErrorDisplay from './ErrorDisplay'; import AgentCard from './AgentCard'; import { cn } from '~/utils'; - +import { PERMISSION_BITS } from 'librechat-data-provider'; interface AgentGridProps { category: string; // Currently selected category searchQuery: string; // Current search query 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); // Get category data from API const { categories } = useAgentCategories(); - // Single dynamic query that handles all cases - much cleaner! + // Build query parameters based on current state + const queryParams = useMemo(() => { + const params: { + requiredPermission: number; + category?: string; + search?: string; + limit: number; + promoted?: 0 | 1; + } = { + requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing + limit: 6, + }; + + // 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]); + + // Use infinite query for marketplace agents const { - data: rawData, + data, isLoading, error, isFetching, + fetchNextPage, + hasNextPage, refetch, - } = useDynamicAgentQuery({ - category, - searchQuery, - page, - limit: 6, - }); + isFetchingNextPage, + } = useMarketplaceAgentsInfiniteQuery(queryParams); - // Type the data properly - const data = rawData as AgentGridData | undefined; + // Flatten all pages into a single array of agents + const currentAgents = useMemo(() => { + if (!data?.pages) return []; + return data.pages.flatMap((page) => page.data || []); + }, [data?.pages]); // Check if we have meaningful data to prevent unnecessary loading states - const hasData = useHasData(data); + const hasData = useHasData(data?.pages?.[0]); /** * Get category display name from API data or use fallback @@ -82,16 +106,11 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg * Load more agents when "See More" button is clicked */ const handleLoadMore = () => { - setPage((prevPage) => prevPage + 1); + if (hasNextPage && !isFetching) { + fetchNextPage(); + } }; - /** - * Reset page when category or search changes - */ - React.useEffect(() => { - setPage(1); - }, [category, searchQuery]); - /** * Get the appropriate title for the agents grid based on current state */ @@ -163,7 +182,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg

{getGridTitle()}

@@ -171,7 +190,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 +241,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg )} {/* Loading indicator when fetching more with accessibility */} - {isFetching && page > 1 && ( + {isFetching && hasNextPage && (
= ({ category, searchQuery, onSelectAg )} {/* Load more button with enhanced accessibility */} - {data?.pagination?.hasMore && !isFetching && ( + {hasNextPage && !isFetching && (
); - // Use SmartLoader to prevent unnecessary loading flashes - return ( - - {mainContent} - - ); + if (isLoading || (isFetching && !isFetchingNextPage)) { + return loadingSkeleton; + } + return mainContent; }; export default AgentGrid; diff --git a/client/src/components/SidePanel/Agents/AgentMarketplace.tsx b/client/src/components/SidePanel/Agents/AgentMarketplace.tsx index 7f0291749f..78740c7edd 100644 --- a/client/src/components/SidePanel/Agents/AgentMarketplace.tsx +++ b/client/src/components/SidePanel/Agents/AgentMarketplace.tsx @@ -6,7 +6,7 @@ import { useSetRecoilState, useRecoilValue } from 'recoil'; import type t from 'librechat-data-provider'; import type { ContextType } from '~/common'; -import { useGetAgentCategoriesQuery, useGetEndpointsQuery } from '~/data-provider'; +import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider'; import { useDocumentTitle } from '~/hooks'; import useLocalize from '~/hooks/useLocalize'; import { TooltipAnchor, Button } from '~/components/ui'; diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index be13404784..c9ae23ec07 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -242,6 +242,12 @@ export default function AgentPanel({ status: 'error', }); } + if (!name) { + return showToast({ + message: localize('com_agents_missing_name'), + status: 'error', + }); + } create.mutate({ name, @@ -274,12 +280,12 @@ export default function AgentPanel({ return true; } - if (agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN) { + if (user?.role === SystemRoles.ADMIN) { return true; } return canEdit; - }, [agentQuery.data?.author, agentQuery.data?.id, user?.id, user?.role, canEdit]); + }, [agentQuery.data?.id, user?.role, canEdit]); return ( diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 820e489e23..bd8ce58601 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -1,7 +1,11 @@ import { EarthIcon } from 'lucide-react'; import { useCallback, useEffect, useRef } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; -import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider'; +import { + AgentCapabilities, + defaultAgentFormValues, + PERMISSION_BITS, +} from 'librechat-data-provider'; import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query'; import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { TAgentCapabilities, AgentForm } from '~/common'; @@ -28,18 +32,21 @@ export default function AgentSelect({ const { control, reset } = useFormContext(); const { data: startupConfig } = useGetStartupConfig(); - const { data: agents = null } = useListAgentsQuery(undefined, { - select: (res) => - res.data.map((agent) => - processAgentOption({ - agent: { - ...agent, - name: agent.name || agent.id, - }, - instanceProjectId: startupConfig?.instanceProjectId, - }), - ), - }); + const { data: agents = null } = useListAgentsQuery( + { requiredPermission: PERMISSION_BITS.EDIT }, + { + select: (res) => + res.data.map((agent) => + processAgentOption({ + agent: { + ...agent, + name: agent.name || agent.id, + }, + instanceProjectId: startupConfig?.instanceProjectId, + }), + ), + }, + ); const resetAgentForm = useCallback( (fullAgent: Agent) => { diff --git a/client/src/components/SidePanel/Agents/ErrorDisplay.tsx b/client/src/components/SidePanel/Agents/ErrorDisplay.tsx index f19c4648dc..be60fea0c9 100644 --- a/client/src/components/SidePanel/Agents/ErrorDisplay.tsx +++ b/client/src/components/SidePanel/Agents/ErrorDisplay.tsx @@ -67,23 +67,25 @@ export const ErrorDisplay: React.FC = ({ error, onRetry, cont errorData = error; } - // Use user-friendly message from backend if available - if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) { + // Handle network errors first + let errorMessage = ''; + if (isErrorInstance(error)) { + errorMessage = error.message; + } else if (isErrorObject(error) && (error as any)?.message) { + errorMessage = (error as any).message; + } + + const errorCode = isErrorObject(error) ? (error as any)?.code : ''; + + // Handle timeout errors specifically + if (errorCode === 'ECONNABORTED' || errorMessage?.includes('timeout')) { return { - title: getContextualTitle(), - message: (errorData as any).userMessage, - suggestion: - (errorData as any).suggestion || localize('com_agents_error_suggestion_generic'), + title: localize('com_agents_error_timeout_title'), + message: localize('com_agents_error_timeout_message'), + suggestion: localize('com_agents_error_timeout_suggestion'), }; } - // Handle network errors - const errorMessage = isErrorInstance(error) - ? error.message - : isErrorObject(error) && (error as any)?.message - ? (error as any).message - : ''; - const errorCode = isErrorObject(error) ? (error as any)?.code : ''; if (errorCode === 'NETWORK_ERROR' || errorMessage?.includes('Network Error')) { return { title: localize('com_agents_error_network_title'), @@ -92,7 +94,7 @@ export const ErrorDisplay: React.FC = ({ error, onRetry, cont }; } - // Handle specific HTTP status codes + // Handle specific HTTP status codes before generic userMessage const status = isErrorObject(error) ? (error as any)?.response?.status : null; if (status) { if (status === 404) { @@ -108,7 +110,8 @@ export const ErrorDisplay: React.FC = ({ error, onRetry, cont title: localize('com_agents_error_invalid_request'), message: (errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'), - suggestion: localize('com_agents_error_bad_request_suggestion'), + suggestion: + (errorData as any)?.suggestion || localize('com_agents_error_bad_request_suggestion'), }; } @@ -121,9 +124,19 @@ export const ErrorDisplay: React.FC = ({ error, onRetry, cont } } - // Fallback to generic error + // Use user-friendly message from backend if available (after specific status code handling) + if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) { + return { + title: getContextualTitle(), + message: (errorData as any).userMessage, + suggestion: + (errorData as any).suggestion || localize('com_agents_error_suggestion_generic'), + }; + } + + // Fallback to generic error with contextual title return { - title: localize('com_agents_error_title'), + title: getContextualTitle(), message: localize('com_agents_error_generic'), suggestion: localize('com_agents_error_suggestion_generic'), }; @@ -193,9 +206,9 @@ export const ErrorDisplay: React.FC = ({ error, onRetry, cont {/* Error content with proper headings and structure */}
-

+

{title} -

+

{ if (permissionsData) { - setManagedShares(currentShares); - setManagedIsPublic(isPublic); - setManagedPublicRole(publicRole); + const shares = permissionsData.principals || []; + const isPublicValue = permissionsData.public || false; + const publicRoleValue = permissionsData.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER; + + setManagedShares(shares); + setManagedIsPublic(isPublicValue); + setManagedPublicRole(publicRoleValue); setHasChanges(false); } }, [permissionsData, isModalOpen]); diff --git a/client/src/components/SidePanel/Agents/SmartLoader.tsx b/client/src/components/SidePanel/Agents/SmartLoader.tsx index f31694c162..2dcc7dbebb 100644 --- a/client/src/components/SidePanel/Agents/SmartLoader.tsx +++ b/client/src/components/SidePanel/Agents/SmartLoader.tsx @@ -1,7 +1,6 @@ +import { AgentListResponse } from 'librechat-data-provider'; import React, { useState, useEffect } from 'react'; -import { cn } from '~/utils'; - interface SmartLoaderProps { /** Whether the content is currently loading */ isLoading: boolean; @@ -69,7 +68,7 @@ export const SmartLoader: React.FC = ({ * Hook to determine if we have meaningful data to show * 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; // Type guard for object data diff --git a/client/src/components/SidePanel/Agents/__tests__/Accessibility.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/Accessibility.spec.tsx index b2ed1ecc45..2121226f22 100644 --- a/client/src/components/SidePanel/Agents/__tests__/Accessibility.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/Accessibility.spec.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import CategoryTabs from '../CategoryTabs'; import AgentGrid from '../AgentGrid'; import AgentCard from '../AgentCard'; import SearchBar from '../SearchBar'; import ErrorDisplay from '../ErrorDisplay'; +import * as t from 'librechat-data-provider'; // Mock matchMedia Object.defineProperty(window, 'matchMedia', { @@ -22,36 +23,110 @@ Object.defineProperty(window, 'matchMedia', { })), }); -// Mock hooks -jest.mock( - '~/hooks/useLocalize', - () => () => - jest.fn((key: string, options?: any) => { - const translations: Record = { - com_agents_category_tabs_label: 'Agent Categories', - com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`, - com_agents_search_instructions: 'Type to search agents by name or description', - com_agents_search_aria: 'Search agents', - com_agents_search_placeholder: 'Search agents...', - com_agents_clear_search: 'Clear search', - com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`, - com_agents_no_description: 'No description available', - com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`, - com_agents_load_more_label: `Load more agents from ${options?.category} category`, - com_agents_error_retry: 'Try Again', - com_agents_loading: 'Loading...', - com_agents_empty_state_heading: 'No agents found', - com_agents_search_empty_heading: 'No search results', - }; - return translations[key] || key; - }), -); - -jest.mock('~/hooks/Agents', () => ({ - useDynamicAgentQuery: jest.fn(), +// Mock Recoil +jest.mock('recoil', () => ({ + useRecoilValue: jest.fn(() => 'en'), + RecoilRoot: ({ children }: any) => children, + atom: jest.fn(() => ({})), + atomFamily: jest.fn(() => ({})), + selector: jest.fn(() => ({})), + selectorFamily: jest.fn(() => ({})), + useRecoilState: jest.fn(() => ['en', jest.fn()]), + useSetRecoilState: jest.fn(() => jest.fn()), })); -const { useDynamicAgentQuery } = require('~/hooks/Agents'); +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: jest.fn() }, + }), +})); + +// Create the localize function once to be reused +const mockLocalize = jest.fn((key: string, options?: any) => { + const translations: Record = { + com_agents_category_tabs_label: 'Agent Categories', + com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`, + com_agents_search_instructions: 'Type to search agents by name or description', + com_agents_search_aria: 'Search agents', + com_agents_search_placeholder: 'Search agents...', + com_agents_clear_search: 'Clear search', + com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`, + com_agents_no_description: 'No description available', + com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`, + com_agents_load_more_label: `Load more agents from ${options?.category} category`, + com_agents_error_retry: 'Try Again', + com_agents_loading: 'Loading...', + com_agents_empty_state_heading: 'No agents found', + com_agents_search_empty_heading: 'No search results', + com_agents_created_by: 'by', + com_agents_top_picks: 'Top Picks', + // ErrorDisplay translations + com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection', + com_agents_error_network_title: 'Network Error', + com_agents_error_network_message: 'Unable to connect to the server', + com_agents_error_network_suggestion: 'Check your internet connection and try again', + com_agents_error_not_found_title: 'Not Found', + com_agents_error_not_found_suggestion: 'The requested resource could not be found', + com_agents_error_invalid_request: 'Invalid Request', + com_agents_error_bad_request_message: 'The request was invalid', + com_agents_error_bad_request_suggestion: 'Please check your input and try again', + com_agents_error_server_title: 'Server Error', + com_agents_error_server_message: 'An internal server error occurred', + com_agents_error_server_suggestion: 'Please try again later', + com_agents_error_title: 'Error', + com_agents_error_generic: 'An unexpected error occurred', + com_agents_error_search_title: 'Search Error', + com_agents_error_category_title: 'Category Error', + com_agents_search_no_results: `No results found for "${options?.query}"`, + com_agents_category_empty: `No agents found in ${options?.category} category`, + com_agents_error_not_found_message: 'The requested resource could not be found', + }; + return translations[key] || key; +}); + +// Mock useLocalize specifically +jest.mock('~/hooks/useLocalize', () => ({ + __esModule: true, + default: () => mockLocalize, +})); + +// Mock hooks +jest.mock('~/hooks', () => ({ + useLocalize: () => mockLocalize, + useDebounce: jest.fn(), +})); + +jest.mock('~/data-provider/Agents', () => ({ + useMarketplaceAgentsInfiniteQuery: jest.fn(), +})); + +jest.mock('~/hooks/Agents', () => ({ + useAgentCategories: jest.fn(), +})); + +// Mock utility functions +jest.mock('~/utils/agents', () => ({ + renderAgentAvatar: jest.fn(() =>

), + getContactDisplayName: jest.fn((agent) => agent.authorName), +})); + +// Mock SmartLoader +jest.mock('../SmartLoader', () => ({ + SmartLoader: ({ children, isLoading }: any) => (isLoading ?
Loading...
: children), + useHasData: jest.fn(() => true), +})); + +// Import the actual modules to get the mocked functions +import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; +import { useAgentCategories } from '~/hooks/Agents'; +import { useDebounce } from '~/hooks'; + +// Get typed mock functions +const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery); +const mockUseAgentCategories = jest.mocked(useAgentCategories); +const mockUseDebounce = jest.mocked(useDebounce); // Create wrapper with QueryClient const createWrapper = () => { @@ -66,14 +141,29 @@ const createWrapper = () => { describe('Accessibility Improvements', () => { beforeEach(() => { - useDynamicAgentQuery.mockClear(); + mockUseMarketplaceAgentsInfiniteQuery.mockClear(); + mockUseAgentCategories.mockClear(); + mockUseDebounce.mockClear(); + + // Default mock implementations + mockUseDebounce.mockImplementation((value) => value); + mockUseAgentCategories.mockReturnValue({ + categories: [ + { value: 'promoted', label: 'Top Picks' }, + { value: 'all', label: 'All' }, + { value: 'productivity', label: 'Productivity' }, + ], + emptyCategory: { value: 'all', label: 'All' }, + isLoading: false, + error: null, + }); }); describe('CategoryTabs Accessibility', () => { const categories = [ - { name: 'promoted', count: 5 }, - { name: 'all', count: 20 }, - { name: 'productivity', count: 8 }, + { value: 'promoted', label: 'Top Picks', count: 5 }, + { value: 'all', label: 'All', count: 20 }, + { value: 'productivity', label: 'Productivity', count: 8 }, ]; it('implements proper tablist role and ARIA attributes', () => { @@ -96,7 +186,7 @@ describe('Accessibility Improvements', () => { const tabs = screen.getAllByRole('tab'); expect(tabs).toHaveLength(3); - tabs.forEach((tab, index) => { + tabs.forEach((tab) => { expect(tab).toHaveAttribute('aria-selected'); expect(tab).toHaveAttribute('aria-controls'); expect(tab).toHaveAttribute('id'); @@ -114,7 +204,7 @@ describe('Accessibility Improvements', () => { />, ); - const promotedTab = screen.getByRole('tab', { name: /promoted category/ }); + const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ }); // Test arrow key navigation fireEvent.keyDown(promotedTab, { key: 'ArrowRight' }); @@ -141,8 +231,8 @@ describe('Accessibility Improvements', () => { />, ); - const promotedTab = screen.getByRole('tab', { name: /promoted category/ }); - const allTab = screen.getByRole('tab', { name: /all category/ }); + const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ }); + const allTab = screen.getByRole('tab', { name: /All tab/ }); // Active tab should be focusable expect(promotedTab).toHaveAttribute('tabIndex', '0'); @@ -159,7 +249,7 @@ describe('Accessibility Improvements', () => { expect(searchRegion).toBeInTheDocument(); // Check input accessibility - const searchInput = screen.getByRole('searchbox'); + const searchInput = screen.getByRole('textbox'); expect(searchInput).toHaveAttribute('id', 'agent-search'); expect(searchInput).toHaveAttribute('aria-label', 'Search agents'); expect(searchInput).toHaveAttribute( @@ -167,10 +257,9 @@ describe('Accessibility Improvements', () => { 'search-instructions search-results-count', ); - // Check hidden label - expect(screen.getByText('Type to search agents by name or description')).toHaveClass( - 'sr-only', - ); + // Check hidden label exists + const hiddenLabel = screen.getByLabelText('Search agents'); + expect(hiddenLabel).toBeInTheDocument(); }); it('provides accessible clear button', () => { @@ -197,10 +286,24 @@ describe('Accessibility Improvements', () => { name: 'Test Agent', description: 'A test agent for testing', authorName: 'Test Author', + created_at: 1704067200000, + avatar: null, + instructions: 'Test instructions', + provider: 'openai' as const, + model: 'gpt-4', + model_parameters: { + temperature: 0.7, + maxContextTokens: 4096, + max_context_tokens: 4096, + max_output_tokens: 1024, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + }, }; it('provides comprehensive ARIA labels', () => { - render(); + render(); const card = screen.getByRole('button'); expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing'); @@ -210,14 +313,14 @@ describe('Accessibility Improvements', () => { it('handles agents without descriptions', () => { const agentWithoutDesc = { ...mockAgent, description: undefined }; - render(); + render(); expect(screen.getByText('No description available')).toBeInTheDocument(); }); it('supports keyboard interaction', () => { const onClick = jest.fn(); - render(); + render(); const card = screen.getByRole('button'); @@ -231,19 +334,20 @@ describe('Accessibility Improvements', () => { describe('AgentGrid Accessibility', () => { beforeEach(() => { - useDynamicAgentQuery.mockReturnValue({ + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ data: { - agents: [ - { id: '1', name: 'Agent 1', description: 'First agent' }, - { id: '2', name: 'Agent 2', description: 'Second agent' }, + pages: [ + { + data: [ + { id: '1', name: 'Agent 1', description: 'First agent' }, + { id: '2', name: 'Agent 2', description: 'Second agent' }, + ], + }, ], - pagination: { hasMore: false, total: 2, current: 1 }, }, isLoading: false, - isFetching: false, error: null, - refetch: jest.fn(), - }); + } as any); }); it('implements proper tabpanel structure', () => { @@ -272,7 +376,7 @@ describe('Accessibility Improvements', () => { // Check grid role const grid = screen.getByRole('grid'); expect(grid).toBeInTheDocument(); - expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in all category'); + expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in All category'); // Check gridcells const gridcells = screen.getAllByRole('gridcell'); @@ -280,13 +384,16 @@ describe('Accessibility Improvements', () => { }); it('announces loading states to screen readers', () => { - useDynamicAgentQuery.mockReturnValue({ - data: { agents: [{ id: '1', name: 'Agent 1' }] }, - isLoading: false, + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + data: { + pages: [{ data: [{ id: '1', name: 'Agent 1' }] }], + }, isFetching: true, + hasNextPage: true, + isFetchingNextPage: true, + isLoading: false, error: null, - refetch: jest.fn(), - }); + } as any); const Wrapper = createWrapper(); render( @@ -295,20 +402,26 @@ describe('Accessibility Improvements', () => { , ); - // Check for loading announcement - const loadingStatus = screen.getByRole('status', { name: 'Loading...' }); + // Check for loading announcement when fetching more data + const loadingStatus = screen.getByRole('status'); expect(loadingStatus).toBeInTheDocument(); expect(loadingStatus).toHaveAttribute('aria-live', 'polite'); + expect(loadingStatus).toHaveAttribute('aria-label', 'Loading...'); + + // Check for screen reader text + const srText = screen.getByText('Loading...'); + expect(srText).toHaveClass('sr-only'); }); it('provides accessible empty states', () => { - useDynamicAgentQuery.mockReturnValue({ - data: { agents: [], pagination: { hasMore: false, total: 0, current: 1 } }, + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + data: { + pages: [{ data: [] }], + }, isLoading: false, isFetching: false, error: null, - refetch: jest.fn(), - }); + } as any); const Wrapper = createWrapper(); render( @@ -343,7 +456,7 @@ describe('Accessibility Improvements', () => { expect(alert).toHaveAttribute('aria-atomic', 'true'); // Check heading structure - const heading = screen.getByRole('heading', { level: 2 }); + const heading = screen.getByRole('heading', { level: 3 }); expect(heading).toHaveAttribute('id', 'error-title'); }); @@ -382,7 +495,7 @@ describe('Accessibility Improvements', () => { it('provides visible focus indicators on interactive elements', () => { render( { const tab = screen.getByRole('tab'); expect(tab.className).toContain('focus:outline-none'); - expect(tab.className).toContain('focus:ring-2'); + expect(tab.className).toContain('focus:bg-gray-100'); }); }); @@ -418,5 +531,3 @@ describe('Accessibility Improvements', () => { }); }); }); - -export default {}; diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentCard.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentCard.spec.tsx index e319bca6d9..15e95bc1a6 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentCard.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/AgentCard.spec.tsx @@ -21,8 +21,21 @@ describe('AgentCard', () => { name: 'Test Support', email: 'test@example.com', }, - avatar: '/test-avatar.png', - } as t.Agent; + avatar: { filepath: '/test-avatar.png', source: 'local' }, + created_at: 1672531200000, + instructions: 'Test instructions', + provider: 'openai' as const, + model: 'gpt-4', + model_parameters: { + temperature: 0.7, + maxContextTokens: 4096, + max_context_tokens: 4096, + max_output_tokens: 1024, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + }, + }; const mockOnClick = jest.fn(); @@ -39,7 +52,7 @@ describe('AgentCard', () => { expect(screen.getByText('Test Support')).toBeInTheDocument(); }); - it('displays avatar when provided as string', () => { + it('displays avatar when provided as object', () => { render(); const avatarImg = screen.getByAltText('Test Agent avatar'); @@ -47,17 +60,17 @@ describe('AgentCard', () => { expect(avatarImg).toHaveAttribute('src', '/test-avatar.png'); }); - it('displays avatar when provided as object with filepath', () => { - const agentWithObjectAvatar = { + it('displays avatar when provided as string', () => { + const agentWithStringAvatar = { ...mockAgent, - avatar: { filepath: '/object-avatar.png' }, + avatar: '/string-avatar.png' as any, // Legacy support for string avatars }; - render(); + render(); const avatarImg = screen.getByAltText('Test Agent avatar'); expect(avatarImg).toBeInTheDocument(); - expect(avatarImg).toHaveAttribute('src', '/object-avatar.png'); + expect(avatarImg).toHaveAttribute('src', '/string-avatar.png'); }); it('displays Bot icon fallback when no avatar is provided', () => { @@ -66,7 +79,7 @@ describe('AgentCard', () => { avatar: undefined, }; - render(); + render(); // Check for Bot icon presence by looking for the svg with lucide-bot class const botIcon = document.querySelector('.lucide-bot'); diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentDetail.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentDetail.spec.tsx index 00df97a062..364ac82aec 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentDetail.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/AgentDetail.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -19,6 +19,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('~/hooks', () => ({ useToast: jest.fn(), + useMediaQuery: jest.fn(() => false), // Mock as desktop by default })); jest.mock('~/hooks/useLocalize', () => ({ @@ -33,11 +34,7 @@ jest.mock('~/utils/agents', () => ({ })); // Mock clipboard API -Object.assign(navigator, { - clipboard: { - writeText: jest.fn(), - }, -}); +const mockWriteText = jest.fn(); const mockNavigate = jest.fn(); const mockShowToast = jest.fn(); @@ -55,17 +52,23 @@ const mockAgent: t.Agent = { provider: 'openai', instructions: 'You are a helpful test agent', tools: [], - code_interpreter: false, - file_search: false, author: 'test-user-id', - author_name: 'Test User', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + created_at: new Date().getTime(), version: 1, support_contact: { name: 'Support Team', email: 'support@test.com', }, + model_parameters: { + model: undefined, + temperature: null, + maxContextTokens: null, + max_context_tokens: null, + max_output_tokens: null, + top_p: null, + frequency_penalty: null, + presence_penalty: null, + }, }; // Helper function to render with providers @@ -95,8 +98,19 @@ describe('AgentDetail', () => { (useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast }); (useLocalize as jest.Mock).mockReturnValue(mockLocalize); - // Reset clipboard mock - (navigator.clipboard.writeText as jest.Mock).mockResolvedValue(undefined); + // Setup clipboard mock if it doesn't exist + if (!navigator.clipboard) { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: mockWriteText, + }, + configurable: true, + }); + } else { + // If clipboard exists, spy on it + jest.spyOn(navigator.clipboard, 'writeText').mockImplementation(mockWriteText); + } + mockWriteText.mockResolvedValue(undefined); }); const defaultProps = { @@ -145,7 +159,7 @@ describe('AgentDetail', () => { it('should render 3-dot menu button', () => { renderWithProviders(); - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); expect(menuButton).toBeInTheDocument(); expect(menuButton).toHaveAttribute('aria-haspopup', 'menu'); }); @@ -185,7 +199,7 @@ describe('AgentDetail', () => { const user = userEvent.setup(); renderWithProviders(); - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); await user.click(menuButton); expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument(); @@ -196,7 +210,7 @@ describe('AgentDetail', () => { renderWithProviders(); // Open dropdown - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); await user.click(menuButton); expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument(); @@ -217,18 +231,24 @@ describe('AgentDetail', () => { renderWithProviders(); // Open dropdown - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); await user.click(menuButton); // Click copy link const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); await user.click(copyLinkButton); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - `${window.location.origin}/c/new?agent_id=test-agent-id`, - ); - expect(mockShowToast).toHaveBeenCalledWith({ - message: 'Link copied', + // Wait for async clipboard operation to complete + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith( + `${window.location.origin}/c/new?agent_id=test-agent-id`, + ); + }); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith({ + message: 'com_agents_link_copied', + }); }); // Dropdown should close @@ -241,17 +261,22 @@ describe('AgentDetail', () => { it('should show error toast when clipboard write fails', async () => { const user = userEvent.setup(); - (navigator.clipboard.writeText as jest.Mock).mockRejectedValue(new Error('Clipboard error')); + mockWriteText.mockRejectedValue(new Error('Clipboard error')); renderWithProviders(); // Open dropdown and click copy link - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); await user.click(menuButton); const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); await user.click(copyLinkButton); + // Wait for clipboard operation to fail and error toast to show + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalled(); + }); + await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith({ message: 'com_agents_link_copy_failed', @@ -261,7 +286,7 @@ describe('AgentDetail', () => { it('should call onClose when dialog is closed', () => { const mockOnClose = jest.fn(); - render(); + renderWithProviders(); // Since we're testing the onOpenChange callback, we need to trigger it // This would normally be done by the Dialog component when ESC is pressed or overlay is clicked @@ -274,16 +299,16 @@ describe('AgentDetail', () => { it('should have proper ARIA attributes', () => { renderWithProviders(); - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); expect(menuButton).toHaveAttribute('aria-haspopup', 'menu'); - expect(menuButton).toHaveAttribute('aria-label', 'More options'); + expect(menuButton).toHaveAttribute('aria-label', 'com_agents_more_options'); }); it('should support keyboard navigation for dropdown', async () => { const user = userEvent.setup(); renderWithProviders(); - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); // Focus and open with Enter key menuButton.focus(); @@ -296,7 +321,7 @@ describe('AgentDetail', () => { const user = userEvent.setup(); renderWithProviders(); - const menuButton = screen.getByRole('button', { name: 'More options' }); + const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' }); await user.click(menuButton); const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentGrid.integration.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentGrid.integration.spec.tsx index 55ca664f9d..722c6edfb7 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentGrid.integration.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/AgentGrid.integration.spec.tsx @@ -1,17 +1,30 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import AgentGrid from '../AgentGrid'; -import { useDynamicAgentQuery } from '~/hooks/Agents'; import type t from 'librechat-data-provider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock the marketplace agent query hook +jest.mock('~/data-provider/Agents', () => ({ + useMarketplaceAgentsInfiniteQuery: jest.fn(), +})); -// Mock the dynamic agent query hook jest.mock('~/hooks/Agents', () => ({ - useDynamicAgentQuery: jest.fn(), + useAgentCategories: jest.fn(() => ({ + categories: [], + isLoading: false, + error: null, + })), +})); + +// Mock SmartLoader +jest.mock('../SmartLoader', () => ({ + useHasData: jest.fn(() => true), })); // Mock useLocalize hook -jest.mock('~/hooks/useLocalize', () => () => (key: string) => { +jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => { const mockTranslations: Record = { com_agents_top_picks: 'Top Picks', com_agents_all: 'All Agents', @@ -22,318 +35,342 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => { com_agents_error_searching: 'Error searching agents', com_agents_no_results: 'No agents found. Try another search term.', 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; + + let translation = mockTranslations[key] || key; + + if (options) { + Object.keys(options).forEach((optionKey) => { + translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), options[optionKey]); + }); + } + + return translation; }); -// Mock getCategoryDisplayName and getCategoryDescription -jest.mock('~/utils/agents', () => ({ - getCategoryDisplayName: (category: string) => { - const names: Record = { - promoted: 'Top Picks', - all: 'All', - general: 'General', - hr: 'HR', - finance: 'Finance', - }; - return names[category] || category; - }, - getCategoryDescription: (category: string) => { - const descriptions: Record = { - promoted: 'Our recommended agents', - all: 'Browse all available agents', - general: 'General purpose agents', - hr: 'HR agents', - finance: 'Finance agents', - }; - return descriptions[category] || ''; - }, +// Mock ErrorDisplay component +jest.mock('../ErrorDisplay', () => ({ + __esModule: true, + default: ({ error, onRetry }: { error: any; onRetry: () => void }) => ( +
+
+ {`Error: `} + {typeof error === 'string' ? error : error?.message || 'Unknown error'} +
+ +
+ ), })); -const mockUseDynamicAgentQuery = useDynamicAgentQuery as jest.MockedFunction< - typeof useDynamicAgentQuery ->; +// Mock AgentCard component +jest.mock('../AgentCard', () => ({ + __esModule: true, + default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => ( +
+

{agent.name}

+

{agent.description}

+
+ ), +})); -describe('AgentGrid Integration with useDynamicAgentQuery', () => { +// Import the actual modules to get the mocked functions +import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; + +const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery); + +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: { + temperature: null, + maxContextTokens: null, + max_context_tokens: null, + max_output_tokens: null, + top_p: null, + frequency_penalty: null, + presence_penalty: null, + }, }, { 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: { + temperature: 0.7, + top_p: 0.9, + frequency_penalty: 0, + maxContextTokens: null, + max_context_tokens: null, + max_output_tokens: null, + presence_penalty: null, + }, }, ]; - const defaultMockQueryResult = { data: { - agents: mockAgents, - pagination: { - current: 1, - hasMore: true, - total: 10, - }, + pages: [ + { + data: mockAgents, + }, + ], }, isLoading: false, error: null, isFetching: false, - queryType: 'promoted' as const, - }; + isFetchingNextPage: false, + hasNextPage: true, + fetchNextPage: jest.fn(), + refetch: jest.fn(), + } as any; beforeEach(() => { jest.clearAllMocks(); - mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult); + mockUseMarketplaceAgentsInfiniteQuery.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(mockUseMarketplaceAgentsInfiniteQuery).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(mockUseMarketplaceAgentsInfiniteQuery).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(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({ + requiredPermission: 1, + limit: 6, + }); + }); + + it('should not include category in search when category is "all" or "promoted"', () => { + render(); + + expect(mockUseMarketplaceAgentsInfiniteQuery).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, - }); - - render(); - - // Should show loading skeletons - const loadingElements = screen.getAllByRole('generic'); - const hasLoadingClass = loadingElements.some((el) => el.className.includes('animate-pulse')); - expect(hasLoadingClass).toBe(true); + // Create wrapper with QueryClient + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, }); - it('should show error message when there is an error', () => { - mockUseDynamicAgentQuery.mockReturnValue({ - ...defaultMockQueryResult, - data: undefined, - error: new Error('Test error'), - }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; - render(); + describe('Agent Display', () => { + it('should render agent cards when data is available', () => { + const Wrapper = createWrapper(); + 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); + const Wrapper = createWrapper(); + 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', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, - data: { - agents: mockAgents, - pagination: { - current: 1, - hasMore: true, - total: 10, - }, - }, + isLoading: true, + data: undefined, }); - render(); + const Wrapper = createWrapper(); + render( + + + , + ); - expect(screen.getByText('See more')).toBeInTheDocument(); + // Should show skeleton loading state + expect(document.querySelector('.animate-pulse')).toBeInTheDocument(); }); - it('should not show "See More" button when hasMore is false', () => { - mockUseDynamicAgentQuery.mockReturnValue({ + it('should show empty state when no agents are available', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, data: { - agents: mockAgents, - pagination: { - current: 1, - hasMore: false, - total: 2, - }, + pages: [ + { + data: [], + }, + ], }, }); - render(); + const Wrapper = createWrapper(); + 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'); + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, - data: { - agents: [], - pagination: { current: 1, hasMore: false, total: 0 }, - }, - queryType: 'search', + error: mockError, + isError: true, + data: undefined, }); + const Wrapper = createWrapper(); render( - , + + + , ); - expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument(); + 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', () => { + const Wrapper = createWrapper(); + render( + + + , + ); + + expect(screen.getByText('Results for "automation"')).toBeInTheDocument(); }); - it('should show empty state for category with no agents', () => { - mockUseDynamicAgentQuery.mockReturnValue({ + it('should show empty search results message', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, data: { - agents: [], - pagination: { current: 1, hasMore: false, total: 0 }, + pages: [ + { + data: [], + }, + ], }, - queryType: 'category', }); - render(); + const Wrapper = createWrapper(); + render( + + + , + ); - expect(screen.getByText('No agents found in this category')).toBeInTheDocument(); + expect(screen.getByText('No results found')).toBeInTheDocument(); + expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument(); + }); + }); + + describe('Load More Functionality', () => { + it('should show "See more" button when hasNextPage is true', () => { + const Wrapper = createWrapper(); + render( + + + , + ); + + expect( + screen.getByRole('button', { name: 'Load more agents from Finance' }), + ).toBeInTheDocument(); + }); + + it('should not show "See more" button when hasNextPage is false', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + ...defaultMockQueryResult, + hasNextPage: false, + }); + + const Wrapper = createWrapper(); + render( + + + , + ); + + expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument(); }); }); }); diff --git a/client/src/components/SidePanel/Agents/__tests__/CategoryTabs.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/CategoryTabs.spec.tsx index a5bb1108d6..cbcf9c601d 100644 --- a/client/src/components/SidePanel/Agents/__tests__/CategoryTabs.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/CategoryTabs.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import CategoryTabs from '../CategoryTabs'; diff --git a/client/src/components/SidePanel/Agents/__tests__/ErrorDisplay.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/ErrorDisplay.spec.tsx index 8008d5e4d9..303308cefc 100644 --- a/client/src/components/SidePanel/Agents/__tests__/ErrorDisplay.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/ErrorDisplay.spec.tsx @@ -38,6 +38,9 @@ const mockLocalize = jest.fn((key: string, options?: any) => { com_agents_error_server_suggestion: 'Please try again in a few moments.', com_agents_error_search_title: 'Search Error', com_agents_error_category_title: 'Category Error', + com_agents_error_timeout_title: 'Connection Timeout', + com_agents_error_timeout_message: 'The request took too long to complete.', + com_agents_error_timeout_suggestion: 'Please check your internet connection and try again.', com_agents_search_no_results: `No agents found for "${options?.query}"`, com_agents_category_empty: `No agents found in the ${options?.category} category`, com_agents_error_retry: 'Try Again', @@ -298,5 +301,3 @@ describe('ErrorDisplay', () => { }); }); }); - -export default {}; diff --git a/client/src/components/SidePanel/Agents/__tests__/SearchBar.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/SearchBar.spec.tsx index 10e20b6353..5977680baf 100644 --- a/client/src/components/SidePanel/Agents/__tests__/SearchBar.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/SearchBar.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import SearchBar from '../SearchBar'; @@ -9,7 +9,7 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => key); // Mock useDebounce hook jest.mock('~/hooks', () => ({ - useDebounce: (value: string, delay: number) => value, // Return value immediately for testing + useDebounce: (value: string) => value, // Return value immediately for testing })); describe('SearchBar', () => { diff --git a/client/src/data-provider/Agents/index.ts b/client/src/data-provider/Agents/index.ts index d0720956a0..b91a65fc3f 100644 --- a/client/src/data-provider/Agents/index.ts +++ b/client/src/data-provider/Agents/index.ts @@ -1,2 +1,5 @@ export * from './queries'; export * from './mutations'; + +// Re-export specific marketplace queries for easier imports +export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries'; diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 9956f40e42..8ce6611cc4 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -1,12 +1,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider'; +import { dataService, MutationKeys, PERMISSION_BITS, QueryKeys } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; -import type { UseMutationResult } from '@tanstack/react-query'; +import type { QueryClient, UseMutationResult } from '@tanstack/react-query'; /** * AGENTS */ - +export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [ + { requiredPermission: PERMISSION_BITS.VIEW }, + { requiredPermission: PERMISSION_BITS.EDIT }, +]; /** * Create a new agent */ @@ -18,21 +21,22 @@ export const useCreateAgentMutation = ( onMutate: (variables) => options?.onMutate?.(variables), onError: (error, variables, context) => options?.onError?.(error, variables, context), onSuccess: (newAgent, variables, context) => { - const listRes = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); + if (!listRes) { + return options?.onSuccess?.(newAgent, variables, context); + } + const currentAgents = [newAgent, ...JSON.parse(JSON.stringify(listRes.data))]; - if (!listRes) { - return options?.onSuccess?.(newAgent, variables, context); - } + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data: currentAgents, + }); + }); + })(allAgentViewAndEditQueryKeys); + invalidateAgentMarketplaceQueries(queryClient); - const currentAgents = [newAgent, ...JSON.parse(JSON.stringify(listRes.data))]; - - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...listRes, - data: currentAgents, - }); return options?.onSuccess?.(newAgent, variables, context); }, }); @@ -63,30 +67,33 @@ export const useUpdateAgentMutation = ( return options?.onError?.(typedError, variables, context); }, onSuccess: (updatedAgent, variables, context) => { - const listRes = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); - if (!listRes) { - return options?.onSuccess?.(updatedAgent, variables, context); - } - - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...listRes, - data: listRes.data.map((agent) => { - if (agent.id === variables.agent_id) { - return updatedAgent; + if (!listRes) { + return options?.onSuccess?.(updatedAgent, variables, context); } - return agent; - }), - }); + + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data: listRes.data.map((agent) => { + if (agent.id === variables.agent_id) { + return updatedAgent; + } + return agent; + }), + }); + }); + })(allAgentViewAndEditQueryKeys); queryClient.setQueryData([QueryKeys.agent, variables.agent_id], updatedAgent); queryClient.setQueryData( [QueryKeys.agent, variables.agent_id, 'expanded'], updatedAgent, ); + invalidateAgentMarketplaceQueries(queryClient); + return options?.onSuccess?.(updatedAgent, variables, context); }, }, @@ -108,24 +115,28 @@ export const useDeleteAgentMutation = ( onMutate: (variables) => options?.onMutate?.(variables), onError: (error, variables, context) => options?.onError?.(error, variables, context), onSuccess: (_data, variables, context) => { - const listRes = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); + const data = ((keys: t.AgentListParams[]) => { + let data: t.Agent[] = []; + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); - if (!listRes) { - return options?.onSuccess?.(_data, variables, context); - } + if (!listRes) { + return options?.onSuccess?.(_data, variables, context); + } - const data = listRes.data.filter((agent) => agent.id !== variables.agent_id); + data = listRes.data.filter((agent) => agent.id !== variables.agent_id); - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...listRes, - data, - }); + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data, + }); + }); + return data; + })(allAgentViewAndEditQueryKeys); queryClient.removeQueries([QueryKeys.agent, variables.agent_id]); queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']); + invalidateAgentMarketplaceQueries(queryClient); return options?.onSuccess?.(_data, variables, data); }, @@ -147,22 +158,23 @@ export const useDuplicateAgentMutation = ( onMutate: options?.onMutate, onError: options?.onError, onSuccess: ({ agent, actions }, variables, context) => { - const listRes = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); - - if (listRes) { - const currentAgents = [agent, ...listRes.data]; - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...listRes, - data: currentAgents, + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); + if (listRes) { + const currentAgents = [agent, ...listRes.data]; + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data: currentAgents, + }); + } }); - } + })(allAgentViewAndEditQueryKeys); const existingActions = queryClient.getQueryData([QueryKeys.actions]) || []; queryClient.setQueryData([QueryKeys.actions], existingActions.concat(actions)); + invalidateAgentMarketplaceQueries(queryClient); return options?.onSuccess?.({ agent, actions }, variables, context); }, @@ -207,26 +219,25 @@ export const useUpdateAgentAction = ( onMutate: (variables) => options?.onMutate?.(variables), onError: (error, variables, context) => options?.onError?.(error, variables, context), onSuccess: (updateAgentActionResponse, variables, context) => { - const listRes = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); - - if (!listRes) { - return options?.onSuccess?.(updateAgentActionResponse, variables, context); - } - const updatedAgent = updateAgentActionResponse[0]; + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...listRes, - data: listRes.data.map((agent) => { - if (agent.id === variables.agent_id) { - return updatedAgent; + if (!listRes) { + return options?.onSuccess?.(updateAgentActionResponse, variables, context); } - return agent; - }), - }); + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data: listRes.data.map((agent) => { + if (agent.id === variables.agent_id) { + return updatedAgent; + } + return agent; + }), + }); + }); + })(allAgentViewAndEditQueryKeys); queryClient.setQueryData([QueryKeys.actions], (prev) => { if (!prev) { @@ -280,28 +291,28 @@ export const useDeleteAgentAction = ( return action.action_id !== variables.action_id; }); }); + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + queryClient.setQueryData([QueryKeys.agents, key], (prev) => { + if (!prev) { + return prev; + } - queryClient.setQueryData( - [QueryKeys.agents, defaultOrderQuery], - (prev) => { - if (!prev) { - return prev; - } - - return { - ...prev, - data: prev.data.map((agent) => { - if (agent.id === variables.agent_id) { - return { - ...agent, - tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')), - }; - } - return agent; - }), - }; - }, - ); + return { + ...prev, + data: prev.data.map((agent) => { + if (agent.id === variables.agent_id) { + return { + ...agent, + tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')), + }; + } + return agent; + }), + }; + }); + }); + })(allAgentViewAndEditQueryKeys); const updaterFn = (prev) => { if (!prev) { return prev; @@ -342,25 +353,30 @@ export const useRevertAgentVersionMutation = ( onSuccess: (revertedAgent, variables, context) => { queryClient.setQueryData([QueryKeys.agent, variables.agent_id], revertedAgent); - const listRes = queryClient.getQueryData([ - QueryKeys.agents, - defaultOrderQuery, - ]); + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); - if (listRes) { - queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { - ...listRes, - data: listRes.data.map((agent) => { - if (agent.id === variables.agent_id) { - return revertedAgent; - } - return agent; - }), + if (listRes) { + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data: listRes.data.map((agent) => { + if (agent.id === variables.agent_id) { + return revertedAgent; + } + return agent; + }), + }); + } }); - } + })(allAgentViewAndEditQueryKeys); return options?.onSuccess?.(revertedAgent, variables, context); }, }, ); }; + +export const invalidateAgentMarketplaceQueries = (queryClient: QueryClient) => { + queryClient.invalidateQueries([QueryKeys.marketplaceAgents]); +}; diff --git a/client/src/data-provider/Agents/queries.ts b/client/src/data-provider/Agents/queries.ts index a00ba93b1a..0fb35beebf 100644 --- a/client/src/data-provider/Agents/queries.ts +++ b/client/src/data-provider/Agents/queries.ts @@ -1,12 +1,19 @@ -import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; +import { QueryKeys, dataService, EModelEndpoint, PERMISSION_BITS } from 'librechat-data-provider'; +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import type { + QueryObserverResult, + UseQueryOptions, + UseInfiniteQueryOptions, +} from '@tanstack/react-query'; import type t from 'librechat-data-provider'; /** * AGENTS */ - +export const defaultAgentParams: t.AgentListParams = { + limit: 10, + requiredPermission: PERMISSION_BITS.EDIT, +}; /** * Hook for getting all available tools for A */ @@ -27,7 +34,7 @@ export const useAvailableAgentToolsQuery = (): QueryObserverResult * Hook for listing all Agents, with optional parameters provided for pagination and sorting */ export const useListAgentsQuery = ( - params: t.AgentListParams = defaultOrderQuery, + params: t.AgentListParams = defaultAgentParams, config?: UseQueryOptions, ): QueryObserverResult => { const queryClient = useQueryClient(); @@ -76,143 +83,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) */ @@ -235,3 +105,60 @@ export const useGetExpandedAgentByIdQuery = ( }, ); }; + +/** + * MARKETPLACE + */ +/** + * Hook for getting agent categories for marketplace tabs + */ +export const useGetAgentCategoriesQuery = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.agentCategories], + () => dataService.getAgentCategories(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + ...config, + }, + ); +}; + +/** + * Hook for infinite loading of marketplace agents with cursor-based pagination + */ +export const useMarketplaceAgentsInfiniteQuery = ( + params: { + requiredPermission: number; + category?: string; + search?: string; + limit?: number; + promoted?: 0 | 1; + cursor?: string; // For pagination + }, + config?: UseInfiniteQueryOptions, +) => { + return useInfiniteQuery({ + queryKey: [QueryKeys.marketplaceAgents, params], + queryFn: ({ pageParam }) => { + const queryParams = { ...params }; + if (pageParam) { + queryParams.cursor = pageParam.toString(); + } + return dataService.getMarketplaceAgents(queryParams); + }, + getNextPageParam: (lastPage) => lastPage?.after ?? undefined, + enabled: !!params.requiredPermission, + keepPreviousData: true, + staleTime: 2 * 60 * 1000, // 2 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }); +}; diff --git a/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx b/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx index af44405e10..b689f29e79 100644 --- a/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx +++ b/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx @@ -1,6 +1,8 @@ -import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import useAgentCategories from '../useAgentCategories'; -import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; +import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; // Mock the useLocalize hook jest.mock('~/hooks/useLocalize', () => ({ @@ -11,25 +13,68 @@ jest.mock('~/hooks/useLocalize', () => ({ }, })); -describe('useAgentCategories', () => { - it('should return processed categories with correct structure', () => { - const { result } = renderHook(() => useAgentCategories()); +// Mock the data provider +jest.mock('~/data-provider/Agents', () => ({ + useGetAgentCategoriesQuery: jest.fn(() => ({ + data: [ + { value: 'general', label: 'com_ui_agent_category_general' }, + { value: 'hr', label: 'com_ui_agent_category_hr' }, + { value: 'rd', label: 'com_ui_agent_category_rd' }, + { value: 'finance', label: 'com_ui_agent_category_finance' }, + { value: 'it', label: 'com_ui_agent_category_it' }, + { value: 'sales', label: 'com_ui_agent_category_sales' }, + { value: 'aftersales', label: 'com_ui_agent_category_aftersales' }, + { value: 'promoted', label: 'Promoted' }, // Should be filtered out + { value: 'all', label: 'All' }, // Should be filtered out + ], + isLoading: false, + error: null, + })), +})); - // Check that we have the expected number of categories - expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length); +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('useAgentCategories', () => { + it('should return processed categories with correct structure', async () => { + const { result } = renderHook(() => useAgentCategories(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + // Check that we have the expected number of categories (excluding 'promoted' and 'all') + expect(result.current.categories.length).toBe(7); + }); // Check that the first category has the expected structure const firstCategory = result.current.categories[0]; - const firstOriginalCategory = AGENT_CATEGORIES[0]; - - expect(firstCategory.value).toBe(firstOriginalCategory.value); - - // Check that labels are properly translated - expect(firstCategory.label).toBe('General (Translated)'); + expect(firstCategory.value).toBe('general'); + expect(firstCategory.label).toBe('com_ui_agent_category_general'); expect(firstCategory.className).toBe('w-full'); + // Verify special categories are filtered out + const categoryValues = result.current.categories.map((cat) => cat.value); + expect(categoryValues).not.toContain('promoted'); + expect(categoryValues).not.toContain('all'); + // Check the empty category expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value); - expect(result.current.emptyCategory.label).toBeTruthy(); + expect(result.current.emptyCategory.label).toBe('General (Translated)'); + expect(result.current.emptyCategory.className).toBe('w-full'); + + // Check loading state + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); }); }); 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..77c0a224c9 100644 --- a/client/src/hooks/Agents/useAgentCategories.tsx +++ b/client/src/hooks/Agents/useAgentCategories.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import useLocalize from '~/hooks/useLocalize'; -import { useGetAgentCategoriesQuery } from '~/data-provider'; +import { useGetAgentCategoriesQuery } from '~/data-provider/Agents'; import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; // This interface matches the structure used by the ControlCombobox component @@ -9,6 +9,7 @@ export interface ProcessedAgentCategory { label: string; // Translated label value: string; // Category value className?: string; + icon?: string; } /** diff --git a/client/src/hooks/Agents/useAgentsMap.ts b/client/src/hooks/Agents/useAgentsMap.ts index 0b5222c290..d872e6f34d 100644 --- a/client/src/hooks/Agents/useAgentsMap.ts +++ b/client/src/hooks/Agents/useAgentsMap.ts @@ -1,4 +1,4 @@ -import { TAgentsMap } from 'librechat-data-provider'; +import { PERMISSION_BITS, TAgentsMap } from 'librechat-data-provider'; import { useMemo } from 'react'; import { useListAgentsQuery } from '~/data-provider'; import { mapAgents } from '~/utils'; @@ -8,10 +8,13 @@ export default function useAgentsMap({ }: { isAuthenticated: boolean; }): TAgentsMap | undefined { - const { data: agentsList = null } = useListAgentsQuery(undefined, { - select: (res) => mapAgents(res.data), - enabled: isAuthenticated, - }); + const { data: agentsList = null } = useListAgentsQuery( + { requiredPermission: PERMISSION_BITS.EDIT }, + { + select: (res) => mapAgents(res.data), + enabled: isAuthenticated, + }, + ); const agents = useMemo(() => { return agentsList !== null ? agentsList : undefined; diff --git a/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/client/src/hooks/Input/useMentions.ts b/client/src/hooks/Input/useMentions.ts index 46f438ba91..fa27f183f0 100644 --- a/client/src/hooks/Input/useMentions.ts +++ b/client/src/hooks/Input/useMentions.ts @@ -8,6 +8,7 @@ import { isAgentsEndpoint, getConfigDefaults, isAssistantsEndpoint, + PERMISSION_BITS, } from 'librechat-data-provider'; import type { TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider'; import type { MentionOption } from '~/common'; @@ -79,28 +80,31 @@ export default function useMentions({ () => startupConfig?.interface ?? defaultInterface, [startupConfig?.interface], ); - const { data: agentsList = null } = useListAgentsQuery(undefined, { - enabled: hasAgentAccess && interfaceConfig.modelSelect === true, - select: (res) => { - const { data } = res; - return data.map(({ id, name, avatar }) => ({ - value: id, - label: name ?? '', - type: EModelEndpoint.agents, - icon: EndpointIcon({ - conversation: { - agent_id: id, - endpoint: EModelEndpoint.agents, - iconURL: avatar?.filepath, - }, - containerClassName: 'shadow-stroke overflow-hidden rounded-full', - endpointsConfig: endpointsConfig, - context: 'menu-item', - size: 20, - }), - })); + const { data: agentsList = null } = useListAgentsQuery( + { requiredPermission: PERMISSION_BITS.VIEW }, + { + enabled: hasAgentAccess && interfaceConfig.modelSelect === true, + select: (res) => { + const { data } = res; + return data.map(({ id, name, avatar }) => ({ + value: id, + label: name ?? '', + type: EModelEndpoint.agents, + icon: EndpointIcon({ + conversation: { + agent_id: id, + endpoint: EModelEndpoint.agents, + iconURL: avatar?.filepath, + }, + containerClassName: 'shadow-stroke overflow-hidden rounded-full', + endpointsConfig: endpointsConfig, + context: 'menu-item', + size: 20, + }), + })); + }, }, - }); + ); const assistantListMap = useMemo( () => ({ [EModelEndpoint.assistants]: listMap[EModelEndpoint.assistants] diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 0d708c68f3..728b856735 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -1,11 +1,5 @@ import { useMemo } from 'react'; -import { - MessageSquareQuote, - ArrowRightToLine, - Settings2, Database, - Bookmark, - LayoutGrid, -} from 'lucide-react'; +import { MessageSquareQuote, ArrowRightToLine, Settings2, Database, Bookmark } from 'lucide-react'; import { isAssistantsEndpoint, isAgentsEndpoint, @@ -27,7 +21,6 @@ import FilesPanel from '~/components/SidePanel/Files/Panel'; import MCPPanel from '~/components/SidePanel/MCP/MCPPanel'; import { useGetStartupConfig } from '~/data-provider'; import { useHasAccess } from '~/hooks'; -import { useNavigate } from 'react-router-dom'; export default function useSideNavLinks({ hidePanel, @@ -44,7 +37,6 @@ export default function useSideNavLinks({ interfaceConfig: Partial; endpointsConfig: TEndpointsConfig; }) { - const navigate = useNavigate(); const hasAccessToPrompts = useHasAccess({ permissionType: PermissionTypes.PROMPTS, permission: Permissions.USE, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index d0e3dd54ed..cce6835370 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1132,9 +1132,7 @@ "com_agents_link_copied": "Link copied", "com_agents_link_copy_failed": "Failed to copy link", "com_agents_more_options": "More options", - "com_agents_close": "Close", "com_agents_loading": "Loading...", - "com_agents_loading_description": "Loading agent description...", "com_agents_error_loading": "Error loading agents", "com_agents_error_searching": "Error searching agents", "com_agents_error_title": "Something went wrong", @@ -1154,6 +1152,9 @@ "com_agents_error_server_suggestion": "Please try again in a few moments.", "com_agents_error_search_title": "Search Error", "com_agents_error_category_title": "Category Error", + "com_agents_error_timeout_title": "Connection Timeout", + "com_agents_error_timeout_message": "The request took too long to complete.", + "com_agents_error_timeout_suggestion": "Please check your internet connection and try again.", "com_agents_search_no_results": "No agents found for \"{{query}}\"", "com_agents_category_empty": "No agents found in the {{category}} category", "com_agents_error_retry": "Try Again", @@ -1170,5 +1171,7 @@ "com_agents_no_results": "No agents found. Try another search term.", "com_agents_results_for": "Results for '{{query}}'", "com_nav_agents_marketplace": "Agent Marketplace", - "com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity" + "com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity", + "com_ui_agent_name_is_required": "Agent name is required", + "com_agents_missing_name": "Please type in a name before creating an agent." } diff --git a/client/src/utils/__tests__/agents.spec.tsx b/client/src/utils/__tests__/agents.spec.tsx index 3d960a07bf..6bbd2dbc38 100644 --- a/client/src/utils/__tests__/agents.spec.tsx +++ b/client/src/utils/__tests__/agents.spec.tsx @@ -33,7 +33,7 @@ describe('Agent Utilities', () => { id: '1', name: 'Test Agent', avatar: '/path/to/avatar.png', - } as t.Agent; + } as unknown as t.Agent; expect(getAgentAvatarUrl(agent)).toBe('/path/to/avatar.png'); }); @@ -62,7 +62,7 @@ describe('Agent Utilities', () => { id: '1', name: 'Test Agent', avatar: '/test-avatar.png', - } as t.Agent; + } as unknown as t.Agent; render(
{renderAgentAvatar(agent)}
); @@ -90,7 +90,7 @@ describe('Agent Utilities', () => { id: '1', name: 'Test Agent', avatar: '/test-avatar.png', - } as t.Agent; + } as unknown as t.Agent; const { rerender } = render(
{renderAgentAvatar(agent, { size: 'sm' })}
); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12'); @@ -107,7 +107,7 @@ describe('Agent Utilities', () => { id: '1', name: 'Test Agent', avatar: '/test-avatar.png', - } as t.Agent; + } as unknown as t.Agent; render(
{renderAgentAvatar(agent, { className: 'custom-class' })}
); @@ -120,7 +120,7 @@ describe('Agent Utilities', () => { id: '1', name: 'Test Agent', avatar: '/test-avatar.png', - } as t.Agent; + } as unknown as t.Agent; const { rerender } = render(
{renderAgentAvatar(agent, { showBorder: true })}
); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2'); diff --git a/config/seed-categories.js b/config/seed-categories.js deleted file mode 100644 index 2b4f5bba2a..0000000000 --- a/config/seed-categories.js +++ /dev/null @@ -1,106 +0,0 @@ -const connectDb = require('../api/lib/db/connectDb'); -const AgentCategory = require('../api/models/AgentCategory'); - -// Define category constants directly since the constants file was removed -const CATEGORY_VALUES = { - GENERAL: 'general', - HR: 'hr', - RD: 'rd', - FINANCE: 'finance', - IT: 'it', - SALES: 'sales', - AFTERSALES: 'aftersales', -}; - -const CATEGORY_DESCRIPTIONS = { - general: 'General purpose agents for common tasks and inquiries', - hr: 'Agents specialized in HR processes, policies, and employee support', - rd: 'Agents focused on R&D processes, innovation, and technical research', - finance: 'Agents specialized in financial analysis, budgeting, and accounting', - it: 'Agents for IT support, technical troubleshooting, and system administration', - sales: 'Agents focused on sales processes, customer relations, and marketing', - aftersales: 'Agents specialized in post-sale support, maintenance, and customer service', -}; - -/** - * Seed agent categories from existing constants into MongoDB - * This migration creates the initial category data in the database - */ -async function seedCategories() { - try { - await connectDb(); - console.log('Connected to database'); - - // Prepare category data from existing constants - const categoryData = [ - { - value: CATEGORY_VALUES.GENERAL, - label: 'General', - description: CATEGORY_DESCRIPTIONS.general, - order: 0, - }, - { - value: CATEGORY_VALUES.HR, - label: 'Human Resources', - description: CATEGORY_DESCRIPTIONS.hr, - order: 1, - }, - { - value: CATEGORY_VALUES.RD, - label: 'Research & Development', - description: CATEGORY_DESCRIPTIONS.rd, - order: 2, - }, - { - value: CATEGORY_VALUES.FINANCE, - label: 'Finance', - description: CATEGORY_DESCRIPTIONS.finance, - order: 3, - }, - { - value: CATEGORY_VALUES.IT, - label: 'Information Technology', - description: CATEGORY_DESCRIPTIONS.it, - order: 4, - }, - { - value: CATEGORY_VALUES.SALES, - label: 'Sales & Marketing', - description: CATEGORY_DESCRIPTIONS.sales, - order: 5, - }, - { - value: CATEGORY_VALUES.AFTERSALES, - label: 'After Sales', - description: CATEGORY_DESCRIPTIONS.aftersales, - order: 6, - }, - ]; - - console.log('Seeding categories...'); - const result = await AgentCategory.seedCategories(categoryData); - - console.log(`Successfully seeded ${result.upsertedCount} new categories`); - console.log(`Modified ${result.modifiedCount} existing categories`); - - // Verify the seeded data - const categories = await AgentCategory.getActiveCategories(); - console.log('Active categories in database:'); - categories.forEach((cat) => { - console.log(` - ${cat.value}: ${cat.label} (order: ${cat.order})`); - }); - - console.log('Category seeding completed successfully'); - process.exit(0); - } catch (error) { - console.error('Error seeding categories:', error); - process.exit(1); - } -} - -// Run if called directly -if (require.main === module) { - seedCategories(); -} - -module.exports = seedCategories; diff --git a/package.json b/package.json index 73c299297b..ba1c3c4592 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "delete-user": "node config/delete-user.js", "update-banner": "node config/update-banner.js", "delete-banner": "node config/delete-banner.js", - "seed-categories": "node config/seed-categories.js", "backend": "cross-env NODE_ENV=production node api/server/index.js", "backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js", "backend:stop": "node config/stop-backend.js", diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 56e709cb2f..4fc3a432f1 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -451,69 +451,23 @@ export const revertAgentVersion = ({ * Get agent categories with counts for marketplace tabs */ export const getAgentCategories = (): Promise => { - return request.get(endpoints.agents({ path: 'marketplace/categories' })); + return request.get(endpoints.agents({ path: 'categories' })); }; /** - * 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/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index ce33b65450..df90097282 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,15 +275,22 @@ 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 = { limit?: number; - before?: string | null; - after?: string | null; - order?: 'asc' | 'desc'; - provider?: AgentProvider; + requiredPermission: number; + category?: string; + search?: string; + cursor?: string; + promoted?: 0 | 1; }; export type AgentListResponse = { @@ -280,6 +299,7 @@ export type AgentListResponse = { first_id: string; last_id: string; has_more: boolean; + after?: string; }; export type AgentFile = { diff --git a/packages/data-schemas/src/methods/aclEntry.spec.ts b/packages/data-schemas/src/methods/aclEntry.spec.ts index 968bc8c588..54b00b78cc 100644 --- a/packages/data-schemas/src/methods/aclEntry.spec.ts +++ b/packages/data-schemas/src/methods/aclEntry.spec.ts @@ -271,22 +271,7 @@ describe('AclEntry Model Tests', () => { const effective = await methods.getEffectivePermissions(principalsList, 'agent', resourceId); /** Combined permissions should be VIEW | EDIT */ - expect(effective.effectiveBits).toBe(PermissionBits.VIEW | PermissionBits.EDIT); - - /** Should have 2 sources */ - expect(effective.sources).toHaveLength(2); - - /** Check sources */ - const userSource = effective.sources.find((s) => s.from === 'user'); - const groupSource = effective.sources.find((s) => s.from === 'group'); - - expect(userSource).toBeDefined(); - expect(userSource?.permBits).toBe(PermissionBits.VIEW); - expect(userSource?.direct).toBe(true); - - expect(groupSource).toBeDefined(); - expect(groupSource?.permBits).toBe(PermissionBits.EDIT); - expect(groupSource?.direct).toBe(true); + expect(effective).toBe(PermissionBits.VIEW | PermissionBits.EDIT); }); }); @@ -489,16 +474,15 @@ describe('AclEntry Model Tests', () => { inheritedFrom: projectId, }); - /** Get effective permissions including sources */ + /** Get effective permissions */ const effective = await methods.getEffectivePermissions( [{ principalType: 'user', principalId: userId }], 'agent', childResourceId, ); - expect(effective.sources).toHaveLength(1); - expect(effective.sources[0].inheritedFrom?.toString()).toBe(projectId.toString()); - expect(effective.sources[0].direct).toBe(false); + /** Should have VIEW permission from inherited entry */ + expect(effective).toBe(PermissionBits.VIEW); }); }); }); diff --git a/packages/data-schemas/src/methods/agentCategory.ts b/packages/data-schemas/src/methods/agentCategory.ts index a35e6128f3..fb1b1aaa14 100644 --- a/packages/data-schemas/src/methods/agentCategory.ts +++ b/packages/data-schemas/src/methods/agentCategory.ts @@ -144,6 +144,66 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) return await AgentCategory.find({}).sort({ order: 1, label: 1 }).lean(); } + /** + * Ensure default categories exist, seed them if none are present + * @returns Promise - true if categories were seeded, false if they already existed + */ + async function ensureDefaultCategories(): Promise { + const existingCategories = await getAllCategories(); + + if (existingCategories.length > 0) { + return false; // Categories already exist + } + + const defaultCategories = [ + { + value: 'general', + label: 'General', + description: 'General purpose agents for common tasks and inquiries', + order: 0, + }, + { + value: 'hr', + label: 'Human Resources', + description: 'Agents specialized in HR processes, policies, and employee support', + order: 1, + }, + { + value: 'rd', + label: 'Research & Development', + description: 'Agents focused on R&D processes, innovation, and technical research', + order: 2, + }, + { + value: 'finance', + label: 'Finance', + description: 'Agents specialized in financial analysis, budgeting, and accounting', + order: 3, + }, + { + value: 'it', + label: 'IT', + description: 'Agents for IT support, technical troubleshooting, and system administration', + order: 4, + }, + { + value: 'sales', + label: 'Sales', + description: 'Agents focused on sales processes, customer relations.', + order: 5, + }, + { + value: 'aftersales', + label: 'After Sales', + description: 'Agents specialized in post-sale support, maintenance, and customer service', + order: 6, + }, + ]; + + await seedCategories(defaultCategories); + return true; // Categories were seeded + } + return { getActiveCategories, getCategoriesWithCounts, @@ -155,6 +215,7 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) deleteCategory, findCategoryById, getAllCategories, + ensureDefaultCategories, }; } diff --git a/packages/data-schemas/src/models/agentCategory.ts b/packages/data-schemas/src/models/agentCategory.ts new file mode 100644 index 0000000000..1ba26c037a --- /dev/null +++ b/packages/data-schemas/src/models/agentCategory.ts @@ -0,0 +1,9 @@ +import agentCategorySchema from '~/schema/agentCategory'; +import type * as t from '~/types'; + +/** + * Creates or returns the AgentCategory model using the provided mongoose instance and schema + */ +export function createAgentCategoryModel(mongoose: typeof import('mongoose')) { + return mongoose.models.AgentCategory || mongoose.model('AgentCategory', agentCategorySchema); +} \ No newline at end of file diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index bf8776b60e..dd1d8ee23c 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -5,6 +5,7 @@ import { createBalanceModel } from './balance'; import { createConversationModel } from './convo'; import { createMessageModel } from './message'; import { createAgentModel } from './agent'; +import { createAgentCategoryModel } from './agentCategory'; import { createRoleModel } from './role'; import { createActionModel } from './action'; import { createAssistantModel } from './assistant'; @@ -37,6 +38,7 @@ export function createModels(mongoose: typeof import('mongoose')) { Conversation: createConversationModel(mongoose), Message: createMessageModel(mongoose), Agent: createAgentModel(mongoose), + AgentCategory: createAgentCategoryModel(mongoose), Role: createRoleModel(mongoose), Action: createActionModel(mongoose), Assistant: createAssistantModel(mongoose), diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index d6c2607e31..5d213b957a 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -102,7 +102,6 @@ const agentSchema = new Schema( type: { name: { type: String, - minlength: [3, 'Support contact name must be at least 3 characters.'], trim: true, }, email: { diff --git a/packages/data-schemas/src/schema/agentCategory.ts b/packages/data-schemas/src/schema/agentCategory.ts index 4bc0d778dd..61792de3f8 100644 --- a/packages/data-schemas/src/schema/agentCategory.ts +++ b/packages/data-schemas/src/schema/agentCategory.ts @@ -1,12 +1,5 @@ import { Schema, Document } from 'mongoose'; - -export interface IAgentCategory extends Document { - value: string; - label: string; - description?: string; - order: number; - isActive: boolean; -} +import type { IAgentCategory } from '~/types'; const agentCategorySchema = new Schema( { @@ -46,4 +39,4 @@ const agentCategorySchema = new Schema( agentCategorySchema.index({ isActive: 1, order: 1 }); -export default agentCategorySchema; \ No newline at end of file +export default agentCategorySchema; diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 74161b3c65..29d5191567 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -36,10 +36,5 @@ export interface IAgent extends Omit { versions?: Omit[]; category: string; support_contact?: ISupportContact; - category: string; - support_contact?: { - name?: string; - email?: string; - }; is_promoted?: boolean; } diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 3e6539492b..679553aa82 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -9,6 +9,7 @@ export * from './balance'; export * from './banner'; export * from './message'; export * from './agent'; +export * from './agentCategory'; export * from './role'; export * from './action'; export * from './assistant';