WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features
This commit is contained in:
Danny Avila 2025-06-23 10:22:27 -04:00
parent dd67e463e4
commit d471209ced
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
55 changed files with 6280 additions and 17 deletions

View file

@ -5,6 +5,8 @@ const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
// Default category value for new agents
const AgentCategory = require('./AgentCategory');
const {
getProjectByName,
addAgentIdsToProject,
@ -13,6 +15,82 @@ const {
} = require('./Project');
const { getCachedTools } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
// 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,
},
},
);
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@ -34,6 +112,7 @@ const createAgent = async (agentData) => {
updatedAt: timestamp,
},
],
category: agentData.category || 'general',
};
return (await Agent.create(initialAgentData)).toObject();
};
@ -491,6 +570,7 @@ const getListAgents = async (searchParameter) => {
projectIds: 1,
description: 1,
isCollaborative: 1,
category: 1,
}).lean()
).map((agent) => {
if (agent.author?.toString() !== author) {

125
api/models/AgentCategory.js Normal file
View file

@ -0,0 +1,125 @@
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<AgentCategory[]>} 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<AgentCategory[]>} 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<string[]>} 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;

View file

@ -0,0 +1,255 @@
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<Object>} 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,
};

View file

@ -10,6 +10,7 @@ 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 ?? {};
@ -37,4 +38,7 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
// Add marketplace routes
router.use('/marketplace', marketplace);
module.exports = router;

View file

@ -0,0 +1,46 @@
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;