🏪 feat: Agent Marketplace

bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status

refactored and moved agent category methods and schema to data-schema package
This commit is contained in:
“Praneeth 2025-06-11 22:55:07 +05:30 committed by Danny Avila
parent 658480d7cd
commit f59ef0ecb0
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
10 changed files with 291 additions and 100 deletions

View file

@ -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');

View file

@ -15,28 +15,28 @@ const agentCategorySchema = new mongoose.Schema(
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,
@ -46,7 +46,7 @@ const agentCategorySchema = new mongoose.Schema(
},
{
timestamps: true,
}
},
);
// Indexes for performance
@ -56,32 +56,30 @@ 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();
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');
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]));
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 => ({
return categories.map((category) => ({
...category,
agentCount: countMap.get(category.value) || 0,
}));
@ -91,16 +89,14 @@ agentCategorySchema.statics.getCategoriesWithCounts = async function() {
* 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();
agentCategorySchema.statics.getValidCategoryValues = function () {
return this.find({ isActive: true }).distinct('value').lean();
};
/**
* Seed initial categories from existing constants
*/
agentCategorySchema.statics.seedCategories = async function(categories) {
agentCategorySchema.statics.seedCategories = async function (categories) {
const operations = categories.map((category, index) => ({
updateOne: {
filter: { value: category.value },
@ -116,10 +112,10 @@ agentCategorySchema.statics.seedCategories = async function(categories) {
upsert: true,
},
}));
return this.bulkWrite(operations);
};
const AgentCategory = mongoose.model('AgentCategory', agentCategorySchema);
module.exports = AgentCategory;
module.exports = AgentCategory;

View file

@ -3,7 +3,7 @@ const mongoose = require('mongoose');
const { logger } = require('~/config');
// Get the Agent model
const Agent = mongoose.model('agent');
const Agent = mongoose.model('Agent');
// Default page size for agent browsing
const DEFAULT_PAGE_SIZE = 6;

View file

@ -0,0 +1,161 @@
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<IAgentCategory[]> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<string[]> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<any> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<IAgentCategory | null> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<IAgentCategory>): Promise<IAgentCategory> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<IAgentCategory>,
): Promise<IAgentCategory | null> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<boolean> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
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<IAgentCategory | null> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findById(id).lean();
}
/**
* Get all categories (active and inactive)
* @returns Array of all categories
*/
async function getAllCategories(): Promise<IAgentCategory[]> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({}).sort({ order: 1, label: 1 }).lean();
}
return {
getActiveCategories,
getCategoriesWithCounts,
getValidCategoryValues,
seedCategories,
findCategoryByValue,
createCategory,
updateCategory,
deleteCategory,
findCategoryById,
getAllCategories,
};
}
export type AgentCategoryMethods = ReturnType<typeof createAgentCategoryMethods>;

View file

@ -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 &

View file

@ -92,6 +92,35 @@ const agentSchema = new Schema<IAgent>(
type: [Schema.Types.Mixed],
default: [],
},
category: {
type: String,
trim: true,
index: true,
default: 'general',
},
support_contact: {
type: {
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,
},
},
default: {},
},
is_promoted: {
type: Boolean,
default: false,
index: true,
},
},
{
timestamps: true,

View file

@ -0,0 +1,49 @@
import { Schema, Document } from 'mongoose';
export interface IAgentCategory extends Document {
value: string;
label: string;
description?: string;
order: number;
isActive: boolean;
}
const agentCategorySchema = new Schema<IAgentCategory>(
{
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;

View file

@ -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';

View file

@ -36,4 +36,10 @@ export interface IAgent extends Omit<Document, 'model'> {
versions?: Omit<IAgent, 'versions'>[];
category: string;
support_contact?: ISupportContact;
category: string;
support_contact?: {
name?: string;
email?: string;
};
is_promoted?: boolean;
}

View file

@ -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;
};