mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-12 05:28:51 +01:00
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:
parent
dd67e463e4
commit
d471209ced
55 changed files with 6280 additions and 17 deletions
|
|
@ -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
125
api/models/AgentCategory.js
Normal 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;
|
||||
255
api/server/controllers/agents/marketplace.js
Normal file
255
api/server/controllers/agents/marketplace.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
46
api/server/routes/agents/marketplace.js
Normal file
46
api/server/routes/agents/marketplace.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue