mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +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
01e9b196bc
commit
6c9a29b6cf
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 } =
|
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||||
require('librechat-data-provider').Constants;
|
require('librechat-data-provider').Constants;
|
||||||
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
||||||
|
// Default category value for new agents
|
||||||
|
const AgentCategory = require('./AgentCategory');
|
||||||
const {
|
const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
addAgentIdsToProject,
|
addAgentIdsToProject,
|
||||||
|
|
@ -13,6 +15,82 @@ const {
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
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 { getActions } = require('./Action');
|
||||||
const { Agent } = require('~/db/models');
|
const { Agent } = require('~/db/models');
|
||||||
|
|
||||||
|
|
@ -34,6 +112,7 @@ const createAgent = async (agentData) => {
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
category: agentData.category || 'general',
|
||||||
};
|
};
|
||||||
return (await Agent.create(initialAgentData)).toObject();
|
return (await Agent.create(initialAgentData)).toObject();
|
||||||
};
|
};
|
||||||
|
|
@ -491,6 +570,7 @@ const getListAgents = async (searchParameter) => {
|
||||||
projectIds: 1,
|
projectIds: 1,
|
||||||
description: 1,
|
description: 1,
|
||||||
isCollaborative: 1,
|
isCollaborative: 1,
|
||||||
|
category: 1,
|
||||||
}).lean()
|
}).lean()
|
||||||
).map((agent) => {
|
).map((agent) => {
|
||||||
if (agent.author?.toString() !== author) {
|
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 { isEnabled } = require('~/server/utils');
|
||||||
const { v1 } = require('./v1');
|
const { v1 } = require('./v1');
|
||||||
const chat = require('./chat');
|
const chat = require('./chat');
|
||||||
|
const marketplace = require('./marketplace');
|
||||||
|
|
||||||
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||||
|
|
||||||
|
|
@ -37,4 +38,7 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||||
chatRouter.use('/', chat);
|
chatRouter.use('/', chat);
|
||||||
router.use('/chat', chatRouter);
|
router.use('/chat', chatRouter);
|
||||||
|
|
||||||
|
// Add marketplace routes
|
||||||
|
router.use('/marketplace', marketplace);
|
||||||
|
|
||||||
module.exports = router;
|
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;
|
||||||
|
|
@ -17,6 +17,11 @@ export type TAgentCapabilities = {
|
||||||
[AgentCapabilities.hide_sequential_outputs]?: boolean;
|
[AgentCapabilities.hide_sequential_outputs]?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SupportContact = {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentForm = {
|
export type AgentForm = {
|
||||||
agent?: TAgentOption;
|
agent?: TAgentOption;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -30,4 +35,5 @@ export type AgentForm = {
|
||||||
agent_ids?: string[];
|
agent_ids?: string[];
|
||||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||||
recursion_limit?: number;
|
recursion_limit?: number;
|
||||||
|
support_contact?: SupportContact;
|
||||||
} & TAgentCapabilities;
|
} & TAgentCapabilities;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const defaultInterface = getConfigDefaults().interface;
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||||
|
|
||||||
const interfaceConfig = useMemo(
|
const interfaceConfig = useMemo(
|
||||||
() => startupConfig?.interface ?? defaultInterface,
|
() => startupConfig?.interface ?? defaultInterface,
|
||||||
[startupConfig],
|
[startupConfig],
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export default function OpenSidebar({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
data-testid="open-sidebar-button"
|
data-testid="open-sidebar-button"
|
||||||
aria-label={localize('com_nav_open_sidebar')}
|
aria-label={localize('com_nav_open_sidebar')}
|
||||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setNavVisible((prev) => {
|
setNavVisible((prev) => {
|
||||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useContext } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
import {
|
||||||
|
QueryKeys,
|
||||||
|
Constants,
|
||||||
|
EModelEndpoint,
|
||||||
|
PermissionTypes,
|
||||||
|
Permissions,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
|
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
|
||||||
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
|
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
|
||||||
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
|
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
|
||||||
import { TooltipAnchor, Button } from '~/components/ui';
|
import { TooltipAnchor, Button } from '~/components/ui';
|
||||||
import { useLocalize, useNewConvo } from '~/hooks';
|
import { useLocalize, useNewConvo, useHasAccess } from '~/hooks';
|
||||||
|
import { AuthContext } from '~/hooks/AuthContext';
|
||||||
|
import { LayoutGrid } from 'lucide-react';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function NewChat({
|
export default function NewChat({
|
||||||
|
|
@ -29,6 +37,12 @@ export default function NewChat({
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { conversation } = store.useCreateConversationAtom(index);
|
const { conversation } = store.useCreateConversationAtom(index);
|
||||||
|
const endpointsConfig = useRecoilValue(store.endpointsConfig);
|
||||||
|
const authContext = useContext(AuthContext);
|
||||||
|
const hasAccessToAgents = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.AGENTS,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|
@ -50,6 +64,22 @@ export default function NewChat({
|
||||||
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
|
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAgentMarketplace = useCallback(() => {
|
||||||
|
navigate('/agents');
|
||||||
|
if (isSmallScreen) {
|
||||||
|
toggleNav();
|
||||||
|
}
|
||||||
|
}, [navigate, isSmallScreen, toggleNav]);
|
||||||
|
|
||||||
|
// Check if auth is ready (avoid race conditions)
|
||||||
|
const authReady =
|
||||||
|
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between py-[2px] md:py-2">
|
<div className="flex items-center justify-between py-[2px] md:py-2">
|
||||||
|
|
@ -88,6 +118,29 @@ export default function NewChat({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Marketplace button - separate row like ChatGPT */}
|
||||||
|
{showAgentMarketplace && (
|
||||||
|
<div className="flex px-2 pb-4 pt-2 md:px-3">
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_nav_agents_marketplace')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-testid="nav-agents-marketplace-button"
|
||||||
|
aria-label={localize('com_nav_agents_marketplace')}
|
||||||
|
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
|
||||||
|
onClick={handleAgentMarketplace}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<span className="truncate text-base font-medium">
|
||||||
|
{localize('com_nav_agents_marketplace')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{subHeaders != null ? subHeaders : null}
|
{subHeaders != null ? subHeaders : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import {
|
||||||
PlaneTakeoffIcon,
|
PlaneTakeoffIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
TerminalSquareIcon,
|
TerminalSquareIcon,
|
||||||
|
// NEW: Add these for agent categories
|
||||||
|
Users as UsersIcon,
|
||||||
|
Beaker as BeakerIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
|
@ -22,6 +26,13 @@ const categoryIconMap: Record<string, React.ElementType> = {
|
||||||
code: TerminalSquareIcon,
|
code: TerminalSquareIcon,
|
||||||
travel: PlaneTakeoffIcon,
|
travel: PlaneTakeoffIcon,
|
||||||
teach_or_explain: GraduationCapIcon,
|
teach_or_explain: GraduationCapIcon,
|
||||||
|
// NEW: Agent categories
|
||||||
|
general: BoxIcon,
|
||||||
|
hr: UsersIcon,
|
||||||
|
rd: BeakerIcon,
|
||||||
|
it: TerminalSquareIcon,
|
||||||
|
sales: LineChartIcon,
|
||||||
|
aftersales: SettingsIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryColorMap: Record<string, string> = {
|
const categoryColorMap: Record<string, string> = {
|
||||||
|
|
@ -34,6 +45,13 @@ const categoryColorMap: Record<string, string> = {
|
||||||
finance: 'text-orange-400',
|
finance: 'text-orange-400',
|
||||||
roleplay: 'text-orange-400',
|
roleplay: 'text-orange-400',
|
||||||
teach_or_explain: 'text-blue-300',
|
teach_or_explain: 'text-blue-300',
|
||||||
|
// NEW: Agent categories
|
||||||
|
general: 'text-blue-500',
|
||||||
|
hr: 'text-green-500',
|
||||||
|
rd: 'text-purple-500',
|
||||||
|
it: 'text-red-500',
|
||||||
|
sales: 'text-orange-500',
|
||||||
|
aftersales: 'text-yellow-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CategoryIcon({
|
export default function CategoryIcon({
|
||||||
|
|
|
||||||
93
client/src/components/SidePanel/Agents/AgentCard.tsx
Normal file
93
client/src/components/SidePanel/Agents/AgentCard.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { renderAgentAvatar, getContactDisplayName } from '~/utils/agents';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface AgentCardProps {
|
||||||
|
agent: t.Agent; // The agent data to display
|
||||||
|
onClick: () => void; // Callback when card is clicked
|
||||||
|
className?: string; // Additional CSS classes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card component to display agent information
|
||||||
|
*/
|
||||||
|
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group relative flex overflow-hidden rounded-2xl',
|
||||||
|
'cursor-pointer transition-colors duration-200',
|
||||||
|
'aspect-[5/2.5] w-full',
|
||||||
|
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={localize('com_agents_agent_card_label', {
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description || localize('com_agents_no_description'),
|
||||||
|
})}
|
||||||
|
aria-describedby={`agent-${agent.id}-description`}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||||
|
{/* Agent avatar section - left side, responsive */}
|
||||||
|
<div className="flex flex-shrink-0 items-center">
|
||||||
|
{renderAgentAvatar(agent, { size: 'md' })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent info section - right side, responsive */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||||
|
{/* Agent name - responsive text sizing */}
|
||||||
|
<h3 className="mb-1 line-clamp-1 text-base font-bold text-gray-900 dark:text-white sm:mb-2 sm:text-lg">
|
||||||
|
{agent.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Agent description - responsive text sizing and spacing */}
|
||||||
|
<p
|
||||||
|
id={`agent-${agent.id}-description`}
|
||||||
|
className={cn(
|
||||||
|
'mb-1 line-clamp-2 text-xs leading-relaxed text-gray-600 dark:text-gray-300',
|
||||||
|
'sm:mb-2 sm:text-sm',
|
||||||
|
)}
|
||||||
|
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||||
|
>
|
||||||
|
{agent.description || (
|
||||||
|
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Owner info - responsive text sizing */}
|
||||||
|
{(() => {
|
||||||
|
const displayName = getContactDisplayName(agent);
|
||||||
|
|
||||||
|
if (displayName) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
|
||||||
|
<span className="font-light">{localize('com_agents_created_by')}</span>
|
||||||
|
<span className="ml-1 font-bold">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentCard;
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useAgentCategories } from '~/hooks/Agents';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface AgentCategoryDisplayProps {
|
||||||
|
category?: string;
|
||||||
|
className?: string;
|
||||||
|
showIcon?: boolean;
|
||||||
|
iconClassName?: string;
|
||||||
|
showEmptyFallback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display an agent category with proper translation
|
||||||
|
*
|
||||||
|
* @param category - The category value (e.g., "general", "hr", etc.)
|
||||||
|
* @param className - Optional className for the container
|
||||||
|
* @param showIcon - Whether to show the category icon
|
||||||
|
* @param iconClassName - Optional className for the icon
|
||||||
|
* @param showEmptyFallback - Whether to show a fallback for empty categories
|
||||||
|
*/
|
||||||
|
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
|
||||||
|
category,
|
||||||
|
className = '',
|
||||||
|
showIcon = true,
|
||||||
|
iconClassName = 'h-4 w-4 mr-2',
|
||||||
|
showEmptyFallback = false,
|
||||||
|
}) => {
|
||||||
|
const { categories, emptyCategory } = useAgentCategories();
|
||||||
|
|
||||||
|
// Find the category in our processed categories list
|
||||||
|
const categoryItem = categories.find((c) => c.value === category);
|
||||||
|
|
||||||
|
// Handle empty string case differently than undefined/null
|
||||||
|
if (category === '') {
|
||||||
|
if (!showEmptyFallback) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Show the empty category placeholder
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center text-gray-400', className)}>
|
||||||
|
<span>{emptyCategory.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No category or unknown category
|
||||||
|
if (!category || !categoryItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center', className)}>
|
||||||
|
{showIcon && categoryItem.icon && (
|
||||||
|
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
|
||||||
|
)}
|
||||||
|
<span>{categoryItem.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentCategoryDisplay;
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useFormContext,
|
||||||
|
Controller,
|
||||||
|
useWatch,
|
||||||
|
ControllerRenderProps,
|
||||||
|
FieldValues,
|
||||||
|
FieldPath,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||||
|
import { useAgentCategories } from '~/hooks/Agents';
|
||||||
|
import { OptionWithIcon } from '~/common/types';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle category synchronization
|
||||||
|
*/
|
||||||
|
const useCategorySync = (agent_id: string | null) => {
|
||||||
|
const [handled, setHandled] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncCategory: (field: ControllerRenderProps<FieldValues, FieldPath<FieldValues>>) => {
|
||||||
|
// Only run once and only for new agents
|
||||||
|
if (!handled && agent_id === '' && !field.value) {
|
||||||
|
field.onChange('general');
|
||||||
|
setHandled(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component for selecting agent categories with form validation
|
||||||
|
*/
|
||||||
|
const AgentCategorySelector: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const formContext = useFormContext();
|
||||||
|
const { categories } = useAgentCategories();
|
||||||
|
|
||||||
|
// Always call useWatch
|
||||||
|
const agent_id = useWatch({
|
||||||
|
name: 'id',
|
||||||
|
control: formContext.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use custom hook for category sync
|
||||||
|
const { syncCategory } = useCategorySync(agent_id);
|
||||||
|
|
||||||
|
// Transform categories to the format expected by ControlCombobox
|
||||||
|
const comboboxItems = categories.map((category) => ({
|
||||||
|
label: category.label,
|
||||||
|
value: category.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCategoryDisplayValue = (value: string) => {
|
||||||
|
const categoryItem = comboboxItems.find((c) => c.value === value);
|
||||||
|
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
|
||||||
|
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name="category"
|
||||||
|
control={formContext.control}
|
||||||
|
defaultValue="general"
|
||||||
|
render={({ field }) => {
|
||||||
|
// Sync category if needed (without using useEffect in render)
|
||||||
|
syncCategory(field);
|
||||||
|
|
||||||
|
const displayValue = getCategoryDisplayValue(field.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlCombobox
|
||||||
|
selectedValue={field.value}
|
||||||
|
displayValue={displayValue}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
setValue={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
items={comboboxItems}
|
||||||
|
className=""
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
isCollapsed={false}
|
||||||
|
showCarat={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentCategorySelector;
|
||||||
|
|
@ -17,6 +17,7 @@ import FileSearch from './FileSearch';
|
||||||
import Artifacts from './Artifacts';
|
import Artifacts from './Artifacts';
|
||||||
import AgentTool from './AgentTool';
|
import AgentTool from './AgentTool';
|
||||||
import CodeForm from './Code/Form';
|
import CodeForm from './Code/Form';
|
||||||
|
import AgentCategorySelector from './AgentCategorySelector';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
|
|
||||||
const labelClass = 'mb-2 text-token-text-primary block font-medium';
|
const labelClass = 'mb-2 text-token-text-primary block font-medium';
|
||||||
|
|
@ -238,6 +239,13 @@ export default function AgentConfig({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Category */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className={labelClass} htmlFor="category-selector">
|
||||||
|
{localize('com_ui_category')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<AgentCategorySelector className="w-full" />
|
||||||
|
</div>
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<Instructions />
|
<Instructions />
|
||||||
{/* Model and Provider */}
|
{/* Model and Provider */}
|
||||||
|
|
@ -356,6 +364,93 @@ export default function AgentConfig({
|
||||||
</div>
|
</div>
|
||||||
{/* MCP Section */}
|
{/* MCP Section */}
|
||||||
{/* <MCPSection /> */}
|
{/* <MCPSection /> */}
|
||||||
|
|
||||||
|
{/* Support Contact (Optional) */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
<label className="text-token-text-primary block font-medium">
|
||||||
|
{localize('com_ui_support_contact')}
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Support Contact Name */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label
|
||||||
|
className="mb-1 flex items-center justify-between"
|
||||||
|
htmlFor="support-contact-name"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{localize('com_ui_support_contact_name')}</span>
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="support_contact.name"
|
||||||
|
control={control}
|
||||||
|
rules={{
|
||||||
|
minLength: {
|
||||||
|
value: 3,
|
||||||
|
message: localize('com_ui_support_contact_name_min_length', { minLength: 3 }),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
|
||||||
|
id="support-contact-name"
|
||||||
|
type="text"
|
||||||
|
placeholder={localize('com_ui_support_contact_name_placeholder')}
|
||||||
|
aria-label="Support contact name"
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||||
|
{error.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Support Contact Email */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label
|
||||||
|
className="mb-1 flex items-center justify-between"
|
||||||
|
htmlFor="support-contact-email"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{localize('com_ui_support_contact_email')}</span>
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="support_contact.email"
|
||||||
|
control={control}
|
||||||
|
rules={{
|
||||||
|
pattern: {
|
||||||
|
value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
|
||||||
|
message: localize('com_ui_support_contact_email_invalid'),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
|
||||||
|
id="support-contact-email"
|
||||||
|
type="email"
|
||||||
|
placeholder={localize('com_ui_support_contact_email_placeholder')}
|
||||||
|
aria-label="Support contact email"
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||||
|
{error.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToolSelectDialog
|
<ToolSelectDialog
|
||||||
isOpen={showToolDialog}
|
isOpen={showToolDialog}
|
||||||
|
|
|
||||||
196
client/src/components/SidePanel/Agents/AgentDetail.tsx
Normal file
196
client/src/components/SidePanel/Agents/AgentDetail.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
interface SupportContact {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentWithSupport extends t.Agent {
|
||||||
|
support_contact?: SupportContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { useToast } from '~/hooks';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '~/components/ui';
|
||||||
|
import { DotsIcon } from '~/components/svg';
|
||||||
|
import { renderAgentAvatar } from '~/utils/agents';
|
||||||
|
|
||||||
|
interface AgentDetailProps {
|
||||||
|
agent: AgentWithSupport; // The agent data to display
|
||||||
|
isOpen: boolean; // Whether the detail dialog is open
|
||||||
|
onClose: () => void; // Callback when dialog is closed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for displaying agent details
|
||||||
|
*/
|
||||||
|
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside the dropdown menu
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownOpen &&
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to chat with the selected agent
|
||||||
|
*/
|
||||||
|
const handleStartChat = () => {
|
||||||
|
if (agent) {
|
||||||
|
navigate(`/c/new?agent_id=${agent.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the agent's shareable link to clipboard
|
||||||
|
*/
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
const baseUrl = new URL(window.location.origin);
|
||||||
|
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(chatUrl)
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
message: 'Link copied',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_agents_link_copy_failed'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format contact information with mailto links when appropriate
|
||||||
|
*/
|
||||||
|
const formatContact = () => {
|
||||||
|
if (!agent?.support_contact) return null;
|
||||||
|
|
||||||
|
const { name, email } = agent.support_contact;
|
||||||
|
|
||||||
|
if (name && email) {
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||||
|
{email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return <span>{name}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent ref={dialogRef} className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]">
|
||||||
|
{/* Context menu - top right */}
|
||||||
|
<div ref={dropdownRef} className="absolute right-12 top-5 z-50">
|
||||||
|
<Button
|
||||||
|
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-expanded={dropdownOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownOpen(!dropdownOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DotsIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Simple dropdown menu */}
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute right-0 top-10 z-[9999] w-48 rounded-xl border border-border-light bg-surface-primary py-1 shadow-lg dark:bg-surface-secondary dark:shadow-2xl">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownOpen(false);
|
||||||
|
handleCopyLink();
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-text-primary transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
|
||||||
|
>
|
||||||
|
{localize('com_agents_copy_link')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent avatar - top center */}
|
||||||
|
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
|
||||||
|
|
||||||
|
{/* Agent name - center aligned below image */}
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{agent?.name || localize('com_agents_loading')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact info - center aligned below name */}
|
||||||
|
{agent?.support_contact && formatContact() && (
|
||||||
|
<div className="mt-1 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{localize('com_agents_contact')}: {formatContact()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent description - below contact */}
|
||||||
|
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-gray-700 dark:text-gray-300">
|
||||||
|
{agent?.description || (
|
||||||
|
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action button */}
|
||||||
|
<div className="mb-4 mt-6 flex justify-center">
|
||||||
|
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
|
||||||
|
{localize('com_agents_start_chat')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentDetail;
|
||||||
276
client/src/components/SidePanel/Agents/AgentGrid.tsx
Normal file
276
client/src/components/SidePanel/Agents/AgentGrid.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
import { useDynamicAgentQuery, useAgentCategories } from '~/hooks/Agents';
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { Button } from '~/components/ui';
|
||||||
|
import { Spinner } from '~/components/svg';
|
||||||
|
import { SmartLoader, useHasData } from './SmartLoader';
|
||||||
|
import ErrorDisplay from './ErrorDisplay';
|
||||||
|
import AgentCard from './AgentCard';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface AgentGridProps {
|
||||||
|
category: string; // Currently selected category
|
||||||
|
searchQuery: string; // Current search query
|
||||||
|
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for the actual data structure returned by the API
|
||||||
|
interface AgentGridData {
|
||||||
|
agents: t.Agent[];
|
||||||
|
pagination?: {
|
||||||
|
hasMore: boolean;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for displaying a grid of agent cards
|
||||||
|
*/
|
||||||
|
const AgentGrid: React.FC<AgentGridProps> = ({ 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!
|
||||||
|
const {
|
||||||
|
data: rawData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useDynamicAgentQuery({
|
||||||
|
category,
|
||||||
|
searchQuery,
|
||||||
|
page,
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type the data properly
|
||||||
|
const data = rawData as AgentGridData | undefined;
|
||||||
|
|
||||||
|
// Check if we have meaningful data to prevent unnecessary loading states
|
||||||
|
const hasData = useHasData(data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get category display name from API data or use fallback
|
||||||
|
*/
|
||||||
|
const getCategoryDisplayName = (categoryValue: string) => {
|
||||||
|
const categoryData = categories.find((cat) => cat.value === categoryValue);
|
||||||
|
if (categoryData) {
|
||||||
|
return categoryData.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for special categories or unknown categories
|
||||||
|
if (categoryValue === 'promoted') {
|
||||||
|
return localize('com_agents_top_picks');
|
||||||
|
}
|
||||||
|
if (categoryValue === 'all') {
|
||||||
|
return 'All';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple capitalization for unknown categories
|
||||||
|
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more agents when "See More" button is clicked
|
||||||
|
*/
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
setPage((prevPage) => prevPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
const getGridTitle = () => {
|
||||||
|
if (searchQuery) {
|
||||||
|
return localize('com_agents_results_for', { query: searchQuery });
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCategoryDisplayName(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading skeleton component
|
||||||
|
const loadingSkeleton = (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<div className="h-4 w-64 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{Array(6)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[250px] animate-pulse flex-col overflow-hidden rounded-lg',
|
||||||
|
'bg-gray-200 dark:bg-gray-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="h-40 bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
<div className="flex-1 p-5">
|
||||||
|
<div className="mb-3 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
<div className="mb-2 h-3 w-full rounded bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
<div className="h-3 w-2/3 rounded bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle error state with enhanced error display
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
error={error || 'Unknown error occurred'}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
context={{
|
||||||
|
searchQuery,
|
||||||
|
category,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content component with proper semantic structure
|
||||||
|
const mainContent = (
|
||||||
|
<div
|
||||||
|
className="space-y-6"
|
||||||
|
role="tabpanel"
|
||||||
|
id={`category-panel-${category}`}
|
||||||
|
aria-labelledby={`category-tab-${category}`}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy={isLoading && !hasData}
|
||||||
|
>
|
||||||
|
{/* Grid title - only show for search results */}
|
||||||
|
{searchQuery && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-xl font-bold text-gray-900 dark:text-white"
|
||||||
|
id={`category-heading-${category}`}
|
||||||
|
aria-label={`${getGridTitle()}, ${data?.agents?.length || 0} agents available`}
|
||||||
|
>
|
||||||
|
{getGridTitle()}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Handle empty results with enhanced accessibility */}
|
||||||
|
{(!data?.agents || data.agents.length === 0) && !isLoading && !isFetching ? (
|
||||||
|
<div
|
||||||
|
className="py-12 text-center text-gray-500"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={
|
||||||
|
searchQuery
|
||||||
|
? localize('com_agents_search_empty_heading')
|
||||||
|
: localize('com_agents_empty_state_heading')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 className="mb-2 text-lg font-medium">
|
||||||
|
{searchQuery
|
||||||
|
? localize('com_agents_search_empty_heading')
|
||||||
|
: localize('com_agents_empty_state_heading')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm">
|
||||||
|
{searchQuery
|
||||||
|
? localize('com_agents_no_results')
|
||||||
|
: localize('com_agents_none_in_category')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Announcement for screen readers */}
|
||||||
|
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
|
||||||
|
{localize('com_agents_grid_announcement', {
|
||||||
|
count: data?.agents?.length || 0,
|
||||||
|
category: getCategoryDisplayName(category),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent grid - 2 per row with proper semantic structure */}
|
||||||
|
{data?.agents && data.agents.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 gap-6 md:grid-cols-2"
|
||||||
|
role="grid"
|
||||||
|
aria-label={localize('com_agents_grid_announcement', {
|
||||||
|
count: data.agents.length,
|
||||||
|
category: getCategoryDisplayName(category),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{data.agents.map((agent: t.Agent, index: number) => (
|
||||||
|
<div key={`${agent.id}-${index}`} role="gridcell">
|
||||||
|
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator when fetching more with accessibility */}
|
||||||
|
{isFetching && page > 1 && (
|
||||||
|
<div
|
||||||
|
className="flex justify-center py-4"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={localize('com_agents_loading')}
|
||||||
|
>
|
||||||
|
<Spinner className="h-6 w-6 text-primary" />
|
||||||
|
<span className="sr-only">{localize('com_agents_loading')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load more button with enhanced accessibility */}
|
||||||
|
{data?.pagination?.hasMore && !isFetching && (
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
className={cn(
|
||||||
|
'min-w-[160px] border-2 border-gray-300 bg-white px-6 py-3 font-medium text-gray-700',
|
||||||
|
'shadow-sm transition-all duration-200 hover:border-gray-400 hover:bg-gray-50',
|
||||||
|
'hover:shadow-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||||
|
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
|
||||||
|
'dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:focus:ring-blue-400',
|
||||||
|
)}
|
||||||
|
aria-label={localize('com_agents_load_more_label', {
|
||||||
|
category: getCategoryDisplayName(category),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{localize('com_agents_see_more')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use SmartLoader to prevent unnecessary loading flashes
|
||||||
|
return (
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasData={hasData}
|
||||||
|
delay={200} // Show loading only after 200ms delay
|
||||||
|
loadingComponent={loadingSkeleton}
|
||||||
|
>
|
||||||
|
{mainContent}
|
||||||
|
</SmartLoader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentGrid;
|
||||||
301
client/src/components/SidePanel/Agents/AgentMarketplace.tsx
Normal file
301
client/src/components/SidePanel/Agents/AgentMarketplace.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
import type { ContextType } from '~/common';
|
||||||
|
|
||||||
|
import { useGetAgentCategoriesQuery, useGetEndpointsQuery } from '~/data-provider';
|
||||||
|
import { useDocumentTitle } from '~/hooks';
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { TooltipAnchor, Button } from '~/components/ui';
|
||||||
|
import { NewChatIcon } from '~/components/svg';
|
||||||
|
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||||
|
import { SidePanelGroup } from '~/components/SidePanel';
|
||||||
|
import { MarketplaceProvider } from './MarketplaceContext';
|
||||||
|
import CategoryTabs from './CategoryTabs';
|
||||||
|
import AgentDetail from './AgentDetail';
|
||||||
|
import SearchBar from './SearchBar';
|
||||||
|
import AgentGrid from './AgentGrid';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
interface AgentMarketplaceProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AgentMarketplace - Main component for browsing and discovering agents
|
||||||
|
*
|
||||||
|
* Provides tabbed navigation for different agent categories,
|
||||||
|
* search functionality, and detailed agent view through a modal dialog.
|
||||||
|
* Uses URL parameters for state persistence and deep linking.
|
||||||
|
*/
|
||||||
|
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { category } = useParams();
|
||||||
|
const setHideSidePanel = useSetRecoilState(store.hideSidePanel);
|
||||||
|
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||||
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||||
|
|
||||||
|
// Get URL parameters (default to 'promoted' instead of 'all')
|
||||||
|
const activeTab = category || 'promoted';
|
||||||
|
const searchQuery = searchParams.get('q') || '';
|
||||||
|
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||||
|
|
||||||
|
// Set page title
|
||||||
|
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
||||||
|
|
||||||
|
// Ensure right sidebar is always visible in marketplace
|
||||||
|
useEffect(() => {
|
||||||
|
setHideSidePanel(false);
|
||||||
|
|
||||||
|
// Also try to force expand via localStorage
|
||||||
|
localStorage.setItem('hideSidePanel', 'false');
|
||||||
|
localStorage.setItem('fullPanelCollapse', 'false');
|
||||||
|
}, [setHideSidePanel, hideSidePanel]);
|
||||||
|
|
||||||
|
// Ensure endpoints config is loaded first (required for agent queries)
|
||||||
|
useGetEndpointsQuery();
|
||||||
|
|
||||||
|
// Fetch categories using existing query pattern
|
||||||
|
const categoriesQuery = useGetAgentCategoriesQuery({
|
||||||
|
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle agent card selection
|
||||||
|
*
|
||||||
|
* @param agent - The selected agent object
|
||||||
|
*/
|
||||||
|
const handleAgentSelect = (agent: t.Agent) => {
|
||||||
|
// Update URL with selected agent
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set('agent_id', agent.id);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
setSelectedAgent(agent);
|
||||||
|
setIsDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle closing the agent detail dialog
|
||||||
|
*/
|
||||||
|
const handleDetailClose = () => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.delete('agent_id');
|
||||||
|
setSearchParams(newParams);
|
||||||
|
setSelectedAgent(null);
|
||||||
|
setIsDetailOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle category tab selection changes
|
||||||
|
*
|
||||||
|
* @param tabValue - The selected category value
|
||||||
|
*/
|
||||||
|
const handleTabChange = (tabValue: string) => {
|
||||||
|
const currentSearchParams = searchParams.toString();
|
||||||
|
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||||
|
|
||||||
|
// Navigate to the selected category
|
||||||
|
if (tabValue === 'promoted') {
|
||||||
|
navigate(`/agents${searchParamsStr}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle search query changes
|
||||||
|
*
|
||||||
|
* @param query - The search query string
|
||||||
|
*/
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (query.trim()) {
|
||||||
|
newParams.set('q', query.trim());
|
||||||
|
// Switch to "all" category when starting a new search
|
||||||
|
navigate(`/agents/all?${newParams.toString()}`);
|
||||||
|
} else {
|
||||||
|
newParams.delete('q');
|
||||||
|
// Preserve current category when clearing search
|
||||||
|
const currentCategory = activeTab;
|
||||||
|
if (currentCategory === 'promoted') {
|
||||||
|
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
||||||
|
} else {
|
||||||
|
navigate(
|
||||||
|
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new chat button click
|
||||||
|
*/
|
||||||
|
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
window.open('/c/new', '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/c/new');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a detail view should be open based on URL
|
||||||
|
useEffect(() => {
|
||||||
|
setIsDetailOpen(!!selectedAgentId);
|
||||||
|
}, [selectedAgentId]);
|
||||||
|
|
||||||
|
// Layout configuration for SidePanelGroup
|
||||||
|
const defaultLayout = useMemo(() => {
|
||||||
|
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
|
||||||
|
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const defaultCollapsed = useMemo(() => {
|
||||||
|
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
|
||||||
|
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<SidePanelGroup
|
||||||
|
defaultLayout={defaultLayout}
|
||||||
|
fullPanelCollapse={fullCollapse}
|
||||||
|
defaultCollapsed={defaultCollapsed}
|
||||||
|
>
|
||||||
|
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||||||
|
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||||
|
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
|
||||||
|
<div className="mx-1 flex items-center gap-2">
|
||||||
|
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
|
||||||
|
{!navVisible && (
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_new_chat')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
data-testid="agents-new-chat-button"
|
||||||
|
aria-label={localize('com_ui_new_chat')}
|
||||||
|
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
>
|
||||||
|
<NewChatIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="container mx-auto max-w-4xl px-4 py-8">
|
||||||
|
{/* Hero Section - ChatGPT Style */}
|
||||||
|
<div className="mb-8 mt-12 text-center">
|
||||||
|
<h1 className="mb-3 text-5xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
{localize('com_agents_marketplace')}
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mb-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300">
|
||||||
|
{localize('com_agents_marketplace_subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<CategoryTabs
|
||||||
|
categories={categoriesQuery.data || []}
|
||||||
|
activeTab={activeTab}
|
||||||
|
isLoading={categoriesQuery.isLoading}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category header - only show when not searching */}
|
||||||
|
{!searchQuery && (
|
||||||
|
<div className="mb-6">
|
||||||
|
{(() => {
|
||||||
|
// 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: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { name, description } = getCategoryData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-left">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{name}</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent grid */}
|
||||||
|
<AgentGrid
|
||||||
|
category={activeTab}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSelectAgent={handleAgentSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent detail dialog */}
|
||||||
|
{isDetailOpen && selectedAgent && (
|
||||||
|
<AgentDetail
|
||||||
|
agent={selectedAgent}
|
||||||
|
isOpen={isDetailOpen}
|
||||||
|
onClose={handleDetailClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</SidePanelGroup>
|
||||||
|
</MarketplaceProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentMarketplace;
|
||||||
|
|
@ -183,6 +183,8 @@ export default function AgentPanel({
|
||||||
end_after_tools,
|
end_after_tools,
|
||||||
hide_sequential_outputs,
|
hide_sequential_outputs,
|
||||||
recursion_limit,
|
recursion_limit,
|
||||||
|
category,
|
||||||
|
support_contact,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const model = _model ?? '';
|
const model = _model ?? '';
|
||||||
|
|
@ -205,6 +207,8 @@ export default function AgentPanel({
|
||||||
end_after_tools,
|
end_after_tools,
|
||||||
hide_sequential_outputs,
|
hide_sequential_outputs,
|
||||||
recursion_limit,
|
recursion_limit,
|
||||||
|
category,
|
||||||
|
support_contact,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -230,6 +234,8 @@ export default function AgentPanel({
|
||||||
end_after_tools,
|
end_after_tools,
|
||||||
hide_sequential_outputs,
|
hide_sequential_outputs,
|
||||||
recursion_limit,
|
recursion_limit,
|
||||||
|
category,
|
||||||
|
support_contact,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agent_id, create, update, showToast, localize],
|
[agent_id, create, update, showToast, localize],
|
||||||
|
|
@ -263,7 +269,7 @@ export default function AgentPanel({
|
||||||
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
||||||
aria-label="Agent configuration form"
|
aria-label="Agent configuration form"
|
||||||
>
|
>
|
||||||
<div className="mt-2 flex w-full flex-wrap gap-2">
|
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<AgentSelect
|
<AgentSelect
|
||||||
createMutation={create}
|
createMutation={create}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,10 @@ export default function AgentSelect({
|
||||||
agent: update,
|
agent: update,
|
||||||
model: update.model,
|
model: update.model,
|
||||||
tools: agentTools,
|
tools: agentTools,
|
||||||
|
// Ensure the category is properly set for the form
|
||||||
|
category: fullAgent.category || 'general',
|
||||||
|
// Make sure support_contact is properly loaded
|
||||||
|
support_contact: fullAgent.support_contact,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(fullAgent).forEach(([name, value]) => {
|
Object.entries(fullAgent).forEach(([name, value]) => {
|
||||||
|
|
|
||||||
172
client/src/components/SidePanel/Agents/CategoryTabs.tsx
Normal file
172
client/src/components/SidePanel/Agents/CategoryTabs.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { SmartLoader } from './SmartLoader';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the CategoryTabs component
|
||||||
|
*/
|
||||||
|
interface CategoryTabsProps {
|
||||||
|
/** Array of agent categories to display as tabs */
|
||||||
|
categories: t.TMarketplaceCategory[];
|
||||||
|
/** Currently selected tab value */
|
||||||
|
activeTab: string;
|
||||||
|
/** Whether categories are currently loading */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Callback fired when a tab is selected */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CategoryTabs - Component for displaying category tabs with counts
|
||||||
|
*
|
||||||
|
* Renders a tabbed navigation interface showing agent categories.
|
||||||
|
* Includes loading states, empty state handling, and displays counts for each category.
|
||||||
|
* Uses database-driven category labels with no hardcoded values.
|
||||||
|
*/
|
||||||
|
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
|
categories,
|
||||||
|
activeTab,
|
||||||
|
isLoading,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
// Helper function to get category display name from database data
|
||||||
|
const getCategoryDisplayName = (category: t.TCategory) => {
|
||||||
|
// Special cases for system categories
|
||||||
|
if (category.value === 'promoted') {
|
||||||
|
return localize('com_agents_top_picks');
|
||||||
|
}
|
||||||
|
if (category.value === 'all') {
|
||||||
|
return 'All';
|
||||||
|
}
|
||||||
|
// Use database label or fallback to capitalized value
|
||||||
|
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading skeleton component
|
||||||
|
const loadingSkeleton = (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-6 min-w-[60px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard navigation between tabs
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent, currentCategory: string) => {
|
||||||
|
const currentIndex = categories.findIndex((cat) => cat.value === currentCategory);
|
||||||
|
let newIndex = currentIndex;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = 0;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = categories.length - 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCategory = categories[newIndex];
|
||||||
|
if (newCategory) {
|
||||||
|
onChange(newCategory.value);
|
||||||
|
// Focus the new tab
|
||||||
|
setTimeout(() => {
|
||||||
|
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
|
||||||
|
newTab?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if no categories available
|
||||||
|
if (!isLoading && (!categories || categories.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8 text-center text-gray-500">{localize('com_agents_no_categories')}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main tabs content
|
||||||
|
const tabsContent = (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{/* Accessible tab navigation with proper ARIA attributes */}
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-center gap-6"
|
||||||
|
role="tablist"
|
||||||
|
aria-label={localize('com_agents_category_tabs_label')}
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
>
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<button
|
||||||
|
key={category.value}
|
||||||
|
id={`category-tab-${category.value}`}
|
||||||
|
onClick={() => onChange(category.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||||
|
className={cn(
|
||||||
|
'relative px-4 py-2 text-sm font-medium transition-colors duration-200',
|
||||||
|
'focus:bg-gray-100 focus:outline-none dark:focus:bg-gray-800',
|
||||||
|
'hover:text-gray-900 dark:hover:text-white',
|
||||||
|
activeTab === category.value
|
||||||
|
? 'text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-600 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === category.value}
|
||||||
|
aria-controls={`tabpanel-${category.value}`}
|
||||||
|
tabIndex={activeTab === category.value ? 0 : -1}
|
||||||
|
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{getCategoryDisplayName(category)}</span>
|
||||||
|
{/* Underline for active tab */}
|
||||||
|
{activeTab === category.value && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-gray-900 dark:bg-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use SmartLoader to prevent category loading flashes
|
||||||
|
return (
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasData={categories?.length > 0}
|
||||||
|
delay={100} // Very short delay since categories should load quickly
|
||||||
|
loadingComponent={loadingSkeleton}
|
||||||
|
>
|
||||||
|
{tabsContent}
|
||||||
|
</SmartLoader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryTabs;
|
||||||
239
client/src/components/SidePanel/Agents/ErrorDisplay.tsx
Normal file
239
client/src/components/SidePanel/Agents/ErrorDisplay.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { Button } from '~/components/ui';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
// Comprehensive error type that handles all possible error structures
|
||||||
|
type ApiError =
|
||||||
|
| string
|
||||||
|
| Error
|
||||||
|
| {
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
code?: string;
|
||||||
|
response?: {
|
||||||
|
data?: {
|
||||||
|
userMessage?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
data?: {
|
||||||
|
userMessage?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
error: ApiError;
|
||||||
|
onRetry?: () => void;
|
||||||
|
context?: {
|
||||||
|
searchQuery?: string;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-friendly error display component with actionable suggestions
|
||||||
|
*/
|
||||||
|
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, context }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
// Type guards
|
||||||
|
const isErrorObject = (err: ApiError): err is { [key: string]: unknown } => {
|
||||||
|
return typeof err === 'object' && err !== null && !(err instanceof Error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isErrorInstance = (err: ApiError): err is Error => {
|
||||||
|
return err instanceof Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract user-friendly error information
|
||||||
|
const getErrorInfo = (): { title: string; message: string; suggestion: string } => {
|
||||||
|
// Handle different error types
|
||||||
|
let errorData: unknown;
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
errorData = { message: error };
|
||||||
|
} else if (isErrorInstance(error)) {
|
||||||
|
errorData = { message: error.message };
|
||||||
|
} else if (isErrorObject(error)) {
|
||||||
|
// Handle axios error response structure
|
||||||
|
errorData = (error as any)?.response?.data || (error as any)?.data || error;
|
||||||
|
} else {
|
||||||
|
errorData = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use user-friendly message from backend if available
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'),
|
||||||
|
message: localize('com_agents_error_network_message'),
|
||||||
|
suggestion: localize('com_agents_error_network_suggestion'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific HTTP status codes
|
||||||
|
const status = isErrorObject(error) ? (error as any)?.response?.status : null;
|
||||||
|
if (status) {
|
||||||
|
if (status === 404) {
|
||||||
|
return {
|
||||||
|
title: localize('com_agents_error_not_found_title'),
|
||||||
|
message: getNotFoundMessage(),
|
||||||
|
suggestion: localize('com_agents_error_not_found_suggestion'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 400) {
|
||||||
|
return {
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
return {
|
||||||
|
title: localize('com_agents_error_server_title'),
|
||||||
|
message: localize('com_agents_error_server_message'),
|
||||||
|
suggestion: localize('com_agents_error_server_suggestion'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to generic error
|
||||||
|
return {
|
||||||
|
title: localize('com_agents_error_title'),
|
||||||
|
message: localize('com_agents_error_generic'),
|
||||||
|
suggestion: localize('com_agents_error_suggestion_generic'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contextual title based on current operation
|
||||||
|
*/
|
||||||
|
const getContextualTitle = (): string => {
|
||||||
|
if (context?.searchQuery) {
|
||||||
|
return localize('com_agents_error_search_title');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.category) {
|
||||||
|
return localize('com_agents_error_category_title');
|
||||||
|
}
|
||||||
|
|
||||||
|
return localize('com_agents_error_title');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-specific not found message
|
||||||
|
*/
|
||||||
|
const getNotFoundMessage = (): string => {
|
||||||
|
if (context?.searchQuery) {
|
||||||
|
return localize('com_agents_search_no_results', { query: context.searchQuery });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.category && context.category !== 'all') {
|
||||||
|
return localize('com_agents_category_empty', { category: context.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
return localize('com_agents_error_not_found_message');
|
||||||
|
};
|
||||||
|
|
||||||
|
const { title, message, suggestion } = getErrorInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div className="mx-auto max-w-md space-y-4">
|
||||||
|
{/* Error icon with proper accessibility */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-12 w-12 items-center justify-center rounded-full',
|
||||||
|
'bg-red-100 dark:bg-red-900/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-red-600 dark:text-red-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
aria-label="Error icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error content with proper headings and structure */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
id="error-message"
|
||||||
|
aria-describedby="error-title"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-sm text-gray-500 dark:text-gray-500"
|
||||||
|
id="error-suggestion"
|
||||||
|
role="note"
|
||||||
|
aria-label={`Suggestion: ${suggestion}`}
|
||||||
|
>
|
||||||
|
💡 {suggestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retry button with enhanced accessibility */}
|
||||||
|
{onRetry && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'border-red-300 text-red-700 hover:bg-red-50 focus:ring-2 focus:ring-red-500',
|
||||||
|
'dark:border-red-600 dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:ring-red-400',
|
||||||
|
)}
|
||||||
|
aria-describedby="error-message error-suggestion"
|
||||||
|
aria-label={`Retry action. ${message}`}
|
||||||
|
>
|
||||||
|
{localize('com_agents_error_retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorDisplay;
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
import { ChatContext } from '~/Providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal marketplace provider that provides only what SidePanel actually needs
|
||||||
|
* Replaces the bloated 44-function ChatContext implementation
|
||||||
|
*/
|
||||||
|
interface MarketplaceProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ 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: () => {},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ChatContext.Provider value={marketplaceContext as any}>{children}</ChatContext.Provider>;
|
||||||
|
};
|
||||||
111
client/src/components/SidePanel/Agents/SearchBar.tsx
Normal file
111
client/src/components/SidePanel/Agents/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { useDebounce } from '~/hooks';
|
||||||
|
import { Input } from '~/components/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the SearchBar component
|
||||||
|
*/
|
||||||
|
interface SearchBarProps {
|
||||||
|
/** Current search query value */
|
||||||
|
value: string;
|
||||||
|
/** Callback fired when the search query changes */
|
||||||
|
onSearch: (query: string) => void;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchBar - Component for searching agents with debounced input
|
||||||
|
*
|
||||||
|
* Provides a search input with clear button and debounced search functionality.
|
||||||
|
* Includes proper ARIA attributes for accessibility and visual indicators.
|
||||||
|
* Uses 300ms debounce delay to prevent excessive API calls during typing.
|
||||||
|
*/
|
||||||
|
const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [searchTerm, setSearchTerm] = useState(value);
|
||||||
|
|
||||||
|
// Debounced search value (300ms delay)
|
||||||
|
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||||
|
|
||||||
|
// Update internal state when props change
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Trigger search when debounced value changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Only trigger search if the debounced value matches current searchTerm
|
||||||
|
// This prevents stale debounced values from triggering after clear
|
||||||
|
if (debouncedSearchTerm !== value && debouncedSearchTerm === searchTerm) {
|
||||||
|
onSearch(debouncedSearchTerm);
|
||||||
|
}
|
||||||
|
}, [debouncedSearchTerm, onSearch, value, searchTerm]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle search input changes
|
||||||
|
*
|
||||||
|
* @param e - Input change event
|
||||||
|
*/
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search input and reset results
|
||||||
|
*/
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
// Immediately call parent onSearch to clear the URL parameter
|
||||||
|
onSearch('');
|
||||||
|
// Also clear local state
|
||||||
|
setSearchTerm('');
|
||||||
|
}, [onSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative w-full max-w-4xl ${className}`} role="search">
|
||||||
|
<label htmlFor="agent-search" className="sr-only">
|
||||||
|
{localize('com_agents_search_instructions')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="agent-search"
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={localize('com_agents_search_placeholder')}
|
||||||
|
className="h-14 rounded-2xl border-2 border-gray-200 bg-white pl-12 pr-12 text-lg text-gray-900 shadow-lg placeholder:text-gray-500 focus:border-gray-300 focus:ring-0 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-400 dark:focus:border-gray-500"
|
||||||
|
aria-label={localize('com_agents_search_aria')}
|
||||||
|
aria-describedby="search-instructions search-results-count"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search icon with proper accessibility */}
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
|
||||||
|
<Search className="h-6 w-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden instructions for screen readers */}
|
||||||
|
<div id="search-instructions" className="sr-only">
|
||||||
|
{localize('com_agents_search_instructions')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show clear button only when search has value - Google style */}
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 transition-colors duration-150 hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-500 dark:hover:bg-gray-400"
|
||||||
|
aria-label={localize('com_agents_clear_search')}
|
||||||
|
title={localize('com_agents_clear_search')}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-white group-hover:text-white" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
||||||
|
|
@ -135,10 +135,9 @@ export default function ShareAgent({
|
||||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||||
removeFocusOutlines,
|
removeFocusOutlines,
|
||||||
)}
|
)}
|
||||||
aria-label={localize(
|
aria-label={localize('com_ui_share_var', {
|
||||||
'com_ui_share_var',
|
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||||
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
|
})}
|
||||||
)}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||||
|
|
@ -148,10 +147,9 @@ export default function ShareAgent({
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogContent className="w-11/12 md:max-w-xl">
|
<OGDialogContent className="w-11/12 md:max-w-xl">
|
||||||
<OGDialogTitle>
|
<OGDialogTitle>
|
||||||
{localize(
|
{localize('com_ui_share_var', {
|
||||||
'com_ui_share_var',
|
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||||
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
|
})}
|
||||||
)}
|
|
||||||
</OGDialogTitle>
|
</OGDialogTitle>
|
||||||
<form
|
<form
|
||||||
className="p-2"
|
className="p-2"
|
||||||
|
|
|
||||||
97
client/src/components/SidePanel/Agents/SmartLoader.tsx
Normal file
97
client/src/components/SidePanel/Agents/SmartLoader.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface SmartLoaderProps {
|
||||||
|
/** Whether the content is currently loading */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Whether there is existing data to show */
|
||||||
|
hasData: boolean;
|
||||||
|
/** Delay before showing loading state (in ms) - prevents flashing for quick loads */
|
||||||
|
delay?: number;
|
||||||
|
/** Loading skeleton/spinner component */
|
||||||
|
loadingComponent: React.ReactNode;
|
||||||
|
/** Content to show when loaded */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmartLoader - Intelligent loading wrapper that prevents flashing
|
||||||
|
*
|
||||||
|
* Only shows loading states when:
|
||||||
|
* 1. Actually loading AND no existing data
|
||||||
|
* 2. Loading has lasted longer than the delay threshold
|
||||||
|
*
|
||||||
|
* This prevents brief loading flashes for cached/fast responses
|
||||||
|
*/
|
||||||
|
export const SmartLoader: React.FC<SmartLoaderProps> = ({
|
||||||
|
isLoading,
|
||||||
|
hasData,
|
||||||
|
delay = 150,
|
||||||
|
loadingComponent,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const [shouldShowLoading, setShouldShowLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (isLoading && !hasData) {
|
||||||
|
// Only show loading after delay to prevent flashing
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setShouldShowLoading(true);
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
// Immediately hide loading when done
|
||||||
|
setShouldShowLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isLoading, hasData, delay]);
|
||||||
|
|
||||||
|
// Show loading state only if we've determined it should be shown
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
return <div className={className}>{loadingComponent}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show content (including when loading but we have existing data)
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine if we have meaningful data to show
|
||||||
|
* Helps prevent loading states when we already have cached content
|
||||||
|
*/
|
||||||
|
export const useHasData = (data: unknown): boolean => {
|
||||||
|
if (!data) return false;
|
||||||
|
|
||||||
|
// Type guard for object data
|
||||||
|
if (typeof data === 'object' && data !== null) {
|
||||||
|
// Check for agent list data
|
||||||
|
if ('agents' in data) {
|
||||||
|
const agents = (data as any).agents;
|
||||||
|
return Array.isArray(agents) && agents.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for single agent data
|
||||||
|
if ('id' in data || 'name' in data) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for categories data (array)
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SmartLoader;
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import CategoryTabs from '../CategoryTabs';
|
||||||
|
import AgentGrid from '../AgentGrid';
|
||||||
|
import AgentCard from '../AgentCard';
|
||||||
|
import SearchBar from '../SearchBar';
|
||||||
|
import ErrorDisplay from '../ErrorDisplay';
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation(() => ({
|
||||||
|
matches: false,
|
||||||
|
media: '',
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
jest.mock(
|
||||||
|
'~/hooks/useLocalize',
|
||||||
|
() => () =>
|
||||||
|
jest.fn((key: string, options?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
com_agents_category_tabs_label: 'Agent Categories',
|
||||||
|
com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`,
|
||||||
|
com_agents_search_instructions: 'Type to search agents by name or description',
|
||||||
|
com_agents_search_aria: 'Search agents',
|
||||||
|
com_agents_search_placeholder: 'Search agents...',
|
||||||
|
com_agents_clear_search: 'Clear search',
|
||||||
|
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
|
||||||
|
com_agents_no_description: 'No description available',
|
||||||
|
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
|
||||||
|
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
|
||||||
|
com_agents_error_retry: 'Try Again',
|
||||||
|
com_agents_loading: 'Loading...',
|
||||||
|
com_agents_empty_state_heading: 'No agents found',
|
||||||
|
com_agents_search_empty_heading: 'No search results',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('~/hooks/Agents', () => ({
|
||||||
|
useDynamicAgentQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useDynamicAgentQuery } = require('~/hooks/Agents');
|
||||||
|
|
||||||
|
// Create wrapper with QueryClient
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Accessibility Improvements', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useDynamicAgentQuery.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CategoryTabs Accessibility', () => {
|
||||||
|
const categories = [
|
||||||
|
{ name: 'promoted', count: 5 },
|
||||||
|
{ name: 'all', count: 20 },
|
||||||
|
{ name: 'productivity', count: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('implements proper tablist role and ARIA attributes', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={categories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check tablist role
|
||||||
|
const tablist = screen.getByRole('tablist');
|
||||||
|
expect(tablist).toBeInTheDocument();
|
||||||
|
expect(tablist).toHaveAttribute('aria-label', 'Agent Categories');
|
||||||
|
expect(tablist).toHaveAttribute('aria-orientation', 'horizontal');
|
||||||
|
|
||||||
|
// Check individual tabs
|
||||||
|
const tabs = screen.getAllByRole('tab');
|
||||||
|
expect(tabs).toHaveLength(3);
|
||||||
|
|
||||||
|
tabs.forEach((tab, index) => {
|
||||||
|
expect(tab).toHaveAttribute('aria-selected');
|
||||||
|
expect(tab).toHaveAttribute('aria-controls');
|
||||||
|
expect(tab).toHaveAttribute('id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard navigation', () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={categories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const promotedTab = screen.getByRole('tab', { name: /promoted category/ });
|
||||||
|
|
||||||
|
// Test arrow key navigation
|
||||||
|
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
|
||||||
|
expect(onChange).toHaveBeenCalledWith('all');
|
||||||
|
|
||||||
|
fireEvent.keyDown(promotedTab, { key: 'ArrowLeft' });
|
||||||
|
expect(onChange).toHaveBeenCalledWith('productivity');
|
||||||
|
|
||||||
|
fireEvent.keyDown(promotedTab, { key: 'Home' });
|
||||||
|
expect(onChange).toHaveBeenCalledWith('promoted');
|
||||||
|
|
||||||
|
fireEvent.keyDown(promotedTab, { key: 'End' });
|
||||||
|
expect(onChange).toHaveBeenCalledWith('productivity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manages focus correctly', () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={categories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const promotedTab = screen.getByRole('tab', { name: /promoted category/ });
|
||||||
|
const allTab = screen.getByRole('tab', { name: /all category/ });
|
||||||
|
|
||||||
|
// Active tab should be focusable
|
||||||
|
expect(promotedTab).toHaveAttribute('tabIndex', '0');
|
||||||
|
expect(allTab).toHaveAttribute('tabIndex', '-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SearchBar Accessibility', () => {
|
||||||
|
it('provides proper search role and labels', () => {
|
||||||
|
render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||||
|
|
||||||
|
// Check search landmark
|
||||||
|
const searchRegion = screen.getByRole('search');
|
||||||
|
expect(searchRegion).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check input accessibility
|
||||||
|
const searchInput = screen.getByRole('searchbox');
|
||||||
|
expect(searchInput).toHaveAttribute('id', 'agent-search');
|
||||||
|
expect(searchInput).toHaveAttribute('aria-label', 'Search agents');
|
||||||
|
expect(searchInput).toHaveAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
'search-instructions search-results-count',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check hidden label
|
||||||
|
expect(screen.getByText('Type to search agents by name or description')).toHaveClass(
|
||||||
|
'sr-only',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides accessible clear button', () => {
|
||||||
|
render(<SearchBar value="test" onSearch={jest.fn()} />);
|
||||||
|
|
||||||
|
const clearButton = screen.getByRole('button', { name: 'Clear search' });
|
||||||
|
expect(clearButton).toBeInTheDocument();
|
||||||
|
expect(clearButton).toHaveAttribute('aria-label', 'Clear search');
|
||||||
|
expect(clearButton).toHaveAttribute('title', 'Clear search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides decorative icons from screen readers', () => {
|
||||||
|
render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||||
|
|
||||||
|
// Search icon should be hidden
|
||||||
|
const iconContainer = document.querySelector('[aria-hidden="true"]');
|
||||||
|
expect(iconContainer).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AgentCard Accessibility', () => {
|
||||||
|
const mockAgent = {
|
||||||
|
id: 'test-agent',
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'A test agent for testing',
|
||||||
|
authorName: 'Test Author',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('provides comprehensive ARIA labels', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={jest.fn()} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
|
||||||
|
expect(card).toHaveAttribute('aria-describedby', 'agent-test-agent-description');
|
||||||
|
expect(card).toHaveAttribute('role', 'button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles agents without descriptions', () => {
|
||||||
|
const agentWithoutDesc = { ...mockAgent, description: undefined };
|
||||||
|
render(<AgentCard agent={agentWithoutDesc} onClick={jest.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('No description available')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard interaction', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={onClick} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
|
||||||
|
fireEvent.keyDown(card, { key: 'Enter' });
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.keyDown(card, { key: ' ' });
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AgentGrid Accessibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useDynamicAgentQuery.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
agents: [
|
||||||
|
{ id: '1', name: 'Agent 1', description: 'First agent' },
|
||||||
|
{ id: '2', name: 'Agent 2', description: 'Second agent' },
|
||||||
|
],
|
||||||
|
pagination: { hasMore: false, total: 2, current: 1 },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('implements proper tabpanel structure', () => {
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check tabpanel role
|
||||||
|
const tabpanel = screen.getByRole('tabpanel');
|
||||||
|
expect(tabpanel).toHaveAttribute('id', 'category-panel-all');
|
||||||
|
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-all');
|
||||||
|
expect(tabpanel).toHaveAttribute('aria-live', 'polite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides grid structure with proper roles', () => {
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check grid role
|
||||||
|
const grid = screen.getByRole('grid');
|
||||||
|
expect(grid).toBeInTheDocument();
|
||||||
|
expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in all category');
|
||||||
|
|
||||||
|
// Check gridcells
|
||||||
|
const gridcells = screen.getAllByRole('gridcell');
|
||||||
|
expect(gridcells).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces loading states to screen readers', () => {
|
||||||
|
useDynamicAgentQuery.mockReturnValue({
|
||||||
|
data: { agents: [{ id: '1', name: 'Agent 1' }] },
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: true,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for loading announcement
|
||||||
|
const loadingStatus = screen.getByRole('status', { name: 'Loading...' });
|
||||||
|
expect(loadingStatus).toBeInTheDocument();
|
||||||
|
expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides accessible empty states', () => {
|
||||||
|
useDynamicAgentQuery.mockReturnValue({
|
||||||
|
data: { agents: [], pagination: { hasMore: false, total: 0, current: 1 } },
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check empty state accessibility
|
||||||
|
const emptyState = screen.getByRole('status');
|
||||||
|
expect(emptyState).toHaveAttribute('aria-live', 'polite');
|
||||||
|
expect(emptyState).toHaveAttribute('aria-label', 'No agents found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ErrorDisplay Accessibility', () => {
|
||||||
|
const mockError = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||||
|
suggestion: 'Try refreshing the page or check your network connection',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('implements proper alert role and ARIA attributes', () => {
|
||||||
|
render(<ErrorDisplay error={mockError} onRetry={jest.fn()} />);
|
||||||
|
|
||||||
|
// Check alert role
|
||||||
|
const alert = screen.getByRole('alert');
|
||||||
|
expect(alert).toHaveAttribute('aria-live', 'assertive');
|
||||||
|
expect(alert).toHaveAttribute('aria-atomic', 'true');
|
||||||
|
|
||||||
|
// Check heading structure
|
||||||
|
const heading = screen.getByRole('heading', { level: 2 });
|
||||||
|
expect(heading).toHaveAttribute('id', 'error-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides accessible retry button', () => {
|
||||||
|
const onRetry = jest.fn();
|
||||||
|
render(<ErrorDisplay error={mockError} onRetry={onRetry} />);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /retry action/i });
|
||||||
|
expect(retryButton).toHaveAttribute('aria-describedby', 'error-message error-suggestion');
|
||||||
|
|
||||||
|
fireEvent.click(retryButton);
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('structures error content with proper semantics', () => {
|
||||||
|
render(<ErrorDisplay error={mockError} />);
|
||||||
|
|
||||||
|
// Check error message structure
|
||||||
|
expect(screen.getByText(/unable to load agents/i)).toHaveAttribute('id', 'error-message');
|
||||||
|
|
||||||
|
// Check suggestion note
|
||||||
|
const suggestion = screen.getByRole('note');
|
||||||
|
expect(suggestion).toHaveAttribute('aria-label', expect.stringContaining('Suggestion:'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Focus Management', () => {
|
||||||
|
it('maintains proper focus ring styles', () => {
|
||||||
|
const { container } = render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||||
|
|
||||||
|
// Check for focus styles in CSS classes
|
||||||
|
const searchInput = container.querySelector('input');
|
||||||
|
expect(searchInput?.className).toContain('focus:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides visible focus indicators on interactive elements', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={[{ name: 'test', count: 1 }]}
|
||||||
|
activeTab="test"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tab = screen.getByRole('tab');
|
||||||
|
expect(tab.className).toContain('focus:outline-none');
|
||||||
|
expect(tab.className).toContain('focus:ring-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Screen Reader Announcements', () => {
|
||||||
|
it('includes live regions for dynamic content', () => {
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for live region
|
||||||
|
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||||
|
expect(liveRegion).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides screen reader only content', () => {
|
||||||
|
render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||||
|
|
||||||
|
// Check for screen reader only instructions
|
||||||
|
const srOnlyElement = document.querySelector('.sr-only');
|
||||||
|
expect(srOnlyElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {};
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import AgentCard from '../AgentCard';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock useLocalize hook
|
||||||
|
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||||
|
const mockTranslations: Record<string, string> = {
|
||||||
|
com_agents_created_by: 'Created by',
|
||||||
|
};
|
||||||
|
return mockTranslations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AgentCard', () => {
|
||||||
|
const mockAgent: t.Agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'A test agent for testing purposes',
|
||||||
|
support_contact: {
|
||||||
|
name: 'Test Support',
|
||||||
|
email: 'test@example.com',
|
||||||
|
},
|
||||||
|
avatar: '/test-avatar.png',
|
||||||
|
} as t.Agent;
|
||||||
|
|
||||||
|
const mockOnClick = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnClick.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders agent information correctly', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays avatar when provided as string', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const avatarImg = screen.getByAltText('Test Agent avatar');
|
||||||
|
expect(avatarImg).toBeInTheDocument();
|
||||||
|
expect(avatarImg).toHaveAttribute('src', '/test-avatar.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays avatar when provided as object with filepath', () => {
|
||||||
|
const agentWithObjectAvatar = {
|
||||||
|
...mockAgent,
|
||||||
|
avatar: { filepath: '/object-avatar.png' },
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithObjectAvatar} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const avatarImg = screen.getByAltText('Test Agent avatar');
|
||||||
|
expect(avatarImg).toBeInTheDocument();
|
||||||
|
expect(avatarImg).toHaveAttribute('src', '/object-avatar.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Bot icon fallback when no avatar is provided', () => {
|
||||||
|
const agentWithoutAvatar = {
|
||||||
|
...mockAgent,
|
||||||
|
avatar: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithoutAvatar} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
// Check for Bot icon presence by looking for the svg with lucide-bot class
|
||||||
|
const botIcon = document.querySelector('.lucide-bot');
|
||||||
|
expect(botIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when card is clicked', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
fireEvent.click(card);
|
||||||
|
|
||||||
|
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when Enter key is pressed', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(card, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when Space key is pressed', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(card, { key: ' ' });
|
||||||
|
|
||||||
|
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onClick for other keys', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(card, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(mockOnClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies additional className when provided', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
expect(card).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing support contact gracefully', () => {
|
||||||
|
const agentWithoutContact = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: undefined,
|
||||||
|
authorName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays authorName when support_contact is missing', () => {
|
||||||
|
const agentWithAuthorName = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: undefined,
|
||||||
|
authorName: 'John Doe',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays support_contact email when name is missing', () => {
|
||||||
|
const agentWithEmailOnly = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: { email: 'contact@example.com' },
|
||||||
|
authorName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes support_contact name over authorName', () => {
|
||||||
|
const agentWithBoth = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: { name: 'Support Team' },
|
||||||
|
authorName: 'John Doe',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes name over email in support_contact', () => {
|
||||||
|
const agentWithNameAndEmail = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Support Team',
|
||||||
|
email: 'support@example.com',
|
||||||
|
},
|
||||||
|
authorName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper accessibility attributes', () => {
|
||||||
|
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole('button');
|
||||||
|
expect(card).toHaveAttribute('tabIndex', '0');
|
||||||
|
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import AgentCategoryDisplay from '../AgentCategoryDisplay';
|
||||||
|
|
||||||
|
// Mock the useAgentCategories hook
|
||||||
|
jest.mock('~/hooks/Agents', () => ({
|
||||||
|
useAgentCategories: () => ({
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
value: 'general',
|
||||||
|
label: 'General',
|
||||||
|
icon: <span data-testid="icon-general">{''}</span>,
|
||||||
|
className: 'w-full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'hr',
|
||||||
|
label: 'HR',
|
||||||
|
icon: <span data-testid="icon-hr">{''}</span>,
|
||||||
|
className: 'w-full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'rd',
|
||||||
|
label: 'R&D',
|
||||||
|
icon: <span data-testid="icon-rd">{''}</span>,
|
||||||
|
className: 'w-full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'finance',
|
||||||
|
label: 'Finance',
|
||||||
|
icon: <span data-testid="icon-finance">{''}</span>,
|
||||||
|
className: 'w-full',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
emptyCategory: {
|
||||||
|
value: '',
|
||||||
|
label: 'General',
|
||||||
|
className: 'w-full',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AgentCategoryDisplay', () => {
|
||||||
|
it('should display the proper label for a category', () => {
|
||||||
|
render(<AgentCategoryDisplay category="rd" />);
|
||||||
|
expect(screen.getByText('R&D')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the icon when showIcon is true', () => {
|
||||||
|
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
|
||||||
|
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Finance')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display the icon when showIcon is false', () => {
|
||||||
|
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
|
||||||
|
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('HR')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom classnames', () => {
|
||||||
|
render(<AgentCategoryDisplay category="general" className="test-class" />);
|
||||||
|
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render anything for unknown categories', () => {
|
||||||
|
const { container } = render(<AgentCategoryDisplay category="unknown" />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render anything when no category is provided', () => {
|
||||||
|
const { container } = render(<AgentCategoryDisplay />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render anything for empty category when showEmptyFallback is false', () => {
|
||||||
|
const { container } = render(<AgentCategoryDisplay category="" />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render empty category placeholder when showEmptyFallback is true', () => {
|
||||||
|
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
|
||||||
|
expect(screen.getByText('General')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom iconClassName to the icon', () => {
|
||||||
|
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
|
||||||
|
const iconElement = screen.getByTestId('icon-general').parentElement;
|
||||||
|
expect(iconElement).toHaveClass('custom-icon-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
import AgentDetail from '../AgentDetail';
|
||||||
|
import { useToast } from '~/hooks';
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useNavigate: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useToast: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks/useLocalize', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils/agents', () => ({
|
||||||
|
renderAgentAvatar: jest.fn((agent, options) => (
|
||||||
|
<div data-testid="agent-avatar" data-size={options?.size} />
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock clipboard API
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: {
|
||||||
|
writeText: jest.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockNavigate = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
const mockLocalize = jest.fn((key: string) => key);
|
||||||
|
|
||||||
|
const mockAgent: t.Agent = {
|
||||||
|
id: 'test-agent-id',
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'This is a test agent for unit testing',
|
||||||
|
avatar: {
|
||||||
|
filepath: '/path/to/avatar.png',
|
||||||
|
source: 'local' as const,
|
||||||
|
},
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
instructions: 'You are a helpful test agent',
|
||||||
|
tools: [],
|
||||||
|
code_interpreter: false,
|
||||||
|
file_search: false,
|
||||||
|
author: 'test-user-id',
|
||||||
|
author_name: 'Test User',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
version: 1,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Support Team',
|
||||||
|
email: 'support@test.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to render with providers
|
||||||
|
const renderWithProviders = (ui: React.ReactElement, options = {}) => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RecoilRoot>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</RecoilRoot>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(ui, { wrapper: Wrapper, ...options });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AgentDetail', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
|
||||||
|
(useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast });
|
||||||
|
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
|
||||||
|
|
||||||
|
// Reset clipboard mock
|
||||||
|
(navigator.clipboard.writeText as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
agent: mockAgent,
|
||||||
|
isOpen: true,
|
||||||
|
onClose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render agent details correctly', () => {
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('This is a test agent for unit testing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('agent-avatar')).toHaveAttribute('data-size', 'xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render contact information when available', () => {
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('com_agents_contact:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Support Team' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'mailto:support@test.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render contact information when not available', () => {
|
||||||
|
const agentWithoutContact = { ...mockAgent };
|
||||||
|
delete (agentWithoutContact as any).support_contact;
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithoutContact} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('com_agents_contact:')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render loading state when agent is null', () => {
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render 3-dot menu button', () => {
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
expect(menuButton).toBeInTheDocument();
|
||||||
|
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Start Chat button', () => {
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||||
|
expect(startChatButton).toBeInTheDocument();
|
||||||
|
expect(startChatButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interactions', () => {
|
||||||
|
it('should navigate to chat when Start Chat button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||||
|
await user.click(startChatButton);
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/c/new?agent_id=test-agent-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not navigate when agent is null', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||||
|
|
||||||
|
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||||
|
expect(startChatButton).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(startChatButton);
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open dropdown when 3-dot menu is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dropdown when clicking outside', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click outside (on the agent name)
|
||||||
|
const agentName = screen.getByText('Test Agent');
|
||||||
|
await user.click(agentName);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should copy link and show success toast when Copy Link is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
|
// Click copy link
|
||||||
|
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||||
|
await user.click(copyLinkButton);
|
||||||
|
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/c/new?agent_id=test-agent-id`,
|
||||||
|
);
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith({
|
||||||
|
message: 'Link copied',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dropdown should close
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error toast when clipboard write fails', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
(navigator.clipboard.writeText as jest.Mock).mockRejectedValue(new Error('Clipboard error'));
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
// Open dropdown and click copy link
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
|
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||||
|
await user.click(copyLinkButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith({
|
||||||
|
message: 'com_agents_link_copy_failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onClose when dialog is closed', () => {
|
||||||
|
const mockOnClose = jest.fn();
|
||||||
|
render(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />);
|
||||||
|
|
||||||
|
// Since we're testing the onOpenChange callback, we need to trigger it
|
||||||
|
// This would normally be done by the Dialog component when ESC is pressed or overlay is clicked
|
||||||
|
// We'll test this by checking that onClose is properly passed to the Dialog
|
||||||
|
expect(mockOnClose).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('should have proper ARIA attributes', () => {
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
|
||||||
|
expect(menuButton).toHaveAttribute('aria-label', 'More options');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support keyboard navigation for dropdown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
|
||||||
|
// Focus and open with Enter key
|
||||||
|
menuButton.focus();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper focus management', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByRole('button', { name: 'More options' });
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
|
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||||
|
expect(copyLinkButton).toHaveClass('focus:bg-surface-hover', 'focus:outline-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle agent with only email contact', () => {
|
||||||
|
const agentWithEmailOnly = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: {
|
||||||
|
email: 'support@test.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithEmailOnly} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: 'support@test.com' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle agent with only name contact', () => {
|
||||||
|
const agentWithNameOnly = {
|
||||||
|
...mockAgent,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Support Team',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithNameOnly} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long description with proper text wrapping', () => {
|
||||||
|
const agentWithLongDescription = {
|
||||||
|
...mockAgent,
|
||||||
|
description:
|
||||||
|
'This is a very long description that should wrap properly and be displayed in multiple lines when the content exceeds the available width of the container.',
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithLongDescription} />);
|
||||||
|
|
||||||
|
const description = screen.getByText(agentWithLongDescription.description);
|
||||||
|
expect(description).toHaveClass('whitespace-pre-wrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in agent name', () => {
|
||||||
|
const agentWithSpecialChars = {
|
||||||
|
...mockAgent,
|
||||||
|
name: 'Test Agent™ & Co. (v2.0)',
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithSpecialChars} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Agent™ & Co. (v2.0)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import AgentGrid from '../AgentGrid';
|
||||||
|
import { useDynamicAgentQuery } from '~/hooks/Agents';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock the dynamic agent query hook
|
||||||
|
jest.mock('~/hooks/Agents', () => ({
|
||||||
|
useDynamicAgentQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useLocalize hook
|
||||||
|
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||||
|
const mockTranslations: Record<string, string> = {
|
||||||
|
com_agents_top_picks: 'Top Picks',
|
||||||
|
com_agents_all: 'All Agents',
|
||||||
|
com_agents_recommended: 'Our recommended agents',
|
||||||
|
com_agents_results_for: 'Results for "{{query}}"',
|
||||||
|
com_agents_see_more: 'See more',
|
||||||
|
com_agents_error_loading: 'Error loading agents',
|
||||||
|
com_agents_error_searching: 'Error searching agents',
|
||||||
|
com_agents_no_results: 'No agents found. Try another search term.',
|
||||||
|
com_agents_none_in_category: 'No agents found in this category',
|
||||||
|
};
|
||||||
|
return mockTranslations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock getCategoryDisplayName and getCategoryDescription
|
||||||
|
jest.mock('~/utils/agents', () => ({
|
||||||
|
getCategoryDisplayName: (category: string) => {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
promoted: 'Top Picks',
|
||||||
|
all: 'All',
|
||||||
|
general: 'General',
|
||||||
|
hr: 'HR',
|
||||||
|
finance: 'Finance',
|
||||||
|
};
|
||||||
|
return names[category] || category;
|
||||||
|
},
|
||||||
|
getCategoryDescription: (category: string) => {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
promoted: 'Our recommended agents',
|
||||||
|
all: 'Browse all available agents',
|
||||||
|
general: 'General purpose agents',
|
||||||
|
hr: 'HR agents',
|
||||||
|
finance: 'Finance agents',
|
||||||
|
};
|
||||||
|
return descriptions[category] || '';
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseDynamicAgentQuery = useDynamicAgentQuery as jest.MockedFunction<
|
||||||
|
typeof useDynamicAgentQuery
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe('AgentGrid Integration with useDynamicAgentQuery', () => {
|
||||||
|
const mockOnSelectAgent = jest.fn();
|
||||||
|
|
||||||
|
const mockAgents: Partial<t.Agent>[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent 1',
|
||||||
|
description: 'First test agent',
|
||||||
|
avatar: '/avatar1.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Test Agent 2',
|
||||||
|
description: 'Second test agent',
|
||||||
|
avatar: { filepath: '/avatar2.png' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultMockQueryResult = {
|
||||||
|
data: {
|
||||||
|
agents: mockAgents,
|
||||||
|
pagination: {
|
||||||
|
current: 1,
|
||||||
|
hasMore: true,
|
||||||
|
total: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isFetching: false,
|
||||||
|
queryType: 'promoted' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Query Integration', () => {
|
||||||
|
it('should call useDynamicAgentQuery with correct parameters', () => {
|
||||||
|
render(
|
||||||
|
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({
|
||||||
|
category: 'finance',
|
||||||
|
searchQuery: 'test query',
|
||||||
|
page: 1,
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update page when "See More" is clicked', async () => {
|
||||||
|
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate clicking "See More" to increment page
|
||||||
|
const seeMoreButton = screen.getByText('See more');
|
||||||
|
fireEvent.click(seeMoreButton);
|
||||||
|
|
||||||
|
// Change category - should reset page to 1
|
||||||
|
rerender(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
|
||||||
|
category: 'finance',
|
||||||
|
searchQuery: '',
|
||||||
|
page: 1,
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset page when search query changes', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change search query - should reset page to 1
|
||||||
|
rerender(
|
||||||
|
<AgentGrid category="hr" searchQuery="new search" onSelectAgent={mockOnSelectAgent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Top Picks')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Our recommended agents')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display correct title for search results', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
queryType: 'search',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentGrid category="all" searchQuery="test search" onSelectAgent={mockOnSelectAgent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Results for "test search"')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display correct title for specific category', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
queryType: 'category',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Finance')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Finance agents')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display correct title for all category', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
queryType: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('All')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Browse all available agents')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
// Should show loading skeletons
|
||||||
|
const loadingElements = screen.getAllByRole('generic');
|
||||||
|
const hasLoadingClass = loadingElements.some((el) => el.className.includes('animate-pulse'));
|
||||||
|
expect(hasLoadingClass).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error message when there is an error', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
data: undefined,
|
||||||
|
error: new Error('Test error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Error loading agents')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading spinner when fetching more data', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
isFetching: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
// Should show agents and loading spinner for pagination
|
||||||
|
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(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
const agentCard = screen.getByLabelText('Test Agent 1 agent card');
|
||||||
|
fireEvent.click(agentCard);
|
||||||
|
|
||||||
|
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('should show "See More" button when hasMore is true', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
data: {
|
||||||
|
agents: mockAgents,
|
||||||
|
pagination: {
|
||||||
|
current: 1,
|
||||||
|
hasMore: true,
|
||||||
|
total: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('See more')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show "See More" button when hasMore is false', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
data: {
|
||||||
|
agents: mockAgents,
|
||||||
|
pagination: {
|
||||||
|
current: 1,
|
||||||
|
hasMore: false,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('See more')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty States', () => {
|
||||||
|
it('should show empty state for search results', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
data: {
|
||||||
|
agents: [],
|
||||||
|
pagination: { current: 1, hasMore: false, total: 0 },
|
||||||
|
},
|
||||||
|
queryType: 'search',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentGrid category="all" searchQuery="no results" onSelectAgent={mockOnSelectAgent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show empty state for category with no agents', () => {
|
||||||
|
mockUseDynamicAgentQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
data: {
|
||||||
|
agents: [],
|
||||||
|
pagination: { current: 1, hasMore: false, total: 0 },
|
||||||
|
},
|
||||||
|
queryType: 'category',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('No agents found in this category')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import CategoryTabs from '../CategoryTabs';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock useLocalize hook
|
||||||
|
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||||
|
const mockTranslations: Record<string, string> = {
|
||||||
|
com_agents_top_picks: 'Top Picks',
|
||||||
|
com_agents_all: 'All',
|
||||||
|
com_agents_no_categories: 'No categories available',
|
||||||
|
com_agents_category_tabs_label: 'Agent Categories',
|
||||||
|
com_ui_agent_category_general: 'General',
|
||||||
|
com_ui_agent_category_hr: 'HR',
|
||||||
|
com_ui_agent_category_rd: 'R&D',
|
||||||
|
com_ui_agent_category_finance: 'Finance',
|
||||||
|
com_ui_agent_category_it: 'IT',
|
||||||
|
com_ui_agent_category_sales: 'Sales',
|
||||||
|
com_ui_agent_category_aftersales: 'After Sales',
|
||||||
|
};
|
||||||
|
return mockTranslations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CategoryTabs', () => {
|
||||||
|
const mockCategories: t.TMarketplaceCategory[] = [
|
||||||
|
{ value: 'promoted', label: 'Top Picks', description: 'Our recommended agents', count: 5 },
|
||||||
|
{ value: 'all', label: 'All', description: 'All available agents', count: 20 },
|
||||||
|
{ value: 'general', label: 'General', description: 'General purpose agents', count: 8 },
|
||||||
|
{ value: 'hr', label: 'HR', description: 'HR agents', count: 3 },
|
||||||
|
{ value: 'finance', label: 'Finance', description: 'Finance agents', count: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnChange.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders provided categories', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for provided categories
|
||||||
|
expect(screen.getByText('Top Picks')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('All')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('General')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('HR')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Finance')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles loading state properly', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={[]}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={true}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// SmartLoader should handle loading behavior correctly
|
||||||
|
// The component should render without crashing during loading
|
||||||
|
expect(screen.queryByText('No categories available')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the active tab', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="general"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const generalTab = screen.getByText('General').closest('button');
|
||||||
|
expect(generalTab).toHaveClass('text-gray-900');
|
||||||
|
|
||||||
|
// Should have active underline
|
||||||
|
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||||
|
expect(underline).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when a tab is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hrTab = screen.getByText('HR');
|
||||||
|
await user.click(hrTab);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('hr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles promoted tab click correctly', async () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="general"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const topPicksTab = screen.getByText('Top Picks');
|
||||||
|
await user.click(topPicksTab);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('promoted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all tab click correctly', async () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTab = screen.getByText('All');
|
||||||
|
await user.click(allTab);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows inactive state for non-selected tabs', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const generalTab = screen.getByText('General').closest('button');
|
||||||
|
expect(generalTab).toHaveClass('text-gray-600');
|
||||||
|
|
||||||
|
// Should not have active underline
|
||||||
|
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||||
|
expect(underline).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with proper accessibility', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = screen.getAllByRole('tab');
|
||||||
|
expect(tabs.length).toBe(5);
|
||||||
|
// Verify all tabs are properly clickable buttons
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
expect(tab.tagName).toBe('BUTTON');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles keyboard navigation', async () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const generalTab = screen.getByText('General').closest('button')!;
|
||||||
|
|
||||||
|
// Focus the button and click it
|
||||||
|
generalTab.focus();
|
||||||
|
expect(document.activeElement).toBe(generalTab);
|
||||||
|
|
||||||
|
await user.click(generalTab);
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('general');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when categories prop is empty', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={[]}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show empty state message (localized)
|
||||||
|
expect(screen.getByText('No categories available')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains consistent ordering of categories', () => {
|
||||||
|
render(
|
||||||
|
<CategoryTabs
|
||||||
|
categories={mockCategories}
|
||||||
|
activeTab="promoted"
|
||||||
|
isLoading={false}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = screen.getAllByRole('tab');
|
||||||
|
const tabTexts = tabs.map((tab) => tab.textContent);
|
||||||
|
|
||||||
|
// Check that promoted is first and all is second
|
||||||
|
expect(tabTexts[0]).toBe('Top Picks');
|
||||||
|
expect(tabTexts[1]).toBe('All');
|
||||||
|
expect(tabTexts.length).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ErrorDisplay } from '../ErrorDisplay';
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the localize hook
|
||||||
|
const mockLocalize = jest.fn((key: string, options?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
com_agents_error_title: 'Something went wrong',
|
||||||
|
com_agents_error_generic: 'We encountered an issue while loading the content.',
|
||||||
|
com_agents_error_suggestion_generic: 'Please try refreshing the page or try again later.',
|
||||||
|
com_agents_error_network_title: 'Connection Problem',
|
||||||
|
com_agents_error_network_message: 'Unable to connect to the server.',
|
||||||
|
com_agents_error_network_suggestion: 'Check your internet connection and try again.',
|
||||||
|
com_agents_error_not_found_title: 'Not Found',
|
||||||
|
com_agents_error_not_found_message: 'The requested content could not be found.',
|
||||||
|
com_agents_error_not_found_suggestion:
|
||||||
|
'Try browsing other options or go back to the marketplace.',
|
||||||
|
com_agents_error_invalid_request: 'Invalid Request',
|
||||||
|
com_agents_error_bad_request_message: 'The request could not be processed.',
|
||||||
|
com_agents_error_bad_request_suggestion: 'Please check your input and try again.',
|
||||||
|
com_agents_error_server_title: 'Server Error',
|
||||||
|
com_agents_error_server_message: 'The server is temporarily unavailable.',
|
||||||
|
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_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',
|
||||||
|
};
|
||||||
|
|
||||||
|
return translations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('~/hooks/useLocalize', () => () => mockLocalize);
|
||||||
|
|
||||||
|
describe('ErrorDisplay', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLocalize.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backend error responses', () => {
|
||||||
|
it('displays user-friendly message from backend response', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||||
|
suggestion: 'Try refreshing the page or check your network connection',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Unable to load agents. Please try refreshing the page.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('💡 Try refreshing the page or check your network connection'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles search context with backend response', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
userMessage: 'Search is temporarily unavailable. Please try again.',
|
||||||
|
suggestion: 'Try a different search term or check your network connection',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} context={{ searchQuery: 'test query' }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Search Error')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Search is temporarily unavailable. Please try again.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Network errors', () => {
|
||||||
|
it('displays network error message', () => {
|
||||||
|
const error = {
|
||||||
|
code: 'NETWORK_ERROR',
|
||||||
|
message: 'Network Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Connection Problem')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Unable to connect to the server.')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('💡 Check your internet connection and try again.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles timeout errors', () => {
|
||||||
|
const error = {
|
||||||
|
code: 'ECONNABORTED',
|
||||||
|
message: 'timeout of 5000ms exceeded',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_title');
|
||||||
|
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_message');
|
||||||
|
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_suggestion');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTTP status codes', () => {
|
||||||
|
it('handles 404 errors with search context', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} context={{ searchQuery: 'nonexistent agent' }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Not Found')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No agents found for "nonexistent agent"')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 404 errors with category context', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Not Found')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No agents found in the productivity category')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 400 bad request errors', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Invalid Request')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please enter a search term to find agents')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('💡 Enter a search term to find agents by name or description'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 500 server errors', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 500,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Server Error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('💡 Please try again in a few moments.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Retry functionality', () => {
|
||||||
|
it('displays retry button when onRetry is provided', () => {
|
||||||
|
const mockRetry = jest.fn();
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} onRetry={mockRetry} />);
|
||||||
|
|
||||||
|
const retryButton = screen.getByText('Try Again');
|
||||||
|
expect(retryButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(retryButton);
|
||||||
|
expect(mockRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not display retry button when onRetry is not provided', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Try Again')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Context-aware titles', () => {
|
||||||
|
it('shows search error title for search context', () => {
|
||||||
|
const error = { message: 'Some error' };
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} context={{ searchQuery: 'test' }} />);
|
||||||
|
|
||||||
|
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_search_title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows category error title for category context', () => {
|
||||||
|
const error = { message: 'Some error' };
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
|
||||||
|
|
||||||
|
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_category_title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generic error title when no context', () => {
|
||||||
|
const error = { message: 'Some error' };
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fallback error handling', () => {
|
||||||
|
it('handles unknown errors gracefully', () => {
|
||||||
|
const error = {
|
||||||
|
message: 'Unknown error occurred',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('We encountered an issue while loading the content.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('💡 Please try refreshing the page or try again later.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null/undefined errors', () => {
|
||||||
|
render(<ErrorDisplay error={null} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('We encountered an issue while loading the content.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('renders error icon with proper accessibility', () => {
|
||||||
|
const error = { message: 'Test error' };
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
const errorIcon = screen.getByRole('img', { hidden: true });
|
||||||
|
expect(errorIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper heading structure', () => {
|
||||||
|
const error = { message: 'Test error' };
|
||||||
|
|
||||||
|
render(<ErrorDisplay error={error} />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole('heading', { level: 3 });
|
||||||
|
expect(heading).toHaveTextContent('Something went wrong');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {};
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import { MarketplaceProvider } from '../MarketplaceContext';
|
||||||
|
import { useChatContext } from '~/Providers';
|
||||||
|
|
||||||
|
// Mock the ChatContext from Providers
|
||||||
|
jest.mock('~/Providers', () => ({
|
||||||
|
ChatContext: {
|
||||||
|
Provider: ({ children, value }: { children: React.ReactNode; value: any }) => (
|
||||||
|
<div data-testid="chat-context-provider" data-value={JSON.stringify(value)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
useChatContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
|
||||||
|
|
||||||
|
// Test component that consumes the context
|
||||||
|
const TestConsumer: React.FC = () => {
|
||||||
|
const context = mockedUseChatContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="endpoint">{context?.conversation?.endpoint}</div>
|
||||||
|
<div data-testid="conversation-id">{context?.conversation?.conversationId}</div>
|
||||||
|
<div data-testid="title">{context?.conversation?.title}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MarketplaceProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedUseChatContext.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides correct marketplace context values', () => {
|
||||||
|
const mockContext = {
|
||||||
|
conversation: {
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
conversationId: 'marketplace',
|
||||||
|
title: 'Agent Marketplace',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedUseChatContext.mockReturnValue(mockContext);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</MarketplaceProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('endpoint')).toHaveTextContent(EModelEndpoint.agents);
|
||||||
|
expect(screen.getByTestId('conversation-id')).toHaveTextContent('marketplace');
|
||||||
|
expect(screen.getByTestId('title')).toHaveTextContent('Agent Marketplace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates ChatContext.Provider with correct structure', () => {
|
||||||
|
render(
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<div>{/* eslint-disable-line i18next/no-literal-string */}Test Child</div>
|
||||||
|
</MarketplaceProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const provider = screen.getByTestId('chat-context-provider');
|
||||||
|
expect(provider).toBeInTheDocument();
|
||||||
|
|
||||||
|
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
|
||||||
|
expect(valueData.conversation).toEqual({
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
conversationId: 'marketplace',
|
||||||
|
title: 'Agent Marketplace',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children correctly', () => {
|
||||||
|
render(
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<div data-testid="test-child">
|
||||||
|
{/* eslint-disable-line i18next/no-literal-string */}Test Content
|
||||||
|
</div>
|
||||||
|
</MarketplaceProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-child')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('test-child')).toHaveTextContent('Test Content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides stable context value (memoization)', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</MarketplaceProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstProvider = screen.getByTestId('chat-context-provider');
|
||||||
|
const firstValue = firstProvider.getAttribute('data-value');
|
||||||
|
|
||||||
|
// Rerender should provide the same memoized value
|
||||||
|
rerender(
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</MarketplaceProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondProvider = screen.getByTestId('chat-context-provider');
|
||||||
|
const secondValue = secondProvider.getAttribute('data-value');
|
||||||
|
|
||||||
|
expect(firstValue).toBe(secondValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides minimal context without bloated functions', () => {
|
||||||
|
render(
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<div>{/* eslint-disable-line i18next/no-literal-string */}Test</div>
|
||||||
|
</MarketplaceProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const provider = screen.getByTestId('chat-context-provider');
|
||||||
|
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
|
||||||
|
|
||||||
|
// Should only have conversation object, not 44 empty functions
|
||||||
|
expect(Object.keys(valueData)).toContain('conversation');
|
||||||
|
expect(valueData.conversation).toEqual({
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
conversationId: 'marketplace',
|
||||||
|
title: 'Agent Marketplace',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import SearchBar from '../SearchBar';
|
||||||
|
|
||||||
|
// Mock useLocalize hook
|
||||||
|
jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
|
||||||
|
|
||||||
|
// Mock useDebounce hook
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useDebounce: (value: string, delay: number) => value, // Return value immediately for testing
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SearchBar', () => {
|
||||||
|
const mockOnSearch = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnSearch.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with correct placeholder', () => {
|
||||||
|
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'com_agents_search_placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the provided value', () => {
|
||||||
|
render(<SearchBar value="test query" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('test query');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSearch when user types', async () => {
|
||||||
|
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
await user.type(input, 'test');
|
||||||
|
|
||||||
|
// Should call onSearch for each character due to debounce mock
|
||||||
|
expect(mockOnSearch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows clear button when there is text', () => {
|
||||||
|
render(<SearchBar value="test" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||||
|
expect(clearButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show clear button when text is empty', () => {
|
||||||
|
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const clearButton = screen.queryByRole('button', { name: 'com_agents_clear_search' });
|
||||||
|
expect(clearButton).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears search when clear button is clicked', async () => {
|
||||||
|
render(<SearchBar value="test" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
expect(input).toHaveValue('test');
|
||||||
|
|
||||||
|
await user.click(clearButton);
|
||||||
|
|
||||||
|
// Verify onSearch is called and input is cleared
|
||||||
|
expect(mockOnSearch).toHaveBeenCalledWith('');
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates internal state when value prop changes', () => {
|
||||||
|
const { rerender } = render(<SearchBar value="initial" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('initial')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<SearchBar value="updated" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('updated')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper accessibility attributes', () => {
|
||||||
|
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toHaveAttribute('aria-label', 'com_agents_search_aria');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<SearchBar value="" onSearch={mockOnSearch} className="custom-class" />);
|
||||||
|
|
||||||
|
const container = screen.getByRole('textbox').closest('div');
|
||||||
|
expect(container).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents form submission on clear button click', async () => {
|
||||||
|
const handleSubmit = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<SearchBar value="test" onSearch={mockOnSearch} />
|
||||||
|
</form>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||||
|
await user.click(clearButton);
|
||||||
|
|
||||||
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid typing correctly', async () => {
|
||||||
|
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
|
// Type multiple characters quickly
|
||||||
|
await user.type(input, 'quick');
|
||||||
|
|
||||||
|
// Should handle all characters
|
||||||
|
expect(input).toHaveValue('quick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains focus after clear button click', async () => {
|
||||||
|
render(<SearchBar value="test" onSearch={mockOnSearch} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await user.click(clearButton);
|
||||||
|
|
||||||
|
// Input should still be in the document and ready for new input
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,370 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||||
|
import { SmartLoader, useHasData } from '../SmartLoader';
|
||||||
|
|
||||||
|
// Mock setTimeout and clearTimeout for testing
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
describe('SmartLoader', () => {
|
||||||
|
const LoadingComponent = () => <div data-testid="loading">Loading...</div>;
|
||||||
|
const ContentComponent = () => (
|
||||||
|
<div data-testid="content">
|
||||||
|
{/* eslint-disable-line i18next/no-literal-string */}Content loaded
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic functionality', () => {
|
||||||
|
it('shows content immediately when not loading', () => {
|
||||||
|
render(
|
||||||
|
<SmartLoader isLoading={false} hasData={true} loadingComponent={<LoadingComponent />}>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows content immediately when loading but has existing data', () => {
|
||||||
|
render(
|
||||||
|
<SmartLoader isLoading={true} hasData={true} loadingComponent={<LoadingComponent />}>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows content initially, then loading after delay when loading with no data', async () => {
|
||||||
|
render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={150}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initially shows content
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// After delay, shows loading
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents loading flash for quick responses', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={150}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initially shows content
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Advance time but not past delay
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading finishes before delay
|
||||||
|
rerender(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={false}
|
||||||
|
hasData={true}
|
||||||
|
delay={150}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still show content, never showed loading
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Advance past original delay to ensure loading doesn't appear
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Delay behavior', () => {
|
||||||
|
it('respects custom delay times', async () => {
|
||||||
|
render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={300}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show content initially
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not show loading before delay
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show loading after delay
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default delay when not specified', async () => {
|
||||||
|
render(
|
||||||
|
<SmartLoader isLoading={true} hasData={false} loadingComponent={<LoadingComponent />}>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show content initially
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show loading after default delay (150ms)
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('State transitions', () => {
|
||||||
|
it('immediately hides loading when loading completes', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={100}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance past delay to show loading
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading completes
|
||||||
|
rerender(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={false}
|
||||||
|
hasData={true}
|
||||||
|
delay={100}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should immediately show content
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid loading state changes correctly', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={100}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rapid state changes
|
||||||
|
rerender(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={false}
|
||||||
|
hasData={true}
|
||||||
|
delay={100}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={100}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show content throughout rapid changes
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSS classes', () => {
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={false}
|
||||||
|
hasData={true}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
className="custom-class"
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
expect(wrapper).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies className to both loading and content states', async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SmartLoader
|
||||||
|
isLoading={true}
|
||||||
|
hasData={false}
|
||||||
|
delay={50}
|
||||||
|
loadingComponent={<LoadingComponent />}
|
||||||
|
className="custom-class"
|
||||||
|
>
|
||||||
|
<ContentComponent />
|
||||||
|
</SmartLoader>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Content state
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useHasData', () => {
|
||||||
|
const TestComponent: React.FC<{ data: any }> = ({ data }) => {
|
||||||
|
const hasData = useHasData(data);
|
||||||
|
return <div data-testid="result">{hasData ? 'has-data' : 'no-data'}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns false for null data', () => {
|
||||||
|
render(<TestComponent data={null} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for undefined data', () => {
|
||||||
|
render(<TestComponent data={undefined} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects empty agents array as no data', () => {
|
||||||
|
render(<TestComponent data={{ agents: [] }} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects non-empty agents array as has data', () => {
|
||||||
|
render(<TestComponent data={{ agents: [{ id: '1', name: 'Test' }] }} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects invalid agents property as no data', () => {
|
||||||
|
render(<TestComponent data={{ agents: 'not-array' }} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects empty array as no data', () => {
|
||||||
|
render(<TestComponent data={[]} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects non-empty array as has data', () => {
|
||||||
|
render(<TestComponent data={[{ name: 'category1' }]} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects agent with id as has data', () => {
|
||||||
|
render(<TestComponent data={{ id: '123', name: 'Test Agent' }} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects agent with name only as has data', () => {
|
||||||
|
render(<TestComponent data={{ name: 'Test Agent' }} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects object without id or name as no data', () => {
|
||||||
|
render(<TestComponent data={{ description: 'Some description' }} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles string data as no data', () => {
|
||||||
|
render(<TestComponent data="some string" />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles number data as no data', () => {
|
||||||
|
render(<TestComponent data={42} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles boolean data as no data', () => {
|
||||||
|
render(<TestComponent data={true} />);
|
||||||
|
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||||
|
});
|
||||||
|
});
|
||||||
12
client/src/constants/agentCategories.ts
Normal file
12
client/src/constants/agentCategories.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { TranslationKeys } from '~/hooks/useLocalize';
|
||||||
|
|
||||||
|
export interface AgentCategory {
|
||||||
|
label: TranslationKeys;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The empty category placeholder - used for form defaults
|
||||||
|
export const EMPTY_AGENT_CATEGORY: AgentCategory = {
|
||||||
|
value: '',
|
||||||
|
label: 'com_ui_agent_category_general',
|
||||||
|
};
|
||||||
|
|
@ -75,3 +75,140 @@ export const useGetAgentByIdQuery = (
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MARKETPLACE QUERIES
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for getting all agent categories with counts
|
||||||
|
*/
|
||||||
|
export const useGetAgentCategoriesQuery = <TData = t.TMarketplaceCategory[]>(
|
||||||
|
config?: UseQueryOptions<t.TMarketplaceCategory[], unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||||
|
|
||||||
|
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
|
||||||
|
return useQuery<t.TMarketplaceCategory[], unknown, TData>(
|
||||||
|
[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 = <TData = t.AgentListResponse>(
|
||||||
|
params: { page?: number; limit?: number; showAll?: string } = { page: 1, limit: 6 },
|
||||||
|
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||||
|
|
||||||
|
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
|
||||||
|
return useQuery<t.AgentListResponse, unknown, TData>(
|
||||||
|
[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 = <TData = t.AgentListResponse>(
|
||||||
|
params: { page?: number; limit?: number } = { page: 1, limit: 6 },
|
||||||
|
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||||
|
|
||||||
|
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
|
||||||
|
return useQuery<t.AgentListResponse, unknown, TData>(
|
||||||
|
[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 = <TData = t.AgentListResponse>(
|
||||||
|
params: { category: string; page?: number; limit?: number },
|
||||||
|
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||||
|
|
||||||
|
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
|
||||||
|
return useQuery<t.AgentListResponse, unknown, TData>(
|
||||||
|
[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 = <TData = t.AgentListResponse>(
|
||||||
|
params: { q: string; category?: string; page?: number; limit?: number },
|
||||||
|
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||||
|
|
||||||
|
const enabled = !!endpointsConfig?.[EModelEndpoint.agents] && !!params.q;
|
||||||
|
return useQuery<t.AgentListResponse, unknown, TData>(
|
||||||
|
[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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import useAgentCategories from '../useAgentCategories';
|
||||||
|
import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
|
||||||
|
|
||||||
|
// Mock the useLocalize hook
|
||||||
|
jest.mock('~/hooks/useLocalize', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => (key: string) => {
|
||||||
|
// Simple mock implementation that returns the key as the translation
|
||||||
|
return key === 'com_ui_agent_category_general' ? 'General (Translated)' : key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAgentCategories', () => {
|
||||||
|
it('should return processed categories with correct structure', () => {
|
||||||
|
const { result } = renderHook(() => useAgentCategories());
|
||||||
|
|
||||||
|
// Check that we have the expected number of categories
|
||||||
|
expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length);
|
||||||
|
|
||||||
|
// 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.className).toBe('w-full');
|
||||||
|
|
||||||
|
// Check the empty category
|
||||||
|
expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value);
|
||||||
|
expect(result.current.emptyCategory.label).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
360
client/src/hooks/Agents/__tests__/useDynamicAgentQuery.spec.ts
Normal file
360
client/src/hooks/Agents/__tests__/useDynamicAgentQuery.spec.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,2 +1,5 @@
|
||||||
export { default as useAgentsMap } from './useAgentsMap';
|
export { default as useAgentsMap } from './useAgentsMap';
|
||||||
export { default as useSelectAgent } from './useSelectAgent';
|
export { default as useSelectAgent } from './useSelectAgent';
|
||||||
|
export { default as useAgentCategories } from './useAgentCategories';
|
||||||
|
export { useDynamicAgentQuery } from './useDynamicAgentQuery';
|
||||||
|
export type { ProcessedAgentCategory } from './useAgentCategories';
|
||||||
|
|
|
||||||
57
client/src/hooks/Agents/useAgentCategories.tsx
Normal file
57
client/src/hooks/Agents/useAgentCategories.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import { useGetAgentCategoriesQuery } from '~/data-provider';
|
||||||
|
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
|
||||||
|
|
||||||
|
// This interface matches the structure used by the ControlCombobox component
|
||||||
|
export interface ProcessedAgentCategory {
|
||||||
|
label: string; // Translated label
|
||||||
|
value: string; // Category value
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that provides processed and translated agent categories from API
|
||||||
|
*
|
||||||
|
* @returns Object containing categories, emptyCategory, and loading state
|
||||||
|
*/
|
||||||
|
const useAgentCategories = () => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
// Fetch categories from API
|
||||||
|
const categoriesQuery = useGetAgentCategoriesQuery({
|
||||||
|
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = useMemo((): ProcessedAgentCategory[] => {
|
||||||
|
if (!categoriesQuery.data) return [];
|
||||||
|
|
||||||
|
// Filter out special categories (promoted, all) and convert to form format
|
||||||
|
return categoriesQuery.data
|
||||||
|
.filter((category) => category.value !== 'promoted' && category.value !== 'all')
|
||||||
|
.map((category) => ({
|
||||||
|
label: category.label || category.value,
|
||||||
|
value: category.value,
|
||||||
|
className: 'w-full',
|
||||||
|
}));
|
||||||
|
}, [categoriesQuery.data]);
|
||||||
|
|
||||||
|
const emptyCategory = useMemo(
|
||||||
|
(): ProcessedAgentCategory => ({
|
||||||
|
label: localize(EMPTY_AGENT_CATEGORY.label),
|
||||||
|
value: EMPTY_AGENT_CATEGORY.value,
|
||||||
|
className: 'w-full',
|
||||||
|
}),
|
||||||
|
[localize],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
emptyCategory,
|
||||||
|
isLoading: categoriesQuery.isLoading,
|
||||||
|
error: categoriesQuery.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAgentCategories;
|
||||||
112
client/src/hooks/Agents/useDynamicAgentQuery.ts
Normal file
112
client/src/hooks/Agents/useDynamicAgentQuery.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
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<t.AgentListResponse> = 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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { MessageSquareQuote, ArrowRightToLine, Settings2, Database, Bookmark } from 'lucide-react';
|
import {
|
||||||
|
MessageSquareQuote,
|
||||||
|
ArrowRightToLine,
|
||||||
|
Settings2, Database,
|
||||||
|
Bookmark,
|
||||||
|
LayoutGrid,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
isAssistantsEndpoint,
|
isAssistantsEndpoint,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
|
|
@ -21,6 +27,7 @@ import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||||
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import { useHasAccess } from '~/hooks';
|
import { useHasAccess } from '~/hooks';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export default function useSideNavLinks({
|
export default function useSideNavLinks({
|
||||||
hidePanel,
|
hidePanel,
|
||||||
|
|
@ -37,6 +44,7 @@ export default function useSideNavLinks({
|
||||||
interfaceConfig: Partial<TInterfaceConfig>;
|
interfaceConfig: Partial<TInterfaceConfig>;
|
||||||
endpointsConfig: TEndpointsConfig;
|
endpointsConfig: TEndpointsConfig;
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const hasAccessToPrompts = useHasAccess({
|
const hasAccessToPrompts = useHasAccess({
|
||||||
permissionType: PermissionTypes.PROMPTS,
|
permissionType: PermissionTypes.PROMPTS,
|
||||||
permission: Permissions.USE,
|
permission: Permissions.USE,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
|
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
|
||||||
"com_assistants_add_actions": "Add Actions",
|
"com_assistants_add_actions": "Add Actions",
|
||||||
"com_assistants_add_tools": "Add Tools",
|
"com_assistants_add_tools": "Add Tools",
|
||||||
"com_assistants_allow_sites_you_trust": "Only allow sites you trust.",
|
"com_assistants_allow_sites_you_trust": "Only allow sites you trust..",
|
||||||
"com_assistants_append_date": "Append Current Date & Time",
|
"com_assistants_append_date": "Append Current Date & Time",
|
||||||
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
|
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
|
||||||
"com_assistants_attempt_info": "Assistant wants to send the following:",
|
"com_assistants_attempt_info": "Assistant wants to send the following:",
|
||||||
|
|
@ -617,6 +617,14 @@
|
||||||
"com_ui_cancel": "Cancel",
|
"com_ui_cancel": "Cancel",
|
||||||
"com_ui_cancelled": "Cancelled",
|
"com_ui_cancelled": "Cancelled",
|
||||||
"com_ui_category": "Category",
|
"com_ui_category": "Category",
|
||||||
|
"com_ui_agent_category_selector_aria": "Agent's category selector",
|
||||||
|
"com_ui_agent_category_general": "General",
|
||||||
|
"com_ui_agent_category_hr": "HR",
|
||||||
|
"com_ui_agent_category_rd": "R&D",
|
||||||
|
"com_ui_agent_category_finance": "Finance",
|
||||||
|
"com_ui_agent_category_it": "IT",
|
||||||
|
"com_ui_agent_category_sales": "Sales",
|
||||||
|
"com_ui_agent_category_aftersales": "After Sales",
|
||||||
"com_ui_chat": "Chat",
|
"com_ui_chat": "Chat",
|
||||||
"com_ui_chat_history": "Chat History",
|
"com_ui_chat_history": "Chat History",
|
||||||
"com_ui_clear": "Clear",
|
"com_ui_clear": "Clear",
|
||||||
|
|
@ -933,6 +941,7 @@
|
||||||
"com_ui_scope": "Scope",
|
"com_ui_scope": "Scope",
|
||||||
"com_ui_search": "Search",
|
"com_ui_search": "Search",
|
||||||
"com_ui_seconds": "seconds",
|
"com_ui_seconds": "seconds",
|
||||||
|
"com_ui_search_agent_category": "Search categories...",
|
||||||
"com_ui_secret_key": "Secret Key",
|
"com_ui_secret_key": "Secret Key",
|
||||||
"com_ui_select": "Select",
|
"com_ui_select": "Select",
|
||||||
"com_ui_select_all": "Select All",
|
"com_ui_select_all": "Select All",
|
||||||
|
|
@ -977,6 +986,13 @@
|
||||||
"com_ui_stop": "Stop",
|
"com_ui_stop": "Stop",
|
||||||
"com_ui_storage": "Storage",
|
"com_ui_storage": "Storage",
|
||||||
"com_ui_submit": "Submit",
|
"com_ui_submit": "Submit",
|
||||||
|
"com_ui_support_contact": "Support Contact",
|
||||||
|
"com_ui_support_contact_name": "Name",
|
||||||
|
"com_ui_support_contact_name_placeholder": "Support contact name",
|
||||||
|
"com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters",
|
||||||
|
"com_ui_support_contact_email": "Email",
|
||||||
|
"com_ui_support_contact_email_placeholder": "support@example.com",
|
||||||
|
"com_ui_support_contact_email_invalid": "Please enter a valid email address",
|
||||||
"com_ui_teach_or_explain": "Learning",
|
"com_ui_teach_or_explain": "Learning",
|
||||||
"com_ui_temporary": "Temporary Chat",
|
"com_ui_temporary": "Temporary Chat",
|
||||||
"com_ui_terms_and_conditions": "Terms and Conditions",
|
"com_ui_terms_and_conditions": "Terms and Conditions",
|
||||||
|
|
@ -1055,5 +1071,59 @@
|
||||||
"com_ui_yes": "Yes",
|
"com_ui_yes": "Yes",
|
||||||
"com_ui_zoom": "Zoom",
|
"com_ui_zoom": "Zoom",
|
||||||
"com_user_message": "You",
|
"com_user_message": "You",
|
||||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
|
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
|
||||||
|
"com_agents_marketplace": "Agent Marketplace",
|
||||||
|
"com_agents_all": "All Agents",
|
||||||
|
"com_agents_top_picks": "Top Picks",
|
||||||
|
"com_agents_recommended": "Our recommended agents",
|
||||||
|
"com_agents_search_placeholder": "Search agents...",
|
||||||
|
"com_agents_search_aria": "Search for agents",
|
||||||
|
"com_agents_clear_search": "Clear search",
|
||||||
|
"com_agents_see_more": "See more",
|
||||||
|
"com_agents_created_by": "by",
|
||||||
|
"com_agents_contact": "Contact",
|
||||||
|
"com_agents_start_chat": "Start Chat",
|
||||||
|
"com_agents_copy_link": "Copy Link",
|
||||||
|
"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",
|
||||||
|
"com_agents_error_generic": "We encountered an issue while loading the content.",
|
||||||
|
"com_agents_error_suggestion_generic": "Please try refreshing the page or try again later.",
|
||||||
|
"com_agents_error_network_title": "Connection Problem",
|
||||||
|
"com_agents_error_network_message": "Unable to connect to the server.",
|
||||||
|
"com_agents_error_network_suggestion": "Check your internet connection and try again.",
|
||||||
|
"com_agents_error_not_found_title": "Not Found",
|
||||||
|
"com_agents_error_not_found_message": "The requested content could not be found.",
|
||||||
|
"com_agents_error_not_found_suggestion": "Try browsing other options or go back to the marketplace.",
|
||||||
|
"com_agents_error_invalid_request": "Invalid Request",
|
||||||
|
"com_agents_error_bad_request_message": "The request could not be processed.",
|
||||||
|
"com_agents_error_bad_request_suggestion": "Please check your input and try again.",
|
||||||
|
"com_agents_error_server_title": "Server Error",
|
||||||
|
"com_agents_error_server_message": "The server is temporarily unavailable.",
|
||||||
|
"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_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",
|
||||||
|
"com_agents_category_tabs_label": "Agent Categories",
|
||||||
|
"com_agents_category_tab_label": "{{category}} category, {{position}} of {{total}}",
|
||||||
|
"com_agents_search_instructions": "Type to search agents by name or description",
|
||||||
|
"com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category",
|
||||||
|
"com_agents_load_more_label": "Load more agents from {{category}} category",
|
||||||
|
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
|
||||||
|
"com_agents_empty_state_heading": "No agents found",
|
||||||
|
"com_agents_search_empty_heading": "No search results",
|
||||||
|
"com_agents_no_description": "No description available",
|
||||||
|
"com_agents_none_in_category": "No agents found in this category",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import ShareRoute from './ShareRoute';
|
||||||
import ChatRoute from './ChatRoute';
|
import ChatRoute from './ChatRoute';
|
||||||
import Search from './Search';
|
import Search from './Search';
|
||||||
import Root from './Root';
|
import Root from './Root';
|
||||||
|
import AgentMarketplace from '~/components/SidePanel/Agents/AgentMarketplace';
|
||||||
|
|
||||||
const AuthLayout = () => (
|
const AuthLayout = () => (
|
||||||
<AuthContextProvider>
|
<AuthContextProvider>
|
||||||
|
|
@ -105,6 +106,14 @@ export const router = createBrowserRouter([
|
||||||
path: 'search',
|
path: 'search',
|
||||||
element: <Search />,
|
element: <Search />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'agents',
|
||||||
|
element: <AgentMarketplace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agents/:category',
|
||||||
|
element: <AgentMarketplace />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
178
client/src/utils/__tests__/agents.spec.tsx
Normal file
178
client/src/utils/__tests__/agents.spec.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock the Bot icon from lucide-react
|
||||||
|
jest.mock('lucide-react', () => ({
|
||||||
|
Bot: ({ className, strokeWidth, ...props }: any) => (
|
||||||
|
<svg data-testid="bot-icon" className={className} data-stroke-width={strokeWidth} {...props}>
|
||||||
|
<title>{/* eslint-disable-line i18next/no-literal-string */}Bot Icon</title>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Agent Utilities', () => {
|
||||||
|
describe('getAgentAvatarUrl', () => {
|
||||||
|
it('should return null for null agent', () => {
|
||||||
|
expect(getAgentAvatarUrl(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for undefined agent', () => {
|
||||||
|
expect(getAgentAvatarUrl(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for agent without avatar', () => {
|
||||||
|
const agent = { id: '1', name: 'Test Agent' } as t.Agent;
|
||||||
|
expect(getAgentAvatarUrl(agent)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return string avatar directly', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: '/path/to/avatar.png',
|
||||||
|
} as t.Agent;
|
||||||
|
expect(getAgentAvatarUrl(agent)).toBe('/path/to/avatar.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract filepath from object avatar', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: { filepath: '/path/to/object-avatar.png' },
|
||||||
|
} as t.Agent;
|
||||||
|
expect(getAgentAvatarUrl(agent)).toBe('/path/to/object-avatar.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for object avatar without filepath', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: { someOtherProperty: 'value' },
|
||||||
|
} as any;
|
||||||
|
expect(getAgentAvatarUrl(agent)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderAgentAvatar', () => {
|
||||||
|
it('should render image when avatar URL exists', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: '/test-avatar.png',
|
||||||
|
} as t.Agent;
|
||||||
|
|
||||||
|
render(<div>{renderAgentAvatar(agent)}</div>);
|
||||||
|
|
||||||
|
const img = screen.getByAltText('Test Agent avatar');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', '/test-avatar.png');
|
||||||
|
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Bot icon fallback when no avatar', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
} as t.Agent;
|
||||||
|
|
||||||
|
render(<div>{renderAgentAvatar(agent)}</div>);
|
||||||
|
|
||||||
|
const botIcon = screen.getByTestId('bot-icon');
|
||||||
|
expect(botIcon).toBeInTheDocument();
|
||||||
|
expect(botIcon).toHaveAttribute('data-stroke-width', '1.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply different size classes', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: '/test-avatar.png',
|
||||||
|
} as t.Agent;
|
||||||
|
|
||||||
|
const { rerender } = render(<div>{renderAgentAvatar(agent, { size: 'sm' })}</div>);
|
||||||
|
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12');
|
||||||
|
|
||||||
|
rerender(<div>{renderAgentAvatar(agent, { size: 'lg' })}</div>);
|
||||||
|
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-20', 'w-20');
|
||||||
|
|
||||||
|
rerender(<div>{renderAgentAvatar(agent, { size: 'xl' })}</div>);
|
||||||
|
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-24', 'w-24');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom className', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: '/test-avatar.png',
|
||||||
|
} as t.Agent;
|
||||||
|
|
||||||
|
render(<div>{renderAgentAvatar(agent, { className: 'custom-class' })}</div>);
|
||||||
|
|
||||||
|
const container = screen.getByAltText('Test Agent avatar').parentElement;
|
||||||
|
expect(container).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle showBorder option', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
avatar: '/test-avatar.png',
|
||||||
|
} as t.Agent;
|
||||||
|
|
||||||
|
const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
|
||||||
|
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2');
|
||||||
|
|
||||||
|
rerender(<div>{renderAgentAvatar(agent, { showBorder: false })}</div>);
|
||||||
|
expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContactDisplayName', () => {
|
||||||
|
it('should return null for null agent', () => {
|
||||||
|
expect(getContactDisplayName(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for undefined agent', () => {
|
||||||
|
expect(getContactDisplayName(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize support_contact name', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
support_contact: { name: 'Support Team', email: 'support@example.com' },
|
||||||
|
authorName: 'John Doe',
|
||||||
|
} as any;
|
||||||
|
expect(getContactDisplayName(agent)).toBe('Support Team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use authorName when support_contact name is missing', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
support_contact: { email: 'support@example.com' },
|
||||||
|
authorName: 'John Doe',
|
||||||
|
} as any;
|
||||||
|
expect(getContactDisplayName(agent)).toBe('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use support_contact email when both name and authorName are missing', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
support_contact: { email: 'support@example.com' },
|
||||||
|
} as any;
|
||||||
|
expect(getContactDisplayName(agent)).toBe('support@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no contact info is available', () => {
|
||||||
|
const agent = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent',
|
||||||
|
} as any;
|
||||||
|
expect(getContactDisplayName(agent)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for hardcoded category functions removed - now using database-driven categories
|
||||||
|
});
|
||||||
109
client/src/utils/agents.tsx
Normal file
109
client/src/utils/agents.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Bot } from 'lucide-react';
|
||||||
|
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the avatar URL from an agent's avatar property
|
||||||
|
* Handles both string and object formats
|
||||||
|
*/
|
||||||
|
export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | null => {
|
||||||
|
if (!agent?.avatar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof agent.avatar === 'string') {
|
||||||
|
return agent.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.avatar && typeof agent.avatar === 'object' && 'filepath' in agent.avatar) {
|
||||||
|
return agent.avatar.filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an agent avatar with fallback to Bot icon
|
||||||
|
* Consistent across all agent displays
|
||||||
|
*/
|
||||||
|
export const renderAgentAvatar = (
|
||||||
|
agent: t.Agent | null | undefined,
|
||||||
|
options: {
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
className?: string;
|
||||||
|
showBorder?: boolean;
|
||||||
|
} = {},
|
||||||
|
): React.ReactElement => {
|
||||||
|
const { size = 'md', className = '', showBorder = true } = options;
|
||||||
|
|
||||||
|
const avatarUrl = getAgentAvatarUrl(agent);
|
||||||
|
|
||||||
|
// Size mappings for responsive design
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-12 w-12 sm:h-14 sm:w-14',
|
||||||
|
md: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
|
||||||
|
lg: 'h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28',
|
||||||
|
xl: 'h-24 w-24',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizeClasses = {
|
||||||
|
sm: 'h-6 w-6 sm:h-7 sm:w-7',
|
||||||
|
md: 'h-6 w-6 sm:h-8 sm:w-8 md:h-10 md:w-10',
|
||||||
|
lg: 'h-8 w-8 sm:h-10 sm:w-10 md:h-12 md:w-12',
|
||||||
|
xl: 'h-10 w-10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderSizeClasses = {
|
||||||
|
sm: 'h-10 w-10 sm:h-12 sm:w-12',
|
||||||
|
md: 'h-12 w-12 sm:h-16 sm:w-16 md:h-20 md:w-20',
|
||||||
|
lg: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
|
||||||
|
xl: 'h-20 w-20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderClasses = showBorder ? 'border-2 border-white dark:border-gray-800' : '';
|
||||||
|
|
||||||
|
if (avatarUrl) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={`${agent?.name || 'Agent'} avatar`}
|
||||||
|
className={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback placeholder with Bot icon
|
||||||
|
return (
|
||||||
|
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
||||||
|
{/* Subtle minimalistic placeholder */}
|
||||||
|
<div className="absolute inset-0 rounded-full border border-gray-300 bg-gray-200 dark:border-gray-600 dark:bg-gray-700"></div>
|
||||||
|
<div
|
||||||
|
className={`relative flex items-center justify-center rounded-full bg-gray-300 dark:bg-gray-600 ${placeholderSizeClasses[size]}`}
|
||||||
|
>
|
||||||
|
<Bot
|
||||||
|
className={`text-gray-500 dark:text-gray-400 ${iconSizeClasses[size]}`}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for a contact (prioritizes name over email)
|
||||||
|
*/
|
||||||
|
export const getContactDisplayName = (agent: t.Agent | null | undefined): string | null => {
|
||||||
|
if (!agent) return null;
|
||||||
|
|
||||||
|
const supportName = (agent as any).support_contact?.name;
|
||||||
|
const supportEmail = (agent as any).support_contact?.email;
|
||||||
|
const authorName = (agent as any).authorName;
|
||||||
|
|
||||||
|
return supportName || authorName || supportEmail || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// All hardcoded category constants removed - now using database-driven categories
|
||||||
|
|
@ -21,6 +21,21 @@ beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock window.matchMedia for tests
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(), // deprecated
|
||||||
|
removeListener: jest.fn(), // deprecated
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock('react-i18next', () => {
|
jest.mock('react-i18next', () => {
|
||||||
const actual = jest.requireActual('react-i18next');
|
const actual = jest.requireActual('react-i18next');
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
106
config/seed-categories.js
Normal file
106
config/seed-categories.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
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;
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"delete-user": "node config/delete-user.js",
|
"delete-user": "node config/delete-user.js",
|
||||||
"update-banner": "node config/update-banner.js",
|
"update-banner": "node config/update-banner.js",
|
||||||
"delete-banner": "node config/delete-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": "cross-env NODE_ENV=production node api/server/index.js",
|
||||||
"backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
|
"backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
|
||||||
"backend:stop": "node config/stop-backend.js",
|
"backend:stop": "node config/stop-backend.js",
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,80 @@ export const revertAgentVersion = ({
|
||||||
version_index: number;
|
version_index: number;
|
||||||
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
|
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
|
||||||
|
|
||||||
|
/* Marketplace */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get agent categories with counts for marketplace tabs
|
||||||
|
*/
|
||||||
|
export const getAgentCategories = (): Promise<t.TMarketplaceCategory[]> => {
|
||||||
|
return request.get(endpoints.agents({ path: 'marketplace/categories' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get promoted/top picks agents with pagination
|
||||||
|
*/
|
||||||
|
export const getPromotedAgents = (params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
showAll?: string; // Add showAll parameter to get all shared agents instead of just promoted
|
||||||
|
}): Promise<a.AgentListResponse> => {
|
||||||
|
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<a.AgentListResponse> => {
|
||||||
|
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<a.AgentListResponse> => {
|
||||||
|
const { category, ...options } = params;
|
||||||
|
return request.get(
|
||||||
|
endpoints.agents({
|
||||||
|
path: `marketplace/category/${category}`,
|
||||||
|
options,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search agents in marketplace
|
||||||
|
*/
|
||||||
|
export const searchAgents = (params: {
|
||||||
|
q: string;
|
||||||
|
category?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<a.AgentListResponse> => {
|
||||||
|
return request.get(
|
||||||
|
endpoints.agents({
|
||||||
|
path: 'marketplace/search',
|
||||||
|
options: params,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/* Tools */
|
/* Tools */
|
||||||
|
|
||||||
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {
|
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,11 @@ export const defaultAgentFormValues = {
|
||||||
[Tools.execute_code]: false,
|
[Tools.execute_code]: false,
|
||||||
[Tools.file_search]: false,
|
[Tools.file_search]: false,
|
||||||
[Tools.web_search]: false,
|
[Tools.web_search]: false,
|
||||||
|
category: 'general',
|
||||||
|
support_contact: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageVisionTool: FunctionTool = {
|
export const ImageVisionTool: FunctionTool = {
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,11 @@ export type TCategory = {
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TMarketplaceCategory = TCategory & {
|
||||||
|
count: number;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TError = {
|
export type TError = {
|
||||||
message: string;
|
message: string;
|
||||||
code?: number | string;
|
code?: number | string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { Document, Types } from 'mongoose';
|
import { Document, Types } from 'mongoose';
|
||||||
|
|
||||||
|
export interface ISupportContact {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAgent extends Omit<Document, 'model'> {
|
export interface IAgent extends Omit<Document, 'model'> {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -28,4 +33,6 @@ export interface IAgent extends Omit<Document, 'model'> {
|
||||||
tool_resources?: unknown;
|
tool_resources?: unknown;
|
||||||
projectIds?: Types.ObjectId[];
|
projectIds?: Types.ObjectId[];
|
||||||
versions?: Omit<IAgent, 'versions'>[];
|
versions?: Omit<IAgent, 'versions'>[];
|
||||||
|
category: string;
|
||||||
|
support_contact?: ISupportContact;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue