mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🔒 feat: View/Delete Shared Agent Files (#8419)
* 🔧 fix: Add localized message for delete operation not allowed
* refactor: improve file deletion operations ux
* feat: agent-based file access control and enhance file retrieval logic
* feat: implement agent-specific file retrieval
* feat: enhance agent file retrieval logic for authors and shared access
* ci: include userId and agentId in mockGetFiles call for OCR file retrieval
This commit is contained in:
parent
6aa4bb5a4a
commit
f1b29ffb45
22 changed files with 1216 additions and 35 deletions
|
|
@ -11,17 +11,25 @@ const { getFiles } = require('~/models/File');
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {ServerRequest} options.req
|
* @param {ServerRequest} options.req
|
||||||
* @param {Agent['tool_resources']} options.tool_resources
|
* @param {Agent['tool_resources']} options.tool_resources
|
||||||
|
* @param {string} [options.agentId] - The agent ID for file access control
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* files: Array<{ file_id: string; filename: string }>,
|
* files: Array<{ file_id: string; filename: string }>,
|
||||||
* toolContext: string
|
* toolContext: string
|
||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
const primeFiles = async (options) => {
|
const primeFiles = async (options) => {
|
||||||
const { tool_resources } = options;
|
const { tool_resources, req, agentId } = 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({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles);
|
const dbFiles = (
|
||||||
|
(await getFiles(
|
||||||
|
{ file_id: { $in: file_ids } },
|
||||||
|
null,
|
||||||
|
{ text: 0 },
|
||||||
|
{ userId: req?.user?.id, agentId },
|
||||||
|
)) ?? []
|
||||||
|
).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.`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,13 @@ const loadTools = async ({
|
||||||
authFields: [EnvVar.CODE_API_KEY],
|
authFields: [EnvVar.CODE_API_KEY],
|
||||||
});
|
});
|
||||||
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||||
const { files, toolContext } = await primeCodeFiles(options, codeApiKey);
|
const { files, toolContext } = await primeCodeFiles(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
agentId: agent?.id,
|
||||||
|
},
|
||||||
|
codeApiKey,
|
||||||
|
);
|
||||||
if (toolContext) {
|
if (toolContext) {
|
||||||
toolContextMap[tool] = toolContext;
|
toolContextMap[tool] = toolContext;
|
||||||
}
|
}
|
||||||
|
|
@ -260,7 +266,10 @@ const loadTools = async ({
|
||||||
continue;
|
continue;
|
||||||
} else if (tool === Tools.file_search) {
|
} else if (tool === Tools.file_search) {
|
||||||
requestedTools[tool] = async () => {
|
requestedTools[tool] = async () => {
|
||||||
const { files, toolContext } = await primeSearchFiles(options);
|
const { files, toolContext } = await primeSearchFiles({
|
||||||
|
...options,
|
||||||
|
agentId: agent?.id,
|
||||||
|
});
|
||||||
if (toolContext) {
|
if (toolContext) {
|
||||||
toolContextMap[tool] = toolContext;
|
toolContextMap[tool] = toolContext;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { EToolResources, FileContext } = require('librechat-data-provider');
|
const { EToolResources, FileContext, Constants } = require('librechat-data-provider');
|
||||||
|
const { getProjectByName } = require('./Project');
|
||||||
|
const { getAgent } = require('./Agent');
|
||||||
const { File } = require('~/db/models');
|
const { File } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,17 +14,119 @@ 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) => {
|
||||||
|
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 (!agent.isCollaborative) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent is globally shared and collaborative - 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 }) => {
|
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => {
|
||||||
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
||||||
return await File.find(filter).select(selectFields).sort(sortOptions).lean();
|
const files = 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);
|
||||||
|
|
||||||
|
// Filter files based on access
|
||||||
|
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
|
||||||
|
|
||||||
|
return [...ownedFiles, ...accessibleFiles];
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,4 +280,5 @@ module.exports = {
|
||||||
deleteFiles,
|
deleteFiles,
|
||||||
deleteFileByFilter,
|
deleteFileByFilter,
|
||||||
batchUpdateFiles,
|
batchUpdateFiles,
|
||||||
|
hasAccessToFilesViaAgent,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
264
api/models/File.spec.js
Normal file
264
api/models/File.spec.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
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 { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
const { getFiles, createFile } = require('./File');
|
||||||
|
const { getProjectByName } = require('./Project');
|
||||||
|
const { createAgent } = require('./Agent');
|
||||||
|
|
||||||
|
let File;
|
||||||
|
let Agent;
|
||||||
|
let Project;
|
||||||
|
|
||||||
|
describe('File Access Control', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await File.deleteMany({});
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
await Project.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasAccessToFilesViaAgent', () => {
|
||||||
|
it('should efficiently check access for multiple files at once', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create files
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId,
|
||||||
|
filename: `file-${fileId}.txt`,
|
||||||
|
filepath: `/uploads/${fileId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create agent with only first two files attached
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: true,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileIds[0], fileIds[1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get or create global project
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
|
||||||
|
// Share agent globally
|
||||||
|
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
|
||||||
|
|
||||||
|
// Check access for all files
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Should have access only to the first two files
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[2])).toBe(false);
|
||||||
|
expect(accessMap.get(fileIds[3])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should grant access to all files when user is the agent author', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileIds[0]], // Only one file attached
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check access as the author
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Author should have access to all files
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[2])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent agent gracefully', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const fileIds = [uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent');
|
||||||
|
|
||||||
|
// Should have no access to any files
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when agent is not collaborative', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const fileIds = [uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create agent with files but isCollaborative: false
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Non-Collaborative Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: false,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: fileIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get or create global project
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
|
||||||
|
// Share agent globally
|
||||||
|
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
|
||||||
|
|
||||||
|
// Check access for files
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Should have no access to any files when isCollaborative is false
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFiles with agent access control', () => {
|
||||||
|
test('should return files owned by user and files accessible through agent', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const ownedFileId = `file_${uuidv4()}`;
|
||||||
|
const sharedFileId = `file_${uuidv4()}`;
|
||||||
|
const inaccessibleFileId = `file_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Create/get global project using getProjectByName which will upsert
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME);
|
||||||
|
|
||||||
|
// Create agent with shared file
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Shared Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
projectIds: [globalProject._id],
|
||||||
|
isCollaborative: true,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [sharedFileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create files
|
||||||
|
await createFile({
|
||||||
|
file_id: ownedFileId,
|
||||||
|
user: userId,
|
||||||
|
filename: 'owned.txt',
|
||||||
|
filepath: '/uploads/owned.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: sharedFileId,
|
||||||
|
user: authorId,
|
||||||
|
filename: 'shared.txt',
|
||||||
|
filepath: '/uploads/shared.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 200,
|
||||||
|
embedded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: inaccessibleFileId,
|
||||||
|
user: authorId,
|
||||||
|
filename: 'inaccessible.txt',
|
||||||
|
filepath: '/uploads/inaccessible.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get files with access control
|
||||||
|
const files = await getFiles(
|
||||||
|
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
|
||||||
|
null,
|
||||||
|
{ text: 0 },
|
||||||
|
{ userId: userId.toString(), agentId },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
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)).not.toContain(inaccessibleFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return all files when no userId/agentId provided', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const fileId1 = `file_${uuidv4()}`;
|
||||||
|
const fileId2 = `file_${uuidv4()}`;
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: fileId1,
|
||||||
|
user: userId,
|
||||||
|
filename: 'file1.txt',
|
||||||
|
filepath: '/uploads/file1.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: fileId2,
|
||||||
|
user: new mongoose.Types.ObjectId(),
|
||||||
|
filename: 'file2.txt',
|
||||||
|
filepath: '/uploads/file2.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await getFiles({ file_id: { $in: [fileId1, fileId2] } });
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
282
api/server/routes/files/files.agents.test.js
Normal file
282
api/server/routes/files/files.agents.test.js
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||||
|
filterFile: jest.fn(),
|
||||||
|
processFileUpload: jest.fn(),
|
||||||
|
processAgentFileUpload: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||||||
|
getStrategyFunctions: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
||||||
|
getOpenAIClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||||
|
loadAuthValues: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||||
|
refreshS3FileUrls: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
let app;
|
||||||
|
let mongoServer;
|
||||||
|
let authorId;
|
||||||
|
let otherUserId;
|
||||||
|
let agentId;
|
||||||
|
let fileId1;
|
||||||
|
let fileId2;
|
||||||
|
let fileId3;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
await mongoose.connect(mongoServer.getUri());
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
require('~/db/models');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mock authentication middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: otherUserId || 'default-user' };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/files', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Clear database
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId().toString();
|
||||||
|
agentId = uuidv4();
|
||||||
|
fileId1 = uuidv4();
|
||||||
|
fileId2 = uuidv4();
|
||||||
|
fileId3 = uuidv4();
|
||||||
|
|
||||||
|
// Create files
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId1,
|
||||||
|
filename: 'agent-file1.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${fileId1}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId2,
|
||||||
|
filename: 'agent-file2.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${fileId2}`,
|
||||||
|
bytes: 2048,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
user: otherUserId,
|
||||||
|
file_id: fileId3,
|
||||||
|
filename: 'user-file.txt',
|
||||||
|
filepath: `/uploads/${otherUserId}/${fileId3}`,
|
||||||
|
bytes: 512,
|
||||||
|
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', () => {
|
||||||
|
it('should return files accessible through the agent for non-author', async () => {
|
||||||
|
const response = await request(app).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(2); // Only agent files, not user-owned files
|
||||||
|
|
||||||
|
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 400 when agent_id is not provided', async () => {
|
||||||
|
const response = await request(app).get('/files/agent/');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404); // Express returns 404 for missing route parameter
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for non-existent agent', async () => {
|
||||||
|
const response = await request(app).get('/files/agent/non-existent-agent');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([]); // Empty array for non-existent agent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when agent is not collaborative', async () => {
|
||||||
|
// Create a non-collaborative agent
|
||||||
|
const nonCollabAgentId = uuidv4();
|
||||||
|
await createAgent({
|
||||||
|
id: nonCollabAgentId,
|
||||||
|
name: 'Non-Collaborative Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: false,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share it globally
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
if (globalProject) {
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(app).get(`/files/agent/${nonCollabAgentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([]); // Empty array when not collaborative
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return agent files for agent author', async () => {
|
||||||
|
// Create a new app instance with author authentication
|
||||||
|
const authorApp = express();
|
||||||
|
authorApp.use(express.json());
|
||||||
|
authorApp.use((req, res, next) => {
|
||||||
|
req.user = { id: authorId };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
authorApp.use('/files', router);
|
||||||
|
|
||||||
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(2); // Agent files for author
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
// Create a file uploaded by another user
|
||||||
|
const otherUserFileId = uuidv4();
|
||||||
|
const anotherUserId = new mongoose.Types.ObjectId().toString();
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
user: anotherUserId,
|
||||||
|
file_id: otherUserFileId,
|
||||||
|
filename: 'other-user-file.txt',
|
||||||
|
filepath: `/uploads/${anotherUserId}/${otherUserFileId}`,
|
||||||
|
bytes: 4096,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update agent to include the file uploaded by another user
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1, fileId2, otherUserFileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create app instance with author authentication
|
||||||
|
const authorApp = express();
|
||||||
|
authorApp.use(express.json());
|
||||||
|
authorApp.use((req, res, next) => {
|
||||||
|
req.user = { id: authorId };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
authorApp.use('/files', router);
|
||||||
|
|
||||||
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(3); // Including file from another user
|
||||||
|
|
||||||
|
const fileIds = response.body.map((f) => f.file_id);
|
||||||
|
expect(fileIds).toContain(fileId1);
|
||||||
|
expect(fileIds).toContain(fileId2);
|
||||||
|
expect(fileIds).toContain(otherUserFileId); // File uploaded by another user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,7 @@ const {
|
||||||
Time,
|
Time,
|
||||||
isUUID,
|
isUUID,
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
|
Constants,
|
||||||
FileSources,
|
FileSources,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
|
|
@ -16,11 +17,12 @@ 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 { 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 { getFiles, batchUpdateFiles } = require('~/models/File');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { getAssistant } = require('~/models/Assistant');
|
const { getAssistant } = require('~/models/Assistant');
|
||||||
const { getAgent } = require('~/models/Agent');
|
const { getAgent } = require('~/models/Agent');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
@ -50,6 +52,68 @@ router.get('/', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get files specific to an agent
|
||||||
|
* @route GET /files/agent/:agent_id
|
||||||
|
* @param {string} agent_id - The agent ID to get files for
|
||||||
|
* @returns {Promise<TFile[]>} Array of files attached to the agent
|
||||||
|
*/
|
||||||
|
router.get('/agent/:agent_id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { agent_id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
if (!agent_id) {
|
||||||
|
return res.status(400).json({ error: 'Agent ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the agent to check ownership and attached files
|
||||||
|
const agent = await getAgent({ id: agent_id });
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
// No agent found, return empty array
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to the agent
|
||||||
|
if (agent.author.toString() !== userId) {
|
||||||
|
// Non-authors need the agent to be globally shared and collaborative
|
||||||
|
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!globalProject ||
|
||||||
|
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) ||
|
||||||
|
!agent.isCollaborative
|
||||||
|
) {
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all file IDs from agent's tool resources
|
||||||
|
const agentFileIds = [];
|
||||||
|
if (agent.tool_resources) {
|
||||||
|
for (const [, resource] of Object.entries(agent.tool_resources)) {
|
||||||
|
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
||||||
|
agentFileIds.push(...resource.file_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no files attached to agent, return empty array
|
||||||
|
if (agentFileIds.length === 0) {
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get only the files attached to this agent
|
||||||
|
const files = await getFiles({ file_id: { $in: agentFileIds } }, null, { text: 0 });
|
||||||
|
|
||||||
|
res.status(200).json(files);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[/files/agent/:agent_id] Error fetching agent files:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch agent files' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/config', async (req, res) => {
|
router.get('/config', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.status(200).json(req.app.locals.fileConfig);
|
res.status(200).json(req.app.locals.fileConfig);
|
||||||
|
|
@ -86,11 +150,62 @@ router.delete('/', async (req, res) => {
|
||||||
|
|
||||||
const fileIds = files.map((file) => file.file_id);
|
const fileIds = files.map((file) => file.file_id);
|
||||||
const dbFiles = await getFiles({ file_id: { $in: fileIds } });
|
const dbFiles = await getFiles({ file_id: { $in: fileIds } });
|
||||||
const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id);
|
|
||||||
|
const ownedFiles = [];
|
||||||
|
const nonOwnedFiles = [];
|
||||||
|
const fileMap = new Map();
|
||||||
|
|
||||||
|
for (const file of dbFiles) {
|
||||||
|
fileMap.set(file.file_id, file);
|
||||||
|
if (file.user.toString() === req.user.id) {
|
||||||
|
ownedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
nonOwnedFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all files are owned by the user, no need for further checks
|
||||||
|
if (nonOwnedFiles.length === 0) {
|
||||||
|
await processDeleteRequest({ req, files: ownedFiles });
|
||||||
|
logger.debug(
|
||||||
|
`[/files] Files deleted successfully: ${ownedFiles
|
||||||
|
.filter((f) => f.file_id)
|
||||||
|
.map((f) => f.file_id)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
res.status(200).json({ message: 'Files deleted successfully' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access for non-owned files
|
||||||
|
let authorizedFiles = [...ownedFiles];
|
||||||
|
let unauthorizedFiles = [];
|
||||||
|
|
||||||
|
if (req.body.agent_id && nonOwnedFiles.length > 0) {
|
||||||
|
// Batch check access for all non-owned files
|
||||||
|
const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id);
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(
|
||||||
|
req.user.id,
|
||||||
|
nonOwnedFileIds,
|
||||||
|
req.body.agent_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate authorized and unauthorized files
|
||||||
|
for (const file of nonOwnedFiles) {
|
||||||
|
if (accessMap.get(file.file_id)) {
|
||||||
|
authorizedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
unauthorizedFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No agent context, all non-owned files are unauthorized
|
||||||
|
unauthorizedFiles = nonOwnedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
if (unauthorizedFiles.length > 0) {
|
if (unauthorizedFiles.length > 0) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: 'You can only delete your own files',
|
message: 'You can only delete files you have access to',
|
||||||
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
|
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -131,10 +246,10 @@ router.delete('/', async (req, res) => {
|
||||||
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await processDeleteRequest({ req, files: dbFiles });
|
await processDeleteRequest({ req, files: authorizedFiles });
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[/files] Files deleted successfully: ${files
|
`[/files] Files deleted successfully: ${authorizedFiles
|
||||||
.filter((f) => f.file_id)
|
.filter((f) => f.file_id)
|
||||||
.map((f) => f.file_id)
|
.map((f) => f.file_id)
|
||||||
.join(', ')}`,
|
.join(', ')}`,
|
||||||
|
|
|
||||||
302
api/server/routes/files/files.test.js
Normal file
302
api/server/routes/files/files.test.js
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||||
|
filterFile: jest.fn(),
|
||||||
|
processFileUpload: jest.fn(),
|
||||||
|
processAgentFileUpload: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||||||
|
getStrategyFunctions: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
||||||
|
getOpenAIClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||||
|
loadAuthValues: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||||
|
refreshS3FileUrls: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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');
|
||||||
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
|
|
||||||
|
// Import the router after mocks
|
||||||
|
const router = require('./files');
|
||||||
|
|
||||||
|
describe('File Routes - Delete with Agent Access', () => {
|
||||||
|
let app;
|
||||||
|
let mongoServer;
|
||||||
|
let authorId;
|
||||||
|
let otherUserId;
|
||||||
|
let agentId;
|
||||||
|
let fileId;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
await mongoose.connect(mongoServer.getUri());
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
require('~/db/models');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mock authentication middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: otherUserId || 'default-user' };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/files', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Clear database
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId().toString();
|
||||||
|
fileId = uuidv4();
|
||||||
|
|
||||||
|
// Create a file owned by the author
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId,
|
||||||
|
filename: 'test.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
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', () => {
|
||||||
|
it('should allow deleting files owned by the user', async () => {
|
||||||
|
// Create a file owned by the current user
|
||||||
|
const userFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: otherUserId,
|
||||||
|
file_id: userFileId,
|
||||||
|
filename: 'user-file.txt',
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: userFileId,
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Files deleted successfully');
|
||||||
|
expect(processDeleteRequest).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deleting files not owned by user without agent context', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId,
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(fileId);
|
||||||
|
expect(processDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow deleting files accessible through shared agent', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId,
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Files deleted successfully');
|
||||||
|
expect(processDeleteRequest).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deleting files not attached to the specified agent', async () => {
|
||||||
|
// Create another file not attached to the agent
|
||||||
|
const unattachedFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: unattachedFileId,
|
||||||
|
filename: 'unattached.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: unattachedFileId,
|
||||||
|
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed authorized and unauthorized files', async () => {
|
||||||
|
// Create a file owned by the current user
|
||||||
|
const userFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: otherUserId,
|
||||||
|
file_id: userFileId,
|
||||||
|
filename: 'user-file.txt',
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an unauthorized file
|
||||||
|
const unauthorizedFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: unauthorizedFileId,
|
||||||
|
filename: 'unauthorized.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId, // Authorized through agent
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
|
||||||
|
expect(response.body.unauthorizedFiles).not.toContain(fileId);
|
||||||
|
expect(response.body.unauthorizedFiles).not.toContain(userFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deleting files when agent is not collaborative', async () => {
|
||||||
|
// Update the agent to be non-collaborative
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent({ id: agentId }, { isCollaborative: false });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId,
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(fileId);
|
||||||
|
expect(processDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,8 +11,8 @@ const {
|
||||||
replaceSpecialVars,
|
replaceSpecialVars,
|
||||||
providerEndpointMap,
|
providerEndpointMap,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
|
||||||
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||||
|
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||||
const { processFiles } = require('~/server/services/Files/process');
|
const { processFiles } = require('~/server/services/Files/process');
|
||||||
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
||||||
const { getConvoFiles } = require('~/models/Conversation');
|
const { getConvoFiles } = require('~/models/Conversation');
|
||||||
|
|
@ -82,6 +82,7 @@ const initializeAgent = async ({
|
||||||
attachments: currentFiles,
|
attachments: currentFiles,
|
||||||
tool_resources: agent.tool_resources,
|
tool_resources: agent.tool_resources,
|
||||||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||||
|
agentId: agent.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const provider = agent.provider;
|
const provider = agent.provider;
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {ServerRequest} options.req
|
* @param {ServerRequest} options.req
|
||||||
* @param {Agent['tool_resources']} options.tool_resources
|
* @param {Agent['tool_resources']} options.tool_resources
|
||||||
|
* @param {string} [options.agentId] - The agent ID for file access control
|
||||||
* @param {string} apiKey
|
* @param {string} apiKey
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* files: Array<{ id: string; session_id: string; name: string }>,
|
* files: Array<{ id: string; session_id: string; name: string }>,
|
||||||
|
|
@ -159,11 +160,18 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
const primeFiles = async (options, apiKey) => {
|
const primeFiles = async (options, apiKey) => {
|
||||||
const { tool_resources } = options;
|
const { tool_resources, req, agentId } = options;
|
||||||
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({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles);
|
const dbFiles = (
|
||||||
|
(await getFiles(
|
||||||
|
{ file_id: { $in: file_ids } },
|
||||||
|
null,
|
||||||
|
{ text: 0 },
|
||||||
|
{ userId: req?.user?.id, agentId },
|
||||||
|
)) ?? []
|
||||||
|
).concat(resourceFiles);
|
||||||
|
|
||||||
const files = [];
|
const files = [];
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { useEffect } from 'react';
|
||||||
import { EToolResources } from 'librechat-data-provider';
|
import { EToolResources } from 'librechat-data-provider';
|
||||||
import type { ExtendedFile } from '~/common';
|
import type { ExtendedFile } from '~/common';
|
||||||
import { useDeleteFilesMutation } from '~/data-provider';
|
import { useDeleteFilesMutation } from '~/data-provider';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
import { useFileDeletion } from '~/hooks/Files';
|
import { useFileDeletion } from '~/hooks/Files';
|
||||||
import FileContainer from './FileContainer';
|
import FileContainer from './FileContainer';
|
||||||
import { logger } from '~/utils';
|
import { logger } from '~/utils';
|
||||||
|
|
@ -30,6 +32,8 @@ export default function FileRow({
|
||||||
isRTL?: boolean;
|
isRTL?: boolean;
|
||||||
Wrapper?: React.FC<{ children: React.ReactNode }>;
|
Wrapper?: React.FC<{ children: React.ReactNode }>;
|
||||||
}) {
|
}) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const files = Array.from(_files?.values() ?? []).filter((file) =>
|
const files = Array.from(_files?.values() ?? []).filter((file) =>
|
||||||
fileFilter ? fileFilter(file) : true,
|
fileFilter ? fileFilter(file) : true,
|
||||||
);
|
);
|
||||||
|
|
@ -105,6 +109,10 @@ export default function FileRow({
|
||||||
)
|
)
|
||||||
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_deleting_file'),
|
||||||
|
status: 'info',
|
||||||
|
});
|
||||||
if (abortUpload && file.progress < 1) {
|
if (abortUpload && file.progress < 1) {
|
||||||
abortUpload();
|
abortUpload();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Prov
|
||||||
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
|
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
|
||||||
import Action from '~/components/SidePanel/Builder/Action';
|
import Action from '~/components/SidePanel/Builder/Action';
|
||||||
import { ToolSelectDialog } from '~/components/Tools';
|
import { ToolSelectDialog } from '~/components/Tools';
|
||||||
|
import { useGetAgentFiles } from '~/data-provider';
|
||||||
import { icons } from '~/hooks/Endpoint/Icons';
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
import { processAgentOption } from '~/utils';
|
import { processAgentOption } from '~/utils';
|
||||||
import Instructions from './Instructions';
|
import Instructions from './Instructions';
|
||||||
|
|
@ -49,6 +50,18 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
const tools = useWatch({ control, name: 'tools' });
|
const tools = useWatch({ control, name: 'tools' });
|
||||||
const agent_id = useWatch({ control, name: 'id' });
|
const agent_id = useWatch({ control, name: 'id' });
|
||||||
|
|
||||||
|
const { data: agentFiles = [] } = useGetAgentFiles(agent_id);
|
||||||
|
|
||||||
|
const mergedFileMap = useMemo(() => {
|
||||||
|
const newFileMap = { ...fileMap };
|
||||||
|
agentFiles.forEach((file) => {
|
||||||
|
if (file.file_id) {
|
||||||
|
newFileMap[file.file_id] = file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newFileMap;
|
||||||
|
}, [fileMap, agentFiles]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ocrEnabled,
|
ocrEnabled,
|
||||||
codeEnabled,
|
codeEnabled,
|
||||||
|
|
@ -74,10 +87,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
|
|
||||||
const _agent = processAgentOption({
|
const _agent = processAgentOption({
|
||||||
agent,
|
agent,
|
||||||
fileMap,
|
fileMap: mergedFileMap,
|
||||||
});
|
});
|
||||||
return _agent.context_files ?? [];
|
return _agent.context_files ?? [];
|
||||||
}, [agent, agent_id, fileMap]);
|
}, [agent, agent_id, mergedFileMap]);
|
||||||
|
|
||||||
const knowledge_files = useMemo(() => {
|
const knowledge_files = useMemo(() => {
|
||||||
if (typeof agent === 'string') {
|
if (typeof agent === 'string') {
|
||||||
|
|
@ -94,10 +107,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
|
|
||||||
const _agent = processAgentOption({
|
const _agent = processAgentOption({
|
||||||
agent,
|
agent,
|
||||||
fileMap,
|
fileMap: mergedFileMap,
|
||||||
});
|
});
|
||||||
return _agent.knowledge_files ?? [];
|
return _agent.knowledge_files ?? [];
|
||||||
}, [agent, agent_id, fileMap]);
|
}, [agent, agent_id, mergedFileMap]);
|
||||||
|
|
||||||
const code_files = useMemo(() => {
|
const code_files = useMemo(() => {
|
||||||
if (typeof agent === 'string') {
|
if (typeof agent === 'string') {
|
||||||
|
|
@ -114,10 +127,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
|
|
||||||
const _agent = processAgentOption({
|
const _agent = processAgentOption({
|
||||||
agent,
|
agent,
|
||||||
fileMap,
|
fileMap: mergedFileMap,
|
||||||
});
|
});
|
||||||
return _agent.code_files ?? [];
|
return _agent.code_files ?? [];
|
||||||
}, [agent, agent_id, fileMap]);
|
}, [agent, agent_id, mergedFileMap]);
|
||||||
|
|
||||||
const handleAddActions = useCallback(() => {
|
const handleAddActions = useCallback(() => {
|
||||||
if (!agent_id) {
|
if (!agent_id) {
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,9 @@ export default function ShareAgent({
|
||||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||||
removeFocusOutlines,
|
removeFocusOutlines,
|
||||||
)}
|
)}
|
||||||
aria-label={localize(
|
aria-label={localize('com_ui_share_var', {
|
||||||
'com_ui_share_var',
|
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||||
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
|
})}
|
||||||
)}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||||
|
|
@ -148,10 +147,9 @@ export default function ShareAgent({
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogContent className="w-11/12 md:max-w-xl">
|
<OGDialogContent className="w-11/12 md:max-w-xl">
|
||||||
<OGDialogTitle>
|
<OGDialogTitle>
|
||||||
{localize(
|
{localize('com_ui_share_var', {
|
||||||
'com_ui_share_var',
|
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||||
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
|
})}
|
||||||
)}
|
|
||||||
</OGDialogTitle>
|
</OGDialogTitle>
|
||||||
<form
|
<form
|
||||||
className="p-2"
|
className="p-2"
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export * from './queries';
|
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
|
export * from './queries';
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
} from 'librechat-data-provider';
|
} 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 { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export const useUploadFileMutation = (
|
export const useUploadFileMutation = (
|
||||||
_options?: t.UploadMutationOptions,
|
_options?: t.UploadMutationOptions,
|
||||||
|
|
@ -145,10 +147,24 @@ export const useDeleteFilesMutation = (
|
||||||
unknown // context
|
unknown // context
|
||||||
> => {
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { onSuccess, ...options } = _options || {};
|
const { showToast } = useToastContext();
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { onSuccess, onError, ...options } = _options || {};
|
||||||
return useMutation([MutationKeys.fileDelete], {
|
return useMutation([MutationKeys.fileDelete], {
|
||||||
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body),
|
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body),
|
||||||
...options,
|
...options,
|
||||||
|
onError: (error, vars, context) => {
|
||||||
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
|
const errorWithResponse = error as { response?: { status?: number } };
|
||||||
|
if (errorWithResponse.response?.status === 403) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_delete_not_allowed'),
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onError?.(error, vars, context);
|
||||||
|
},
|
||||||
onSuccess: (data, vars, context) => {
|
onSuccess: (data, vars, context) => {
|
||||||
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
|
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
|
||||||
const { files: filesDeleted } = vars;
|
const { files: filesDeleted } = vars;
|
||||||
|
|
@ -160,6 +176,12 @@ export const useDeleteFilesMutation = (
|
||||||
|
|
||||||
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
|
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_delete_success'),
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
onSuccess?.(data, vars, context);
|
onSuccess?.(data, vars, context);
|
||||||
if (vars.agent_id != null && vars.agent_id) {
|
if (vars.agent_id != null && vars.agent_id) {
|
||||||
queryClient.refetchQueries([QueryKeys.agent, vars.agent_id]);
|
queryClient.refetchQueries([QueryKeys.agent, vars.agent_id]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider';
|
||||||
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
|
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import { addFileToCache } from '~/utils';
|
import { addFileToCache } from '~/utils';
|
||||||
|
|
@ -19,6 +19,24 @@ export const useGetFiles = <TData = t.TFile[] | boolean>(
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetAgentFiles = <TData = t.TFile[]>(
|
||||||
|
agentId: string | undefined,
|
||||||
|
config?: UseQueryOptions<t.TFile[], unknown, TData>,
|
||||||
|
): QueryObserverResult<TData, unknown> => {
|
||||||
|
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
|
||||||
|
return useQuery<t.TFile[], unknown, TData>(
|
||||||
|
DynamicQueryKeys.agentFiles(agentId ?? ''),
|
||||||
|
() => (agentId ? dataService.getAgentFiles(agentId) : Promise.resolve([])),
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
...config,
|
||||||
|
enabled: (config?.enabled ?? true) === true && queriesEnabled && !!agentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const useGetFileConfig = <TData = t.FileConfig>(
|
export const useGetFileConfig = <TData = t.FileConfig>(
|
||||||
config?: UseQueryOptions<t.FileConfig, unknown, TData>,
|
config?: UseQueryOptions<t.FileConfig, unknown, TData>,
|
||||||
): QueryObserverResult<TData, unknown> => {
|
): QueryObserverResult<TData, unknown> => {
|
||||||
|
|
|
||||||
|
|
@ -703,8 +703,11 @@
|
||||||
"com_ui_delete_mcp_error": "Failed to delete MCP server",
|
"com_ui_delete_mcp_error": "Failed to delete MCP server",
|
||||||
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
||||||
"com_ui_delete_memory": "Delete Memory",
|
"com_ui_delete_memory": "Delete Memory",
|
||||||
|
"com_ui_delete_not_allowed": "Delete operation is not allowed",
|
||||||
"com_ui_delete_prompt": "Delete Prompt?",
|
"com_ui_delete_prompt": "Delete Prompt?",
|
||||||
|
"com_ui_delete_success": "Successfully deleted",
|
||||||
"com_ui_delete_shared_link": "Delete shared link?",
|
"com_ui_delete_shared_link": "Delete shared link?",
|
||||||
|
"com_ui_deleting_file": "Deleting file...",
|
||||||
"com_ui_delete_tool": "Delete Tool",
|
"com_ui_delete_tool": "Delete Tool",
|
||||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||||
"com_ui_deleted": "Deleted",
|
"com_ui_deleted": "Deleted",
|
||||||
|
|
@ -1085,4 +1088,4 @@
|
||||||
"com_ui_yes": "Yes",
|
"com_ui_yes": "Yes",
|
||||||
"com_ui_zoom": "Zoom",
|
"com_ui_zoom": "Zoom",
|
||||||
"com_user_message": "You"
|
"com_user_message": "You"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function mapAttachments(attachments: Array<t.TAttachment | null | undefin
|
||||||
attachmentMap[key] = [];
|
attachmentMap[key] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
attachmentMap[key].push(attachment);
|
attachmentMap[key]?.push(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachmentMap;
|
return attachmentMap;
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,12 @@ describe('primeResources', () => {
|
||||||
tool_resources,
|
tool_resources,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockGetFiles).toHaveBeenCalledWith({ file_id: { $in: ['ocr-file-1'] } }, {}, {});
|
expect(mockGetFiles).toHaveBeenCalledWith(
|
||||||
|
{ file_id: { $in: ['ocr-file-1'] } },
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ userId: undefined, agentId: undefined },
|
||||||
|
);
|
||||||
expect(result.attachments).toEqual(mockOcrFiles);
|
expect(result.attachments).toEqual(mockOcrFiles);
|
||||||
expect(result.tool_resources).toEqual(tool_resources);
|
expect(result.tool_resources).toEqual(tool_resources);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ import type { Request as ServerRequest } from 'express';
|
||||||
* @param filter - MongoDB filter query for files
|
* @param filter - MongoDB filter query for files
|
||||||
* @param _sortOptions - Sorting options (currently unused)
|
* @param _sortOptions - Sorting options (currently unused)
|
||||||
* @param selectFields - Field selection options
|
* @param selectFields - Field selection options
|
||||||
|
* @param options - Additional options including userId and agentId for access control
|
||||||
* @returns Promise resolving to array of files
|
* @returns Promise resolving to array of files
|
||||||
*/
|
*/
|
||||||
export type TGetFiles = (
|
export type TGetFiles = (
|
||||||
filter: FilterQuery<IMongoFile>,
|
filter: FilterQuery<IMongoFile>,
|
||||||
_sortOptions: ProjectionType<IMongoFile> | null | undefined,
|
_sortOptions: ProjectionType<IMongoFile> | null | undefined,
|
||||||
selectFields: QueryOptions<IMongoFile> | null | undefined,
|
selectFields: QueryOptions<IMongoFile> | null | undefined,
|
||||||
|
options?: { userId?: string; agentId?: string },
|
||||||
) => Promise<Array<TFile>>;
|
) => Promise<Array<TFile>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,12 +147,14 @@ export const primeResources = async ({
|
||||||
requestFileSet,
|
requestFileSet,
|
||||||
attachments: _attachments,
|
attachments: _attachments,
|
||||||
tool_resources: _tool_resources,
|
tool_resources: _tool_resources,
|
||||||
|
agentId,
|
||||||
}: {
|
}: {
|
||||||
req: ServerRequest;
|
req: ServerRequest;
|
||||||
requestFileSet: Set<string>;
|
requestFileSet: Set<string>;
|
||||||
attachments: Promise<Array<TFile | null>> | undefined;
|
attachments: Promise<Array<TFile | null>> | undefined;
|
||||||
tool_resources: AgentToolResources | undefined;
|
tool_resources: AgentToolResources | undefined;
|
||||||
getFiles: TGetFiles;
|
getFiles: TGetFiles;
|
||||||
|
agentId?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
attachments: Array<TFile | undefined> | undefined;
|
attachments: Array<TFile | undefined> | undefined;
|
||||||
tool_resources: AgentToolResources | undefined;
|
tool_resources: AgentToolResources | undefined;
|
||||||
|
|
@ -205,6 +209,7 @@ export const primeResources = async ({
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
|
{ userId: req.user?.id, agentId },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const file of context) {
|
for (const file of context) {
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,12 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
|
||||||
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
||||||
|
|
||||||
export const files = () => '/api/files';
|
export const files = () => '/api/files';
|
||||||
|
export const fileUpload = () => '/api/files';
|
||||||
|
export const fileDelete = () => '/api/files';
|
||||||
|
export const fileDownload = (userId: string, fileId: string) =>
|
||||||
|
`/api/files/download/${userId}/${fileId}`;
|
||||||
|
export const fileConfig = () => '/api/files/config';
|
||||||
|
export const agentFiles = (agentId: string) => `/api/files/agent/${agentId}`;
|
||||||
|
|
||||||
export const images = () => `${files()}/images`;
|
export const images = () => `${files()}/images`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,10 @@ export const getFiles = (): Promise<f.TFile[]> => {
|
||||||
return request.get(endpoints.files());
|
return request.get(endpoints.files());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAgentFiles = (agentId: string): Promise<f.TFile[]> => {
|
||||||
|
return request.get(endpoints.agentFiles(agentId));
|
||||||
|
};
|
||||||
|
|
||||||
export const getFileConfig = (): Promise<f.FileConfig> => {
|
export const getFileConfig = (): Promise<f.FileConfig> => {
|
||||||
return request.get(`${endpoints.files()}/config`);
|
return request.get(`${endpoints.files()}/config`);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ export enum QueryKeys {
|
||||||
memories = 'memories',
|
memories = 'memories',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dynamic query keys that require parameters
|
||||||
|
export const DynamicQueryKeys = {
|
||||||
|
agentFiles: (agentId: string) => ['agentFiles', agentId] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export enum MutationKeys {
|
export enum MutationKeys {
|
||||||
fileUpload = 'fileUpload',
|
fileUpload = 'fileUpload',
|
||||||
fileDelete = 'fileDelete',
|
fileDelete = 'fileDelete',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue