diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 319e987f6..c97b2e0b6 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -3,6 +3,7 @@ const axios = require('axios'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { Tools, EToolResources } = require('librechat-data-provider'); +const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { generateShortLivedToken } = require('~/server/services/AuthService'); const { getFiles } = require('~/models/File'); @@ -22,14 +23,19 @@ const primeFiles = async (options) => { const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? []; - const dbFiles = ( - (await getFiles( - { file_id: { $in: file_ids } }, - null, - { text: 0 }, - { userId: req?.user?.id, agentId }, - )) ?? [] - ).concat(resourceFiles); + + // Get all files first + const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? []; + + // Filter by access if user and agent are provided + let dbFiles; + if (req?.user?.id && agentId) { + dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId); + } else { + dbFiles = allFiles; + } + + dbFiles = dbFiles.concat(resourceFiles); let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`; diff --git a/api/jest.config.js b/api/jest.config.js index 7169e8225..fd8bd31bd 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -3,6 +3,7 @@ module.exports = { clearMocks: true, roots: [''], coverageDirectory: 'coverage', + testTimeout: 30000, // 30 seconds timeout for all tests setupFiles: [ './test/jestSetup.js', './test/__mocks__/logger.js', diff --git a/api/models/Agent.js b/api/models/Agent.js index 00f0af6cf..8cfca747e 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -5,7 +5,6 @@ const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } = require('librechat-data-provider').Constants; // Default category value for new agents -const AgentCategory = require('./AgentCategory'); const { getProjectByName, addAgentIdsToProject, @@ -15,80 +14,7 @@ const { const { getCachedTools } = require('~/server/services/Config'); // Category values are now imported from shared constants - -// Add category field to the Agent schema if it doesn't already exist -if (!agentSchema.paths.category) { - agentSchema.add({ - category: { - type: String, - trim: true, - validate: { - validator: async function (value) { - if (!value) return true; // Allow empty values (will use default) - - // Check if category exists in database - const validCategories = await AgentCategory.getValidCategoryValues(); - return validCategories.includes(value); - }, - message: function (props) { - return `"${props.value}" is not a valid agent category. Please check available categories.`; - }, - }, - index: true, - default: 'general', - }, - }); -} - -// Add support_contact field to the Agent schema if it doesn't already exist -if (!agentSchema.paths.support_contact) { - agentSchema.add({ - support_contact: { - type: Object, - default: {}, - name: { - type: String, - minlength: [3, 'Support contact name must be at least 3 characters.'], - trim: true, - }, - email: { - type: String, - match: [ - /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, - 'Please enter a valid email address.', - ], - trim: true, - }, - }, - }); -} - -// Add promotion field to the Agent schema if it doesn't already exist -if (!agentSchema.paths.is_promoted) { - agentSchema.add({ - is_promoted: { - type: Boolean, - default: false, - index: true, // Index for efficient promoted agent queries - }, - }); -} - -// Add additional indexes for marketplace functionality -agentSchema.index({ projectIds: 1, is_promoted: 1, updatedAt: -1 }); // Optimize promoted agents query -agentSchema.index({ category: 1, projectIds: 1, updatedAt: -1 }); // Optimize category filtering -agentSchema.index({ projectIds: 1, category: 1 }); // Optimize aggregation pipeline - -// Text indexes for search functionality -agentSchema.index( - { name: 'text', description: 'text' }, - { - weights: { - name: 3, // Name matches are 3x more important than description matches - description: 1, - }, - }, -); +// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas const { getActions } = require('./Action'); const { Agent } = require('~/db/models'); @@ -112,6 +38,7 @@ const createAgent = async (agentData) => { ], category: agentData.category || 'general', }; + return (await Agent.create(initialAgentData)).toObject(); }; @@ -257,54 +184,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; } } @@ -558,7 +547,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) } }, ], }; @@ -586,6 +575,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 @@ -820,6 +812,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 @@ -840,4 +840,5 @@ module.exports = { getListAgentsByAccess, removeAgentResourceFiles, generateActionMetadataHash, + countPromotedAgents, }; diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index eaaab4ef4..13c3a38f2 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -1237,6 +1237,328 @@ describe('models/Agent', () => { expect(secondUpdate.versions).toHaveLength(3); }); + test('should detect changes in support_contact fields', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + // Create agent with initial support_contact + await createAgent({ + id: agentId, + name: 'Agent with Support Contact', + provider: 'test', + model: 'test-model', + author: authorId, + support_contact: { + name: 'Initial Support', + email: 'initial@support.com', + }, + }); + + // Update support_contact name only + const firstUpdate = await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'Updated Support', + email: 'initial@support.com', + }, + }, + ); + + expect(firstUpdate.versions).toHaveLength(2); + expect(firstUpdate.support_contact.name).toBe('Updated Support'); + expect(firstUpdate.support_contact.email).toBe('initial@support.com'); + + // Update support_contact email only + const secondUpdate = await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'Updated Support', + email: 'updated@support.com', + }, + }, + ); + + expect(secondUpdate.versions).toHaveLength(3); + expect(secondUpdate.support_contact.email).toBe('updated@support.com'); + + // Try to update with same support_contact - should be detected as duplicate + await expect( + updateAgent( + { id: agentId }, + { + support_contact: { + name: 'Updated Support', + email: 'updated@support.com', + }, + }, + ), + ).rejects.toThrow('Duplicate version'); + }); + + test('should handle support_contact from empty to populated', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + // Create agent without support_contact + const agent = await createAgent({ + id: agentId, + name: 'Agent without Support', + provider: 'test', + model: 'test-model', + author: authorId, + }); + + // Verify support_contact is undefined since it wasn't provided + expect(agent.support_contact).toBeUndefined(); + + // Update to add support_contact + const updated = await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'New Support Team', + email: 'support@example.com', + }, + }, + ); + + expect(updated.versions).toHaveLength(2); + expect(updated.support_contact.name).toBe('New Support Team'); + expect(updated.support_contact.email).toBe('support@example.com'); + }); + + test('should handle support_contact edge cases in isDuplicateVersion', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + // Create agent with support_contact + await createAgent({ + id: agentId, + name: 'Edge Case Agent', + provider: 'test', + model: 'test-model', + author: authorId, + support_contact: { + name: 'Support', + email: 'support@test.com', + }, + }); + + // Update to empty support_contact + const emptyUpdate = await updateAgent( + { id: agentId }, + { + support_contact: {}, + }, + ); + + expect(emptyUpdate.versions).toHaveLength(2); + expect(emptyUpdate.support_contact).toEqual({}); + + // Update back to populated support_contact + const repopulated = await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'Support', + email: 'support@test.com', + }, + }, + ); + + expect(repopulated.versions).toHaveLength(3); + + // Verify all versions have correct support_contact + const finalAgent = await getAgent({ id: agentId }); + expect(finalAgent.versions[0].support_contact).toEqual({ + name: 'Support', + email: 'support@test.com', + }); + expect(finalAgent.versions[1].support_contact).toEqual({}); + expect(finalAgent.versions[2].support_contact).toEqual({ + name: 'Support', + email: 'support@test.com', + }); + }); + + test('should preserve support_contact in version history', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + // Create agent + await createAgent({ + id: agentId, + name: 'Version History Test', + provider: 'test', + model: 'test-model', + author: authorId, + support_contact: { + name: 'Initial Contact', + email: 'initial@test.com', + }, + }); + + // Multiple updates with different support_contact values + await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'Second Contact', + email: 'second@test.com', + }, + }, + ); + + await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'Third Contact', + email: 'third@test.com', + }, + }, + ); + + const finalAgent = await getAgent({ id: agentId }); + + // Verify version history + expect(finalAgent.versions).toHaveLength(3); + expect(finalAgent.versions[0].support_contact).toEqual({ + name: 'Initial Contact', + email: 'initial@test.com', + }); + expect(finalAgent.versions[1].support_contact).toEqual({ + name: 'Second Contact', + email: 'second@test.com', + }); + expect(finalAgent.versions[2].support_contact).toEqual({ + name: 'Third Contact', + email: 'third@test.com', + }); + + // Current state should match last version + expect(finalAgent.support_contact).toEqual({ + name: 'Third Contact', + email: 'third@test.com', + }); + }); + + test('should handle partial support_contact updates', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + // Create agent with full support_contact + await createAgent({ + id: agentId, + name: 'Partial Update Test', + provider: 'test', + model: 'test-model', + author: authorId, + support_contact: { + name: 'Original Name', + email: 'original@email.com', + }, + }); + + // MongoDB's findOneAndUpdate will replace the entire support_contact object + // So we need to verify that partial updates still work correctly + const updated = await updateAgent( + { id: agentId }, + { + support_contact: { + name: 'New Name', + email: '', // Empty email + }, + }, + ); + + expect(updated.versions).toHaveLength(2); + expect(updated.support_contact.name).toBe('New Name'); + expect(updated.support_contact.email).toBe(''); + + // Verify isDuplicateVersion works with partial changes + await expect( + updateAgent( + { id: agentId }, + { + support_contact: { + name: 'New Name', + email: '', + }, + }, + ), + ).rejects.toThrow('Duplicate version'); + }); + + // Edge Cases + describe.each([ + { + operation: 'add', + name: 'empty file_id', + needsAgent: true, + params: { tool_resource: 'file_search', file_id: '' }, + shouldResolve: true, + }, + { + operation: 'add', + name: 'non-existent agent', + needsAgent: false, + params: { tool_resource: 'file_search', file_id: 'file123' }, + shouldResolve: false, + error: 'Agent not found for adding resource file', + }, + ])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => { + test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => { + const agent = needsAgent ? await createBasicAgent() : null; + const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`; + + if (shouldResolve) { + await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined(); + } else { + await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error); + } + }); + }); + + describe.each([ + { + name: 'empty files array', + files: [], + needsAgent: true, + shouldResolve: true, + }, + { + name: 'non-existent tool_resource', + files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }], + needsAgent: true, + shouldResolve: true, + }, + { + name: 'non-existent agent', + files: [{ tool_resource: 'file_search', file_id: 'file123' }], + needsAgent: false, + shouldResolve: false, + error: 'Agent not found for removing resource files', + }, + ])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => { + test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => { + const agent = needsAgent ? await createBasicAgent() : null; + const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`; + + if (shouldResolve) { + const result = await removeAgentResourceFiles({ agent_id, files }); + expect(result).toBeDefined(); + if (agent) { + expect(result.id).toBe(agent.id); + } + } else { + await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error); + } + }); + }); + describe('Edge Cases', () => { test('should handle extremely large version history', async () => { const agentId = `agent_${uuidv4()}`; @@ -2565,6 +2887,93 @@ describe('models/Agent', () => { }); }); +describe('Support Contact Field', () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + await mongoose.connect(mongoUri); + }, 20000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await Agent.deleteMany({}); + }); + + it('should not create subdocument with ObjectId for support_contact', async () => { + const userId = new mongoose.Types.ObjectId(); + const agentData = { + id: 'agent_test_support', + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: userId, + support_contact: { + name: 'Support Team', + email: 'support@example.com', + }, + }; + + // Create agent + const agent = await createAgent(agentData); + + // Verify support_contact is stored correctly + expect(agent.support_contact).toBeDefined(); + expect(agent.support_contact.name).toBe('Support Team'); + expect(agent.support_contact.email).toBe('support@example.com'); + + // Verify no _id field is created in support_contact + expect(agent.support_contact._id).toBeUndefined(); + + // Fetch from database to double-check + const dbAgent = await Agent.findOne({ id: agentData.id }); + expect(dbAgent.support_contact).toBeDefined(); + expect(dbAgent.support_contact.name).toBe('Support Team'); + expect(dbAgent.support_contact.email).toBe('support@example.com'); + expect(dbAgent.support_contact._id).toBeUndefined(); + }); + + it('should handle empty support_contact correctly', async () => { + const userId = new mongoose.Types.ObjectId(); + const agentData = { + id: 'agent_test_empty_support', + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: userId, + support_contact: {}, + }; + + const agent = await createAgent(agentData); + + // Verify empty support_contact is stored as empty object + expect(agent.support_contact).toEqual({}); + expect(agent.support_contact._id).toBeUndefined(); + }); + + it('should handle missing support_contact correctly', async () => { + const userId = new mongoose.Types.ObjectId(); + const agentData = { + id: 'agent_test_no_support', + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: userId, + }; + + const agent = await createAgent(agentData); + + // Verify support_contact is undefined when not provided + expect(agent.support_contact).toBeUndefined(); + }); +}); + function createBasicAgent(overrides = {}) { const defaults = { id: `agent_${uuidv4()}`, diff --git a/api/models/AgentCategory.js b/api/models/AgentCategory.js deleted file mode 100644 index c4265228d..000000000 --- a/api/models/AgentCategory.js +++ /dev/null @@ -1,125 +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; \ No newline at end of file diff --git a/api/models/File.js b/api/models/File.js index 3d735434e..1ee943131 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,7 +1,5 @@ const { logger } = require('@librechat/data-schemas'); -const { EToolResources, FileContext, Constants } = require('librechat-data-provider'); -const { getProjectByName } = require('./Project'); -const { getAgent } = require('./Agent'); +const { EToolResources, FileContext } = require('librechat-data-provider'); const { File } = require('~/db/models'); /** @@ -14,124 +12,17 @@ const findFileById = async (file_id, options = {}) => { return await File.findOne({ file_id, ...options }).lean(); }; -/** - * Checks if a user has access to multiple files through a shared agent (batch operation) - * @param {string} userId - The user ID to check access for - * @param {string[]} fileIds - Array of file IDs to check - * @param {string} agentId - The agent ID that might grant access - * @returns {Promise>} Map of fileId to access status - */ -const hasAccessToFilesViaAgent = async (userId, fileIds, agentId, checkCollaborative = true) => { - const accessMap = new Map(); - - // Initialize all files as no access - fileIds.forEach((fileId) => accessMap.set(fileId, false)); - - try { - const agent = await getAgent({ id: agentId }); - - if (!agent) { - return accessMap; - } - - // Check if user is the author - if so, grant access to all files - if (agent.author.toString() === userId) { - fileIds.forEach((fileId) => accessMap.set(fileId, true)); - return accessMap; - } - - // Check if agent is shared with the user via projects - if (!agent.projectIds || agent.projectIds.length === 0) { - return accessMap; - } - - // Check if agent is in global project - const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); - if ( - !globalProject || - !agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) - ) { - return accessMap; - } - - // Agent is globally shared - check if it's collaborative - if (checkCollaborative && !agent.isCollaborative) { - return accessMap; - } - - // Check which files are actually attached - const attachedFileIds = new Set(); - if (agent.tool_resources) { - for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) { - if (resource?.file_ids && Array.isArray(resource.file_ids)) { - resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId)); - } - } - } - - // Grant access only to files that are attached to this agent - fileIds.forEach((fileId) => { - if (attachedFileIds.has(fileId)) { - accessMap.set(fileId, true); - } - }); - - return accessMap; - } catch (error) { - logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error); - return accessMap; - } -}; - /** * Retrieves files matching a given filter, sorted by the most recently updated. * @param {Object} filter - The filter criteria to apply. * @param {Object} [_sortOptions] - Optional sort parameters. * @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results. * Default excludes the 'text' field. - * @param {Object} [options] - Additional options - * @param {string} [options.userId] - User ID for access control - * @param {string} [options.agentId] - Agent ID that might grant access to files * @returns {Promise>} A promise that resolves to an array of file documents. */ -const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => { +const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; - const files = await File.find(filter).select(selectFields).sort(sortOptions).lean(); - - // If userId and agentId are provided, filter files based on access - if (options.userId && options.agentId) { - // Collect file IDs that need access check - const filesToCheck = []; - const ownedFiles = []; - - for (const file of files) { - if (file.user && file.user.toString() === options.userId) { - ownedFiles.push(file); - } else { - filesToCheck.push(file); - } - } - - if (filesToCheck.length === 0) { - return ownedFiles; - } - - // Batch check access for all non-owned files - const fileIds = filesToCheck.map((f) => f.file_id); - const accessMap = await hasAccessToFilesViaAgent( - options.userId, - fileIds, - options.agentId, - false, - ); - - // Filter files based on access - const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id)); - - return [...ownedFiles, ...accessibleFiles]; - } - - return files; + return await File.find(filter).select(selectFields).sort(sortOptions).lean(); }; /** @@ -285,5 +176,4 @@ module.exports = { deleteFiles, deleteFileByFilter, batchUpdateFiles, - hasAccessToFilesViaAgent, }; diff --git a/api/models/File.spec.js b/api/models/File.spec.js index 9861571e3..99464fdbd 100644 --- a/api/models/File.spec.js +++ b/api/models/File.spec.js @@ -1,17 +1,17 @@ const mongoose = require('mongoose'); const { v4: uuidv4 } = require('uuid'); -const { fileSchema } = require('@librechat/data-schemas'); -const { agentSchema } = require('@librechat/data-schemas'); -const { projectSchema } = require('@librechat/data-schemas'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; +const { createModels } = require('@librechat/data-schemas'); const { getFiles, createFile } = require('./File'); -const { getProjectByName } = require('./Project'); const { createAgent } = require('./Agent'); +const { grantPermission } = require('~/server/services/PermissionService'); +const { seedDefaultRoles } = require('~/models'); let File; let Agent; -let Project; +let AclEntry; +let User; +let modelsToCleanup = []; describe('File Access Control', () => { let mongoServer; @@ -19,13 +19,41 @@ describe('File Access Control', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - File = mongoose.models.File || mongoose.model('File', fileSchema); - Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); - Project = mongoose.models.Project || mongoose.model('Project', projectSchema); await mongoose.connect(mongoUri); + + // Initialize all models + const models = createModels(mongoose); + + // Track which models we're adding + modelsToCleanup = Object.keys(models); + + // Register models on mongoose.models so methods can access them + const dbModels = require('~/db/models'); + Object.assign(mongoose.models, dbModels); + + File = dbModels.File; + Agent = dbModels.Agent; + AclEntry = dbModels.AclEntry; + User = dbModels.User; + + // Seed default roles + await seedDefaultRoles(); }); afterAll(async () => { + // Clean up all collections before disconnecting + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + + // Clear only the models we added + for (const modelName of modelsToCleanup) { + if (mongoose.models[modelName]) { + delete mongoose.models[modelName]; + } + } + await mongoose.disconnect(); await mongoServer.stop(); }); @@ -33,16 +61,33 @@ describe('File Access Control', () => { beforeEach(async () => { await File.deleteMany({}); await Agent.deleteMany({}); - await Project.deleteMany({}); + await AclEntry.deleteMany({}); + await User.deleteMany({}); + // Don't delete AccessRole as they are seeded defaults needed for tests }); describe('hasAccessToFilesViaAgent', () => { it('should efficiently check access for multiple files at once', async () => { - const userId = new mongoose.Types.ObjectId().toString(); - const authorId = new mongoose.Types.ObjectId().toString(); + const userId = new mongoose.Types.ObjectId(); + const authorId = new mongoose.Types.ObjectId(); const agentId = uuidv4(); const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()]; + // Create users + await User.create({ + _id: userId, + email: 'user@example.com', + emailVerified: true, + provider: 'local', + }); + + await User.create({ + _id: authorId, + email: 'author@example.com', + emailVerified: true, + provider: 'local', + }); + // Create files for (const fileId of fileIds) { await createFile({ @@ -54,13 +99,12 @@ describe('File Access Control', () => { } // Create agent with only first two files attached - await createAgent({ + const agent = await createAgent({ id: agentId, name: 'Test Agent', author: authorId, model: 'gpt-4', provider: 'openai', - isCollaborative: true, tool_resources: { file_search: { file_ids: [fileIds[0], fileIds[1]], @@ -68,15 +112,19 @@ describe('File Access Control', () => { }, }); - // Get or create global project - const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); - - // Share agent globally - await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); + // Grant EDIT permission to user on the agent + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_editor', + grantedBy: authorId, + }); // Check access for all files - const { hasAccessToFilesViaAgent } = require('./File'); - const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); + const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId); // Should have access only to the first two files expect(accessMap.get(fileIds[0])).toBe(true); @@ -86,10 +134,18 @@ describe('File Access Control', () => { }); it('should grant access to all files when user is the agent author', async () => { - const authorId = new mongoose.Types.ObjectId().toString(); + const authorId = new mongoose.Types.ObjectId(); const agentId = uuidv4(); const fileIds = [uuidv4(), uuidv4(), uuidv4()]; + // Create author user + await User.create({ + _id: authorId, + email: 'author@example.com', + emailVerified: true, + provider: 'local', + }); + // Create agent await createAgent({ id: agentId, @@ -105,8 +161,8 @@ describe('File Access Control', () => { }); // Check access as the author - const { hasAccessToFilesViaAgent } = require('./File'); - const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); + const accessMap = await hasAccessToFilesViaAgent(authorId.toString(), fileIds, agentId); // Author should have access to all files expect(accessMap.get(fileIds[0])).toBe(true); @@ -115,31 +171,57 @@ describe('File Access Control', () => { }); it('should handle non-existent agent gracefully', async () => { - const userId = new mongoose.Types.ObjectId().toString(); + const userId = new mongoose.Types.ObjectId(); const fileIds = [uuidv4(), uuidv4()]; - const { hasAccessToFilesViaAgent } = require('./File'); - const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent'); + // Create user + await User.create({ + _id: userId, + email: 'user@example.com', + emailVerified: true, + provider: 'local', + }); + + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); + const accessMap = await hasAccessToFilesViaAgent( + userId.toString(), + fileIds, + 'non-existent-agent', + ); // Should have no access to any files expect(accessMap.get(fileIds[0])).toBe(false); expect(accessMap.get(fileIds[1])).toBe(false); }); - it('should deny access when agent is not collaborative', async () => { - const userId = new mongoose.Types.ObjectId().toString(); - const authorId = new mongoose.Types.ObjectId().toString(); + it('should deny access when user only has VIEW permission', async () => { + const userId = new mongoose.Types.ObjectId(); + const authorId = new mongoose.Types.ObjectId(); const agentId = uuidv4(); const fileIds = [uuidv4(), uuidv4()]; - // Create agent with files but isCollaborative: false - await createAgent({ + // Create users + await User.create({ + _id: userId, + email: 'user@example.com', + emailVerified: true, + provider: 'local', + }); + + await User.create({ + _id: authorId, + email: 'author@example.com', + emailVerified: true, + provider: 'local', + }); + + // Create agent with files + const agent = await createAgent({ id: agentId, - name: 'Non-Collaborative Agent', + name: 'View-Only Agent', author: authorId, model: 'gpt-4', provider: 'openai', - isCollaborative: false, tool_resources: { file_search: { file_ids: fileIds, @@ -147,17 +229,21 @@ describe('File Access Control', () => { }, }); - // Get or create global project - const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); - - // Share agent globally - await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); + // Grant only VIEW permission to user on the agent + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_viewer', + grantedBy: authorId, + }); // Check access for files - const { hasAccessToFilesViaAgent } = require('./File'); - const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); + const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId); - // Should have no access to any files when isCollaborative is false + // Should have no access to any files when only VIEW permission expect(accessMap.get(fileIds[0])).toBe(false); expect(accessMap.get(fileIds[1])).toBe(false); }); @@ -172,18 +258,28 @@ describe('File Access Control', () => { const sharedFileId = `file_${uuidv4()}`; const inaccessibleFileId = `file_${uuidv4()}`; - // Create/get global project using getProjectByName which will upsert - const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME); + // Create users + await User.create({ + _id: userId, + email: 'user@example.com', + emailVerified: true, + provider: 'local', + }); + + await User.create({ + _id: authorId, + email: 'author@example.com', + emailVerified: true, + provider: 'local', + }); // Create agent with shared file - await createAgent({ + const agent = await createAgent({ id: agentId, name: 'Shared Agent', provider: 'test', model: 'test-model', author: authorId, - projectIds: [globalProject._id], - isCollaborative: true, tool_resources: { file_search: { file_ids: [sharedFileId], @@ -191,6 +287,16 @@ describe('File Access Control', () => { }, }); + // Grant EDIT permission to user on the agent + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_editor', + grantedBy: authorId, + }); + // Create files await createFile({ file_id: ownedFileId, @@ -220,14 +326,17 @@ describe('File Access Control', () => { bytes: 300, }); - // Get files with access control - const files = await getFiles( + // Get all files first + const allFiles = await getFiles( { file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } }, null, { text: 0 }, - { userId: userId.toString(), agentId }, ); + // Then filter by access control + const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); + const files = await filterFilesByAgentAccess(allFiles, userId.toString(), agentId); + expect(files).toHaveLength(2); expect(files.map((f) => f.file_id)).toContain(ownedFileId); expect(files.map((f) => f.file_id)).toContain(sharedFileId); diff --git a/api/models/Role.js b/api/models/Role.js index d7f1c0f9c..8f9e8810f 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 bee6bd3d9..000000000 --- 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 6e9d732a2..3089a637a 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -17,6 +17,8 @@ const { updateAgent, deleteAgent, getListAgentsByAccess, + countPromotedAgents, + revertAgentVersion, } = require('~/models/Agent'); const { grantPermission, @@ -30,8 +32,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, @@ -45,7 +47,7 @@ const systemTools = { * @param {ServerRequest} req - The request object. * @param {AgentCreateParams} req.body - The request body. * @param {ServerResponse} res - The response object. - * @returns {Agent} 201 - success response - application/json + * @returns {Promise} 201 - success response - application/json */ const createAgentHandler = async (req, res) => { try { @@ -402,12 +404,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', @@ -416,7 +449,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) => { @@ -592,7 +627,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, @@ -602,4 +678,5 @@ module.exports = { getListAgents: getListAgentsHandler, uploadAgentAvatar: uploadAgentAvatarHandler, revertAgentVersion: revertAgentVersionHandler, + getAgentCategories, }; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 0574dde4e..dd12634d6 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -235,6 +235,81 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.tool_resources.invalid_resource).toBeUndefined(); }); + test('should handle support_contact with empty strings', async () => { + const dataWithEmptyContact = { + provider: 'openai', + model: 'gpt-4', + name: 'Agent with Empty Contact', + support_contact: { + name: '', + email: '', + }, + }; + + mockReq.body = dataWithEmptyContact; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + + const createdAgent = mockRes.json.mock.calls[0][0]; + expect(createdAgent.name).toBe('Agent with Empty Contact'); + expect(createdAgent.support_contact).toBeDefined(); + expect(createdAgent.support_contact.name).toBe(''); + expect(createdAgent.support_contact.email).toBe(''); + }); + + test('should handle support_contact with valid email', async () => { + const dataWithValidContact = { + provider: 'openai', + model: 'gpt-4', + name: 'Agent with Valid Contact', + support_contact: { + name: 'Support Team', + email: 'support@example.com', + }, + }; + + mockReq.body = dataWithValidContact; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + + const createdAgent = mockRes.json.mock.calls[0][0]; + expect(createdAgent.support_contact).toBeDefined(); + expect(createdAgent.support_contact.name).toBe('Support Team'); + expect(createdAgent.support_contact.email).toBe('support@example.com'); + }); + + test('should reject support_contact with invalid email', async () => { + const dataWithInvalidEmail = { + provider: 'openai', + model: 'gpt-4', + name: 'Agent with Invalid Email', + support_contact: { + name: 'Support', + email: 'not-an-email', + }, + }; + + mockReq.body = dataWithInvalidEmail; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Invalid request data', + details: expect.arrayContaining([ + expect.objectContaining({ + path: ['support_contact', 'email'], + }), + ]), + }), + ); + }); + test('should handle avatar validation', async () => { const dataWithAvatar = { provider: 'openai', @@ -372,52 +447,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.id).toBe(existingAgentId); }); - test('should reject update from non-author when not collaborative', async () => { - const differentUserId = new mongoose.Types.ObjectId().toString(); - mockReq.user.id = differentUserId; // Different user - mockReq.params.id = existingAgentId; - mockReq.body = { - name: 'Unauthorized Update', - }; - - await updateAgentHandler(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(403); - expect(mockRes.json).toHaveBeenCalledWith({ - error: 'You do not have permission to modify this non-collaborative agent', - }); - - // Verify agent was not modified in database - const agentInDb = await Agent.findOne({ id: existingAgentId }); - expect(agentInDb.name).toBe('Original Agent'); - }); - - test('should allow update from non-author when collaborative', async () => { - // First make the agent collaborative - await Agent.updateOne({ id: existingAgentId }, { isCollaborative: true }); - - const differentUserId = new mongoose.Types.ObjectId().toString(); - mockReq.user.id = differentUserId; // Different user - mockReq.params.id = existingAgentId; - mockReq.body = { - name: 'Collaborative Update', - }; - - await updateAgentHandler(mockReq, mockRes); - - expect(mockRes.status).not.toHaveBeenCalledWith(403); - expect(mockRes.json).toHaveBeenCalled(); - - const updatedAgent = mockRes.json.mock.calls[0][0]; - expect(updatedAgent.name).toBe('Collaborative Update'); - // Author field should be removed for non-author - expect(updatedAgent.author).toBeUndefined(); - - // Verify in database - const agentInDb = await Agent.findOne({ id: existingAgentId }); - expect(agentInDb.name).toBe('Collaborative Update'); - }); - test('should allow admin to update any agent', async () => { const adminUserId = new mongoose.Types.ObjectId().toString(); mockReq.user.id = adminUserId; @@ -577,45 +606,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.__v).not.toBe(99); }); - test('should prevent privilege escalation through isCollaborative', async () => { - // Create a non-collaborative agent - const authorId = new mongoose.Types.ObjectId(); - const agent = await Agent.create({ - id: `agent_${uuidv4()}`, - name: 'Private Agent', - provider: 'openai', - model: 'gpt-4', - author: authorId, - isCollaborative: false, - versions: [ - { - name: 'Private Agent', - provider: 'openai', - model: 'gpt-4', - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - }); - - // Try to make it collaborative as a different user - const attackerId = new mongoose.Types.ObjectId().toString(); - mockReq.user.id = attackerId; - mockReq.params.id = agent.id; - mockReq.body = { - isCollaborative: true, // Trying to escalate privileges - }; - - await updateAgentHandler(mockReq, mockRes); - - // Should be rejected - expect(mockRes.status).toHaveBeenCalledWith(403); - - // Verify in database that it's still not collaborative - const agentInDb = await Agent.findOne({ id: agent.id }); - expect(agentInDb.isCollaborative).toBe(false); - }); - test('should prevent author hijacking', async () => { const originalAuthorId = new mongoose.Types.ObjectId(); const attackerId = new mongoose.Types.ObjectId(); diff --git a/api/server/middleware/checkPeoplePickerAccess.js b/api/server/middleware/checkPeoplePickerAccess.js new file mode 100644 index 000000000..b2931608f --- /dev/null +++ b/api/server/middleware/checkPeoplePickerAccess.js @@ -0,0 +1,72 @@ +const { PermissionTypes, Permissions } = require('librechat-data-provider'); +const { getRoleByName } = require('~/models/Role'); +const { logger } = require('~/config'); + +/** + * Middleware to check if user has permission to access people picker functionality + * Checks specific permission based on the 'type' query parameter: + * - type=user: requires VIEW_USERS permission + * - type=group: requires VIEW_GROUPS permission + * - no type (mixed search): requires either VIEW_USERS OR VIEW_GROUPS + */ +const checkPeoplePickerAccess = async (req, res, next) => { + try { + const user = req.user; + if (!user || !user.role) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication required', + }); + } + + const role = await getRoleByName(user.role); + if (!role || !role.permissions) { + return res.status(403).json({ + error: 'Forbidden', + message: 'No permissions configured for user role', + }); + } + + const { type } = req.query; + const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {}; + const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true; + const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true; + + if (type === 'user') { + if (!canViewUsers) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions to search for users', + }); + } + } else if (type === 'group') { + if (!canViewGroups) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions to search for groups', + }); + } + } else { + if (!canViewUsers || !canViewGroups) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions to search for both users and groups', + }); + } + } + next(); + } catch (error) { + logger.error( + `[checkPeoplePickerAccess][${req.user?.id}] checkPeoplePickerAccess error for req.query.type = ${req.query.type}`, + error, + ); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to check permissions', + }); + } +}; + +module.exports = { + checkPeoplePickerAccess, +}; diff --git a/api/server/middleware/roles/access.spec.js b/api/server/middleware/roles/access.spec.js index 435b20e9f..fe8d77a4f 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,127 @@ 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, - ]); + test('should return false if user has only some of multiple permissions', async () => { + // User has USE but not CREATE, so should fail when checking for both + const result = await checkAccess({ + req: {}, + user: { id: 'user123', role: 'user' }, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.CREATE, Permissions.USE], + getRoleByName, + }); + expect(result).toBe(false); + }); + + test('should return true if user has all of multiple permissions', async () => { + // Admin has both USE and CREATE + const result = await checkAccess({ + req: {}, + user: { id: 'admin123', role: 'admin' }, + 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 +262,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 +281,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 +294,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 +316,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 +329,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 +355,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/accessPermissions.js b/api/server/routes/accessPermissions.js index e5720de81..814fa233c 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -8,6 +8,7 @@ const { searchPrincipals, } = require('~/server/controllers/PermissionsController'); const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware'); +const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess'); const router = express.Router(); @@ -25,7 +26,7 @@ router.use(uaParser); * GET /api/permissions/search-principals * Search for users and groups to grant permissions */ -router.get('/search-principals', searchPrincipals); +router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals); /** * GET /api/permissions/{resourceType}/roles diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 3394693c8..1f170b1d6 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -9,11 +9,13 @@ const { removeNullishValues, } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); +const { findAccessibleResources } = require('~/server/services/PermissionService'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { isActionDomainAllowed } = require('~/server/services/domains'); const { canAccessAgentResource } = require('~/server/middleware'); const { getAgent, updateAgent } = require('~/models/Agent'); const { getRoleByName } = require('~/models/Role'); +const { getListAgentsByAccess } = require('~/models/Agent'); const router = express.Router(); @@ -31,9 +33,22 @@ const checkAgentCreate = generateCheckAccess({ */ router.get('/', async (req, res) => { try { - // Get all actions for the user (admin permissions handled by middleware if needed) - const searchParams = { user: req.user.id }; - res.json(await getActions(searchParams)); + const userId = req.user.id; + const editableAgentObjectIds = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: PermissionBits.EDIT, + }); + + const agentsResponse = await getListAgentsByAccess({ + accessibleIds: editableAgentObjectIds, + }); + + const editableAgentIds = agentsResponse.data.map((agent) => agent.id); + const actions = + editableAgentIds.length > 0 ? await getActions({ agent_id: { $in: editableAgentIds } }) : []; + + res.json(actions); } catch (error) { res.status(500).json({ error: error.message }); } diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 7c9423cf1..1c4f69d9a 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 5733ffed0..000000000 --- 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 253e800ed..9073decd5 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/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js index a66955998..0ef5580eb 100644 --- a/api/server/routes/files/files.agents.test.js +++ b/api/server/routes/files/files.agents.test.js @@ -3,9 +3,11 @@ const request = require('supertest'); const mongoose = require('mongoose'); const { v4: uuidv4 } = require('uuid'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; +const { createMethods } = require('@librechat/data-schemas'); +const { createAgent } = require('~/models/Agent'); +const { createFile } = require('~/models/File'); -// Mock dependencies +// Only mock the external dependencies that we don't want to test jest.mock('~/server/services/Files/process', () => ({ processDeleteRequest: jest.fn().mockResolvedValue({}), filterFile: jest.fn(), @@ -25,31 +27,8 @@ jest.mock('~/server/services/Tools/credentials', () => ({ loadAuthValues: jest.fn(), })); -jest.mock('~/server/services/Files/S3/crud', () => ({ - refreshS3FileUrls: jest.fn(), -})); - -jest.mock('~/cache', () => ({ - getLogStores: jest.fn(() => ({ - get: jest.fn(), - set: jest.fn(), - })), -})); - -jest.mock('~/config', () => ({ - logger: { - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -const { createFile } = require('~/models/File'); -const { createAgent } = require('~/models/Agent'); -const { getProjectByName } = require('~/models/Project'); - -// Import the router after mocks -const router = require('./files'); +// Import the router +const router = require('~/server/routes/files/files'); describe('File Routes - Agent Files Endpoint', () => { let app; @@ -60,13 +39,42 @@ describe('File Routes - Agent Files Endpoint', () => { let fileId1; let fileId2; let fileId3; + let File; + let User; + let Agent; + let methods; + let AclEntry; + // eslint-disable-next-line no-unused-vars + let AccessRole; + let modelsToCleanup = []; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); - // Initialize models - require('~/db/models'); + // Initialize all models using createModels + const { createModels } = require('@librechat/data-schemas'); + const models = createModels(mongoose); + + // Track which models we're adding + modelsToCleanup = Object.keys(models); + + // Register models on mongoose.models so methods can access them + Object.assign(mongoose.models, models); + + // Create methods with our test mongoose instance + methods = createMethods(mongoose); + + // Now we can access models from the db/models + File = models.File; + Agent = models.Agent; + AclEntry = models.AclEntry; + User = models.User; + AccessRole = models.AccessRole; + + // Seed default roles using our methods + await methods.seedDefaultRoles(); app = express(); app.use(express.json()); @@ -82,88 +90,121 @@ describe('File Routes - Agent Files Endpoint', () => { }); afterAll(async () => { - await mongoose.disconnect(); - await mongoServer.stop(); - }); - - beforeEach(async () => { - jest.clearAllMocks(); - - // Clear database + // Clean up all collections before disconnecting const collections = mongoose.connection.collections; for (const key in collections) { await collections[key].deleteMany({}); } - authorId = new mongoose.Types.ObjectId().toString(); - otherUserId = new mongoose.Types.ObjectId().toString(); + // Clear only the models we added + for (const modelName of modelsToCleanup) { + if (mongoose.models[modelName]) { + delete mongoose.models[modelName]; + } + } + + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + // Clean up all test data + await File.deleteMany({}); + await Agent.deleteMany({}); + await User.deleteMany({}); + await AclEntry.deleteMany({}); + // Don't delete AccessRole as they are seeded defaults needed for tests + + // Create test users + authorId = new mongoose.Types.ObjectId(); + otherUserId = new mongoose.Types.ObjectId(); agentId = uuidv4(); fileId1 = uuidv4(); fileId2 = uuidv4(); fileId3 = uuidv4(); + // Create users in database + await User.create({ + _id: authorId, + username: 'author', + email: 'author@test.com', + }); + + await User.create({ + _id: otherUserId, + username: 'other', + email: 'other@test.com', + }); + // Create files await createFile({ user: authorId, file_id: fileId1, - filename: 'agent-file1.txt', - filepath: `/uploads/${authorId}/${fileId1}`, - bytes: 1024, + filename: 'file1.txt', + filepath: '/uploads/file1.txt', + bytes: 100, type: 'text/plain', }); await createFile({ user: authorId, file_id: fileId2, - filename: 'agent-file2.txt', - filepath: `/uploads/${authorId}/${fileId2}`, - bytes: 2048, + filename: 'file2.txt', + filepath: '/uploads/file2.txt', + bytes: 200, type: 'text/plain', }); await createFile({ user: otherUserId, file_id: fileId3, - filename: 'user-file.txt', - filepath: `/uploads/${otherUserId}/${fileId3}`, - bytes: 512, + filename: 'file3.txt', + filepath: '/uploads/file3.txt', + bytes: 300, type: 'text/plain', }); - - // Create an agent with files attached - await createAgent({ - id: agentId, - name: 'Test Agent', - author: authorId, - model: 'gpt-4', - provider: 'openai', - isCollaborative: true, - tool_resources: { - file_search: { - file_ids: [fileId1, fileId2], - }, - }, - }); - - // Share the agent globally - const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); - if (globalProject) { - const { updateAgent } = require('~/models/Agent'); - await updateAgent({ id: agentId }, { projectIds: [globalProject._id] }); - } }); describe('GET /files/agent/:agent_id', () => { - it('should return files accessible through the agent for non-author', async () => { + it('should return files accessible through the agent for non-author with EDIT permission', async () => { + // Create an agent with files attached + const agent = await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId1, fileId2], + }, + }, + }); + + // Grant EDIT permission to user on the agent using PermissionService + const { grantPermission } = require('~/server/services/PermissionService'); + await grantPermission({ + principalType: 'user', + principalId: otherUserId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_editor', + grantedBy: authorId, + }); + + // Mock req.user for this request + app.use((req, res, next) => { + req.user = { id: otherUserId.toString() }; + next(); + }); + const response = await request(app).get(`/files/agent/${agentId}`); expect(response.status).toBe(200); - expect(response.body).toHaveLength(2); // Only agent files, not user-owned files - - const fileIds = response.body.map((f) => f.file_id); - expect(fileIds).toContain(fileId1); - expect(fileIds).toContain(fileId2); - expect(fileIds).not.toContain(fileId3); // User's own file not included + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body.map((f) => f.file_id)).toContain(fileId1); + expect(response.body.map((f) => f.file_id)).toContain(fileId2); }); it('should return 400 when agent_id is not provided', async () => { @@ -176,45 +217,63 @@ describe('File Routes - Agent Files Endpoint', () => { const response = await request(app).get('/files/agent/non-existent-agent'); expect(response.status).toBe(200); - expect(response.body).toEqual([]); // Empty array for non-existent agent + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toEqual([]); }); - it('should return empty array when agent is not collaborative', async () => { - // Create a non-collaborative agent - const nonCollabAgentId = uuidv4(); - await createAgent({ - id: nonCollabAgentId, - name: 'Non-Collaborative Agent', - author: authorId, - model: 'gpt-4', + it('should return empty array when user only has VIEW permission', async () => { + // Create an agent with files attached + const agent = await createAgent({ + id: agentId, + name: 'Test Agent', provider: 'openai', - isCollaborative: false, + model: 'gpt-4', + author: authorId, tool_resources: { file_search: { - file_ids: [fileId1], + file_ids: [fileId1, fileId2], }, }, }); - // Share it globally - const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); - if (globalProject) { - const { updateAgent } = require('~/models/Agent'); - await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] }); - } + // Grant only VIEW permission to user on the agent + const { grantPermission } = require('~/server/services/PermissionService'); + await grantPermission({ + principalType: 'user', + principalId: otherUserId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_viewer', + grantedBy: authorId, + }); - const response = await request(app).get(`/files/agent/${nonCollabAgentId}`); + const response = await request(app).get(`/files/agent/${agentId}`); expect(response.status).toBe(200); - expect(response.body).toEqual([]); // Empty array when not collaborative + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toEqual([]); }); it('should return agent files for agent author', async () => { + // Create an agent with files attached + await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId1, fileId2], + }, + }, + }); + // Create a new app instance with author authentication const authorApp = express(); authorApp.use(express.json()); authorApp.use((req, res, next) => { - req.user = { id: authorId }; + req.user = { id: authorId.toString() }; req.app = { locals: {} }; next(); }); @@ -223,46 +282,48 @@ describe('File Routes - Agent Files Endpoint', () => { const response = await request(authorApp).get(`/files/agent/${agentId}`); expect(response.status).toBe(200); - expect(response.body).toHaveLength(2); // Agent files for author - - const fileIds = response.body.map((f) => f.file_id); - expect(fileIds).toContain(fileId1); - expect(fileIds).toContain(fileId2); - expect(fileIds).not.toContain(fileId3); // User's own file not included + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); }); it('should return files uploaded by other users to shared agent for author', async () => { - // Create a file uploaded by another user + const anotherUserId = new mongoose.Types.ObjectId(); const otherUserFileId = uuidv4(); - const anotherUserId = new mongoose.Types.ObjectId().toString(); + + await User.create({ + _id: anotherUserId, + username: 'another', + email: 'another@test.com', + }); await createFile({ user: anotherUserId, file_id: otherUserFileId, filename: 'other-user-file.txt', - filepath: `/uploads/${anotherUserId}/${otherUserFileId}`, - bytes: 4096, + filepath: '/uploads/other-user-file.txt', + bytes: 400, type: 'text/plain', }); - // Update agent to include the file uploaded by another user - const { updateAgent } = require('~/models/Agent'); - await updateAgent( - { id: agentId }, - { - tool_resources: { - file_search: { - file_ids: [fileId1, fileId2, otherUserFileId], - }, + // Create agent to include the file uploaded by another user + await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId1, otherUserFileId], }, }, - ); + }); - // Create app instance with author authentication + // Create a new app instance with author authentication const authorApp = express(); authorApp.use(express.json()); authorApp.use((req, res, next) => { - req.user = { id: authorId }; + req.user = { id: authorId.toString() }; req.app = { locals: {} }; next(); }); @@ -271,12 +332,10 @@ describe('File Routes - Agent Files Endpoint', () => { const response = await request(authorApp).get(`/files/agent/${agentId}`); expect(response.status).toBe(200); - expect(response.body).toHaveLength(3); // Including file from another user - - const fileIds = response.body.map((f) => f.file_id); - expect(fileIds).toContain(fileId1); - expect(fileIds).toContain(fileId2); - expect(fileIds).toContain(otherUserFileId); // File uploaded by another user + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body.map((f) => f.file_id)).toContain(fileId1); + expect(response.body.map((f) => f.file_id)).toContain(otherUserFileId); }); }); }); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 20399af8e..eb139ab99 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -5,8 +5,8 @@ const { Time, isUUID, CacheKeys, - Constants, FileSources, + PERMISSION_BITS, EModelEndpoint, isAgentsEndpoint, checkOpenAIStorage, @@ -17,12 +17,13 @@ const { processDeleteRequest, processAgentFileUpload, } = require('~/server/services/Files/process'); -const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); +const { checkPermission } = require('~/server/services/PermissionService'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); -const { getProjectByName } = require('~/models/Project'); +const { hasAccessToFilesViaAgent } = require('~/server/services/Files'); +const { getFiles, batchUpdateFiles } = require('~/models/File'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { cleanFileName } = require('~/server/utils/files'); @@ -123,14 +124,15 @@ router.get('/agent/:agent_id', async (req, res) => { // Check if user has access to the agent if (agent.author.toString() !== userId) { - // Non-authors need the agent to be globally shared and collaborative - const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); + // Non-authors need at least EDIT permission to view agent files + const hasEditPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: agent._id, + requiredPermission: PERMISSION_BITS.EDIT, + }); - if ( - !globalProject || - !agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) || - !agent.isCollaborative - ) { + if (!hasEditPermission) { return res.status(200).json([]); } } diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 3f5ae9fc4..02aaf3c68 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -2,10 +2,12 @@ const express = require('express'); const request = require('supertest'); const mongoose = require('mongoose'); const { v4: uuidv4 } = require('uuid'); +const { createMethods } = require('@librechat/data-schemas'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; +const { createAgent } = require('~/models/Agent'); +const { createFile } = require('~/models/File'); -// Mock dependencies +// Only mock the external dependencies that we don't want to test jest.mock('~/server/services/Files/process', () => ({ processDeleteRequest: jest.fn().mockResolvedValue({}), filterFile: jest.fn(), @@ -44,9 +46,6 @@ jest.mock('~/config', () => ({ }, })); -const { createFile } = require('~/models/File'); -const { createAgent } = require('~/models/Agent'); -const { getProjectByName } = require('~/models/Project'); const { processDeleteRequest } = require('~/server/services/Files/process'); // Import the router after mocks @@ -57,22 +56,51 @@ describe('File Routes - Delete with Agent Access', () => { let mongoServer; let authorId; let otherUserId; - let agentId; let fileId; + let File; + let Agent; + let AclEntry; + let User; + let AccessRole; + let methods; + let modelsToCleanup = []; + // eslint-disable-next-line no-unused-vars + let agentId; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); - // Initialize models - require('~/db/models'); + // Initialize all models using createModels + const { createModels } = require('@librechat/data-schemas'); + const models = createModels(mongoose); + + // Track which models we're adding + modelsToCleanup = Object.keys(models); + + // Register models on mongoose.models so methods can access them + Object.assign(mongoose.models, models); + + // Create methods with our test mongoose instance + methods = createMethods(mongoose); + + // Now we can access models from the db/models + File = models.File; + Agent = models.Agent; + AclEntry = models.AclEntry; + User = models.User; + AccessRole = models.AccessRole; + + // Seed default roles using our methods + await methods.seedDefaultRoles(); app = express(); app.use(express.json()); // Mock authentication middleware app.use((req, res, next) => { - req.user = { id: otherUserId || 'default-user' }; + req.user = { id: otherUserId ? otherUserId.toString() : 'default-user' }; req.app = { locals: {} }; next(); }); @@ -81,6 +109,19 @@ describe('File Routes - Delete with Agent Access', () => { }); afterAll(async () => { + // Clean up all collections before disconnecting + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + + // Clear only the models we added + for (const modelName of modelsToCleanup) { + if (mongoose.models[modelName]) { + delete mongoose.models[modelName]; + } + } + await mongoose.disconnect(); await mongoServer.stop(); }); @@ -88,48 +129,41 @@ describe('File Routes - Delete with Agent Access', () => { beforeEach(async () => { jest.clearAllMocks(); - // Clear database - const collections = mongoose.connection.collections; - for (const key in collections) { - await collections[key].deleteMany({}); - } + // Clear database - clean up all test data + await File.deleteMany({}); + await Agent.deleteMany({}); + await User.deleteMany({}); + await AclEntry.deleteMany({}); + // Don't delete AccessRole as they are seeded defaults needed for tests - authorId = new mongoose.Types.ObjectId().toString(); - otherUserId = new mongoose.Types.ObjectId().toString(); + // Create test data + authorId = new mongoose.Types.ObjectId(); + otherUserId = new mongoose.Types.ObjectId(); + agentId = uuidv4(); fileId = uuidv4(); + // Create users in database + await User.create({ + _id: authorId, + username: 'author', + email: 'author@test.com', + }); + + await User.create({ + _id: otherUserId, + username: 'other', + email: 'other@test.com', + }); + // Create a file owned by the author await createFile({ user: authorId, file_id: fileId, filename: 'test.txt', - filepath: `/uploads/${authorId}/${fileId}`, - bytes: 1024, + filepath: '/uploads/test.txt', + bytes: 100, type: 'text/plain', }); - - // Create an agent with the file attached - const agent = await createAgent({ - id: uuidv4(), - name: 'Test Agent', - author: authorId, - model: 'gpt-4', - provider: 'openai', - isCollaborative: true, - tool_resources: { - file_search: { - file_ids: [fileId], - }, - }, - }); - agentId = agent.id; - - // Share the agent globally - const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); - if (globalProject) { - const { updateAgent } = require('~/models/Agent'); - await updateAgent({ id: agentId }, { projectIds: [globalProject._id] }); - } }); describe('DELETE /files', () => { @@ -140,8 +174,8 @@ describe('File Routes - Delete with Agent Access', () => { user: otherUserId, file_id: userFileId, filename: 'user-file.txt', - filepath: `/uploads/${otherUserId}/${userFileId}`, - bytes: 1024, + filepath: '/uploads/user-file.txt', + bytes: 200, type: 'text/plain', }); @@ -151,7 +185,7 @@ describe('File Routes - Delete with Agent Access', () => { files: [ { file_id: userFileId, - filepath: `/uploads/${otherUserId}/${userFileId}`, + filepath: '/uploads/user-file.txt', }, ], }); @@ -168,7 +202,7 @@ describe('File Routes - Delete with Agent Access', () => { files: [ { file_id: fileId, - filepath: `/uploads/${authorId}/${fileId}`, + filepath: '/uploads/test.txt', }, ], }); @@ -180,14 +214,39 @@ describe('File Routes - Delete with Agent Access', () => { }); it('should allow deleting files accessible through shared agent', async () => { + // Create an agent with the file attached + const agent = await createAgent({ + id: uuidv4(), + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId], + }, + }, + }); + + // Grant EDIT permission to user on the agent + const { grantPermission } = require('~/server/services/PermissionService'); + await grantPermission({ + principalType: 'user', + principalId: otherUserId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_editor', + grantedBy: authorId, + }); + const response = await request(app) .delete('/files') .send({ - agent_id: agentId, + agent_id: agent.id, files: [ { file_id: fileId, - filepath: `/uploads/${authorId}/${fileId}`, + filepath: '/uploads/test.txt', }, ], }); @@ -204,19 +263,44 @@ describe('File Routes - Delete with Agent Access', () => { user: authorId, file_id: unattachedFileId, filename: 'unattached.txt', - filepath: `/uploads/${authorId}/${unattachedFileId}`, - bytes: 1024, + filepath: '/uploads/unattached.txt', + bytes: 300, type: 'text/plain', }); + // Create an agent without the unattached file + const agent = await createAgent({ + id: uuidv4(), + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId], // Only fileId, not unattachedFileId + }, + }, + }); + + // Grant EDIT permission to user on the agent + const { grantPermission } = require('~/server/services/PermissionService'); + await grantPermission({ + principalType: 'user', + principalId: otherUserId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_editor', + grantedBy: authorId, + }); + const response = await request(app) .delete('/files') .send({ - agent_id: agentId, + agent_id: agent.id, files: [ { file_id: unattachedFileId, - filepath: `/uploads/${authorId}/${unattachedFileId}`, + filepath: '/uploads/unattached.txt', }, ], }); @@ -224,6 +308,7 @@ describe('File Routes - Delete with Agent Access', () => { expect(response.status).toBe(403); expect(response.body.message).toBe('You can only delete files you have access to'); expect(response.body.unauthorizedFiles).toContain(unattachedFileId); + expect(processDeleteRequest).not.toHaveBeenCalled(); }); it('should handle mixed authorized and unauthorized files', async () => { @@ -233,8 +318,8 @@ describe('File Routes - Delete with Agent Access', () => { user: otherUserId, file_id: userFileId, filename: 'user-file.txt', - filepath: `/uploads/${otherUserId}/${userFileId}`, - bytes: 1024, + filepath: '/uploads/user-file.txt', + bytes: 200, type: 'text/plain', }); @@ -244,51 +329,87 @@ describe('File Routes - Delete with Agent Access', () => { user: authorId, file_id: unauthorizedFileId, filename: 'unauthorized.txt', - filepath: `/uploads/${authorId}/${unauthorizedFileId}`, - bytes: 1024, + filepath: '/uploads/unauthorized.txt', + bytes: 400, type: 'text/plain', }); + // Create an agent with only fileId attached + const agent = await createAgent({ + id: uuidv4(), + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId], + }, + }, + }); + + // Grant EDIT permission to user on the agent + const { grantPermission } = require('~/server/services/PermissionService'); + await grantPermission({ + principalType: 'user', + principalId: otherUserId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_editor', + grantedBy: authorId, + }); + const response = await request(app) .delete('/files') .send({ - agent_id: agentId, + agent_id: agent.id, files: [ - { - file_id: fileId, // Authorized through agent - filepath: `/uploads/${authorId}/${fileId}`, - }, - { - file_id: userFileId, // Owned by user - filepath: `/uploads/${otherUserId}/${userFileId}`, - }, - { - file_id: unauthorizedFileId, // Not authorized - filepath: `/uploads/${authorId}/${unauthorizedFileId}`, - }, + { file_id: userFileId, filepath: '/uploads/user-file.txt' }, + { file_id: fileId, filepath: '/uploads/test.txt' }, + { file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' }, ], }); expect(response.status).toBe(403); expect(response.body.message).toBe('You can only delete files you have access to'); expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId); - expect(response.body.unauthorizedFiles).not.toContain(fileId); - expect(response.body.unauthorizedFiles).not.toContain(userFileId); + expect(processDeleteRequest).not.toHaveBeenCalled(); }); - it('should prevent deleting files when agent is not collaborative', async () => { - // Update the agent to be non-collaborative - const { updateAgent } = require('~/models/Agent'); - await updateAgent({ id: agentId }, { isCollaborative: false }); + it('should prevent deleting files when user lacks EDIT permission on agent', async () => { + // Create an agent with the file attached + const agent = await createAgent({ + id: uuidv4(), + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: authorId, + tool_resources: { + file_search: { + file_ids: [fileId], + }, + }, + }); + + // Grant only VIEW permission to user on the agent + const { grantPermission } = require('~/server/services/PermissionService'); + await grantPermission({ + principalType: 'user', + principalId: otherUserId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_viewer', + grantedBy: authorId, + }); const response = await request(app) .delete('/files') .send({ - agent_id: agentId, + agent_id: agent.id, files: [ { file_id: fileId, - filepath: `/uploads/${authorId}/${fileId}`, + filepath: '/uploads/test.txt', }, ], }); diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js index f35e706a2..a2e19ea91 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 6e90c9254..4743a46d8 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -15,7 +15,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'); @@ -36,6 +36,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 2480f1065..8c2f185ba 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/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 4ea7fa00b..f3f5687b5 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -11,6 +11,7 @@ const { imageExtRegex, EToolResources, } = require('librechat-data-provider'); +const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { createFile, getFiles, updateFile } = require('~/models/File'); @@ -164,14 +165,19 @@ const primeFiles = async (options, apiKey) => { const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? []; - const dbFiles = ( - (await getFiles( - { file_id: { $in: file_ids } }, - null, - { text: 0 }, - { userId: req?.user?.id, agentId }, - )) ?? [] - ).concat(resourceFiles); + + // Get all files first + const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? []; + + // Filter by access if user and agent are provided + let dbFiles; + if (req?.user?.id && agentId) { + dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId); + } else { + dbFiles = allFiles; + } + + dbFiles = dbFiles.concat(resourceFiles); const files = []; const sessions = new Map(); diff --git a/api/server/services/Files/index.js b/api/server/services/Files/index.js new file mode 100644 index 000000000..872e8a0e8 --- /dev/null +++ b/api/server/services/Files/index.js @@ -0,0 +1,12 @@ +const { processCodeFile } = require('./Code/process'); +const { processFileUpload } = require('./process'); +const { uploadImageBuffer } = require('./images'); +const { hasAccessToFilesViaAgent, filterFilesByAgentAccess } = require('./permissions'); + +module.exports = { + processCodeFile, + processFileUpload, + uploadImageBuffer, + hasAccessToFilesViaAgent, + filterFilesByAgentAccess, +}; diff --git a/api/server/services/Files/permissions.js b/api/server/services/Files/permissions.js new file mode 100644 index 000000000..f71a707ca --- /dev/null +++ b/api/server/services/Files/permissions.js @@ -0,0 +1,123 @@ +const { logger } = require('@librechat/data-schemas'); +const { PERMISSION_BITS } = require('librechat-data-provider'); +const { checkPermission } = require('~/server/services/PermissionService'); +const { getAgent } = require('~/models/Agent'); + +/** + * Checks if a user has access to multiple files through a shared agent (batch operation) + * @param {string} userId - The user ID to check access for + * @param {string[]} fileIds - Array of file IDs to check + * @param {string} agentId - The agent ID that might grant access + * @returns {Promise>} Map of fileId to access status + */ +const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => { + const accessMap = new Map(); + + // Initialize all files as no access + fileIds.forEach((fileId) => accessMap.set(fileId, false)); + + try { + const agent = await getAgent({ id: agentId }); + + if (!agent) { + return accessMap; + } + + // Check if user is the author - if so, grant access to all files + if (agent.author.toString() === userId) { + fileIds.forEach((fileId) => accessMap.set(fileId, true)); + return accessMap; + } + + // Check if user has at least VIEW permission on the agent + const hasViewPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: agent._id, + requiredPermission: PERMISSION_BITS.VIEW, + }); + + if (!hasViewPermission) { + return accessMap; + } + + // Check if user has EDIT permission (which would indicate collaborative access) + const hasEditPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: agent._id, + requiredPermission: PERMISSION_BITS.EDIT, + }); + + // If user only has VIEW permission, they can't access files + // Only users with EDIT permission or higher can access agent files + if (!hasEditPermission) { + return accessMap; + } + + // User has edit permissions - check which files are actually attached + const attachedFileIds = new Set(); + if (agent.tool_resources) { + for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) { + if (resource?.file_ids && Array.isArray(resource.file_ids)) { + resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId)); + } + } + } + + // Grant access only to files that are attached to this agent + fileIds.forEach((fileId) => { + if (attachedFileIds.has(fileId)) { + accessMap.set(fileId, true); + } + }); + + return accessMap; + } catch (error) { + logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error); + return accessMap; + } +}; + +/** + * Filter files based on user access through agents + * @param {Array} files - Array of file documents + * @param {string} userId - User ID for access control + * @param {string} agentId - Agent ID that might grant access to files + * @returns {Promise>} Filtered array of accessible files + */ +const filterFilesByAgentAccess = async (files, userId, agentId) => { + if (!userId || !agentId || !files || files.length === 0) { + return files; + } + + // Separate owned files from files that need access check + const filesToCheck = []; + const ownedFiles = []; + + for (const file of files) { + if (file.user && file.user.toString() === userId) { + ownedFiles.push(file); + } else { + filesToCheck.push(file); + } + } + + if (filesToCheck.length === 0) { + return ownedFiles; + } + + // Batch check access for all non-owned files + const fileIds = filesToCheck.map((f) => f.file_id); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + + // Filter files based on access + const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id)); + + return [...ownedFiles, ...accessibleFiles]; +}; + +module.exports = { + hasAccessToFilesViaAgent, + filterFilesByAgentAccess, +}; diff --git a/api/server/services/start/interface.js b/api/server/services/start/interface.js index 841aca880..fecd01508 100644 --- a/api/server/services/start/interface.js +++ b/api/server/services/start/interface.js @@ -53,6 +53,24 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch, fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations, customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome, + peoplePicker: { + admin: { + users: interfaceConfig?.peoplePicker?.admin?.users ?? defaults.peoplePicker?.admin.users, + groups: interfaceConfig?.peoplePicker?.admin?.groups ?? defaults.peoplePicker?.admin.groups, + }, + user: { + users: interfaceConfig?.peoplePicker?.user?.users ?? defaults.peoplePicker?.user.users, + groups: interfaceConfig?.peoplePicker?.user?.groups ?? defaults.peoplePicker?.user.groups, + }, + }, + marketplace: { + admin: { + use: interfaceConfig?.marketplace?.admin?.use ?? defaults.marketplace?.admin.use, + }, + user: { + use: interfaceConfig?.marketplace?.user?.use ?? defaults.marketplace?.user.use, + }, + }, }); await updateAccessPermissions(roleName, { @@ -67,6 +85,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: loadedInterface.peoplePicker.user?.users, + [Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.user?.groups, + }, + [PermissionTypes.MARKETPLACE]: { + [Permissions.USE]: loadedInterface.marketplace.user?.use, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations }, }); @@ -82,6 +107,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: loadedInterface.peoplePicker.admin?.users, + [Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.admin?.groups, + }, + [PermissionTypes.MARKETPLACE]: { + [Permissions.USE]: loadedInterface.marketplace.admin?.use, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations }, }); diff --git a/api/server/services/start/interface.spec.js b/api/server/services/start/interface.spec.js index 98c20df0d..4ee3f7872 100644 --- a/api/server/services/start/interface.spec.js +++ b/api/server/services/start/interface.spec.js @@ -29,12 +29,20 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, - [PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: true, + [Permissions.OPT_OUT]: undefined, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }); @@ -68,6 +76,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false }, }); @@ -91,6 +104,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -126,6 +144,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -159,6 +182,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }); @@ -192,6 +220,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }); @@ -215,6 +248,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -238,6 +276,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -261,6 +304,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -292,6 +340,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -324,6 +377,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, }); @@ -355,6 +413,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, }); }); @@ -372,12 +435,20 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, - [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: undefined, + [Permissions.OPT_OUT]: undefined, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, }); }); @@ -395,12 +466,20 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, - [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: undefined, + [Permissions.OPT_OUT]: undefined, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, }); }); @@ -432,6 +511,11 @@ describe('loadDefaultInterface', () => { [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_GROUPS]: undefined, + [Permissions.VIEW_USERS]: undefined, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }); diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js index ae30db72c..d62b8467e 100644 --- a/api/test/jestSetup.js +++ b/api/test/jestSetup.js @@ -10,4 +10,9 @@ process.env.JWT_SECRET = 'test'; process.env.JWT_REFRESH_SECRET = 'test'; process.env.CREDS_KEY = 'test'; process.env.CREDS_IV = 'test'; +process.env.ALLOW_EMAIL_LOGIN = 'true'; + +// Set global test timeout to 30 seconds +// This can be overridden in individual tests if needed +jest.setTimeout(30000); process.env.OPENAI_API_KEY = 'test'; diff --git a/client/package.json b/client/package.json index 95dd2dfd4..3e5cc881a 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 320538fc2..a49586b8a 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 e8f905556..548f20980 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -2,8 +2,8 @@ import React, { useCallback, useContext } from 'react'; import { LayoutGrid } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, Constants, Permissions, PermissionTypes } from 'librechat-data-provider'; -import { NewChatIcon, MobileSidebar, Sidebar, TooltipAnchor, Button } from '@librechat/client'; +import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider'; +import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; import { useLocalize, useNewConvo, useHasAccess, AuthContext } from '~/hooks'; import store from '~/store'; @@ -32,6 +32,10 @@ export default function NewChat({ permissionType: PermissionTypes.AGENTS, permission: Permissions.USE, }); + const hasAccessToMarketplace = useHasAccess({ + permissionType: PermissionTypes.MARKETPLACE, + permission: Permissions.USE, + }); const clickHandler: React.MouseEventHandler = useCallback( (e) => { @@ -65,9 +69,8 @@ export default function NewChat({ authContext?.isAuthenticated !== undefined && (authContext?.isAuthenticated === false || authContext?.user !== undefined); - // Show agent marketplace when auth is ready and user has access - // Note: endpointsConfig[agents] is null, but we can still show the marketplace - const showAgentMarketplace = authReady && hasAccessToAgents; + // Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents + const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace; return ( <> diff --git a/client/src/components/SidePanel/Agents/AgentAvatar.tsx b/client/src/components/SidePanel/Agents/AgentAvatar.tsx index 5d0790012..12bc6ae5f 100644 --- a/client/src/components/SidePanel/Agents/AgentAvatar.tsx +++ b/client/src/components/SidePanel/Agents/AgentAvatar.tsx @@ -3,10 +3,9 @@ import * as Popover from '@radix-ui/react-popover'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; import { - fileConfig as defaultFileConfig, QueryKeys, - defaultOrderQuery, mergeFileConfig, + fileConfig as defaultFileConfig, } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; import type { @@ -15,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 { useLocalize } from '~/hooks'; import { formatBytes } from '~/utils'; @@ -46,41 +50,41 @@ function Avatar({ onMutate: () => { setProgress(0.4); }, - onSuccess: (data, vars) => { - if (vars.postCreation === false) { - showToast({ message: localize('com_ui_upload_success') }); - } else if (lastSeenCreatedId.current !== createMutation.data?.id) { + onSuccess: (data) => { + if (lastSeenCreatedId.current !== createMutation.data?.id) { lastSeenCreatedId.current = createMutation.data?.id ?? ''; } + showToast({ message: localize('com_ui_upload_agent_avatar') }); setInput(null); 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) => { @@ -137,7 +141,6 @@ function Avatar({ uploadAvatar({ agent_id: createMutation.data.id, - postCreation: true, formData, }); } diff --git a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx index ed0c7cd09..6fb46121e 100644 --- a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx +++ b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx @@ -10,6 +10,7 @@ import { ControllerRenderProps, } from 'react-hook-form'; import { useAgentCategories } from '~/hooks/Agents'; +import { cn } from '~/utils'; /** * Custom hook to handle category synchronization @@ -18,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'); @@ -31,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(); @@ -79,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 9e4d4b51f..5d6c9a7cf 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -51,7 +51,10 @@ export default function AgentConfig({ createMutation }: Pick ( - + <> + +
+ {errors.name ? errors.name.message : ' '} +
+ )} /> = ({ agent, isOpen, onClose }) => { const localize = useLocalize(); - const navigate = useNavigate(); + // const navigate = useNavigate(); + const { conversation, newConversation } = useChatContext(); const { showToast } = useToastContext(); 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) => { @@ -54,7 +63,31 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => */ const handleStartChat = () => { if (agent) { - navigate(`/c/new?agent_id=${agent.id}`); + 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 }); + } + } + + localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id); + + queryClient.setQueryData( + [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], + [], + ); + queryClient.invalidateQueries([QueryKeys.messages]); + + newConversation({ + template: { + conversationId: Constants.NEW_CONVO as string, + endpoint: EModelEndpoint.agents, + agent_id: agent.id, + title: `Chat with ${agent.name || 'Agent'}`, + }, + }); } }; @@ -68,7 +101,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => .writeText(chatUrl) .then(() => { showToast({ - message: 'Link copied', + message: localize('com_agents_link_copied'), }); }) .catch(() => { @@ -118,7 +151,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 0d7a0196a..16a7d2a1c 100644 --- a/client/src/components/SidePanel/Agents/AgentGrid.tsx +++ b/client/src/components/SidePanel/Agents/AgentGrid.tsx @@ -1,9 +1,11 @@ -import React, { useState } from 'react'; +import React, { useMemo } from 'react'; import { Button, Spinner } from '@librechat/client'; +import { PERMISSION_BITS } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; -import { useDynamicAgentQuery, useAgentCategories } from '~/hooks/Agents'; -import { SmartLoader, useHasData } from './SmartLoader'; +import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; +import { useAgentCategories } from '~/hooks/Agents'; import useLocalize from '~/hooks/useLocalize'; +import { useHasData } from './SmartLoader'; import ErrorDisplay from './ErrorDisplay'; import AgentCard from './AgentCard'; import { cn } from '~/utils'; @@ -14,45 +16,68 @@ interface AgentGridProps { onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected } -// Interface for the actual data structure returned by the API -interface AgentGridData { - agents: t.Agent[]; - pagination?: { - hasMore: boolean; - current: number; - total: number; - }; -} - /** * Component for displaying a grid of agent cards */ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAgent }) => { const localize = useLocalize(); - const [page, setPage] = useState(1); // 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 @@ -79,16 +104,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 */ @@ -160,7 +180,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAg

{getGridTitle()}

@@ -168,7 +188,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)} />
@@ -219,7 +239,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 9df8a8140..339412d45 100644 --- a/client/src/components/SidePanel/Agents/AgentMarketplace.tsx +++ b/client/src/components/SidePanel/Agents/AgentMarketplace.tsx @@ -1,13 +1,16 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useOutletContext } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { useSetRecoilState, useRecoilValue } from 'recoil'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; +import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider'; 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, useHasAccess, useLocalize } from '~/hooks'; +import { SidePanelProvider, useChatContext } from '~/Providers'; import { MarketplaceProvider } from './MarketplaceContext'; -import { useDocumentTitle, useLocalize } from '~/hooks'; import { SidePanelGroup } from '~/components/SidePanel'; import { OpenSidebar } from '~/components/Chat/Menus'; import CategoryTabs from './CategoryTabs'; @@ -30,6 +33,8 @@ interface AgentMarketplaceProps { const AgentMarketplace: React.FC = ({ className = '' }) => { const localize = useLocalize(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { conversation, newConversation } = useChatContext(); const [searchParams, setSearchParams] = useSearchParams(); const { category } = useParams(); const setHideSidePanel = useSetRecoilState(store.hideSidePanel); @@ -138,12 +143,18 @@ const AgentMarketplace: React.FC = ({ className = '' }) = /** * Handle new chat button click */ + const handleNewChat = (e: React.MouseEvent) => { if (e.button === 0 && (e.ctrlKey || e.metaKey)) { window.open('/c/new', '_blank'); return; } - navigate('/c/new'); + queryClient.setQueryData( + [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], + [], + ); + queryClient.invalidateQueries([QueryKeys.messages]); + newConversation(); }; // Check if a detail view should be open based on URL @@ -164,131 +175,154 @@ const AgentMarketplace: React.FC = ({ className = '' }) = const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []); + const hasAccessToMarketplace = useHasAccess({ + permissionType: PermissionTypes.MARKETPLACE, + permission: Permissions.USE, + }); + useEffect(() => { + let timeoutId: ReturnType; + if (!hasAccessToMarketplace) { + timeoutId = setTimeout(() => { + navigate('/c/new'); + }, 1000); + } + return () => { + clearTimeout(timeoutId); + }; + }, [hasAccessToMarketplace, navigate]); + + if (!hasAccessToMarketplace) { + return null; + } return (
- -
- {/* Simplified header for agents marketplace - only show nav controls when needed */} -
-
- {!navVisible && } - {!navVisible && ( - - - - } - /> - )} -
-
-
- {/* Hero Section - ChatGPT Style */} -
-

- {localize('com_agents_marketplace')} -

-

- {localize('com_agents_marketplace_subtitle')} -

- - {/* Search bar */} -
- + + +
+ {/* Simplified header for agents marketplace - only show nav controls when needed */} +
+
+ {!navVisible && } + {!navVisible && ( + + + + } + /> + )}
+
+ {/* Hero Section - ChatGPT Style */} +
+

+ {localize('com_agents_marketplace')} +

+

+ {localize('com_agents_marketplace_subtitle')} +

- {/* Category tabs */} - + {/* Search bar */} +
+ +
+
- {/* Category header - only show when not searching */} - {!searchQuery && ( -
- {(() => { - // Get category data for display - const getCategoryData = () => { - if (activeTab === 'promoted') { + {/* Category tabs */} + + + {/* Category header - only show when not searching */} + {!searchQuery && ( +
+ {(() => { + // Get category data for display + const getCategoryData = () => { + if (activeTab === 'promoted') { + return { + name: localize('com_agents_top_picks'), + description: localize('com_agents_recommended'), + }; + } + if (activeTab === 'all') { + return { + name: 'All Agents', + description: 'Browse all shared agents across all categories', + }; + } + + // Find the category in the API data + const categoryData = categoriesQuery.data?.find( + (cat) => cat.value === activeTab, + ); + if (categoryData) { + return { + name: categoryData.label, + description: categoryData.description || '', + }; + } + + // Fallback for unknown categories return { - name: localize('com_agents_top_picks'), - description: localize('com_agents_recommended'), + name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1), + description: '', }; - } - if (activeTab === 'all') { - return { - name: 'All Agents', - description: 'Browse all shared agents across all categories', - }; - } - - // Find the category in the API data - const categoryData = categoriesQuery.data?.find( - (cat) => cat.value === activeTab, - ); - if (categoryData) { - return { - name: categoryData.label, - description: categoryData.description || '', - }; - } - - // Fallback for unknown categories - return { - name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1), - description: '', }; - }; - const { name, description } = getCategoryData(); + const { name, description } = getCategoryData(); - return ( -
-

{name}

- {description && ( -

{description}

- )} -
- ); - })()} -
+ return ( +
+

+ {name} +

+ {description && ( +

{description}

+ )} +
+ ); + })()} +
+ )} + + {/* Agent grid */} + +
+ + {/* Agent detail dialog */} + {isDetailOpen && selectedAgent && ( + )} - - {/* Agent grid */} - -
- - {/* Agent detail dialog */} - {isDetailOpen && selectedAgent && ( - - )} -
-
+ + +
); diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 8ae2c0edf..7cf710533 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -215,6 +215,12 @@ export default function AgentPanel() { status: 'error', }); } + if (!name) { + return showToast({ + message: localize('com_agents_missing_name'), + status: 'error', + }); + } create.mutate({ name, @@ -247,12 +253,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 afaaececc..5d3285d4a 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -7,7 +7,11 @@ import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-que import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { TAgentCapabilities, AgentForm } from '~/common'; import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils'; -import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider'; +import { + useAgentListingDefaultPermissionLevel, + useGetStartupConfig, + useListAgentsQuery, +} from '~/data-provider'; import { useLocalize } from '~/hooks'; const keys = new Set(Object.keys(defaultAgentFormValues)); @@ -28,18 +32,23 @@ 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 permissionLevel = useAgentListingDefaultPermissionLevel(); + + const { data: agents = null } = useListAgentsQuery( + { requiredPermission: permissionLevel }, + { + 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 a5e261d4d..84acdce6b 100644 --- a/client/src/components/SidePanel/Agents/ErrorDisplay.tsx +++ b/client/src/components/SidePanel/Agents/ErrorDisplay.tsx @@ -66,23 +66,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'), @@ -91,7 +93,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) { @@ -107,7 +109,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'), }; } @@ -120,9 +123,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'), }; @@ -192,9 +205,9 @@ export const ErrorDisplay: React.FC = ({ error, onRetry, cont {/* Error content with proper headings and structure */}
-

+

{title} -

+

= ({ children }) => { - // Create more complete context to prevent FileRow and other component errors - // when agents with files are opened in the marketplace - const marketplaceContext = useMemo( - () => ({ - conversation: { - endpoint: EModelEndpoint.agents, - conversationId: 'marketplace', - title: 'Agent Marketplace', - }, - // File-related context properties to prevent FileRow errors - files: new Map(), - setFiles: () => {}, - setFilesLoading: () => {}, - // Other commonly used context properties to prevent undefined errors - isSubmitting: false, - setIsSubmitting: () => {}, - latestMessage: null, - setLatestMessage: () => {}, - // Minimal functions to prevent errors when components try to use them - ask: () => {}, - regenerate: () => {}, - stopGenerating: () => {}, - submitMessage: () => {}, - }), - [], - ); + const chatHelpers = useChatHelpers(0, 'new'); - return {children}; + return {children}; }; diff --git a/client/src/components/SidePanel/Agents/Sharing/GrantAccessDialog.tsx b/client/src/components/SidePanel/Agents/Sharing/GrantAccessDialog.tsx index f11e9c399..cef79d133 100644 --- a/client/src/components/SidePanel/Agents/Sharing/GrantAccessDialog.tsx +++ b/client/src/components/SidePanel/Agents/Sharing/GrantAccessDialog.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { ACCESS_ROLE_IDS } from 'librechat-data-provider'; +import React, { useState, useEffect, useMemo } from 'react'; +import { ACCESS_ROLE_IDS, PermissionTypes } from 'librechat-data-provider'; import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react'; import { useGetResourcePermissionsQuery, @@ -15,8 +15,8 @@ import { useToastContext, } from '@librechat/client'; import type { TPrincipal } from 'librechat-data-provider'; +import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks'; import ManagePermissionsDialog from './ManagePermissionsDialog'; -import { useLocalize, useCopyToClipboard } from '~/hooks'; import PublicSharingToggle from './PublicSharingToggle'; import PeoplePicker from './PeoplePicker/PeoplePicker'; import AccessRolesPicker from './AccessRolesPicker'; @@ -38,6 +38,29 @@ export default function GrantAccessDialog({ const localize = useLocalize(); const { showToast } = useToastContext(); + // Check if user has permission to access people picker + const canViewUsers = useHasAccess({ + permissionType: PermissionTypes.PEOPLE_PICKER, + permission: Permissions.VIEW_USERS, + }); + const canViewGroups = useHasAccess({ + permissionType: PermissionTypes.PEOPLE_PICKER, + permission: Permissions.VIEW_GROUPS, + }); + const hasPeoplePickerAccess = canViewUsers || canViewGroups; + + // Determine type filter based on permissions + const peoplePickerTypeFilter = useMemo(() => { + if (canViewUsers && canViewGroups) { + return null; // Both types allowed + } else if (canViewUsers) { + return 'user' as const; + } else if (canViewGroups) { + return 'group' as const; + } + return null; + }, [canViewUsers, canViewGroups]); + const { data: permissionsData, // isLoading: isLoadingPermissions, @@ -177,26 +200,31 @@ export default function GrantAccessDialog({

- + {hasPeoplePickerAccess && ( + <> + -
-
-
- - +
+
+
+ + +
+
+
-
- -
+ + )}
- + {hasPeoplePickerAccess && ( + + )} {agentId && ( +
+ ), })); -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 a5bb1108d..cbcf9c601 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 8008d5e4d..303308cef 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__/MarketplaceContext.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/MarketplaceContext.spec.tsx index d61ed0054..49ff4cfda 100644 --- a/client/src/components/SidePanel/Agents/__tests__/MarketplaceContext.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/MarketplaceContext.spec.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; @@ -17,6 +18,11 @@ jest.mock('~/Providers', () => ({ useChatContext: jest.fn(), })); +// Mock useChatHelpers to avoid Recoil dependency +jest.mock('~/hooks', () => ({ + useChatHelpers: jest.fn(), +})); + const mockedUseChatContext = useChatContext as jest.MockedFunction; // Test component that consumes the context @@ -35,6 +41,16 @@ const TestConsumer: React.FC = () => { describe('MarketplaceProvider', () => { beforeEach(() => { mockedUseChatContext.mockClear(); + + // Mock useChatHelpers return value + const { useChatHelpers } = require('~/hooks'); + (useChatHelpers as jest.Mock).mockReturnValue({ + conversation: { + endpoint: EModelEndpoint.agents, + conversationId: 'marketplace', + title: 'Agent Marketplace', + }, + }); }); it('provides correct marketplace context values', () => { @@ -46,7 +62,7 @@ describe('MarketplaceProvider', () => { }, }; - mockedUseChatContext.mockReturnValue(mockContext); + mockedUseChatContext.mockReturnValue(mockContext as ReturnType); render( diff --git a/client/src/components/SidePanel/Agents/__tests__/SearchBar.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/SearchBar.spec.tsx index 10e20b635..5977680ba 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 d0720956a..b91a65fc3 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 e9aac269e..057d0996e 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); }, }); @@ -58,30 +62,33 @@ export const useUpdateAgentMutation = ( return options?.onError?.(error, 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); }, }, @@ -103,24 +110,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); }, @@ -142,22 +153,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); }, @@ -177,8 +189,7 @@ export const useUploadAgentAvatarMutation = ( unknown // context > => { return useMutation([MutationKeys.agentAvatarUpload], { - mutationFn: ({ postCreation, ...variables }: t.AgentAvatarVariables) => - dataService.uploadAgentAvatar(variables), + mutationFn: (variables: t.AgentAvatarVariables) => dataService.uploadAgentAvatar(variables), ...(options || {}), }); }; @@ -202,26 +213,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) { @@ -275,28 +285,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; @@ -337,25 +347,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 a00ba93b1..2fb81cc05 100644 --- a/client/src/data-provider/Agents/queries.ts +++ b/client/src/data-provider/Agents/queries.ts @@ -1,12 +1,41 @@ -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, + Permissions, + EModelEndpoint, + PERMISSION_BITS, + PermissionTypes, +} 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'; +import { useHasAccess } from '~/hooks'; + +/** + * Hook to determine the appropriate permission level for agent queries based on marketplace configuration + */ +export const useAgentListingDefaultPermissionLevel = () => { + const hasMarketplaceAccess = useHasAccess({ + permissionType: PermissionTypes.MARKETPLACE, + permission: Permissions.USE, + }); + + // When marketplace is active: EDIT permissions (builder mode) + // When marketplace is not active: VIEW permissions (browse mode) + return hasMarketplaceAccess ? PERMISSION_BITS.EDIT : PERMISSION_BITS.VIEW; +}; /** * AGENTS */ - +export const defaultAgentParams: t.AgentListParams = { + limit: 10, + requiredPermission: PERMISSION_BITS.EDIT, +}; /** * Hook for getting all available tools for A */ @@ -27,7 +56,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 +105,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 +127,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 af44405e1..b689f29e7 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 339163636..000000000 --- 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 4806c4343..4eba68fe0 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -1,7 +1,6 @@ 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'; export { default as useAgentCapabilities } from './useAgentCapabilities'; export { default as useGetAgentsConfig } from './useGetAgentsConfig'; diff --git a/client/src/hooks/Agents/useAgentCategories.tsx b/client/src/hooks/Agents/useAgentCategories.tsx index 5f921458a..77c0a224c 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 0b5222c29..112f75a25 100644 --- a/client/src/hooks/Agents/useAgentsMap.ts +++ b/client/src/hooks/Agents/useAgentsMap.ts @@ -1,6 +1,6 @@ import { TAgentsMap } from 'librechat-data-provider'; import { useMemo } from 'react'; -import { useListAgentsQuery } from '~/data-provider'; +import { useListAgentsQuery, useAgentListingDefaultPermissionLevel } from '~/data-provider'; import { mapAgents } from '~/utils'; export default function useAgentsMap({ @@ -8,10 +8,15 @@ export default function useAgentsMap({ }: { isAuthenticated: boolean; }): TAgentsMap | undefined { - const { data: agentsList = null } = useListAgentsQuery(undefined, { - select: (res) => mapAgents(res.data), - enabled: isAuthenticated, - }); + const permissionLevel = useAgentListingDefaultPermissionLevel(); + + const { data: agentsList = null } = useListAgentsQuery( + { requiredPermission: permissionLevel }, + { + 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 0e957d416..000000000 --- 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 46f438ba9..fa27f183f 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/Input/useQueryParams.spec.ts b/client/src/hooks/Input/useQueryParams.spec.ts index 52a5a877a..9647fc2d7 100644 --- a/client/src/hooks/Input/useQueryParams.spec.ts +++ b/client/src/hooks/Input/useQueryParams.spec.ts @@ -34,6 +34,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('@tanstack/react-query', () => ({ useQueryClient: jest.fn(), + useQuery: jest.fn(), })); jest.mock('~/Providers', () => ({ @@ -51,6 +52,15 @@ jest.mock('~/hooks/Conversations/useDefaultConvo', () => ({ default: jest.fn(), })); +jest.mock('~/hooks/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('~/hooks/Agents/useAgentsMap', () => ({ + __esModule: true, + default: jest.fn(() => ({})), +})); + jest.mock('~/utils', () => ({ getConvoSwitchLogic: jest.fn(() => ({ template: {}, @@ -63,6 +73,8 @@ jest.mock('~/utils', () => ({ getModelSpecIconURL: jest.fn(() => 'icon-url'), removeUnavailableTools: jest.fn((preset) => preset), logger: { log: jest.fn() }, + getInitialTheme: jest.fn(() => 'light'), + applyFontSize: jest.fn(), })); // Mock the tQueryParamsSchema @@ -82,6 +94,21 @@ jest.mock('librechat-data-provider', () => ({ EModelEndpoint: { custom: 'custom', assistants: 'assistants', agents: 'agents' }, })); +// Mock data-provider hooks +jest.mock('~/data-provider', () => ({ + useGetAgentByIdQuery: jest.fn(() => ({ + data: null, + isLoading: false, + error: null, + })), + useAgentListingDefaultPermissionLevel: jest.fn(() => 'view'), + useListAgentsQuery: jest.fn(() => ({ + data: null, + isLoading: false, + error: null, + })), +})); + // Mock global window.history global.window = Object.create(window); global.window.history = { @@ -103,6 +130,14 @@ describe('useQueryParams', () => { // Reset mock for window.history.replaceState jest.spyOn(window.history, 'replaceState').mockClear(); + // Reset data-provider mocks + const dataProvider = jest.requireMock('~/data-provider'); + (dataProvider.useGetAgentByIdQuery as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + error: null, + }); + // Create mocks for all dependencies const mockSearchParams = new URLSearchParams(); (useSearchParams as jest.Mock).mockReturnValue([mockSearchParams, jest.fn()]); @@ -147,6 +182,13 @@ describe('useQueryParams', () => { const mockGetDefaultConversation = jest.fn().mockReturnValue({}); (useDefaultConvo as jest.Mock).mockReturnValue(mockGetDefaultConversation); + + // Mock useAuthContext + const { useAuthContext } = jest.requireMock('~/hooks/AuthContext'); + (useAuthContext as jest.Mock).mockReturnValue({ + user: { id: 'test-user-id' }, + isAuthenticated: true, + }); }); afterEach(() => { diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts index 52794a745..7145ffd37 100644 --- a/client/src/hooks/Input/useQueryParams.ts +++ b/client/src/hooks/Input/useQueryParams.ts @@ -1,20 +1,26 @@ import { useEffect, useCallback, useRef } from 'react'; import { useRecoilValue } from 'recoil'; import { useSearchParams } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; +import { QueryClient, useQueryClient } from '@tanstack/react-query'; import { QueryKeys, EModelEndpoint, isAgentsEndpoint, tQueryParamsSchema, isAssistantsEndpoint, + PERMISSION_BITS, +} from 'librechat-data-provider'; +import type { + TPreset, + TEndpointsConfig, + TStartupConfig, + AgentListResponse, } from 'librechat-data-provider'; -import type { TPreset, TEndpointsConfig, TStartupConfig } from 'librechat-data-provider'; import type { ZodAny } from 'zod'; import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils'; -import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo'; +import { useAuthContext, useAgentsMap, useDefaultConvo, useSubmitMessage } from '~/hooks'; import { useChatContext, useChatFormContext } from '~/Providers'; -import useSubmitMessage from '~/hooks/Messages/useSubmitMessage'; +import { useGetAgentByIdQuery } from '~/data-provider'; import store from '~/store'; /** @@ -73,6 +79,21 @@ const processValidSettings = (queryParams: Record) => { return validSettings; }; +const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => { + const editCacheKey = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }]; + const editCache = queryClient.getQueryData(editCacheKey); + + if (editCache?.data && !editCache.data.some((cachedAgent) => cachedAgent.id === agent.id)) { + // Inject agent into EDIT cache so dropdown can display it + const updatedCache = { + ...editCache, + data: [agent, ...editCache.data], + }; + queryClient.setQueryData(editCacheKey, updatedCache); + logger.log('agent', 'Injected URL agent into cache:', agent); + } +}; + /** * Hook that processes URL query parameters to initialize chat with specified settings and prompt. * Handles model switching, prompt auto-filling, and optional auto-submission with race condition protection. @@ -104,6 +125,14 @@ export default function useQueryParams({ const queryClient = useQueryClient(); const { conversation, newConversation } = useChatContext(); + // Extract agent_id from URL for proactive fetching + const urlAgentId = searchParams.get('agent_id') || ''; + + // Use the existing query hook to fetch agent if present in URL + const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId, { + enabled: !!urlAgentId, // Only fetch if agent_id exists in URL + }); + /** * Applies settings from URL query parameters to create a new conversation. * Handles model spec lookup, endpoint normalization, and conversation switching logic. @@ -418,4 +447,12 @@ export default function useQueryParams({ } } }, [conversation, processSubmission, areSettingsApplied]); + + const { isAuthenticated } = useAuthContext(); + const agentsMap = useAgentsMap({ isAuthenticated }); + useEffect(() => { + if (urlAgent) { + injectAgentIntoAgentsMap(queryClient, urlAgent); + } + }, [urlAgent, queryClient, agentsMap]); } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 2af78a71e..cfb5e1edf 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -23,7 +23,7 @@ "com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat", "com_agents_mcps_disabled": "You need to create an agent before adding MCPs.", "com_agents_missing_provider_model": "Please select a provider and model before creating an agent.", - "com_agents_name_placeholder": "Optional: The name of the agent", + "com_agents_name_placeholder": "The name of the agent", "com_agents_no_access": "You don't have access to edit this agent.", "com_agents_no_agent_id_error": "No agent ID found. Please ensure the agent is created first.", "com_agents_not_available": "Agent Not Available", @@ -1097,6 +1097,7 @@ "com_ui_update_mcp_error": "There was an error creating or updating the MCP.", "com_ui_update_mcp_success": "Successfully created or updated MCP", "com_ui_upload": "Upload", + "com_ui_upload_agent_avatar": "Successfully updated agent avatar", "com_ui_upload_code_files": "Upload for Code Interpreter", "com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.", "com_ui_upload_error": "There was an error uploading your file", @@ -1207,9 +1208,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", @@ -1229,6 +1228,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", @@ -1245,5 +1247,8 @@ "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 3d960a07b..6bbd2dbc3 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 2b4f5bba2..000000000 --- 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/librechat.example.yaml b/librechat.example.yaml index 924f5b7b9..f6ff905e8 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -74,6 +74,18 @@ interface: bookmarks: true multiConvo: true agents: true + peoplePicker: + admin: + users: true + groups: true + user: + users: false + groups: false + marketplace: + admin: + use: false # Enable marketplace mode for admin role + user: + use: false # Enable marketplace mode for user role fileCitations: true # Temporary chat retention period in hours (default: 720, min: 1, max: 8760) # temporaryChatRetention: 1 diff --git a/package.json b/package.json index d8a3b96d2..3065ca5a8 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "reset-meili-sync": "node config/reset-meili-sync.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/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index e660e6404..c1ca01d20 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -30,6 +30,14 @@ export const agentToolResourcesSchema = z }) .optional(); +/** Support contact schema for agent */ +export const agentSupportContactSchema = z + .object({ + name: z.string().optional(), + email: z.union([z.literal(''), z.string().email()]).optional(), + }) + .optional(); + /** Base agent schema with all common fields */ export const agentBaseSchema = z.object({ name: z.string().nullable().optional(), @@ -45,6 +53,8 @@ export const agentBaseSchema = z.object({ recursion_limit: z.number().optional(), conversation_starters: z.array(z.string()).optional(), tool_resources: agentToolResourcesSchema, + support_contact: agentSupportContactSchema, + category: z.string().optional(), }); /** Create schema extends base with required fields for creation */ diff --git a/packages/api/src/middleware/access.ts b/packages/api/src/middleware/access.ts index d5efb0fb5..8b3d83d03 100644 --- a/packages/api/src/middleware/access.ts +++ b/packages/api/src/middleware/access.ts @@ -125,7 +125,7 @@ export const generateCheckAccess = ({ } logger.warn( - `[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${req.user?.id}: ${permissions.join(', ')}`, + `[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${(req.user as IUser)?.id}: ${permissions.join(', ')}`, ); return res.status(403).json({ message: 'Forbidden: Insufficient permissions' }); } catch (error) { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 75f66474c..b1ed26eb7 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -498,7 +498,7 @@ const mcpServersSchema = z.object({ export type TMcpServersConfig = z.infer; -export const intefaceSchema = z +export const interfaceSchema = z .object({ privacyPolicy: z .object({ @@ -523,6 +523,36 @@ export const intefaceSchema = z temporaryChatRetention: z.number().min(1).max(8760).optional(), runCode: z.boolean().optional(), webSearch: z.boolean().optional(), + peoplePicker: z + .object({ + admin: z + .object({ + users: z.boolean().optional(), + groups: z.boolean().optional(), + }) + .optional(), + user: z + .object({ + users: z.boolean().optional(), + groups: z.boolean().optional(), + }) + .optional(), + }) + .optional(), + marketplace: z + .object({ + admin: z + .object({ + use: z.boolean().optional(), + }) + .optional(), + user: z + .object({ + use: z.boolean().optional(), + }) + .optional(), + }) + .optional(), fileSearch: z.boolean().optional(), fileCitations: z.boolean().optional(), }) @@ -540,11 +570,29 @@ export const intefaceSchema = z temporaryChat: true, runCode: true, webSearch: true, + peoplePicker: { + admin: { + users: true, + groups: true, + }, + user: { + users: false, + groups: false, + }, + }, + marketplace: { + admin: { + use: false, + }, + user: { + use: false, + }, + }, fileSearch: true, fileCitations: true, }); -export type TInterfaceConfig = z.infer; +export type TInterfaceConfig = z.infer; export type TBalanceConfig = z.infer; export const turnstileOptionsSchema = z @@ -771,7 +819,7 @@ export const configSchema = z.object({ includedTools: z.array(z.string()).optional(), filteredTools: z.array(z.string()).optional(), mcpServers: MCPServersSchema.optional(), - interface: intefaceSchema, + interface: interfaceSchema, turnstile: turnstileSchema.optional(), fileStrategy: fileSourceSchema.default(FileSources.local), actions: z @@ -867,7 +915,7 @@ export const defaultEndpoints: EModelEndpoint[] = [ export const alternateName = { [EModelEndpoint.openAI]: 'OpenAI', [EModelEndpoint.assistants]: 'Assistants', - [EModelEndpoint.agents]: 'Agents', + [EModelEndpoint.agents]: 'My Agents', [EModelEndpoint.azureAssistants]: 'Azure Assistants', [EModelEndpoint.azureOpenAI]: 'Azure OpenAI', [EModelEndpoint.chatGPTBrowser]: 'ChatGPT', diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index b850e141f..ff386c097 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -477,69 +477,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 dce189496..90a55c3b7 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -39,6 +39,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 c8b9c8feb..fda75a389 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -43,6 +43,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/permissions.ts b/packages/data-provider/src/permissions.ts index c39538324..a62916c7a 100644 --- a/packages/data-provider/src/permissions.ts +++ b/packages/data-provider/src/permissions.ts @@ -36,6 +36,14 @@ export enum PermissionTypes { * Type for using the "Web Search" feature */ WEB_SEARCH = 'WEB_SEARCH', + /** + * Type for People Picker Permissions + */ + PEOPLE_PICKER = 'PEOPLE_PICKER', + /** + * Type for Marketplace Permissions + */ + MARKETPLACE = 'MARKETPLACE', /** * Type for using the "File Search" feature */ @@ -59,6 +67,8 @@ export enum Permissions { SHARE = 'SHARE', /** Can disable if desired */ OPT_OUT = 'OPT_OUT', + VIEW_USERS = 'VIEW_USERS', + VIEW_GROUPS = 'VIEW_GROUPS', } export const promptPermissionsSchema = z.object({ @@ -111,6 +121,17 @@ export const webSearchPermissionsSchema = z.object({ }); export type TWebSearchPermissions = z.infer; +export const peoplePickerPermissionsSchema = z.object({ + [Permissions.VIEW_USERS]: z.boolean().default(true), + [Permissions.VIEW_GROUPS]: z.boolean().default(true), +}); +export type TPeoplePickerPermissions = z.infer; + +export const marketplacePermissionsSchema = z.object({ + [Permissions.USE]: z.boolean().default(false), +}); +export type TMarketplacePermissions = z.infer; + export const fileSearchPermissionsSchema = z.object({ [Permissions.USE]: z.boolean().default(true), }); @@ -131,6 +152,8 @@ export const permissionsSchema = z.object({ [PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema, [PermissionTypes.RUN_CODE]: runCodePermissionsSchema, [PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema, + [PermissionTypes.PEOPLE_PICKER]: peoplePickerPermissionsSchema, + [PermissionTypes.MARKETPLACE]: marketplacePermissionsSchema, [PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema, [PermissionTypes.FILE_CITATIONS]: fileCitationsPermissionsSchema, }); diff --git a/packages/data-provider/src/roles.ts b/packages/data-provider/src/roles.ts index 4a0b99a4b..d36ae72f0 100644 --- a/packages/data-provider/src/roles.ts +++ b/packages/data-provider/src/roles.ts @@ -12,6 +12,7 @@ import { fileSearchPermissionsSchema, multiConvoPermissionsSchema, temporaryChatPermissionsSchema, + peoplePickerPermissionsSchema, fileCitationsPermissionsSchema, } from './permissions'; @@ -76,6 +77,13 @@ const defaultRolesSchema = z.object({ [PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema.extend({ [Permissions.USE]: z.boolean().default(true), }), + [PermissionTypes.PEOPLE_PICKER]: peoplePickerPermissionsSchema.extend({ + [Permissions.VIEW_USERS]: z.boolean().default(true), + [Permissions.VIEW_GROUPS]: z.boolean().default(true), + }), + [PermissionTypes.MARKETPLACE]: z.object({ + [Permissions.USE]: z.boolean().default(false), + }), [PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema.extend({ [Permissions.USE]: z.boolean().default(true), }), @@ -126,6 +134,13 @@ export const roleDefaults = defaultRolesSchema.parse({ [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true, }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: true, + [Permissions.VIEW_GROUPS]: true, + }, + [PermissionTypes.MARKETPLACE]: { + [Permissions.USE]: true, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true, }, @@ -145,6 +160,13 @@ export const roleDefaults = defaultRolesSchema.parse({ [PermissionTypes.TEMPORARY_CHAT]: {}, [PermissionTypes.RUN_CODE]: {}, [PermissionTypes.WEB_SEARCH]: {}, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: false, + [Permissions.VIEW_GROUPS]: false, + }, + [PermissionTypes.MARKETPLACE]: { + [Permissions.USE]: false, + }, [PermissionTypes.FILE_SEARCH]: {}, [PermissionTypes.FILE_CITATIONS]: {}, }, diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index ce33b6545..df9009728 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-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 7b1b65ab7..759220c56 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -109,7 +109,6 @@ export type DeleteActionOptions = MutationOptions; export type AgentAvatarVariables = { agent_id: string; formData: FormData; - postCreation?: boolean; }; export type UpdateAgentActionVariables = { diff --git a/packages/data-schemas/src/methods/aclEntry.spec.ts b/packages/data-schemas/src/methods/aclEntry.spec.ts index 968bc8c58..54b00b78c 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 new file mode 100644 index 000000000..fb1b1aaa1 --- /dev/null +++ b/packages/data-schemas/src/methods/agentCategory.ts @@ -0,0 +1,222 @@ +import type { Model, Types, DeleteResult } from 'mongoose'; +import type { IAgentCategory, AgentCategory } from '../types/agentCategory'; + +export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) { + /** + * Get all active categories sorted by order + * @returns Array of active categories + */ + async function getActiveCategories(): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + return await AgentCategory.find({ isActive: true }).sort({ order: 1, label: 1 }).lean(); + } + + /** + * Get categories with agent counts + * @returns Categories with agent counts + */ + async function getCategoriesWithCounts(): Promise<(IAgentCategory & { agentCount: number })[]> { + const Agent = mongoose.models.Agent; + + const categoryCounts = await Agent.aggregate([ + { $match: { category: { $exists: true, $ne: null } } }, + { $group: { _id: '$category', count: { $sum: 1 } } }, + ]); + + const countMap = new Map(categoryCounts.map((c) => [c._id, c.count])); + const categories = await getActiveCategories(); + + return categories.map((category) => ({ + ...category, + agentCount: countMap.get(category.value) || (0 as number), + })) as (IAgentCategory & { agentCount: number })[]; + } + + /** + * Get valid category values for Agent model validation + * @returns Array of valid category values + */ + async function getValidCategoryValues(): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + return await AgentCategory.find({ isActive: true }).distinct('value').lean(); + } + + /** + * Seed initial categories from existing constants + * @param categories - Array of category data to seed + * @returns Bulk write result + */ + async function seedCategories( + categories: Array<{ + value: string; + label?: string; + description?: string; + order?: number; + }>, + ): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + + 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 await AgentCategory.bulkWrite(operations); + } + + /** + * Find a category by value + * @param value - The category value to search for + * @returns The category document or null + */ + async function findCategoryByValue(value: string): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + return await AgentCategory.findOne({ value }).lean(); + } + + /** + * Create a new category + * @param categoryData - The category data to create + * @returns The created category + */ + async function createCategory(categoryData: Partial): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + const category = await AgentCategory.create(categoryData); + return category.toObject() as IAgentCategory; + } + + /** + * Update a category by value + * @param value - The category value to update + * @param updateData - The data to update + * @returns The updated category or null + */ + async function updateCategory( + value: string, + updateData: Partial, + ): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + return await AgentCategory.findOneAndUpdate( + { value }, + { $set: updateData }, + { new: true, runValidators: true }, + ).lean(); + } + + /** + * Delete a category by value + * @param value - The category value to delete + * @returns Whether the deletion was successful + */ + async function deleteCategory(value: string): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + const result = await AgentCategory.deleteOne({ value }); + return result.deletedCount > 0; + } + + /** + * Find a category by ID + * @param id - The category ID to search for + * @returns The category document or null + */ + async function findCategoryById(id: string | Types.ObjectId): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + return await AgentCategory.findById(id).lean(); + } + + /** + * Get all categories (active and inactive) + * @returns Array of all categories + */ + async function getAllCategories(): Promise { + const AgentCategory = mongoose.models.AgentCategory as Model; + 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, + getValidCategoryValues, + seedCategories, + findCategoryByValue, + createCategory, + updateCategory, + deleteCategory, + findCategoryById, + getAllCategories, + ensureDefaultCategories, + }; +} + +export type AgentCategoryMethods = ReturnType; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index bc27e58ab..c42590f61 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -4,6 +4,8 @@ import { createTokenMethods, type TokenMethods } from './token'; import { createRoleMethods, type RoleMethods } from './role'; /* Memories */ import { createMemoryMethods, type MemoryMethods } from './memory'; +/* Agent Categories */ +import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory'; /* Permissions */ import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole'; import { createUserGroupMethods, type UserGroupMethods } from './userGroup'; @@ -22,6 +24,7 @@ export function createMethods(mongoose: typeof import('mongoose')) { ...createTokenMethods(mongoose), ...createRoleMethods(mongoose), ...createMemoryMethods(mongoose), + ...createAgentCategoryMethods(mongoose), ...createAccessRoleMethods(mongoose), ...createUserGroupMethods(mongoose), ...createAclEntryMethods(mongoose), @@ -37,6 +40,7 @@ export type AllMethods = UserMethods & TokenMethods & RoleMethods & MemoryMethods & + AgentCategoryMethods & AccessRoleMethods & UserGroupMethods & AclEntryMethods & diff --git a/packages/data-schemas/src/models/agentCategory.ts b/packages/data-schemas/src/models/agentCategory.ts new file mode 100644 index 000000000..1ba26c037 --- /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 bf8776b60..dd1d8ee23 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 9deda176c..1fd330eb0 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -92,6 +92,21 @@ const agentSchema = new Schema( type: [Schema.Types.Mixed], default: [], }, + category: { + type: String, + trim: true, + index: true, + default: 'general', + }, + support_contact: { + type: Schema.Types.Mixed, + default: undefined, + }, + is_promoted: { + type: Boolean, + default: false, + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/agentCategory.ts b/packages/data-schemas/src/schema/agentCategory.ts new file mode 100644 index 000000000..61792de3f --- /dev/null +++ b/packages/data-schemas/src/schema/agentCategory.ts @@ -0,0 +1,42 @@ +import { Schema, Document } from 'mongoose'; +import type { IAgentCategory } from '~/types'; + +const agentCategorySchema = new Schema( + { + value: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + index: true, + }, + label: { + type: String, + required: true, + trim: true, + }, + description: { + type: String, + trim: true, + default: '', + }, + order: { + type: Number, + default: 0, + index: true, + }, + isActive: { + type: Boolean, + default: true, + index: true, + }, + }, + { + timestamps: true, + }, +); + +agentCategorySchema.index({ isActive: 1, order: 1 }); + +export default agentCategorySchema; diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index e95f560b2..a7de162e0 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -1,5 +1,6 @@ export { default as actionSchema } from './action'; export { default as agentSchema } from './agent'; +export { default as agentCategorySchema } from './agentCategory'; export { default as assistantSchema } from './assistant'; export { default as balanceSchema } from './balance'; export { default as bannerSchema } from './banner'; diff --git a/packages/data-schemas/src/schema/role.ts b/packages/data-schemas/src/schema/role.ts index 43da45a49..78f22f9b3 100644 --- a/packages/data-schemas/src/schema/role.ts +++ b/packages/data-schemas/src/schema/role.ts @@ -39,6 +39,13 @@ const rolePermissionsSchema = new Schema( [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: { type: Boolean, default: true }, }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: { type: Boolean, default: false }, + [Permissions.VIEW_GROUPS]: { type: Boolean, default: false }, + }, + [PermissionTypes.MARKETPLACE]: { + [Permissions.USE]: { type: Boolean, default: false }, + }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: { type: Boolean, default: true }, }, @@ -75,6 +82,11 @@ const roleSchema: Schema = new Schema({ [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: false, + [Permissions.VIEW_GROUPS]: false, + }, + [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }), diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 95a9953ed..29d519156 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -36,4 +36,5 @@ export interface IAgent extends Omit { versions?: Omit[]; category: string; support_contact?: ISupportContact; + is_promoted?: boolean; } diff --git a/packages/data-schemas/src/types/agentCategory.ts b/packages/data-schemas/src/types/agentCategory.ts new file mode 100644 index 000000000..ccf266a61 --- /dev/null +++ b/packages/data-schemas/src/types/agentCategory.ts @@ -0,0 +1,19 @@ +import type { Document, Types } from 'mongoose'; + +export type AgentCategory = { + /** Unique identifier for the category (e.g., 'general', 'hr', 'finance') */ + value: string; + /** Display label for the category */ + label: string; + /** Description of the category */ + description?: string; + /** Display order for sorting categories */ + order: number; + /** Whether the category is active and should be displayed */ + isActive: boolean; +}; + +export type IAgentCategory = AgentCategory & + Document & { + _id: Types.ObjectId; + }; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 3e6539492..679553aa8 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'; diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index 13bb5116e..515b5236f 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -35,6 +35,13 @@ export interface IRole extends Document { [PermissionTypes.WEB_SEARCH]?: { [Permissions.USE]?: boolean; }; + [PermissionTypes.PEOPLE_PICKER]?: { + [Permissions.VIEW_USERS]?: boolean; + [Permissions.VIEW_GROUPS]?: boolean; + }; + [PermissionTypes.MARKETPLACE]?: { + [Permissions.USE]?: boolean; + }; [PermissionTypes.FILE_SEARCH]?: { [Permissions.USE]?: boolean; };