🏪 feat: Agent Marketplace

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

refactored and moved agent category methods and schema to data-schema package

🔧 fix: Merge and Rebase Conflicts

- Move AgentCategory from api/models to @packages/data-schemas structure
  - Add schema, types, methods, and model following codebase conventions
  - Implement auto-seeding of default categories during AppService startup
  - Update marketplace controller to use new data-schemas methods
  - Remove old model file and standalone seed script

refactor: unify agent marketplace to single endpoint with cursor pagination

  - Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests

feat: add icon property to ProcessedAgentCategory interface

- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
  - Replace manual pagination in AgentGrid with infinite query pattern
  - Update imports to use local data provider instead of librechat-data-provider
  - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
  - Improve agent access control by adding requiredPermission validation in backend
  - Remove manual cursor/state management in favor of infinite query built-ins
  - Maintain existing search and category filtering functionality

refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency

  - Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
  - Add countPromotedAgents function to Agent model for promoted agents count
  - Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
  - Move getAgentCategories from marketplace to v1 controller with same functionality
  - Update agent mutations to invalidate marketplace queries and handle multiple permission levels
  - Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
  - Consolidate agent data access patterns for better maintainability and consistency
  - Remove duplicate marketplace route definitions and middleware

selected view only agents injected in the drop down

fix: remove minlength validation for support contact name in agent schema

feat: add validation and error messages for agent name in AgentConfig and AgentPanel

fix: update agent permission check logic in AgentPanel to simplify condition

Fix linting WIP

Fix Unit tests WIP

ESLint fixes

eslint fix

refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic

- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.

refactor: remove redundant properties from IAgent interface in agent schema

chore: update localization for agent detail component and clean up imports

ci: update access middleware tests

chore: remove unused PermissionTypes import from Role model

ci: update AclEntry model tests

ci: update button accessibility labels in AgentDetail tests

refactor: update exhaustive dep. lint warning

🔧 fix: Fixed agent actions access

feat: Add role-level permissions for agent sharing people picker

  - Add PEOPLE_PICKER permission type with VIEW_USERS and VIEW_GROUPS permissions
  - Create custom middleware for query-aware permission validation
  - Implement permission-based type filtering in PeoplePicker component
  - Hide people picker UI when user lacks permissions, show only public toggle
  - Support granular access: users-only, groups-only, or mixed search modes

refactor: Replace marketplace interface config with permission-based system

  - Add MARKETPLACE permission type to handle marketplace access control
  - Update interface configuration to use role-based marketplace settings (admin/user)
  - Replace direct marketplace boolean config with permission-based checks
  - Modify frontend components to use marketplace permissions instead of interface config
  - Update agent query hooks to use marketplace permissions for determining permission levels
  - Add marketplace configuration structure similar to peoplePicker in YAML config
  - Backend now sets MARKETPLACE permissions based on interface configuration
  - When marketplace enabled: users get agents with EDIT permissions in dropdown lists  (builder mode)
  - When marketplace disabled: users get agents with VIEW permissions  in dropdown lists (browse mode)

🔧 fix: Redirect to New Chat if No Marketplace Access and Required Agent Name Placeholder (#8213)

* Fix: Fix the redirect to new chat page if access to marketplace is denied

* Fixed the required agent name placeholder

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>

chore: fix tests, remove unnecessary imports

refactor: Implement permission checks for file access via agents

- Updated `hasAccessToFilesViaAgent` to utilize permission checks for VIEW and EDIT access.
- Replaced project-based access validation with permission-based checks.
- Enhanced tests to cover new permission logic and ensure proper access control for files associated with agents.
- Cleaned up imports and initialized models in test files for consistency.

refactor: Enhance test setup and cleanup for file access control

- Introduced modelsToCleanup array to track models added during tests for proper cleanup.
- Updated afterAll hooks in test files to ensure all collections are cleared and only added models are deleted.
- Improved consistency in model initialization across test files.
- Added comments for clarity on cleanup processes and test data management.

chore: Update Jest configuration and test setup for improved timeout handling

- Added a global test timeout of 30 seconds in jest.config.js.
- Configured jest.setTimeout in jestSetup.js to allow individual test overrides if needed.
- Enhanced test reliability by ensuring consistent timeout settings across all tests.

refactor: Implement file access filtering based on agent permissions

- Introduced `filterFilesByAgentAccess` function to filter files based on user access through agents.
- Updated `getFiles` and `primeFiles` functions to utilize the new filtering logic.
- Moved `hasAccessToFilesViaAgent` function from the File model to permission services, adjusting imports accordingly
- Enhanced tests to ensure proper access control and filtering behavior for files associated with agents.

fix: make support_contact field a nested object rather than a sub-document

refactor: Update support_contact field initialization in agent model

- Removed handling for empty support_contact object in createAgent function.
- Changed default value of support_contact in agent schema to undefined.

test: Add comprehensive tests for support_contact field handling and versioning

refactor: remove unused avatar upload mutation field and add informational toast for success

chore: add missing SidePanelProvider for AgentMarketplace and organize imports

fix: resolve agent selection race condition in marketplace HandleStartChat
- Set agent in localStorage before newConversation to prevent useSelectorEffects from auto-selecting previous agent

fix: resolve agent dropdown showing raw ID instead of agent info from URL

  - Add proactive agent fetching when agent_id is present in URL parameters
  - Inject fetched agent into agents cache so dropdowns display proper name/avatar
  - Use useAgentsMap dependency to ensure proper cache initialization timing
  - Prevents raw agent IDs from showing in UI when visiting shared agent links

Fix: Agents endpoint renamed to "My Agent" for less confusion with the Marketplace agents.

chore: fix ESLint issues and Test Mocks

ci: update permissions structure in loadDefaultInterface tests

- Refactored permissions for MEMORY and added new permissions for MARKETPLACE and PEOPLE_PICKER.
- Ensured consistent structure for permissions across different types.

feat:  support_contact validation to allow empty email strings
This commit is contained in:
“Praneeth 2025-06-11 22:55:07 +05:30 committed by Danny Avila
parent 66bd419baa
commit 949682ef0f
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
95 changed files with 3770 additions and 2728 deletions

View file

@ -3,6 +3,7 @@ const axios = require('axios');
const { tool } = require('@langchain/core/tools'); const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { Tools, EToolResources } = require('librechat-data-provider'); const { Tools, EToolResources } = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { generateShortLivedToken } = require('~/server/services/AuthService'); const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getFiles } = require('~/models/File'); const { getFiles } = require('~/models/File');
@ -22,14 +23,19 @@ const primeFiles = async (options) => {
const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? []; const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
const agentResourceIds = new Set(file_ids); const agentResourceIds = new Set(file_ids);
const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? []; const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
const dbFiles = (
(await getFiles( // Get all files first
{ file_id: { $in: file_ids } }, const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
null,
{ text: 0 }, // Filter by access if user and agent are provided
{ userId: req?.user?.id, agentId }, let dbFiles;
)) ?? [] if (req?.user?.id && agentId) {
).concat(resourceFiles); dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId);
} else {
dbFiles = allFiles;
}
dbFiles = dbFiles.concat(resourceFiles);
let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`; let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;

View file

@ -3,6 +3,7 @@ module.exports = {
clearMocks: true, clearMocks: true,
roots: ['<rootDir>'], roots: ['<rootDir>'],
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
testTimeout: 30000, // 30 seconds timeout for all tests
setupFiles: [ setupFiles: [
'./test/jestSetup.js', './test/jestSetup.js',
'./test/__mocks__/logger.js', './test/__mocks__/logger.js',

View file

@ -5,7 +5,6 @@ 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;
// Default category value for new agents // Default category value for new agents
const AgentCategory = require('./AgentCategory');
const { const {
getProjectByName, getProjectByName,
addAgentIdsToProject, addAgentIdsToProject,
@ -15,80 +14,7 @@ const {
const { getCachedTools } = require('~/server/services/Config'); const { getCachedTools } = require('~/server/services/Config');
// Category values are now imported from shared constants // Category values are now imported from shared constants
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
// 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');
@ -112,6 +38,7 @@ const createAgent = async (agentData) => {
], ],
category: agentData.category || 'general', category: agentData.category || 'general',
}; };
return (await Agent.create(initialAgentData)).toObject(); return (await Agent.create(initialAgentData)).toObject();
}; };
@ -257,54 +184,116 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
let isMatch = true; let isMatch = true;
for (const field of importantFields) { for (const field of importantFields) {
if (!wouldBeVersion[field] && !lastVersion[field]) { const wouldBeValue = wouldBeVersion[field];
const lastVersionValue = lastVersion[field];
// Skip if both are undefined/null
if (!wouldBeValue && !lastVersionValue) {
continue; continue;
} }
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) { // Handle arrays
if (wouldBeVersion[field].length !== lastVersion[field].length) { if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
// Normalize: treat undefined/null as empty array for comparison
let wouldBeArr;
if (Array.isArray(wouldBeValue)) {
wouldBeArr = wouldBeValue;
} else if (wouldBeValue == null) {
wouldBeArr = [];
} else {
wouldBeArr = [wouldBeValue];
}
let lastVersionArr;
if (Array.isArray(lastVersionValue)) {
lastVersionArr = lastVersionValue;
} else if (lastVersionValue == null) {
lastVersionArr = [];
} else {
lastVersionArr = [lastVersionValue];
}
if (wouldBeArr.length !== lastVersionArr.length) {
isMatch = false; isMatch = false;
break; break;
} }
// Special handling for projectIds (MongoDB ObjectIds) // Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') { if (field === 'projectIds') {
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort(); const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort(); const versionIds = lastVersionArr.map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) { if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false; isMatch = false;
break; break;
} }
} }
// Handle arrays of objects like tool_kwargs // Handle arrays of objects
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) { else if (
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort(); wouldBeArr.length > 0 &&
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort(); typeof wouldBeArr[0] === 'object' &&
wouldBeArr[0] !== null
) {
const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) { if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false; isMatch = false;
break; break;
} }
} else { } else {
const sortedWouldBe = [...wouldBeVersion[field]].sort(); const sortedWouldBe = [...wouldBeArr].sort();
const sortedVersion = [...lastVersion[field]].sort(); const sortedVersion = [...lastVersionArr].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) { if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false; isMatch = false;
break; break;
} }
} }
} else if (field === 'model_parameters') { }
const wouldBeParams = wouldBeVersion[field] || {}; // Handle objects
const lastVersionParams = lastVersion[field] || {}; else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) { const lastVersionObj =
typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
// For empty objects, normalize the comparison
const wouldBeKeys = Object.keys(wouldBeValue);
const lastVersionKeys = Object.keys(lastVersionObj);
// If both are empty objects, they're equal
if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
continue;
}
// Otherwise do a deep comparison
if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
isMatch = false;
break;
}
}
// Handle primitive values
else {
// For primitives, handle the case where one is undefined and the other is a default value
if (wouldBeValue !== lastVersionValue) {
// Special handling for boolean false vs undefined
if (
typeof wouldBeValue === 'boolean' &&
wouldBeValue === false &&
lastVersionValue === undefined
) {
continue;
}
// Special handling for empty string vs undefined
if (
typeof wouldBeValue === 'string' &&
wouldBeValue === '' &&
lastVersionValue === undefined
) {
continue;
}
isMatch = false; isMatch = false;
break; break;
} }
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
} }
} }
@ -558,7 +547,7 @@ const getListAgentsByAccess = async ({
const cursorCondition = { const cursorCondition = {
$or: [ $or: [
{ updatedAt: { $lt: new Date(updatedAt) } }, { updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: mongoose.Types.ObjectId(_id) } }, { updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
], ],
}; };
@ -586,6 +575,9 @@ const getListAgentsByAccess = async ({
projectIds: 1, projectIds: 1,
description: 1, description: 1,
updatedAt: 1, updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 }); }).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested // Only apply limit if pagination is requested
@ -820,6 +812,14 @@ const generateActionMetadataHash = async (actionIds, actions) => {
return hashHex; return hashHex;
}; };
/**
* Counts the number of promoted agents.
* @returns {Promise<number>} - The count of promoted agents
*/
const countPromotedAgents = async () => {
const count = await Agent.countDocuments({ is_promoted: true });
return count;
};
/** /**
* Load a default agent based on the endpoint * Load a default agent based on the endpoint
@ -840,4 +840,5 @@ module.exports = {
getListAgentsByAccess, getListAgentsByAccess,
removeAgentResourceFiles, removeAgentResourceFiles,
generateActionMetadataHash, generateActionMetadataHash,
countPromotedAgents,
}; };

View file

@ -1237,6 +1237,328 @@ describe('models/Agent', () => {
expect(secondUpdate.versions).toHaveLength(3); expect(secondUpdate.versions).toHaveLength(3);
}); });
test('should detect changes in support_contact fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with initial support_contact
await createAgent({
id: agentId,
name: 'Agent with Support Contact',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Initial Support',
email: 'initial@support.com',
},
});
// Update support_contact name only
const firstUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'initial@support.com',
},
},
);
expect(firstUpdate.versions).toHaveLength(2);
expect(firstUpdate.support_contact.name).toBe('Updated Support');
expect(firstUpdate.support_contact.email).toBe('initial@support.com');
// Update support_contact email only
const secondUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
},
);
expect(secondUpdate.versions).toHaveLength(3);
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
// Try to update with same support_contact - should be detected as duplicate
await expect(
updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
},
),
).rejects.toThrow('Duplicate version');
});
test('should handle support_contact from empty to populated', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent without support_contact
const agent = await createAgent({
id: agentId,
name: 'Agent without Support',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Verify support_contact is undefined since it wasn't provided
expect(agent.support_contact).toBeUndefined();
// Update to add support_contact
const updated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Support Team',
email: 'support@example.com',
},
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.support_contact.name).toBe('New Support Team');
expect(updated.support_contact.email).toBe('support@example.com');
});
test('should handle support_contact edge cases in isDuplicateVersion', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with support_contact
await createAgent({
id: agentId,
name: 'Edge Case Agent',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Support',
email: 'support@test.com',
},
});
// Update to empty support_contact
const emptyUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {},
},
);
expect(emptyUpdate.versions).toHaveLength(2);
expect(emptyUpdate.support_contact).toEqual({});
// Update back to populated support_contact
const repopulated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Support',
email: 'support@test.com',
},
},
);
expect(repopulated.versions).toHaveLength(3);
// Verify all versions have correct support_contact
const finalAgent = await getAgent({ id: agentId });
expect(finalAgent.versions[0].support_contact).toEqual({
name: 'Support',
email: 'support@test.com',
});
expect(finalAgent.versions[1].support_contact).toEqual({});
expect(finalAgent.versions[2].support_contact).toEqual({
name: 'Support',
email: 'support@test.com',
});
});
test('should preserve support_contact in version history', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Version History Test',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Initial Contact',
email: 'initial@test.com',
},
});
// Multiple updates with different support_contact values
await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Second Contact',
email: 'second@test.com',
},
},
);
await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Third Contact',
email: 'third@test.com',
},
},
);
const finalAgent = await getAgent({ id: agentId });
// Verify version history
expect(finalAgent.versions).toHaveLength(3);
expect(finalAgent.versions[0].support_contact).toEqual({
name: 'Initial Contact',
email: 'initial@test.com',
});
expect(finalAgent.versions[1].support_contact).toEqual({
name: 'Second Contact',
email: 'second@test.com',
});
expect(finalAgent.versions[2].support_contact).toEqual({
name: 'Third Contact',
email: 'third@test.com',
});
// Current state should match last version
expect(finalAgent.support_contact).toEqual({
name: 'Third Contact',
email: 'third@test.com',
});
});
test('should handle partial support_contact updates', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with full support_contact
await createAgent({
id: agentId,
name: 'Partial Update Test',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Original Name',
email: 'original@email.com',
},
});
// MongoDB's findOneAndUpdate will replace the entire support_contact object
// So we need to verify that partial updates still work correctly
const updated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '', // Empty email
},
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.support_contact.name).toBe('New Name');
expect(updated.support_contact.email).toBe('');
// Verify isDuplicateVersion works with partial changes
await expect(
updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '',
},
},
),
).rejects.toThrow('Duplicate version');
});
// Edge Cases
describe.each([
{
operation: 'add',
name: 'empty file_id',
needsAgent: true,
params: { tool_resource: 'file_search', file_id: '' },
shouldResolve: true,
},
{
operation: 'add',
name: 'non-existent agent',
needsAgent: false,
params: { tool_resource: 'file_search', file_id: 'file123' },
shouldResolve: false,
error: 'Agent not found for adding resource file',
},
])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
} else {
await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
}
});
});
describe.each([
{
name: 'empty files array',
files: [],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent tool_resource',
files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent agent',
files: [{ tool_resource: 'file_search', file_id: 'file123' }],
needsAgent: false,
shouldResolve: false,
error: 'Agent not found for removing resource files',
},
])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
const result = await removeAgentResourceFiles({ agent_id, files });
expect(result).toBeDefined();
if (agent) {
expect(result.id).toBe(agent.id);
}
} else {
await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
}
});
});
describe('Edge Cases', () => { describe('Edge Cases', () => {
test('should handle extremely large version history', async () => { test('should handle extremely large version history', async () => {
const agentId = `agent_${uuidv4()}`; const agentId = `agent_${uuidv4()}`;
@ -2565,6 +2887,93 @@ describe('models/Agent', () => {
}); });
}); });
describe('Support Contact Field', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
it('should not create subdocument with ObjectId for support_contact', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
};
// Create agent
const agent = await createAgent(agentData);
// Verify support_contact is stored correctly
expect(agent.support_contact).toBeDefined();
expect(agent.support_contact.name).toBe('Support Team');
expect(agent.support_contact.email).toBe('support@example.com');
// Verify no _id field is created in support_contact
expect(agent.support_contact._id).toBeUndefined();
// Fetch from database to double-check
const dbAgent = await Agent.findOne({ id: agentData.id });
expect(dbAgent.support_contact).toBeDefined();
expect(dbAgent.support_contact.name).toBe('Support Team');
expect(dbAgent.support_contact.email).toBe('support@example.com');
expect(dbAgent.support_contact._id).toBeUndefined();
});
it('should handle empty support_contact correctly', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_empty_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
support_contact: {},
};
const agent = await createAgent(agentData);
// Verify empty support_contact is stored as empty object
expect(agent.support_contact).toEqual({});
expect(agent.support_contact._id).toBeUndefined();
});
it('should handle missing support_contact correctly', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_no_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
};
const agent = await createAgent(agentData);
// Verify support_contact is undefined when not provided
expect(agent.support_contact).toBeUndefined();
});
});
function createBasicAgent(overrides = {}) { function createBasicAgent(overrides = {}) {
const defaults = { const defaults = {
id: `agent_${uuidv4()}`, id: `agent_${uuidv4()}`,

View file

@ -1,125 +0,0 @@
const mongoose = require('mongoose');
/**
* AgentCategory Schema - Dynamic agent category management
* Focused implementation for core features only
*/
const agentCategorySchema = new mongoose.Schema(
{
// Unique identifier for the category (e.g., 'general', 'hr', 'finance')
value: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
index: true,
},
// Display label for the category
label: {
type: String,
required: true,
trim: true,
},
// Description of the category
description: {
type: String,
trim: true,
default: '',
},
// Display order for sorting categories
order: {
type: Number,
default: 0,
index: true,
},
// Whether the category is active and should be displayed
isActive: {
type: Boolean,
default: true,
index: true,
},
},
{
timestamps: true,
}
);
// Indexes for performance
agentCategorySchema.index({ isActive: 1, order: 1 });
/**
* Get all active categories sorted by order
* @returns {Promise<AgentCategory[]>} Array of active categories
*/
agentCategorySchema.statics.getActiveCategories = function() {
return this.find({ isActive: true })
.sort({ order: 1, label: 1 })
.lean();
};
/**
* Get categories with agent counts
* @returns {Promise<AgentCategory[]>} Categories with agent counts
*/
agentCategorySchema.statics.getCategoriesWithCounts = async function() {
const Agent = mongoose.model('agent');
// Aggregate to get agent counts per category
const categoryCounts = await Agent.aggregate([
{ $match: { category: { $exists: true, $ne: null } } },
{ $group: { _id: '$category', count: { $sum: 1 } } },
]);
// Create a map for quick lookup
const countMap = new Map(categoryCounts.map(c => [c._id, c.count]));
// Get all active categories and add counts
const categories = await this.getActiveCategories();
return categories.map(category => ({
...category,
agentCount: countMap.get(category.value) || 0,
}));
};
/**
* Get valid category values for Agent model validation
* @returns {Promise<string[]>} Array of valid category values
*/
agentCategorySchema.statics.getValidCategoryValues = function() {
return this.find({ isActive: true })
.distinct('value')
.lean();
};
/**
* Seed initial categories from existing constants
*/
agentCategorySchema.statics.seedCategories = async function(categories) {
const operations = categories.map((category, index) => ({
updateOne: {
filter: { value: category.value },
update: {
$setOnInsert: {
value: category.value,
label: category.label || category.value,
description: category.description || '',
order: category.order || index,
isActive: true,
},
},
upsert: true,
},
}));
return this.bulkWrite(operations);
};
const AgentCategory = mongoose.model('AgentCategory', agentCategorySchema);
module.exports = AgentCategory;

View file

@ -1,7 +1,5 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { EToolResources, FileContext, Constants } = require('librechat-data-provider'); const { EToolResources, FileContext } = require('librechat-data-provider');
const { getProjectByName } = require('./Project');
const { getAgent } = require('./Agent');
const { File } = require('~/db/models'); const { File } = require('~/db/models');
/** /**
@ -14,124 +12,17 @@ const findFileById = async (file_id, options = {}) => {
return await File.findOne({ file_id, ...options }).lean(); return await File.findOne({ file_id, ...options }).lean();
}; };
/**
* Checks if a user has access to multiple files through a shared agent (batch operation)
* @param {string} userId - The user ID to check access for
* @param {string[]} fileIds - Array of file IDs to check
* @param {string} agentId - The agent ID that might grant access
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
*/
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId, checkCollaborative = true) => {
const accessMap = new Map();
// Initialize all files as no access
fileIds.forEach((fileId) => accessMap.set(fileId, false));
try {
const agent = await getAgent({ id: agentId });
if (!agent) {
return accessMap;
}
// Check if user is the author - if so, grant access to all files
if (agent.author.toString() === userId) {
fileIds.forEach((fileId) => accessMap.set(fileId, true));
return accessMap;
}
// Check if agent is shared with the user via projects
if (!agent.projectIds || agent.projectIds.length === 0) {
return accessMap;
}
// Check if agent is in global project
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
if (
!globalProject ||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString())
) {
return accessMap;
}
// Agent is globally shared - check if it's collaborative
if (checkCollaborative && !agent.isCollaborative) {
return accessMap;
}
// Check which files are actually attached
const attachedFileIds = new Set();
if (agent.tool_resources) {
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
}
}
}
// Grant access only to files that are attached to this agent
fileIds.forEach((fileId) => {
if (attachedFileIds.has(fileId)) {
accessMap.set(fileId, true);
}
});
return accessMap;
} catch (error) {
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
return accessMap;
}
};
/** /**
* Retrieves files matching a given filter, sorted by the most recently updated. * Retrieves files matching a given filter, sorted by the most recently updated.
* @param {Object} filter - The filter criteria to apply. * @param {Object} filter - The filter criteria to apply.
* @param {Object} [_sortOptions] - Optional sort parameters. * @param {Object} [_sortOptions] - Optional sort parameters.
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results. * @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
* Default excludes the 'text' field. * Default excludes the 'text' field.
* @param {Object} [options] - Additional options
* @param {string} [options.userId] - User ID for access control
* @param {string} [options.agentId] - Agent ID that might grant access to files
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents. * @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
*/ */
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => { const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
const sortOptions = { updatedAt: -1, ..._sortOptions }; const sortOptions = { updatedAt: -1, ..._sortOptions };
const files = await File.find(filter).select(selectFields).sort(sortOptions).lean(); return await File.find(filter).select(selectFields).sort(sortOptions).lean();
// If userId and agentId are provided, filter files based on access
if (options.userId && options.agentId) {
// Collect file IDs that need access check
const filesToCheck = [];
const ownedFiles = [];
for (const file of files) {
if (file.user && file.user.toString() === options.userId) {
ownedFiles.push(file);
} else {
filesToCheck.push(file);
}
}
if (filesToCheck.length === 0) {
return ownedFiles;
}
// Batch check access for all non-owned files
const fileIds = filesToCheck.map((f) => f.file_id);
const accessMap = await hasAccessToFilesViaAgent(
options.userId,
fileIds,
options.agentId,
false,
);
// Filter files based on access
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
return [...ownedFiles, ...accessibleFiles];
}
return files;
}; };
/** /**
@ -285,5 +176,4 @@ module.exports = {
deleteFiles, deleteFiles,
deleteFileByFilter, deleteFileByFilter,
batchUpdateFiles, batchUpdateFiles,
hasAccessToFilesViaAgent,
}; };

View file

@ -1,17 +1,17 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { fileSchema } = require('@librechat/data-schemas');
const { agentSchema } = require('@librechat/data-schemas');
const { projectSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; const { createModels } = require('@librechat/data-schemas');
const { getFiles, createFile } = require('./File'); const { getFiles, createFile } = require('./File');
const { getProjectByName } = require('./Project');
const { createAgent } = require('./Agent'); const { createAgent } = require('./Agent');
const { grantPermission } = require('~/server/services/PermissionService');
const { seedDefaultRoles } = require('~/models');
let File; let File;
let Agent; let Agent;
let Project; let AclEntry;
let User;
let modelsToCleanup = [];
describe('File Access Control', () => { describe('File Access Control', () => {
let mongoServer; let mongoServer;
@ -19,13 +19,41 @@ describe('File Access Control', () => {
beforeAll(async () => { beforeAll(async () => {
mongoServer = await MongoMemoryServer.create(); mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri(); const mongoUri = mongoServer.getUri();
File = mongoose.models.File || mongoose.model('File', fileSchema);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
Project = mongoose.models.Project || mongoose.model('Project', projectSchema);
await mongoose.connect(mongoUri); await mongoose.connect(mongoUri);
// Initialize all models
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
const dbModels = require('~/db/models');
Object.assign(mongoose.models, dbModels);
File = dbModels.File;
Agent = dbModels.Agent;
AclEntry = dbModels.AclEntry;
User = dbModels.User;
// Seed default roles
await seedDefaultRoles();
}); });
afterAll(async () => { afterAll(async () => {
// Clean up all collections before disconnecting
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
// Clear only the models we added
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect(); await mongoose.disconnect();
await mongoServer.stop(); await mongoServer.stop();
}); });
@ -33,16 +61,33 @@ describe('File Access Control', () => {
beforeEach(async () => { beforeEach(async () => {
await File.deleteMany({}); await File.deleteMany({});
await Agent.deleteMany({}); await Agent.deleteMany({});
await Project.deleteMany({}); await AclEntry.deleteMany({});
await User.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
}); });
describe('hasAccessToFilesViaAgent', () => { describe('hasAccessToFilesViaAgent', () => {
it('should efficiently check access for multiple files at once', async () => { it('should efficiently check access for multiple files at once', async () => {
const userId = new mongoose.Types.ObjectId().toString(); const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId().toString(); const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4(); const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()]; const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create files // Create files
for (const fileId of fileIds) { for (const fileId of fileIds) {
await createFile({ await createFile({
@ -54,13 +99,12 @@ describe('File Access Control', () => {
} }
// Create agent with only first two files attached // Create agent with only first two files attached
await createAgent({ const agent = await createAgent({
id: agentId, id: agentId,
name: 'Test Agent', name: 'Test Agent',
author: authorId, author: authorId,
model: 'gpt-4', model: 'gpt-4',
provider: 'openai', provider: 'openai',
isCollaborative: true,
tool_resources: { tool_resources: {
file_search: { file_search: {
file_ids: [fileIds[0], fileIds[1]], file_ids: [fileIds[0], fileIds[1]],
@ -68,15 +112,19 @@ describe('File Access Control', () => {
}, },
}); });
// Get or create global project // Grant EDIT permission to user on the agent
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); await grantPermission({
principalType: 'user',
// Share agent globally principalId: userId,
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
// Check access for all files // Check access for all files
const { hasAccessToFilesViaAgent } = require('./File'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
// Should have access only to the first two files // Should have access only to the first two files
expect(accessMap.get(fileIds[0])).toBe(true); expect(accessMap.get(fileIds[0])).toBe(true);
@ -86,10 +134,18 @@ describe('File Access Control', () => {
}); });
it('should grant access to all files when user is the agent author', async () => { it('should grant access to all files when user is the agent author', async () => {
const authorId = new mongoose.Types.ObjectId().toString(); const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4(); const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4()]; const fileIds = [uuidv4(), uuidv4(), uuidv4()];
// Create author user
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent // Create agent
await createAgent({ await createAgent({
id: agentId, id: agentId,
@ -105,8 +161,8 @@ describe('File Access Control', () => {
}); });
// Check access as the author // Check access as the author
const { hasAccessToFilesViaAgent } = require('./File'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId); const accessMap = await hasAccessToFilesViaAgent(authorId.toString(), fileIds, agentId);
// Author should have access to all files // Author should have access to all files
expect(accessMap.get(fileIds[0])).toBe(true); expect(accessMap.get(fileIds[0])).toBe(true);
@ -115,31 +171,57 @@ describe('File Access Control', () => {
}); });
it('should handle non-existent agent gracefully', async () => { it('should handle non-existent agent gracefully', async () => {
const userId = new mongoose.Types.ObjectId().toString(); const userId = new mongoose.Types.ObjectId();
const fileIds = [uuidv4(), uuidv4()]; const fileIds = [uuidv4(), uuidv4()];
const { hasAccessToFilesViaAgent } = require('./File'); // Create user
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent'); await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(
userId.toString(),
fileIds,
'non-existent-agent',
);
// Should have no access to any files // Should have no access to any files
expect(accessMap.get(fileIds[0])).toBe(false); expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false); expect(accessMap.get(fileIds[1])).toBe(false);
}); });
it('should deny access when agent is not collaborative', async () => { it('should deny access when user only has VIEW permission', async () => {
const userId = new mongoose.Types.ObjectId().toString(); const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId().toString(); const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4(); const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4()]; const fileIds = [uuidv4(), uuidv4()];
// Create agent with files but isCollaborative: false // Create users
await createAgent({ await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with files
const agent = await createAgent({
id: agentId, id: agentId,
name: 'Non-Collaborative Agent', name: 'View-Only Agent',
author: authorId, author: authorId,
model: 'gpt-4', model: 'gpt-4',
provider: 'openai', provider: 'openai',
isCollaborative: false,
tool_resources: { tool_resources: {
file_search: { file_search: {
file_ids: fileIds, file_ids: fileIds,
@ -147,17 +229,21 @@ describe('File Access Control', () => {
}, },
}); });
// Get or create global project // Grant only VIEW permission to user on the agent
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); await grantPermission({
principalType: 'user',
// Share agent globally principalId: userId,
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_viewer',
grantedBy: authorId,
});
// Check access for files // Check access for files
const { hasAccessToFilesViaAgent } = require('./File'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
// Should have no access to any files when isCollaborative is false // Should have no access to any files when only VIEW permission
expect(accessMap.get(fileIds[0])).toBe(false); expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false); expect(accessMap.get(fileIds[1])).toBe(false);
}); });
@ -172,18 +258,28 @@ describe('File Access Control', () => {
const sharedFileId = `file_${uuidv4()}`; const sharedFileId = `file_${uuidv4()}`;
const inaccessibleFileId = `file_${uuidv4()}`; const inaccessibleFileId = `file_${uuidv4()}`;
// Create/get global project using getProjectByName which will upsert // Create users
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME); await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with shared file // Create agent with shared file
await createAgent({ const agent = await createAgent({
id: agentId, id: agentId,
name: 'Shared Agent', name: 'Shared Agent',
provider: 'test', provider: 'test',
model: 'test-model', model: 'test-model',
author: authorId, author: authorId,
projectIds: [globalProject._id],
isCollaborative: true,
tool_resources: { tool_resources: {
file_search: { file_search: {
file_ids: [sharedFileId], file_ids: [sharedFileId],
@ -191,6 +287,16 @@ describe('File Access Control', () => {
}, },
}); });
// Grant EDIT permission to user on the agent
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
// Create files // Create files
await createFile({ await createFile({
file_id: ownedFileId, file_id: ownedFileId,
@ -220,14 +326,17 @@ describe('File Access Control', () => {
bytes: 300, bytes: 300,
}); });
// Get files with access control // Get all files first
const files = await getFiles( const allFiles = await getFiles(
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } }, { file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
null, null,
{ text: 0 }, { text: 0 },
{ userId: userId.toString(), agentId },
); );
// Then filter by access control
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const files = await filterFilesByAgentAccess(allFiles, userId.toString(), agentId);
expect(files).toHaveLength(2); expect(files).toHaveLength(2);
expect(files.map((f) => f.file_id)).toContain(ownedFileId); expect(files.map((f) => f.file_id)).toContain(ownedFileId);
expect(files.map((f) => f.file_id)).toContain(sharedFileId); expect(files.map((f) => f.file_id)).toContain(sharedFileId);

View file

@ -2,7 +2,6 @@ const {
CacheKeys, CacheKeys,
SystemRoles, SystemRoles,
roleDefaults, roleDefaults,
PermissionTypes,
permissionsSchema, permissionsSchema,
removeNullishValues, removeNullishValues,
} = require('librechat-data-provider'); } = require('librechat-data-provider');

View file

@ -1,255 +0,0 @@
const AgentCategory = require('~/models/AgentCategory');
const mongoose = require('mongoose');
const { logger } = require('~/config');
// Get the Agent model
const Agent = mongoose.model('agent');
// Default page size for agent browsing
const DEFAULT_PAGE_SIZE = 6;
/**
* Common pagination utility for agent queries
*
* @param {Object} filter - MongoDB filter object
* @param {number} page - Page number (1-based)
* @param {number} limit - Items per page
* @returns {Promise<Object>} Paginated results with agents and pagination info
*/
const paginateAgents = async (filter, page = 1, limit = DEFAULT_PAGE_SIZE) => {
const skip = (page - 1) * limit;
// Get total count for pagination
const total = await Agent.countDocuments(filter);
// Get agents with pagination
const agents = await Agent.find(filter)
.select('id name description avatar category support_contact authorName')
.sort({ updatedAt: -1 })
.skip(skip)
.limit(limit)
.lean();
// Calculate if there are more agents to load
const hasMore = total > page * limit;
return {
agents,
pagination: {
current: page,
hasMore,
total,
},
};
};
/**
* Get promoted/top picks agents with pagination
* Can also return all agents when showAll=true parameter is provided
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getPromotedAgents = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
// Check if this is a request for "all" agents via query parameter
const showAllAgents = req.query.showAll === 'true';
// Base filter for shared agents only
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
// Only add promoted filter if not requesting all agents
if (!showAllAgents) {
filter.is_promoted = true; // Only get promoted agents
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json(result);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching promoted agents:', error);
res.status(500).json({
error: 'Failed to fetch promoted agents',
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
/**
* Get agents by category with pagination
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAgentsByCategory = async (req, res) => {
try {
const { category } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
const filter = {
category,
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
const result = await paginateAgents(filter, page, limit);
// Get category description from database
const categoryDoc = await AgentCategory.findOne({ value: category, isActive: true });
const categoryInfo = {
name: category,
description: categoryDoc?.description || '',
total: result.pagination.total,
};
res.status(200).json({
...result,
category: categoryInfo,
});
} catch (error) {
logger.error(
`[/Agents/Marketplace] Error fetching agents for category ${req.params.category}:`,
error,
);
res.status(500).json({
error: 'Failed to fetch agents by category',
userMessage: `Unable to load agents for this category. Please try a different category.`,
suggestion: 'Try selecting a different category or refresh the page',
});
}
};
/**
* Search agents with filters
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const searchAgents = async (req, res) => {
try {
const { q, category } = req.query;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
if (!q || q.trim() === '') {
return res.status(400).json({
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
});
}
// Build search filter
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
$or: [
{ name: { $regex: q, $options: 'i' } }, // Case-insensitive name search
{ description: { $regex: q, $options: 'i' } },
],
};
// Add category filter if provided
if (category && category !== 'all') {
filter.category = category;
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json({
...result,
query: q,
});
} catch (error) {
logger.error('[/Agents/Marketplace] Error searching agents:', error);
res.status(500).json({
error: 'Failed to search agents',
userMessage: 'Search is temporarily unavailable. Please try again.',
suggestion: 'Try a different search term or check your network connection',
});
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
// Get categories with agent counts from database
const categories = await AgentCategory.getCategoriesWithCounts();
// Get count of promoted agents for Top Picks
const promotedCount = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
is_promoted: true,
});
// Convert to marketplace format (TCategory structure)
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
// Add promoted category if agents exist
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
// Get total count of all shared agents for "All" category
const totalAgents = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
});
// Add "All" category at the end
formattedCategories.push({
value: 'all',
label: 'All',
count: totalAgents,
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
/**
* Get all agents with pagination (for "all" category)
* This is an alias for getPromotedAgents with showAll=true for backwards compatibility
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAllAgents = async (req, res) => {
// Set showAll parameter and delegate to getPromotedAgents
req.query.showAll = 'true';
return getPromotedAgents(req, res);
};
module.exports = {
getPromotedAgents,
getAgentsByCategory,
searchAgents,
getAgentCategories,
getAllAgents,
};

View file

@ -17,6 +17,8 @@ const {
updateAgent, updateAgent,
deleteAgent, deleteAgent,
getListAgentsByAccess, getListAgentsByAccess,
countPromotedAgents,
revertAgentVersion,
} = require('~/models/Agent'); } = require('~/models/Agent');
const { const {
grantPermission, grantPermission,
@ -30,8 +32,8 @@ const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process'); const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action'); const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config'); const { getCachedTools } = require('~/server/services/Config');
const { revertAgentVersion } = require('~/models/Agent');
const { deleteFileByFilter } = require('~/models/File'); const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models');
const systemTools = { const systemTools = {
[Tools.execute_code]: true, [Tools.execute_code]: true,
@ -45,7 +47,7 @@ const systemTools = {
* @param {ServerRequest} req - The request object. * @param {ServerRequest} req - The request object.
* @param {AgentCreateParams} req.body - The request body. * @param {AgentCreateParams} req.body - The request body.
* @param {ServerResponse} res - The response object. * @param {ServerResponse} res - The response object.
* @returns {Agent} 201 - success response - application/json * @returns {Promise<Agent>} 201 - success response - application/json
*/ */
const createAgentHandler = async (req, res) => { const createAgentHandler = async (req, res) => {
try { try {
@ -402,12 +404,43 @@ const deleteAgentHandler = async (req, res) => {
const getListAgentsHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { category, search, limit, cursor, promoted } = req.query;
let requiredPermission = req.query.requiredPermission;
if (typeof requiredPermission === 'string') {
requiredPermission = parseInt(requiredPermission, 10);
if (isNaN(requiredPermission)) {
requiredPermission = PermissionBits.VIEW;
}
} else if (typeof requiredPermission !== 'number') {
requiredPermission = PermissionBits.VIEW;
}
// Base filter
const filter = {};
// Handle category filter - only apply if category is defined
if (category !== undefined && category.trim() !== '') {
filter.category = category;
}
// Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
// Handle search filter
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Get agent IDs the user has VIEW access to via ACL // Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({ const accessibleIds = await findAccessibleResources({
userId, userId,
resourceType: 'agent', resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW, requiredPermissions: requiredPermission,
}); });
const publiclyAccessibleIds = await findPubliclyAccessibleResources({ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent', resourceType: 'agent',
@ -416,7 +449,9 @@ const getListAgentsHandler = async (req, res) => {
// Use the new ACL-aware function // Use the new ACL-aware function
const data = await getListAgentsByAccess({ const data = await getListAgentsByAccess({
accessibleIds, accessibleIds,
otherParams: {}, // Can add query params here if needed otherParams: filter,
limit,
after: cursor,
}); });
if (data?.data?.length) { if (data?.data?.length) {
data.data = data.data.map((agent) => { data.data = data.data.map((agent) => {
@ -592,7 +627,48 @@ const revertAgentVersionHandler = async (req, res) => {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}; };
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await getCategoriesWithCounts();
const promotedCount = await countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
formattedCategories.push({
value: 'all',
label: 'All',
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
module.exports = { module.exports = {
createAgent: createAgentHandler, createAgent: createAgentHandler,
getAgent: getAgentHandler, getAgent: getAgentHandler,
@ -602,4 +678,5 @@ module.exports = {
getListAgents: getListAgentsHandler, getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler, uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler, revertAgentVersion: revertAgentVersionHandler,
getAgentCategories,
}; };

View file

@ -235,6 +235,81 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.tool_resources.invalid_resource).toBeUndefined(); expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
}); });
test('should handle support_contact with empty strings', async () => {
const dataWithEmptyContact = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Empty Contact',
support_contact: {
name: '',
email: '',
},
};
mockReq.body = dataWithEmptyContact;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.name).toBe('Agent with Empty Contact');
expect(createdAgent.support_contact).toBeDefined();
expect(createdAgent.support_contact.name).toBe('');
expect(createdAgent.support_contact.email).toBe('');
});
test('should handle support_contact with valid email', async () => {
const dataWithValidContact = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Valid Contact',
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
};
mockReq.body = dataWithValidContact;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.support_contact).toBeDefined();
expect(createdAgent.support_contact.name).toBe('Support Team');
expect(createdAgent.support_contact.email).toBe('support@example.com');
});
test('should reject support_contact with invalid email', async () => {
const dataWithInvalidEmail = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Invalid Email',
support_contact: {
name: 'Support',
email: 'not-an-email',
},
};
mockReq.body = dataWithInvalidEmail;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Invalid request data',
details: expect.arrayContaining([
expect.objectContaining({
path: ['support_contact', 'email'],
}),
]),
}),
);
});
test('should handle avatar validation', async () => { test('should handle avatar validation', async () => {
const dataWithAvatar = { const dataWithAvatar = {
provider: 'openai', provider: 'openai',
@ -372,52 +447,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.id).toBe(existingAgentId); expect(agentInDb.id).toBe(existingAgentId);
}); });
test('should reject update from non-author when not collaborative', async () => {
const differentUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = differentUserId; // Different user
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Unauthorized Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'You do not have permission to modify this non-collaborative agent',
});
// Verify agent was not modified in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Original Agent');
});
test('should allow update from non-author when collaborative', async () => {
// First make the agent collaborative
await Agent.updateOne({ id: existingAgentId }, { isCollaborative: true });
const differentUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = differentUserId; // Different user
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Collaborative Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.name).toBe('Collaborative Update');
// Author field should be removed for non-author
expect(updatedAgent.author).toBeUndefined();
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Collaborative Update');
});
test('should allow admin to update any agent', async () => { test('should allow admin to update any agent', async () => {
const adminUserId = new mongoose.Types.ObjectId().toString(); const adminUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = adminUserId; mockReq.user.id = adminUserId;
@ -577,45 +606,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.__v).not.toBe(99); expect(agentInDb.__v).not.toBe(99);
}); });
test('should prevent privilege escalation through isCollaborative', async () => {
// Create a non-collaborative agent
const authorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
isCollaborative: false,
versions: [
{
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
// Try to make it collaborative as a different user
const attackerId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = attackerId;
mockReq.params.id = agent.id;
mockReq.body = {
isCollaborative: true, // Trying to escalate privileges
};
await updateAgentHandler(mockReq, mockRes);
// Should be rejected
expect(mockRes.status).toHaveBeenCalledWith(403);
// Verify in database that it's still not collaborative
const agentInDb = await Agent.findOne({ id: agent.id });
expect(agentInDb.isCollaborative).toBe(false);
});
test('should prevent author hijacking', async () => { test('should prevent author hijacking', async () => {
const originalAuthorId = new mongoose.Types.ObjectId(); const originalAuthorId = new mongoose.Types.ObjectId();
const attackerId = new mongoose.Types.ObjectId(); const attackerId = new mongoose.Types.ObjectId();

View file

@ -0,0 +1,72 @@
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Middleware to check if user has permission to access people picker functionality
* Checks specific permission based on the 'type' query parameter:
* - type=user: requires VIEW_USERS permission
* - type=group: requires VIEW_GROUPS permission
* - no type (mixed search): requires either VIEW_USERS OR VIEW_GROUPS
*/
const checkPeoplePickerAccess = async (req, res, next) => {
try {
const user = req.user;
if (!user || !user.role) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const role = await getRoleByName(user.role);
if (!role || !role.permissions) {
return res.status(403).json({
error: 'Forbidden',
message: 'No permissions configured for user role',
});
}
const { type } = req.query;
const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {};
const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true;
const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true;
if (type === 'user') {
if (!canViewUsers) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for users',
});
}
} else if (type === 'group') {
if (!canViewGroups) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for groups',
});
}
} else {
if (!canViewUsers || !canViewGroups) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for both users and groups',
});
}
}
next();
} catch (error) {
logger.error(
`[checkPeoplePickerAccess][${req.user?.id}] checkPeoplePickerAccess error for req.query.type = ${req.query.type}`,
error,
);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check permissions',
});
}
};
module.exports = {
checkPeoplePickerAccess,
};

View file

@ -1,17 +1,31 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('./access'); const { checkAccess, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { Role } = require('~/db/models'); const { Role } = require('~/db/models');
// Mock only the logger // Mock the logger from @librechat/data-schemas
jest.mock('~/config', () => ({ jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { logger: {
warn: jest.fn(), warn: jest.fn(),
error: jest.fn(), error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}, },
})); }));
// Mock the cache to use a simple in-memory implementation
const mockCache = new Map();
jest.mock('~/cache/getLogStores', () => {
return jest.fn(() => ({
get: jest.fn(async (key) => mockCache.get(key)),
set: jest.fn(async (key, value) => mockCache.set(key, value)),
clear: jest.fn(async () => mockCache.clear()),
}));
});
describe('Access Middleware', () => { describe('Access Middleware', () => {
let mongoServer; let mongoServer;
let req, res, next; let req, res, next;
@ -29,33 +43,86 @@ describe('Access Middleware', () => {
beforeEach(async () => { beforeEach(async () => {
await mongoose.connection.dropDatabase(); await mongoose.connection.dropDatabase();
mockCache.clear(); // Clear the cache between tests
// Create test roles // Create test roles
await Role.create({ await Role.create({
name: 'user', name: 'user',
permissions: { permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: false,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: { [PermissionTypes.AGENTS]: {
[Permissions.USE]: true, [Permissions.USE]: true,
[Permissions.CREATE]: false, [Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false, [Permissions.SHARED_GLOBAL]: false,
}, },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
}, },
}); });
await Role.create({ await Role.create({
name: 'admin', name: 'admin',
permissions: { permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: true,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: { [PermissionTypes.AGENTS]: {
[Permissions.USE]: true, [Permissions.USE]: true,
[Permissions.CREATE]: true, [Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true, [Permissions.SHARED_GLOBAL]: true,
}, },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
// Create limited role with no AGENTS permissions
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
}, },
}); });
req = { req = {
user: { id: 'user123', role: 'user' }, user: { id: 'user123', role: 'user' },
body: {}, body: {},
originalUrl: '/test',
}; };
res = { res = {
status: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(),
@ -67,92 +134,127 @@ describe('Access Middleware', () => {
describe('checkAccess', () => { describe('checkAccess', () => {
test('should return false if user is not provided', async () => { test('should return false if user is not provided', async () => {
const result = await checkAccess(null, PermissionTypes.AGENTS, [Permissions.USE]); const result = await checkAccess({
user: null,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false); expect(result).toBe(false);
}); });
test('should return true if user has required permission', async () => { test('should return true if user has required permission', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(true); expect(result).toBe(true);
}); });
test('should return false if user lacks required permission', async () => { test('should return false if user lacks required permission', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.CREATE]); const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(result).toBe(false); expect(result).toBe(false);
}); });
test('should return true if user has any of multiple permissions', async () => { test('should return false if user has only some of multiple permissions', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [ // User has USE but not CREATE, so should fail when checking for both
Permissions.USE, const result = await checkAccess({
Permissions.CREATE, req: {},
]); user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has all of multiple permissions', async () => {
// Admin has both USE and CREATE
const result = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(true); expect(result).toBe(true);
}); });
test('should check body properties when permission is not directly granted', async () => { test('should check body properties when permission is not directly granted', async () => {
// User role doesn't have CREATE permission, but bodyProps allows it const req = { body: { id: 'agent123' } };
const bodyProps = { const result = await checkAccess({
[Permissions.CREATE]: ['agentId', 'name'], req,
}; user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
const checkObject = { agentId: 'agent123' }; permissions: [Permissions.UPDATE],
bodyProps: {
const result = await checkAccess( [Permissions.UPDATE]: ['id'],
req.user, },
PermissionTypes.AGENTS, checkObject: req.body,
[Permissions.CREATE], getRoleByName,
bodyProps, });
checkObject,
);
expect(result).toBe(true); expect(result).toBe(true);
}); });
test('should return false if role is not found', async () => { test('should return false if role is not found', async () => {
req.user.role = 'nonexistent'; const result = await checkAccess({
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); req: {},
user: { id: 'user123', role: 'nonexistent' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false); expect(result).toBe(false);
}); });
test('should return false if role has no permissions for the requested type', async () => { test('should return false if role has no permissions for the requested type', async () => {
await Role.create({ const result = await checkAccess({
name: 'limited', req: {},
permissions: { user: { id: 'user123', role: 'limited' },
// Explicitly set AGENTS permissions to false permissionType: PermissionTypes.AGENTS,
[PermissionTypes.AGENTS]: { permissions: [Permissions.USE],
[Permissions.USE]: false, getRoleByName,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
}); });
req.user.role = 'limited';
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
expect(result).toBe(false); expect(result).toBe(false);
}); });
test('should handle admin role with all permissions', async () => { test('should handle admin role with all permissions', async () => {
req.user.role = 'admin'; const createResult = await checkAccess({
req: {},
const createResult = await checkAccess(req.user, PermissionTypes.AGENTS, [ user: { id: 'admin123', role: 'admin' },
Permissions.CREATE, permissionType: PermissionTypes.AGENTS,
]); permissions: [Permissions.CREATE],
getRoleByName,
});
expect(createResult).toBe(true); expect(createResult).toBe(true);
const shareResult = await checkAccess(req.user, PermissionTypes.AGENTS, [ const shareResult = await checkAccess({
Permissions.SHARED_GLOBAL, req: {},
]); user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARED_GLOBAL],
getRoleByName,
});
expect(shareResult).toBe(true); expect(shareResult).toBe(true);
}); });
}); });
describe('generateCheckAccess', () => { describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => { test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next); await middleware(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
@ -160,7 +262,11 @@ describe('Access Middleware', () => {
}); });
test('should return 403 when user lacks permission', async () => { test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.CREATE]); const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
await middleware(req, res, next); await middleware(req, res, next);
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
@ -175,11 +281,12 @@ describe('Access Middleware', () => {
[Permissions.CREATE]: ['agentId'], [Permissions.CREATE]: ['agentId'],
}; };
const middleware = generateCheckAccess( const middleware = generateCheckAccess({
PermissionTypes.AGENTS, permissionType: PermissionTypes.AGENTS,
[Permissions.CREATE], permissions: [Permissions.CREATE],
bodyProps, bodyProps,
); getRoleByName,
});
await middleware(req, res, next); await middleware(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
@ -187,10 +294,16 @@ describe('Access Middleware', () => {
}); });
test('should handle database errors gracefully', async () => { test('should handle database errors gracefully', async () => {
// Create a user with an invalid role that will cause getRoleByName to fail // Mock getRoleByName to throw an error
req.user.role = { invalid: 'object' }; // This will cause an error when querying const mockGetRoleByName = jest
.fn()
.mockRejectedValue(new Error('Database connection failed'));
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: mockGetRoleByName,
});
await middleware(req, res, next); await middleware(req, res, next);
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
@ -203,11 +316,11 @@ describe('Access Middleware', () => {
test('should work with multiple permission types', async () => { test('should work with multiple permission types', async () => {
req.user.role = 'admin'; req.user.role = 'admin';
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [ const middleware = generateCheckAccess({
Permissions.USE, permissionType: PermissionTypes.AGENTS,
Permissions.CREATE, permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
Permissions.SHARED_GLOBAL, getRoleByName,
]); });
await middleware(req, res, next); await middleware(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
@ -216,14 +329,16 @@ describe('Access Middleware', () => {
test('should handle missing user gracefully', async () => { test('should handle missing user gracefully', async () => {
req.user = null; req.user = null;
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next); await middleware(req, res, next);
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
message: expect.stringContaining('Server error:'),
});
}); });
test('should handle role with no AGENTS permissions', async () => { test('should handle role with no AGENTS permissions', async () => {
@ -240,7 +355,11 @@ describe('Access Middleware', () => {
}); });
req.user.role = 'noaccess'; req.user.role = 'noaccess';
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next); await middleware(req, res, next);
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();

View file

@ -8,6 +8,7 @@ const {
searchPrincipals, searchPrincipals,
} = require('~/server/controllers/PermissionsController'); } = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware'); const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
const router = express.Router(); const router = express.Router();
@ -25,7 +26,7 @@ router.use(uaParser);
* GET /api/permissions/search-principals * GET /api/permissions/search-principals
* Search for users and groups to grant permissions * Search for users and groups to grant permissions
*/ */
router.get('/search-principals', searchPrincipals); router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
/** /**
* GET /api/permissions/{resourceType}/roles * GET /api/permissions/{resourceType}/roles

View file

@ -9,11 +9,13 @@ const {
removeNullishValues, removeNullishValues,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains'); const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware'); const { canAccessAgentResource } = require('~/server/middleware');
const { getAgent, updateAgent } = require('~/models/Agent'); const { getAgent, updateAgent } = require('~/models/Agent');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
const { getListAgentsByAccess } = require('~/models/Agent');
const router = express.Router(); const router = express.Router();
@ -31,9 +33,22 @@ const checkAgentCreate = generateCheckAccess({
*/ */
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
// Get all actions for the user (admin permissions handled by middleware if needed) const userId = req.user.id;
const searchParams = { user: req.user.id }; const editableAgentObjectIds = await findAccessibleResources({
res.json(await getActions(searchParams)); userId,
resourceType: 'agent',
requiredPermissions: PermissionBits.EDIT,
});
const agentsResponse = await getListAgentsByAccess({
accessibleIds: editableAgentObjectIds,
});
const editableAgentIds = agentsResponse.data.map((agent) => agent.id);
const actions =
editableAgentIds.length > 0 ? await getActions({ agent_id: { $in: editableAgentIds } }) : [];
res.json(actions);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }

View file

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

View file

@ -1,46 +0,0 @@
const express = require('express');
const { requireJwtAuth, checkBan, generateCheckAccess } = require('~/server/middleware');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const marketplace = require('~/server/controllers/agents/marketplace');
const router = express.Router();
// Apply middleware for authentication and authorization
router.use(requireJwtAuth);
router.use(checkBan);
// Check if user has permission to use agents
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
router.use(checkAgentAccess);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', marketplace.getAgentCategories);
/**
* Get promoted/top picks agents with pagination
* @route GET /agents/marketplace/promoted
*/
router.get('/promoted', marketplace.getPromotedAgents);
/**
* Get all agents with pagination (for "all" category)
* @route GET /agents/marketplace/all
*/
router.get('/all', marketplace.getAllAgents);
/**
* Search agents with filters
* @route GET /agents/marketplace/search
*/
router.get('/search', marketplace.searchAgents);
/**
* Get agents by category with pagination
* @route GET /agents/marketplace/category/:category
*/
router.get('/category/:category', marketplace.getAgentsByCategory);
module.exports = router;

View file

@ -45,6 +45,11 @@ router.use('/actions', actions);
*/ */
router.use('/tools', tools); router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', v1.getAgentCategories);
/** /**
* Creates an agent. * Creates an agent.
* @route POST /agents * @route POST /agents

View file

@ -3,9 +3,11 @@ const request = require('supertest');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; const { createMethods } = require('@librechat/data-schemas');
const { createAgent } = require('~/models/Agent');
const { createFile } = require('~/models/File');
// Mock dependencies // Only mock the external dependencies that we don't want to test
jest.mock('~/server/services/Files/process', () => ({ jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({}), processDeleteRequest: jest.fn().mockResolvedValue({}),
filterFile: jest.fn(), filterFile: jest.fn(),
@ -25,31 +27,8 @@ jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(), loadAuthValues: jest.fn(),
})); }));
jest.mock('~/server/services/Files/S3/crud', () => ({ // Import the router
refreshS3FileUrls: jest.fn(), const router = require('~/server/routes/files/files');
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
})),
}));
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
const { createFile } = require('~/models/File');
const { createAgent } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
// Import the router after mocks
const router = require('./files');
describe('File Routes - Agent Files Endpoint', () => { describe('File Routes - Agent Files Endpoint', () => {
let app; let app;
@ -60,13 +39,42 @@ describe('File Routes - Agent Files Endpoint', () => {
let fileId1; let fileId1;
let fileId2; let fileId2;
let fileId3; let fileId3;
let File;
let User;
let Agent;
let methods;
let AclEntry;
// eslint-disable-next-line no-unused-vars
let AccessRole;
let modelsToCleanup = [];
beforeAll(async () => { beforeAll(async () => {
mongoServer = await MongoMemoryServer.create(); mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri()); const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize models // Initialize all models using createModels
require('~/db/models'); const { createModels } = require('@librechat/data-schemas');
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
Object.assign(mongoose.models, models);
// Create methods with our test mongoose instance
methods = createMethods(mongoose);
// Now we can access models from the db/models
File = models.File;
Agent = models.Agent;
AclEntry = models.AclEntry;
User = models.User;
AccessRole = models.AccessRole;
// Seed default roles using our methods
await methods.seedDefaultRoles();
app = express(); app = express();
app.use(express.json()); app.use(express.json());
@ -82,88 +90,121 @@ describe('File Routes - Agent Files Endpoint', () => {
}); });
afterAll(async () => { afterAll(async () => {
await mongoose.disconnect(); // Clean up all collections before disconnecting
await mongoServer.stop();
});
beforeEach(async () => {
jest.clearAllMocks();
// Clear database
const collections = mongoose.connection.collections; const collections = mongoose.connection.collections;
for (const key in collections) { for (const key in collections) {
await collections[key].deleteMany({}); await collections[key].deleteMany({});
} }
authorId = new mongoose.Types.ObjectId().toString(); // Clear only the models we added
otherUserId = new mongoose.Types.ObjectId().toString(); for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
// Clean up all test data
await File.deleteMany({});
await Agent.deleteMany({});
await User.deleteMany({});
await AclEntry.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
// Create test users
authorId = new mongoose.Types.ObjectId();
otherUserId = new mongoose.Types.ObjectId();
agentId = uuidv4(); agentId = uuidv4();
fileId1 = uuidv4(); fileId1 = uuidv4();
fileId2 = uuidv4(); fileId2 = uuidv4();
fileId3 = uuidv4(); fileId3 = uuidv4();
// Create users in database
await User.create({
_id: authorId,
username: 'author',
email: 'author@test.com',
});
await User.create({
_id: otherUserId,
username: 'other',
email: 'other@test.com',
});
// Create files // Create files
await createFile({ await createFile({
user: authorId, user: authorId,
file_id: fileId1, file_id: fileId1,
filename: 'agent-file1.txt', filename: 'file1.txt',
filepath: `/uploads/${authorId}/${fileId1}`, filepath: '/uploads/file1.txt',
bytes: 1024, bytes: 100,
type: 'text/plain', type: 'text/plain',
}); });
await createFile({ await createFile({
user: authorId, user: authorId,
file_id: fileId2, file_id: fileId2,
filename: 'agent-file2.txt', filename: 'file2.txt',
filepath: `/uploads/${authorId}/${fileId2}`, filepath: '/uploads/file2.txt',
bytes: 2048, bytes: 200,
type: 'text/plain', type: 'text/plain',
}); });
await createFile({ await createFile({
user: otherUserId, user: otherUserId,
file_id: fileId3, file_id: fileId3,
filename: 'user-file.txt', filename: 'file3.txt',
filepath: `/uploads/${otherUserId}/${fileId3}`, filepath: '/uploads/file3.txt',
bytes: 512, bytes: 300,
type: 'text/plain', type: 'text/plain',
}); });
// Create an agent with files attached
await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
isCollaborative: true,
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2],
},
},
});
// Share the agent globally
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
if (globalProject) {
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
}
}); });
describe('GET /files/agent/:agent_id', () => { describe('GET /files/agent/:agent_id', () => {
it('should return files accessible through the agent for non-author', async () => { it('should return files accessible through the agent for non-author with EDIT permission', async () => {
// Create an agent with files attached
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2],
},
},
});
// Grant EDIT permission to user on the agent using PermissionService
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
// Mock req.user for this request
app.use((req, res, next) => {
req.user = { id: otherUserId.toString() };
next();
});
const response = await request(app).get(`/files/agent/${agentId}`); const response = await request(app).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toHaveLength(2); // Only agent files, not user-owned files expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toHaveLength(2);
const fileIds = response.body.map((f) => f.file_id); expect(response.body.map((f) => f.file_id)).toContain(fileId1);
expect(fileIds).toContain(fileId1); expect(response.body.map((f) => f.file_id)).toContain(fileId2);
expect(fileIds).toContain(fileId2);
expect(fileIds).not.toContain(fileId3); // User's own file not included
}); });
it('should return 400 when agent_id is not provided', async () => { it('should return 400 when agent_id is not provided', async () => {
@ -176,45 +217,63 @@ describe('File Routes - Agent Files Endpoint', () => {
const response = await request(app).get('/files/agent/non-existent-agent'); const response = await request(app).get('/files/agent/non-existent-agent');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual([]); // Empty array for non-existent agent expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toEqual([]);
}); });
it('should return empty array when agent is not collaborative', async () => { it('should return empty array when user only has VIEW permission', async () => {
// Create a non-collaborative agent // Create an agent with files attached
const nonCollabAgentId = uuidv4(); const agent = await createAgent({
await createAgent({ id: agentId,
id: nonCollabAgentId, name: 'Test Agent',
name: 'Non-Collaborative Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai', provider: 'openai',
isCollaborative: false, model: 'gpt-4',
author: authorId,
tool_resources: { tool_resources: {
file_search: { file_search: {
file_ids: [fileId1], file_ids: [fileId1, fileId2],
}, },
}, },
}); });
// Share it globally // Grant only VIEW permission to user on the agent
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); const { grantPermission } = require('~/server/services/PermissionService');
if (globalProject) { await grantPermission({
const { updateAgent } = require('~/models/Agent'); principalType: 'user',
await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] }); principalId: otherUserId,
} resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_viewer',
grantedBy: authorId,
});
const response = await request(app).get(`/files/agent/${nonCollabAgentId}`); const response = await request(app).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual([]); // Empty array when not collaborative expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toEqual([]);
}); });
it('should return agent files for agent author', async () => { it('should return agent files for agent author', async () => {
// Create an agent with files attached
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2],
},
},
});
// Create a new app instance with author authentication // Create a new app instance with author authentication
const authorApp = express(); const authorApp = express();
authorApp.use(express.json()); authorApp.use(express.json());
authorApp.use((req, res, next) => { authorApp.use((req, res, next) => {
req.user = { id: authorId }; req.user = { id: authorId.toString() };
req.app = { locals: {} }; req.app = { locals: {} };
next(); next();
}); });
@ -223,46 +282,48 @@ describe('File Routes - Agent Files Endpoint', () => {
const response = await request(authorApp).get(`/files/agent/${agentId}`); const response = await request(authorApp).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toHaveLength(2); // Agent files for author expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toHaveLength(2);
const fileIds = response.body.map((f) => f.file_id);
expect(fileIds).toContain(fileId1);
expect(fileIds).toContain(fileId2);
expect(fileIds).not.toContain(fileId3); // User's own file not included
}); });
it('should return files uploaded by other users to shared agent for author', async () => { it('should return files uploaded by other users to shared agent for author', async () => {
// Create a file uploaded by another user const anotherUserId = new mongoose.Types.ObjectId();
const otherUserFileId = uuidv4(); const otherUserFileId = uuidv4();
const anotherUserId = new mongoose.Types.ObjectId().toString();
await User.create({
_id: anotherUserId,
username: 'another',
email: 'another@test.com',
});
await createFile({ await createFile({
user: anotherUserId, user: anotherUserId,
file_id: otherUserFileId, file_id: otherUserFileId,
filename: 'other-user-file.txt', filename: 'other-user-file.txt',
filepath: `/uploads/${anotherUserId}/${otherUserFileId}`, filepath: '/uploads/other-user-file.txt',
bytes: 4096, bytes: 400,
type: 'text/plain', type: 'text/plain',
}); });
// Update agent to include the file uploaded by another user // Create agent to include the file uploaded by another user
const { updateAgent } = require('~/models/Agent'); await createAgent({
await updateAgent( id: agentId,
{ id: agentId }, name: 'Test Agent',
{ provider: 'openai',
tool_resources: { model: 'gpt-4',
file_search: { author: authorId,
file_ids: [fileId1, fileId2, otherUserFileId], tool_resources: {
}, file_search: {
file_ids: [fileId1, otherUserFileId],
}, },
}, },
); });
// Create app instance with author authentication // Create a new app instance with author authentication
const authorApp = express(); const authorApp = express();
authorApp.use(express.json()); authorApp.use(express.json());
authorApp.use((req, res, next) => { authorApp.use((req, res, next) => {
req.user = { id: authorId }; req.user = { id: authorId.toString() };
req.app = { locals: {} }; req.app = { locals: {} };
next(); next();
}); });
@ -271,12 +332,10 @@ describe('File Routes - Agent Files Endpoint', () => {
const response = await request(authorApp).get(`/files/agent/${agentId}`); const response = await request(authorApp).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toHaveLength(3); // Including file from another user expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toHaveLength(2);
const fileIds = response.body.map((f) => f.file_id); expect(response.body.map((f) => f.file_id)).toContain(fileId1);
expect(fileIds).toContain(fileId1); expect(response.body.map((f) => f.file_id)).toContain(otherUserFileId);
expect(fileIds).toContain(fileId2);
expect(fileIds).toContain(otherUserFileId); // File uploaded by another user
}); });
}); });
}); });

View file

@ -5,8 +5,8 @@ const {
Time, Time,
isUUID, isUUID,
CacheKeys, CacheKeys,
Constants,
FileSources, FileSources,
PERMISSION_BITS,
EModelEndpoint, EModelEndpoint,
isAgentsEndpoint, isAgentsEndpoint,
checkOpenAIStorage, checkOpenAIStorage,
@ -17,12 +17,13 @@ const {
processDeleteRequest, processDeleteRequest,
processAgentFileUpload, processAgentFileUpload,
} = require('~/server/services/Files/process'); } = require('~/server/services/Files/process');
const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { checkPermission } = require('~/server/services/PermissionService');
const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
const { getProjectByName } = require('~/models/Project'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
const { getFiles, batchUpdateFiles } = require('~/models/File');
const { getAssistant } = require('~/models/Assistant'); const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent'); const { getAgent } = require('~/models/Agent');
const { cleanFileName } = require('~/server/utils/files'); const { cleanFileName } = require('~/server/utils/files');
@ -123,14 +124,15 @@ router.get('/agent/:agent_id', async (req, res) => {
// Check if user has access to the agent // Check if user has access to the agent
if (agent.author.toString() !== userId) { if (agent.author.toString() !== userId) {
// Non-authors need the agent to be globally shared and collaborative // Non-authors need at least EDIT permission to view agent files
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); const hasEditPermission = await checkPermission({
userId,
resourceType: 'agent',
resourceId: agent._id,
requiredPermission: PERMISSION_BITS.EDIT,
});
if ( if (!hasEditPermission) {
!globalProject ||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) ||
!agent.isCollaborative
) {
return res.status(200).json([]); return res.status(200).json([]);
} }
} }

View file

@ -2,10 +2,12 @@ const express = require('express');
const request = require('supertest'); const request = require('supertest');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { createMethods } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; const { createAgent } = require('~/models/Agent');
const { createFile } = require('~/models/File');
// Mock dependencies // Only mock the external dependencies that we don't want to test
jest.mock('~/server/services/Files/process', () => ({ jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({}), processDeleteRequest: jest.fn().mockResolvedValue({}),
filterFile: jest.fn(), filterFile: jest.fn(),
@ -44,9 +46,6 @@ jest.mock('~/config', () => ({
}, },
})); }));
const { createFile } = require('~/models/File');
const { createAgent } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { processDeleteRequest } = require('~/server/services/Files/process'); const { processDeleteRequest } = require('~/server/services/Files/process');
// Import the router after mocks // Import the router after mocks
@ -57,22 +56,51 @@ describe('File Routes - Delete with Agent Access', () => {
let mongoServer; let mongoServer;
let authorId; let authorId;
let otherUserId; let otherUserId;
let agentId;
let fileId; let fileId;
let File;
let Agent;
let AclEntry;
let User;
let AccessRole;
let methods;
let modelsToCleanup = [];
// eslint-disable-next-line no-unused-vars
let agentId;
beforeAll(async () => { beforeAll(async () => {
mongoServer = await MongoMemoryServer.create(); mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri()); const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize models // Initialize all models using createModels
require('~/db/models'); const { createModels } = require('@librechat/data-schemas');
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
Object.assign(mongoose.models, models);
// Create methods with our test mongoose instance
methods = createMethods(mongoose);
// Now we can access models from the db/models
File = models.File;
Agent = models.Agent;
AclEntry = models.AclEntry;
User = models.User;
AccessRole = models.AccessRole;
// Seed default roles using our methods
await methods.seedDefaultRoles();
app = express(); app = express();
app.use(express.json()); app.use(express.json());
// Mock authentication middleware // Mock authentication middleware
app.use((req, res, next) => { app.use((req, res, next) => {
req.user = { id: otherUserId || 'default-user' }; req.user = { id: otherUserId ? otherUserId.toString() : 'default-user' };
req.app = { locals: {} }; req.app = { locals: {} };
next(); next();
}); });
@ -81,6 +109,19 @@ describe('File Routes - Delete with Agent Access', () => {
}); });
afterAll(async () => { afterAll(async () => {
// Clean up all collections before disconnecting
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
// Clear only the models we added
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect(); await mongoose.disconnect();
await mongoServer.stop(); await mongoServer.stop();
}); });
@ -88,48 +129,41 @@ describe('File Routes - Delete with Agent Access', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
// Clear database // Clear database - clean up all test data
const collections = mongoose.connection.collections; await File.deleteMany({});
for (const key in collections) { await Agent.deleteMany({});
await collections[key].deleteMany({}); await User.deleteMany({});
} await AclEntry.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
authorId = new mongoose.Types.ObjectId().toString(); // Create test data
otherUserId = new mongoose.Types.ObjectId().toString(); authorId = new mongoose.Types.ObjectId();
otherUserId = new mongoose.Types.ObjectId();
agentId = uuidv4();
fileId = uuidv4(); fileId = uuidv4();
// Create users in database
await User.create({
_id: authorId,
username: 'author',
email: 'author@test.com',
});
await User.create({
_id: otherUserId,
username: 'other',
email: 'other@test.com',
});
// Create a file owned by the author // Create a file owned by the author
await createFile({ await createFile({
user: authorId, user: authorId,
file_id: fileId, file_id: fileId,
filename: 'test.txt', filename: 'test.txt',
filepath: `/uploads/${authorId}/${fileId}`, filepath: '/uploads/test.txt',
bytes: 1024, bytes: 100,
type: 'text/plain', type: 'text/plain',
}); });
// Create an agent with the file attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
isCollaborative: true,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
agentId = agent.id;
// Share the agent globally
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
if (globalProject) {
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
}
}); });
describe('DELETE /files', () => { describe('DELETE /files', () => {
@ -140,8 +174,8 @@ describe('File Routes - Delete with Agent Access', () => {
user: otherUserId, user: otherUserId,
file_id: userFileId, file_id: userFileId,
filename: 'user-file.txt', filename: 'user-file.txt',
filepath: `/uploads/${otherUserId}/${userFileId}`, filepath: '/uploads/user-file.txt',
bytes: 1024, bytes: 200,
type: 'text/plain', type: 'text/plain',
}); });
@ -151,7 +185,7 @@ describe('File Routes - Delete with Agent Access', () => {
files: [ files: [
{ {
file_id: userFileId, file_id: userFileId,
filepath: `/uploads/${otherUserId}/${userFileId}`, filepath: '/uploads/user-file.txt',
}, },
], ],
}); });
@ -168,7 +202,7 @@ describe('File Routes - Delete with Agent Access', () => {
files: [ files: [
{ {
file_id: fileId, file_id: fileId,
filepath: `/uploads/${authorId}/${fileId}`, filepath: '/uploads/test.txt',
}, },
], ],
}); });
@ -180,14 +214,39 @@ describe('File Routes - Delete with Agent Access', () => {
}); });
it('should allow deleting files accessible through shared agent', async () => { it('should allow deleting files accessible through shared agent', async () => {
// Create an agent with the file attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant EDIT permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
const response = await request(app) const response = await request(app)
.delete('/files') .delete('/files')
.send({ .send({
agent_id: agentId, agent_id: agent.id,
files: [ files: [
{ {
file_id: fileId, file_id: fileId,
filepath: `/uploads/${authorId}/${fileId}`, filepath: '/uploads/test.txt',
}, },
], ],
}); });
@ -204,19 +263,44 @@ describe('File Routes - Delete with Agent Access', () => {
user: authorId, user: authorId,
file_id: unattachedFileId, file_id: unattachedFileId,
filename: 'unattached.txt', filename: 'unattached.txt',
filepath: `/uploads/${authorId}/${unattachedFileId}`, filepath: '/uploads/unattached.txt',
bytes: 1024, bytes: 300,
type: 'text/plain', type: 'text/plain',
}); });
// Create an agent without the unattached file
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId], // Only fileId, not unattachedFileId
},
},
});
// Grant EDIT permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
const response = await request(app) const response = await request(app)
.delete('/files') .delete('/files')
.send({ .send({
agent_id: agentId, agent_id: agent.id,
files: [ files: [
{ {
file_id: unattachedFileId, file_id: unattachedFileId,
filepath: `/uploads/${authorId}/${unattachedFileId}`, filepath: '/uploads/unattached.txt',
}, },
], ],
}); });
@ -224,6 +308,7 @@ describe('File Routes - Delete with Agent Access', () => {
expect(response.status).toBe(403); expect(response.status).toBe(403);
expect(response.body.message).toBe('You can only delete files you have access to'); expect(response.body.message).toBe('You can only delete files you have access to');
expect(response.body.unauthorizedFiles).toContain(unattachedFileId); expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
expect(processDeleteRequest).not.toHaveBeenCalled();
}); });
it('should handle mixed authorized and unauthorized files', async () => { it('should handle mixed authorized and unauthorized files', async () => {
@ -233,8 +318,8 @@ describe('File Routes - Delete with Agent Access', () => {
user: otherUserId, user: otherUserId,
file_id: userFileId, file_id: userFileId,
filename: 'user-file.txt', filename: 'user-file.txt',
filepath: `/uploads/${otherUserId}/${userFileId}`, filepath: '/uploads/user-file.txt',
bytes: 1024, bytes: 200,
type: 'text/plain', type: 'text/plain',
}); });
@ -244,51 +329,87 @@ describe('File Routes - Delete with Agent Access', () => {
user: authorId, user: authorId,
file_id: unauthorizedFileId, file_id: unauthorizedFileId,
filename: 'unauthorized.txt', filename: 'unauthorized.txt',
filepath: `/uploads/${authorId}/${unauthorizedFileId}`, filepath: '/uploads/unauthorized.txt',
bytes: 1024, bytes: 400,
type: 'text/plain', type: 'text/plain',
}); });
// Create an agent with only fileId attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant EDIT permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
const response = await request(app) const response = await request(app)
.delete('/files') .delete('/files')
.send({ .send({
agent_id: agentId, agent_id: agent.id,
files: [ files: [
{ { file_id: userFileId, filepath: '/uploads/user-file.txt' },
file_id: fileId, // Authorized through agent { file_id: fileId, filepath: '/uploads/test.txt' },
filepath: `/uploads/${authorId}/${fileId}`, { file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' },
},
{
file_id: userFileId, // Owned by user
filepath: `/uploads/${otherUserId}/${userFileId}`,
},
{
file_id: unauthorizedFileId, // Not authorized
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
},
], ],
}); });
expect(response.status).toBe(403); expect(response.status).toBe(403);
expect(response.body.message).toBe('You can only delete files you have access to'); expect(response.body.message).toBe('You can only delete files you have access to');
expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId); expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
expect(response.body.unauthorizedFiles).not.toContain(fileId); expect(processDeleteRequest).not.toHaveBeenCalled();
expect(response.body.unauthorizedFiles).not.toContain(userFileId);
}); });
it('should prevent deleting files when agent is not collaborative', async () => { it('should prevent deleting files when user lacks EDIT permission on agent', async () => {
// Update the agent to be non-collaborative // Create an agent with the file attached
const { updateAgent } = require('~/models/Agent'); const agent = await createAgent({
await updateAgent({ id: agentId }, { isCollaborative: false }); id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant only VIEW permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_viewer',
grantedBy: authorId,
});
const response = await request(app) const response = await request(app)
.delete('/files') .delete('/files')
.send({ .send({
agent_id: agentId, agent_id: agent.id,
files: [ files: [
{ {
file_id: fileId, file_id: fileId,
filepath: `/uploads/${authorId}/${fileId}`, filepath: '/uploads/test.txt',
}, },
], ],
}); });

View file

@ -1,6 +1,7 @@
jest.mock('~/models', () => ({ jest.mock('~/models', () => ({
initializeRoles: jest.fn(), initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(), seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
})); }));
jest.mock('~/models/Role', () => ({ jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(), updateAccessPermissions: jest.fn(),

View file

@ -15,7 +15,7 @@ const {
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants'); const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeAzureBlobService } = require('./Files/Azure/initialize'); const { initializeAzureBlobService } = require('./Files/Azure/initialize');
const { initializeFirebase } = require('./Files/Firebase/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize');
const { seedDefaultRoles, initializeRoles } = require('~/models'); const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
const loadCustomConfig = require('./Config/loadCustomConfig'); const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits'); const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface'); const { loadDefaultInterface } = require('./start/interface');
@ -36,6 +36,7 @@ const paths = require('~/config/paths');
const AppService = async (app) => { const AppService = async (app) => {
await initializeRoles(); await initializeRoles();
await seedDefaultRoles(); await seedDefaultRoles();
await ensureDefaultCategories();
/** @type {TCustomConfig} */ /** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {}; const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults(); const configDefaults = getConfigDefaults();

View file

@ -29,6 +29,7 @@ jest.mock('./Files/Firebase/initialize', () => ({
jest.mock('~/models', () => ({ jest.mock('~/models', () => ({
initializeRoles: jest.fn(), initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(), seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
})); }));
jest.mock('~/models/Role', () => ({ jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(), updateAccessPermissions: jest.fn(),

View file

@ -11,6 +11,7 @@ const {
imageExtRegex, imageExtRegex,
EToolResources, EToolResources,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert'); const { convertImage } = require('~/server/services/Files/images/convert');
const { createFile, getFiles, updateFile } = require('~/models/File'); const { createFile, getFiles, updateFile } = require('~/models/File');
@ -164,14 +165,19 @@ const primeFiles = async (options, apiKey) => {
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? []; const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
const agentResourceIds = new Set(file_ids); const agentResourceIds = new Set(file_ids);
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? []; const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
const dbFiles = (
(await getFiles( // Get all files first
{ file_id: { $in: file_ids } }, const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
null,
{ text: 0 }, // Filter by access if user and agent are provided
{ userId: req?.user?.id, agentId }, let dbFiles;
)) ?? [] if (req?.user?.id && agentId) {
).concat(resourceFiles); dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId);
} else {
dbFiles = allFiles;
}
dbFiles = dbFiles.concat(resourceFiles);
const files = []; const files = [];
const sessions = new Map(); const sessions = new Map();

View file

@ -0,0 +1,12 @@
const { processCodeFile } = require('./Code/process');
const { processFileUpload } = require('./process');
const { uploadImageBuffer } = require('./images');
const { hasAccessToFilesViaAgent, filterFilesByAgentAccess } = require('./permissions');
module.exports = {
processCodeFile,
processFileUpload,
uploadImageBuffer,
hasAccessToFilesViaAgent,
filterFilesByAgentAccess,
};

View file

@ -0,0 +1,123 @@
const { logger } = require('@librechat/data-schemas');
const { PERMISSION_BITS } = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
const { getAgent } = require('~/models/Agent');
/**
* Checks if a user has access to multiple files through a shared agent (batch operation)
* @param {string} userId - The user ID to check access for
* @param {string[]} fileIds - Array of file IDs to check
* @param {string} agentId - The agent ID that might grant access
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
*/
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => {
const accessMap = new Map();
// Initialize all files as no access
fileIds.forEach((fileId) => accessMap.set(fileId, false));
try {
const agent = await getAgent({ id: agentId });
if (!agent) {
return accessMap;
}
// Check if user is the author - if so, grant access to all files
if (agent.author.toString() === userId) {
fileIds.forEach((fileId) => accessMap.set(fileId, true));
return accessMap;
}
// Check if user has at least VIEW permission on the agent
const hasViewPermission = await checkPermission({
userId,
resourceType: 'agent',
resourceId: agent._id,
requiredPermission: PERMISSION_BITS.VIEW,
});
if (!hasViewPermission) {
return accessMap;
}
// Check if user has EDIT permission (which would indicate collaborative access)
const hasEditPermission = await checkPermission({
userId,
resourceType: 'agent',
resourceId: agent._id,
requiredPermission: PERMISSION_BITS.EDIT,
});
// If user only has VIEW permission, they can't access files
// Only users with EDIT permission or higher can access agent files
if (!hasEditPermission) {
return accessMap;
}
// User has edit permissions - check which files are actually attached
const attachedFileIds = new Set();
if (agent.tool_resources) {
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
}
}
}
// Grant access only to files that are attached to this agent
fileIds.forEach((fileId) => {
if (attachedFileIds.has(fileId)) {
accessMap.set(fileId, true);
}
});
return accessMap;
} catch (error) {
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
return accessMap;
}
};
/**
* Filter files based on user access through agents
* @param {Array<MongoFile>} files - Array of file documents
* @param {string} userId - User ID for access control
* @param {string} agentId - Agent ID that might grant access to files
* @returns {Promise<Array<MongoFile>>} Filtered array of accessible files
*/
const filterFilesByAgentAccess = async (files, userId, agentId) => {
if (!userId || !agentId || !files || files.length === 0) {
return files;
}
// Separate owned files from files that need access check
const filesToCheck = [];
const ownedFiles = [];
for (const file of files) {
if (file.user && file.user.toString() === userId) {
ownedFiles.push(file);
} else {
filesToCheck.push(file);
}
}
if (filesToCheck.length === 0) {
return ownedFiles;
}
// Batch check access for all non-owned files
const fileIds = filesToCheck.map((f) => f.file_id);
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
// Filter files based on access
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
return [...ownedFiles, ...accessibleFiles];
};
module.exports = {
hasAccessToFilesViaAgent,
filterFilesByAgentAccess,
};

View file

@ -53,6 +53,24 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch, fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations, fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations,
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome, customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
peoplePicker: {
admin: {
users: interfaceConfig?.peoplePicker?.admin?.users ?? defaults.peoplePicker?.admin.users,
groups: interfaceConfig?.peoplePicker?.admin?.groups ?? defaults.peoplePicker?.admin.groups,
},
user: {
users: interfaceConfig?.peoplePicker?.user?.users ?? defaults.peoplePicker?.user.users,
groups: interfaceConfig?.peoplePicker?.user?.groups ?? defaults.peoplePicker?.user.groups,
},
},
marketplace: {
admin: {
use: interfaceConfig?.marketplace?.admin?.use ?? defaults.marketplace?.admin.use,
},
user: {
use: interfaceConfig?.marketplace?.user?.use ?? defaults.marketplace?.user.use,
},
},
}); });
await updateAccessPermissions(roleName, { await updateAccessPermissions(roleName, {
@ -67,6 +85,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.user?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.user?.groups,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace.user?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
}); });
@ -82,6 +107,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.admin?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.admin?.groups,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace.admin?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
}); });

View file

@ -29,12 +29,20 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined }, [PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
}); });
@ -68,6 +76,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
}); });
@ -91,6 +104,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -126,6 +144,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -159,6 +182,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
}); });
@ -192,6 +220,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
}); });
@ -215,6 +248,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -238,6 +276,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -261,6 +304,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -292,6 +340,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -324,6 +377,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
}); });
@ -355,6 +413,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
}); });
}); });
@ -372,12 +435,20 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
}); });
}); });
@ -395,12 +466,20 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
}); });
}); });
@ -432,6 +511,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
}); });

View file

@ -10,4 +10,9 @@ process.env.JWT_SECRET = 'test';
process.env.JWT_REFRESH_SECRET = 'test'; process.env.JWT_REFRESH_SECRET = 'test';
process.env.CREDS_KEY = 'test'; process.env.CREDS_KEY = 'test';
process.env.CREDS_IV = 'test'; process.env.CREDS_IV = 'test';
process.env.ALLOW_EMAIL_LOGIN = 'true';
// Set global test timeout to 30 seconds
// This can be overridden in individual tests if needed
jest.setTimeout(30000);
process.env.OPENAI_API_KEY = 'test'; process.env.OPENAI_API_KEY = 'test';

View file

@ -4,6 +4,7 @@
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit",
"data-provider": "cd .. && npm run build:data-provider", "data-provider": "cd .. && npm run build:data-provider",
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1", "build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs", "build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",

View file

@ -1,5 +1,10 @@
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider'; import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import type { Agent, AgentProvider, AgentModelParameters } from 'librechat-data-provider'; import type {
Agent,
AgentProvider,
AgentModelParameters,
SupportContact,
} from 'librechat-data-provider';
import type { OptionWithIcon, ExtendedFile } from './types'; import type { OptionWithIcon, ExtendedFile } from './types';
export type TAgentOption = OptionWithIcon & export type TAgentOption = OptionWithIcon &
@ -18,11 +23,6 @@ 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;
@ -37,4 +37,5 @@ export type AgentForm = {
[AgentCapabilities.artifacts]?: ArtifactModes | string; [AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number; recursion_limit?: number;
support_contact?: SupportContact; support_contact?: SupportContact;
category: string;
} & TAgentCapabilities; } & TAgentCapabilities;

View file

@ -2,8 +2,8 @@ import React, { useCallback, useContext } from 'react';
import { LayoutGrid } from 'lucide-react'; import { LayoutGrid } from 'lucide-react';
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, Permissions, PermissionTypes } from 'librechat-data-provider'; import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
import { NewChatIcon, MobileSidebar, Sidebar, TooltipAnchor, Button } from '@librechat/client'; import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useLocalize, useNewConvo, useHasAccess, AuthContext } from '~/hooks'; import { useLocalize, useNewConvo, useHasAccess, AuthContext } from '~/hooks';
import store from '~/store'; import store from '~/store';
@ -32,6 +32,10 @@ export default function NewChat({
permissionType: PermissionTypes.AGENTS, permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE, permission: Permissions.USE,
}); });
const hasAccessToMarketplace = useHasAccess({
permissionType: PermissionTypes.MARKETPLACE,
permission: Permissions.USE,
});
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback( const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => { (e) => {
@ -65,9 +69,8 @@ export default function NewChat({
authContext?.isAuthenticated !== undefined && authContext?.isAuthenticated !== undefined &&
(authContext?.isAuthenticated === false || authContext?.user !== undefined); (authContext?.isAuthenticated === false || authContext?.user !== undefined);
// Show agent marketplace when auth is ready and user has access // Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
// Note: endpointsConfig[agents] is null, but we can still show the marketplace const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
const showAgentMarketplace = authReady && hasAccessToAgents;
return ( return (
<> <>

View file

@ -3,10 +3,9 @@ import * as Popover from '@radix-ui/react-popover';
import { useToastContext } from '@librechat/client'; import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { import {
fileConfig as defaultFileConfig,
QueryKeys, QueryKeys,
defaultOrderQuery,
mergeFileConfig, mergeFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query'; import type { UseMutationResult } from '@tanstack/react-query';
import type { import type {
@ -15,7 +14,12 @@ import type {
AgentCreateParams, AgentCreateParams,
AgentListResponse, AgentListResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { useUploadAgentAvatarMutation, useGetFileConfig } from '~/data-provider'; import {
useUploadAgentAvatarMutation,
useGetFileConfig,
allAgentViewAndEditQueryKeys,
invalidateAgentMarketplaceQueries,
} from '~/data-provider';
import { AgentAvatarRender, NoImage, AvatarMenu } from './Images'; import { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { formatBytes } from '~/utils'; import { formatBytes } from '~/utils';
@ -46,41 +50,41 @@ function Avatar({
onMutate: () => { onMutate: () => {
setProgress(0.4); setProgress(0.4);
}, },
onSuccess: (data, vars) => { onSuccess: (data) => {
if (vars.postCreation === false) { if (lastSeenCreatedId.current !== createMutation.data?.id) {
showToast({ message: localize('com_ui_upload_success') });
} else if (lastSeenCreatedId.current !== createMutation.data?.id) {
lastSeenCreatedId.current = createMutation.data?.id ?? ''; lastSeenCreatedId.current = createMutation.data?.id ?? '';
} }
showToast({ message: localize('com_ui_upload_agent_avatar') });
setInput(null); setInput(null);
const newUrl = data.avatar?.filepath ?? ''; const newUrl = data.avatar?.filepath ?? '';
setPreviewUrl(newUrl); setPreviewUrl(newUrl);
const res = queryClient.getQueryData<AgentListResponse>([ ((keys) => {
QueryKeys.agents, keys.forEach((key) => {
defaultOrderQuery, const res = queryClient.getQueryData<AgentListResponse>([QueryKeys.agents, key]);
]);
if (!res?.data) { if (!res?.data) {
return; return;
} }
const agents = res.data.map((agent) => { const agents = res.data.map((agent) => {
if (agent.id === agent_id) { if (agent.id === agent_id) {
return { return {
...agent, ...agent,
...data, ...data,
}; };
} }
return agent; return agent;
}); });
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...res,
data: agents,
});
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, key], {
...res,
data: agents,
});
});
})(allAgentViewAndEditQueryKeys);
invalidateAgentMarketplaceQueries(queryClient);
setProgress(1); setProgress(1);
}, },
onError: (error) => { onError: (error) => {
@ -137,7 +141,6 @@ function Avatar({
uploadAvatar({ uploadAvatar({
agent_id: createMutation.data.id, agent_id: createMutation.data.id,
postCreation: true,
formData, formData,
}); });
} }

View file

@ -10,6 +10,7 @@ import {
ControllerRenderProps, ControllerRenderProps,
} from 'react-hook-form'; } from 'react-hook-form';
import { useAgentCategories } from '~/hooks/Agents'; import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
/** /**
* Custom hook to handle category synchronization * Custom hook to handle category synchronization
@ -18,7 +19,9 @@ const useCategorySync = (agent_id: string | null) => {
const [handled, setHandled] = useState(false); const [handled, setHandled] = useState(false);
return { return {
syncCategory: (field: ControllerRenderProps<FieldValues, FieldPath<FieldValues>>) => { syncCategory: <T extends FieldPath<FieldValues>>(
field: ControllerRenderProps<FieldValues, T>,
) => {
// Only run once and only for new agents // Only run once and only for new agents
if (!handled && agent_id === '' && !field.value) { if (!handled && agent_id === '' && !field.value) {
field.onChange('general'); field.onChange('general');
@ -31,7 +34,7 @@ const useCategorySync = (agent_id: string | null) => {
/** /**
* A component for selecting agent categories with form validation * A component for selecting agent categories with form validation
*/ */
const AgentCategorySelector: React.FC = () => { const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const formContext = useFormContext(); const formContext = useFormContext();
const { categories } = useAgentCategories(); const { categories } = useAgentCategories();
@ -79,7 +82,7 @@ const AgentCategorySelector: React.FC = () => {
field.onChange(value); field.onChange(value);
}} }}
items={comboboxItems} items={comboboxItems}
className="" className={cn(className)}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
isCollapsed={false} isCollapsed={false}
showCarat={true} showCarat={true}

View file

@ -51,7 +51,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
groupedTools: allTools, groupedTools: allTools,
} = useAgentPanelContext(); } = useAgentPanelContext();
const { control } = methods; const {
control,
formState: { errors },
} = methods;
const provider = useWatch({ control, name: 'provider' }); const provider = useWatch({ control, name: 'provider' });
const model = useWatch({ control, name: 'model' }); const model = useWatch({ control, name: 'model' });
const agent = useWatch({ control, name: 'agent' }); const agent = useWatch({ control, name: 'agent' });
@ -195,21 +198,33 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
/> />
<label className={labelClass} htmlFor="name"> <label className={labelClass} htmlFor="name">
{localize('com_ui_name')} {localize('com_ui_name')}
<span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
name="name" name="name"
rules={{ required: localize('com_ui_agent_name_is_required') }}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<input <>
{...field} <input
value={field.value ?? ''} {...field}
maxLength={256} value={field.value ?? ''}
className={inputClass} maxLength={256}
id="name" className={inputClass}
type="text" id="name"
placeholder={localize('com_agents_name_placeholder')} type="text"
aria-label="Agent name" placeholder={localize('com_agents_name_placeholder')}
/> aria-label="Agent name"
/>
<div
className={cn(
'mt-1 w-56 text-sm text-red-500',
errors.name ? 'visible h-auto' : 'invisible h-0',
)}
>
{errors.name ? errors.name.message : ' '}
</div>
</>
)} )}
/> />
<Controller <Controller

View file

@ -1,7 +1,16 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query';
import { Dialog, Button, DotsIcon, DialogContent, useToastContext } from '@librechat/client'; import { Dialog, DialogContent, Button, DotsIcon, useToastContext } from '@librechat/client';
import {
QueryKeys,
Constants,
EModelEndpoint,
PERMISSION_BITS,
LocalStorageKeys,
AgentListResponse,
} from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { renderAgentAvatar } from '~/utils'; import { renderAgentAvatar } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
@ -13,7 +22,6 @@ interface SupportContact {
interface AgentWithSupport extends t.Agent { interface AgentWithSupport extends t.Agent {
support_contact?: SupportContact; support_contact?: SupportContact;
} }
interface AgentDetailProps { interface AgentDetailProps {
agent: AgentWithSupport; // The agent data to display agent: AgentWithSupport; // The agent data to display
isOpen: boolean; // Whether the detail dialog is open isOpen: boolean; // Whether the detail dialog is open
@ -25,12 +33,13 @@ interface AgentDetailProps {
*/ */
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => { const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
const localize = useLocalize(); const localize = useLocalize();
const navigate = useNavigate(); // const navigate = useNavigate();
const { conversation, newConversation } = useChatContext();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const queryClient = useQueryClient();
// Close dropdown when clicking outside the dropdown menu // Close dropdown when clicking outside the dropdown menu
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -54,7 +63,31 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
*/ */
const handleStartChat = () => { const handleStartChat = () => {
if (agent) { if (agent) {
navigate(`/c/new?agent_id=${agent.id}`); const keys = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }];
const listResp = queryClient.getQueryData<AgentListResponse>(keys);
if (listResp != null) {
if (!listResp.data.some((a) => a.id === agent.id)) {
const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))];
queryClient.setQueryData<AgentListResponse>(keys, { ...listResp, data: currentAgents });
}
}
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({
template: {
conversationId: Constants.NEW_CONVO as string,
endpoint: EModelEndpoint.agents,
agent_id: agent.id,
title: `Chat with ${agent.name || 'Agent'}`,
},
});
} }
}; };
@ -68,7 +101,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
.writeText(chatUrl) .writeText(chatUrl)
.then(() => { .then(() => {
showToast({ showToast({
message: 'Link copied', message: localize('com_agents_link_copied'),
}); });
}) })
.catch(() => { .catch(() => {
@ -118,7 +151,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
variant="ghost" variant="ghost"
size="icon" 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" className="h-8 w-8 rounded-lg text-text-secondary hover:bg-surface-hover hover:text-text-primary dark:hover:bg-surface-hover"
aria-label="More options" aria-label={localize('com_agents_more_options')}
aria-expanded={dropdownOpen} aria-expanded={dropdownOpen}
aria-haspopup="menu" aria-haspopup="menu"
onClick={(e) => { onClick={(e) => {

View file

@ -1,9 +1,11 @@
import React, { useState } from 'react'; import React, { useMemo } from 'react';
import { Button, Spinner } from '@librechat/client'; import { Button, Spinner } from '@librechat/client';
import { PERMISSION_BITS } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useDynamicAgentQuery, useAgentCategories } from '~/hooks/Agents'; import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { SmartLoader, useHasData } from './SmartLoader'; import { useAgentCategories } from '~/hooks/Agents';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { useHasData } from './SmartLoader';
import ErrorDisplay from './ErrorDisplay'; import ErrorDisplay from './ErrorDisplay';
import AgentCard from './AgentCard'; import AgentCard from './AgentCard';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -14,45 +16,68 @@ interface AgentGridProps {
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected 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 * Component for displaying a grid of agent cards
*/ */
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => { const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
const localize = useLocalize(); const localize = useLocalize();
const [page, setPage] = useState(1);
// Get category data from API // Get category data from API
const { categories } = useAgentCategories(); const { categories } = useAgentCategories();
// Single dynamic query that handles all cases - much cleaner! // Build query parameters based on current state
const queryParams = useMemo(() => {
const params: {
requiredPermission: number;
category?: string;
search?: string;
limit: number;
promoted?: 0 | 1;
} = {
requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing
limit: 6,
};
// Handle search
if (searchQuery) {
params.search = searchQuery;
// Include category filter for search if it's not 'all' or 'promoted'
if (category !== 'all' && category !== 'promoted') {
params.category = category;
}
} else {
// Handle category-based queries
if (category === 'promoted') {
params.promoted = 1;
} else if (category !== 'all') {
params.category = category;
}
// For 'all' category, no additional filters needed
}
return params;
}, [category, searchQuery]);
// Use infinite query for marketplace agents
const { const {
data: rawData, data,
isLoading, isLoading,
error, error,
isFetching, isFetching,
fetchNextPage,
hasNextPage,
refetch, refetch,
} = useDynamicAgentQuery({ isFetchingNextPage,
category, } = useMarketplaceAgentsInfiniteQuery(queryParams);
searchQuery,
page,
limit: 6,
});
// Type the data properly // Flatten all pages into a single array of agents
const data = rawData as AgentGridData | undefined; const currentAgents = useMemo(() => {
if (!data?.pages) return [];
return data.pages.flatMap((page) => page.data || []);
}, [data?.pages]);
// Check if we have meaningful data to prevent unnecessary loading states // Check if we have meaningful data to prevent unnecessary loading states
const hasData = useHasData(data); const hasData = useHasData(data?.pages?.[0]);
/** /**
* Get category display name from API data or use fallback * Get category display name from API data or use fallback
@ -79,16 +104,11 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
* Load more agents when "See More" button is clicked * Load more agents when "See More" button is clicked
*/ */
const handleLoadMore = () => { const handleLoadMore = () => {
setPage((prevPage) => prevPage + 1); if (hasNextPage && !isFetching) {
fetchNextPage();
}
}; };
/**
* Reset page when category or search changes
*/
React.useEffect(() => {
setPage(1);
}, [category, searchQuery]);
/** /**
* Get the appropriate title for the agents grid based on current state * Get the appropriate title for the agents grid based on current state
*/ */
@ -160,7 +180,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
<h2 <h2
className="text-xl font-bold text-gray-900 dark:text-white" className="text-xl font-bold text-gray-900 dark:text-white"
id={`category-heading-${category}`} id={`category-heading-${category}`}
aria-label={`${getGridTitle()}, ${data?.agents?.length || 0} agents available`} aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
> >
{getGridTitle()} {getGridTitle()}
</h2> </h2>
@ -168,7 +188,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)} )}
{/* Handle empty results with enhanced accessibility */} {/* Handle empty results with enhanced accessibility */}
{(!data?.agents || data.agents.length === 0) && !isLoading && !isFetching ? ( {(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
<div <div
className="py-12 text-center text-gray-500" className="py-12 text-center text-gray-500"
role="status" role="status"
@ -195,22 +215,22 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
{/* Announcement for screen readers */} {/* Announcement for screen readers */}
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true"> <div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
{localize('com_agents_grid_announcement', { {localize('com_agents_grid_announcement', {
count: data?.agents?.length || 0, count: currentAgents?.length || 0,
category: getCategoryDisplayName(category), category: getCategoryDisplayName(category),
})} })}
</div> </div>
{/* Agent grid - 2 per row with proper semantic structure */} {/* Agent grid - 2 per row with proper semantic structure */}
{data?.agents && data.agents.length > 0 && ( {currentAgents && currentAgents.length > 0 && (
<div <div
className="grid grid-cols-1 gap-6 md:grid-cols-2" className="grid grid-cols-1 gap-6 md:grid-cols-2"
role="grid" role="grid"
aria-label={localize('com_agents_grid_announcement', { aria-label={localize('com_agents_grid_announcement', {
count: data.agents.length, count: currentAgents.length,
category: getCategoryDisplayName(category), category: getCategoryDisplayName(category),
})} })}
> >
{data.agents.map((agent: t.Agent, index: number) => ( {currentAgents.map((agent: t.Agent, index: number) => (
<div key={`${agent.id}-${index}`} role="gridcell"> <div key={`${agent.id}-${index}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} /> <AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
</div> </div>
@ -219,7 +239,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)} )}
{/* Loading indicator when fetching more with accessibility */} {/* Loading indicator when fetching more with accessibility */}
{isFetching && page > 1 && ( {isFetching && hasNextPage && (
<div <div
className="flex justify-center py-4" className="flex justify-center py-4"
role="status" role="status"
@ -232,7 +252,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)} )}
{/* Load more button with enhanced accessibility */} {/* Load more button with enhanced accessibility */}
{data?.pagination?.hasMore && !isFetching && ( {hasNextPage && !isFetching && (
<div className="mt-8 flex justify-center"> <div className="mt-8 flex justify-center">
<Button <Button
variant="outline" variant="outline"
@ -257,17 +277,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
</div> </div>
); );
// Use SmartLoader to prevent unnecessary loading flashes if (isLoading || (isFetching && !isFetchingNextPage)) {
return ( return loadingSkeleton;
<SmartLoader }
isLoading={isLoading} return mainContent;
hasData={hasData}
delay={200} // Show loading only after 200ms delay
loadingComponent={loadingSkeleton}
>
{mainContent}
</SmartLoader>
);
}; };
export default AgentGrid; export default AgentGrid;

View file

@ -1,13 +1,16 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue } from 'recoil'; import { useSetRecoilState, useRecoilValue } from 'recoil';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import type { ContextType } from '~/common'; import type { ContextType } from '~/common';
import { useGetAgentCategoriesQuery, useGetEndpointsQuery } from '~/data-provider'; import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext'; import { MarketplaceProvider } from './MarketplaceContext';
import { useDocumentTitle, useLocalize } from '~/hooks';
import { SidePanelGroup } from '~/components/SidePanel'; import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus'; import { OpenSidebar } from '~/components/Chat/Menus';
import CategoryTabs from './CategoryTabs'; import CategoryTabs from './CategoryTabs';
@ -30,6 +33,8 @@ interface AgentMarketplaceProps {
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => { const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
const localize = useLocalize(); const localize = useLocalize();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { category } = useParams(); const { category } = useParams();
const setHideSidePanel = useSetRecoilState(store.hideSidePanel); const setHideSidePanel = useSetRecoilState(store.hideSidePanel);
@ -138,12 +143,18 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
/** /**
* Handle new chat button click * Handle new chat button click
*/ */
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => { const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) { if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank'); window.open('/c/new', '_blank');
return; return;
} }
navigate('/c/new'); queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}; };
// Check if a detail view should be open based on URL // Check if a detail view should be open based on URL
@ -164,131 +175,154 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []); const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
const hasAccessToMarketplace = useHasAccess({
permissionType: PermissionTypes.MARKETPLACE,
permission: Permissions.USE,
});
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccessToMarketplace) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [hasAccessToMarketplace, navigate]);
if (!hasAccessToMarketplace) {
return null;
}
return ( return (
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}> <div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
<MarketplaceProvider> <MarketplaceProvider>
<SidePanelGroup <SidePanelProvider>
defaultLayout={defaultLayout} <SidePanelGroup
fullPanelCollapse={fullCollapse} defaultLayout={defaultLayout}
defaultCollapsed={defaultCollapsed} 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 */} <main className="flex h-full flex-col overflow-y-auto" role="main">
<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"> {/* Simplified header for agents marketplace - only show nav controls when needed */}
<div className="mx-1 flex items-center gap-2"> <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">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />} <div className="mx-1 flex items-center gap-2">
{!navVisible && ( {!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
<TooltipAnchor {!navVisible && (
description={localize('com_ui_new_chat')} <TooltipAnchor
render={ description={localize('com_ui_new_chat')}
<Button render={
size="icon" <Button
variant="outline" size="icon"
data-testid="agents-new-chat-button" variant="outline"
aria-label={localize('com_ui_new_chat')} data-testid="agents-new-chat-button"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden" aria-label={localize('com_ui_new_chat')}
onClick={handleNewChat} className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
> onClick={handleNewChat}
<NewChatIcon /> >
</Button> <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>
</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>
{/* Category tabs */} {/* Search bar */}
<CategoryTabs <div className="mx-auto max-w-2xl">
categories={categoriesQuery.data || []} <SearchBar value={searchQuery} onSearch={handleSearch} />
activeTab={activeTab} </div>
isLoading={categoriesQuery.isLoading} </div>
onChange={handleTabChange}
/>
{/* Category header - only show when not searching */} {/* Category tabs */}
{!searchQuery && ( <CategoryTabs
<div className="mb-6"> categories={categoriesQuery.data || []}
{(() => { activeTab={activeTab}
// Get category data for display isLoading={categoriesQuery.isLoading}
const getCategoryData = () => { onChange={handleTabChange}
if (activeTab === 'promoted') { />
{/* 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 { return {
name: localize('com_agents_top_picks'), name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
description: localize('com_agents_recommended'), description: '',
}; };
}
if (activeTab === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === activeTab,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
description: '',
}; };
};
const { name, description } = getCategoryData(); const { name, description } = getCategoryData();
return ( return (
<div className="text-left"> <div className="text-left">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{name}</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{description && ( {name}
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p> </h2>
)} {description && (
</div> <p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
); )}
})()} </div>
</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>
{/* Agent grid */} </SidePanelGroup>
<AgentGrid </SidePanelProvider>
category={activeTab}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
/>
</div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</main>
</SidePanelGroup>
</MarketplaceProvider> </MarketplaceProvider>
</div> </div>
); );

View file

@ -215,6 +215,12 @@ export default function AgentPanel() {
status: 'error', status: 'error',
}); });
} }
if (!name) {
return showToast({
message: localize('com_agents_missing_name'),
status: 'error',
});
}
create.mutate({ create.mutate({
name, name,
@ -247,12 +253,12 @@ export default function AgentPanel() {
return true; return true;
} }
if (agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN) { if (user?.role === SystemRoles.ADMIN) {
return true; return true;
} }
return canEdit; return canEdit;
}, [agentQuery.data?.author, agentQuery.data?.id, user?.id, user?.role, canEdit]); }, [agentQuery.data?.id, user?.role, canEdit]);
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>

View file

@ -7,7 +7,11 @@ import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-que
import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { Agent, AgentCreateParams } from 'librechat-data-provider';
import type { TAgentCapabilities, AgentForm } from '~/common'; import type { TAgentCapabilities, AgentForm } from '~/common';
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils'; import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider'; import {
useAgentListingDefaultPermissionLevel,
useGetStartupConfig,
useListAgentsQuery,
} from '~/data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const keys = new Set(Object.keys(defaultAgentFormValues)); const keys = new Set(Object.keys(defaultAgentFormValues));
@ -28,18 +32,23 @@ export default function AgentSelect({
const { control, reset } = useFormContext(); const { control, reset } = useFormContext();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { data: agents = null } = useListAgentsQuery(undefined, { const permissionLevel = useAgentListingDefaultPermissionLevel();
select: (res) =>
res.data.map((agent) => const { data: agents = null } = useListAgentsQuery(
processAgentOption({ { requiredPermission: permissionLevel },
agent: { {
...agent, select: (res) =>
name: agent.name || agent.id, res.data.map((agent) =>
}, processAgentOption({
instanceProjectId: startupConfig?.instanceProjectId, agent: {
}), ...agent,
), name: agent.name || agent.id,
}); },
instanceProjectId: startupConfig?.instanceProjectId,
}),
),
},
);
const resetAgentForm = useCallback( const resetAgentForm = useCallback(
(fullAgent: Agent) => { (fullAgent: Agent) => {

View file

@ -66,23 +66,25 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, cont
errorData = error; errorData = error;
} }
// Use user-friendly message from backend if available // Handle network errors first
if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) { let errorMessage = '';
if (isErrorInstance(error)) {
errorMessage = error.message;
} else if (isErrorObject(error) && (error as any)?.message) {
errorMessage = (error as any).message;
}
const errorCode = isErrorObject(error) ? (error as any)?.code : '';
// Handle timeout errors specifically
if (errorCode === 'ECONNABORTED' || errorMessage?.includes('timeout')) {
return { return {
title: getContextualTitle(), title: localize('com_agents_error_timeout_title'),
message: (errorData as any).userMessage, message: localize('com_agents_error_timeout_message'),
suggestion: suggestion: localize('com_agents_error_timeout_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')) { if (errorCode === 'NETWORK_ERROR' || errorMessage?.includes('Network Error')) {
return { return {
title: localize('com_agents_error_network_title'), title: localize('com_agents_error_network_title'),
@ -91,7 +93,7 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, cont
}; };
} }
// Handle specific HTTP status codes // Handle specific HTTP status codes before generic userMessage
const status = isErrorObject(error) ? (error as any)?.response?.status : null; const status = isErrorObject(error) ? (error as any)?.response?.status : null;
if (status) { if (status) {
if (status === 404) { if (status === 404) {
@ -107,7 +109,8 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, cont
title: localize('com_agents_error_invalid_request'), title: localize('com_agents_error_invalid_request'),
message: message:
(errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'), (errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'),
suggestion: localize('com_agents_error_bad_request_suggestion'), suggestion:
(errorData as any)?.suggestion || localize('com_agents_error_bad_request_suggestion'),
}; };
} }
@ -120,9 +123,19 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, cont
} }
} }
// Fallback to generic error // Use user-friendly message from backend if available (after specific status code handling)
if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) {
return {
title: getContextualTitle(),
message: (errorData as any).userMessage,
suggestion:
(errorData as any).suggestion || localize('com_agents_error_suggestion_generic'),
};
}
// Fallback to generic error with contextual title
return { return {
title: localize('com_agents_error_title'), title: getContextualTitle(),
message: localize('com_agents_error_generic'), message: localize('com_agents_error_generic'),
suggestion: localize('com_agents_error_suggestion_generic'), suggestion: localize('com_agents_error_suggestion_generic'),
}; };
@ -192,9 +205,9 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, cont
{/* Error content with proper headings and structure */} {/* Error content with proper headings and structure */}
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title">
{title} {title}
</h2> </h3>
<p <p
className="text-gray-600 dark:text-gray-400" className="text-gray-600 dark:text-gray-400"
id="error-message" id="error-message"

View file

@ -1,8 +1,6 @@
import React, { useMemo } from 'react'; import React from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { ChatContext } from '~/Providers'; import { ChatContext } from '~/Providers';
import { useChatHelpers } from '~/hooks';
/** /**
* Minimal marketplace provider that provides only what SidePanel actually needs * Minimal marketplace provider that provides only what SidePanel actually needs
@ -13,32 +11,7 @@ interface MarketplaceProviderProps {
} }
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => { export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => {
// Create more complete context to prevent FileRow and other component errors const chatHelpers = useChatHelpers(0, 'new');
// 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>; return <ChatContext.Provider value={chatHelpers as any}>{children}</ChatContext.Provider>;
}; };

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { ACCESS_ROLE_IDS } from 'librechat-data-provider'; import { ACCESS_ROLE_IDS, PermissionTypes } from 'librechat-data-provider';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react'; import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import { import {
useGetResourcePermissionsQuery, useGetResourcePermissionsQuery,
@ -15,8 +15,8 @@ import {
useToastContext, useToastContext,
} from '@librechat/client'; } from '@librechat/client';
import type { TPrincipal } from 'librechat-data-provider'; import type { TPrincipal } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks';
import ManagePermissionsDialog from './ManagePermissionsDialog'; import ManagePermissionsDialog from './ManagePermissionsDialog';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import PublicSharingToggle from './PublicSharingToggle'; import PublicSharingToggle from './PublicSharingToggle';
import PeoplePicker from './PeoplePicker/PeoplePicker'; import PeoplePicker from './PeoplePicker/PeoplePicker';
import AccessRolesPicker from './AccessRolesPicker'; import AccessRolesPicker from './AccessRolesPicker';
@ -38,6 +38,29 @@ export default function GrantAccessDialog({
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
// Check if user has permission to access people picker
const canViewUsers = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_USERS,
});
const canViewGroups = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_GROUPS,
});
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
// Determine type filter based on permissions
const peoplePickerTypeFilter = useMemo(() => {
if (canViewUsers && canViewGroups) {
return null; // Both types allowed
} else if (canViewUsers) {
return 'user' as const;
} else if (canViewGroups) {
return 'group' as const;
}
return null;
}, [canViewUsers, canViewGroups]);
const { const {
data: permissionsData, data: permissionsData,
// isLoading: isLoadingPermissions, // isLoading: isLoadingPermissions,
@ -177,26 +200,31 @@ export default function GrantAccessDialog({
</OGDialogTitle> </OGDialogTitle>
<div className="space-y-6 p-2"> <div className="space-y-6 p-2">
<PeoplePicker {hasPeoplePickerAccess && (
onSelectionChange={setNewShares} <>
placeholder={localize('com_ui_search_people_placeholder')} <PeoplePicker
/> onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
/>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" /> <Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary"> <label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')} {localize('com_ui_permission_level')}
</label> </label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
/>
</div> </div>
</div> </>
<AccessRolesPicker )}
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
/>
</div>
<PublicSharingToggle <PublicSharingToggle
isPublic={isPublic} isPublic={isPublic}
publicRole={publicRole} publicRole={publicRole}
@ -206,11 +234,13 @@ export default function GrantAccessDialog({
/> />
<div className="flex justify-between border-t pt-4"> <div className="flex justify-between border-t pt-4">
<div className="flex gap-2"> <div className="flex gap-2">
<ManagePermissionsDialog {hasPeoplePickerAccess && (
agentDbId={agentDbId} <ManagePermissionsDialog
agentName={agentName} agentDbId={agentDbId}
resourceType={resourceType} agentName={agentName}
/> resourceType={resourceType}
/>
)}
{agentId && ( {agentId && (
<Button <Button
variant="outline" variant="outline"

View file

@ -61,9 +61,13 @@ export default function ManagePermissionsDialog({
useEffect(() => { useEffect(() => {
if (permissionsData) { if (permissionsData) {
setManagedShares(currentShares); const shares = permissionsData.principals || [];
setManagedIsPublic(isPublic); const isPublicValue = permissionsData.public || false;
setManagedPublicRole(publicRole); const publicRoleValue = permissionsData.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
setManagedShares(shares);
setManagedIsPublic(isPublicValue);
setManagedPublicRole(publicRoleValue);
setHasChanges(false); setHasChanges(false);
} }
}, [permissionsData, isModalOpen]); }, [permissionsData, isModalOpen]);

View file

@ -10,12 +10,14 @@ interface PeoplePickerProps {
onSelectionChange: (principals: TPrincipal[]) => void; onSelectionChange: (principals: TPrincipal[]) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
typeFilter?: 'user' | 'group' | null;
} }
export default function PeoplePicker({ export default function PeoplePicker({
onSelectionChange, onSelectionChange,
placeholder, placeholder,
className = '', className = '',
typeFilter = null,
}: PeoplePickerProps) { }: PeoplePickerProps) {
const localize = useLocalize(); const localize = useLocalize();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@ -25,8 +27,9 @@ export default function PeoplePicker({
() => ({ () => ({
q: searchQuery, q: searchQuery,
limit: 30, limit: 30,
...(typeFilter && { type: typeFilter }),
}), }),
[searchQuery], [searchQuery, typeFilter],
); );
const { const {

View file

@ -1,7 +1,6 @@
import { AgentListResponse } from 'librechat-data-provider';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { cn } from '~/utils';
interface SmartLoaderProps { interface SmartLoaderProps {
/** Whether the content is currently loading */ /** Whether the content is currently loading */
isLoading: boolean; isLoading: boolean;
@ -69,7 +68,7 @@ export const SmartLoader: React.FC<SmartLoaderProps> = ({
* Hook to determine if we have meaningful data to show * Hook to determine if we have meaningful data to show
* Helps prevent loading states when we already have cached content * Helps prevent loading states when we already have cached content
*/ */
export const useHasData = (data: unknown): boolean => { export const useHasData = (data: AgentListResponse | undefined): boolean => {
if (!data) return false; if (!data) return false;
// Type guard for object data // Type guard for object data

View file

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import CategoryTabs from '../CategoryTabs'; import CategoryTabs from '../CategoryTabs';
import AgentGrid from '../AgentGrid'; import AgentGrid from '../AgentGrid';
import AgentCard from '../AgentCard'; import AgentCard from '../AgentCard';
import SearchBar from '../SearchBar'; import SearchBar from '../SearchBar';
import ErrorDisplay from '../ErrorDisplay'; import ErrorDisplay from '../ErrorDisplay';
import * as t from 'librechat-data-provider';
// Mock matchMedia // Mock matchMedia
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
@ -22,36 +23,110 @@ Object.defineProperty(window, 'matchMedia', {
})), })),
}); });
// Mock hooks // Mock Recoil
jest.mock( jest.mock('recoil', () => ({
'~/hooks/useLocalize', useRecoilValue: jest.fn(() => 'en'),
() => () => RecoilRoot: ({ children }: any) => children,
jest.fn((key: string, options?: any) => { atom: jest.fn(() => ({})),
const translations: Record<string, string> = { atomFamily: jest.fn(() => ({})),
com_agents_category_tabs_label: 'Agent Categories', selector: jest.fn(() => ({})),
com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`, selectorFamily: jest.fn(() => ({})),
com_agents_search_instructions: 'Type to search agents by name or description', useRecoilState: jest.fn(() => ['en', jest.fn()]),
com_agents_search_aria: 'Search agents', useSetRecoilState: jest.fn(() => jest.fn()),
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'); // Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: jest.fn() },
}),
}));
// Create the localize function once to be reused
const mockLocalize = 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',
com_agents_created_by: 'by',
com_agents_top_picks: 'Top Picks',
// ErrorDisplay translations
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
com_agents_error_network_title: 'Network Error',
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_suggestion: 'The requested resource could not be found',
com_agents_error_invalid_request: 'Invalid Request',
com_agents_error_bad_request_message: 'The request was invalid',
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: 'An internal server error occurred',
com_agents_error_server_suggestion: 'Please try again later',
com_agents_error_title: 'Error',
com_agents_error_generic: 'An unexpected error occurred',
com_agents_error_search_title: 'Search Error',
com_agents_error_category_title: 'Category Error',
com_agents_search_no_results: `No results found for "${options?.query}"`,
com_agents_category_empty: `No agents found in ${options?.category} category`,
com_agents_error_not_found_message: 'The requested resource could not be found',
};
return translations[key] || key;
});
// Mock useLocalize specifically
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: () => mockLocalize,
}));
// Mock hooks
jest.mock('~/hooks', () => ({
useLocalize: () => mockLocalize,
useDebounce: jest.fn(),
}));
jest.mock('~/data-provider/Agents', () => ({
useMarketplaceAgentsInfiniteQuery: jest.fn(),
}));
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: jest.fn(),
}));
// Mock utility functions
jest.mock('~/utils/agents', () => ({
renderAgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />),
getContactDisplayName: jest.fn((agent) => agent.authorName),
}));
// Mock SmartLoader
jest.mock('../SmartLoader', () => ({
SmartLoader: ({ children, isLoading }: any) => (isLoading ? <div>Loading...</div> : children),
useHasData: jest.fn(() => true),
}));
// Import the actual modules to get the mocked functions
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories } from '~/hooks/Agents';
import { useDebounce } from '~/hooks';
// Get typed mock functions
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
const mockUseAgentCategories = jest.mocked(useAgentCategories);
const mockUseDebounce = jest.mocked(useDebounce);
// Create wrapper with QueryClient // Create wrapper with QueryClient
const createWrapper = () => { const createWrapper = () => {
@ -66,14 +141,29 @@ const createWrapper = () => {
describe('Accessibility Improvements', () => { describe('Accessibility Improvements', () => {
beforeEach(() => { beforeEach(() => {
useDynamicAgentQuery.mockClear(); mockUseMarketplaceAgentsInfiniteQuery.mockClear();
mockUseAgentCategories.mockClear();
mockUseDebounce.mockClear();
// Default mock implementations
mockUseDebounce.mockImplementation((value) => value);
mockUseAgentCategories.mockReturnValue({
categories: [
{ value: 'promoted', label: 'Top Picks' },
{ value: 'all', label: 'All' },
{ value: 'productivity', label: 'Productivity' },
],
emptyCategory: { value: 'all', label: 'All' },
isLoading: false,
error: null,
});
}); });
describe('CategoryTabs Accessibility', () => { describe('CategoryTabs Accessibility', () => {
const categories = [ const categories = [
{ name: 'promoted', count: 5 }, { value: 'promoted', label: 'Top Picks', count: 5 },
{ name: 'all', count: 20 }, { value: 'all', label: 'All', count: 20 },
{ name: 'productivity', count: 8 }, { value: 'productivity', label: 'Productivity', count: 8 },
]; ];
it('implements proper tablist role and ARIA attributes', () => { it('implements proper tablist role and ARIA attributes', () => {
@ -96,7 +186,7 @@ describe('Accessibility Improvements', () => {
const tabs = screen.getAllByRole('tab'); const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3); expect(tabs).toHaveLength(3);
tabs.forEach((tab, index) => { tabs.forEach((tab) => {
expect(tab).toHaveAttribute('aria-selected'); expect(tab).toHaveAttribute('aria-selected');
expect(tab).toHaveAttribute('aria-controls'); expect(tab).toHaveAttribute('aria-controls');
expect(tab).toHaveAttribute('id'); expect(tab).toHaveAttribute('id');
@ -114,7 +204,7 @@ describe('Accessibility Improvements', () => {
/>, />,
); );
const promotedTab = screen.getByRole('tab', { name: /promoted category/ }); const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
// Test arrow key navigation // Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' }); fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
@ -141,8 +231,8 @@ describe('Accessibility Improvements', () => {
/>, />,
); );
const promotedTab = screen.getByRole('tab', { name: /promoted category/ }); const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const allTab = screen.getByRole('tab', { name: /all category/ }); const allTab = screen.getByRole('tab', { name: /All tab/ });
// Active tab should be focusable // Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0'); expect(promotedTab).toHaveAttribute('tabIndex', '0');
@ -159,7 +249,7 @@ describe('Accessibility Improvements', () => {
expect(searchRegion).toBeInTheDocument(); expect(searchRegion).toBeInTheDocument();
// Check input accessibility // Check input accessibility
const searchInput = screen.getByRole('searchbox'); const searchInput = screen.getByRole('textbox');
expect(searchInput).toHaveAttribute('id', 'agent-search'); expect(searchInput).toHaveAttribute('id', 'agent-search');
expect(searchInput).toHaveAttribute('aria-label', 'Search agents'); expect(searchInput).toHaveAttribute('aria-label', 'Search agents');
expect(searchInput).toHaveAttribute( expect(searchInput).toHaveAttribute(
@ -167,10 +257,9 @@ describe('Accessibility Improvements', () => {
'search-instructions search-results-count', 'search-instructions search-results-count',
); );
// Check hidden label // Check hidden label exists
expect(screen.getByText('Type to search agents by name or description')).toHaveClass( const hiddenLabel = screen.getByLabelText('Search agents');
'sr-only', expect(hiddenLabel).toBeInTheDocument();
);
}); });
it('provides accessible clear button', () => { it('provides accessible clear button', () => {
@ -197,10 +286,24 @@ describe('Accessibility Improvements', () => {
name: 'Test Agent', name: 'Test Agent',
description: 'A test agent for testing', description: 'A test agent for testing',
authorName: 'Test Author', authorName: 'Test Author',
created_at: 1704067200000,
avatar: null,
instructions: 'Test instructions',
provider: 'openai' as const,
model: 'gpt-4',
model_parameters: {
temperature: 0.7,
maxContextTokens: 4096,
max_context_tokens: 4096,
max_output_tokens: 1024,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
},
}; };
it('provides comprehensive ARIA labels', () => { it('provides comprehensive ARIA labels', () => {
render(<AgentCard agent={mockAgent} onClick={jest.fn()} />); render(<AgentCard agent={mockAgent as t.Agent} onClick={jest.fn()} />);
const card = screen.getByRole('button'); const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing'); expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
@ -210,14 +313,14 @@ describe('Accessibility Improvements', () => {
it('handles agents without descriptions', () => { it('handles agents without descriptions', () => {
const agentWithoutDesc = { ...mockAgent, description: undefined }; const agentWithoutDesc = { ...mockAgent, description: undefined };
render(<AgentCard agent={agentWithoutDesc} onClick={jest.fn()} />); render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
expect(screen.getByText('No description available')).toBeInTheDocument(); expect(screen.getByText('No description available')).toBeInTheDocument();
}); });
it('supports keyboard interaction', () => { it('supports keyboard interaction', () => {
const onClick = jest.fn(); const onClick = jest.fn();
render(<AgentCard agent={mockAgent} onClick={onClick} />); render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
const card = screen.getByRole('button'); const card = screen.getByRole('button');
@ -231,19 +334,20 @@ describe('Accessibility Improvements', () => {
describe('AgentGrid Accessibility', () => { describe('AgentGrid Accessibility', () => {
beforeEach(() => { beforeEach(() => {
useDynamicAgentQuery.mockReturnValue({ mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
data: { data: {
agents: [ pages: [
{ id: '1', name: 'Agent 1', description: 'First agent' }, {
{ id: '2', name: 'Agent 2', description: 'Second agent' }, data: [
{ 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, isLoading: false,
isFetching: false,
error: null, error: null,
refetch: jest.fn(), } as any);
});
}); });
it('implements proper tabpanel structure', () => { it('implements proper tabpanel structure', () => {
@ -272,7 +376,7 @@ describe('Accessibility Improvements', () => {
// Check grid role // Check grid role
const grid = screen.getByRole('grid'); const grid = screen.getByRole('grid');
expect(grid).toBeInTheDocument(); expect(grid).toBeInTheDocument();
expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in all category'); expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in All category');
// Check gridcells // Check gridcells
const gridcells = screen.getAllByRole('gridcell'); const gridcells = screen.getAllByRole('gridcell');
@ -280,13 +384,16 @@ describe('Accessibility Improvements', () => {
}); });
it('announces loading states to screen readers', () => { it('announces loading states to screen readers', () => {
useDynamicAgentQuery.mockReturnValue({ mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
data: { agents: [{ id: '1', name: 'Agent 1' }] }, data: {
isLoading: false, pages: [{ data: [{ id: '1', name: 'Agent 1' }] }],
},
isFetching: true, isFetching: true,
hasNextPage: true,
isFetchingNextPage: true,
isLoading: false,
error: null, error: null,
refetch: jest.fn(), } as any);
});
const Wrapper = createWrapper(); const Wrapper = createWrapper();
render( render(
@ -295,20 +402,26 @@ describe('Accessibility Improvements', () => {
</Wrapper>, </Wrapper>,
); );
// Check for loading announcement // Check for loading announcement when fetching more data
const loadingStatus = screen.getByRole('status', { name: 'Loading...' }); const loadingStatus = screen.getByRole('status');
expect(loadingStatus).toBeInTheDocument(); expect(loadingStatus).toBeInTheDocument();
expect(loadingStatus).toHaveAttribute('aria-live', 'polite'); expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
expect(loadingStatus).toHaveAttribute('aria-label', 'Loading...');
// Check for screen reader text
const srText = screen.getByText('Loading...');
expect(srText).toHaveClass('sr-only');
}); });
it('provides accessible empty states', () => { it('provides accessible empty states', () => {
useDynamicAgentQuery.mockReturnValue({ mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
data: { agents: [], pagination: { hasMore: false, total: 0, current: 1 } }, data: {
pages: [{ data: [] }],
},
isLoading: false, isLoading: false,
isFetching: false, isFetching: false,
error: null, error: null,
refetch: jest.fn(), } as any);
});
const Wrapper = createWrapper(); const Wrapper = createWrapper();
render( render(
@ -343,7 +456,7 @@ describe('Accessibility Improvements', () => {
expect(alert).toHaveAttribute('aria-atomic', 'true'); expect(alert).toHaveAttribute('aria-atomic', 'true');
// Check heading structure // Check heading structure
const heading = screen.getByRole('heading', { level: 2 }); const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveAttribute('id', 'error-title'); expect(heading).toHaveAttribute('id', 'error-title');
}); });
@ -382,7 +495,7 @@ describe('Accessibility Improvements', () => {
it('provides visible focus indicators on interactive elements', () => { it('provides visible focus indicators on interactive elements', () => {
render( render(
<CategoryTabs <CategoryTabs
categories={[{ name: 'test', count: 1 }]} categories={[{ value: 'test', label: 'Test', count: 1 }]}
activeTab="test" activeTab="test"
isLoading={false} isLoading={false}
onChange={jest.fn()} onChange={jest.fn()}
@ -391,7 +504,7 @@ describe('Accessibility Improvements', () => {
const tab = screen.getByRole('tab'); const tab = screen.getByRole('tab');
expect(tab.className).toContain('focus:outline-none'); expect(tab.className).toContain('focus:outline-none');
expect(tab.className).toContain('focus:ring-2'); expect(tab.className).toContain('focus:bg-gray-100');
}); });
}); });
@ -418,5 +531,3 @@ describe('Accessibility Improvements', () => {
}); });
}); });
}); });
export default {};

View file

@ -21,8 +21,21 @@ describe('AgentCard', () => {
name: 'Test Support', name: 'Test Support',
email: 'test@example.com', email: 'test@example.com',
}, },
avatar: '/test-avatar.png', avatar: { filepath: '/test-avatar.png', source: 'local' },
} as t.Agent; created_at: 1672531200000,
instructions: 'Test instructions',
provider: 'openai' as const,
model: 'gpt-4',
model_parameters: {
temperature: 0.7,
maxContextTokens: 4096,
max_context_tokens: 4096,
max_output_tokens: 1024,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
},
};
const mockOnClick = jest.fn(); const mockOnClick = jest.fn();
@ -39,7 +52,7 @@ describe('AgentCard', () => {
expect(screen.getByText('Test Support')).toBeInTheDocument(); expect(screen.getByText('Test Support')).toBeInTheDocument();
}); });
it('displays avatar when provided as string', () => { it('displays avatar when provided as object', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />); render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar'); const avatarImg = screen.getByAltText('Test Agent avatar');
@ -47,17 +60,17 @@ describe('AgentCard', () => {
expect(avatarImg).toHaveAttribute('src', '/test-avatar.png'); expect(avatarImg).toHaveAttribute('src', '/test-avatar.png');
}); });
it('displays avatar when provided as object with filepath', () => { it('displays avatar when provided as string', () => {
const agentWithObjectAvatar = { const agentWithStringAvatar = {
...mockAgent, ...mockAgent,
avatar: { filepath: '/object-avatar.png' }, avatar: '/string-avatar.png' as any, // Legacy support for string avatars
}; };
render(<AgentCard agent={agentWithObjectAvatar} onClick={mockOnClick} />); render(<AgentCard agent={agentWithStringAvatar} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar'); const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument(); expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/object-avatar.png'); expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
}); });
it('displays Bot icon fallback when no avatar is provided', () => { it('displays Bot icon fallback when no avatar is provided', () => {
@ -66,7 +79,7 @@ describe('AgentCard', () => {
avatar: undefined, avatar: undefined,
}; };
render(<AgentCard agent={agentWithoutAvatar} onClick={mockOnClick} />); render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
// Check for Bot icon presence by looking for the svg with lucide-bot class // Check for Bot icon presence by looking for the svg with lucide-bot class
const botIcon = document.querySelector('.lucide-bot'); const botIcon = document.querySelector('.lucide-bot');

View file

@ -1,15 +1,16 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, useNavigate } from 'react-router-dom'; import { MemoryRouter, useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import AgentDetail from '../AgentDetail'; import AgentDetail from '../AgentDetail';
import { useToast } from '~/hooks'; import { useToast } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
// Mock dependencies // Mock dependencies
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
@ -19,11 +20,8 @@ jest.mock('react-router-dom', () => ({
jest.mock('~/hooks', () => ({ jest.mock('~/hooks', () => ({
useToast: jest.fn(), useToast: jest.fn(),
})); useMediaQuery: jest.fn(() => false), // Mock as desktop by default
useLocalize: jest.fn(),
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: jest.fn(),
})); }));
jest.mock('~/utils/agents', () => ({ jest.mock('~/utils/agents', () => ({
@ -32,12 +30,17 @@ jest.mock('~/utils/agents', () => ({
)), )),
})); }));
jest.mock('~/Providers', () => ({
useChatContext: jest.fn(),
}));
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
useQueryClient: jest.fn(),
}));
// Mock clipboard API // Mock clipboard API
Object.assign(navigator, { const mockWriteText = jest.fn();
clipboard: {
writeText: jest.fn(),
},
});
const mockNavigate = jest.fn(); const mockNavigate = jest.fn();
const mockShowToast = jest.fn(); const mockShowToast = jest.fn();
@ -55,17 +58,23 @@ const mockAgent: t.Agent = {
provider: 'openai', provider: 'openai',
instructions: 'You are a helpful test agent', instructions: 'You are a helpful test agent',
tools: [], tools: [],
code_interpreter: false,
file_search: false,
author: 'test-user-id', author: 'test-user-id',
author_name: 'Test User', created_at: new Date().getTime(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1, version: 1,
support_contact: { support_contact: {
name: 'Support Team', name: 'Support Team',
email: 'support@test.com', email: 'support@test.com',
}, },
model_parameters: {
model: undefined,
temperature: null,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
}; };
// Helper function to render with providers // Helper function to render with providers
@ -93,10 +102,37 @@ describe('AgentDetail', () => {
jest.clearAllMocks(); jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(mockNavigate); (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
(useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast }); (useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast });
const { useLocalize } = require('~/hooks');
(useLocalize as jest.Mock).mockReturnValue(mockLocalize); (useLocalize as jest.Mock).mockReturnValue(mockLocalize);
// Reset clipboard mock // Mock useChatContext
(navigator.clipboard.writeText as jest.Mock).mockResolvedValue(undefined); const { useChatContext } = require('~/Providers');
(useChatContext as jest.Mock).mockReturnValue({
conversation: { conversationId: 'test-convo-id' },
newConversation: jest.fn(),
});
// Mock useQueryClient
const { useQueryClient } = require('@tanstack/react-query');
(useQueryClient as jest.Mock).mockReturnValue({
getQueryData: jest.fn(),
setQueryData: jest.fn(),
invalidateQueries: jest.fn(),
});
// Setup clipboard mock if it doesn't exist
if (!navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: mockWriteText,
},
configurable: true,
});
} else {
// If clipboard exists, spy on it
jest.spyOn(navigator.clipboard, 'writeText').mockImplementation(mockWriteText);
}
mockWriteText.mockResolvedValue(undefined);
}); });
const defaultProps = { const defaultProps = {
@ -145,7 +181,7 @@ describe('AgentDetail', () => {
it('should render 3-dot menu button', () => { it('should render 3-dot menu button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
expect(menuButton).toBeInTheDocument(); expect(menuButton).toBeInTheDocument();
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu'); expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
}); });
@ -162,12 +198,36 @@ describe('AgentDetail', () => {
describe('Interactions', () => { describe('Interactions', () => {
it('should navigate to chat when Start Chat button is clicked', async () => { it('should navigate to chat when Start Chat button is clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const mockNewConversation = jest.fn();
const mockQueryClient = {
getQueryData: jest.fn().mockReturnValue(null),
setQueryData: jest.fn(),
invalidateQueries: jest.fn(),
};
// Update mocks for this test
const { useChatContext } = require('~/Providers');
(useChatContext as jest.Mock).mockReturnValue({
conversation: { conversationId: 'test-convo-id' },
newConversation: mockNewConversation,
});
const { useQueryClient } = require('@tanstack/react-query');
(useQueryClient as jest.Mock).mockReturnValue(mockQueryClient);
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' }); const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
await user.click(startChatButton); await user.click(startChatButton);
expect(mockNavigate).toHaveBeenCalledWith('/c/new?agent_id=test-agent-id'); expect(mockNewConversation).toHaveBeenCalledWith({
template: {
conversationId: Constants.NEW_CONVO,
endpoint: EModelEndpoint.agents,
agent_id: 'test-agent-id',
title: 'Chat with Test Agent',
},
});
}); });
it('should not navigate when agent is null', async () => { it('should not navigate when agent is null', async () => {
@ -185,7 +245,7 @@ describe('AgentDetail', () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton); await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
@ -196,7 +256,7 @@ describe('AgentDetail', () => {
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown // Open dropdown
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton); await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
@ -217,18 +277,24 @@ describe('AgentDetail', () => {
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown // Open dropdown
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton); await user.click(menuButton);
// Click copy link // Click copy link
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton); await user.click(copyLinkButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith( // Wait for async clipboard operation to complete
`${window.location.origin}/c/new?agent_id=test-agent-id`, await waitFor(() => {
); expect(mockWriteText).toHaveBeenCalledWith(
expect(mockShowToast).toHaveBeenCalledWith({ `${window.location.origin}/c/new?agent_id=test-agent-id`,
message: 'Link copied', );
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_link_copied',
});
}); });
// Dropdown should close // Dropdown should close
@ -241,17 +307,22 @@ describe('AgentDetail', () => {
it('should show error toast when clipboard write fails', async () => { it('should show error toast when clipboard write fails', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
(navigator.clipboard.writeText as jest.Mock).mockRejectedValue(new Error('Clipboard error')); mockWriteText.mockRejectedValue(new Error('Clipboard error'));
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown and click copy link // Open dropdown and click copy link
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton); await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton); await user.click(copyLinkButton);
// Wait for clipboard operation to fail and error toast to show
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalled();
});
await waitFor(() => { await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({ expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_link_copy_failed', message: 'com_agents_link_copy_failed',
@ -261,7 +332,7 @@ describe('AgentDetail', () => {
it('should call onClose when dialog is closed', () => { it('should call onClose when dialog is closed', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
render(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />); renderWithProviders(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />);
// Since we're testing the onOpenChange callback, we need to trigger it // 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 // This would normally be done by the Dialog component when ESC is pressed or overlay is clicked
@ -274,16 +345,16 @@ describe('AgentDetail', () => {
it('should have proper ARIA attributes', () => { it('should have proper ARIA attributes', () => {
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu'); expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
expect(menuButton).toHaveAttribute('aria-label', 'More options'); expect(menuButton).toHaveAttribute('aria-label', 'com_agents_more_options');
}); });
it('should support keyboard navigation for dropdown', async () => { it('should support keyboard navigation for dropdown', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
// Focus and open with Enter key // Focus and open with Enter key
menuButton.focus(); menuButton.focus();
@ -296,7 +367,7 @@ describe('AgentDetail', () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />); renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' }); const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton); await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });

View file

@ -1,17 +1,30 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid'; import AgentGrid from '../AgentGrid';
import { useDynamicAgentQuery } from '~/hooks/Agents';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the marketplace agent query hook
jest.mock('~/data-provider/Agents', () => ({
useMarketplaceAgentsInfiniteQuery: jest.fn(),
}));
// Mock the dynamic agent query hook
jest.mock('~/hooks/Agents', () => ({ jest.mock('~/hooks/Agents', () => ({
useDynamicAgentQuery: jest.fn(), useAgentCategories: jest.fn(() => ({
categories: [],
isLoading: false,
error: null,
})),
}));
// Mock SmartLoader
jest.mock('../SmartLoader', () => ({
useHasData: jest.fn(() => true),
})); }));
// Mock useLocalize hook // Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => { jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
const mockTranslations: Record<string, string> = { const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks', com_agents_top_picks: 'Top Picks',
com_agents_all: 'All Agents', com_agents_all: 'All Agents',
@ -22,318 +35,342 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
com_agents_error_searching: 'Error searching agents', com_agents_error_searching: 'Error searching agents',
com_agents_no_results: 'No agents found. Try another search term.', com_agents_no_results: 'No agents found. Try another search term.',
com_agents_none_in_category: 'No agents found in this category', com_agents_none_in_category: 'No agents found in this category',
com_agents_search_empty_heading: 'No results found',
com_agents_empty_state_heading: 'No agents available',
com_agents_loading: 'Loading...',
com_agents_grid_announcement: '{{count}} agents in {{category}}',
com_agents_load_more_label: 'Load more agents from {{category}}',
}; };
return mockTranslations[key] || key;
let translation = mockTranslations[key] || key;
if (options) {
Object.keys(options).forEach((optionKey) => {
translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), options[optionKey]);
});
}
return translation;
}); });
// Mock getCategoryDisplayName and getCategoryDescription // Mock ErrorDisplay component
jest.mock('~/utils/agents', () => ({ jest.mock('../ErrorDisplay', () => ({
getCategoryDisplayName: (category: string) => { __esModule: true,
const names: Record<string, string> = { default: ({ error, onRetry }: { error: any; onRetry: () => void }) => (
promoted: 'Top Picks', <div>
all: 'All', <div>
general: 'General', {`Error: `}
hr: 'HR', {typeof error === 'string' ? error : error?.message || 'Unknown error'}
finance: 'Finance', </div>
}; <button onClick={onRetry}>{`Retry`}</button>
return names[category] || category; </div>
}, ),
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< // Mock AgentCard component
typeof useDynamicAgentQuery jest.mock('../AgentCard', () => ({
>; __esModule: true,
default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => (
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>
),
}));
describe('AgentGrid Integration with useDynamicAgentQuery', () => { // Import the actual modules to get the mocked functions
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
const mockOnSelectAgent = jest.fn(); const mockOnSelectAgent = jest.fn();
const mockAgents: Partial<t.Agent>[] = [ const mockAgents: t.Agent[] = [
{ {
id: '1', id: '1',
name: 'Test Agent 1', name: 'Test Agent 1',
description: 'First test agent', description: 'First test agent',
avatar: '/avatar1.png', avatar: { filepath: '/avatar1.png', source: 'local' },
category: 'finance',
authorName: 'Author 1',
created_at: 1672531200000,
instructions: null,
provider: 'custom',
model: 'gpt-4',
model_parameters: {
temperature: null,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
}, },
{ {
id: '2', id: '2',
name: 'Test Agent 2', name: 'Test Agent 2',
description: 'Second test agent', description: 'Second test agent',
avatar: { filepath: '/avatar2.png' }, avatar: { filepath: '/avatar2.png', source: 'local' },
category: 'finance',
authorName: 'Author 2',
created_at: 1672531200000,
instructions: null,
provider: 'custom',
model: 'gpt-4',
model_parameters: {
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
presence_penalty: null,
},
}, },
]; ];
const defaultMockQueryResult = { const defaultMockQueryResult = {
data: { data: {
agents: mockAgents, pages: [
pagination: { {
current: 1, data: mockAgents,
hasMore: true, },
total: 10, ],
},
}, },
isLoading: false, isLoading: false,
error: null, error: null,
isFetching: false, isFetching: false,
queryType: 'promoted' as const, isFetchingNextPage: false,
}; hasNextPage: true,
fetchNextPage: jest.fn(),
refetch: jest.fn(),
} as any;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult); mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(defaultMockQueryResult);
}); });
describe('Query Integration', () => { describe('Query Integration', () => {
it('should call useDynamicAgentQuery with correct parameters', () => { it('should call useGetMarketplaceAgentsQuery with correct parameters for category search', () => {
render( render(
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />, <AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
); );
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({ expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
requiredPermission: 1,
category: 'finance', category: 'finance',
searchQuery: 'test query', search: 'test query',
page: 1,
limit: 6, limit: 6,
}); });
}); });
it('should update page when "See More" is clicked', async () => { it('should call useGetMarketplaceAgentsQuery with promoted=1 for promoted category', () => {
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} />); render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Top Picks')).toBeInTheDocument(); expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
expect(screen.getByText('Our recommended agents')).toBeInTheDocument(); requiredPermission: 1,
promoted: 1,
limit: 6,
});
}); });
it('should display correct title for search results', () => { it('should call useGetMarketplaceAgentsQuery without category filter for "all" category', () => {
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} />); render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('All')).toBeInTheDocument(); expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
expect(screen.getByText('Browse all available agents')).toBeInTheDocument(); requiredPermission: 1,
limit: 6,
});
});
it('should not include category in search when category is "all" or "promoted"', () => {
render(<AgentGrid category="all" searchQuery="test" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
requiredPermission: 1,
search: 'test',
limit: 6,
});
}); });
}); });
describe('Loading and Error States', () => { // Create wrapper with QueryClient
it('should show loading skeleton when isLoading is true and no data', () => { const createWrapper = () => {
mockUseDynamicAgentQuery.mockReturnValue({ const queryClient = new QueryClient({
...defaultMockQueryResult, defaultOptions: { queries: { retry: false } },
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', () => { return ({ children }: { children: React.ReactNode }) => (
mockUseDynamicAgentQuery.mockReturnValue({ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
...defaultMockQueryResult, );
data: undefined, };
error: new Error('Test error'),
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); describe('Agent Display', () => {
it('should render agent cards when data is available', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.getByText('Error loading agents')).toBeInTheDocument(); expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
}); expect(screen.getByTestId('agent-card-2')).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 1')).toBeInTheDocument();
expect(screen.getByText('Test Agent 2')).toBeInTheDocument(); expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
}); });
});
describe('Agent Interaction', () => {
it('should call onSelectAgent when agent card is clicked', () => { it('should call onSelectAgent when agent card is clicked', () => {
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); const Wrapper = createWrapper();
render(
const agentCard = screen.getByLabelText('Test Agent 1 agent card'); <Wrapper>
fireEvent.click(agentCard); <AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
fireEvent.click(screen.getByTestId('agent-card-1'));
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]); expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
}); });
}); });
describe('Pagination', () => { describe('Loading States', () => {
it('should show "See More" button when hasMore is true', () => { it('should show loading state when isLoading is true', () => {
mockUseDynamicAgentQuery.mockReturnValue({ mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { isLoading: true,
agents: mockAgents, data: undefined,
pagination: {
current: 1,
hasMore: true,
total: 10,
},
},
}); });
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.getByText('See more')).toBeInTheDocument(); // Should show skeleton loading state
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
}); });
it('should not show "See More" button when hasMore is false', () => { it('should show empty state when no agents are available', () => {
mockUseDynamicAgentQuery.mockReturnValue({ mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { data: {
agents: mockAgents, pages: [
pagination: { {
current: 1, data: [],
hasMore: false, },
total: 2, ],
},
}, },
}); });
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />); const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.queryByText('See more')).not.toBeInTheDocument(); expect(screen.getByText('No agents available')).toBeInTheDocument();
}); });
}); });
describe('Empty States', () => { describe('Error Handling', () => {
it('should show empty state for search results', () => { it('should show error display when query has error', () => {
mockUseDynamicAgentQuery.mockReturnValue({ const mockError = new Error('Failed to fetch agents');
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { error: mockError,
agents: [], isError: true,
pagination: { current: 1, hasMore: false, total: 0 }, data: undefined,
},
queryType: 'search',
}); });
const Wrapper = createWrapper();
render( render(
<AgentGrid category="all" searchQuery="no results" onSelectAgent={mockOnSelectAgent} />, <Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
); );
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument(); expect(screen.getByText('Error: Failed to fetch agents')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});
describe('Search Results', () => {
it('should show search results title when searching', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="finance"
searchQuery="automation"
onSelectAgent={mockOnSelectAgent}
/>
</Wrapper>,
);
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
}); });
it('should show empty state for category with no agents', () => { it('should show empty search results message', () => {
mockUseDynamicAgentQuery.mockReturnValue({ mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult, ...defaultMockQueryResult,
data: { data: {
agents: [], pages: [
pagination: { current: 1, hasMore: false, total: 0 }, {
data: [],
},
],
}, },
queryType: 'category',
}); });
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />); const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="finance"
searchQuery="nonexistent"
onSelectAgent={mockOnSelectAgent}
/>
</Wrapper>,
);
expect(screen.getByText('No agents found in this category')).toBeInTheDocument(); expect(screen.getByText('No results found')).toBeInTheDocument();
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
});
});
describe('Load More Functionality', () => {
it('should show "See more" button when hasNextPage is true', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(
screen.getByRole('button', { name: 'Load more agents from Finance' }),
).toBeInTheDocument();
});
it('should not show "See more" button when hasNextPage is false', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
hasNextPage: false,
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument();
}); });
}); });
}); });

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import CategoryTabs from '../CategoryTabs'; import CategoryTabs from '../CategoryTabs';

View file

@ -38,6 +38,9 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
com_agents_error_server_suggestion: 'Please try again in a few moments.', com_agents_error_server_suggestion: 'Please try again in a few moments.',
com_agents_error_search_title: 'Search Error', com_agents_error_search_title: 'Search Error',
com_agents_error_category_title: 'Category Error', com_agents_error_category_title: 'Category Error',
com_agents_error_timeout_title: 'Connection Timeout',
com_agents_error_timeout_message: 'The request took too long to complete.',
com_agents_error_timeout_suggestion: 'Please check your internet connection and try again.',
com_agents_search_no_results: `No agents found for "${options?.query}"`, com_agents_search_no_results: `No agents found for "${options?.query}"`,
com_agents_category_empty: `No agents found in the ${options?.category} category`, com_agents_category_empty: `No agents found in the ${options?.category} category`,
com_agents_error_retry: 'Try Again', com_agents_error_retry: 'Try Again',
@ -298,5 +301,3 @@ describe('ErrorDisplay', () => {
}); });
}); });
}); });
export default {};

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
@ -17,6 +18,11 @@ jest.mock('~/Providers', () => ({
useChatContext: jest.fn(), useChatContext: jest.fn(),
})); }));
// Mock useChatHelpers to avoid Recoil dependency
jest.mock('~/hooks', () => ({
useChatHelpers: jest.fn(),
}));
const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>; const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
// Test component that consumes the context // Test component that consumes the context
@ -35,6 +41,16 @@ const TestConsumer: React.FC = () => {
describe('MarketplaceProvider', () => { describe('MarketplaceProvider', () => {
beforeEach(() => { beforeEach(() => {
mockedUseChatContext.mockClear(); mockedUseChatContext.mockClear();
// Mock useChatHelpers return value
const { useChatHelpers } = require('~/hooks');
(useChatHelpers as jest.Mock).mockReturnValue({
conversation: {
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
},
});
}); });
it('provides correct marketplace context values', () => { it('provides correct marketplace context values', () => {
@ -46,7 +62,7 @@ describe('MarketplaceProvider', () => {
}, },
}; };
mockedUseChatContext.mockReturnValue(mockContext); mockedUseChatContext.mockReturnValue(mockContext as ReturnType<typeof useChatContext>);
render( render(
<MarketplaceProvider> <MarketplaceProvider>

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import SearchBar from '../SearchBar'; import SearchBar from '../SearchBar';
@ -9,7 +9,7 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
// Mock useDebounce hook // Mock useDebounce hook
jest.mock('~/hooks', () => ({ jest.mock('~/hooks', () => ({
useDebounce: (value: string, delay: number) => value, // Return value immediately for testing useDebounce: (value: string) => value, // Return value immediately for testing
})); }));
describe('SearchBar', () => { describe('SearchBar', () => {

View file

@ -1,2 +1,5 @@
export * from './queries'; export * from './queries';
export * from './mutations'; export * from './mutations';
// Re-export specific marketplace queries for easier imports
export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries';

View file

@ -1,12 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider'; import { dataService, MutationKeys, PERMISSION_BITS, QueryKeys } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query'; import type { QueryClient, UseMutationResult } from '@tanstack/react-query';
/** /**
* AGENTS * AGENTS
*/ */
export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [
{ requiredPermission: PERMISSION_BITS.VIEW },
{ requiredPermission: PERMISSION_BITS.EDIT },
];
/** /**
* Create a new agent * Create a new agent
*/ */
@ -18,21 +21,22 @@ export const useCreateAgentMutation = (
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (newAgent, variables, context) => { onSuccess: (newAgent, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([ ((keys: t.AgentListParams[]) => {
QueryKeys.agents, keys.forEach((key) => {
defaultOrderQuery, const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
]); if (!listRes) {
return options?.onSuccess?.(newAgent, variables, context);
}
const currentAgents = [newAgent, ...JSON.parse(JSON.stringify(listRes.data))];
if (!listRes) { queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
return options?.onSuccess?.(newAgent, variables, context); ...listRes,
} data: currentAgents,
});
});
})(allAgentViewAndEditQueryKeys);
invalidateAgentMarketplaceQueries(queryClient);
const currentAgents = [newAgent, ...JSON.parse(JSON.stringify(listRes.data))];
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: currentAgents,
});
return options?.onSuccess?.(newAgent, variables, context); return options?.onSuccess?.(newAgent, variables, context);
}, },
}); });
@ -58,30 +62,33 @@ export const useUpdateAgentMutation = (
return options?.onError?.(error, variables, context); return options?.onError?.(error, variables, context);
}, },
onSuccess: (updatedAgent, variables, context) => { onSuccess: (updatedAgent, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([ ((keys: t.AgentListParams[]) => {
QueryKeys.agents, keys.forEach((key) => {
defaultOrderQuery, const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
]);
if (!listRes) { if (!listRes) {
return options?.onSuccess?.(updatedAgent, variables, context); return options?.onSuccess?.(updatedAgent, variables, context);
}
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
} }
return agent;
}), queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
}); ...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
}
return agent;
}),
});
});
})(allAgentViewAndEditQueryKeys);
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent); queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
queryClient.setQueryData<t.Agent>( queryClient.setQueryData<t.Agent>(
[QueryKeys.agent, variables.agent_id, 'expanded'], [QueryKeys.agent, variables.agent_id, 'expanded'],
updatedAgent, updatedAgent,
); );
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.(updatedAgent, variables, context); return options?.onSuccess?.(updatedAgent, variables, context);
}, },
}, },
@ -103,24 +110,28 @@ export const useDeleteAgentMutation = (
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (_data, variables, context) => { onSuccess: (_data, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([ const data = ((keys: t.AgentListParams[]) => {
QueryKeys.agents, let data: t.Agent[] = [];
defaultOrderQuery, keys.forEach((key) => {
]); const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (!listRes) { if (!listRes) {
return options?.onSuccess?.(_data, variables, context); return options?.onSuccess?.(_data, variables, context);
} }
const data = listRes.data.filter((agent) => agent.id !== variables.agent_id); data = listRes.data.filter((agent) => agent.id !== variables.agent_id);
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], { queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes, ...listRes,
data, data,
}); });
});
return data;
})(allAgentViewAndEditQueryKeys);
queryClient.removeQueries([QueryKeys.agent, variables.agent_id]); queryClient.removeQueries([QueryKeys.agent, variables.agent_id]);
queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']); queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']);
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.(_data, variables, data); return options?.onSuccess?.(_data, variables, data);
}, },
@ -142,22 +153,23 @@ export const useDuplicateAgentMutation = (
onMutate: options?.onMutate, onMutate: options?.onMutate,
onError: options?.onError, onError: options?.onError,
onSuccess: ({ agent, actions }, variables, context) => { onSuccess: ({ agent, actions }, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([ ((keys: t.AgentListParams[]) => {
QueryKeys.agents, keys.forEach((key) => {
defaultOrderQuery, const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
]); if (listRes) {
const currentAgents = [agent, ...listRes.data];
if (listRes) { queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
const currentAgents = [agent, ...listRes.data]; ...listRes,
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], { data: currentAgents,
...listRes, });
data: currentAgents, }
}); });
} })(allAgentViewAndEditQueryKeys);
const existingActions = queryClient.getQueryData<t.Action[]>([QueryKeys.actions]) || []; const existingActions = queryClient.getQueryData<t.Action[]>([QueryKeys.actions]) || [];
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], existingActions.concat(actions)); queryClient.setQueryData<t.Action[]>([QueryKeys.actions], existingActions.concat(actions));
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.({ agent, actions }, variables, context); return options?.onSuccess?.({ agent, actions }, variables, context);
}, },
@ -177,8 +189,7 @@ export const useUploadAgentAvatarMutation = (
unknown // context unknown // context
> => { > => {
return useMutation([MutationKeys.agentAvatarUpload], { return useMutation([MutationKeys.agentAvatarUpload], {
mutationFn: ({ postCreation, ...variables }: t.AgentAvatarVariables) => mutationFn: (variables: t.AgentAvatarVariables) => dataService.uploadAgentAvatar(variables),
dataService.uploadAgentAvatar(variables),
...(options || {}), ...(options || {}),
}); });
}; };
@ -202,26 +213,25 @@ export const useUpdateAgentAction = (
onMutate: (variables) => options?.onMutate?.(variables), onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context), onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updateAgentActionResponse, variables, context) => { onSuccess: (updateAgentActionResponse, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
if (!listRes) {
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
}
const updatedAgent = updateAgentActionResponse[0]; const updatedAgent = updateAgentActionResponse[0];
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], { if (!listRes) {
...listRes, return options?.onSuccess?.(updateAgentActionResponse, variables, context);
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
} }
return agent; queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
}), ...listRes,
}); data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
}
return agent;
}),
});
});
})(allAgentViewAndEditQueryKeys);
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => { queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
if (!prev) { if (!prev) {
@ -275,28 +285,28 @@ export const useDeleteAgentAction = (
return action.action_id !== variables.action_id; return action.action_id !== variables.action_id;
}); });
}); });
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], (prev) => {
if (!prev) {
return prev;
}
queryClient.setQueryData<t.AgentListResponse>( return {
[QueryKeys.agents, defaultOrderQuery], ...prev,
(prev) => { data: prev.data.map((agent) => {
if (!prev) { if (agent.id === variables.agent_id) {
return prev; return {
} ...agent,
tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')),
return { };
...prev, }
data: prev.data.map((agent) => { return agent;
if (agent.id === variables.agent_id) { }),
return { };
...agent, });
tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')), });
}; })(allAgentViewAndEditQueryKeys);
}
return agent;
}),
};
},
);
const updaterFn = (prev) => { const updaterFn = (prev) => {
if (!prev) { if (!prev) {
return prev; return prev;
@ -337,25 +347,30 @@ export const useRevertAgentVersionMutation = (
onSuccess: (revertedAgent, variables, context) => { onSuccess: (revertedAgent, variables, context) => {
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent); queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
const listRes = queryClient.getQueryData<t.AgentListResponse>([ ((keys: t.AgentListParams[]) => {
QueryKeys.agents, keys.forEach((key) => {
defaultOrderQuery, const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
]);
if (listRes) { if (listRes) {
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], { queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes, ...listRes,
data: listRes.data.map((agent) => { data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) { if (agent.id === variables.agent_id) {
return revertedAgent; return revertedAgent;
} }
return agent; return agent;
}), }),
});
}
}); });
} })(allAgentViewAndEditQueryKeys);
return options?.onSuccess?.(revertedAgent, variables, context); return options?.onSuccess?.(revertedAgent, variables, context);
}, },
}, },
); );
}; };
export const invalidateAgentMarketplaceQueries = (queryClient: QueryClient) => {
queryClient.invalidateQueries([QueryKeys.marketplaceAgents]);
};

View file

@ -1,12 +1,41 @@
import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider'; import {
import { useQuery, useQueryClient } from '@tanstack/react-query'; QueryKeys,
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; dataService,
Permissions,
EModelEndpoint,
PERMISSION_BITS,
PermissionTypes,
} from 'librechat-data-provider';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type {
QueryObserverResult,
UseQueryOptions,
UseInfiniteQueryOptions,
} from '@tanstack/react-query';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useHasAccess } from '~/hooks';
/**
* Hook to determine the appropriate permission level for agent queries based on marketplace configuration
*/
export const useAgentListingDefaultPermissionLevel = () => {
const hasMarketplaceAccess = useHasAccess({
permissionType: PermissionTypes.MARKETPLACE,
permission: Permissions.USE,
});
// When marketplace is active: EDIT permissions (builder mode)
// When marketplace is not active: VIEW permissions (browse mode)
return hasMarketplaceAccess ? PERMISSION_BITS.EDIT : PERMISSION_BITS.VIEW;
};
/** /**
* AGENTS * AGENTS
*/ */
export const defaultAgentParams: t.AgentListParams = {
limit: 10,
requiredPermission: PERMISSION_BITS.EDIT,
};
/** /**
* Hook for getting all available tools for A * Hook for getting all available tools for A
*/ */
@ -27,7 +56,7 @@ export const useAvailableAgentToolsQuery = (): QueryObserverResult<t.TPlugin[]>
* Hook for listing all Agents, with optional parameters provided for pagination and sorting * Hook for listing all Agents, with optional parameters provided for pagination and sorting
*/ */
export const useListAgentsQuery = <TData = t.AgentListResponse>( export const useListAgentsQuery = <TData = t.AgentListResponse>(
params: t.AgentListParams = defaultOrderQuery, params: t.AgentListParams = defaultAgentParams,
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>, config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => { ): QueryObserverResult<TData> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -76,143 +105,6 @@ 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,
},
);
};
/** /**
* Hook for retrieving full agent details including sensitive configuration (EDIT permission) * Hook for retrieving full agent details including sensitive configuration (EDIT permission)
*/ */
@ -235,3 +127,60 @@ export const useGetExpandedAgentByIdQuery = (
}, },
); );
}; };
/**
* MARKETPLACE
*/
/**
* Hook for getting agent categories for marketplace tabs
*/
export const useGetAgentCategoriesQuery = (
config?: UseQueryOptions<t.TMarketplaceCategory[]>,
): QueryObserverResult<t.TMarketplaceCategory[]> => {
return useQuery<t.TMarketplaceCategory[]>(
[QueryKeys.agentCategories],
() => dataService.getAgentCategories(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
...config,
},
);
};
/**
* Hook for infinite loading of marketplace agents with cursor-based pagination
*/
export const useMarketplaceAgentsInfiniteQuery = (
params: {
requiredPermission: number;
category?: string;
search?: string;
limit?: number;
promoted?: 0 | 1;
cursor?: string; // For pagination
},
config?: UseInfiniteQueryOptions<t.AgentListResponse, unknown>,
) => {
return useInfiniteQuery<t.AgentListResponse>({
queryKey: [QueryKeys.marketplaceAgents, params],
queryFn: ({ pageParam }) => {
const queryParams = { ...params };
if (pageParam) {
queryParams.cursor = pageParam.toString();
}
return dataService.getMarketplaceAgents(queryParams);
},
getNextPageParam: (lastPage) => lastPage?.after ?? undefined,
enabled: !!params.requiredPermission,
keepPreviousData: true,
staleTime: 2 * 60 * 1000, // 2 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
});
};

View file

@ -1,6 +1,8 @@
import { renderHook } from '@testing-library/react'; import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import useAgentCategories from '../useAgentCategories'; import useAgentCategories from '../useAgentCategories';
import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// Mock the useLocalize hook // Mock the useLocalize hook
jest.mock('~/hooks/useLocalize', () => ({ jest.mock('~/hooks/useLocalize', () => ({
@ -11,25 +13,68 @@ jest.mock('~/hooks/useLocalize', () => ({
}, },
})); }));
describe('useAgentCategories', () => { // Mock the data provider
it('should return processed categories with correct structure', () => { jest.mock('~/data-provider/Agents', () => ({
const { result } = renderHook(() => useAgentCategories()); useGetAgentCategoriesQuery: jest.fn(() => ({
data: [
{ value: 'general', label: 'com_ui_agent_category_general' },
{ value: 'hr', label: 'com_ui_agent_category_hr' },
{ value: 'rd', label: 'com_ui_agent_category_rd' },
{ value: 'finance', label: 'com_ui_agent_category_finance' },
{ value: 'it', label: 'com_ui_agent_category_it' },
{ value: 'sales', label: 'com_ui_agent_category_sales' },
{ value: 'aftersales', label: 'com_ui_agent_category_aftersales' },
{ value: 'promoted', label: 'Promoted' }, // Should be filtered out
{ value: 'all', label: 'All' }, // Should be filtered out
],
isLoading: false,
error: null,
})),
}));
// Check that we have the expected number of categories const createWrapper = () => {
expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length); const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useAgentCategories', () => {
it('should return processed categories with correct structure', async () => {
const { result } = renderHook(() => useAgentCategories(), {
wrapper: createWrapper(),
});
await waitFor(() => {
// Check that we have the expected number of categories (excluding 'promoted' and 'all')
expect(result.current.categories.length).toBe(7);
});
// Check that the first category has the expected structure // Check that the first category has the expected structure
const firstCategory = result.current.categories[0]; const firstCategory = result.current.categories[0];
const firstOriginalCategory = AGENT_CATEGORIES[0]; expect(firstCategory.value).toBe('general');
expect(firstCategory.label).toBe('com_ui_agent_category_general');
expect(firstCategory.value).toBe(firstOriginalCategory.value);
// Check that labels are properly translated
expect(firstCategory.label).toBe('General (Translated)');
expect(firstCategory.className).toBe('w-full'); expect(firstCategory.className).toBe('w-full');
// Verify special categories are filtered out
const categoryValues = result.current.categories.map((cat) => cat.value);
expect(categoryValues).not.toContain('promoted');
expect(categoryValues).not.toContain('all');
// Check the empty category // Check the empty category
expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value); expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value);
expect(result.current.emptyCategory.label).toBeTruthy(); expect(result.current.emptyCategory.label).toBe('General (Translated)');
expect(result.current.emptyCategory.className).toBe('w-full');
// Check loading state
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
}); });
}); });

View file

@ -1,360 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useDynamicAgentQuery } from '../useDynamicAgentQuery';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
// Mock the data provider queries
jest.mock('~/data-provider', () => ({
useGetPromotedAgentsQuery: jest.fn(),
useGetAgentsByCategoryQuery: jest.fn(),
useSearchAgentsQuery: jest.fn(),
}));
const mockUseGetPromotedAgentsQuery = useGetPromotedAgentsQuery as jest.MockedFunction<
typeof useGetPromotedAgentsQuery
>;
const mockUseGetAgentsByCategoryQuery = useGetAgentsByCategoryQuery as jest.MockedFunction<
typeof useGetAgentsByCategoryQuery
>;
const mockUseSearchAgentsQuery = useSearchAgentsQuery as jest.MockedFunction<
typeof useSearchAgentsQuery
>;
describe('useDynamicAgentQuery', () => {
const defaultMockQueryResult = {
data: undefined,
isLoading: false,
error: null,
isFetching: false,
refetch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Set default mock returns
mockUseGetPromotedAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseGetAgentsByCategoryQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseSearchAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
});
describe('Search Query Type', () => {
it('should use search query when searchQuery is provided', () => {
const mockSearchResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseSearchAgentsQuery.mockReturnValue(mockSearchResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
// Should call search query with correct parameters
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
category: 'hr',
page: 1,
limit: 6,
},
expect.objectContaining({
enabled: true,
staleTime: 120000,
refetchOnWindowFocus: false,
keepPreviousData: true,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 1,
}),
);
// Should return search query result
expect(result.current.data).toBe(mockSearchResult.data);
expect(result.current.queryType).toBe('search');
});
it('should not include category in search when category is "all" or "promoted"', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
page: 1,
limit: 6,
// No category parameter should be included
},
expect.any(Object),
);
});
});
describe('Promoted Query Type', () => {
it('should use promoted query when category is "promoted" and no search', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '',
page: 2,
limit: 8,
}),
);
// Should call promoted query with correct parameters (no showAll)
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 2,
limit: 8,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockPromotedResult.data);
expect(result.current.queryType).toBe('promoted');
});
});
describe('All Agents Query Type', () => {
it('should use promoted query with showAll when category is "all" and no search', () => {
const mockAllResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
// Mock the second call to useGetPromotedAgentsQuery (for "all" category)
mockUseGetPromotedAgentsQuery
.mockReturnValueOnce(defaultMockQueryResult as any) // First call for promoted
.mockReturnValueOnce(mockAllResult as any); // Second call for all
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should call promoted query with showAll parameter
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 1,
limit: 6,
showAll: 'true',
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.queryType).toBe('all');
});
});
describe('Category Query Type', () => {
it('should use category query for specific categories', () => {
const mockCategoryResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockCategoryResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'finance',
searchQuery: '',
page: 3,
limit: 10,
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'finance',
page: 3,
limit: 10,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockCategoryResult.data);
expect(result.current.queryType).toBe('category');
});
});
describe('Query Configuration', () => {
it('should apply correct query configuration to all queries', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
const expectedConfig = expect.objectContaining({
staleTime: 120000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
});
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expectedConfig,
);
});
it('should enable only the correct query based on query type', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Category query should be enabled
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
// Other queries should be disabled
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
});
describe('Default Parameters', () => {
it('should use default page and limit when not provided', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'general',
searchQuery: '',
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'general',
page: 1,
limit: 6,
},
expect.any(Object),
);
});
});
describe('Return Values', () => {
it('should return all necessary query properties', () => {
const mockResult = {
data: { agents: [{ id: '1', name: 'Test Agent' }] },
isLoading: true,
error: null,
isFetching: false,
refetch: jest.fn(),
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'it',
searchQuery: '',
page: 1,
limit: 6,
}),
);
expect(result.current).toEqual({
data: mockResult.data,
isLoading: mockResult.isLoading,
error: mockResult.error,
isFetching: mockResult.isFetching,
refetch: mockResult.refetch,
queryType: 'category',
});
});
});
describe('Edge Cases', () => {
it('should handle empty search query as no search', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '', // Empty string should not trigger search
page: 1,
limit: 6,
}),
);
// Should use promoted query, not search query
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
it('should fallback to promoted query for unknown query types', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [] },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'unknown-category',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should determine this as 'category' type and use category query
expect(result.current.queryType).toBe('category');
});
});
});

View file

@ -1,7 +1,6 @@
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 { default as useAgentCategories } from './useAgentCategories';
export { useDynamicAgentQuery } from './useDynamicAgentQuery';
export type { ProcessedAgentCategory } from './useAgentCategories'; export type { ProcessedAgentCategory } from './useAgentCategories';
export { default as useAgentCapabilities } from './useAgentCapabilities'; export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig'; export { default as useGetAgentsConfig } from './useGetAgentsConfig';

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { useGetAgentCategoriesQuery } from '~/data-provider'; import { useGetAgentCategoriesQuery } from '~/data-provider/Agents';
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// This interface matches the structure used by the ControlCombobox component // This interface matches the structure used by the ControlCombobox component
@ -9,6 +9,7 @@ export interface ProcessedAgentCategory {
label: string; // Translated label label: string; // Translated label
value: string; // Category value value: string; // Category value
className?: string; className?: string;
icon?: string;
} }
/** /**

View file

@ -1,6 +1,6 @@
import { TAgentsMap } from 'librechat-data-provider'; import { TAgentsMap } from 'librechat-data-provider';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useListAgentsQuery } from '~/data-provider'; import { useListAgentsQuery, useAgentListingDefaultPermissionLevel } from '~/data-provider';
import { mapAgents } from '~/utils'; import { mapAgents } from '~/utils';
export default function useAgentsMap({ export default function useAgentsMap({
@ -8,10 +8,15 @@ export default function useAgentsMap({
}: { }: {
isAuthenticated: boolean; isAuthenticated: boolean;
}): TAgentsMap | undefined { }): TAgentsMap | undefined {
const { data: agentsList = null } = useListAgentsQuery(undefined, { const permissionLevel = useAgentListingDefaultPermissionLevel();
select: (res) => mapAgents(res.data),
enabled: isAuthenticated, const { data: agentsList = null } = useListAgentsQuery(
}); { requiredPermission: permissionLevel },
{
select: (res) => mapAgents(res.data),
enabled: isAuthenticated,
},
);
const agents = useMemo<TAgentsMap | undefined>(() => { const agents = useMemo<TAgentsMap | undefined>(() => {
return agentsList !== null ? agentsList : undefined; return agentsList !== null ? agentsList : undefined;

View file

@ -1,112 +0,0 @@
import { useMemo } from 'react';
import type { UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
interface UseDynamicAgentQueryParams {
category: string;
searchQuery: string;
page?: number;
limit?: number;
}
/**
* Single dynamic query hook that replaces 4 separate conditional queries
* Determines the appropriate query based on category and search state
*/
export const useDynamicAgentQuery = ({
category,
searchQuery,
page = 1,
limit = 6,
}: UseDynamicAgentQueryParams) => {
// Shared query configuration optimized to prevent unnecessary loading states
const queryConfig: UseQueryOptions<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
};
};

View file

@ -8,6 +8,7 @@ import {
isAgentsEndpoint, isAgentsEndpoint,
getConfigDefaults, getConfigDefaults,
isAssistantsEndpoint, isAssistantsEndpoint,
PERMISSION_BITS,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider'; import type { TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider';
import type { MentionOption } from '~/common'; import type { MentionOption } from '~/common';
@ -79,28 +80,31 @@ export default function useMentions({
() => startupConfig?.interface ?? defaultInterface, () => startupConfig?.interface ?? defaultInterface,
[startupConfig?.interface], [startupConfig?.interface],
); );
const { data: agentsList = null } = useListAgentsQuery(undefined, { const { data: agentsList = null } = useListAgentsQuery(
enabled: hasAgentAccess && interfaceConfig.modelSelect === true, { requiredPermission: PERMISSION_BITS.VIEW },
select: (res) => { {
const { data } = res; enabled: hasAgentAccess && interfaceConfig.modelSelect === true,
return data.map(({ id, name, avatar }) => ({ select: (res) => {
value: id, const { data } = res;
label: name ?? '', return data.map(({ id, name, avatar }) => ({
type: EModelEndpoint.agents, value: id,
icon: EndpointIcon({ label: name ?? '',
conversation: { type: EModelEndpoint.agents,
agent_id: id, icon: EndpointIcon({
endpoint: EModelEndpoint.agents, conversation: {
iconURL: avatar?.filepath, agent_id: id,
}, endpoint: EModelEndpoint.agents,
containerClassName: 'shadow-stroke overflow-hidden rounded-full', iconURL: avatar?.filepath,
endpointsConfig: endpointsConfig, },
context: 'menu-item', containerClassName: 'shadow-stroke overflow-hidden rounded-full',
size: 20, endpointsConfig: endpointsConfig,
}), context: 'menu-item',
})); size: 20,
}),
}));
},
}, },
}); );
const assistantListMap = useMemo( const assistantListMap = useMemo(
() => ({ () => ({
[EModelEndpoint.assistants]: listMap[EModelEndpoint.assistants] [EModelEndpoint.assistants]: listMap[EModelEndpoint.assistants]

View file

@ -34,6 +34,7 @@ jest.mock('react-router-dom', () => ({
jest.mock('@tanstack/react-query', () => ({ jest.mock('@tanstack/react-query', () => ({
useQueryClient: jest.fn(), useQueryClient: jest.fn(),
useQuery: jest.fn(),
})); }));
jest.mock('~/Providers', () => ({ jest.mock('~/Providers', () => ({
@ -51,6 +52,15 @@ jest.mock('~/hooks/Conversations/useDefaultConvo', () => ({
default: jest.fn(), default: jest.fn(),
})); }));
jest.mock('~/hooks/AuthContext', () => ({
useAuthContext: jest.fn(),
}));
jest.mock('~/hooks/Agents/useAgentsMap', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('~/utils', () => ({ jest.mock('~/utils', () => ({
getConvoSwitchLogic: jest.fn(() => ({ getConvoSwitchLogic: jest.fn(() => ({
template: {}, template: {},
@ -63,6 +73,8 @@ jest.mock('~/utils', () => ({
getModelSpecIconURL: jest.fn(() => 'icon-url'), getModelSpecIconURL: jest.fn(() => 'icon-url'),
removeUnavailableTools: jest.fn((preset) => preset), removeUnavailableTools: jest.fn((preset) => preset),
logger: { log: jest.fn() }, logger: { log: jest.fn() },
getInitialTheme: jest.fn(() => 'light'),
applyFontSize: jest.fn(),
})); }));
// Mock the tQueryParamsSchema // Mock the tQueryParamsSchema
@ -82,6 +94,21 @@ jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom', assistants: 'assistants', agents: 'agents' }, EModelEndpoint: { custom: 'custom', assistants: 'assistants', agents: 'agents' },
})); }));
// Mock data-provider hooks
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
useAgentListingDefaultPermissionLevel: jest.fn(() => 'view'),
useListAgentsQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
}));
// Mock global window.history // Mock global window.history
global.window = Object.create(window); global.window = Object.create(window);
global.window.history = { global.window.history = {
@ -103,6 +130,14 @@ describe('useQueryParams', () => {
// Reset mock for window.history.replaceState // Reset mock for window.history.replaceState
jest.spyOn(window.history, 'replaceState').mockClear(); jest.spyOn(window.history, 'replaceState').mockClear();
// Reset data-provider mocks
const dataProvider = jest.requireMock('~/data-provider');
(dataProvider.useGetAgentByIdQuery as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
error: null,
});
// Create mocks for all dependencies // Create mocks for all dependencies
const mockSearchParams = new URLSearchParams(); const mockSearchParams = new URLSearchParams();
(useSearchParams as jest.Mock).mockReturnValue([mockSearchParams, jest.fn()]); (useSearchParams as jest.Mock).mockReturnValue([mockSearchParams, jest.fn()]);
@ -147,6 +182,13 @@ describe('useQueryParams', () => {
const mockGetDefaultConversation = jest.fn().mockReturnValue({}); const mockGetDefaultConversation = jest.fn().mockReturnValue({});
(useDefaultConvo as jest.Mock).mockReturnValue(mockGetDefaultConversation); (useDefaultConvo as jest.Mock).mockReturnValue(mockGetDefaultConversation);
// Mock useAuthContext
const { useAuthContext } = jest.requireMock('~/hooks/AuthContext');
(useAuthContext as jest.Mock).mockReturnValue({
user: { id: 'test-user-id' },
isAuthenticated: true,
});
}); });
afterEach(() => { afterEach(() => {

View file

@ -1,20 +1,26 @@
import { useEffect, useCallback, useRef } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { QueryClient, useQueryClient } from '@tanstack/react-query';
import { import {
QueryKeys, QueryKeys,
EModelEndpoint, EModelEndpoint,
isAgentsEndpoint, isAgentsEndpoint,
tQueryParamsSchema, tQueryParamsSchema,
isAssistantsEndpoint, isAssistantsEndpoint,
PERMISSION_BITS,
} from 'librechat-data-provider';
import type {
TPreset,
TEndpointsConfig,
TStartupConfig,
AgentListResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { TPreset, TEndpointsConfig, TStartupConfig } from 'librechat-data-provider';
import type { ZodAny } from 'zod'; import type { ZodAny } from 'zod';
import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils'; import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils';
import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo'; import { useAuthContext, useAgentsMap, useDefaultConvo, useSubmitMessage } from '~/hooks';
import { useChatContext, useChatFormContext } from '~/Providers'; import { useChatContext, useChatFormContext } from '~/Providers';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage'; import { useGetAgentByIdQuery } from '~/data-provider';
import store from '~/store'; import store from '~/store';
/** /**
@ -73,6 +79,21 @@ const processValidSettings = (queryParams: Record<string, string>) => {
return validSettings; return validSettings;
}; };
const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => {
const editCacheKey = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }];
const editCache = queryClient.getQueryData<AgentListResponse>(editCacheKey);
if (editCache?.data && !editCache.data.some((cachedAgent) => cachedAgent.id === agent.id)) {
// Inject agent into EDIT cache so dropdown can display it
const updatedCache = {
...editCache,
data: [agent, ...editCache.data],
};
queryClient.setQueryData(editCacheKey, updatedCache);
logger.log('agent', 'Injected URL agent into cache:', agent);
}
};
/** /**
* Hook that processes URL query parameters to initialize chat with specified settings and prompt. * Hook that processes URL query parameters to initialize chat with specified settings and prompt.
* Handles model switching, prompt auto-filling, and optional auto-submission with race condition protection. * Handles model switching, prompt auto-filling, and optional auto-submission with race condition protection.
@ -104,6 +125,14 @@ export default function useQueryParams({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext(); const { conversation, newConversation } = useChatContext();
// Extract agent_id from URL for proactive fetching
const urlAgentId = searchParams.get('agent_id') || '';
// Use the existing query hook to fetch agent if present in URL
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId, {
enabled: !!urlAgentId, // Only fetch if agent_id exists in URL
});
/** /**
* Applies settings from URL query parameters to create a new conversation. * Applies settings from URL query parameters to create a new conversation.
* Handles model spec lookup, endpoint normalization, and conversation switching logic. * Handles model spec lookup, endpoint normalization, and conversation switching logic.
@ -418,4 +447,12 @@ export default function useQueryParams({
} }
} }
}, [conversation, processSubmission, areSettingsApplied]); }, [conversation, processSubmission, areSettingsApplied]);
const { isAuthenticated } = useAuthContext();
const agentsMap = useAgentsMap({ isAuthenticated });
useEffect(() => {
if (urlAgent) {
injectAgentIntoAgentsMap(queryClient, urlAgent);
}
}, [urlAgent, queryClient, agentsMap]);
} }

View file

@ -23,7 +23,7 @@
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat", "com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.", "com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
"com_agents_missing_provider_model": "Please select a provider and model before creating an agent.", "com_agents_missing_provider_model": "Please select a provider and model before creating an agent.",
"com_agents_name_placeholder": "Optional: The name of the agent", "com_agents_name_placeholder": "The name of the agent",
"com_agents_no_access": "You don't have access to edit this agent.", "com_agents_no_access": "You don't have access to edit this agent.",
"com_agents_no_agent_id_error": "No agent ID found. Please ensure the agent is created first.", "com_agents_no_agent_id_error": "No agent ID found. Please ensure the agent is created first.",
"com_agents_not_available": "Agent Not Available", "com_agents_not_available": "Agent Not Available",
@ -1097,6 +1097,7 @@
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.", "com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
"com_ui_update_mcp_success": "Successfully created or updated MCP", "com_ui_update_mcp_success": "Successfully created or updated MCP",
"com_ui_upload": "Upload", "com_ui_upload": "Upload",
"com_ui_upload_agent_avatar": "Successfully updated agent avatar",
"com_ui_upload_code_files": "Upload for Code Interpreter", "com_ui_upload_code_files": "Upload for Code Interpreter",
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.", "com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
"com_ui_upload_error": "There was an error uploading your file", "com_ui_upload_error": "There was an error uploading your file",
@ -1207,9 +1208,7 @@
"com_agents_link_copied": "Link copied", "com_agents_link_copied": "Link copied",
"com_agents_link_copy_failed": "Failed to copy link", "com_agents_link_copy_failed": "Failed to copy link",
"com_agents_more_options": "More options", "com_agents_more_options": "More options",
"com_agents_close": "Close",
"com_agents_loading": "Loading...", "com_agents_loading": "Loading...",
"com_agents_loading_description": "Loading agent description...",
"com_agents_error_loading": "Error loading agents", "com_agents_error_loading": "Error loading agents",
"com_agents_error_searching": "Error searching agents", "com_agents_error_searching": "Error searching agents",
"com_agents_error_title": "Something went wrong", "com_agents_error_title": "Something went wrong",
@ -1229,6 +1228,9 @@
"com_agents_error_server_suggestion": "Please try again in a few moments.", "com_agents_error_server_suggestion": "Please try again in a few moments.",
"com_agents_error_search_title": "Search Error", "com_agents_error_search_title": "Search Error",
"com_agents_error_category_title": "Category Error", "com_agents_error_category_title": "Category Error",
"com_agents_error_timeout_title": "Connection Timeout",
"com_agents_error_timeout_message": "The request took too long to complete.",
"com_agents_error_timeout_suggestion": "Please check your internet connection and try again.",
"com_agents_search_no_results": "No agents found for \"{{query}}\"", "com_agents_search_no_results": "No agents found for \"{{query}}\"",
"com_agents_category_empty": "No agents found in the {{category}} category", "com_agents_category_empty": "No agents found in the {{category}} category",
"com_agents_error_retry": "Try Again", "com_agents_error_retry": "Try Again",
@ -1245,5 +1247,8 @@
"com_agents_no_results": "No agents found. Try another search term.", "com_agents_no_results": "No agents found. Try another search term.",
"com_agents_results_for": "Results for '{{query}}'", "com_agents_results_for": "Results for '{{query}}'",
"com_nav_agents_marketplace": "Agent Marketplace", "com_nav_agents_marketplace": "Agent Marketplace",
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity" "com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
"com_ui_agent_name_is_required": "Agent name is required",
"com_agents_missing_name": "Please type in a name before creating an agent."
} }

View file

@ -33,7 +33,7 @@ describe('Agent Utilities', () => {
id: '1', id: '1',
name: 'Test Agent', name: 'Test Agent',
avatar: '/path/to/avatar.png', avatar: '/path/to/avatar.png',
} as t.Agent; } as unknown as t.Agent;
expect(getAgentAvatarUrl(agent)).toBe('/path/to/avatar.png'); expect(getAgentAvatarUrl(agent)).toBe('/path/to/avatar.png');
}); });
@ -62,7 +62,7 @@ describe('Agent Utilities', () => {
id: '1', id: '1',
name: 'Test Agent', name: 'Test Agent',
avatar: '/test-avatar.png', avatar: '/test-avatar.png',
} as t.Agent; } as unknown as t.Agent;
render(<div>{renderAgentAvatar(agent)}</div>); render(<div>{renderAgentAvatar(agent)}</div>);
@ -90,7 +90,7 @@ describe('Agent Utilities', () => {
id: '1', id: '1',
name: 'Test Agent', name: 'Test Agent',
avatar: '/test-avatar.png', avatar: '/test-avatar.png',
} as t.Agent; } as unknown as t.Agent;
const { rerender } = render(<div>{renderAgentAvatar(agent, { size: 'sm' })}</div>); const { rerender } = render(<div>{renderAgentAvatar(agent, { size: 'sm' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12'); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12');
@ -107,7 +107,7 @@ describe('Agent Utilities', () => {
id: '1', id: '1',
name: 'Test Agent', name: 'Test Agent',
avatar: '/test-avatar.png', avatar: '/test-avatar.png',
} as t.Agent; } as unknown as t.Agent;
render(<div>{renderAgentAvatar(agent, { className: 'custom-class' })}</div>); render(<div>{renderAgentAvatar(agent, { className: 'custom-class' })}</div>);
@ -120,7 +120,7 @@ describe('Agent Utilities', () => {
id: '1', id: '1',
name: 'Test Agent', name: 'Test Agent',
avatar: '/test-avatar.png', avatar: '/test-avatar.png',
} as t.Agent; } as unknown as t.Agent;
const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>); const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2'); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2');

View file

@ -1,106 +0,0 @@
const connectDb = require('../api/lib/db/connectDb');
const AgentCategory = require('../api/models/AgentCategory');
// Define category constants directly since the constants file was removed
const CATEGORY_VALUES = {
GENERAL: 'general',
HR: 'hr',
RD: 'rd',
FINANCE: 'finance',
IT: 'it',
SALES: 'sales',
AFTERSALES: 'aftersales',
};
const CATEGORY_DESCRIPTIONS = {
general: 'General purpose agents for common tasks and inquiries',
hr: 'Agents specialized in HR processes, policies, and employee support',
rd: 'Agents focused on R&D processes, innovation, and technical research',
finance: 'Agents specialized in financial analysis, budgeting, and accounting',
it: 'Agents for IT support, technical troubleshooting, and system administration',
sales: 'Agents focused on sales processes, customer relations, and marketing',
aftersales: 'Agents specialized in post-sale support, maintenance, and customer service',
};
/**
* Seed agent categories from existing constants into MongoDB
* This migration creates the initial category data in the database
*/
async function seedCategories() {
try {
await connectDb();
console.log('Connected to database');
// Prepare category data from existing constants
const categoryData = [
{
value: CATEGORY_VALUES.GENERAL,
label: 'General',
description: CATEGORY_DESCRIPTIONS.general,
order: 0,
},
{
value: CATEGORY_VALUES.HR,
label: 'Human Resources',
description: CATEGORY_DESCRIPTIONS.hr,
order: 1,
},
{
value: CATEGORY_VALUES.RD,
label: 'Research & Development',
description: CATEGORY_DESCRIPTIONS.rd,
order: 2,
},
{
value: CATEGORY_VALUES.FINANCE,
label: 'Finance',
description: CATEGORY_DESCRIPTIONS.finance,
order: 3,
},
{
value: CATEGORY_VALUES.IT,
label: 'Information Technology',
description: CATEGORY_DESCRIPTIONS.it,
order: 4,
},
{
value: CATEGORY_VALUES.SALES,
label: 'Sales & Marketing',
description: CATEGORY_DESCRIPTIONS.sales,
order: 5,
},
{
value: CATEGORY_VALUES.AFTERSALES,
label: 'After Sales',
description: CATEGORY_DESCRIPTIONS.aftersales,
order: 6,
},
];
console.log('Seeding categories...');
const result = await AgentCategory.seedCategories(categoryData);
console.log(`Successfully seeded ${result.upsertedCount} new categories`);
console.log(`Modified ${result.modifiedCount} existing categories`);
// Verify the seeded data
const categories = await AgentCategory.getActiveCategories();
console.log('Active categories in database:');
categories.forEach((cat) => {
console.log(` - ${cat.value}: ${cat.label} (order: ${cat.order})`);
});
console.log('Category seeding completed successfully');
process.exit(0);
} catch (error) {
console.error('Error seeding categories:', error);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
seedCategories();
}
module.exports = seedCategories;

View file

@ -74,6 +74,18 @@ interface:
bookmarks: true bookmarks: true
multiConvo: true multiConvo: true
agents: true agents: true
peoplePicker:
admin:
users: true
groups: true
user:
users: false
groups: false
marketplace:
admin:
use: false # Enable marketplace mode for admin role
user:
use: false # Enable marketplace mode for user role
fileCitations: true fileCitations: true
# Temporary chat retention period in hours (default: 720, min: 1, max: 8760) # Temporary chat retention period in hours (default: 720, min: 1, max: 8760)
# temporaryChatRetention: 1 # temporaryChatRetention: 1

View file

@ -35,7 +35,6 @@
"reset-meili-sync": "node config/reset-meili-sync.js", "reset-meili-sync": "node config/reset-meili-sync.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",

View file

@ -30,6 +30,14 @@ export const agentToolResourcesSchema = z
}) })
.optional(); .optional();
/** Support contact schema for agent */
export const agentSupportContactSchema = z
.object({
name: z.string().optional(),
email: z.union([z.literal(''), z.string().email()]).optional(),
})
.optional();
/** Base agent schema with all common fields */ /** Base agent schema with all common fields */
export const agentBaseSchema = z.object({ export const agentBaseSchema = z.object({
name: z.string().nullable().optional(), name: z.string().nullable().optional(),
@ -45,6 +53,8 @@ export const agentBaseSchema = z.object({
recursion_limit: z.number().optional(), recursion_limit: z.number().optional(),
conversation_starters: z.array(z.string()).optional(), conversation_starters: z.array(z.string()).optional(),
tool_resources: agentToolResourcesSchema, tool_resources: agentToolResourcesSchema,
support_contact: agentSupportContactSchema,
category: z.string().optional(),
}); });
/** Create schema extends base with required fields for creation */ /** Create schema extends base with required fields for creation */

View file

@ -125,7 +125,7 @@ export const generateCheckAccess = ({
} }
logger.warn( logger.warn(
`[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${req.user?.id}: ${permissions.join(', ')}`, `[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${(req.user as IUser)?.id}: ${permissions.join(', ')}`,
); );
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' }); return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) { } catch (error) {

View file

@ -498,7 +498,7 @@ const mcpServersSchema = z.object({
export type TMcpServersConfig = z.infer<typeof mcpServersSchema>; export type TMcpServersConfig = z.infer<typeof mcpServersSchema>;
export const intefaceSchema = z export const interfaceSchema = z
.object({ .object({
privacyPolicy: z privacyPolicy: z
.object({ .object({
@ -523,6 +523,36 @@ export const intefaceSchema = z
temporaryChatRetention: z.number().min(1).max(8760).optional(), temporaryChatRetention: z.number().min(1).max(8760).optional(),
runCode: z.boolean().optional(), runCode: z.boolean().optional(),
webSearch: z.boolean().optional(), webSearch: z.boolean().optional(),
peoplePicker: z
.object({
admin: z
.object({
users: z.boolean().optional(),
groups: z.boolean().optional(),
})
.optional(),
user: z
.object({
users: z.boolean().optional(),
groups: z.boolean().optional(),
})
.optional(),
})
.optional(),
marketplace: z
.object({
admin: z
.object({
use: z.boolean().optional(),
})
.optional(),
user: z
.object({
use: z.boolean().optional(),
})
.optional(),
})
.optional(),
fileSearch: z.boolean().optional(), fileSearch: z.boolean().optional(),
fileCitations: z.boolean().optional(), fileCitations: z.boolean().optional(),
}) })
@ -540,11 +570,29 @@ export const intefaceSchema = z
temporaryChat: true, temporaryChat: true,
runCode: true, runCode: true,
webSearch: true, webSearch: true,
peoplePicker: {
admin: {
users: true,
groups: true,
},
user: {
users: false,
groups: false,
},
},
marketplace: {
admin: {
use: false,
},
user: {
use: false,
},
},
fileSearch: true, fileSearch: true,
fileCitations: true, fileCitations: true,
}); });
export type TInterfaceConfig = z.infer<typeof intefaceSchema>; export type TInterfaceConfig = z.infer<typeof interfaceSchema>;
export type TBalanceConfig = z.infer<typeof balanceSchema>; export type TBalanceConfig = z.infer<typeof balanceSchema>;
export const turnstileOptionsSchema = z export const turnstileOptionsSchema = z
@ -771,7 +819,7 @@ export const configSchema = z.object({
includedTools: z.array(z.string()).optional(), includedTools: z.array(z.string()).optional(),
filteredTools: z.array(z.string()).optional(), filteredTools: z.array(z.string()).optional(),
mcpServers: MCPServersSchema.optional(), mcpServers: MCPServersSchema.optional(),
interface: intefaceSchema, interface: interfaceSchema,
turnstile: turnstileSchema.optional(), turnstile: turnstileSchema.optional(),
fileStrategy: fileSourceSchema.default(FileSources.local), fileStrategy: fileSourceSchema.default(FileSources.local),
actions: z actions: z
@ -867,7 +915,7 @@ export const defaultEndpoints: EModelEndpoint[] = [
export const alternateName = { export const alternateName = {
[EModelEndpoint.openAI]: 'OpenAI', [EModelEndpoint.openAI]: 'OpenAI',
[EModelEndpoint.assistants]: 'Assistants', [EModelEndpoint.assistants]: 'Assistants',
[EModelEndpoint.agents]: 'Agents', [EModelEndpoint.agents]: 'My Agents',
[EModelEndpoint.azureAssistants]: 'Azure Assistants', [EModelEndpoint.azureAssistants]: 'Azure Assistants',
[EModelEndpoint.azureOpenAI]: 'Azure OpenAI', [EModelEndpoint.azureOpenAI]: 'Azure OpenAI',
[EModelEndpoint.chatGPTBrowser]: 'ChatGPT', [EModelEndpoint.chatGPTBrowser]: 'ChatGPT',

View file

@ -477,69 +477,23 @@ export const revertAgentVersion = ({
* Get agent categories with counts for marketplace tabs * Get agent categories with counts for marketplace tabs
*/ */
export const getAgentCategories = (): Promise<t.TMarketplaceCategory[]> => { export const getAgentCategories = (): Promise<t.TMarketplaceCategory[]> => {
return request.get(endpoints.agents({ path: 'marketplace/categories' })); return request.get(endpoints.agents({ path: 'categories' }));
}; };
/** /**
* Get promoted/top picks agents with pagination * Unified marketplace agents endpoint with query string controls
*/ */
export const getPromotedAgents = (params: { export const getMarketplaceAgents = (params: {
page?: number; requiredPermission: 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; category?: string;
page?: number; search?: string;
limit?: number; limit?: number;
cursor?: string;
promoted?: 0 | 1;
}): Promise<a.AgentListResponse> => { }): Promise<a.AgentListResponse> => {
return request.get( return request.get(
endpoints.agents({ endpoints.agents({
path: 'marketplace/search', // path: 'marketplace',
options: params, options: params,
}), }),
); );

View file

@ -39,6 +39,8 @@ import * as dataService from './data-service';
export * from './utils'; export * from './utils';
export * from './actions'; export * from './actions';
export { default as createPayload } from './createPayload'; export { default as createPayload } from './createPayload';
// /* react query hooks */
// export * from './react-query/react-query-service';
/* feedback */ /* feedback */
export * from './feedback'; export * from './feedback';
export * from './parameterSettings'; export * from './parameterSettings';

View file

@ -43,6 +43,8 @@ export enum QueryKeys {
promptGroup = 'promptGroup', promptGroup = 'promptGroup',
categories = 'categories', categories = 'categories',
randomPrompts = 'randomPrompts', randomPrompts = 'randomPrompts',
agentCategories = 'agentCategories',
marketplaceAgents = 'marketplaceAgents',
roles = 'roles', roles = 'roles',
conversationTags = 'conversationTags', conversationTags = 'conversationTags',
health = 'health', health = 'health',

View file

@ -36,6 +36,14 @@ export enum PermissionTypes {
* Type for using the "Web Search" feature * Type for using the "Web Search" feature
*/ */
WEB_SEARCH = 'WEB_SEARCH', WEB_SEARCH = 'WEB_SEARCH',
/**
* Type for People Picker Permissions
*/
PEOPLE_PICKER = 'PEOPLE_PICKER',
/**
* Type for Marketplace Permissions
*/
MARKETPLACE = 'MARKETPLACE',
/** /**
* Type for using the "File Search" feature * Type for using the "File Search" feature
*/ */
@ -59,6 +67,8 @@ export enum Permissions {
SHARE = 'SHARE', SHARE = 'SHARE',
/** Can disable if desired */ /** Can disable if desired */
OPT_OUT = 'OPT_OUT', OPT_OUT = 'OPT_OUT',
VIEW_USERS = 'VIEW_USERS',
VIEW_GROUPS = 'VIEW_GROUPS',
} }
export const promptPermissionsSchema = z.object({ export const promptPermissionsSchema = z.object({
@ -111,6 +121,17 @@ export const webSearchPermissionsSchema = z.object({
}); });
export type TWebSearchPermissions = z.infer<typeof webSearchPermissionsSchema>; export type TWebSearchPermissions = z.infer<typeof webSearchPermissionsSchema>;
export const peoplePickerPermissionsSchema = z.object({
[Permissions.VIEW_USERS]: z.boolean().default(true),
[Permissions.VIEW_GROUPS]: z.boolean().default(true),
});
export type TPeoplePickerPermissions = z.infer<typeof peoplePickerPermissionsSchema>;
export const marketplacePermissionsSchema = z.object({
[Permissions.USE]: z.boolean().default(false),
});
export type TMarketplacePermissions = z.infer<typeof marketplacePermissionsSchema>;
export const fileSearchPermissionsSchema = z.object({ export const fileSearchPermissionsSchema = z.object({
[Permissions.USE]: z.boolean().default(true), [Permissions.USE]: z.boolean().default(true),
}); });
@ -131,6 +152,8 @@ export const permissionsSchema = z.object({
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema, [PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema, [PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
[PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema, [PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema,
[PermissionTypes.PEOPLE_PICKER]: peoplePickerPermissionsSchema,
[PermissionTypes.MARKETPLACE]: marketplacePermissionsSchema,
[PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema, [PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema,
[PermissionTypes.FILE_CITATIONS]: fileCitationsPermissionsSchema, [PermissionTypes.FILE_CITATIONS]: fileCitationsPermissionsSchema,
}); });

View file

@ -12,6 +12,7 @@ import {
fileSearchPermissionsSchema, fileSearchPermissionsSchema,
multiConvoPermissionsSchema, multiConvoPermissionsSchema,
temporaryChatPermissionsSchema, temporaryChatPermissionsSchema,
peoplePickerPermissionsSchema,
fileCitationsPermissionsSchema, fileCitationsPermissionsSchema,
} from './permissions'; } from './permissions';
@ -76,6 +77,13 @@ const defaultRolesSchema = z.object({
[PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema.extend({ [PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema.extend({
[Permissions.USE]: z.boolean().default(true), [Permissions.USE]: z.boolean().default(true),
}), }),
[PermissionTypes.PEOPLE_PICKER]: peoplePickerPermissionsSchema.extend({
[Permissions.VIEW_USERS]: z.boolean().default(true),
[Permissions.VIEW_GROUPS]: z.boolean().default(true),
}),
[PermissionTypes.MARKETPLACE]: z.object({
[Permissions.USE]: z.boolean().default(false),
}),
[PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema.extend({ [PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema.extend({
[Permissions.USE]: z.boolean().default(true), [Permissions.USE]: z.boolean().default(true),
}), }),
@ -126,6 +134,13 @@ export const roleDefaults = defaultRolesSchema.parse({
[PermissionTypes.WEB_SEARCH]: { [PermissionTypes.WEB_SEARCH]: {
[Permissions.USE]: true, [Permissions.USE]: true,
}, },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: true,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: true,
},
[PermissionTypes.FILE_SEARCH]: { [PermissionTypes.FILE_SEARCH]: {
[Permissions.USE]: true, [Permissions.USE]: true,
}, },
@ -145,6 +160,13 @@ export const roleDefaults = defaultRolesSchema.parse({
[PermissionTypes.TEMPORARY_CHAT]: {}, [PermissionTypes.TEMPORARY_CHAT]: {},
[PermissionTypes.RUN_CODE]: {}, [PermissionTypes.RUN_CODE]: {},
[PermissionTypes.WEB_SEARCH]: {}, [PermissionTypes.WEB_SEARCH]: {},
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: false,
},
[PermissionTypes.FILE_SEARCH]: {}, [PermissionTypes.FILE_SEARCH]: {},
[PermissionTypes.FILE_CITATIONS]: {}, [PermissionTypes.FILE_CITATIONS]: {},
}, },

View file

@ -196,6 +196,10 @@ export interface AgentFileResource extends AgentBaseResource {
*/ */
vector_store_ids?: Array<string>; vector_store_ids?: Array<string>;
} }
export type SupportContact = {
name?: string;
email?: string;
};
export type Agent = { export type Agent = {
_id?: string; _id?: string;
@ -228,6 +232,8 @@ export type Agent = {
recursion_limit?: number; recursion_limit?: number;
isPublic?: boolean; isPublic?: boolean;
version?: number; version?: number;
category?: string;
support_contact?: SupportContact;
}; };
export type TAgentsMap = Record<string, Agent | undefined>; export type TAgentsMap = Record<string, Agent | undefined>;
@ -244,7 +250,13 @@ export type AgentCreateParams = {
model_parameters: AgentModelParameters; model_parameters: AgentModelParameters;
} & Pick< } & Pick<
Agent, Agent,
'agent_ids' | 'end_after_tools' | 'hide_sequential_outputs' | 'artifacts' | 'recursion_limit' | 'agent_ids'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'artifacts'
| 'recursion_limit'
| 'category'
| 'support_contact'
>; >;
export type AgentUpdateParams = { export type AgentUpdateParams = {
@ -263,15 +275,22 @@ export type AgentUpdateParams = {
isCollaborative?: boolean; isCollaborative?: boolean;
} & Pick< } & Pick<
Agent, Agent,
'agent_ids' | 'end_after_tools' | 'hide_sequential_outputs' | 'artifacts' | 'recursion_limit' | 'agent_ids'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'artifacts'
| 'recursion_limit'
| 'category'
| 'support_contact'
>; >;
export type AgentListParams = { export type AgentListParams = {
limit?: number; limit?: number;
before?: string | null; requiredPermission: number;
after?: string | null; category?: string;
order?: 'asc' | 'desc'; search?: string;
provider?: AgentProvider; cursor?: string;
promoted?: 0 | 1;
}; };
export type AgentListResponse = { export type AgentListResponse = {
@ -280,6 +299,7 @@ export type AgentListResponse = {
first_id: string; first_id: string;
last_id: string; last_id: string;
has_more: boolean; has_more: boolean;
after?: string;
}; };
export type AgentFile = { export type AgentFile = {

View file

@ -109,7 +109,6 @@ export type DeleteActionOptions = MutationOptions<void, DeleteActionVariables>;
export type AgentAvatarVariables = { export type AgentAvatarVariables = {
agent_id: string; agent_id: string;
formData: FormData; formData: FormData;
postCreation?: boolean;
}; };
export type UpdateAgentActionVariables = { export type UpdateAgentActionVariables = {

View file

@ -271,22 +271,7 @@ describe('AclEntry Model Tests', () => {
const effective = await methods.getEffectivePermissions(principalsList, 'agent', resourceId); const effective = await methods.getEffectivePermissions(principalsList, 'agent', resourceId);
/** Combined permissions should be VIEW | EDIT */ /** Combined permissions should be VIEW | EDIT */
expect(effective.effectiveBits).toBe(PermissionBits.VIEW | PermissionBits.EDIT); expect(effective).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
/** Should have 2 sources */
expect(effective.sources).toHaveLength(2);
/** Check sources */
const userSource = effective.sources.find((s) => s.from === 'user');
const groupSource = effective.sources.find((s) => s.from === 'group');
expect(userSource).toBeDefined();
expect(userSource?.permBits).toBe(PermissionBits.VIEW);
expect(userSource?.direct).toBe(true);
expect(groupSource).toBeDefined();
expect(groupSource?.permBits).toBe(PermissionBits.EDIT);
expect(groupSource?.direct).toBe(true);
}); });
}); });
@ -489,16 +474,15 @@ describe('AclEntry Model Tests', () => {
inheritedFrom: projectId, inheritedFrom: projectId,
}); });
/** Get effective permissions including sources */ /** Get effective permissions */
const effective = await methods.getEffectivePermissions( const effective = await methods.getEffectivePermissions(
[{ principalType: 'user', principalId: userId }], [{ principalType: 'user', principalId: userId }],
'agent', 'agent',
childResourceId, childResourceId,
); );
expect(effective.sources).toHaveLength(1); /** Should have VIEW permission from inherited entry */
expect(effective.sources[0].inheritedFrom?.toString()).toBe(projectId.toString()); expect(effective).toBe(PermissionBits.VIEW);
expect(effective.sources[0].direct).toBe(false);
}); });
}); });
}); });

View file

@ -0,0 +1,222 @@
import type { Model, Types, DeleteResult } from 'mongoose';
import type { IAgentCategory, AgentCategory } from '../types/agentCategory';
export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) {
/**
* Get all active categories sorted by order
* @returns Array of active categories
*/
async function getActiveCategories(): Promise<IAgentCategory[]> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({ isActive: true }).sort({ order: 1, label: 1 }).lean();
}
/**
* Get categories with agent counts
* @returns Categories with agent counts
*/
async function getCategoriesWithCounts(): Promise<(IAgentCategory & { agentCount: number })[]> {
const Agent = mongoose.models.Agent;
const categoryCounts = await Agent.aggregate([
{ $match: { category: { $exists: true, $ne: null } } },
{ $group: { _id: '$category', count: { $sum: 1 } } },
]);
const countMap = new Map(categoryCounts.map((c) => [c._id, c.count]));
const categories = await getActiveCategories();
return categories.map((category) => ({
...category,
agentCount: countMap.get(category.value) || (0 as number),
})) as (IAgentCategory & { agentCount: number })[];
}
/**
* Get valid category values for Agent model validation
* @returns Array of valid category values
*/
async function getValidCategoryValues(): Promise<string[]> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({ isActive: true }).distinct('value').lean();
}
/**
* Seed initial categories from existing constants
* @param categories - Array of category data to seed
* @returns Bulk write result
*/
async function seedCategories(
categories: Array<{
value: string;
label?: string;
description?: string;
order?: number;
}>,
): Promise<any> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const operations = categories.map((category, index) => ({
updateOne: {
filter: { value: category.value },
update: {
$setOnInsert: {
value: category.value,
label: category.label || category.value,
description: category.description || '',
order: category.order || index,
isActive: true,
},
},
upsert: true,
},
}));
return await AgentCategory.bulkWrite(operations);
}
/**
* Find a category by value
* @param value - The category value to search for
* @returns The category document or null
*/
async function findCategoryByValue(value: string): Promise<IAgentCategory | null> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findOne({ value }).lean();
}
/**
* Create a new category
* @param categoryData - The category data to create
* @returns The created category
*/
async function createCategory(categoryData: Partial<IAgentCategory>): Promise<IAgentCategory> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const category = await AgentCategory.create(categoryData);
return category.toObject() as IAgentCategory;
}
/**
* Update a category by value
* @param value - The category value to update
* @param updateData - The data to update
* @returns The updated category or null
*/
async function updateCategory(
value: string,
updateData: Partial<IAgentCategory>,
): Promise<IAgentCategory | null> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findOneAndUpdate(
{ value },
{ $set: updateData },
{ new: true, runValidators: true },
).lean();
}
/**
* Delete a category by value
* @param value - The category value to delete
* @returns Whether the deletion was successful
*/
async function deleteCategory(value: string): Promise<boolean> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const result = await AgentCategory.deleteOne({ value });
return result.deletedCount > 0;
}
/**
* Find a category by ID
* @param id - The category ID to search for
* @returns The category document or null
*/
async function findCategoryById(id: string | Types.ObjectId): Promise<IAgentCategory | null> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findById(id).lean();
}
/**
* Get all categories (active and inactive)
* @returns Array of all categories
*/
async function getAllCategories(): Promise<IAgentCategory[]> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({}).sort({ order: 1, label: 1 }).lean();
}
/**
* Ensure default categories exist, seed them if none are present
* @returns Promise<boolean> - true if categories were seeded, false if they already existed
*/
async function ensureDefaultCategories(): Promise<boolean> {
const existingCategories = await getAllCategories();
if (existingCategories.length > 0) {
return false; // Categories already exist
}
const defaultCategories = [
{
value: 'general',
label: 'General',
description: 'General purpose agents for common tasks and inquiries',
order: 0,
},
{
value: 'hr',
label: 'Human Resources',
description: 'Agents specialized in HR processes, policies, and employee support',
order: 1,
},
{
value: 'rd',
label: 'Research & Development',
description: 'Agents focused on R&D processes, innovation, and technical research',
order: 2,
},
{
value: 'finance',
label: 'Finance',
description: 'Agents specialized in financial analysis, budgeting, and accounting',
order: 3,
},
{
value: 'it',
label: 'IT',
description: 'Agents for IT support, technical troubleshooting, and system administration',
order: 4,
},
{
value: 'sales',
label: 'Sales',
description: 'Agents focused on sales processes, customer relations.',
order: 5,
},
{
value: 'aftersales',
label: 'After Sales',
description: 'Agents specialized in post-sale support, maintenance, and customer service',
order: 6,
},
];
await seedCategories(defaultCategories);
return true; // Categories were seeded
}
return {
getActiveCategories,
getCategoriesWithCounts,
getValidCategoryValues,
seedCategories,
findCategoryByValue,
createCategory,
updateCategory,
deleteCategory,
findCategoryById,
getAllCategories,
ensureDefaultCategories,
};
}
export type AgentCategoryMethods = ReturnType<typeof createAgentCategoryMethods>;

View file

@ -4,6 +4,8 @@ import { createTokenMethods, type TokenMethods } from './token';
import { createRoleMethods, type RoleMethods } from './role'; import { createRoleMethods, type RoleMethods } from './role';
/* Memories */ /* Memories */
import { createMemoryMethods, type MemoryMethods } from './memory'; import { createMemoryMethods, type MemoryMethods } from './memory';
/* Agent Categories */
import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory';
/* Permissions */ /* Permissions */
import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole'; import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
import { createUserGroupMethods, type UserGroupMethods } from './userGroup'; import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
@ -22,6 +24,7 @@ export function createMethods(mongoose: typeof import('mongoose')) {
...createTokenMethods(mongoose), ...createTokenMethods(mongoose),
...createRoleMethods(mongoose), ...createRoleMethods(mongoose),
...createMemoryMethods(mongoose), ...createMemoryMethods(mongoose),
...createAgentCategoryMethods(mongoose),
...createAccessRoleMethods(mongoose), ...createAccessRoleMethods(mongoose),
...createUserGroupMethods(mongoose), ...createUserGroupMethods(mongoose),
...createAclEntryMethods(mongoose), ...createAclEntryMethods(mongoose),
@ -37,6 +40,7 @@ export type AllMethods = UserMethods &
TokenMethods & TokenMethods &
RoleMethods & RoleMethods &
MemoryMethods & MemoryMethods &
AgentCategoryMethods &
AccessRoleMethods & AccessRoleMethods &
UserGroupMethods & UserGroupMethods &
AclEntryMethods & AclEntryMethods &

View file

@ -0,0 +1,9 @@
import agentCategorySchema from '~/schema/agentCategory';
import type * as t from '~/types';
/**
* Creates or returns the AgentCategory model using the provided mongoose instance and schema
*/
export function createAgentCategoryModel(mongoose: typeof import('mongoose')) {
return mongoose.models.AgentCategory || mongoose.model<t.IAgentCategory>('AgentCategory', agentCategorySchema);
}

View file

@ -5,6 +5,7 @@ import { createBalanceModel } from './balance';
import { createConversationModel } from './convo'; import { createConversationModel } from './convo';
import { createMessageModel } from './message'; import { createMessageModel } from './message';
import { createAgentModel } from './agent'; import { createAgentModel } from './agent';
import { createAgentCategoryModel } from './agentCategory';
import { createRoleModel } from './role'; import { createRoleModel } from './role';
import { createActionModel } from './action'; import { createActionModel } from './action';
import { createAssistantModel } from './assistant'; import { createAssistantModel } from './assistant';
@ -37,6 +38,7 @@ export function createModels(mongoose: typeof import('mongoose')) {
Conversation: createConversationModel(mongoose), Conversation: createConversationModel(mongoose),
Message: createMessageModel(mongoose), Message: createMessageModel(mongoose),
Agent: createAgentModel(mongoose), Agent: createAgentModel(mongoose),
AgentCategory: createAgentCategoryModel(mongoose),
Role: createRoleModel(mongoose), Role: createRoleModel(mongoose),
Action: createActionModel(mongoose), Action: createActionModel(mongoose),
Assistant: createAssistantModel(mongoose), Assistant: createAssistantModel(mongoose),

View file

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

View file

@ -0,0 +1,42 @@
import { Schema, Document } from 'mongoose';
import type { IAgentCategory } from '~/types';
const agentCategorySchema = new Schema<IAgentCategory>(
{
value: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
index: true,
},
label: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
trim: true,
default: '',
},
order: {
type: Number,
default: 0,
index: true,
},
isActive: {
type: Boolean,
default: true,
index: true,
},
},
{
timestamps: true,
},
);
agentCategorySchema.index({ isActive: 1, order: 1 });
export default agentCategorySchema;

View file

@ -1,5 +1,6 @@
export { default as actionSchema } from './action'; export { default as actionSchema } from './action';
export { default as agentSchema } from './agent'; export { default as agentSchema } from './agent';
export { default as agentCategorySchema } from './agentCategory';
export { default as assistantSchema } from './assistant'; export { default as assistantSchema } from './assistant';
export { default as balanceSchema } from './balance'; export { default as balanceSchema } from './balance';
export { default as bannerSchema } from './banner'; export { default as bannerSchema } from './banner';

View file

@ -39,6 +39,13 @@ const rolePermissionsSchema = new Schema(
[PermissionTypes.WEB_SEARCH]: { [PermissionTypes.WEB_SEARCH]: {
[Permissions.USE]: { type: Boolean, default: true }, [Permissions.USE]: { type: Boolean, default: true },
}, },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: { type: Boolean, default: false },
[Permissions.VIEW_GROUPS]: { type: Boolean, default: false },
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: { type: Boolean, default: false },
},
[PermissionTypes.FILE_SEARCH]: { [PermissionTypes.FILE_SEARCH]: {
[Permissions.USE]: { type: Boolean, default: true }, [Permissions.USE]: { type: Boolean, default: true },
}, },
@ -75,6 +82,11 @@ const roleSchema: Schema<IRole> = new Schema({
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
},
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
}), }),

View file

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

View file

@ -0,0 +1,19 @@
import type { Document, Types } from 'mongoose';
export type AgentCategory = {
/** Unique identifier for the category (e.g., 'general', 'hr', 'finance') */
value: string;
/** Display label for the category */
label: string;
/** Description of the category */
description?: string;
/** Display order for sorting categories */
order: number;
/** Whether the category is active and should be displayed */
isActive: boolean;
};
export type IAgentCategory = AgentCategory &
Document & {
_id: Types.ObjectId;
};

View file

@ -9,6 +9,7 @@ export * from './balance';
export * from './banner'; export * from './banner';
export * from './message'; export * from './message';
export * from './agent'; export * from './agent';
export * from './agentCategory';
export * from './role'; export * from './role';
export * from './action'; export * from './action';
export * from './assistant'; export * from './assistant';

View file

@ -35,6 +35,13 @@ export interface IRole extends Document {
[PermissionTypes.WEB_SEARCH]?: { [PermissionTypes.WEB_SEARCH]?: {
[Permissions.USE]?: boolean; [Permissions.USE]?: boolean;
}; };
[PermissionTypes.PEOPLE_PICKER]?: {
[Permissions.VIEW_USERS]?: boolean;
[Permissions.VIEW_GROUPS]?: boolean;
};
[PermissionTypes.MARKETPLACE]?: {
[Permissions.USE]?: boolean;
};
[PermissionTypes.FILE_SEARCH]?: { [PermissionTypes.FILE_SEARCH]?: {
[Permissions.USE]?: boolean; [Permissions.USE]?: boolean;
}; };