Compare commits
27 Commits
feat/segme
...
feat/merma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67dd1b1b7 | ||
|
|
4136dda7c7 | ||
|
|
c53bdc1fef | ||
|
|
170cc340d8 | ||
|
|
f1b29ffb45 | ||
|
|
6aa4bb5a4a | ||
|
|
9f44187351 | ||
|
|
d2e1ca4c4a | ||
|
|
8e869f2274 | ||
|
|
2e1874e596 | ||
|
|
929b433662 | ||
|
|
1e4f1f780c | ||
|
|
4733f10e41 | ||
|
|
110984b48f | ||
|
|
19320f2296 | ||
|
|
8523074e87 | ||
|
|
e4531d682d | ||
|
|
4bbdc4c402 | ||
|
|
8ca4cf3d2f | ||
|
|
13a9bcdd48 | ||
|
|
4b32ec42c6 | ||
|
|
4918899c8d | ||
|
|
7e37211458 | ||
|
|
e57fc83d40 | ||
|
|
550610dba9 | ||
|
|
916cd46221 | ||
|
|
12b08183ff |
@@ -349,6 +349,11 @@ REGISTRATION_VIOLATION_SCORE=1
|
||||
CONCURRENT_VIOLATION_SCORE=1
|
||||
MESSAGE_VIOLATION_SCORE=1
|
||||
NON_BROWSER_VIOLATION_SCORE=20
|
||||
TTS_VIOLATION_SCORE=0
|
||||
STT_VIOLATION_SCORE=0
|
||||
FORK_VIOLATION_SCORE=0
|
||||
IMPORT_VIOLATION_SCORE=0
|
||||
FILE_UPLOAD_VIOLATION_SCORE=0
|
||||
|
||||
LOGIN_MAX=7
|
||||
LOGIN_WINDOW=5
|
||||
@@ -575,6 +580,10 @@ ALLOW_SHARED_LINKS_PUBLIC=true
|
||||
# If you have another service in front of your LibreChat doing compression, disable express based compression here
|
||||
# DISABLE_COMPRESSION=true
|
||||
|
||||
# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images
|
||||
# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images.
|
||||
# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true
|
||||
|
||||
#===================================================#
|
||||
# UI #
|
||||
#===================================================#
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -56,6 +56,7 @@ bower_components/
|
||||
.clineignore
|
||||
.cursor
|
||||
.aider*
|
||||
CLAUDE.md
|
||||
|
||||
# Floobits
|
||||
.floo
|
||||
@@ -124,4 +125,4 @@ helm/**/.values.yaml
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# SAML Idp cert
|
||||
*.cert
|
||||
*.cert
|
||||
@@ -11,17 +11,25 @@ const { getFiles } = require('~/models/File');
|
||||
* @param {Object} options
|
||||
* @param {ServerRequest} options.req
|
||||
* @param {Agent['tool_resources']} options.tool_resources
|
||||
* @param {string} [options.agentId] - The agent ID for file access control
|
||||
* @returns {Promise<{
|
||||
* files: Array<{ file_id: string; filename: string }>,
|
||||
* toolContext: string
|
||||
* }>}
|
||||
*/
|
||||
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 agentResourceIds = new Set(file_ids);
|
||||
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.`;
|
||||
|
||||
|
||||
@@ -245,7 +245,13 @@ const loadTools = async ({
|
||||
authFields: [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) {
|
||||
toolContextMap[tool] = toolContext;
|
||||
}
|
||||
@@ -260,7 +266,10 @@ const loadTools = async ({
|
||||
continue;
|
||||
} else if (tool === Tools.file_search) {
|
||||
requestedTools[tool] = async () => {
|
||||
const { files, toolContext } = await primeSearchFiles(options);
|
||||
const { files, toolContext } = await primeSearchFiles({
|
||||
...options,
|
||||
agentId: agent?.id,
|
||||
});
|
||||
if (toolContext) {
|
||||
toolContextMap[tool] = toolContext;
|
||||
}
|
||||
|
||||
2
api/cache/logViolation.js
vendored
2
api/cache/logViolation.js
vendored
@@ -9,7 +9,7 @@ const banViolation = require('./banViolation');
|
||||
* @param {Object} res - Express response object.
|
||||
* @param {string} type - The type of violation.
|
||||
* @param {Object} errorMessage - The error message to log.
|
||||
* @param {number} [score=1] - The severity of the violation. Defaults to 1
|
||||
* @param {number | string} [score=1] - The severity of the violation. Defaults to 1
|
||||
*/
|
||||
const logViolation = async (req, res, type, errorMessage, score = 1) => {
|
||||
const userId = req.user?.id ?? req.user?._id;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createTempChatExpirationDate } = require('@librechat/api');
|
||||
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
|
||||
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
|
||||
const { getMessages, deleteMessages } = require('./Message');
|
||||
const { Conversation } = require('~/db/models');
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -12,17 +14,119 @@ const findFileById = async (file_id, options = {}) => {
|
||||
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.
|
||||
* @param {Object} filter - The filter criteria to apply.
|
||||
* @param {Object} [_sortOptions] - Optional sort parameters.
|
||||
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
|
||||
* 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.
|
||||
*/
|
||||
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
|
||||
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => {
|
||||
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,
|
||||
deleteFileByFilter,
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const { z } = require('zod');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createTempChatExpirationDate } = require('@librechat/api');
|
||||
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
|
||||
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
|
||||
const { Message } = require('~/db/models');
|
||||
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
@@ -135,10 +135,11 @@ const tokenValues = Object.assign(
|
||||
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-3-mini-fast': { prompt: 0.4, completion: 4 },
|
||||
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
||||
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
||||
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
|
||||
'grok-3': { prompt: 3.0, completion: 15.0 },
|
||||
'grok-4': { prompt: 3.0, completion: 15.0 },
|
||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
||||
'mistral-large': { prompt: 2.0, completion: 6.0 },
|
||||
'pixtral-large': { prompt: 2.0, completion: 6.0 },
|
||||
|
||||
@@ -636,6 +636,15 @@ describe('Grok Model Tests - Pricing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct prompt and completion rates for Grok 4 model', () => {
|
||||
expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['grok-4'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'completion' })).toBe(
|
||||
tokenValues['grok-4'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
||||
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['grok-3'].prompt,
|
||||
@@ -662,6 +671,15 @@ describe('Grok Model Tests - Pricing', () => {
|
||||
tokenValues['grok-3-mini-fast'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct prompt and completion rates for Grok 4 model with prefixes', () => {
|
||||
expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['grok-4'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'completion' })).toBe(
|
||||
tokenValues['grok-4'].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.56",
|
||||
"@librechat/agents": "^2.4.59",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@node-saml/passport-saml": "^5.0.0",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
||||
const { CacheKeys, AuthType, Constants } = require('librechat-data-provider');
|
||||
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||
const { getToolkitKey } = require('~/server/services/ToolService');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { availableTools } = require('~/app/clients/tools');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Filters out duplicate plugins from the list of plugins.
|
||||
@@ -140,9 +139,9 @@ function createGetServerTools() {
|
||||
const getAvailableTools = async (req, res) => {
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const cachedTools = await cache.get(CacheKeys.TOOLS);
|
||||
if (cachedTools) {
|
||||
res.status(200).json(cachedTools);
|
||||
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
||||
if (cachedToolsArray) {
|
||||
res.status(200).json(cachedToolsArray);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,7 +172,7 @@ const getAvailableTools = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||
const toolDefinitions = (await getCachedTools({ includeGlobal: true })) || {};
|
||||
|
||||
const toolsOutput = [];
|
||||
for (const plugin of authenticatedPlugins) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
require('events').EventEmitter.defaultMaxListeners = 100;
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||
const {
|
||||
sendEvent,
|
||||
createRun,
|
||||
@@ -31,13 +33,16 @@ const {
|
||||
bedrockInputSchema,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||
const { createGetMCPAuthMap, checkCapability } = require('~/server/services/Config');
|
||||
const {
|
||||
findPluginAuthsByKeys,
|
||||
getFormattedMemories,
|
||||
deleteMemory,
|
||||
setMemory,
|
||||
} = require('~/models');
|
||||
const { getMCPAuthMap, checkCapability, hasCustomUserVars } = require('~/server/services/Config');
|
||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
@@ -54,6 +59,7 @@ const omitTitleOptions = new Set([
|
||||
'thinkingBudget',
|
||||
'includeThoughts',
|
||||
'maxOutputTokens',
|
||||
'additionalModelRequestFields',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -700,8 +706,6 @@ class AgentClient extends BaseClient {
|
||||
version: 'v2',
|
||||
};
|
||||
|
||||
const getUserMCPAuthMap = await createGetMCPAuthMap();
|
||||
|
||||
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
||||
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
||||
payload,
|
||||
@@ -822,10 +826,11 @@ class AgentClient extends BaseClient {
|
||||
}
|
||||
|
||||
try {
|
||||
if (getUserMCPAuthMap) {
|
||||
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
|
||||
if (await hasCustomUserVars()) {
|
||||
config.configurable.userMCPAuthMap = await getMCPAuthMap({
|
||||
tools: agent.tools,
|
||||
userId: this.options.req.user.id,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1043,6 +1048,12 @@ class AgentClient extends BaseClient {
|
||||
options.llmConfig?.azureOpenAIApiInstanceName == null
|
||||
) {
|
||||
provider = Providers.OPENAI;
|
||||
} else if (
|
||||
endpoint === EModelEndpoint.azureOpenAI &&
|
||||
options.llmConfig?.azureOpenAIApiInstanceName != null &&
|
||||
provider !== Providers.AZURE
|
||||
) {
|
||||
provider = Providers.AZURE;
|
||||
}
|
||||
|
||||
/** @type {import('@librechat/agents').ClientOptions} */
|
||||
|
||||
@@ -391,6 +391,22 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Agent ID is required' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id: agent_id });
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||
|
||||
if (!hasEditPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'You do not have permission to modify this non-collaborative agent',
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(req.file.path);
|
||||
|
||||
const fileStrategy = req.app.locals.fileStrategy;
|
||||
@@ -413,14 +429,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||
source: fileStrategy,
|
||||
};
|
||||
|
||||
let _avatar;
|
||||
try {
|
||||
const agent = await getAgent({ id: agent_id });
|
||||
_avatar = agent.avatar;
|
||||
} catch (error) {
|
||||
logger.error('[/:agent_id/avatar] Error fetching agent', error);
|
||||
_avatar = {};
|
||||
}
|
||||
let _avatar = existingAgent.avatar;
|
||||
|
||||
if (_avatar && _avatar.source) {
|
||||
const { deleteFile } = getStrategyFunctions(_avatar.source);
|
||||
@@ -442,7 +451,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
promises.push(
|
||||
await updateAgent({ id: agent_id, author: req.user.id }, data, {
|
||||
await updateAgent({ id: agent_id }, data, {
|
||||
updatingUserId: req.user.id,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { handleError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
EndpointURLs,
|
||||
@@ -14,7 +15,6 @@ const openAI = require('~/server/services/Endpoints/openAI');
|
||||
const agents = require('~/server/services/Endpoints/agents');
|
||||
const custom = require('~/server/services/Endpoints/custom');
|
||||
const google = require('~/server/services/Endpoints/google');
|
||||
const { handleError } = require('~/server/utils');
|
||||
|
||||
const buildFunction = {
|
||||
[EModelEndpoint.openAI]: openAI.buildOptions,
|
||||
|
||||
@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
|
||||
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
|
||||
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
|
||||
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
|
||||
const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE;
|
||||
|
||||
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
|
||||
const forkIpMax = FORK_IP_MAX;
|
||||
@@ -27,12 +28,18 @@ const getEnvironmentVariables = () => {
|
||||
forkUserWindowMs,
|
||||
forkUserMax,
|
||||
forkUserWindowInMinutes,
|
||||
forkViolationScore: FORK_VIOLATION_SCORE,
|
||||
};
|
||||
};
|
||||
|
||||
const createForkHandler = (ip = true) => {
|
||||
const { forkIpMax, forkIpWindowInMinutes, forkUserMax, forkUserWindowInMinutes } =
|
||||
getEnvironmentVariables();
|
||||
const {
|
||||
forkIpMax,
|
||||
forkUserMax,
|
||||
forkViolationScore,
|
||||
forkIpWindowInMinutes,
|
||||
forkUserWindowInMinutes,
|
||||
} = getEnvironmentVariables();
|
||||
|
||||
return async (req, res) => {
|
||||
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
||||
@@ -43,7 +50,7 @@ const createForkHandler = (ip = true) => {
|
||||
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
await logViolation(req, res, type, errorMessage, forkViolationScore);
|
||||
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
|
||||
const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
|
||||
const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
|
||||
const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
|
||||
const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE;
|
||||
|
||||
const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
|
||||
const importIpMax = IMPORT_IP_MAX;
|
||||
@@ -27,12 +28,18 @@ const getEnvironmentVariables = () => {
|
||||
importUserWindowMs,
|
||||
importUserMax,
|
||||
importUserWindowInMinutes,
|
||||
importViolationScore: IMPORT_VIOLATION_SCORE,
|
||||
};
|
||||
};
|
||||
|
||||
const createImportHandler = (ip = true) => {
|
||||
const { importIpMax, importIpWindowInMinutes, importUserMax, importUserWindowInMinutes } =
|
||||
getEnvironmentVariables();
|
||||
const {
|
||||
importIpMax,
|
||||
importUserMax,
|
||||
importViolationScore,
|
||||
importIpWindowInMinutes,
|
||||
importUserWindowInMinutes,
|
||||
} = getEnvironmentVariables();
|
||||
|
||||
return async (req, res) => {
|
||||
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
||||
@@ -43,7 +50,7 @@ const createImportHandler = (ip = true) => {
|
||||
windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
await logViolation(req, res, type, errorMessage, importViolationScore);
|
||||
res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
MESSAGE_IP_WINDOW = 1,
|
||||
MESSAGE_USER_MAX = 40,
|
||||
MESSAGE_USER_WINDOW = 1,
|
||||
MESSAGE_VIOLATION_SCORE: score,
|
||||
} = process.env;
|
||||
|
||||
const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
|
||||
@@ -39,7 +40,7 @@ const createHandler = (ip = true) => {
|
||||
windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
await logViolation(req, res, type, errorMessage, score);
|
||||
return await denyRequest(req, res, errorMessage);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
|
||||
const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
|
||||
const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
|
||||
const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
|
||||
const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE;
|
||||
|
||||
const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
|
||||
const sttIpMax = STT_IP_MAX;
|
||||
@@ -27,11 +28,12 @@ const getEnvironmentVariables = () => {
|
||||
sttUserWindowMs,
|
||||
sttUserMax,
|
||||
sttUserWindowInMinutes,
|
||||
sttViolationScore: STT_VIOLATION_SCORE,
|
||||
};
|
||||
};
|
||||
|
||||
const createSTTHandler = (ip = true) => {
|
||||
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes } =
|
||||
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } =
|
||||
getEnvironmentVariables();
|
||||
|
||||
return async (req, res) => {
|
||||
@@ -43,7 +45,7 @@ const createSTTHandler = (ip = true) => {
|
||||
windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
await logViolation(req, res, type, errorMessage, sttViolationScore);
|
||||
res.status(429).json({ message: 'Too many STT requests. Try again later' });
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ const logViolation = require('~/cache/logViolation');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
|
||||
|
||||
const handler = async (req, res) => {
|
||||
const type = ViolationTypes.TOOL_CALL_LIMIT;
|
||||
const errorMessage = {
|
||||
@@ -15,7 +17,7 @@ const handler = async (req, res) => {
|
||||
windowInMinutes: 1,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage, 0);
|
||||
await logViolation(req, res, type, errorMessage, score);
|
||||
res.status(429).json({ message: 'Too many tool call requests. Try again later' });
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
|
||||
const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
|
||||
const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
|
||||
const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
|
||||
const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE;
|
||||
|
||||
const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
|
||||
const ttsIpMax = TTS_IP_MAX;
|
||||
@@ -27,11 +28,12 @@ const getEnvironmentVariables = () => {
|
||||
ttsUserWindowMs,
|
||||
ttsUserMax,
|
||||
ttsUserWindowInMinutes,
|
||||
ttsViolationScore: TTS_VIOLATION_SCORE,
|
||||
};
|
||||
};
|
||||
|
||||
const createTTSHandler = (ip = true) => {
|
||||
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes } =
|
||||
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } =
|
||||
getEnvironmentVariables();
|
||||
|
||||
return async (req, res) => {
|
||||
@@ -43,7 +45,7 @@ const createTTSHandler = (ip = true) => {
|
||||
windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
await logViolation(req, res, type, errorMessage, ttsViolationScore);
|
||||
res.status(429).json({ message: 'Too many TTS requests. Try again later' });
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
|
||||
const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
|
||||
const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
|
||||
const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
|
||||
const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE;
|
||||
|
||||
const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
|
||||
const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
|
||||
@@ -27,6 +28,7 @@ const getEnvironmentVariables = () => {
|
||||
fileUploadUserWindowMs,
|
||||
fileUploadUserMax,
|
||||
fileUploadUserWindowInMinutes,
|
||||
fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +38,7 @@ const createFileUploadHandler = (ip = true) => {
|
||||
fileUploadIpWindowInMinutes,
|
||||
fileUploadUserMax,
|
||||
fileUploadUserWindowInMinutes,
|
||||
fileUploadViolationScore,
|
||||
} = getEnvironmentVariables();
|
||||
|
||||
return async (req, res) => {
|
||||
@@ -47,7 +50,7 @@ const createFileUploadHandler = (ip = true) => {
|
||||
windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
await logViolation(req, res, type, errorMessage, fileUploadViolationScore);
|
||||
res.status(429).json({ message: 'Too many file upload requests. Try again later' });
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const uap = require('ua-parser-js');
|
||||
const { handleError } = require('../utils');
|
||||
const { handleError } = require('@librechat/api');
|
||||
const { logViolation } = require('../../cache');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { handleError } = require('../utils');
|
||||
const { handleError } = require('@librechat/api');
|
||||
|
||||
function validateEndpoint(req, res, next) {
|
||||
const { endpoint: _endpoint, endpointType } = req.body;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { handleError } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { handleError } = require('~/server/utils');
|
||||
const { logViolation } = require('~/cache');
|
||||
/**
|
||||
* Validates the model of the request.
|
||||
|
||||
162
api/server/routes/__tests__/static.spec.js
Normal file
162
api/server/routes/__tests__/static.spec.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const zlib = require('zlib');
|
||||
|
||||
// Create test setup
|
||||
const mockTestDir = path.join(__dirname, 'test-static-route');
|
||||
|
||||
// Mock the paths module to point to our test directory
|
||||
jest.mock('~/config/paths', () => ({
|
||||
imageOutput: mockTestDir,
|
||||
}));
|
||||
|
||||
describe('Static Route Integration', () => {
|
||||
let app;
|
||||
let staticRoute;
|
||||
let testDir;
|
||||
let testImagePath;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create a test directory and files
|
||||
testDir = mockTestDir;
|
||||
testImagePath = path.join(testDir, 'test-image.jpg');
|
||||
|
||||
if (!fs.existsSync(testDir)) {
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create a test image file
|
||||
fs.writeFileSync(testImagePath, 'fake-image-data');
|
||||
|
||||
// Create a gzipped version of the test image (for gzip scanning tests)
|
||||
fs.writeFileSync(testImagePath + '.gz', zlib.gzipSync('fake-image-data'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to set up static route with specific config
|
||||
const setupStaticRoute = (skipGzipScan = false) => {
|
||||
if (skipGzipScan) {
|
||||
delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
|
||||
} else {
|
||||
process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN = 'true';
|
||||
}
|
||||
|
||||
staticRoute = require('../static');
|
||||
app.use('/images', staticRoute);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the module cache to get fresh imports
|
||||
jest.resetModules();
|
||||
|
||||
app = express();
|
||||
|
||||
// Clear environment variables
|
||||
delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
|
||||
describe('route functionality', () => {
|
||||
it('should serve static image files', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
setupStaticRoute();
|
||||
|
||||
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||
|
||||
expect(response.body.toString()).toBe('fake-image-data');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent files', async () => {
|
||||
setupStaticRoute();
|
||||
|
||||
const response = await request(app).get('/images/nonexistent.jpg');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache behavior', () => {
|
||||
it('should set cache headers for images in production', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
setupStaticRoute();
|
||||
|
||||
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
});
|
||||
|
||||
it('should not set cache headers in development', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
setupStaticRoute();
|
||||
|
||||
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||
|
||||
// Our middleware should not set the production cache-control header in development
|
||||
expect(response.headers['cache-control']).not.toBe('public, max-age=172800, s-maxage=86400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gzip compression behavior', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should serve gzipped files when gzip scanning is enabled', async () => {
|
||||
setupStaticRoute(false); // Enable gzip scanning
|
||||
|
||||
const response = await request(app)
|
||||
.get('/images/test-image.jpg')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBe('gzip');
|
||||
expect(response.body.toString()).toBe('fake-image-data');
|
||||
});
|
||||
|
||||
it('should not serve gzipped files when gzip scanning is disabled', async () => {
|
||||
setupStaticRoute(true); // Disable gzip scanning
|
||||
|
||||
const response = await request(app)
|
||||
.get('/images/test-image.jpg')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBeUndefined();
|
||||
expect(response.body.toString()).toBe('fake-image-data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path configuration', () => {
|
||||
it('should use the configured imageOutput path', async () => {
|
||||
setupStaticRoute();
|
||||
|
||||
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||
|
||||
expect(response.body.toString()).toBe('fake-image-data');
|
||||
});
|
||||
|
||||
it('should serve from subdirectories', async () => {
|
||||
// Create a subdirectory with a file
|
||||
const subDir = path.join(testDir, 'thumbs');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const thumbPath = path.join(subDir, 'thumb.jpg');
|
||||
fs.writeFileSync(thumbPath, 'thumbnail-data');
|
||||
|
||||
setupStaticRoute();
|
||||
|
||||
const response = await request(app).get('/images/thumbs/thumb.jpg').expect(200);
|
||||
|
||||
expect(response.body.toString()).toBe('thumbnail-data');
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(subDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
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,
|
||||
isUUID,
|
||||
CacheKeys,
|
||||
Constants,
|
||||
FileSources,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
@@ -16,11 +17,12 @@ const {
|
||||
processDeleteRequest,
|
||||
processAgentFileUpload,
|
||||
} = require('~/server/services/Files/process');
|
||||
const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
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 { getAgent } = require('~/models/Agent');
|
||||
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) => {
|
||||
try {
|
||||
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 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) {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -131,10 +246,10 @@ router.delete('/', async (req, res) => {
|
||||
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
||||
}
|
||||
|
||||
await processDeleteRequest({ req, files: dbFiles });
|
||||
await processDeleteRequest({ req, files: authorizedFiles });
|
||||
|
||||
logger.debug(
|
||||
`[/files] Files deleted successfully: ${files
|
||||
`[/files] Files deleted successfully: ${authorizedFiles
|
||||
.filter((f) => f.file_id)
|
||||
.map((f) => f.file_id)
|
||||
.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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -172,40 +172,68 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
|
||||
/**
|
||||
* PATCH /memories/:key
|
||||
* Updates the value of an existing memory entry for the authenticated user.
|
||||
* Body: { value: string }
|
||||
* Body: { key?: string, value: string }
|
||||
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
|
||||
*/
|
||||
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body || {};
|
||||
const { key: urlKey } = req.params;
|
||||
const { key: bodyKey, value } = req.body || {};
|
||||
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
// Use the key from the body if provided, otherwise use the key from the URL
|
||||
const newKey = bodyKey || urlKey;
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
const existingMemory = memories.find((m) => m.key === key);
|
||||
const existingMemory = memories.find((m) => m.key === urlKey);
|
||||
|
||||
if (!existingMemory) {
|
||||
return res.status(404).json({ error: 'Memory not found.' });
|
||||
}
|
||||
|
||||
const result = await setMemory({
|
||||
userId: req.user.id,
|
||||
key,
|
||||
value,
|
||||
tokenCount,
|
||||
});
|
||||
// If the key is changing, we need to handle it specially
|
||||
if (newKey !== urlKey) {
|
||||
const keyExists = memories.find((m) => m.key === newKey);
|
||||
if (keyExists) {
|
||||
return res.status(409).json({ error: 'Memory with this key already exists.' });
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ error: 'Failed to update memory.' });
|
||||
const createResult = await createMemory({
|
||||
userId: req.user.id,
|
||||
key: newKey,
|
||||
value,
|
||||
tokenCount,
|
||||
});
|
||||
|
||||
if (!createResult.ok) {
|
||||
return res.status(500).json({ error: 'Failed to create new memory.' });
|
||||
}
|
||||
|
||||
const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey });
|
||||
if (!deleteResult.ok) {
|
||||
return res.status(500).json({ error: 'Failed to delete old memory.' });
|
||||
}
|
||||
} else {
|
||||
// Key is not changing, just update the value
|
||||
const result = await setMemory({
|
||||
userId: req.user.id,
|
||||
key: newKey,
|
||||
value,
|
||||
tokenCount,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ error: 'Failed to update memory.' });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMemories = await getAllUserMemories(req.user.id);
|
||||
const updatedMemory = updatedMemories.find((m) => m.key === key);
|
||||
const updatedMemory = updatedMemories.find((m) => m.key === newKey);
|
||||
|
||||
res.json({ updated: true, memory: updatedMemory });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
const express = require('express');
|
||||
const staticCache = require('../utils/staticCache');
|
||||
const paths = require('~/config/paths');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN);
|
||||
|
||||
const router = express.Router();
|
||||
router.use(staticCache(paths.imageOutput));
|
||||
router.use(staticCache(paths.imageOutput, { skipGzipScan }));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { isEnabled, getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
|
||||
const { normalizeEndpointName } = require('~/server/utils');
|
||||
const loadCustomConfig = require('./loadCustomConfig');
|
||||
const { getCachedTools } = require('./getCachedTools');
|
||||
const { findPluginAuthsByKeys } = require('~/models');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
/**
|
||||
@@ -55,46 +54,48 @@ const getCustomEndpointConfig = async (endpoint) => {
|
||||
);
|
||||
};
|
||||
|
||||
async function createGetMCPAuthMap() {
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {string} params.userId
|
||||
* @param {GenericTool[]} [params.tools]
|
||||
* @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys
|
||||
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
|
||||
*/
|
||||
async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) {
|
||||
try {
|
||||
if (!tools || tools.length === 0) {
|
||||
return;
|
||||
}
|
||||
const appTools = await getCachedTools({
|
||||
userId,
|
||||
});
|
||||
return await getUserMCPAuthMap({
|
||||
tools,
|
||||
userId,
|
||||
appTools,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function hasCustomUserVars() {
|
||||
const customConfig = await getCustomConfig();
|
||||
const mcpServers = customConfig?.mcpServers;
|
||||
const hasCustomUserVars = Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
|
||||
if (!hasCustomUserVars) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {GenericTool[]} [params.tools]
|
||||
* @param {string} params.userId
|
||||
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
|
||||
*/
|
||||
return async function ({ tools, userId }) {
|
||||
try {
|
||||
if (!tools || tools.length === 0) {
|
||||
return;
|
||||
}
|
||||
const appTools = await getCachedTools({
|
||||
userId,
|
||||
});
|
||||
return await getUserMCPAuthMap({
|
||||
tools,
|
||||
userId,
|
||||
appTools,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
};
|
||||
return Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMCPAuthMap,
|
||||
getCustomConfig,
|
||||
getBalanceConfig,
|
||||
createGetMCPAuthMap,
|
||||
hasCustomUserVars,
|
||||
getCustomEndpointConfig,
|
||||
};
|
||||
|
||||
@@ -22,8 +22,7 @@ async function loadAsyncEndpoints(req) {
|
||||
} else {
|
||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||
const serviceKeyPath =
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
||||
path.join(__dirname, '../../..', 'data', 'auth.json');
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../..', 'data', 'auth.json');
|
||||
|
||||
try {
|
||||
serviceKey = await loadServiceKey(serviceKeyPath);
|
||||
|
||||
@@ -11,8 +11,8 @@ const {
|
||||
replaceSpecialVars,
|
||||
providerEndpointMap,
|
||||
} = require('librechat-data-provider');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { processFiles } = require('~/server/services/Files/process');
|
||||
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
||||
const { getConvoFiles } = require('~/models/Conversation');
|
||||
@@ -82,6 +82,7 @@ const initializeAgent = async ({
|
||||
attachments: currentFiles,
|
||||
tool_resources: agent.tool_resources,
|
||||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||
agentId: agent.id,
|
||||
});
|
||||
|
||||
const provider = agent.provider;
|
||||
|
||||
@@ -25,7 +25,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||
try {
|
||||
const serviceKeyPath =
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE ||
|
||||
path.join(__dirname, '../../../..', 'data', 'auth.json');
|
||||
serviceKey = await loadServiceKey(serviceKeyPath);
|
||||
if (!serviceKey) {
|
||||
|
||||
@@ -152,6 +152,7 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
||||
* @param {Object} options
|
||||
* @param {ServerRequest} options.req
|
||||
* @param {Agent['tool_resources']} options.tool_resources
|
||||
* @param {string} [options.agentId] - The agent ID for file access control
|
||||
* @param {string} apiKey
|
||||
* @returns {Promise<{
|
||||
* files: Array<{ id: string; session_id: string; name: string }>,
|
||||
@@ -159,11 +160,18 @@ async function getSessionInfo(fileIdentifier, 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 agentResourceIds = new Set(file_ids);
|
||||
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 sessions = new Map();
|
||||
|
||||
@@ -2,16 +2,16 @@ const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
||||
const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api');
|
||||
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
||||
const { Constants, ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
Constants,
|
||||
ContentTypes,
|
||||
isAssistantsEndpoint,
|
||||
convertJsonSchemaToZod,
|
||||
} = require('librechat-data-provider');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
sendEvent,
|
||||
MCPOAuthHandler,
|
||||
normalizeServerName,
|
||||
convertWithResolvedRefs,
|
||||
} = require('@librechat/api');
|
||||
const { findToken, createToken, updateToken } = require('~/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getCachedTools } = require('./Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
@@ -113,7 +113,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||
/** @type {LCTool} */
|
||||
const { description, parameters } = toolDefinition;
|
||||
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
|
||||
let schema = convertJsonSchemaToZod(parameters, {
|
||||
let schema = convertWithResolvedRefs(parameters, {
|
||||
allowEmptyObject: !isGoogle,
|
||||
transformOneOfAnyOf: true,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,9 @@ async function initializeMCP(app) {
|
||||
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
|
||||
await setCachedTools(toolsCopy, { isGlobal: true });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug('Cleared tools array cache after MCP initialization');
|
||||
logger.info('MCP servers initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize MCP servers:', error);
|
||||
|
||||
407
api/server/utils/__tests__/staticCache.spec.js
Normal file
407
api/server/utils/__tests__/staticCache.spec.js
Normal file
@@ -0,0 +1,407 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const zlib = require('zlib');
|
||||
const staticCache = require('../staticCache');
|
||||
|
||||
describe('staticCache', () => {
|
||||
let app;
|
||||
let testDir;
|
||||
let testFile;
|
||||
let indexFile;
|
||||
let manifestFile;
|
||||
let swFile;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create a test directory and files
|
||||
testDir = path.join(__dirname, 'test-static');
|
||||
if (!fs.existsSync(testDir)) {
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create test files
|
||||
testFile = path.join(testDir, 'test.js');
|
||||
indexFile = path.join(testDir, 'index.html');
|
||||
manifestFile = path.join(testDir, 'manifest.json');
|
||||
swFile = path.join(testDir, 'sw.js');
|
||||
|
||||
const jsContent = 'console.log("test");';
|
||||
const htmlContent = '<html><body>Test</body></html>';
|
||||
const jsonContent = '{"name": "test"}';
|
||||
const swContent = 'self.addEventListener("install", () => {});';
|
||||
|
||||
fs.writeFileSync(testFile, jsContent);
|
||||
fs.writeFileSync(indexFile, htmlContent);
|
||||
fs.writeFileSync(manifestFile, jsonContent);
|
||||
fs.writeFileSync(swFile, swContent);
|
||||
|
||||
// Create gzipped versions of some files
|
||||
fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent));
|
||||
fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }');
|
||||
fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }'));
|
||||
|
||||
// Create a file that only exists in gzipped form
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, 'only-gzipped.js.gz'),
|
||||
zlib.gzipSync('console.log("only gzipped");'),
|
||||
);
|
||||
|
||||
// Create a subdirectory for dist/images testing
|
||||
const distImagesDir = path.join(testDir, 'dist', 'images');
|
||||
fs.mkdirSync(distImagesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distImagesDir, 'logo.png'), 'fake-png-data');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
|
||||
// Clear environment variables
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.STATIC_CACHE_S_MAX_AGE;
|
||||
delete process.env.STATIC_CACHE_MAX_AGE;
|
||||
});
|
||||
describe('cache headers in production', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should set standard cache headers for regular files', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
});
|
||||
|
||||
it('should set no-cache headers for index.html', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/index.html').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||
});
|
||||
|
||||
it('should set no-cache headers for manifest.json', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/manifest.json').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||
});
|
||||
|
||||
it('should set no-cache headers for sw.js', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/sw.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||
});
|
||||
|
||||
it('should not set cache headers for /dist/images/ files', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/dist/images/logo.png').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=0');
|
||||
});
|
||||
|
||||
it('should set no-cache headers when noCache option is true', async () => {
|
||||
app.use(staticCache(testDir, { noCache: true }));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache headers in non-production', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
});
|
||||
|
||||
it('should not set cache headers in development', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
// Our middleware should not set cache-control in non-production
|
||||
// Express static might set its own default headers
|
||||
const cacheControl = response.headers['cache-control'];
|
||||
expect(cacheControl).toBe('public, max-age=0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable configuration', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should use custom s-maxage from environment', async () => {
|
||||
process.env.STATIC_CACHE_S_MAX_AGE = '3600';
|
||||
|
||||
// Need to re-require to pick up new env vars
|
||||
jest.resetModules();
|
||||
const freshStaticCache = require('../staticCache');
|
||||
|
||||
app.use(freshStaticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=3600');
|
||||
});
|
||||
|
||||
it('should use custom max-age from environment', async () => {
|
||||
process.env.STATIC_CACHE_MAX_AGE = '7200';
|
||||
|
||||
// Need to re-require to pick up new env vars
|
||||
jest.resetModules();
|
||||
const freshStaticCache = require('../staticCache');
|
||||
|
||||
app.use(freshStaticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=7200, s-maxage=86400');
|
||||
});
|
||||
|
||||
it('should use both custom values from environment', async () => {
|
||||
process.env.STATIC_CACHE_S_MAX_AGE = '1800';
|
||||
process.env.STATIC_CACHE_MAX_AGE = '3600';
|
||||
|
||||
// Need to re-require to pick up new env vars
|
||||
jest.resetModules();
|
||||
const freshStaticCache = require('../staticCache');
|
||||
|
||||
app.use(freshStaticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=3600, s-maxage=1800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('express-static-gzip behavior', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should serve gzipped files when client accepts gzip encoding', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/test.js')
|
||||
.set('Accept-Encoding', 'gzip, deflate')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBe('gzip');
|
||||
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
// Content should be decompressed by supertest
|
||||
expect(response.text).toBe('console.log("test");');
|
||||
});
|
||||
|
||||
it('should fall back to uncompressed files when client does not accept gzip', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/test.js')
|
||||
.set('Accept-Encoding', 'identity')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBeUndefined();
|
||||
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||
expect(response.text).toBe('console.log("test");');
|
||||
});
|
||||
|
||||
it('should serve gzipped CSS files with correct content-type', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/test.css')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBe('gzip');
|
||||
expect(response.headers['content-type']).toMatch(/css/);
|
||||
expect(response.text).toBe('body { color: red; }');
|
||||
});
|
||||
|
||||
it('should serve uncompressed files when no gzipped version exists', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/manifest.json')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBeUndefined();
|
||||
expect(response.headers['content-type']).toMatch(/json/);
|
||||
expect(response.text).toBe('{"name": "test"}');
|
||||
});
|
||||
|
||||
it('should handle files that only exist in gzipped form', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/only-gzipped.js')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBe('gzip');
|
||||
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||
expect(response.text).toBe('console.log("only gzipped");');
|
||||
});
|
||||
|
||||
it('should return 404 for gzip-only files when client does not accept gzip', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/only-gzipped.js')
|
||||
.set('Accept-Encoding', 'identity');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should handle cache headers correctly for gzipped content', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/test.js')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-encoding']).toBe('gzip');
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||
});
|
||||
|
||||
it('should preserve original MIME types for gzipped files', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||
|
||||
const jsResponse = await request(app)
|
||||
.get('/test.js')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
const cssResponse = await request(app)
|
||||
.get('/test.css')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
expect(jsResponse.headers['content-type']).toMatch(/javascript/);
|
||||
expect(cssResponse.headers['content-type']).toMatch(/css/);
|
||||
expect(jsResponse.headers['content-encoding']).toBe('gzip');
|
||||
expect(cssResponse.headers['content-encoding']).toBe('gzip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipGzipScan option comparison', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should use express.static (no gzip) when skipGzipScan is true', async () => {
|
||||
app.use(staticCache(testDir, { skipGzipScan: true }));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/test.js')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
// Should NOT serve gzipped version even though client accepts it
|
||||
expect(response.headers['content-encoding']).toBeUndefined();
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
expect(response.text).toBe('console.log("test");');
|
||||
});
|
||||
|
||||
it('should use expressStaticGzip when skipGzipScan is false', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/test.js')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.expect(200);
|
||||
|
||||
// Should serve gzipped version when client accepts it
|
||||
expect(response.headers['content-encoding']).toBe('gzip');
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
expect(response.text).toBe('console.log("test");');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file serving', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should serve files correctly', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.text).toBe('console.log("test");');
|
||||
expect(response.headers['content-type']).toMatch(/javascript|text/);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent files', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/nonexistent.js');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should serve HTML files', async () => {
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/index.html').expect(200);
|
||||
|
||||
expect(response.text).toBe('<html><body>Test</body></html>');
|
||||
expect(response.headers['content-type']).toMatch(/html/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should handle webmanifest files', async () => {
|
||||
// Create a webmanifest file
|
||||
const webmanifestFile = path.join(testDir, 'site.webmanifest');
|
||||
fs.writeFileSync(webmanifestFile, '{"name": "test app"}');
|
||||
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/site.webmanifest').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(webmanifestFile);
|
||||
});
|
||||
|
||||
it('should handle files in subdirectories', async () => {
|
||||
const subDir = path.join(testDir, 'subdir');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const subFile = path.join(subDir, 'nested.js');
|
||||
fs.writeFileSync(subFile, 'console.log("nested");');
|
||||
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/subdir/nested.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||
expect(response.text).toBe('console.log("nested");');
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(subDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const expressStaticGzip = require('express-static-gzip');
|
||||
|
||||
const oneDayInSeconds = 24 * 60 * 60;
|
||||
@@ -7,44 +8,55 @@ const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds;
|
||||
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
|
||||
|
||||
/**
|
||||
* Creates an Express static middleware with gzip compression and configurable caching
|
||||
* Creates an Express static middleware with optional gzip compression and configurable caching
|
||||
*
|
||||
* @param {string} staticPath - The file system path to serve static files from
|
||||
* @param {Object} [options={}] - Configuration options
|
||||
* @param {boolean} [options.noCache=false] - If true, disables caching entirely for all files
|
||||
* @returns {ReturnType<expressStaticGzip>} Express middleware function for serving static files
|
||||
* @param {boolean} [options.skipGzipScan=false] - If true, skips expressStaticGzip middleware
|
||||
* @returns {ReturnType<expressStaticGzip>|ReturnType<express.static>} Express middleware function for serving static files
|
||||
*/
|
||||
function staticCache(staticPath, options = {}) {
|
||||
const { noCache = false } = options;
|
||||
return expressStaticGzip(staticPath, {
|
||||
enableBrotli: false,
|
||||
orderPreference: ['gz'],
|
||||
setHeaders: (res, filePath) => {
|
||||
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
|
||||
return;
|
||||
}
|
||||
if (noCache) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
return;
|
||||
}
|
||||
if (filePath.includes('/dist/images/')) {
|
||||
return;
|
||||
}
|
||||
const fileName = path.basename(filePath);
|
||||
const { noCache = false, skipGzipScan = false } = options;
|
||||
|
||||
if (
|
||||
fileName === 'index.html' ||
|
||||
fileName.endsWith('.webmanifest') ||
|
||||
fileName === 'manifest.json' ||
|
||||
fileName === 'sw.js'
|
||||
) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
|
||||
}
|
||||
},
|
||||
index: false,
|
||||
});
|
||||
const setHeaders = (res, filePath) => {
|
||||
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
|
||||
return;
|
||||
}
|
||||
if (noCache) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
return;
|
||||
}
|
||||
if (filePath && filePath.includes('/dist/images/')) {
|
||||
return;
|
||||
}
|
||||
const fileName = filePath ? path.basename(filePath) : '';
|
||||
|
||||
if (
|
||||
fileName === 'index.html' ||
|
||||
fileName.endsWith('.webmanifest') ||
|
||||
fileName === 'manifest.json' ||
|
||||
fileName === 'sw.js'
|
||||
) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (skipGzipScan) {
|
||||
return express.static(staticPath, {
|
||||
setHeaders,
|
||||
index: false,
|
||||
});
|
||||
} else {
|
||||
return expressStaticGzip(staticPath, {
|
||||
enableBrotli: false,
|
||||
orderPreference: ['gz'],
|
||||
setHeaders,
|
||||
index: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = staticCache;
|
||||
|
||||
@@ -1074,7 +1074,7 @@
|
||||
|
||||
/**
|
||||
* @exports JsonSchemaType
|
||||
* @typedef {import('librechat-data-provider').JsonSchemaType} JsonSchemaType
|
||||
* @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ const xAIModels = {
|
||||
'grok-3-fast': 131072,
|
||||
'grok-3-mini': 131072,
|
||||
'grok-3-mini-fast': 131072,
|
||||
'grok-4': 256000, // 256K context
|
||||
};
|
||||
|
||||
const aggregateModels = { ...openAIModels, ...googleModels, ...bedrockModels, ...xAIModels };
|
||||
|
||||
@@ -386,7 +386,7 @@ describe('matchModelName', () => {
|
||||
});
|
||||
|
||||
it('should return the closest matching key for gpt-4-1106 partial matches', () => {
|
||||
expect(matchModelName('something/gpt-4-1106')).toBe('gpt-4-1106');
|
||||
expect(matchModelName('gpt-4-1106/something')).toBe('gpt-4-1106');
|
||||
expect(matchModelName('gpt-4-1106-preview')).toBe('gpt-4-1106');
|
||||
expect(matchModelName('gpt-4-1106-vision-preview')).toBe('gpt-4-1106');
|
||||
});
|
||||
@@ -589,6 +589,10 @@ describe('Grok Model Tests - Tokens', () => {
|
||||
expect(getModelMaxTokens('grok-3-mini-fast')).toBe(131072);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Grok 4 model', () => {
|
||||
expect(getModelMaxTokens('grok-4-0709')).toBe(256000);
|
||||
});
|
||||
|
||||
test('should handle partial matches for Grok models with prefixes', () => {
|
||||
// Vision models should match before general models
|
||||
expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768);
|
||||
@@ -606,6 +610,8 @@ describe('Grok Model Tests - Tokens', () => {
|
||||
expect(getModelMaxTokens('xai/grok-3-fast')).toBe(131072);
|
||||
expect(getModelMaxTokens('xai/grok-3-mini')).toBe(131072);
|
||||
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
|
||||
// Grok 4 model
|
||||
expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -627,6 +633,8 @@ describe('Grok Model Tests - Tokens', () => {
|
||||
expect(matchModelName('grok-3-fast')).toBe('grok-3-fast');
|
||||
expect(matchModelName('grok-3-mini')).toBe('grok-3-mini');
|
||||
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||
// Grok 4 model
|
||||
expect(matchModelName('grok-4-0709')).toBe('grok-4');
|
||||
});
|
||||
|
||||
test('should match Grok model variations with prefixes', () => {
|
||||
@@ -646,6 +654,8 @@ describe('Grok Model Tests - Tokens', () => {
|
||||
expect(matchModelName('xai/grok-3-fast')).toBe('grok-3-fast');
|
||||
expect(matchModelName('xai/grok-3-mini')).toBe('grok-3-mini');
|
||||
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||
// Grok 4 model
|
||||
expect(matchModelName('xai/grok-4-0709')).toBe('grok-4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { EModelEndpoint, EToolResources } from 'librechat-data-provider';
|
||||
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import { FileSearch, ImageUpIcon, FileType2Icon, TerminalSquareIcon } from 'lucide-react';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { OGDialog } from '~/components/ui';
|
||||
import { useLocalize, useGetAgentsConfig, useAgentCapabilities } from '~/hooks';
|
||||
import { OGDialog, OGDialogTemplate } from '~/components/ui';
|
||||
|
||||
interface DragDropModalProps {
|
||||
onOptionSelect: (option: EToolResources | undefined) => void;
|
||||
@@ -22,12 +20,12 @@ interface FileOption {
|
||||
|
||||
const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragDropModalProps) => {
|
||||
const localize = useLocalize();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const capabilities = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||
[endpointsConfig],
|
||||
);
|
||||
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
/** TODO: Ephemeral Agent Capabilities
|
||||
* Allow defining agent capabilities on a per-endpoint basis
|
||||
* Use definition for agents endpoint for ephemeral agents
|
||||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
const options = useMemo(() => {
|
||||
const _options: FileOption[] = [
|
||||
{
|
||||
@@ -37,26 +35,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||
condition: files.every((file) => file.type?.startsWith('image/')),
|
||||
},
|
||||
];
|
||||
for (const capability of capabilities) {
|
||||
if (capability === EToolResources.file_search) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
value: EToolResources.file_search,
|
||||
icon: <FileSearch className="icon-md" />,
|
||||
});
|
||||
} else if (capability === EToolResources.execute_code) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
value: EToolResources.execute_code,
|
||||
icon: <TerminalSquareIcon className="icon-md" />,
|
||||
});
|
||||
} else if (capability === EToolResources.ocr) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
value: EToolResources.ocr,
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
if (capabilities.fileSearchEnabled) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
value: EToolResources.file_search,
|
||||
icon: <FileSearch className="icon-md" />,
|
||||
});
|
||||
}
|
||||
if (capabilities.codeEnabled) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
value: EToolResources.execute_code,
|
||||
icon: <TerminalSquareIcon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
if (capabilities.ocrEnabled) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
value: EToolResources.ocr,
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
return _options;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
|
||||
import { EToolResources } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useFileDeletion } from '~/hooks/Files';
|
||||
import FileContainer from './FileContainer';
|
||||
import { logger } from '~/utils';
|
||||
@@ -30,6 +32,8 @@ export default function FileRow({
|
||||
isRTL?: boolean;
|
||||
Wrapper?: React.FC<{ children: React.ReactNode }>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const files = Array.from(_files?.values() ?? []).filter((file) =>
|
||||
fileFilter ? fileFilter(file) : true,
|
||||
);
|
||||
@@ -105,6 +109,10 @@ export default function FileRow({
|
||||
)
|
||||
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
||||
const handleDelete = () => {
|
||||
showToast({
|
||||
message: localize('com_ui_deleting_file'),
|
||||
status: 'info',
|
||||
});
|
||||
if (abortUpload && file.progress < 1) {
|
||||
abortUpload();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useMemo, useRef, useEffect } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import supersub from 'remark-supersub';
|
||||
@@ -7,167 +7,16 @@ import { useRecoilValue } from 'recoil';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkDirective from 'remark-directive';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { Pluggable } from 'unified';
|
||||
import {
|
||||
useToastContext,
|
||||
ArtifactProvider,
|
||||
CodeBlockProvider,
|
||||
useCodeBlockContext,
|
||||
} from '~/Providers';
|
||||
import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation';
|
||||
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
||||
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
|
||||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||
import { langSubset, preprocessLaTeX } from '~/utils';
|
||||
import { unicodeCitation } from '~/components/Web';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { code, a, p } from './MarkdownComponents';
|
||||
import store from '~/store';
|
||||
|
||||
type TCodeProps = {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const canRunCode = useHasAccess({
|
||||
permissionType: PermissionTypes.RUN_CODE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMath = lang === 'math';
|
||||
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
|
||||
|
||||
const { getNextIndex, resetCounter } = useCodeBlockContext();
|
||||
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
|
||||
|
||||
useEffect(() => {
|
||||
resetCounter();
|
||||
}, [children, resetCounter]);
|
||||
|
||||
if (isMath) {
|
||||
return <>{children}</>;
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<CodeBlock
|
||||
lang={lang ?? 'text'}
|
||||
codeChildren={children}
|
||||
blockIndex={blockIndex}
|
||||
allowExecution={canRunCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (lang === 'math') {
|
||||
return children;
|
||||
} else if (typeof children === 'string' && children.split('\n').length === 1) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
|
||||
}
|
||||
});
|
||||
|
||||
type TAnchorProps = {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
|
||||
const user = useRecoilValue(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const {
|
||||
file_id = '',
|
||||
filename = '',
|
||||
filepath,
|
||||
} = useMemo(() => {
|
||||
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
|
||||
const match = href.match(pattern);
|
||||
if (match && match[0]) {
|
||||
const path = match[0];
|
||||
const parts = path.split('/');
|
||||
const name = parts.pop();
|
||||
const file_id = parts.pop();
|
||||
return { file_id, filename: name, filepath: path };
|
||||
}
|
||||
return { file_id: '', filename: '', filepath: '' };
|
||||
}, [user?.id, href]);
|
||||
|
||||
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
|
||||
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
|
||||
|
||||
if (!file_id || !filename) {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const stream = await downloadFile();
|
||||
if (stream.data == null || stream.data === '') {
|
||||
console.error('Error downloading file: No data found');
|
||||
showToast({
|
||||
status: 'error',
|
||||
message: localize('com_ui_download_error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = stream.data;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(stream.data);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
props.onClick = handleDownload;
|
||||
props.target = '_blank';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={filepath?.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
type TParagraphProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
type TContentProps = {
|
||||
content: string;
|
||||
isLatestMessage: boolean;
|
||||
@@ -219,31 +68,33 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ArtifactProvider>
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
/** @ts-ignore */
|
||||
remarkPlugins={remarkPlugins}
|
||||
/* @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={
|
||||
{
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
artifact: Artifact,
|
||||
citation: Citation,
|
||||
'highlighted-text': HighlightedText,
|
||||
'composite-citation': CompositeCitation,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
<MarkdownErrorBoundary content={content} codeExecution={true}>
|
||||
<ArtifactProvider>
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
/** @ts-ignore */
|
||||
remarkPlugins={remarkPlugins}
|
||||
/* @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={
|
||||
{
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
artifact: Artifact,
|
||||
citation: Citation,
|
||||
'highlighted-text': HighlightedText,
|
||||
'composite-citation': CompositeCitation,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
{currentContent}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
>
|
||||
{currentContent}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
</MarkdownErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { memo, useMemo, useRef, useEffect, lazy, Suspense } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useToastContext, useCodeBlockContext } from '~/Providers';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { handleDoubleClick } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
// Loading fallback component for lazy-loaded Mermaid diagrams
|
||||
const MermaidLoadingFallback = memo(() => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-border-light bg-surface-primary p-4 text-center text-text-secondary dark:border-border-heavy dark:bg-surface-primary-alt">
|
||||
{localize('com_ui_loading_diagram')}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type TCodeProps = {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const canRunCode = useHasAccess({
|
||||
permissionType: PermissionTypes.RUN_CODE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMath = lang === 'math';
|
||||
const isMermaid = lang === 'mermaid';
|
||||
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
|
||||
|
||||
const { getNextIndex, resetCounter } = useCodeBlockContext();
|
||||
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
|
||||
|
||||
useEffect(() => {
|
||||
resetCounter();
|
||||
}, [children, resetCounter]);
|
||||
|
||||
if (isMath) {
|
||||
return <>{children}</>;
|
||||
} else if (isMermaid && typeof children === 'string') {
|
||||
const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram'));
|
||||
return (
|
||||
<Suspense fallback={<MermaidLoadingFallback />}>
|
||||
<SandpackMermaidDiagram content={children} />
|
||||
</Suspense>
|
||||
);
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<CodeBlock
|
||||
lang={lang ?? 'text'}
|
||||
codeChildren={children}
|
||||
blockIndex={blockIndex}
|
||||
allowExecution={canRunCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMermaid = lang === 'mermaid';
|
||||
|
||||
if (lang === 'math') {
|
||||
return children;
|
||||
} else if (isMermaid && typeof children === 'string') {
|
||||
const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram'));
|
||||
return (
|
||||
<Suspense fallback={<MermaidLoadingFallback />}>
|
||||
<SandpackMermaidDiagram content={children} />
|
||||
</Suspense>
|
||||
);
|
||||
} else if (typeof children === 'string' && children.split('\n').length === 1) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
|
||||
}
|
||||
});
|
||||
|
||||
type TAnchorProps = {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
|
||||
const user = useRecoilValue(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const {
|
||||
file_id = '',
|
||||
filename = '',
|
||||
filepath,
|
||||
} = useMemo(() => {
|
||||
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
|
||||
const match = href.match(pattern);
|
||||
if (match && match[0]) {
|
||||
const path = match[0];
|
||||
const parts = path.split('/');
|
||||
const name = parts.pop();
|
||||
const file_id = parts.pop();
|
||||
return { file_id, filename: name, filepath: path };
|
||||
}
|
||||
return { file_id: '', filename: '', filepath: '' };
|
||||
}, [user?.id, href]);
|
||||
|
||||
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
|
||||
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
|
||||
|
||||
if (!file_id || !filename) {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const stream = await downloadFile();
|
||||
if (stream.data == null || stream.data === '') {
|
||||
console.error('Error downloading file: No data found');
|
||||
showToast({
|
||||
status: 'error',
|
||||
message: localize('com_ui_download_error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = stream.data;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(stream.data);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
props.onClick = handleDownload;
|
||||
props.target = '_blank';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={filepath?.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
type TParagraphProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import supersub from 'remark-supersub';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList } from 'unified';
|
||||
import { code, codeNoExecution, a, p } from './MarkdownComponents';
|
||||
import { CodeBlockProvider } from '~/Providers';
|
||||
import { langSubset } from '~/utils';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface MarkdownErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
content: string;
|
||||
codeExecution?: boolean;
|
||||
}
|
||||
|
||||
class MarkdownErrorBoundary extends React.Component<
|
||||
MarkdownErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: MarkdownErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Markdown rendering error:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) {
|
||||
if (prevProps.content !== this.props.content && this.state.hasError) {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const { content, codeExecution = true } = this.props;
|
||||
|
||||
const rehypePlugins: PluggableList = [
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
/** @ts-ignore */
|
||||
supersub,
|
||||
remarkGfm,
|
||||
]}
|
||||
/** @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={
|
||||
{
|
||||
code: codeExecution ? code : codeNoExecution,
|
||||
a,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default MarkdownErrorBoundary;
|
||||
@@ -6,8 +6,9 @@ import supersub from 'remark-supersub';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList } from 'unified';
|
||||
import { code, codeNoExecution, a, p } from './Markdown';
|
||||
import { code, codeNoExecution, a, p } from './MarkdownComponents';
|
||||
import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
|
||||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||
import { langSubset } from '~/utils';
|
||||
|
||||
const MarkdownLite = memo(
|
||||
@@ -25,32 +26,34 @@ const MarkdownLite = memo(
|
||||
];
|
||||
|
||||
return (
|
||||
<ArtifactProvider>
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
<MarkdownErrorBoundary content={content} codeExecution={codeExecution}>
|
||||
<ArtifactProvider>
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
/** @ts-ignore */
|
||||
supersub,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
/** @ts-ignore */
|
||||
supersub,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
/** @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
// linkTarget="_new"
|
||||
components={
|
||||
{
|
||||
code: codeExecution ? code : codeNoExecution,
|
||||
a,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
rehypePlugins={rehypePlugins}
|
||||
// linkTarget="_new"
|
||||
components={
|
||||
{
|
||||
code: codeExecution ? code : codeNoExecution,
|
||||
a,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
</MarkdownErrorBoundary>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,14 +13,25 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const prevShowInfoRef = useRef<boolean>(showInfo);
|
||||
|
||||
const memoryArtifacts = useMemo(() => {
|
||||
const { hasErrors, memoryArtifacts } = useMemo(() => {
|
||||
let hasErrors = false;
|
||||
const result: MemoryArtifact[] = [];
|
||||
for (const attachment of attachments ?? []) {
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return { hasErrors, memoryArtifacts: result };
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment?.[Tools.memory] != null) {
|
||||
result.push(attachment[Tools.memory]);
|
||||
|
||||
if (!hasErrors && attachment[Tools.memory].type === 'error') {
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
return { hasErrors, memoryArtifacts: result };
|
||||
}, [attachments]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -75,7 +86,12 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach
|
||||
<div className="flex items-center">
|
||||
<div className="inline-block">
|
||||
<button
|
||||
className="outline-hidden my-1 flex items-center gap-1 text-sm font-semibold text-text-secondary-alt transition-colors hover:text-text-primary"
|
||||
className={cn(
|
||||
'outline-hidden my-1 flex items-center gap-1 text-sm font-semibold transition-colors',
|
||||
hasErrors
|
||||
? 'text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-500'
|
||||
: 'text-text-secondary-alt hover:text-text-primary',
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => setShowInfo((prev) => !prev)}
|
||||
aria-expanded={showInfo}
|
||||
@@ -102,7 +118,7 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{localize('com_ui_memory_updated')}
|
||||
{hasErrors ? localize('com_ui_memory_error') : localize('com_ui_memory_updated')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
import type { MemoryArtifact } from 'librechat-data-provider';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const { updatedMemories, deletedMemories, errorMessages } = useMemo(() => {
|
||||
const updated = memoryArtifacts.filter((art) => art.type === 'update');
|
||||
const deleted = memoryArtifacts.filter((art) => art.type === 'delete');
|
||||
const errors = memoryArtifacts.filter((art) => art.type === 'error');
|
||||
|
||||
const messages = errors.map((artifact) => {
|
||||
try {
|
||||
const errorData = JSON.parse(artifact.value as string);
|
||||
|
||||
if (errorData.errorType === 'already_exceeded') {
|
||||
return localize('com_ui_memory_already_exceeded', {
|
||||
tokens: errorData.tokenCount,
|
||||
});
|
||||
} else if (errorData.errorType === 'would_exceed') {
|
||||
return localize('com_ui_memory_would_exceed', {
|
||||
tokens: errorData.tokenCount,
|
||||
});
|
||||
} else {
|
||||
return localize('com_ui_memory_error');
|
||||
}
|
||||
} catch {
|
||||
return localize('com_ui_memory_error');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
updatedMemories: updated,
|
||||
deletedMemories: deleted,
|
||||
errorMessages: messages,
|
||||
};
|
||||
}, [memoryArtifacts, localize]);
|
||||
|
||||
if (memoryArtifacts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Group artifacts by type
|
||||
const updatedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'update');
|
||||
const deletedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'delete');
|
||||
|
||||
if (updatedMemories.length === 0 && deletedMemories.length === 0) {
|
||||
if (updatedMemories.length === 0 && deletedMemories.length === 0 && errorMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -23,8 +53,8 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor
|
||||
{localize('com_ui_memory_updated_items')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{updatedMemories.map((artifact, index) => (
|
||||
<div key={`update-${index}`} className="rounded-lg p-3">
|
||||
{updatedMemories.map((artifact) => (
|
||||
<div key={`update-${artifact.key}`} className="rounded-lg p-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{artifact.key}
|
||||
</div>
|
||||
@@ -43,8 +73,8 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor
|
||||
{localize('com_ui_memory_deleted_items')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{deletedMemories.map((artifact, index) => (
|
||||
<div key={`delete-${index}`} className="rounded-lg p-3 opacity-60">
|
||||
{deletedMemories.map((artifact) => (
|
||||
<div key={`delete-${artifact.key}`} className="rounded-lg p-3 opacity-60">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{artifact.key}
|
||||
</div>
|
||||
@@ -56,6 +86,24 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-red-500">
|
||||
{localize('com_ui_memory_storage_full')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{errorMessages.map((errorMessage) => (
|
||||
<div
|
||||
key={errorMessage}
|
||||
className="rounded-md bg-red-50 p-3 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, {
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
import { cn } from '~/utils';
|
||||
import { ThemeContext, isDark } from '~/hooks/ThemeContext';
|
||||
import { ClipboardIcon, CheckIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface InlineMermaidProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InlineMermaidDiagram = memo(({ content, className }: InlineMermaidProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [svgContent, setSvgContent] = useState<string>('');
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [wasAutoCorrected, setWasAutoCorrected] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const isDarkMode = isDark(theme);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
|
||||
const diagramKey = useMemo(
|
||||
() => `${content.trim()}-${isDarkMode ? 'dark' : 'light'}`,
|
||||
[content, isDarkMode],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diagram content:', err);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomIn(0.2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomOut(0.2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetTransform();
|
||||
transformRef.current.centerView(1, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoized to prevent re-renders when content/theme changes
|
||||
const fixCommonSyntaxIssues = useMemo(() => {
|
||||
return (text: string) => {
|
||||
let fixed = text;
|
||||
|
||||
fixed = fixed.replace(/--\s+>/g, '-->');
|
||||
fixed = fixed.replace(/--\s+\|/g, '--|');
|
||||
fixed = fixed.replace(/\|\s+-->/g, '|-->');
|
||||
fixed = fixed.replace(/\[([^[\]]*)"([^[\]]*)"([^[\]]*)\]/g, '[$1$2$3]');
|
||||
fixed = fixed.replace(/subgraph([A-Za-z])/g, 'subgraph $1');
|
||||
|
||||
return fixed;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTryFix = useCallback(() => {
|
||||
const fixedContent = fixCommonSyntaxIssues(content);
|
||||
if (fixedContent !== content) {
|
||||
// Currently just copies the fixed version to clipboard
|
||||
navigator.clipboard.writeText(fixedContent).then(() => {
|
||||
setError(t('com_mermaid_fix_copied'));
|
||||
});
|
||||
}
|
||||
}, [content, fixCommonSyntaxIssues, t]);
|
||||
|
||||
// Use ref to track timeout to prevent stale closures
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear previous SVG content
|
||||
setSvgContent('');
|
||||
|
||||
const cleanContent = content.trim();
|
||||
|
||||
setError(null);
|
||||
setWasAutoCorrected(false);
|
||||
setIsRendered(false);
|
||||
setIsLoading(false);
|
||||
|
||||
if (!cleanContent) {
|
||||
setError(t('com_mermaid_error_no_content'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rendering to avoid flickering during rapid content changes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isCancelled) {
|
||||
renderDiagram();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
async function renderDiagram() {
|
||||
if (isCancelled) return;
|
||||
|
||||
try {
|
||||
if (
|
||||
!cleanContent.match(
|
||||
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitgraph|mindmap|timeline|quadrant|block-beta|sankey|xychart|gitgraph)/i,
|
||||
)
|
||||
) {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_type'));
|
||||
setWasAutoCorrected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import to reduce bundle size
|
||||
setIsLoading(true);
|
||||
const mermaid = await import('mermaid').then((m) => m.default);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with error suppression to avoid console spam
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkMode ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
logLevel: 'fatal',
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
let result;
|
||||
let contentToRender = cleanContent;
|
||||
|
||||
try {
|
||||
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
result = await mermaid.render(id, contentToRender);
|
||||
} catch (_renderError) {
|
||||
const fixedContent = fixCommonSyntaxIssues(cleanContent);
|
||||
if (fixedContent !== cleanContent) {
|
||||
try {
|
||||
const fixedId = `mermaid-fixed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
result = await mermaid.render(fixedId, fixedContent);
|
||||
contentToRender = fixedContent;
|
||||
setWasAutoCorrected(true);
|
||||
} catch (_fixedRenderError) {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_syntax_auto_correct'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_syntax'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component was unmounted during async render
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.svg) {
|
||||
let processedSvg = result.svg;
|
||||
|
||||
// Enhance SVG for better zoom/pan interaction
|
||||
processedSvg = processedSvg.replace(
|
||||
'<svg',
|
||||
'<svg style="width: 100%; height: auto;" preserveAspectRatio="xMidYMid meet"',
|
||||
);
|
||||
|
||||
// Sanitize SVG content to prevent XSS attacks
|
||||
const sanitizedSvg = DOMPurify.sanitize(processedSvg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
ADD_ATTR: ['preserveAspectRatio'],
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'iframe'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
setSvgContent(sanitizedSvg);
|
||||
setIsRendered(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_no_svg'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Mermaid rendering error:', err);
|
||||
if (!isCancelled) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t('com_mermaid_error_rendering_failed', 'Failed to render diagram');
|
||||
setError(t('com_mermaid_error_rendering_failed', { '0': errorMessage }));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [diagramKey, content, isDarkMode, fixCommonSyntaxIssues, t]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
const fixedContent = fixCommonSyntaxIssues(content);
|
||||
const canTryFix = fixedContent !== content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-4 overflow-auto rounded-lg border border-red-300 bg-red-50',
|
||||
'dark:border-red-700 dark:bg-red-900/20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-4 text-red-600 dark:text-red-400">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<strong>{t('com_mermaid_error')}</strong> {error}
|
||||
{canTryFix && (
|
||||
<div className={cn('mt-2 text-sm text-red-500 dark:text-red-300')}>
|
||||
💡 {t('com_mermaid_error_fixes_detected')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex gap-2">
|
||||
{canTryFix && (
|
||||
<button
|
||||
onClick={handleTryFix}
|
||||
className={cn(
|
||||
'rounded border px-3 py-1 text-xs transition-colors',
|
||||
'border-blue-300 bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
'dark:border-blue-700 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800',
|
||||
)}
|
||||
title={t('com_mermaid_copy_potential_fix')}
|
||||
>
|
||||
{t('com_mermaid_try_fix')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded border px-3 py-1 text-xs transition-colors',
|
||||
'border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
|
||||
)}
|
||||
title={t('com_mermaid_copy_code')}
|
||||
>
|
||||
{isCopied ? `✓ ${t('com_mermaid_copied')}` : t('com_mermaid_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 pt-0">
|
||||
<pre className="overflow-x-auto rounded bg-gray-100 p-2 text-sm dark:bg-gray-800">
|
||||
<code className="language-mermaid">{content}</code>
|
||||
</pre>
|
||||
{canTryFix && (
|
||||
<div className="mt-3 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
|
||||
<div className={cn('mb-2 text-sm font-medium text-blue-800 dark:text-blue-200')}>
|
||||
{t('com_mermaid_suggested_fix')}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded border bg-white p-2 text-sm dark:bg-gray-800">
|
||||
<code className="language-mermaid">{fixedContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={diagramKey}
|
||||
className={cn(
|
||||
'relative my-4 overflow-auto rounded-lg border border-border-light bg-surface-primary',
|
||||
'dark:border-border-heavy dark:bg-surface-primary-alt',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isRendered && wasAutoCorrected && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
'border border-yellow-300 dark:border-yellow-700',
|
||||
'shadow-sm',
|
||||
)}
|
||||
>
|
||||
✨ {t('com_mermaid_auto_fixed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRendered && svgContent && (
|
||||
<div className="absolute right-2 top-2 z-10 flex gap-1">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_copy_code')}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<ClipboardIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
{(isLoading || !isRendered) && (
|
||||
<div className="animate-pulse text-center text-text-secondary">
|
||||
{t('com_mermaid_rendering')}
|
||||
</div>
|
||||
)}
|
||||
{isRendered && svgContent && (
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
limitToBounds={false}
|
||||
centerOnInit={true}
|
||||
wheel={{ step: 0.1 }}
|
||||
panning={{ velocityDisabled: true }}
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
minHeight: '200px',
|
||||
maxHeight: '600px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mermaid-container flex min-h-[200px] items-center justify-center"
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InlineMermaidDiagram.displayName = 'InlineMermaidDiagram';
|
||||
|
||||
export default InlineMermaidDiagram;
|
||||
@@ -85,7 +85,7 @@ const Part = memo(
|
||||
|
||||
const isToolCall =
|
||||
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
|
||||
if (isToolCall && toolCall.name === Tools.execute_code) {
|
||||
if (isToolCall && toolCall.name === Tools.execute_code && toolCall.args) {
|
||||
return (
|
||||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
|
||||
@@ -10,23 +10,23 @@ import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface ParsedArgs {
|
||||
lang: string;
|
||||
code: string;
|
||||
lang?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export function useParseArgs(args: string): ParsedArgs {
|
||||
export function useParseArgs(args?: string): ParsedArgs | null {
|
||||
return useMemo(() => {
|
||||
let parsedArgs: ParsedArgs | string = args;
|
||||
let parsedArgs: ParsedArgs | string | undefined | null = args;
|
||||
try {
|
||||
parsedArgs = JSON.parse(args);
|
||||
parsedArgs = JSON.parse(args || '');
|
||||
} catch {
|
||||
// console.error('Failed to parse args:', e);
|
||||
}
|
||||
if (typeof parsedArgs === 'object') {
|
||||
return parsedArgs;
|
||||
}
|
||||
const langMatch = args.match(/"lang"\s*:\s*"(\w+)"/);
|
||||
const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s);
|
||||
const langMatch = args?.match(/"lang"\s*:\s*"(\w+)"/);
|
||||
const codeMatch = args?.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s);
|
||||
|
||||
let code = '';
|
||||
if (codeMatch) {
|
||||
@@ -51,7 +51,7 @@ export default function ExecuteCode({
|
||||
attachments,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
args: string;
|
||||
args?: string;
|
||||
output?: string;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
@@ -65,7 +65,7 @@ export default function ExecuteCode({
|
||||
const outputRef = useRef<string>(output);
|
||||
const prevShowCodeRef = useRef<boolean>(showCode);
|
||||
|
||||
const { lang, code } = useParseArgs(args);
|
||||
const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs);
|
||||
const progress = useProgress(initialProgress);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,7 +144,7 @@ export default function ExecuteCode({
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
inProgressText={localize('com_ui_analyzing')}
|
||||
finishedText={localize('com_ui_analyzing_finished')}
|
||||
hasInput={!!code.length}
|
||||
hasInput={!!code?.length}
|
||||
isExpanded={showCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { memo, useMemo, useEffect } from 'react';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import dedent from 'dedent';
|
||||
import { cn } from '~/utils';
|
||||
import { sharedOptions } from '~/utils/artifacts';
|
||||
|
||||
interface SandpackMermaidDiagramProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Minimal dependencies for Mermaid only
|
||||
const mermaidDependencies = {
|
||||
mermaid: '^11.8.1',
|
||||
'react-zoom-pan-pinch': '^3.7.0',
|
||||
};
|
||||
|
||||
// Lean mermaid template with inline SVG icons
|
||||
const leanMermaidTemplate = dedent`
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TransformWrapper,
|
||||
TransformComponent,
|
||||
ReactZoomPanPinchRef,
|
||||
} from "react-zoom-pan-pinch";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
// Inline SVG icons
|
||||
const ZoomInIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
<line x1="11" y1="8" x2="11" y2="14"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ZoomOutIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ResetIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<polyline points="23 20 23 14 17 14"/>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default",
|
||||
securityLevel: "loose",
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
curve: "basis",
|
||||
},
|
||||
});
|
||||
|
||||
const renderDiagram = async () => {
|
||||
if (mermaidRef.current) {
|
||||
try {
|
||||
const id = "mermaid-" + Date.now();
|
||||
const { svg } = await mermaid.render(id, content);
|
||||
mermaidRef.current.innerHTML = svg;
|
||||
|
||||
const svgElement = mermaidRef.current.querySelector("svg");
|
||||
if (svgElement) {
|
||||
svgElement.style.width = "100%";
|
||||
svgElement.style.height = "100%";
|
||||
}
|
||||
setIsRendered(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Mermaid rendering error:", err);
|
||||
setError(err.message || "Failed to render diagram");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [content]);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomIn(0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomOut(0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetTransform();
|
||||
transformRef.current.centerView(1, 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '16px', color: '#ef4444', backgroundColor: '#fee2e2', borderRadius: '8px', border: '1px solid #fecaca' }}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '100%', width: '100%', backgroundColor: '#f9fafb' }}>
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
wheel={{ step: 0.1 }}
|
||||
centerOnInit={true}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={mermaidRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '300px',
|
||||
padding: '20px',
|
||||
}}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
|
||||
{isRendered && (
|
||||
<div style={{ position: 'absolute', bottom: '8px', right: '8px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Reset zoom"
|
||||
>
|
||||
<ResetIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidDiagram;
|
||||
`;
|
||||
|
||||
const wrapLeanMermaidDiagram = (content: string) => {
|
||||
return dedent`
|
||||
import React from 'react';
|
||||
import MermaidDiagram from './MermaidDiagram';
|
||||
|
||||
export default function App() {
|
||||
const content = \`${content.replace(/`/g, '\\`')}\`;
|
||||
return <MermaidDiagram content={content} />;
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
const getLeanMermaidFiles = (content: string) => {
|
||||
return {
|
||||
'/App.tsx': wrapLeanMermaidDiagram(content),
|
||||
'/MermaidDiagram.tsx': leanMermaidTemplate,
|
||||
};
|
||||
};
|
||||
|
||||
const SandpackMermaidDiagram = memo(({ content, className }: SandpackMermaidDiagramProps) => {
|
||||
const files = useMemo(() => getLeanMermaidFiles(content), [content]);
|
||||
const sandpackProps = useMemo(
|
||||
() => ({
|
||||
customSetup: {
|
||||
dependencies: mermaidDependencies,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Force iframe to respect container height
|
||||
useEffect(() => {
|
||||
const fixIframeHeight = () => {
|
||||
const container = document.querySelector('.sandpack-mermaid-diagram');
|
||||
if (container) {
|
||||
const iframe = container.querySelector('iframe');
|
||||
if (iframe && iframe.style.height && iframe.style.height !== '100%') {
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.minHeight = '100%';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fix
|
||||
fixIframeHeight();
|
||||
|
||||
// Fix on any DOM changes
|
||||
const observer = new MutationObserver(fixIframeHeight);
|
||||
const container = document.querySelector('.sandpack-mermaid-diagram');
|
||||
if (container) {
|
||||
observer.observe(container, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<SandpackProvider files={files} options={sharedOptions} template="react-ts" {...sandpackProps}>
|
||||
<SandpackPreview
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
showSandpackErrorOverlay={true}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
);
|
||||
});
|
||||
|
||||
SandpackMermaidDiagram.displayName = 'SandpackMermaidDiagram';
|
||||
|
||||
export default SandpackMermaidDiagram;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import MemoryArtifacts from '../MemoryArtifacts';
|
||||
import type { TAttachment, MemoryArtifact } from 'librechat-data-provider';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
|
||||
// Mock the localize hook
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_memory_updated: 'Updated saved memory',
|
||||
com_ui_memory_error: 'Memory Error',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the MemoryInfo component
|
||||
jest.mock('../MemoryInfo', () => ({
|
||||
__esModule: true,
|
||||
default: ({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) => (
|
||||
<div data-testid="memory-info">
|
||||
{memoryArtifacts.map((artifact, index) => (
|
||||
<div key={index} data-testid={`memory-artifact-${artifact.type}`}>
|
||||
{artifact.type}: {artifact.key}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('MemoryArtifacts', () => {
|
||||
const createMemoryAttachment = (type: 'update' | 'delete' | 'error', key: string): TAttachment =>
|
||||
({
|
||||
type: Tools.memory,
|
||||
[Tools.memory]: {
|
||||
type,
|
||||
key,
|
||||
value:
|
||||
type === 'error'
|
||||
? JSON.stringify({ errorType: 'exceeded', tokenCount: 100 })
|
||||
: 'test value',
|
||||
} as MemoryArtifact,
|
||||
}) as TAttachment;
|
||||
|
||||
describe('Error State Handling', () => {
|
||||
test('displays error styling when memory artifacts contain errors', () => {
|
||||
const attachments = [
|
||||
createMemoryAttachment('error', 'system'),
|
||||
createMemoryAttachment('update', 'memory1'),
|
||||
];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('text-red-500');
|
||||
expect(button).toHaveClass('hover:text-red-600');
|
||||
expect(button).toHaveClass('dark:text-red-400');
|
||||
expect(button).toHaveClass('dark:hover:text-red-500');
|
||||
});
|
||||
|
||||
test('displays normal styling when no errors present', () => {
|
||||
const attachments = [
|
||||
createMemoryAttachment('update', 'memory1'),
|
||||
createMemoryAttachment('delete', 'memory2'),
|
||||
];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('text-text-secondary-alt');
|
||||
expect(button).toHaveClass('hover:text-text-primary');
|
||||
expect(button).not.toHaveClass('text-red-500');
|
||||
});
|
||||
|
||||
test('displays error message when errors are present', () => {
|
||||
const attachments = [createMemoryAttachment('error', 'system')];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
expect(screen.getByText('Memory Error')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Updated saved memory')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays normal message when no errors are present', () => {
|
||||
const attachments = [createMemoryAttachment('update', 'memory1')];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
expect(screen.getByText('Updated saved memory')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Artifacts Filtering', () => {
|
||||
test('filters and passes only memory-type attachments to MemoryInfo', () => {
|
||||
const attachments = [
|
||||
createMemoryAttachment('update', 'memory1'),
|
||||
{ type: 'file' } as TAttachment, // Non-memory attachment
|
||||
createMemoryAttachment('error', 'system'),
|
||||
];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
// Check that only memory artifacts are passed to MemoryInfo
|
||||
expect(screen.getByTestId('memory-artifact-update')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-artifact-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('correctly identifies multiple error artifacts', () => {
|
||||
const attachments = [
|
||||
createMemoryAttachment('error', 'system1'),
|
||||
createMemoryAttachment('error', 'system2'),
|
||||
createMemoryAttachment('update', 'memory1'),
|
||||
];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('text-red-500');
|
||||
expect(screen.getByText('Memory Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collapse/Expand Functionality', () => {
|
||||
test('toggles memory info visibility on button click', () => {
|
||||
const attachments = [createMemoryAttachment('update', 'memory1')];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByTestId('memory-info')).not.toBeInTheDocument();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(screen.getByTestId('memory-info')).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(screen.queryByTestId('memory-info')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates aria-expanded attribute correctly', () => {
|
||||
const attachments = [createMemoryAttachment('update', 'memory1')];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles empty attachments array', () => {
|
||||
render(<MemoryArtifacts attachments={[]} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles undefined attachments', () => {
|
||||
render(<MemoryArtifacts />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles attachments with no memory artifacts', () => {
|
||||
const attachments = [{ type: 'file' } as TAttachment, { type: 'image' } as TAttachment];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles malformed memory artifacts gracefully', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.memory,
|
||||
[Tools.memory]: {
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
// Missing value
|
||||
},
|
||||
} as TAttachment,
|
||||
];
|
||||
|
||||
render(<MemoryArtifacts attachments={attachments} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('text-red-500');
|
||||
expect(screen.getByText('Memory Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import MemoryInfo from '../MemoryInfo';
|
||||
import type { MemoryArtifact } from 'librechat-data-provider';
|
||||
|
||||
// Mock the localize hook
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, params?: Record<string, any>) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_memory_updated_items: 'Updated Memories',
|
||||
com_ui_memory_deleted_items: 'Deleted Memories',
|
||||
com_ui_memory_already_exceeded: `Memory storage already full - exceeded by ${params?.tokens || 0} tokens. Delete existing memories before adding new ones.`,
|
||||
com_ui_memory_would_exceed: `Cannot save - would exceed limit by ${params?.tokens || 0} tokens. Delete existing memories to make space.`,
|
||||
com_ui_memory_deleted: 'This memory has been deleted',
|
||||
com_ui_memory_storage_full: 'Memory Storage Full',
|
||||
com_ui_memory_error: 'Memory Error',
|
||||
com_ui_updated_successfully: 'Updated successfully',
|
||||
com_ui_none_selected: 'None selected',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MemoryInfo', () => {
|
||||
const createMemoryArtifact = (
|
||||
type: 'update' | 'delete' | 'error',
|
||||
key: string,
|
||||
value?: string,
|
||||
): MemoryArtifact => ({
|
||||
type,
|
||||
key,
|
||||
value: value || `test value for ${key}`,
|
||||
});
|
||||
|
||||
describe('Error Memory Display', () => {
|
||||
test('displays error section when memory is already exceeded', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: JSON.stringify({ errorType: 'already_exceeded', tokenCount: 150 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Memory storage already full - exceeded by 150 tokens. Delete existing memories before adding new ones.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays error when memory would exceed limit', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 50 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Cannot save - would exceed limit by 50 tokens. Delete existing memories to make space.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays multiple error messages', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system1',
|
||||
value: JSON.stringify({ errorType: 'already_exceeded', tokenCount: 100 }),
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system2',
|
||||
value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 25 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Memory storage already full - exceeded by 100 tokens. Delete existing memories before adding new ones.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Cannot save - would exceed limit by 25 tokens. Delete existing memories to make space.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies correct styling to error messages', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 50 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
const errorMessage = screen.getByText(
|
||||
'Cannot save - would exceed limit by 50 tokens. Delete existing memories to make space.',
|
||||
);
|
||||
const errorContainer = errorMessage.closest('div');
|
||||
|
||||
expect(errorContainer).toHaveClass('rounded-md');
|
||||
expect(errorContainer).toHaveClass('bg-red-50');
|
||||
expect(errorContainer).toHaveClass('p-3');
|
||||
expect(errorContainer).toHaveClass('text-sm');
|
||||
expect(errorContainer).toHaveClass('text-red-800');
|
||||
expect(errorContainer).toHaveClass('dark:bg-red-900/20');
|
||||
expect(errorContainer).toHaveClass('dark:text-red-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed Memory Types', () => {
|
||||
test('displays all sections when different memory types are present', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
createMemoryArtifact('update', 'memory1', 'Updated content'),
|
||||
createMemoryArtifact('delete', 'memory2'),
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 200 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
// Check all sections are present
|
||||
expect(screen.getByText('Updated Memories')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deleted Memories')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
|
||||
// Check content
|
||||
expect(screen.getByText('memory1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated content')).toBeInTheDocument();
|
||||
expect(screen.getByText('memory2')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Cannot save - would exceed limit by 200 tokens. Delete existing memories to make space.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('only displays sections with content', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: JSON.stringify({ errorType: 'already_exceeded', tokenCount: 10 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
// Only error section should be present
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Updated Memories')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Deleted Memories')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles empty memory artifacts array', () => {
|
||||
const { container } = render(<MemoryInfo memoryArtifacts={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test('handles malformed error data gracefully', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: 'invalid json',
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
// Should render generic error message
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles missing value in error artifact', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
// value is undefined
|
||||
} as MemoryArtifact,
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles unknown errorType gracefully', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
{
|
||||
type: 'error',
|
||||
key: 'system',
|
||||
value: JSON.stringify({ errorType: 'unknown_type', tokenCount: 30 }),
|
||||
},
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
// Should show generic error message for unknown types
|
||||
expect(screen.getByText('Memory Storage Full')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('returns null when no memories of any type exist', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [{ type: 'unknown' as any, key: 'test' }];
|
||||
|
||||
const { container } = render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update and Delete Memory Display', () => {
|
||||
test('displays updated memories correctly', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
createMemoryArtifact('update', 'preferences', 'User prefers dark mode'),
|
||||
createMemoryArtifact('update', 'location', 'Lives in San Francisco'),
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
expect(screen.getByText('Updated Memories')).toBeInTheDocument();
|
||||
expect(screen.getByText('preferences')).toBeInTheDocument();
|
||||
expect(screen.getByText('User prefers dark mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('location')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lives in San Francisco')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays deleted memories correctly', () => {
|
||||
const memoryArtifacts: MemoryArtifact[] = [
|
||||
createMemoryArtifact('delete', 'old_preference'),
|
||||
createMemoryArtifact('delete', 'outdated_info'),
|
||||
];
|
||||
|
||||
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
|
||||
|
||||
expect(screen.getByText('Deleted Memories')).toBeInTheDocument();
|
||||
expect(screen.getByText('old_preference')).toBeInTheDocument();
|
||||
expect(screen.getByText('outdated_info')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,8 @@ import rehypeHighlight from 'rehype-highlight';
|
||||
import { replaceSpecialVars } from 'librechat-data-provider';
|
||||
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
|
||||
import { cn, wrapVariable, defaultTextProps, extractVariableInfo } from '~/utils';
|
||||
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
|
||||
import { TextareaAutosize, InputCombobox, Button } from '~/components/ui';
|
||||
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
|
||||
import { PromptVariableGfm } from '../Markdown';
|
||||
|
||||
@@ -7,7 +7,7 @@ import supersub from 'remark-supersub';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { replaceSpecialVars } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
|
||||
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import CategoryIcon from './Groups/CategoryIcon';
|
||||
import PromptVariables from './PromptVariables';
|
||||
|
||||
@@ -9,7 +9,7 @@ import rehypeKatex from 'rehype-katex';
|
||||
import remarkMath from 'remark-math';
|
||||
import supersub from 'remark-supersub';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
|
||||
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
|
||||
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
|
||||
import { SaveIcon, CrossIcon } from '~/components/svg';
|
||||
import VariablesDropdown from './VariablesDropdown';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Prov
|
||||
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
|
||||
import Action from '~/components/SidePanel/Builder/Action';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import { useGetAgentFiles } from '~/data-provider';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { processAgentOption } from '~/utils';
|
||||
import Instructions from './Instructions';
|
||||
@@ -49,6 +50,18 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
const tools = useWatch({ control, name: 'tools' });
|
||||
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 {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
@@ -74,10 +87,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
|
||||
const _agent = processAgentOption({
|
||||
agent,
|
||||
fileMap,
|
||||
fileMap: mergedFileMap,
|
||||
});
|
||||
return _agent.context_files ?? [];
|
||||
}, [agent, agent_id, fileMap]);
|
||||
}, [agent, agent_id, mergedFileMap]);
|
||||
|
||||
const knowledge_files = useMemo(() => {
|
||||
if (typeof agent === 'string') {
|
||||
@@ -94,10 +107,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
|
||||
const _agent = processAgentOption({
|
||||
agent,
|
||||
fileMap,
|
||||
fileMap: mergedFileMap,
|
||||
});
|
||||
return _agent.knowledge_files ?? [];
|
||||
}, [agent, agent_id, fileMap]);
|
||||
}, [agent, agent_id, mergedFileMap]);
|
||||
|
||||
const code_files = useMemo(() => {
|
||||
if (typeof agent === 'string') {
|
||||
@@ -114,10 +127,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
|
||||
const _agent = processAgentOption({
|
||||
agent,
|
||||
fileMap,
|
||||
fileMap: mergedFileMap,
|
||||
});
|
||||
return _agent.code_files ?? [];
|
||||
}, [agent, agent_id, fileMap]);
|
||||
}, [agent, agent_id, mergedFileMap]);
|
||||
|
||||
const handleAddActions = useCallback(() => {
|
||||
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',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
aria-label={localize(
|
||||
'com_ui_share_var',
|
||||
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
|
||||
)}
|
||||
aria-label={localize('com_ui_share_var', {
|
||||
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
@@ -148,10 +147,9 @@ export default function ShareAgent({
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 md:max-w-xl">
|
||||
<OGDialogTitle>
|
||||
{localize(
|
||||
'com_ui_share_var',
|
||||
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
|
||||
)}
|
||||
{localize('com_ui_share_var', {
|
||||
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
</OGDialogTitle>
|
||||
<form
|
||||
className="p-2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import VersionItem from './VersionItem';
|
||||
import { VersionContext } from './VersionPanel';
|
||||
import type { VersionContext } from './types';
|
||||
|
||||
type VersionContentProps = {
|
||||
selectedAgentId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { VersionRecord } from './VersionPanel';
|
||||
import type { VersionRecord } from './types';
|
||||
|
||||
type VersionItemProps = {
|
||||
version: VersionRecord;
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type { AgentWithVersions, VersionContext } from './types';
|
||||
import { isActiveVersion } from './isActiveVersion';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { useLocalize, useToast } from '~/hooks';
|
||||
import VersionContent from './VersionContent';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export type VersionRecord = Record<string, any>;
|
||||
|
||||
export type AgentState = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
artifacts?: string | null;
|
||||
capabilities?: string[];
|
||||
tools?: string[];
|
||||
} | null;
|
||||
|
||||
export type VersionWithId = {
|
||||
id: number;
|
||||
originalIndex: number;
|
||||
version: VersionRecord;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type VersionContext = {
|
||||
versions: VersionRecord[];
|
||||
versionIds: VersionWithId[];
|
||||
currentAgent: AgentState;
|
||||
selectedAgentId: string;
|
||||
activeVersion: VersionRecord | null;
|
||||
};
|
||||
|
||||
export interface AgentWithVersions extends Agent {
|
||||
capabilities?: string[];
|
||||
versions?: Array<VersionRecord>;
|
||||
}
|
||||
|
||||
export default function VersionPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AgentState, VersionRecord } from './VersionPanel';
|
||||
import type { AgentState, VersionRecord } from './types';
|
||||
|
||||
export const isActiveVersion = (
|
||||
version: VersionRecord,
|
||||
|
||||
35
client/src/components/SidePanel/Agents/Version/types.ts
Normal file
35
client/src/components/SidePanel/Agents/Version/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type VersionRecord = Record<string, any>;
|
||||
|
||||
export type AgentState = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
artifacts?: string | null;
|
||||
capabilities?: string[];
|
||||
tools?: string[];
|
||||
} | null;
|
||||
|
||||
export type VersionWithId = {
|
||||
id: number;
|
||||
originalIndex: number;
|
||||
version: VersionRecord;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type VersionContext = {
|
||||
versions: VersionRecord[];
|
||||
versionIds: VersionWithId[];
|
||||
currentAgent: AgentState;
|
||||
selectedAgentId: string;
|
||||
activeVersion: VersionRecord | null;
|
||||
};
|
||||
|
||||
export interface AgentWithVersions {
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
artifacts?: string | null;
|
||||
capabilities?: string[];
|
||||
tools?: string[];
|
||||
versions?: Array<VersionRecord>;
|
||||
}
|
||||
@@ -52,6 +52,10 @@ export default function MemoryCreateDialog({
|
||||
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
|
||||
errorMessage = localize('com_ui_memory_key_exists');
|
||||
}
|
||||
// Check for key validation error (lowercase and underscores only)
|
||||
else if (errorMessage.includes('lowercase letters and underscores')) {
|
||||
errorMessage = localize('com_ui_memory_key_validation');
|
||||
}
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
|
||||
@@ -44,9 +44,29 @@ export default function MemoryEditDialog({
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error: Error) => {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as any;
|
||||
if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
|
||||
// Check for duplicate key error
|
||||
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
|
||||
errorMessage = localize('com_ui_memory_key_exists');
|
||||
}
|
||||
// Check for key validation error (lowercase and underscores only)
|
||||
else if (errorMessage.includes('lowercase letters and underscores')) {
|
||||
errorMessage = localize('com_ui_memory_key_validation');
|
||||
}
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_error'),
|
||||
message: errorMessage,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
export * from './queries';
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export const useUploadFileMutation = (
|
||||
_options?: t.UploadMutationOptions,
|
||||
@@ -145,10 +147,24 @@ export const useDeleteFilesMutation = (
|
||||
unknown // context
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onSuccess, ...options } = _options || {};
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
const { onSuccess, onError, ...options } = _options || {};
|
||||
return useMutation([MutationKeys.fileDelete], {
|
||||
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body),
|
||||
...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) => {
|
||||
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
|
||||
const { files: filesDeleted } = vars;
|
||||
@@ -160,6 +176,12 @@ export const useDeleteFilesMutation = (
|
||||
|
||||
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
|
||||
});
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_delete_success'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
onSuccess?.(data, vars, context);
|
||||
if (vars.agent_id != null && vars.agent_id) {
|
||||
queryClient.refetchQueries([QueryKeys.agent, vars.agent_id]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||
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 t from 'librechat-data-provider';
|
||||
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>(
|
||||
config?: UseQueryOptions<t.FileConfig, unknown, TData>,
|
||||
): QueryObserverResult<TData, unknown> => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
EToolResources,
|
||||
AgentCapabilities,
|
||||
isAssistantsEndpoint,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import type { DropTargetMonitor } from 'react-dnd';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
@@ -38,13 +39,13 @@ export default function useDragHelpers() {
|
||||
setDraggedFiles([]);
|
||||
};
|
||||
|
||||
const isAgents = useMemo(
|
||||
() => !isAssistantsEndpoint(conversation?.endpoint),
|
||||
const isAssistants = useMemo(
|
||||
() => isAssistantsEndpoint(conversation?.endpoint),
|
||||
[conversation?.endpoint],
|
||||
);
|
||||
|
||||
const { handleFiles } = useFileHandling({
|
||||
overrideEndpoint: isAgents ? EModelEndpoint.agents : undefined,
|
||||
overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents,
|
||||
});
|
||||
|
||||
const [{ canDrop, isOver }, drop] = useDrop(
|
||||
@@ -52,18 +53,18 @@ export default function useDragHelpers() {
|
||||
accept: [NativeTypes.FILE],
|
||||
drop(item: { files: File[] }) {
|
||||
console.log('drop', item.files);
|
||||
if (!isAgents) {
|
||||
if (isAssistants) {
|
||||
handleFiles(item.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||
const agentsConfig = endpointsConfig?.[EModelEndpoint.agents];
|
||||
const codeEnabled =
|
||||
agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) === true;
|
||||
const fileSearchEnabled =
|
||||
agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) === true;
|
||||
if (!codeEnabled && !fileSearchEnabled) {
|
||||
const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities;
|
||||
const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true;
|
||||
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
|
||||
const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true;
|
||||
if (!codeEnabled && !fileSearchEnabled && !ocrEnabled) {
|
||||
handleFiles(item.files);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -918,7 +918,6 @@
|
||||
"com_ui_versions": "Versioner",
|
||||
"com_ui_view_source": "Se kilde-chat",
|
||||
"com_ui_web_search": "Websøgning",
|
||||
"com_ui_web_search_api_subtitle": "Søg på nettet efter opdateret information",
|
||||
"com_ui_web_search_cohere_key": "Indtast Cohere API-nøgle",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (valgfri)",
|
||||
"com_ui_web_search_jina_key": "Indtast Jina API-nøgle",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"com_ui_view_memory": "Erinnerung anzeigen",
|
||||
"com_ui_view_source": "Quell-Chat anzeigen",
|
||||
"com_ui_web_search": "Web-Suche\n",
|
||||
"com_ui_web_search_api_subtitle": "Suche im Internet nach aktuellen Informationen",
|
||||
"com_ui_web_search_cohere_key": "Cohere API-Key eingeben",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)\n",
|
||||
"com_ui_web_search_jina_key": "Den Jina API Schlüssel eingeben",
|
||||
|
||||
@@ -300,6 +300,26 @@
|
||||
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
|
||||
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
|
||||
"com_error_no_user_key": "No key found. Please provide a key and try again.",
|
||||
"com_mermaid_auto_fixed": "Auto-fixed",
|
||||
"com_mermaid_copy": "Copy",
|
||||
"com_mermaid_copy_potential_fix": "Copy potential fix to clipboard",
|
||||
"com_mermaid_copy_code": "Copy mermaid code",
|
||||
"com_mermaid_copied": "Copied",
|
||||
"com_mermaid_error": "Mermaid Error:",
|
||||
"com_mermaid_error_fixes_detected": "Potential fixes detected: spacing issues in arrows or labels",
|
||||
"com_mermaid_error_invalid_syntax": "Invalid diagram syntax - check arrow formatting and node labels",
|
||||
"com_mermaid_error_invalid_syntax_auto_correct": "Invalid diagram syntax - syntax errors found but unable to auto-correct",
|
||||
"com_mermaid_error_invalid_type": "Invalid Mermaid syntax - diagram must start with a valid diagram type (flowchart, graph, sequenceDiagram, etc.)",
|
||||
"com_mermaid_error_no_content": "No diagram content provided",
|
||||
"com_mermaid_error_no_svg": "No SVG generated - rendering failed unexpectedly",
|
||||
"com_mermaid_error_rendering_failed": "Rendering failed: {{0}}",
|
||||
"com_mermaid_fix_copied": "Potential fix copied to clipboard. Common issues found and corrected.",
|
||||
"com_mermaid_rendering": "Rendering diagram...",
|
||||
"com_mermaid_suggested_fix": "Suggested Fix:",
|
||||
"com_mermaid_try_fix": "Try Fix",
|
||||
"com_mermaid_zoom_in": "Zoom in",
|
||||
"com_mermaid_zoom_out": "Zoom out",
|
||||
"com_mermaid_reset_zoom": "Reset zoom",
|
||||
"com_files_filter": "Filter files...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
@@ -703,8 +723,11 @@
|
||||
"com_ui_delete_mcp_error": "Failed to delete MCP server",
|
||||
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
||||
"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_success": "Successfully deleted",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_deleting_file": "Deleting file...",
|
||||
"com_ui_delete_tool": "Delete Tool",
|
||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||
"com_ui_deleted": "Deleted",
|
||||
@@ -807,6 +830,7 @@
|
||||
"com_ui_good_morning": "Good morning",
|
||||
"com_ui_happy_birthday": "It's my 1st birthday!",
|
||||
"com_ui_hide_image_details": "Hide Image Details",
|
||||
"com_ui_hide_password": "Hide password",
|
||||
"com_ui_hide_qr": "Hide QR Code",
|
||||
"com_ui_high": "High",
|
||||
"com_ui_host": "Host",
|
||||
@@ -821,7 +845,7 @@
|
||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
||||
"com_ui_import_conversation_success": "Conversations imported successfully",
|
||||
"com_ui_include_shadcnui": "Include shadcn/ui",
|
||||
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
|
||||
"com_ui_input": "Input",
|
||||
"com_ui_instructions": "Instructions",
|
||||
"com_ui_key": "Key",
|
||||
@@ -833,6 +857,7 @@
|
||||
"com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.",
|
||||
"com_ui_librechat_code_api_title": "Run AI Code",
|
||||
"com_ui_loading": "Loading...",
|
||||
"com_ui_loading_diagram": "Loading diagram...",
|
||||
"com_ui_locked": "Locked",
|
||||
"com_ui_logo": "{{0}} Logo",
|
||||
"com_ui_low": "Low",
|
||||
@@ -856,8 +881,13 @@
|
||||
"com_ui_memory_deleted": "Memory deleted",
|
||||
"com_ui_memory_deleted_items": "Deleted Memories",
|
||||
"com_ui_memory_key_exists": "A memory with this key already exists. Please use a different key.",
|
||||
"com_ui_memory_key_validation": "Memory key must only contain lowercase letters and underscores.",
|
||||
"com_ui_memory_updated": "Updated saved memory",
|
||||
"com_ui_memory_updated_items": "Updated Memories",
|
||||
"com_ui_memory_storage_full": "Memory Storage Full",
|
||||
"com_ui_memory_error": "Memory Error",
|
||||
"com_ui_memory_already_exceeded": "Memory storage already full - exceeded by {{tokens}} tokens. Delete existing memories before adding new ones.",
|
||||
"com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.",
|
||||
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
|
||||
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
|
||||
"com_ui_misc": "Misc.",
|
||||
@@ -983,6 +1013,7 @@
|
||||
"com_ui_show": "Show",
|
||||
"com_ui_show_all": "Show All",
|
||||
"com_ui_show_image_details": "Show Image Details",
|
||||
"com_ui_show_password": "Show password",
|
||||
"com_ui_show_qr": "Show QR Code",
|
||||
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
|
||||
"com_ui_simple": "Simple",
|
||||
@@ -1056,10 +1087,8 @@
|
||||
"com_ui_web_search_jina_key": "Enter Jina API Key",
|
||||
"com_ui_web_search_processing": "Processing results",
|
||||
"com_ui_web_search_provider": "Search Provider",
|
||||
"com_ui_web_search_provider_serper": "Serper API",
|
||||
"com_ui_web_search_provider_searxng": "SearXNG",
|
||||
"com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)",
|
||||
"com_ui_web_search_searxng_instance_url": "SearXNG Instance URL",
|
||||
"com_ui_web_search_provider_serper": "Serper API",
|
||||
"com_ui_web_search_provider_serper_key": "Get your Serper API key",
|
||||
"com_ui_web_search_reading": "Reading results",
|
||||
"com_ui_web_search_reranker": "Reranker",
|
||||
@@ -1070,6 +1099,8 @@
|
||||
"com_ui_web_search_scraper": "Scraper",
|
||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "Get your Firecrawl API key",
|
||||
"com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)",
|
||||
"com_ui_web_search_searxng_instance_url": "SearXNG Instance URL",
|
||||
"com_ui_web_searching": "Searching the web",
|
||||
"com_ui_web_searching_again": "Searching the web again",
|
||||
"com_ui_weekend_morning": "Happy weekend",
|
||||
@@ -1077,7 +1108,5 @@
|
||||
"com_ui_x_selected": "{{0}} selected",
|
||||
"com_ui_yes": "Yes",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "You",
|
||||
"com_ui_show_password": "Show password",
|
||||
"com_ui_hide_password": "Hide password"
|
||||
"com_user_message": "You"
|
||||
}
|
||||
|
||||
@@ -940,7 +940,6 @@
|
||||
"com_ui_versions": "Versioonid",
|
||||
"com_ui_view_source": "Vaata algset vestlust",
|
||||
"com_ui_web_search": "Veebiotsing",
|
||||
"com_ui_web_search_api_subtitle": "Otsi veebist ajakohast teavet",
|
||||
"com_ui_web_search_cohere_key": "Sisesta Cohere API võti",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (valikuline)",
|
||||
"com_ui_web_search_jina_key": "Sisesta Jina API võti",
|
||||
|
||||
@@ -1046,7 +1046,6 @@
|
||||
"com_ui_view_memory": "Voir le Souvenir",
|
||||
"com_ui_view_source": "Voir le message d'origine",
|
||||
"com_ui_web_search": "Recherche web",
|
||||
"com_ui_web_search_api_subtitle": "Rechercher des informations actualisées sur le web",
|
||||
"com_ui_web_search_cohere_key": "Entrez la clé API de Cohere",
|
||||
"com_ui_web_search_firecrawl_url": "Adresse URL de Firecrawl (optionnel)",
|
||||
"com_ui_web_search_jina_key": "Entrez la clé API de Jina",
|
||||
|
||||
@@ -1029,7 +1029,6 @@
|
||||
"com_ui_view_memory": "הצג זיכרון",
|
||||
"com_ui_view_source": "הצג צ'אט מקורי",
|
||||
"com_ui_web_search": "חיפוש ברשת",
|
||||
"com_ui_web_search_api_subtitle": "חפש מידע עדכני ברשת",
|
||||
"com_ui_web_search_cohere_key": "הכנס מפתח API של Cohere",
|
||||
"com_ui_web_search_firecrawl_url": "כתובת URL של ממשק ה-API של Firecrawl (אופציונלי)",
|
||||
"com_ui_web_search_jina_key": "הזן את מפתח ה-API של Jina",
|
||||
|
||||
@@ -75,9 +75,12 @@
|
||||
"com_auth_to_try_again": "նորից փորձելու համար։",
|
||||
"com_auth_username": "Օգտանուն (կամընտրական)",
|
||||
"com_auth_welcome_back": "Բարի վերադարձ",
|
||||
"com_citation_more_details": "Լրացուցիչ մանրամասներ {{label}}-ի մասին",
|
||||
"com_citation_source": "Աղբյուր",
|
||||
"com_click_to_download": "(սեղմեք այստեղ ներբեռնելու համար)",
|
||||
"com_download_expired": "(ներբեռնելու ժամկետը սպառվել է)",
|
||||
"com_download_expires": "(սեղմեք այստեղ ներբեռնելու համար – կսպառվի {{0}} հետո)",
|
||||
"com_endpoint": "Endpoint",
|
||||
"com_endpoint_agent": "Գործակալ",
|
||||
"com_endpoint_agent_placeholder": "Խնդրում ենք ընտրել գործակալին",
|
||||
"com_endpoint_ai": "AI",
|
||||
@@ -95,6 +98,9 @@
|
||||
"com_endpoint_config_key_for": "Մուտքագրեք API key-ը՝",
|
||||
"com_endpoint_config_key_google_need_to": "Դուք պետք է",
|
||||
"com_endpoint_config_key_google_service_account": "Ստեղծել ծառայողական օգտահաշիվ",
|
||||
"com_endpoint_config_key_google_vertex_ai": "Միացնել Vertex AI-ը",
|
||||
"com_endpoint_config_key_name": "Key",
|
||||
"com_endpoint_config_key_never_expires": "Ձեր key-ը երբեք չի սպառվի",
|
||||
"com_endpoint_custom_name": "Անհատական անուն",
|
||||
"com_endpoint_deprecated": "Հնացած",
|
||||
"com_endpoint_examples": "Օրինակ",
|
||||
@@ -102,17 +108,21 @@
|
||||
"com_endpoint_export_share": "Արտահանել/Կիսվել",
|
||||
"com_endpoint_message": "Հաղորդագրություն",
|
||||
"com_endpoint_message_new": "Հաղորդագրություն {{0}}",
|
||||
"com_endpoint_my_preset": "Իմ պրեսեթը",
|
||||
"com_endpoint_open_menu": "Բացել մենյուն",
|
||||
"com_endpoint_output": "Ելք",
|
||||
"com_endpoint_preset": "պրեսեթ",
|
||||
"com_endpoint_preset_default_item": "Սկզբնական",
|
||||
"com_endpoint_preset_default_none": "Սկզբնական նախադրված պրեսեթը ակտիվ չէ։",
|
||||
"com_endpoint_preset_import": "Պրեսեթը ներմուծվեց։",
|
||||
"com_endpoint_preset_name": "Պրեսեթի անուն",
|
||||
"com_endpoint_preset_selected": "Պրեսեթը ակտիվ է։",
|
||||
"com_endpoint_preset_selected_title": "Ակտիվ է։",
|
||||
"com_endpoint_preset_title": "Պրեսեթ",
|
||||
"com_endpoint_presets": "պրեսեթներ",
|
||||
"com_endpoint_save_as_preset": "Պահպանել որպես պրեսեթ",
|
||||
"com_endpoint_temperature": "Temperature",
|
||||
"com_error_files_dupe": "Հայտնաբերվել է կրկնվող ֆայլ։",
|
||||
"com_hide_examples": "Թաքցնել օրինակները",
|
||||
"com_nav_2fa": "Երկուփուլային նույնականացում (2FA)",
|
||||
"com_nav_account_settings": "Ակաունթի կարգավորումներ",
|
||||
@@ -121,7 +131,11 @@
|
||||
"com_nav_balance_every": "Ամեն",
|
||||
"com_nav_balance_refill_amount": "Լիցքավորման գումարը՝",
|
||||
"com_nav_browser": "Բրաուզեր",
|
||||
"com_nav_close_sidebar": "Փակել կողային վահանակը",
|
||||
"com_nav_commands": "Հրամաններ",
|
||||
"com_nav_delete_account": "Ջնջել ակաունթը",
|
||||
"com_nav_delete_account_confirm": "Ջնջե՞լ ակաունթը։ Հաստատո՞ւմ եք։",
|
||||
"com_nav_enabled": "Միացված է",
|
||||
"com_nav_export": "Արտահանել",
|
||||
"com_nav_font_size_base": "Միջին",
|
||||
"com_nav_font_size_lg": "Մեծ",
|
||||
|
||||
@@ -971,7 +971,6 @@
|
||||
"com_ui_view_memory": "메모리 보기",
|
||||
"com_ui_view_source": "원본 채팅 보기",
|
||||
"com_ui_web_search": "웹 검색",
|
||||
"com_ui_web_search_api_subtitle": "최신 정보를 검색하기 위해 웹 검색",
|
||||
"com_ui_web_search_cohere_key": "Cohere API 키 입력",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (선택 사항)",
|
||||
"com_ui_web_search_jina_key": "Jina API 키 입력",
|
||||
|
||||
@@ -1049,7 +1049,6 @@
|
||||
"com_ui_view_memory": "Skatīt atmiņu",
|
||||
"com_ui_view_source": "Skatīt avota sarunu",
|
||||
"com_ui_web_search": "Tīmekļa meklēšana",
|
||||
"com_ui_web_search_api_subtitle": "Meklējiet tīmeklī aktuālo informāciju",
|
||||
"com_ui_web_search_cohere_key": "Ievadiet Cohere API atslēgu",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (pēc izvēles)",
|
||||
"com_ui_web_search_jina_key": "Ievadiet Jina API atslēgu",
|
||||
|
||||
@@ -371,4 +371,130 @@ p.whitespace-pre-wrap a, li a {
|
||||
|
||||
.dark p.whitespace-pre-wrap a, .dark li a {
|
||||
color: #52a0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* .sandpack-mermaid-diagram {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram > div {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-stack {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
flex: 1 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
flex: 1 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-container {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
flex: 1 !important;
|
||||
background: transparent !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-iframe {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-actions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-container::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram [style*="height: 346px"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram iframe[style*="height"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram [style*="height:"] {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram iframe {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-stack {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper,
|
||||
.sandpack-mermaid-diagram .sp-stack,
|
||||
.sandpack-mermaid-diagram .sp-preview,
|
||||
.sandpack-mermaid-diagram .sp-preview-container {
|
||||
max-height: none !important;
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .p-4 > div {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram iframe[style*="height"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram [style*="height:"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper,
|
||||
.sandpack-mermaid-diagram .sp-stack,
|
||||
.sandpack-mermaid-diagram .sp-preview,
|
||||
.sandpack-mermaid-diagram .sp-preview-container {
|
||||
max-height: none !important;
|
||||
} */
|
||||
@@ -18,7 +18,7 @@ export function mapAttachments(attachments: Array<t.TAttachment | null | undefin
|
||||
attachmentMap[key] = [];
|
||||
}
|
||||
|
||||
attachmentMap[key].push(attachment);
|
||||
attachmentMap[key]?.push(attachment);
|
||||
}
|
||||
|
||||
return attachmentMap;
|
||||
|
||||
@@ -17,6 +17,9 @@ import '@testing-library/jest-dom/extend-expect';
|
||||
// 'react-lottie' uses canvas
|
||||
import 'jest-canvas-mock';
|
||||
|
||||
// Mock ResizeObserver
|
||||
import './resizeObserver.mock';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -40,4 +43,4 @@ jest.mock('react-i18next', () => {
|
||||
init: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,8 +113,8 @@ export default defineConfig(({ command }) => ({
|
||||
if (id.includes('i18next') || id.includes('react-i18next')) {
|
||||
return 'i18n';
|
||||
}
|
||||
if (id.includes('lodash')) {
|
||||
return 'utilities';
|
||||
if (id.includes('node_modules/lodash-es')) {
|
||||
return 'lodash-es';
|
||||
}
|
||||
if (id.includes('date-fns')) {
|
||||
return 'date-utils';
|
||||
@@ -231,6 +231,7 @@ export default defineConfig(({ command }) => ({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.join(__dirname, 'src/'),
|
||||
lodash: 'lodash-es',
|
||||
$fonts: path.resolve(__dirname, 'public/fonts'),
|
||||
'micromark-extension-math': 'micromark-extension-llm-math',
|
||||
},
|
||||
|
||||
@@ -26,6 +26,15 @@ const config: PlaywrightTestConfig = {
|
||||
CONCURRENT_VIOLATION_SCORE: '0',
|
||||
MESSAGE_VIOLATION_SCORE: '0',
|
||||
NON_BROWSER_VIOLATION_SCORE: '0',
|
||||
FORK_VIOLATION_SCORE: '0',
|
||||
IMPORT_VIOLATION_SCORE: '0',
|
||||
TTS_VIOLATION_SCORE: '0',
|
||||
STT_VIOLATION_SCORE: '0',
|
||||
FILE_UPLOAD_VIOLATION_SCORE: '0',
|
||||
RESET_PASSWORD_VIOLATION_SCORE: '0',
|
||||
VERIFY_EMAIL_VIOLATION_SCORE: '0',
|
||||
TOOL_CALL_VIOLATION_SCORE: '0',
|
||||
CONVO_ACCESS_VIOLATION_SCORE: '0',
|
||||
ILLEGAL_MODEL_REQ_SCORE: '0',
|
||||
LOGIN_MAX: '20',
|
||||
LOGIN_WINDOW: '1',
|
||||
|
||||
@@ -26,6 +26,15 @@ const config: PlaywrightTestConfig = {
|
||||
CONCURRENT_VIOLATION_SCORE: '0',
|
||||
MESSAGE_VIOLATION_SCORE: '0',
|
||||
NON_BROWSER_VIOLATION_SCORE: '0',
|
||||
FORK_VIOLATION_SCORE: '0',
|
||||
IMPORT_VIOLATION_SCORE: '0',
|
||||
TTS_VIOLATION_SCORE: '0',
|
||||
STT_VIOLATION_SCORE: '0',
|
||||
FILE_UPLOAD_VIOLATION_SCORE: '0',
|
||||
RESET_PASSWORD_VIOLATION_SCORE: '0',
|
||||
VERIFY_EMAIL_VIOLATION_SCORE: '0',
|
||||
TOOL_CALL_VIOLATION_SCORE: '0',
|
||||
CONVO_ACCESS_VIOLATION_SCORE: '0',
|
||||
ILLEGAL_MODEL_REQ_SCORE: '0',
|
||||
LOGIN_MAX: '20',
|
||||
LOGIN_WINDOW: '1',
|
||||
|
||||
84
package-lock.json
generated
84
package-lock.json
generated
@@ -64,7 +64,7 @@
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.56",
|
||||
"@librechat/agents": "^2.4.59",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@node-saml/passport-saml": "^5.0.0",
|
||||
@@ -2511,7 +2511,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
@@ -19050,13 +19050,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.3.6.tgz",
|
||||
"integrity": "sha512-TMRUEPb/eC5mS8XdY6gwLGX2druwFDxSWUQDXHHNsbrqhIrL3BPlw+UumjcKBQ8wvhk3gEspg4aHXGq8mAqbRA==",
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.3.7.tgz",
|
||||
"integrity": "sha512-Cc0VUtuwFziCNqTNvD1QUNpMzhe9r8tTDIxFgpCwbF8oTNXxZ1tcd/iY9wr7gV5WhZm8DHEpZf3h4z3gFNeNMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "~0.0.18",
|
||||
"@langchain/langgraph-sdk": "~0.0.32",
|
||||
"@langchain/langgraph-sdk": "~0.0.90",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
@@ -19102,9 +19102,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "0.0.89",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.89.tgz",
|
||||
"integrity": "sha512-TFNFfhVxAljV4dFJa53otnT3Ox0uN24ZdW7AfV1rTPe4QTnonxlRGEUl3SSky1CaaVxYaHN9dJyn9zyhxr2jVQ==",
|
||||
"version": "0.0.92",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.92.tgz",
|
||||
"integrity": "sha512-YL3uPo4At0q96Jk1v7uPctpf/NuKYlbHuQzuS03lQDvvzkLNBmw6ZRKr8SFmgZwmiHz2CNMfBP21kmb9aq/9Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15",
|
||||
@@ -19114,7 +19114,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": ">=0.2.31 <0.4.0",
|
||||
"react": "^18 || ^19"
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@langchain/core": {
|
||||
@@ -19122,6 +19123,9 @@
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19343,9 +19347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "2.4.56",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.56.tgz",
|
||||
"integrity": "sha512-LABHXnKyRawlzsXjdMKgPhVICapIoFcvqSTU4dmlB2C2/jzOehfmkFZQwvBhaIhx71obMJNUq7eJu1cftVnp4Q==",
|
||||
"version": "2.4.59",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.59.tgz",
|
||||
"integrity": "sha512-fgf5bAGGM/Qp6wcDitf1plDSyvVpe7C+VQMOLveqJG1sPSQjdGgCFdTGyL+rH56M7JEw1di/ab1BhpuFZcLT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^0.3.24",
|
||||
@@ -19358,7 +19362,7 @@
|
||||
"@langchain/langgraph": "^0.3.4",
|
||||
"@langchain/mistralai": "^0.2.1",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@langchain/openai": "^0.5.16",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/xai": "^0.0.3",
|
||||
"cheerio": "^1.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -19894,10 +19898,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents/node_modules/@langchain/openai": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.18.tgz",
|
||||
"integrity": "sha512-CX1kOTbT5xVFNdtLjnM0GIYNf+P7oMSu+dGCFxxWRa3dZwWiuyuBXCm+dToUGxDLnsHuV1bKBtIzrY1mLq/A1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"openai": "^5.3.0",
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": ">=0.3.58 <0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents/node_modules/agent-base": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -19916,6 +19937,27 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents/node_modules/openai": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-5.9.0.tgz",
|
||||
"integrity": "sha512-cmLC0pfqLLhBGxE4aZPyRPjydgYCncppV2ClQkKmW79hNjCvmzkfhz8rN5/YVDmjVQlFV+UsF1JIuNjNgeagyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -35253,6 +35295,12 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
@@ -46494,7 +46542,7 @@
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@librechat/agents": "^2.4.56",
|
||||
"@librechat/agents": "^2.4.59",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.13.3",
|
||||
"axios": "^1.8.2",
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
"typescript-eslint": "^8.24.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@langchain/openai": "^0.5.16",
|
||||
"axios": "1.8.2",
|
||||
"elliptic": "^6.6.1",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export default {
|
||||
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/node_modules/'],
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
coverageReporters: ['text', 'cobertura'],
|
||||
testResultsProcessor: 'jest-junit',
|
||||
moduleNameMapper: {
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@librechat/agents": "^2.4.56",
|
||||
"@librechat/agents": "^2.4.59",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.13.3",
|
||||
"axios": "^1.8.2",
|
||||
|
||||
165
packages/api/src/agents/__tests__/memory.test.ts
Normal file
165
packages/api/src/agents/__tests__/memory.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Tools, type MemoryArtifact } from 'librechat-data-provider';
|
||||
import { createMemoryTool } from '../memory';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('winston', () => ({
|
||||
createLogger: jest.fn(() => ({
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
})),
|
||||
format: {
|
||||
combine: jest.fn(),
|
||||
colorize: jest.fn(),
|
||||
simple: jest.fn(),
|
||||
},
|
||||
transports: {
|
||||
Console: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the Tokenizer
|
||||
jest.mock('~/utils', () => ({
|
||||
Tokenizer: {
|
||||
getTokenCount: jest.fn((text: string) => text.length), // Simple mock: 1 char = 1 token
|
||||
},
|
||||
}));
|
||||
|
||||
describe('createMemoryTool', () => {
|
||||
let mockSetMemory: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSetMemory = jest.fn().mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
// Memory overflow tests
|
||||
describe('overflow handling', () => {
|
||||
it('should return error artifact when memory is already overflowing', async () => {
|
||||
const tool = createMemoryTool({
|
||||
userId: 'test-user',
|
||||
setMemory: mockSetMemory,
|
||||
tokenLimit: 100,
|
||||
totalTokens: 150, // Already over limit
|
||||
});
|
||||
|
||||
// Call the underlying function directly since invoke() doesn't handle responseFormat in tests
|
||||
const result = await tool.func({ key: 'test', value: 'new memory' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('Memory storage exceeded. Cannot save new memories.');
|
||||
|
||||
const artifacts = result[1] as Record<Tools.memory, MemoryArtifact>;
|
||||
expect(artifacts[Tools.memory]).toBeDefined();
|
||||
expect(artifacts[Tools.memory].type).toBe('error');
|
||||
expect(artifacts[Tools.memory].key).toBe('system');
|
||||
|
||||
const errorData = JSON.parse(artifacts[Tools.memory].value as string);
|
||||
expect(errorData).toEqual({
|
||||
errorType: 'already_exceeded',
|
||||
tokenCount: 50,
|
||||
totalTokens: 150,
|
||||
tokenLimit: 100,
|
||||
});
|
||||
|
||||
expect(mockSetMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error artifact when new memory would exceed limit', async () => {
|
||||
const tool = createMemoryTool({
|
||||
userId: 'test-user',
|
||||
setMemory: mockSetMemory,
|
||||
tokenLimit: 100,
|
||||
totalTokens: 80,
|
||||
});
|
||||
|
||||
// This would put us at 101 tokens total, exceeding the limit
|
||||
const result = await tool.func({ key: 'test', value: 'This is a 20 char str' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('Memory storage would exceed limit. Cannot save this memory.');
|
||||
|
||||
const artifacts = result[1] as Record<Tools.memory, MemoryArtifact>;
|
||||
expect(artifacts[Tools.memory]).toBeDefined();
|
||||
expect(artifacts[Tools.memory].type).toBe('error');
|
||||
expect(artifacts[Tools.memory].key).toBe('system');
|
||||
|
||||
const errorData = JSON.parse(artifacts[Tools.memory].value as string);
|
||||
expect(errorData).toEqual({
|
||||
errorType: 'would_exceed',
|
||||
tokenCount: 1, // Math.abs(-1)
|
||||
totalTokens: 101,
|
||||
tokenLimit: 100,
|
||||
});
|
||||
|
||||
expect(mockSetMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should successfully save memory when below limit', async () => {
|
||||
const tool = createMemoryTool({
|
||||
userId: 'test-user',
|
||||
setMemory: mockSetMemory,
|
||||
tokenLimit: 100,
|
||||
totalTokens: 50,
|
||||
});
|
||||
|
||||
const result = await tool.func({ key: 'test', value: 'small memory' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('Memory set for key "test" (12 tokens)');
|
||||
|
||||
const artifacts = result[1] as Record<Tools.memory, MemoryArtifact>;
|
||||
expect(artifacts[Tools.memory]).toBeDefined();
|
||||
expect(artifacts[Tools.memory].type).toBe('update');
|
||||
expect(artifacts[Tools.memory].key).toBe('test');
|
||||
expect(artifacts[Tools.memory].value).toBe('small memory');
|
||||
|
||||
expect(mockSetMemory).toHaveBeenCalledWith({
|
||||
userId: 'test-user',
|
||||
key: 'test',
|
||||
value: 'small memory',
|
||||
tokenCount: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Basic functionality tests
|
||||
describe('basic functionality', () => {
|
||||
it('should validate keys when validKeys is provided', async () => {
|
||||
const tool = createMemoryTool({
|
||||
userId: 'test-user',
|
||||
setMemory: mockSetMemory,
|
||||
validKeys: ['allowed', 'keys'],
|
||||
});
|
||||
|
||||
const result = await tool.func({ key: 'invalid', value: 'some value' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('Invalid key "invalid". Must be one of: allowed, keys');
|
||||
expect(result[1]).toBeUndefined();
|
||||
expect(mockSetMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle setMemory failure', async () => {
|
||||
mockSetMemory.mockResolvedValue({ ok: false });
|
||||
const tool = createMemoryTool({
|
||||
userId: 'test-user',
|
||||
setMemory: mockSetMemory,
|
||||
});
|
||||
|
||||
const result = await tool.func({ key: 'test', value: 'some value' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('Failed to set memory for key "test"');
|
||||
expect(result[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle exceptions', async () => {
|
||||
mockSetMemory.mockRejectedValue(new Error('DB error'));
|
||||
const tool = createMemoryTool({
|
||||
userId: 'test-user',
|
||||
setMemory: mockSetMemory,
|
||||
});
|
||||
|
||||
const result = await tool.func({ key: 'test', value: 'some value' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('Error setting memory for key "test"');
|
||||
expect(result[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,11 @@ export async function getPluginAuthMap({
|
||||
await Promise.all(decryptionPromises);
|
||||
return authMap;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const plugins = pluginKeys?.join(', ') ?? 'all requested';
|
||||
logger.warn(
|
||||
`[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${plugins}: ${message}`,
|
||||
);
|
||||
if (!throwError) {
|
||||
/** Empty objects for each plugin key on error */
|
||||
return pluginKeys.reduce((acc, key) => {
|
||||
@@ -83,11 +88,6 @@ export async function getPluginAuthMap({
|
||||
return acc;
|
||||
}, {} as PluginAuthMap);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(
|
||||
`[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${pluginKeys.join(', ')}: ${message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ const getDefaultInstructions = (
|
||||
/**
|
||||
* Creates a memory tool instance with user context
|
||||
*/
|
||||
const createMemoryTool = ({
|
||||
export const createMemoryTool = ({
|
||||
userId,
|
||||
setMemory,
|
||||
validKeys,
|
||||
@@ -84,6 +84,9 @@ const createMemoryTool = ({
|
||||
tokenLimit?: number;
|
||||
totalTokens?: number;
|
||||
}) => {
|
||||
const remainingTokens = tokenLimit ? tokenLimit - totalTokens : Infinity;
|
||||
const isOverflowing = tokenLimit ? remainingTokens <= 0 : false;
|
||||
|
||||
return tool(
|
||||
async ({ key, value }) => {
|
||||
try {
|
||||
@@ -93,24 +96,48 @@ const createMemoryTool = ({
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
return `Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`;
|
||||
return [`Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`, undefined];
|
||||
}
|
||||
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
if (tokenLimit && tokenCount > tokenLimit) {
|
||||
logger.warn(
|
||||
`Memory Agent failed to set memory: Value exceeds token limit. Value has ${tokenCount} tokens, but limit is ${tokenLimit}`,
|
||||
);
|
||||
return `Memory value too large: ${tokenCount} tokens exceeds limit of ${tokenLimit}`;
|
||||
if (isOverflowing) {
|
||||
const errorArtifact: Record<Tools.memory, MemoryArtifact> = {
|
||||
[Tools.memory]: {
|
||||
key: 'system',
|
||||
type: 'error',
|
||||
value: JSON.stringify({
|
||||
errorType: 'already_exceeded',
|
||||
tokenCount: Math.abs(remainingTokens),
|
||||
totalTokens: totalTokens,
|
||||
tokenLimit: tokenLimit!,
|
||||
}),
|
||||
tokenCount: totalTokens,
|
||||
},
|
||||
};
|
||||
return [`Memory storage exceeded. Cannot save new memories.`, errorArtifact];
|
||||
}
|
||||
|
||||
if (tokenLimit && totalTokens + tokenCount > tokenLimit) {
|
||||
const remainingCapacity = tokenLimit - totalTokens;
|
||||
logger.warn(
|
||||
`Memory Agent failed to set memory: Would exceed total token limit. Current usage: ${totalTokens}, new memory: ${tokenCount} tokens, limit: ${tokenLimit}`,
|
||||
);
|
||||
return `Cannot add memory: would exceed token limit. Current usage: ${totalTokens}/${tokenLimit} tokens. This memory requires ${tokenCount} tokens, but only ${remainingCapacity} tokens available.`;
|
||||
if (tokenLimit) {
|
||||
const newTotalTokens = totalTokens + tokenCount;
|
||||
const newRemainingTokens = tokenLimit - newTotalTokens;
|
||||
|
||||
if (newRemainingTokens < 0) {
|
||||
const errorArtifact: Record<Tools.memory, MemoryArtifact> = {
|
||||
[Tools.memory]: {
|
||||
key: 'system',
|
||||
type: 'error',
|
||||
value: JSON.stringify({
|
||||
errorType: 'would_exceed',
|
||||
tokenCount: Math.abs(newRemainingTokens),
|
||||
totalTokens: newTotalTokens,
|
||||
tokenLimit,
|
||||
}),
|
||||
tokenCount: totalTokens,
|
||||
},
|
||||
};
|
||||
return [`Memory storage would exceed limit. Cannot save this memory.`, errorArtifact];
|
||||
}
|
||||
}
|
||||
|
||||
const artifact: Record<Tools.memory, MemoryArtifact> = {
|
||||
@@ -177,7 +204,7 @@ const createDeleteMemoryTool = ({
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
return `Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`;
|
||||
return [`Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`, undefined];
|
||||
}
|
||||
|
||||
const artifact: Record<Tools.memory, MemoryArtifact> = {
|
||||
@@ -269,7 +296,13 @@ export async function processMemory({
|
||||
llmConfig?: Partial<LLMConfig>;
|
||||
}): Promise<(TAttachment | null)[] | undefined> {
|
||||
try {
|
||||
const memoryTool = createMemoryTool({ userId, tokenLimit, setMemory, validKeys, totalTokens });
|
||||
const memoryTool = createMemoryTool({
|
||||
userId,
|
||||
tokenLimit,
|
||||
setMemory,
|
||||
validKeys,
|
||||
totalTokens,
|
||||
});
|
||||
const deleteMemoryTool = createDeleteMemoryTool({
|
||||
userId,
|
||||
validKeys,
|
||||
|
||||
@@ -71,7 +71,12 @@ describe('primeResources', () => {
|
||||
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.tool_resources).toEqual(tool_resources);
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ import type { Request as ServerRequest } from 'express';
|
||||
* @param filter - MongoDB filter query for files
|
||||
* @param _sortOptions - Sorting options (currently unused)
|
||||
* @param selectFields - Field selection options
|
||||
* @param options - Additional options including userId and agentId for access control
|
||||
* @returns Promise resolving to array of files
|
||||
*/
|
||||
export type TGetFiles = (
|
||||
filter: FilterQuery<IMongoFile>,
|
||||
_sortOptions: ProjectionType<IMongoFile> | null | undefined,
|
||||
selectFields: QueryOptions<IMongoFile> | null | undefined,
|
||||
options?: { userId?: string; agentId?: string },
|
||||
) => Promise<Array<TFile>>;
|
||||
|
||||
/**
|
||||
@@ -145,12 +147,14 @@ export const primeResources = async ({
|
||||
requestFileSet,
|
||||
attachments: _attachments,
|
||||
tool_resources: _tool_resources,
|
||||
agentId,
|
||||
}: {
|
||||
req: ServerRequest;
|
||||
requestFileSet: Set<string>;
|
||||
attachments: Promise<Array<TFile | null>> | undefined;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
getFiles: TGetFiles;
|
||||
agentId?: string;
|
||||
}): Promise<{
|
||||
attachments: Array<TFile | undefined> | undefined;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
@@ -205,6 +209,7 @@ export const primeResources = async ({
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{ userId: req.user?.id, agentId },
|
||||
);
|
||||
|
||||
for (const file of context) {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function getOpenAIConfig(
|
||||
};
|
||||
configOptions.defaultQuery = {
|
||||
...configOptions.defaultQuery,
|
||||
'api-version': 'preview',
|
||||
'api-version': configOptions.defaultQuery?.['api-version'] ?? 'preview',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user