mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-01 16:18:51 +01:00
📚 feat: Add Source Citations for File Search in Agents (#8652)
* feat: Source Citations for file_search in Agents * Fix: Added citation limits and relevance score to app service. Removed duplicate tests * ✨ feat: implement Role-level toggle to optionally disable file Source Citation in Agents * 🐛 fix: update mock for librechat-data-provider to include PermissionTypes and SystemRoles --------- Co-authored-by: “Praneeth <praneeth.goparaju@slalom.com>
This commit is contained in:
parent
d786b1b419
commit
cbde2f7184
36 changed files with 1890 additions and 190 deletions
|
|
@ -49,6 +49,7 @@ const BaseClient = require('~/app/clients/BaseClient');
|
|||
const { getRoleByName } = require('~/models/Role');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { processAgentResponse } = require('~/app/clients/agents/processAgentResponse');
|
||||
|
||||
const omitTitleOptions = new Set([
|
||||
'stream',
|
||||
|
|
@ -810,7 +811,7 @@ class AgentClient extends BaseClient {
|
|||
|
||||
if (noSystemMessages === true && systemContent?.length) {
|
||||
const latestMessageContent = _messages.pop().content;
|
||||
if (typeof latestMessage !== 'string') {
|
||||
if (typeof latestMessageContent !== 'string') {
|
||||
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
_messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
} else {
|
||||
|
|
@ -1008,6 +1009,28 @@ class AgentClient extends BaseClient {
|
|||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
}
|
||||
|
||||
// Process agent response to capture file references and create attachments
|
||||
|
||||
const processedResponse = await processAgentResponse(
|
||||
{
|
||||
messageId: this.responseMessageId,
|
||||
attachments: this.artifactPromises,
|
||||
},
|
||||
this.user ?? this.options.req.user?.id,
|
||||
this.conversationId,
|
||||
this.contentParts,
|
||||
this.options.req.user,
|
||||
);
|
||||
|
||||
// Update artifact promises with any new attachments from agent response
|
||||
if (processedResponse.attachments && processedResponse.attachments.length > 0) {
|
||||
// Add new attachments to existing artifactPromises
|
||||
processedResponse.attachments.forEach((attachment) => {
|
||||
this.artifactPromises.push(Promise.resolve(attachment));
|
||||
});
|
||||
}
|
||||
|
||||
await this.recordCollectedUsage({ context: 'message' });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -25,9 +25,55 @@ const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
|||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { cleanFileName } = require('~/server/utils/files');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Checks if user has access to shared agent file through agent ownership or permissions
|
||||
*/
|
||||
const checkSharedFileAccess = async (userId, fileId) => {
|
||||
try {
|
||||
// Find agents that have this file in their tool_resources
|
||||
const agentsWithFile = await getAgent({
|
||||
$or: [
|
||||
{ 'tool_resources.file_search.file_ids': fileId },
|
||||
{ 'tool_resources.execute_code.file_ids': fileId },
|
||||
{ 'tool_resources.ocr.file_ids': fileId },
|
||||
],
|
||||
});
|
||||
|
||||
if (!agentsWithFile || agentsWithFile.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has access to any of these agents
|
||||
for (const agent of Array.isArray(agentsWithFile) ? agentsWithFile : [agentsWithFile]) {
|
||||
// Check if user is the agent author
|
||||
if (agent.author && agent.author.toString() === userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if agent is collaborative
|
||||
if (agent.isCollaborative) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has access through project membership
|
||||
if (agent.projectIds && agent.projectIds.length > 0) {
|
||||
// For now, return true if agent has project IDs (simplified check)
|
||||
// This could be enhanced to check actual project membership
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[checkSharedFileAccess] Error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
|
|
@ -308,21 +354,32 @@ router.get('/download/:userId/:file_id', async (req, res) => {
|
|||
const { userId, file_id } = req.params;
|
||||
logger.debug(`File download requested by user ${userId}: ${file_id}`);
|
||||
|
||||
if (userId !== req.user.id) {
|
||||
logger.warn(`${errorPrefix} forbidden: ${file_id}`);
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
|
||||
const [file] = await getFiles({ file_id });
|
||||
const errorPrefix = `File download requested by user ${userId}`;
|
||||
const [file] = await getFiles({ file_id });
|
||||
|
||||
if (!file) {
|
||||
logger.warn(`${errorPrefix} not found: ${file_id}`);
|
||||
return res.status(404).send('File not found');
|
||||
}
|
||||
|
||||
if (!file.filepath.includes(userId)) {
|
||||
logger.warn(`${errorPrefix} forbidden: ${file_id}`);
|
||||
// Extract actual file owner from S3 filepath (e.g., /uploads/ownerId/filename)
|
||||
let actualFileOwner = userId;
|
||||
if (file.filepath && file.filepath.includes('/uploads/')) {
|
||||
const pathMatch = file.filepath.match(/\/uploads\/([^/]+)\//);
|
||||
if (pathMatch) {
|
||||
actualFileOwner = pathMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Check access: either own the file or have shared access through conversations
|
||||
const isFileOwner = req.user.id === actualFileOwner;
|
||||
const hasSharedAccess = !isFileOwner && (await checkSharedFileAccess(req.user.id, file_id));
|
||||
|
||||
if (!isFileOwner && !hasSharedAccess) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
|
||||
if (isFileOwner && userId !== actualFileOwner) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
|
||||
|
|
@ -338,7 +395,8 @@ router.get('/download/:userId/:file_id', async (req, res) => {
|
|||
}
|
||||
|
||||
const setHeaders = () => {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
|
||||
const cleanedFilename = cleanFileName(file.filename);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${cleanedFilename}"`);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('X-File-Metadata', JSON.stringify(file));
|
||||
};
|
||||
|
|
@ -365,12 +423,17 @@ router.get('/download/:userId/:file_id', async (req, res) => {
|
|||
logger.debug(`File ${file_id} downloaded from OpenAI`);
|
||||
passThrough.body.pipe(res);
|
||||
} else {
|
||||
fileStream = getDownloadStream(file_id);
|
||||
fileStream = await getDownloadStream(req, file.filepath);
|
||||
|
||||
fileStream.on('error', (streamError) => {
|
||||
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
|
||||
});
|
||||
|
||||
setHeaders();
|
||||
fileStream.pipe(res);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error downloading file:', error);
|
||||
logger.error('[DOWNLOAD ROUTE] Error downloading file:', error);
|
||||
res.status(500).send('Error downloading file');
|
||||
}
|
||||
});
|
||||
|
|
@ -405,7 +468,6 @@ router.post('/', async (req, res) => {
|
|||
message = error.message;
|
||||
}
|
||||
|
||||
// TODO: delete remote file if it exists
|
||||
try {
|
||||
await fs.unlink(req.file.path);
|
||||
cleanup = false;
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ describe('AppService', () => {
|
|||
agents: {
|
||||
disableBuilder: false,
|
||||
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
|
||||
maxCitations: 30,
|
||||
maxCitationsPerFile: 7,
|
||||
minRelevanceScore: 0.45,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -225,7 +225,17 @@ const primeFiles = async (options, apiKey) => {
|
|||
entity_id: queryParams.entity_id,
|
||||
apiKey,
|
||||
});
|
||||
await updateFile({ file_id: file.file_id, metadata: { fileIdentifier } });
|
||||
|
||||
// Preserve existing metadata when adding fileIdentifier
|
||||
const updatedMetadata = {
|
||||
...file.metadata, // Preserve existing metadata (like S3 storage info)
|
||||
fileIdentifier, // Add fileIdentifier
|
||||
};
|
||||
|
||||
await updateFile({
|
||||
file_id: file.file_id,
|
||||
metadata: updatedMetadata,
|
||||
});
|
||||
sessions.set(session_id, true);
|
||||
pushFile();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const {
|
||||
|
|
@ -15,7 +14,7 @@ const { logger } = require('~/config');
|
|||
const bucketName = process.env.AWS_BUCKET_NAME;
|
||||
const defaultBasePath = 'images';
|
||||
|
||||
let s3UrlExpirySeconds = 7 * 24 * 60 * 60;
|
||||
let s3UrlExpirySeconds = 2 * 60; // 2 minutes
|
||||
let s3RefreshExpiryMs = null;
|
||||
|
||||
if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
|
||||
|
|
@ -25,7 +24,7 @@ if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
|
|||
s3UrlExpirySeconds = Math.min(parsed, 7 * 24 * 60 * 60);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[S3] Invalid S3_URL_EXPIRY_SECONDS value: "${process.env.S3_URL_EXPIRY_SECONDS}". Using 7-day expiry.`,
|
||||
`[S3] Invalid S3_URL_EXPIRY_SECONDS value: "${process.env.S3_URL_EXPIRY_SECONDS}". Using 2-minute expiry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -80,12 +79,29 @@ async function saveBufferToS3({ userId, buffer, fileName, basePath = defaultBase
|
|||
* @param {string} params.userId - The user's unique identifier.
|
||||
* @param {string} params.fileName - The file name in S3.
|
||||
* @param {string} [params.basePath='images'] - The base path in the bucket.
|
||||
* @param {string} [params.customFilename] - Custom filename for Content-Disposition header (overrides extracted filename).
|
||||
* @param {string} [params.contentType] - Custom content type for the response.
|
||||
* @returns {Promise<string>} A URL to access the S3 object
|
||||
*/
|
||||
async function getS3URL({ userId, fileName, basePath = defaultBasePath }) {
|
||||
async function getS3URL({
|
||||
userId,
|
||||
fileName,
|
||||
basePath = defaultBasePath,
|
||||
customFilename = null,
|
||||
contentType = null,
|
||||
}) {
|
||||
const key = getS3Key(basePath, userId, fileName);
|
||||
const params = { Bucket: bucketName, Key: key };
|
||||
|
||||
// Add response headers if specified
|
||||
if (customFilename) {
|
||||
params.ResponseContentDisposition = `attachment; filename="${customFilename}"`;
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
params.ResponseContentType = contentType;
|
||||
}
|
||||
|
||||
try {
|
||||
const s3 = initializeS3();
|
||||
return await getSignedUrl(s3, new GetObjectCommand(params), { expiresIn: s3UrlExpirySeconds });
|
||||
|
|
@ -188,7 +204,7 @@ async function uploadFileToS3({ req, file, file_id, basePath = defaultBasePath }
|
|||
try {
|
||||
const inputFilePath = file.path;
|
||||
const userId = req.user.id;
|
||||
const fileName = `${file_id}__${path.basename(inputFilePath)}`;
|
||||
const fileName = `${file_id}__${file.originalname}`;
|
||||
const key = getS3Key(basePath, userId, fileName);
|
||||
|
||||
const stats = await fs.promises.stat(inputFilePath);
|
||||
|
|
|
|||
|
|
@ -60,13 +60,14 @@ const deleteVectors = async (req, file) => {
|
|||
* have a `path` property that points to the location of the uploaded file.
|
||||
* @param {string} params.file_id - The file ID.
|
||||
* @param {string} [params.entity_id] - The entity ID for shared resources.
|
||||
* @param {Object} [params.storageMetadata] - Storage metadata for dual storage pattern.
|
||||
*
|
||||
* @returns {Promise<{ filepath: string, bytes: number }>}
|
||||
* A promise that resolves to an object containing:
|
||||
* - filepath: The path where the file is saved.
|
||||
* - bytes: The size of the file in bytes.
|
||||
*/
|
||||
async function uploadVectors({ req, file, file_id, entity_id }) {
|
||||
async function uploadVectors({ req, file, file_id, entity_id, storageMetadata }) {
|
||||
if (!process.env.RAG_API_URL) {
|
||||
throw new Error('RAG_API_URL not defined');
|
||||
}
|
||||
|
|
@ -80,6 +81,11 @@ async function uploadVectors({ req, file, file_id, entity_id }) {
|
|||
formData.append('entity_id', entity_id);
|
||||
}
|
||||
|
||||
// Include storage metadata for RAG API to store with embeddings
|
||||
if (storageMetadata) {
|
||||
formData.append('storage_metadata', JSON.stringify(storageMetadata));
|
||||
}
|
||||
|
||||
const formHeaders = formData.getHeaders();
|
||||
|
||||
const response = await axios.post(`${process.env.RAG_API_URL}/embed`, formData, {
|
||||
|
|
|
|||
|
|
@ -11,13 +11,12 @@ const {
|
|||
EModelEndpoint,
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
hostImageIdSuffix,
|
||||
AgentCapabilities,
|
||||
checkOpenAIStorage,
|
||||
removeNullishValues,
|
||||
hostImageNamePrefix,
|
||||
isAssistantsEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const { sanitizeFilename } = require('@librechat/api');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const {
|
||||
convertImage,
|
||||
|
|
@ -35,6 +34,29 @@ const { getStrategyFunctions } = require('./strategies');
|
|||
const { determineFileType } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Creates a modular file upload wrapper that ensures filename sanitization
|
||||
* across all storage strategies. This prevents storage-specific implementations
|
||||
* from having to handle sanitization individually.
|
||||
*
|
||||
* @param {Function} uploadFunction - The storage strategy's upload function
|
||||
* @returns {Function} - Wrapped upload function with sanitization
|
||||
*/
|
||||
const createSanitizedUploadWrapper = (uploadFunction) => {
|
||||
return async (params) => {
|
||||
const { req, file, file_id, ...restParams } = params;
|
||||
|
||||
// Create a modified file object with sanitized original name
|
||||
// This ensures consistent filename handling across all storage strategies
|
||||
const sanitizedFile = {
|
||||
...file,
|
||||
originalname: sanitizeFilename(file.originalname),
|
||||
};
|
||||
|
||||
return uploadFunction({ req, file: sanitizedFile, file_id, ...restParams });
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<MongoFile>} files
|
||||
|
|
@ -391,9 +413,10 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
|||
const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint);
|
||||
const assistantSource =
|
||||
metadata.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
|
||||
const source = isAssistantUpload ? assistantSource : FileSources.vectordb;
|
||||
// Use the configured file strategy for regular file uploads (not vectordb)
|
||||
const source = isAssistantUpload ? assistantSource : req.app.locals.fileStrategy;
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const { file_id, temp_file_id } = metadata;
|
||||
const { file_id, temp_file_id = null } = metadata;
|
||||
|
||||
/** @type {OpenAI | undefined} */
|
||||
let openai;
|
||||
|
|
@ -402,6 +425,7 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
|||
}
|
||||
|
||||
const { file } = req;
|
||||
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
|
||||
const {
|
||||
id,
|
||||
bytes,
|
||||
|
|
@ -410,7 +434,7 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
|||
embedded,
|
||||
height,
|
||||
width,
|
||||
} = await handleFileUpload({
|
||||
} = await sanitizedUploadFn({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
|
|
@ -449,7 +473,7 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
|||
temp_file_id,
|
||||
bytes,
|
||||
filepath,
|
||||
filename: filename ?? file.originalname,
|
||||
filename: filename ?? sanitizeFilename(file.originalname),
|
||||
context: isAssistantUpload ? FileContext.assistants : FileContext.message_attachment,
|
||||
model: isAssistantUpload ? req.body.model : undefined,
|
||||
type: file.mimetype,
|
||||
|
|
@ -476,7 +500,7 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
|||
*/
|
||||
const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
const { file } = req;
|
||||
const { agent_id, tool_resource } = metadata;
|
||||
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata;
|
||||
if (agent_id && !tool_resource) {
|
||||
throw new Error('No tool resource provided for agent file upload');
|
||||
}
|
||||
|
|
@ -520,6 +544,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
if (!isFileSearchEnabled) {
|
||||
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) {
|
||||
const isOCREnabled = await checkCapability(req, AgentCapabilities.ocr);
|
||||
if (!isOCREnabled) {
|
||||
|
|
@ -529,7 +554,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
|
||||
req.app.locals?.ocr?.strategy ?? FileSources.mistral_ocr,
|
||||
);
|
||||
const { file_id, temp_file_id } = metadata;
|
||||
const { file_id, temp_file_id = null } = metadata;
|
||||
|
||||
const {
|
||||
text,
|
||||
|
|
@ -568,28 +593,53 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
.json({ message: 'Agent file uploaded and processed successfully', ...result });
|
||||
}
|
||||
|
||||
const source =
|
||||
// Dual storage pattern for RAG files: Storage + Vector DB
|
||||
let storageResult, embeddingResult;
|
||||
const source = req.app.locals.fileStrategy;
|
||||
|
||||
if (tool_resource === EToolResources.file_search) {
|
||||
// FIRST: Upload to Storage for permanent backup (S3/local/etc.)
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
|
||||
storageResult = await sanitizedUploadFn({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
entity_id,
|
||||
basePath,
|
||||
});
|
||||
|
||||
// SECOND: Upload to Vector DB
|
||||
const { uploadVectors } = require('./VectorDB/crud');
|
||||
|
||||
embeddingResult = await uploadVectors({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
entity_id,
|
||||
});
|
||||
|
||||
// Vector status will be stored at root level, no need for metadata
|
||||
fileInfoMetadata = {};
|
||||
} else {
|
||||
// Standard single storage for non-RAG files
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
|
||||
storageResult = await sanitizedUploadFn({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
entity_id,
|
||||
basePath,
|
||||
});
|
||||
}
|
||||
|
||||
const { bytes, filename, filepath: _filepath, height, width } = storageResult;
|
||||
// For RAG files, use embedding result; for others, use storage result
|
||||
const embedded =
|
||||
tool_resource === EToolResources.file_search
|
||||
? FileSources.vectordb
|
||||
: req.app.locals.fileStrategy;
|
||||
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const { file_id, temp_file_id } = metadata;
|
||||
|
||||
const {
|
||||
bytes,
|
||||
filename,
|
||||
filepath: _filepath,
|
||||
embedded,
|
||||
height,
|
||||
width,
|
||||
} = await handleFileUpload({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
entity_id,
|
||||
basePath,
|
||||
});
|
||||
? embeddingResult?.embedded
|
||||
: storageResult.embedded;
|
||||
|
||||
let filepath = _filepath;
|
||||
|
||||
|
|
@ -618,7 +668,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
temp_file_id,
|
||||
bytes,
|
||||
filepath,
|
||||
filename: filename ?? file.originalname,
|
||||
filename: filename ?? sanitizeFilename(file.originalname),
|
||||
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
|
||||
model: messageAttachment ? undefined : req.body.model,
|
||||
metadata: fileInfoMetadata,
|
||||
|
|
@ -630,6 +680,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
});
|
||||
|
||||
const result = await createFile(fileInfo, true);
|
||||
|
||||
res.status(200).json({ message: 'Agent file uploaded and processed successfully', ...result });
|
||||
};
|
||||
|
||||
|
|
@ -700,31 +751,24 @@ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileEx
|
|||
const currentDate = new Date();
|
||||
const formattedDate = currentDate.toISOString();
|
||||
const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`);
|
||||
// Determine the correct source for the assistant
|
||||
const source =
|
||||
req.body.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
|
||||
|
||||
// Create only one file record with the correct information
|
||||
const file = {
|
||||
..._file,
|
||||
usage: 1,
|
||||
user: req.user.id,
|
||||
type: `image/${req.app.locals.imageOutputType}`,
|
||||
type: mime.getType(fileExt),
|
||||
createdAt: formattedDate,
|
||||
updatedAt: formattedDate,
|
||||
source: req.app.locals.fileStrategy,
|
||||
source,
|
||||
context: FileContext.assistants_output,
|
||||
file_id: `${file_id}${hostImageIdSuffix}`,
|
||||
filename: `${hostImageNamePrefix}${filename}`,
|
||||
file_id,
|
||||
filename,
|
||||
};
|
||||
createFile(file, true);
|
||||
const source =
|
||||
req.body.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
|
||||
createFile(
|
||||
{
|
||||
...file,
|
||||
file_id,
|
||||
filename,
|
||||
source,
|
||||
type: mime.getType(fileExt),
|
||||
},
|
||||
true,
|
||||
);
|
||||
return file;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,26 @@ jest.mock('librechat-data-provider', () => ({
|
|||
mergeFileConfig: jest.fn(),
|
||||
removeNullishValues: jest.fn((obj) => obj),
|
||||
isAssistantsEndpoint: jest.fn(),
|
||||
Constants: { COMMANDS_MAX_LENGTH: 56 },
|
||||
PermissionTypes: {
|
||||
BOOKMARKS: 'BOOKMARKS',
|
||||
PROMPTS: 'PROMPTS',
|
||||
MEMORIES: 'MEMORIES',
|
||||
MULTI_CONVO: 'MULTI_CONVO',
|
||||
AGENTS: 'AGENTS',
|
||||
TEMPORARY_CHAT: 'TEMPORARY_CHAT',
|
||||
RUN_CODE: 'RUN_CODE',
|
||||
WEB_SEARCH: 'WEB_SEARCH',
|
||||
FILE_CITATIONS: 'FILE_CITATIONS',
|
||||
},
|
||||
Permissions: {
|
||||
USE: 'USE',
|
||||
OPT_OUT: 'OPT_OUT',
|
||||
},
|
||||
SystemRoles: {
|
||||
USER: 'USER',
|
||||
ADMIN: 'ADMIN',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images', () => ({
|
||||
|
|
|
|||
|
|
@ -546,6 +546,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
|
|||
if (includesWebSearch) {
|
||||
webSearchCallbacks = createOnSearchResults(res);
|
||||
}
|
||||
|
||||
const { loadedTools, toolContextMap } = await loadTools({
|
||||
agent,
|
||||
functions: true,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||
runCode: interfaceConfig?.runCode ?? defaults.runCode,
|
||||
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
||||
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
|
||||
fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations,
|
||||
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
||||
});
|
||||
|
||||
|
|
@ -67,6 +68,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
|
||||
});
|
||||
await updateAccessPermissions(SystemRoles.ADMIN, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
|
|
@ -81,6 +83,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ describe('loadDefaultInterface', () => {
|
|||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
|
@ -35,6 +36,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ describe('loadDefaultInterface', () => {
|
|||
runCode: false,
|
||||
webSearch: false,
|
||||
fileSearch: false,
|
||||
fileCitations: false,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
|
@ -66,6 +69,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -88,6 +92,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -122,6 +127,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -137,6 +143,7 @@ describe('loadDefaultInterface', () => {
|
|||
runCode: false,
|
||||
webSearch: true,
|
||||
fileSearch: false,
|
||||
fileCitations: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
|
@ -153,6 +160,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -169,6 +177,7 @@ describe('loadDefaultInterface', () => {
|
|||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -184,6 +193,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -206,6 +216,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -228,6 +239,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -250,6 +262,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -280,6 +293,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -311,6 +325,7 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -324,6 +339,7 @@ describe('loadDefaultInterface', () => {
|
|||
agents: false,
|
||||
temporaryChat: true,
|
||||
runCode: false,
|
||||
fileCitations: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
|
@ -417,6 +433,45 @@ describe('loadDefaultInterface', () => {
|
|||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when fileCitations is true', async () => {
|
||||
const config = { interface: { fileCitations: true } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when fileCitations is false', async () => {
|
||||
const config = { interface: { fileCitations: false } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,4 +44,24 @@ const getBufferMetadata = async (buffer) => {
|
|||
};
|
||||
};
|
||||
|
||||
module.exports = { determineFileType, getBufferMetadata };
|
||||
/**
|
||||
* Removes UUID prefix from filename for clean display
|
||||
* Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx__filename.ext
|
||||
* @param {string} fileName - The filename to clean
|
||||
* @returns {string} - The cleaned filename without UUID prefix
|
||||
*/
|
||||
const cleanFileName = (fileName) => {
|
||||
if (!fileName) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
// Remove UUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx__
|
||||
const cleaned = fileName.replace(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}__/i,
|
||||
'',
|
||||
);
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
module.exports = { determineFileType, getBufferMetadata, cleanFileName };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue