diff --git a/api/cache/cacheConfig.js b/api/cache/cacheConfig.js index 613cfec74..4a5fea113 100644 --- a/api/cache/cacheConfig.js +++ b/api/cache/cacheConfig.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const { logger } = require('@librechat/data-schemas'); const { math, isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); @@ -34,13 +35,35 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { } } +/** Helper function to safely read Redis CA certificate from file + * @returns {string|null} The contents of the CA certificate file, or null if not set or on error + */ +const getRedisCA = () => { + const caPath = process.env.REDIS_CA; + if (!caPath) { + return null; + } + + try { + if (fs.existsSync(caPath)) { + return fs.readFileSync(caPath, 'utf8'); + } else { + logger.warn(`Redis CA certificate file not found: ${caPath}`); + return null; + } + } catch (error) { + logger.error(`Failed to read Redis CA certificate file '${caPath}':`, error); + return null; + } +}; + const cacheConfig = { FORCED_IN_MEMORY_CACHE_NAMESPACES, USE_REDIS, REDIS_URI: process.env.REDIS_URI, REDIS_USERNAME: process.env.REDIS_USERNAME, REDIS_PASSWORD: process.env.REDIS_PASSWORD, - REDIS_CA: process.env.REDIS_CA ? fs.readFileSync(process.env.REDIS_CA, 'utf8') : null, + REDIS_CA: getRedisCA(), REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '', REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40), REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0), diff --git a/api/models/Agent.js b/api/models/Agent.js index 7bcffe669..b506c58f0 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -49,6 +49,14 @@ const createAgent = async (agentData) => { */ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean(); +/** + * Get multiple agent documents based on the provided search parameters. + * + * @param {Object} searchParameter - The search parameters to find agents. + * @returns {Promise} Array of agent documents as plain objects. + */ +const getAgents = async (searchParameter) => await Agent.find(searchParameter).lean(); + /** * Load an agent based on the provided ID * @@ -835,6 +843,7 @@ const countPromotedAgents = async () => { module.exports = { getAgent, + getAgents, loadAgent, createAgent, updateAgent, diff --git a/api/models/File.js b/api/models/File.js index 1ee943131..5e90c86fe 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -42,7 +42,7 @@ const getToolFilesByIds = async (fileIds, toolResourceSet) => { $or: [], }; - if (toolResourceSet.has(EToolResources.ocr)) { + if (toolResourceSet.has(EToolResources.context)) { filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents }); } if (toolResourceSet.has(EToolResources.file_search)) { diff --git a/api/server/controllers/agents/__tests__/v1.spec.js b/api/server/controllers/agents/__tests__/v1.spec.js index b097cd98c..b7e7b67a2 100644 --- a/api/server/controllers/agents/__tests__/v1.spec.js +++ b/api/server/controllers/agents/__tests__/v1.spec.js @@ -158,7 +158,7 @@ describe('duplicateAgent', () => { }); }); - it('should handle tool_resources.ocr correctly', async () => { + it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => { const mockAgent = { id: 'agent_123', name: 'Test Agent', @@ -178,7 +178,7 @@ describe('duplicateAgent', () => { expect(createAgent).toHaveBeenCalledWith( expect.objectContaining({ tool_resources: { - ocr: { enabled: true, config: 'test' }, + context: { enabled: true, config: 'test' }, }, }), ); diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index eb98c5adb..0334d965d 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -2,7 +2,12 @@ const { z } = require('zod'); const fs = require('fs').promises; const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api'); +const { + agentCreateSchema, + agentUpdateSchema, + mergeAgentOcrConversion, + convertOcrToContextInPlace, +} = require('@librechat/api'); const { Tools, Constants, @@ -198,19 +203,32 @@ const getAgentHandler = async (req, res, expandProperties = false) => { * @param {object} req.params - Request params * @param {string} req.params.id - Agent identifier. * @param {AgentUpdateParams} req.body - The Agent update parameters. - * @returns {Agent} 200 - success response - application/json + * @returns {Promise} 200 - success response - application/json */ const updateAgentHandler = async (req, res) => { try { const id = req.params.id; const validatedData = agentUpdateSchema.parse(req.body); const { _id, ...updateData } = removeNullishValues(validatedData); + + // Convert OCR to context in incoming updateData + convertOcrToContextInPlace(updateData); + const existingAgent = await getAgent({ id }); if (!existingAgent) { return res.status(404).json({ error: 'Agent not found' }); } + // Convert legacy OCR tool resource to context format in existing agent + const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData); + if (ocrConversion.tool_resources) { + updateData.tool_resources = ocrConversion.tool_resources; + } + if (ocrConversion.tools) { + updateData.tools = ocrConversion.tools; + } + let updatedAgent = Object.keys(updateData).length > 0 ? await updateAgent({ id }, updateData, { @@ -255,7 +273,7 @@ const updateAgentHandler = async (req, res) => { * @param {object} req - Express Request * @param {object} req.params - Request params * @param {string} req.params.id - Agent identifier. - * @returns {Agent} 201 - success response - application/json + * @returns {Promise} 201 - success response - application/json */ const duplicateAgentHandler = async (req, res) => { const { id } = req.params; @@ -288,9 +306,19 @@ const duplicateAgentHandler = async (req, res) => { hour12: false, })})`; + if (_tool_resources?.[EToolResources.context]) { + cloneData.tool_resources = { + [EToolResources.context]: _tool_resources[EToolResources.context], + }; + } + if (_tool_resources?.[EToolResources.ocr]) { cloneData.tool_resources = { - [EToolResources.ocr]: _tool_resources[EToolResources.ocr], + /** Legacy conversion from `ocr` to `context` */ + [EToolResources.context]: { + ...(_tool_resources[EToolResources.context] ?? {}), + ..._tool_resources[EToolResources.ocr], + }, }; } @@ -382,7 +410,7 @@ const duplicateAgentHandler = async (req, res) => { * @param {object} req - Express Request * @param {object} req.params - Request params * @param {string} req.params.id - Agent identifier. - * @returns {Agent} 200 - success response - application/json + * @returns {Promise} 200 - success response - application/json */ const deleteAgentHandler = async (req, res) => { try { @@ -484,7 +512,7 @@ const getListAgentsHandler = async (req, res) => { * @param {Express.Multer.File} req.file - The avatar image file. * @param {object} req.body - Request body * @param {string} [req.body.avatar] - Optional avatar for the agent's avatar. - * @returns {Object} 200 - success response - application/json + * @returns {Promise} 200 - success response - application/json */ const uploadAgentAvatarHandler = async (req, res) => { try { diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index c31839feb..b8d4d50ee 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -512,6 +512,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { mockReq.params.id = existingAgentId; mockReq.body = { tool_resources: { + /** Legacy conversion from `ocr` to `context` */ ocr: { file_ids: ['ocr1', 'ocr2'], }, @@ -531,7 +532,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const updatedAgent = mockRes.json.mock.calls[0][0]; expect(updatedAgent.tool_resources).toBeDefined(); - expect(updatedAgent.tool_resources.ocr).toBeDefined(); + expect(updatedAgent.tool_resources.ocr).toBeUndefined(); + expect(updatedAgent.tool_resources.context).toBeDefined(); expect(updatedAgent.tool_resources.execute_code).toBeDefined(); expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined(); }); diff --git a/api/server/middleware/accessResources/fileAccess.js b/api/server/middleware/accessResources/fileAccess.js index 2d588e623..b26a512f5 100644 --- a/api/server/middleware/accessResources/fileAccess.js +++ b/api/server/middleware/accessResources/fileAccess.js @@ -1,7 +1,7 @@ const { logger } = require('@librechat/data-schemas'); const { PermissionBits, hasPermissions, ResourceType } = require('librechat-data-provider'); const { getEffectivePermissions } = require('~/server/services/PermissionService'); -const { getAgent } = require('~/models/Agent'); +const { getAgents } = require('~/models/Agent'); const { getFiles } = require('~/models/File'); /** @@ -10,11 +10,12 @@ const { getFiles } = require('~/models/File'); */ const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => { try { - // Find agents that have this file in their tool_resources - const agentsWithFile = await getAgent({ + /** Agents that have this file in their tool_resources */ + const agentsWithFile = await getAgents({ $or: [ - { 'tool_resources.file_search.file_ids': fileId }, { 'tool_resources.execute_code.file_ids': fileId }, + { 'tool_resources.file_search.file_ids': fileId }, + { 'tool_resources.context.file_ids': fileId }, { 'tool_resources.ocr.file_ids': fileId }, ], }); @@ -24,7 +25,7 @@ const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => { } // Check if user has access to any of these agents - for (const agent of Array.isArray(agentsWithFile) ? agentsWithFile : [agentsWithFile]) { + for (const agent of agentsWithFile) { // Check if user is the agent author if (agent.author && agent.author.toString() === userId) { logger.debug(`[fileAccess] User is author of agent ${agent.id}`); @@ -83,7 +84,6 @@ const fileAccess = async (req, res, next) => { }); } - // Get the file const [file] = await getFiles({ file_id: fileId }); if (!file) { return res.status(404).json({ @@ -92,20 +92,18 @@ const fileAccess = async (req, res, next) => { }); } - // Check if user owns the file if (file.user && file.user.toString() === userId) { req.fileAccess = { file }; return next(); } - // Check agent-based access (file inherits agent permissions) + /** Agent-based access (file inherits agent permissions) */ const hasAgentAccess = await checkAgentBasedFileAccess({ userId, role: userRole, fileId }); if (hasAgentAccess) { req.fileAccess = { file }; return next(); } - // No access logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`); return res.status(403).json({ error: 'Forbidden', diff --git a/api/server/middleware/accessResources/fileAccess.spec.js b/api/server/middleware/accessResources/fileAccess.spec.js new file mode 100644 index 000000000..6e741ac34 --- /dev/null +++ b/api/server/middleware/accessResources/fileAccess.spec.js @@ -0,0 +1,483 @@ +const mongoose = require('mongoose'); +const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { fileAccess } = require('./fileAccess'); +const { User, Role, AclEntry } = require('~/db/models'); +const { createAgent } = require('~/models/Agent'); +const { createFile } = require('~/models/File'); + +describe('fileAccess middleware', () => { + let mongoServer; + let req, res, next; + let testUser, otherUser, thirdUser; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + + // Create test role + await Role.create({ + name: 'test-role', + permissions: { + AGENTS: { + USE: true, + CREATE: true, + SHARED_GLOBAL: false, + }, + }, + }); + + // Create test users + testUser = await User.create({ + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + role: 'test-role', + }); + + otherUser = await User.create({ + email: 'other@example.com', + name: 'Other User', + username: 'otheruser', + role: 'test-role', + }); + + thirdUser = await User.create({ + email: 'third@example.com', + name: 'Third User', + username: 'thirduser', + role: 'test-role', + }); + + // Setup request/response objects + req = { + user: { id: testUser._id.toString(), role: testUser.role }, + params: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + + jest.clearAllMocks(); + }); + + describe('basic file access', () => { + test('should allow access when user owns the file', async () => { + // Create a file owned by testUser + await createFile({ + user: testUser._id.toString(), + file_id: 'file_owned_by_user', + filepath: '/test/file.txt', + filename: 'file.txt', + type: 'text/plain', + size: 100, + }); + + req.params.file_id = 'file_owned_by_user'; + await fileAccess(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.fileAccess).toBeDefined(); + expect(req.fileAccess.file).toBeDefined(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should deny access when user does not own the file and no agent access', async () => { + // Create a file owned by otherUser + await createFile({ + user: otherUser._id.toString(), + file_id: 'file_owned_by_other', + filepath: '/test/file.txt', + filename: 'file.txt', + type: 'text/plain', + size: 100, + }); + + req.params.file_id = 'file_owned_by_other'; + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Forbidden', + message: 'Insufficient permissions to access this file', + }); + }); + + test('should return 404 when file does not exist', async () => { + req.params.file_id = 'non_existent_file'; + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'Not Found', + message: 'File not found', + }); + }); + + test('should return 400 when file_id is missing', async () => { + // Don't set file_id in params + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Bad Request', + message: 'file_id is required', + }); + }); + + test('should return 401 when user is not authenticated', async () => { + req.user = null; + req.params.file_id = 'some_file'; + + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'Authentication required', + }); + }); + }); + + describe('agent-based file access', () => { + beforeEach(async () => { + // Create a file owned by otherUser (not testUser) + await createFile({ + user: otherUser._id.toString(), + file_id: 'shared_file_via_agent', + filepath: '/test/shared.txt', + filename: 'shared.txt', + type: 'text/plain', + size: 100, + }); + }); + + test('should allow access when user is author of agent with file', async () => { + // Create agent owned by testUser with the file + await createAgent({ + id: `agent_${Date.now()}`, + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + tool_resources: { + file_search: { + file_ids: ['shared_file_via_agent'], + }, + }, + }); + + req.params.file_id = 'shared_file_via_agent'; + await fileAccess(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.fileAccess).toBeDefined(); + expect(req.fileAccess.file).toBeDefined(); + }); + + test('should allow access when user has VIEW permission on agent with file', async () => { + // Create agent owned by otherUser + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Shared Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + tool_resources: { + execute_code: { + file_ids: ['shared_file_via_agent'], + }, + }, + }); + + // Grant VIEW permission to testUser + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: 1, // VIEW permission + grantedBy: otherUser._id, + }); + + req.params.file_id = 'shared_file_via_agent'; + await fileAccess(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.fileAccess).toBeDefined(); + }); + + test('should check file in ocr tool_resources', async () => { + await createAgent({ + id: `agent_ocr_${Date.now()}`, + name: 'OCR Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + tool_resources: { + ocr: { + file_ids: ['shared_file_via_agent'], + }, + }, + }); + + req.params.file_id = 'shared_file_via_agent'; + await fileAccess(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.fileAccess).toBeDefined(); + }); + + test('should deny access when user has no permission on agent with file', async () => { + // Create agent owned by otherUser without granting permission to testUser + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Private Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + tool_resources: { + file_search: { + file_ids: ['shared_file_via_agent'], + }, + }, + }); + + // Create ACL entry for otherUser only (owner) + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: otherUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: 15, // All permissions + grantedBy: otherUser._id, + }); + + req.params.file_id = 'shared_file_via_agent'; + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + }); + + describe('multiple agents with same file', () => { + /** + * This test suite verifies that when multiple agents have the same file, + * all agents are checked for permissions, not just the first one found. + * This ensures users can access files through any agent they have permission for. + */ + + test('should check ALL agents with file, not just first one', async () => { + // Create a file owned by someone else + await createFile({ + user: otherUser._id.toString(), + file_id: 'multi_agent_file', + filepath: '/test/multi.txt', + filename: 'multi.txt', + type: 'text/plain', + size: 100, + }); + + // Create first agent (owned by otherUser, no access for testUser) + const agent1 = await createAgent({ + id: 'agent_no_access', + name: 'No Access Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + tool_resources: { + file_search: { + file_ids: ['multi_agent_file'], + }, + }, + }); + + // Create ACL for agent1 - only otherUser has access + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: otherUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agent1._id, + permBits: 15, + grantedBy: otherUser._id, + }); + + // Create second agent (owned by thirdUser, but testUser has VIEW access) + const agent2 = await createAgent({ + id: 'agent_with_access', + name: 'Accessible Agent', + provider: 'openai', + model: 'gpt-4', + author: thirdUser._id, + tool_resources: { + file_search: { + file_ids: ['multi_agent_file'], + }, + }, + }); + + // Grant testUser VIEW access to agent2 + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agent2._id, + permBits: 1, // VIEW permission + grantedBy: thirdUser._id, + }); + + req.params.file_id = 'multi_agent_file'; + await fileAccess(req, res, next); + + /** + * Should succeed because testUser has access to agent2, + * even though they don't have access to agent1. + * The fix ensures all agents are checked, not just the first one. + */ + expect(next).toHaveBeenCalled(); + expect(req.fileAccess).toBeDefined(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should find file in any agent tool_resources type', async () => { + // Create a file + await createFile({ + user: otherUser._id.toString(), + file_id: 'multi_tool_file', + filepath: '/test/tool.txt', + filename: 'tool.txt', + type: 'text/plain', + size: 100, + }); + + // Agent 1: file in file_search (no access for testUser) + await createAgent({ + id: 'agent_file_search', + name: 'File Search Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + tool_resources: { + file_search: { + file_ids: ['multi_tool_file'], + }, + }, + }); + + // Agent 2: same file in execute_code (testUser has access) + await createAgent({ + id: 'agent_execute_code', + name: 'Execute Code Agent', + provider: 'openai', + model: 'gpt-4', + author: thirdUser._id, + tool_resources: { + execute_code: { + file_ids: ['multi_tool_file'], + }, + }, + }); + + // Agent 3: same file in ocr (testUser also has access) + await createAgent({ + id: 'agent_ocr', + name: 'OCR Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, // testUser owns this one + tool_resources: { + ocr: { + file_ids: ['multi_tool_file'], + }, + }, + }); + + req.params.file_id = 'multi_tool_file'; + await fileAccess(req, res, next); + + /** + * Should succeed because testUser owns agent3, + * even if other agents with the file are found first. + */ + expect(next).toHaveBeenCalled(); + expect(req.fileAccess).toBeDefined(); + }); + }); + + describe('edge cases', () => { + test('should handle agent with empty tool_resources', async () => { + await createFile({ + user: otherUser._id.toString(), + file_id: 'orphan_file', + filepath: '/test/orphan.txt', + filename: 'orphan.txt', + type: 'text/plain', + size: 100, + }); + + // Create agent with no files in tool_resources + await createAgent({ + id: `agent_empty_${Date.now()}`, + name: 'Empty Resources Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + tool_resources: {}, + }); + + req.params.file_id = 'orphan_file'; + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should handle agent with null tool_resources', async () => { + await createFile({ + user: otherUser._id.toString(), + file_id: 'another_orphan_file', + filepath: '/test/orphan2.txt', + filename: 'orphan2.txt', + type: 'text/plain', + size: 100, + }); + + // Create agent with null tool_resources + await createAgent({ + id: `agent_null_${Date.now()}`, + name: 'Null Resources Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + tool_resources: null, + }); + + req.params.file_id = 'another_orphan_file'; + await fileAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + }); +}); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 8b3b5fbcf..367e7bf34 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -552,7 +552,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { throw new Error('File search is not enabled for Agents'); } // Note: File search processing continues to dual storage logic below - } else if (tool_resource === EToolResources.ocr) { + } else if (tool_resource === EToolResources.context) { const { file_id, temp_file_id = null } = metadata; /** diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 87005e64d..174eae078 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -353,7 +353,12 @@ async function processRequiredActions(client, requiredActions) { async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) { if (!agent.tools || agent.tools.length === 0) { return {}; - } else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) { + } else if ( + agent.tools && + agent.tools.length === 1 && + /** Legacy handling for `ocr` as may still exist in existing Agents */ + (agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr) + ) { return {}; } diff --git a/client/src/Providers/DragDropContext.tsx b/client/src/Providers/DragDropContext.tsx new file mode 100644 index 000000000..a86af6510 --- /dev/null +++ b/client/src/Providers/DragDropContext.tsx @@ -0,0 +1,32 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useChatContext } from './ChatContext'; + +interface DragDropContextValue { + conversationId: string | null | undefined; + agentId: string | null | undefined; +} + +const DragDropContext = createContext(undefined); + +export function DragDropProvider({ children }: { children: React.ReactNode }) { + const { conversation } = useChatContext(); + + /** Context value only created when conversation fields change */ + const contextValue = useMemo( + () => ({ + conversationId: conversation?.conversationId, + agentId: conversation?.agent_id, + }), + [conversation?.conversationId, conversation?.agent_id], + ); + + return {children}; +} + +export function useDragDropContext() { + const context = useContext(DragDropContext); + if (!context) { + throw new Error('useDragDropContext must be used within DragDropProvider'); + } + return context; +} diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index c12a4e184..d3607d291 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -23,6 +23,7 @@ export * from './SetConvoContext'; export * from './SearchContext'; export * from './BadgeRowContext'; export * from './SidePanelContext'; +export * from './DragDropContext'; export * from './MCPPanelContext'; export * from './ArtifactsContext'; export * from './PromptGroupsContext'; diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index f4588fed8..3cbfe330c 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -11,9 +11,9 @@ import { AgentListResponse, } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; +import { useLocalize, useDefaultConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; import { renderAgentAvatar } from '~/utils'; -import { useLocalize } from '~/hooks'; interface SupportContact { name?: string; @@ -34,11 +34,11 @@ interface AgentDetailProps { */ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => { const localize = useLocalize(); - // const navigate = useNavigate(); - const { conversation, newConversation } = useChatContext(); + const queryClient = useQueryClient(); const { showToast } = useToastContext(); const dialogRef = useRef(null); - const queryClient = useQueryClient(); + const getDefaultConversation = useDefaultConvo(); + const { conversation, newConversation } = useChatContext(); /** * Navigate to chat with the selected agent @@ -62,13 +62,22 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => ); queryClient.invalidateQueries([QueryKeys.messages]); + /** Template with agent configuration */ + const template = { + conversationId: Constants.NEW_CONVO as string, + endpoint: EModelEndpoint.agents, + agent_id: agent.id, + title: localize('com_agents_chat_with', { name: agent.name || localize('com_ui_agent') }), + }; + + const currentConvo = getDefaultConversation({ + conversation: { ...(conversation ?? {}), ...template }, + preset: template, + }); + newConversation({ - template: { - conversationId: Constants.NEW_CONVO as string, - endpoint: EModelEndpoint.agents, - agent_id: agent.id, - title: `Chat with ${agent.name || 'Agent'}`, - }, + template: currentConvo, + preset: template, }); } }; diff --git a/client/src/components/Agents/tests/AgentDetail.spec.tsx b/client/src/components/Agents/tests/AgentDetail.spec.tsx index 54fa764a8..833405c1e 100644 --- a/client/src/components/Agents/tests/AgentDetail.spec.tsx +++ b/client/src/components/Agents/tests/AgentDetail.spec.tsx @@ -20,6 +20,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('~/hooks', () => ({ useMediaQuery: jest.fn(() => false), // Mock as desktop by default useLocalize: jest.fn(), + useDefaultConvo: jest.fn(), })); jest.mock('@librechat/client', () => ({ @@ -47,7 +48,12 @@ const mockWriteText = jest.fn(); const mockNavigate = jest.fn(); const mockShowToast = jest.fn(); -const mockLocalize = jest.fn((key: string) => key); +const mockLocalize = jest.fn((key: string, values?: Record) => { + if (key === 'com_agents_chat_with' && values?.name) { + return `Chat with ${values.name}`; + } + return key; +}); const mockAgent: t.Agent = { id: 'test-agent-id', @@ -106,8 +112,12 @@ describe('AgentDetail', () => { (useNavigate as jest.Mock).mockReturnValue(mockNavigate); const { useToastContext } = require('@librechat/client'); (useToastContext as jest.Mock).mockReturnValue({ showToast: mockShowToast }); - const { useLocalize } = require('~/hooks'); + const { useLocalize, useDefaultConvo } = require('~/hooks'); (useLocalize as jest.Mock).mockReturnValue(mockLocalize); + (useDefaultConvo as jest.Mock).mockReturnValue(() => ({ + conversationId: Constants.NEW_CONVO, + endpoint: EModelEndpoint.agents, + })); // Mock useChatContext const { useChatContext } = require('~/Providers'); @@ -227,6 +237,10 @@ describe('AgentDetail', () => { template: { conversationId: Constants.NEW_CONVO, endpoint: EModelEndpoint.agents, + }, + preset: { + conversationId: Constants.NEW_CONVO, + endpoint: EModelEndpoint.agents, agent_id: 'test-agent-id', title: 'Chat with Test Agent', }, diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 75f38b267..75398c263 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useMemo } from 'react'; import * as Ariakit from '@ariakit/react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilState } from 'recoil'; import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider'; import { @@ -42,7 +42,9 @@ const AttachFileMenu = ({ const isUploadDisabled = disabled ?? false; const inputRef = useRef(null); const [isPopoverActive, setIsPopoverActive] = useState(false); - const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId)); + const [ephemeralAgent, setEphemeralAgent] = useRecoilState( + ephemeralAgentByConvoId(conversationId), + ); const [toolResource, setToolResource] = useState(); const { handleFileChange } = useFileHandling({ overrideEndpoint: EModelEndpoint.agents, @@ -64,7 +66,10 @@ const AttachFileMenu = ({ * */ const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); - const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(agentId); + const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( + agentId, + ephemeralAgent, + ); const handleUploadClick = (isImage?: boolean) => { if (!inputRef.current) { @@ -89,11 +94,11 @@ const AttachFileMenu = ({ }, ]; - if (capabilities.ocrEnabled) { + if (capabilities.contextEnabled) { items.push({ label: localize('com_ui_upload_ocr_text'), onClick: () => { - setToolResource(EToolResources.ocr); + setToolResource(EToolResources.context); onAction(); }, icon: , diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 3263b05f1..e9992c4dc 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -1,14 +1,16 @@ import React, { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; -import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider'; +import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; import { useAgentToolPermissions, useAgentCapabilities, useGetAgentsConfig, useLocalize, } from '~/hooks'; -import { useChatContext } from '~/Providers'; +import { ephemeralAgentByConvoId } from '~/store'; +import { useDragDropContext } from '~/Providers'; interface DragDropModalProps { onOptionSelect: (option: EToolResources | undefined) => void; @@ -32,9 +34,11 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD * Use definition for agents endpoint for ephemeral agents * */ const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); - const { conversation } = useChatContext(); + const { conversationId, agentId } = useDragDropContext(); + const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? '')); const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( - conversation?.agent_id, + agentId, + ephemeralAgent, ); const options = useMemo(() => { @@ -60,10 +64,10 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD icon: , }); } - if (capabilities.ocrEnabled) { + if (capabilities.contextEnabled) { _options.push({ label: localize('com_ui_upload_ocr_text'), - value: EToolResources.ocr, + value: EToolResources.context, icon: , }); } diff --git a/client/src/components/Chat/Input/Files/DragDropWrapper.tsx b/client/src/components/Chat/Input/Files/DragDropWrapper.tsx index 1a3698e09..4a01d8a2a 100644 --- a/client/src/components/Chat/Input/Files/DragDropWrapper.tsx +++ b/client/src/components/Chat/Input/Files/DragDropWrapper.tsx @@ -1,6 +1,7 @@ import { useDragHelpers } from '~/hooks'; import DragDropOverlay from '~/components/Chat/Input/Files/DragDropOverlay'; import DragDropModal from '~/components/Chat/Input/Files/DragDropModal'; +import { DragDropProvider } from '~/Providers'; import { cn } from '~/utils'; interface DragDropWrapperProps { @@ -19,12 +20,14 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper {children} {/** Always render overlay to avoid mount/unmount overhead */} - + + + ); } diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index a48a37259..7f296fe8c 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -79,9 +79,9 @@ export default function AgentConfig({ createMutation }: Pick