mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
🔧 feat: Unified file experience — schema, deferred upload, lazy provisioning
Introduces the foundation for a unified file upload experience where users upload files once without choosing a tool_resource upfront. Files are stored in the configured storage strategy and lazily provisioned to tool environments (execute_code, file_search) at chat-request time based on agent capabilities. Phase 1 - Schema + Server-Side Unified Upload: - Add FileInteractionMode enum (text/provider/deferred/legacy) to fileConfigSchema - Add defaultFileInteraction field to EndpointFileConfig and FileConfig types - Update mergeFileConfig/mergeWithDefault to propagate the new field - Modify processAgentFileUpload to support uploads without tool_resource using effectiveToolResource resolved from config (default: deferred) Phase 2 - Lazy Provisioning + Multi-Resource Support: - Create provision.js with provisionToCodeEnv and provisionToVectorDB - Extend primeResources with lazy provisioning step that provisions deferred files to enabled tool environments at chat-request start - Remove early returns in categorizeFileForToolResources so files can exist in multiple tool_resources simultaneously - Wire provisioning callbacks through initializeAgent dependency injection
This commit is contained in:
parent
04e65bb21a
commit
bdf4ab6043
7 changed files with 324 additions and 20 deletions
|
|
@ -466,6 +466,22 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
|||
* @param {FileMetadata} params.metadata - Additional metadata for the file.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
/**
|
||||
* Resolves the file interaction mode from the merged file config.
|
||||
* Checks endpoint-level config first, then global config.
|
||||
* Returns 'deferred' as the default when nothing is configured.
|
||||
*
|
||||
* @param {object} req - The Express request object
|
||||
* @param {object} appConfig - The application config
|
||||
* @returns {string} - The resolved interaction mode: 'text' | 'provider' | 'deferred' | 'legacy'
|
||||
*/
|
||||
const resolveInteractionMode = (req, appConfig) => {
|
||||
const fileConfig = mergeFileConfig(appConfig?.fileConfig);
|
||||
const endpoint = req.body?.endpoint;
|
||||
const endpointConfig = getEndpointFileConfig({ fileConfig, endpoint });
|
||||
return endpointConfig?.defaultFileInteraction ?? fileConfig?.defaultFileInteraction ?? 'deferred';
|
||||
};
|
||||
|
||||
const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
const { file } = req;
|
||||
const appConfig = req.config;
|
||||
|
|
@ -473,11 +489,19 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
|
||||
let messageAttachment = !!metadata.message_file;
|
||||
|
||||
let effectiveToolResource = tool_resource;
|
||||
if (agent_id && !tool_resource && !messageAttachment) {
|
||||
throw new Error('No tool resource provided for agent file upload');
|
||||
const interactionMode = resolveInteractionMode(req, appConfig);
|
||||
if (interactionMode === 'legacy') {
|
||||
throw new Error('No tool resource provided for agent file upload');
|
||||
}
|
||||
// In unified mode: 'text' routes to context processing, 'deferred'/'provider' fall through to standard storage
|
||||
if (interactionMode === 'text') {
|
||||
effectiveToolResource = EToolResources.context;
|
||||
}
|
||||
}
|
||||
|
||||
if (tool_resource === EToolResources.file_search && file.mimetype.startsWith('image')) {
|
||||
if (effectiveToolResource === EToolResources.file_search && file.mimetype.startsWith('image')) {
|
||||
throw new Error('Image uploads are not supported for file search tool resources');
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +513,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
let fileInfoMetadata;
|
||||
const entity_id = messageAttachment === true ? undefined : agent_id;
|
||||
const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads';
|
||||
if (tool_resource === EToolResources.execute_code) {
|
||||
if (effectiveToolResource === EToolResources.execute_code) {
|
||||
const isCodeEnabled = await checkCapability(req, AgentCapabilities.execute_code);
|
||||
if (!isCodeEnabled) {
|
||||
throw new Error('Code execution is not enabled for Agents');
|
||||
|
|
@ -505,13 +529,13 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
entity_id,
|
||||
});
|
||||
fileInfoMetadata = { fileIdentifier };
|
||||
} else if (tool_resource === EToolResources.file_search) {
|
||||
} else if (effectiveToolResource === EToolResources.file_search) {
|
||||
const isFileSearchEnabled = await checkCapability(req, AgentCapabilities.file_search);
|
||||
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.context) {
|
||||
} else if (effectiveToolResource === EToolResources.context) {
|
||||
const { file_id, temp_file_id = null } = metadata;
|
||||
|
||||
/**
|
||||
|
|
@ -543,11 +567,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
|
||||
});
|
||||
|
||||
if (!messageAttachment && tool_resource) {
|
||||
if (!messageAttachment && effectiveToolResource) {
|
||||
await db.addAgentResourceFile({
|
||||
file_id,
|
||||
agent_id,
|
||||
tool_resource,
|
||||
tool_resource: effectiveToolResource,
|
||||
updatingUserId: req?.user?.id,
|
||||
});
|
||||
}
|
||||
|
|
@ -636,7 +660,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
const isImageFile = file.mimetype.startsWith('image');
|
||||
const source = getFileStrategy(appConfig, { isImage: isImageFile });
|
||||
|
||||
if (tool_resource === EToolResources.file_search) {
|
||||
if (effectiveToolResource === EToolResources.file_search) {
|
||||
// FIRST: Upload to Storage for permanent backup (S3/local/etc.)
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
|
||||
|
|
@ -676,18 +700,18 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
let { bytes, filename, filepath: _filepath, height, width } = storageResult;
|
||||
// For RAG files, use embedding result; for others, use storage result
|
||||
let embedded = storageResult.embedded;
|
||||
if (tool_resource === EToolResources.file_search) {
|
||||
if (effectiveToolResource === EToolResources.file_search) {
|
||||
embedded = embeddingResult?.embedded;
|
||||
filename = embeddingResult?.filename || filename;
|
||||
}
|
||||
|
||||
let filepath = _filepath;
|
||||
|
||||
if (!messageAttachment && tool_resource) {
|
||||
if (!messageAttachment && effectiveToolResource) {
|
||||
await db.addAgentResourceFile({
|
||||
file_id,
|
||||
agent_id,
|
||||
tool_resource,
|
||||
tool_resource: effectiveToolResource,
|
||||
updatingUserId: req?.user?.id,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
135
api/server/services/Files/provision.js
Normal file
135
api/server/services/Files/provision.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
const fs = require('fs');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { getStrategyFunctions } = require('./strategies');
|
||||
const { updateFile } = require('~/models');
|
||||
|
||||
/**
|
||||
* Provisions a file to the code execution environment.
|
||||
* Gets a read stream from our storage and uploads to the code env.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.req - Express request object (needs req.user.id)
|
||||
* @param {import('librechat-data-provider').TFile} params.file - The file record from DB
|
||||
* @param {string} [params.entity_id] - Optional entity ID (agent_id)
|
||||
* @returns {Promise<string>} The fileIdentifier from the code env
|
||||
*/
|
||||
async function provisionToCodeEnv({ req, file, entity_id = '' }) {
|
||||
const { getDownloadStream } = getStrategyFunctions(file.source);
|
||||
if (!getDownloadStream) {
|
||||
throw new Error(
|
||||
`Cannot provision file "${file.filename}" to code env: storage source "${file.source}" does not support download streams`,
|
||||
);
|
||||
}
|
||||
|
||||
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code);
|
||||
const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
|
||||
const stream = await getDownloadStream(req, file.filepath);
|
||||
|
||||
const fileIdentifier = await uploadCodeEnvFile({
|
||||
req,
|
||||
stream,
|
||||
filename: file.filename,
|
||||
apiKey: result[EnvVar.CODE_API_KEY],
|
||||
entity_id,
|
||||
});
|
||||
|
||||
const updatedMetadata = {
|
||||
...file.metadata,
|
||||
fileIdentifier,
|
||||
};
|
||||
|
||||
await updateFile({
|
||||
file_id: file.file_id,
|
||||
metadata: updatedMetadata,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`[provisionToCodeEnv] Provisioned file "${file.filename}" (${file.file_id}) to code env`,
|
||||
);
|
||||
|
||||
return fileIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisions a file to the vector DB for file_search/RAG.
|
||||
* Gets the file from our storage and uploads vectors/embeddings.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.req - Express request object
|
||||
* @param {import('librechat-data-provider').TFile} params.file - The file record from DB
|
||||
* @param {string} [params.entity_id] - Optional entity ID (agent_id)
|
||||
* @returns {Promise<{ embedded: boolean }>} Embedding result
|
||||
*/
|
||||
async function provisionToVectorDB({ req, file, entity_id }) {
|
||||
if (!process.env.RAG_API_URL) {
|
||||
logger.warn('[provisionToVectorDB] RAG_API_URL not defined, skipping vector provisioning');
|
||||
return { embedded: false };
|
||||
}
|
||||
|
||||
const { getDownloadStream } = getStrategyFunctions(file.source);
|
||||
if (!getDownloadStream) {
|
||||
throw new Error(
|
||||
`Cannot provision file "${file.filename}" to vector DB: storage source "${file.source}" does not support download streams`,
|
||||
);
|
||||
}
|
||||
|
||||
// The uploadVectors function expects a file-like object with a `path` property for fs.createReadStream.
|
||||
// Since we're provisioning from storage (not a multer upload), we need to stream to a temp file first.
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const tmpPath = path.join(os.tmpdir(), `provision-${file.file_id}-${file.filename}`);
|
||||
|
||||
try {
|
||||
const stream = await getDownloadStream(req, file.filepath);
|
||||
await new Promise((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(tmpPath);
|
||||
stream.pipe(writeStream);
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
const { uploadVectors } = require('./VectorDB/crud');
|
||||
const tempFile = {
|
||||
path: tmpPath,
|
||||
originalname: file.filename,
|
||||
mimetype: file.type,
|
||||
size: file.bytes,
|
||||
};
|
||||
|
||||
const embeddingResult = await uploadVectors({
|
||||
req,
|
||||
file: tempFile,
|
||||
file_id: file.file_id,
|
||||
entity_id,
|
||||
});
|
||||
|
||||
const embedded = embeddingResult?.embedded ?? false;
|
||||
|
||||
await updateFile({
|
||||
file_id: file.file_id,
|
||||
embedded,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`[provisionToVectorDB] Provisioned file "${file.filename}" (${file.file_id}) to vector DB, embedded=${embedded}`,
|
||||
);
|
||||
|
||||
return { embedded };
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
fs.unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
provisionToCodeEnv,
|
||||
provisionToVectorDB,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue