Compare commits

..

34 Commits

Author SHA1 Message Date
Dustin Healy
3fd84216cf ci: add unit tests for backend prompt file attachment code and for uploaded file processing 2025-09-11 00:53:00 -07:00
Dustin Healy
d8997fdf0e feat: capabilities filtering on AttachFileButton 2025-09-11 00:53:00 -07:00
Dustin Healy
bb6ee0dc58 feat: fix mismatched sizes for icons 2025-09-11 00:53:00 -07:00
Dustin Healy
c4e86539c6 chore: remove debugging logs and tidy up misc stuff 2025-09-11 00:53:00 -07:00
Dustin Healy
f1bc15b3d5 chore: remove unnecessary comments 2025-09-11 00:53:00 -07:00
Dustin Healy
58678be0f8 chore: clean up comments and remove debugging log statements 2025-09-11 00:53:00 -07:00
Dustin Healy
441e69181c chore: import order 2025-09-11 00:53:00 -07:00
Dustin Healy
2c1d7a6b71 refactor: remove unnecessary onFileChange, just handle onSave stuff in onFilesChange 2025-09-11 00:53:00 -07:00
Dustin Healy
384d6c870b chore: remove unused translation string 2025-09-11 00:53:00 -07:00
Dustin Healy
013c002cbb fix: bring back proper deletion handling we lost with refactor for onRemoveHandler 2025-09-11 00:53:00 -07:00
Dustin Healy
e062ed5832 fix: refactor to ifs rather than switch case to maintain codebase style 2025-09-11 00:53:00 -07:00
Dustin Healy
d07d05a8d0 feat: add localization strings for tool resource types in file preview 2025-09-11 00:53:00 -07:00
Dustin Healy
5b38ce8fd9 fix: type guard for compiler 2025-09-11 00:53:00 -07:00
Dustin Healy
0a61e3cb39 feat: remove propdrilling for custom onFileRemove handler and just make it default behavior for PromptFile rather than working around old deletion handlers 2025-09-11 00:53:00 -07:00
Dustin Healy
a52c37faad chore: revert unnecessary change to message file handling 2025-09-11 00:53:00 -07:00
Dustin Healy
1f49c569c3 chore: remove debugging logs 2025-09-11 00:53:00 -07:00
Dustin Healy
479ce5df48 fix: use proper enum for promptGroup in useResourcePermissions arg and remove console.logs
chore: remove debugging logs

chore: remove debugging logs

chore: remove unused component
2025-09-11 00:53:00 -07:00
Dustin Healy
c37e368d98 chore: remove unused component and translation strings 2025-09-11 00:53:00 -07:00
Dustin Healy
fd29cbed4f chore: remove debug logs 2025-09-11 00:53:00 -07:00
Dustin Healy
277a321155 fix: attachments go in new prompt so that sidenav bar updates without refresh 2025-09-11 00:53:00 -07:00
Dustin Healy
0dba5c6450 fix: paperclip was getting larger as title got longer 2025-09-11 00:53:00 -07:00
Dustin Healy
93490764e6 refactor: move attach button to bottom of div when no attachments present 2025-09-11 00:53:00 -07:00
Dustin Healy
094320fcd9 feat: auto send working (still needs clean up) 2025-09-11 00:53:00 -07:00
Dustin Healy
cee11d3353 chore: address ESLint comments 2025-09-11 00:53:00 -07:00
Dustin Healy
69772317b2 chore: clean up usePromptFileHandling 2025-09-11 00:53:00 -07:00
Dustin Healy
607a5a2fcf feat: chat ui and functionality for prompts (auto-send not working) 2025-09-11 00:53:00 -07:00
Dustin Healy
7c3356e10b fix: deletion doesn't cause reference loss in versioning anymore - file reference maintained in db 2025-09-11 00:53:00 -07:00
Dustin Healy
d4fd0047cb fix: deletion + version updates not working properly 2025-09-11 00:53:00 -07:00
Dustin Healy
797fdf4286 feat: add attach section to PromptForm 2025-09-11 00:53:00 -07:00
Dustin Healy
623dfa5b63 feat: add file attachment section PromptFiles, new file display: PromptFile (needed for deletion to work properly), and usePromptFileHandling hook 2025-09-11 00:53:00 -07:00
Dustin Healy
600641d02f feat: add SharePoint picker support 2025-09-11 00:53:00 -07:00
Dustin Healy
d65accddc1 feat: add AttachFileButton for uploading files from a prompt context rather than chat
This is pretty much a stripped down version of AttachFileMenu so ofc there is duplication across this new component and AttachFileMenu but I believe it outweighs the increased complexity that would come from attempting to handle both contexts within just AttachFileMenu in regards to ephemeral agents and the file handling hooks - though we could probably refactor this without too much hassle later on in the file upload unification push once things are more settled.
2025-09-11 00:53:00 -07:00
Dustin Healy
195d2e2014 feat: add tool_resources to the productionPrompt for making and getting groups 2025-09-11 00:53:00 -07:00
Dustin Healy
c0ae6f277f feat: add schemas and types 2025-09-11 00:53:00 -07:00
140 changed files with 3582 additions and 5252 deletions

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc4
# v0.8.0-rc3
# Base node image
FROM node:20-alpine AS node
@@ -30,7 +30,7 @@ RUN \
# Allow mounting of these files, which have no default
touch .env ; \
# Create directories for the volumes to inherit the correct permissions
mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
mkdir -p /app/client/public/images /app/api/logs ; \
npm config set fetch-retry-maxtimeout 600000 ; \
npm config set fetch-retries 5 ; \
npm config set fetch-retry-mintimeout 15000 ; \
@@ -44,6 +44,8 @@ RUN \
npm prune --production; \
npm cache clean --force
RUN mkdir -p /app/client/public/images /app/api/logs
# Node API setup
EXPOSE 3080
ENV HOST=0.0.0.0

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.0-rc4
# v0.8.0-rc3
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -1,6 +1,6 @@
const { MCPManager, FlowStateManager } = require('@librechat/api');
const { EventSource } = require('eventsource');
const { Time } = require('librechat-data-provider');
const { MCPManager, FlowStateManager, OAuthReconnectionManager } = require('@librechat/api');
const logger = require('./winston');
global.EventSource = EventSource;
@@ -26,6 +26,4 @@ module.exports = {
createMCPManager: MCPManager.createInstance,
getMCPManager: MCPManager.getInstance,
getFlowStateManager,
createOAuthReconnectionManager: OAuthReconnectionManager.createInstance,
getOAuthReconnectionManager: OAuthReconnectionManager.getInstance,
};

View File

@@ -51,6 +51,7 @@ const createGroupPipeline = (query, skip, limit) => {
createdAt: 1,
updatedAt: 1,
'productionPrompt.prompt': 1,
'productionPrompt.tool_resources': 1,
// 'productionPrompt._id': 1,
// 'productionPrompt.type': 1,
},
@@ -328,6 +329,7 @@ async function getListPromptGroupsByAccess({
createdAt: 1,
updatedAt: 1,
'productionPrompt.prompt': 1,
'productionPrompt.tool_resources': 1,
},
},
);
@@ -411,7 +413,10 @@ module.exports = {
prompt: newPrompt,
group: {
...newPromptGroup,
productionPrompt: { prompt: newPrompt.prompt },
productionPrompt: {
prompt: newPrompt.prompt,
tool_resources: newPrompt.tool_resources,
},
},
};
} catch (error) {

View File

@@ -562,3 +562,884 @@ describe('Prompt ACL Permissions', () => {
});
});
});
describe('Prompt Model - File Attachments', () => {
describe('Creating Prompts with tool_resources', () => {
it('should create a prompt with file attachments in tool_resources', async () => {
const testGroup = await PromptGroup.create({
name: 'Attachment Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
const promptData = {
prompt: {
prompt: 'Test prompt with file attachments',
type: 'text',
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
image_edit: {
file_ids: ['file-4'],
},
},
},
author: testUsers.owner._id,
};
const result = await promptFns.savePrompt(promptData);
expect(result.prompt).toBeTruthy();
expect(result.prompt.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
image_edit: {
file_ids: ['file-4'],
},
});
const savedPrompt = await Prompt.findById(result.prompt._id);
expect(savedPrompt.tool_resources).toEqual(promptData.prompt.tool_resources);
});
it('should create a prompt without tool_resources when none provided', async () => {
const testGroup = await PromptGroup.create({
name: 'No Attachment Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
const promptData = {
prompt: {
prompt: 'Test prompt without attachments',
type: 'text',
groupId: testGroup._id,
},
author: testUsers.owner._id,
};
const result = await promptFns.savePrompt(promptData);
expect(result.prompt).toBeTruthy();
expect(result.prompt.tool_resources).toEqual({});
const savedPrompt = await Prompt.findById(result.prompt._id);
expect(savedPrompt.tool_resources).toEqual({});
});
it('should create a prompt group with tool_resources', async () => {
const saveData = {
prompt: {
type: 'text',
prompt: 'Test prompt with file attachments',
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2'],
},
ocr: {
file_ids: ['file-3'],
},
},
},
group: {
name: 'Test Prompt Group with Attachments',
category: 'test-category',
oneliner: 'Test description',
},
author: testUsers.owner._id,
authorName: testUsers.owner.name,
};
const result = await promptFns.createPromptGroup(saveData);
expect(result.prompt).toBeTruthy();
expect(result.group).toBeTruthy();
expect(result.prompt.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2'],
},
ocr: {
file_ids: ['file-3'],
},
});
expect(result.group.productionPrompt.tool_resources).toEqual(result.prompt.tool_resources);
});
});
describe('Retrieving Prompts with tool_resources', () => {
let testGroup;
let testPrompt;
beforeEach(async () => {
testGroup = await PromptGroup.create({
name: 'Retrieval Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
testPrompt = await Prompt.create({
prompt: 'Test prompt with attachments for retrieval',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
},
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
});
it('should retrieve a prompt with tool_resources', async () => {
const result = await promptFns.getPrompt({ _id: testPrompt._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
});
});
it('should retrieve prompts with tool_resources by groupId', async () => {
const result = await promptFns.getPrompts({ groupId: testGroup._id });
expect(result).toBeTruthy();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
});
});
it('should handle prompts without tool_resources', async () => {
const promptWithoutAttachments = await Prompt.create({
prompt: 'Test prompt without attachments',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
});
const result = await promptFns.getPrompt({ _id: promptWithoutAttachments._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toBeUndefined();
});
});
describe('Updating Prompts with tool_resources', () => {
let testGroup;
beforeEach(async () => {
testGroup = await PromptGroup.create({
name: 'Update Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
await Prompt.create({
prompt: 'Original prompt',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1'],
},
},
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
});
it('should update prompt with new tool_resources', async () => {
const updatedPromptData = {
prompt: {
prompt: 'Updated prompt with new attachments',
type: 'text',
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
},
},
author: testUsers.owner._id,
};
const result = await promptFns.savePrompt(updatedPromptData);
expect(result.prompt).toBeTruthy();
expect(result.prompt.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
});
});
it('should update prompt to remove tool_resources', async () => {
const updatedPromptData = {
prompt: {
prompt: 'Updated prompt without attachments',
type: 'text',
groupId: testGroup._id,
// No tool_resources field
},
author: testUsers.owner._id,
};
const result = await promptFns.savePrompt(updatedPromptData);
expect(result.prompt).toBeTruthy();
expect(result.prompt.tool_resources).toEqual({});
});
});
describe('Deleting Prompts with tool_resources', () => {
let testGroup;
let testPrompt;
beforeEach(async () => {
testGroup = await PromptGroup.create({
name: 'Deletion Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
testPrompt = await Prompt.create({
prompt: 'Prompt to be deleted',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2'],
},
execute_code: {
file_ids: ['file-3'],
},
},
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
});
it('should delete a prompt with tool_resources', async () => {
const result = await promptFns.deletePrompt({
promptId: testPrompt._id,
groupId: testGroup._id,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
expect(result.prompt).toBe('Prompt deleted successfully');
const deletedPrompt = await Prompt.findById(testPrompt._id);
expect(deletedPrompt).toBeNull();
});
it('should delete prompt group when last prompt with tool_resources is deleted', async () => {
const result = await promptFns.deletePrompt({
promptId: testPrompt._id,
groupId: testGroup._id,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
expect(result.prompt).toBe('Prompt deleted successfully');
expect(result.promptGroup).toBeTruthy();
expect(result.promptGroup.message).toBe('Prompt group deleted successfully');
const deletedPrompt = await Prompt.findById(testPrompt._id);
const deletedGroup = await PromptGroup.findById(testGroup._id);
expect(deletedPrompt).toBeNull();
expect(deletedGroup).toBeNull();
});
});
describe('Making Prompts Production with tool_resources', () => {
let testGroup;
let testPrompt;
beforeEach(async () => {
testGroup = await PromptGroup.create({
name: 'Production Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
testPrompt = await Prompt.create({
prompt: 'Prompt to be made production',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2'],
},
image_edit: {
file_ids: ['file-3'],
},
},
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
});
it('should make a prompt with tool_resources production', async () => {
const result = await promptFns.makePromptProduction(testPrompt._id.toString());
expect(result.message).toBe('Prompt production made successfully');
const updatedGroup = await PromptGroup.findById(testGroup._id);
expect(updatedGroup.productionId.toString()).toBe(testPrompt._id.toString());
});
it('should return error message when prompt not found', async () => {
const nonExistentId = new mongoose.Types.ObjectId().toString();
const result = await promptFns.makePromptProduction(nonExistentId);
expect(result.message).toBe('Error making prompt production');
});
});
describe('Prompt Groups with tool_resources projection', () => {
let testGroup;
let testPrompt;
beforeEach(async () => {
testGroup = await PromptGroup.create({
name: 'Projection Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
testPrompt = await Prompt.create({
prompt: 'Test prompt for projection',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1'],
},
execute_code: {
file_ids: ['file-2', 'file-3'],
},
},
});
await PromptGroup.findByIdAndUpdate(testGroup._id, {
productionId: testPrompt._id,
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
});
it('should include tool_resources in prompt group projection', async () => {
const mockReq = { user: { id: testUsers.owner._id } };
const filter = {
pageNumber: 1,
pageSize: 10,
category: 'testing',
};
const result = await promptFns.getPromptGroups(mockReq, filter);
expect(result.promptGroups).toBeTruthy();
expect(Array.isArray(result.promptGroups)).toBe(true);
expect(result.promptGroups.length).toBeGreaterThan(0);
const foundGroup = result.promptGroups.find(
(group) => group._id.toString() === testGroup._id.toString(),
);
expect(foundGroup).toBeTruthy();
expect(foundGroup.productionPrompt.tool_resources).toEqual({
file_search: {
file_ids: ['file-1'],
},
execute_code: {
file_ids: ['file-2', 'file-3'],
},
});
});
});
describe('Error handling with tool_resources', () => {
it('should handle errors when creating prompt with tool_resources', async () => {
const invalidPromptData = {
prompt: {
prompt: 'Test prompt',
type: 'text',
groupId: 'invalid-id',
tool_resources: {
file_search: {
file_ids: ['file-1'],
},
},
},
author: testUsers.owner._id,
};
const result = await promptFns.savePrompt(invalidPromptData);
expect(result.message).toBe('Error saving prompt');
});
it('should handle errors when retrieving prompt with tool_resources', async () => {
const result = await promptFns.getPrompt({ _id: 'invalid-id' });
expect(result.message).toBe('Error getting prompt');
});
});
describe('Edge Cases - File Attachment Scenarios', () => {
let testGroup;
let testPrompt;
beforeEach(async () => {
testGroup = await PromptGroup.create({
name: 'Edge Case Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
testPrompt = await Prompt.create({
prompt: 'Test prompt with file attachments for edge cases',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2', 'file-3'],
},
execute_code: {
file_ids: ['file-4'],
},
image_edit: {
file_ids: ['file-5', 'file-6'],
},
},
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
});
describe('Orphaned File References', () => {
it('should maintain prompt functionality when referenced files are deleted', async () => {
const result = await promptFns.getPrompt({ _id: testPrompt._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2', 'file-3'],
},
execute_code: {
file_ids: ['file-4'],
},
image_edit: {
file_ids: ['file-5', 'file-6'],
},
});
expect(result.prompt).toBe('Test prompt with file attachments for edge cases');
expect(result.type).toBe('text');
});
it('should handle prompts with empty file_ids arrays', async () => {
const promptWithEmptyFileIds = await Prompt.create({
prompt: 'Prompt with empty file_ids',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: [],
},
execute_code: {
file_ids: [],
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithEmptyFileIds._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: {
file_ids: [],
},
execute_code: {
file_ids: [],
},
});
});
it('should handle prompts with null/undefined file_ids', async () => {
const promptWithNullFileIds = await Prompt.create({
prompt: 'Prompt with null file_ids',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: null,
},
execute_code: {
file_ids: undefined,
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithNullFileIds._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: {
file_ids: null,
},
});
});
});
describe('Invalid File References', () => {
it('should handle prompts with malformed file_ids', async () => {
const promptWithMalformedIds = await Prompt.create({
prompt: 'Prompt with malformed file_ids',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['', null, undefined, 'invalid-id', 'file-valid'],
},
execute_code: {
file_ids: [123, {}, []],
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithMalformedIds._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: {
file_ids: ['', null, null, 'invalid-id', 'file-valid'],
},
execute_code: {
file_ids: [123, {}, []],
},
});
});
it('should handle prompts with duplicate file_ids', async () => {
const promptWithDuplicates = await Prompt.create({
prompt: 'Prompt with duplicate file_ids',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['file-1', 'file-2', 'file-1', 'file-3', 'file-2'],
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithDuplicates._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2', 'file-1', 'file-3', 'file-2'],
},
});
});
});
describe('Tool Resource Edge Cases', () => {
it('should handle prompts with unknown tool resource types', async () => {
const promptWithUnknownTools = await Prompt.create({
prompt: 'Prompt with unknown tool resources',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
unknown_tool: {
file_ids: ['file-1'],
},
another_unknown: {
file_ids: ['file-2', 'file-3'],
},
file_search: {
file_ids: ['file-4'],
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithUnknownTools._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
unknown_tool: {
file_ids: ['file-1'],
},
another_unknown: {
file_ids: ['file-2', 'file-3'],
},
file_search: {
file_ids: ['file-4'],
},
});
});
it('should handle prompts with malformed tool_resources structure', async () => {
const promptWithMalformedTools = await Prompt.create({
prompt: 'Prompt with malformed tool_resources',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: 'not-an-object',
execute_code: {
file_ids: 'not-an-array',
},
image_edit: {
wrong_property: ['file-1'],
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithMalformedTools._id });
expect(result).toBeTruthy();
expect(result.tool_resources).toEqual({
file_search: 'not-an-object',
execute_code: {
file_ids: 'not-an-array',
},
image_edit: {
wrong_property: ['file-1'],
},
});
});
});
describe('Prompt Deletion vs File Persistence', () => {
it('should delete prompt but preserve file references in tool_resources', async () => {
const beforeDelete = await promptFns.getPrompt({ _id: testPrompt._id });
expect(beforeDelete.tool_resources).toEqual({
file_search: {
file_ids: ['file-1', 'file-2', 'file-3'],
},
execute_code: {
file_ids: ['file-4'],
},
image_edit: {
file_ids: ['file-5', 'file-6'],
},
});
const result = await promptFns.deletePrompt({
promptId: testPrompt._id,
groupId: testGroup._id,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
expect(result.prompt).toBe('Prompt deleted successfully');
const deletedPrompt = await Prompt.findById(testPrompt._id);
expect(deletedPrompt).toBeNull();
});
it('should handle prompt deletion when tool_resources contain non-existent files', async () => {
const promptWithNonExistentFiles = await Prompt.create({
prompt: 'Prompt with non-existent file references',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['non-existent-file-1', 'non-existent-file-2'],
},
},
});
const result = await promptFns.deletePrompt({
promptId: promptWithNonExistentFiles._id,
groupId: testGroup._id,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
expect(result.prompt).toBe('Prompt deleted successfully');
const deletedPrompt = await Prompt.findById(promptWithNonExistentFiles._id);
expect(deletedPrompt).toBeNull();
});
});
describe('Large File Collections', () => {
it('should handle prompts with many file attachments', async () => {
const manyFileIds = Array.from({ length: 100 }, (_, i) => `file-${i + 1}`);
const promptWithManyFiles = await Prompt.create({
prompt: 'Prompt with many file attachments',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: manyFileIds.slice(0, 50),
},
execute_code: {
file_ids: manyFileIds.slice(50, 100),
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithManyFiles._id });
expect(result).toBeTruthy();
expect(result.tool_resources.file_search.file_ids).toHaveLength(50);
expect(result.tool_resources.execute_code.file_ids).toHaveLength(50);
expect(result.tool_resources.file_search.file_ids[0]).toBe('file-1');
expect(result.tool_resources.execute_code.file_ids[49]).toBe('file-100');
});
it('should handle prompts with very long file_ids', async () => {
const longFileId = 'a'.repeat(1000);
const promptWithLongFileId = await Prompt.create({
prompt: 'Prompt with very long file ID',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: [longFileId],
},
},
});
const result = await promptFns.getPrompt({ _id: promptWithLongFileId._id });
expect(result).toBeTruthy();
expect(result.tool_resources.file_search.file_ids[0]).toBe(longFileId);
expect(result.tool_resources.file_search.file_ids[0].length).toBe(1000);
});
});
describe('Concurrent Operations', () => {
it('should handle concurrent updates to prompts with tool_resources', async () => {
const concurrentPrompts = await Promise.all([
Prompt.create({
prompt: 'Concurrent prompt 1',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['shared-file-1', 'unique-file-1'],
},
},
}),
Prompt.create({
prompt: 'Concurrent prompt 2',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['shared-file-1', 'unique-file-2'],
},
},
}),
Prompt.create({
prompt: 'Concurrent prompt 3',
type: 'text',
author: testUsers.owner._id,
groupId: testGroup._id,
tool_resources: {
file_search: {
file_ids: ['shared-file-1', 'unique-file-3'],
},
},
}),
]);
expect(concurrentPrompts).toHaveLength(3);
concurrentPrompts.forEach((prompt, index) => {
expect(prompt.tool_resources.file_search.file_ids).toContain('shared-file-1');
expect(prompt.tool_resources.file_search.file_ids).toContain(`unique-file-${index + 1}`);
});
const retrievedPrompts = await promptFns.getPrompts({ groupId: testGroup._id });
expect(retrievedPrompts.length).toBeGreaterThanOrEqual(3);
});
});
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc4",
"version": "v0.8.0-rc3",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -56,7 +56,7 @@
"@modelcontextprotocol/sdk": "^1.17.1",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.12.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",

View File

@@ -11,9 +11,8 @@ const {
registerUser,
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOAuthReconnectionManager } = require('~/config');
const { getOpenIdConfig } = require('~/strategies');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const registrationController = async (req, res) => {
try {
@@ -97,25 +96,14 @@ const refreshController = async (req, res) => {
return res.status(200).send({ token, user });
}
/** Session with the hashed refresh token */
const session = await findSession(
{
userId: userId,
refreshToken: refreshToken,
},
{ lean: false },
);
// Find the session with the hashed refresh token
const session = await findSession({
userId: userId,
refreshToken: refreshToken,
});
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session);
// trigger OAuth MCP server reconnection asynchronously (best effort)
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('Error reconnecting OAuth MCP servers:', err);
});
const token = await setAuthTokens(userId, res, session._id);
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)

View File

@@ -1,342 +0,0 @@
const { Tools } = require('librechat-data-provider');
// Mock all dependencies before requiring the module
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-id'),
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
Providers: { GOOGLE: 'google' },
GraphEvents: {},
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
ChatModelStreamHandler: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked logger
logger = require('@librechat/data-schemas').logger;
// Now require the module after all mocks are set up
const callbacks = require('../callbacks');
createToolEndCallback = callbacks.createToolEndCallback;
req = {
user: { id: 'user123' },
};
res = {
headersSent: false,
write: jest.fn(),
};
artifactPromises = [];
});
describe('ui_resources artifact handling', () => {
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
// Wait for all promises to resolve
const results = await Promise.all(artifactPromises);
// When headers are not sent, it returns attachment without writing
expect(res.write).not.toHaveBeenCalled();
const attachment = results[0];
expect(attachment).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
});
});
it('should write to response when headers are already sent', async () => {
res.headersSent = true;
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'carousel', items: [] },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(res.write).toHaveBeenCalled();
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'carousel', items: [] },
},
});
});
it('should handle errors when processing ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
// Mock res.write to throw an error
res.headersSent = true;
res.write.mockImplementation(() => {
throw new Error('Write failed');
});
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'test' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(logger.error).toHaveBeenCalledWith(
'Error processing artifact content:',
expect.any(Error),
);
expect(results[0]).toBeNull();
});
it('should handle multiple artifacts including ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'chart', data: [] },
},
},
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
// Both ui_resources and web_search should be processed
expect(artifactPromises).toHaveLength(2);
expect(results).toHaveLength(2);
// Check ui_resources attachment
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
expect(uiResourceAttachment).toBeTruthy();
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
0: { type: 'chart', data: [] },
});
// Check web_search attachment
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
expect(webSearchAttachment).toBeTruthy();
expect(webSearchAttachment[Tools.web_search]).toEqual({
results: ['result1', 'result2'],
});
});
it('should not process artifacts when output has no artifacts', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
content: 'Some regular content',
// No artifact property
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty ui_resources data object', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {},
});
});
it('should handle ui_resources with complex nested data', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const complexData = {
0: {
type: 'form',
fields: [
{ name: 'field1', type: 'text', required: true },
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
],
nested: {
deep: {
value: 123,
array: [1, 2, 3],
},
},
},
};
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: complexData,
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0][Tools.ui_resources]).toEqual(complexData);
});
it('should handle when output is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output: undefined }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
it('should handle when data parameter is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback(undefined, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
});

View File

@@ -265,30 +265,6 @@ function createToolEndCallback({ req, res, artifactPromises }) {
);
}
// TODO: a lot of duplicated code in createToolEndCallback
// we should refactor this to use a helper function in a follow-up PR
if (output.artifact[Tools.ui_resources]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.ui_resources,
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
};
if (!res.headersSent) {
return attachment;
}
res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
return attachment;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
if (output.artifact[Tools.web_search]) {
artifactPromises.push(
(async () => {

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const loginController = async (req, res) => {
try {

View File

@@ -1,50 +0,0 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { checkBan } = require('~/server/middleware');
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
function createOAuthHandler(redirectUri = domains.client) {
/**
* A handler to process OAuth authentication results.
* @type {Function}
* @param {ServerRequest} req - Express request object.
* @param {ServerResponse} res - Express response object.
* @param {NextFunction} next - Express next middleware function.
*/
return async (req, res, next) => {
try {
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
}
if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
} else {
await setAuthTokens(req.user._id, res);
}
res.redirect(redirectUri);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
}
module.exports = {
createOAuthHandler,
};

View File

@@ -12,7 +12,6 @@ const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { isEnabled, ErrorController } = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
@@ -109,7 +108,6 @@ const startServer = async () => {
app.use('/oauth', routes.oauth);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
@@ -156,7 +154,7 @@ const startServer = async () => {
res.send(updatedIndexHtml);
});
app.listen(port, host, async () => {
app.listen(port, host, () => {
if (host === '0.0.0.0') {
logger.info(
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
@@ -165,9 +163,7 @@ const startServer = async () => {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
await initializeMCPs();
await initializeOAuthReconnectManager();
await checkMigrations();
initializeMCPs().then(() => checkMigrations());
});
};

View File

@@ -14,7 +14,6 @@ const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const configMiddleware = require('./config/app');
const validateModel = require('./validateModel');
const requireAdmin = require('./requireAdmin');
const moderateText = require('./moderateText');
const logHeaders = require('./logHeaders');
const setHeaders = require('./setHeaders');
@@ -37,7 +36,6 @@ module.exports = {
setHeaders,
logHeaders,
moderateText,
requireAdmin,
validateModel,
requireJwtAuth,
checkInviteUser,

View File

@@ -1,22 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
/**
* Middleware to check if authenticated user has admin role
* Should be used AFTER authentication middleware (requireJwtAuth, requireLocalAuth, etc.)
*/
const requireAdmin = (req, res, next) => {
if (!req.user) {
logger.warn('[requireAdmin] No user found in request');
return res.status(401).json({ message: 'Authentication required' });
}
if (!req.user.role || req.user.role !== SystemRoles.ADMIN) {
logger.debug('[requireAdmin] Access denied for non-admin user:', req.user.email);
return res.status(403).json({ message: 'Access denied: Admin privileges required' });
}
next();
};
module.exports = requireAdmin;

View File

@@ -1,6 +1,6 @@
const cookies = require('cookie');
const passport = require('passport');
const { isEnabled } = require('@librechat/api');
const cookies = require('cookie');
const { isEnabled } = require('~/server/utils');
/**
* Custom Middleware to handle JWT authentication, with support for OpenID token reuse

View File

@@ -1,66 +0,0 @@
const express = require('express');
const passport = require('passport');
const { randomState } = require('openid-client');
const { createSetBalanceConfig } = require('@librechat/api');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
const { getAppConfig } = require('~/server/services/Config');
const { getOpenIdConfig } = require('~/strategies');
const middleware = require('~/server/middleware');
const { Balance } = require('~/db/models');
const setBalanceConfig = createSetBalanceConfig({
getAppConfig,
Balance,
});
const router = express.Router();
router.post(
'/login/local',
middleware.logHeaders,
middleware.loginLimiter,
middleware.checkBan,
middleware.requireLocalAuth,
middleware.requireAdmin,
setBalanceConfig,
loginController,
);
router.get('/verify', middleware.requireJwtAuth, middleware.requireAdmin, (req, res) => {
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
user.id = user._id.toString();
res.status(200).json({ user });
});
router.get('/oauth/openid/check', (req, res) => {
const openidConfig = getOpenIdConfig();
if (!openidConfig) {
return res.status(404).json({ message: 'OpenID configuration not found' });
}
res.status(200).json({ message: 'OpenID check successful' });
});
router.get('/oauth/openid', (req, res, next) => {
return passport.authenticate('openidAdmin', {
session: false,
state: randomState(),
})(req, res, next);
});
router.get(
'/oauth/openid/callback',
passport.authenticate('openidAdmin', {
failureRedirect: `${process.env.DOMAIN_CLIENT}/oauth/error`,
failureMessage: true,
session: false,
}),
middleware.requireAdmin,
setBalanceConfig,
middleware.checkDomainAllowed,
createOAuthHandler(
(process.env.ADMIN_PANEL_URL || 'http://localhost:3000') + '/auth/openid/callback',
),
);
module.exports = router;

View File

@@ -1,7 +1,6 @@
const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants');
const categories = require('./categories');
const adminAuth = require('./admin/auth');
const tokenizer = require('./tokenizer');
const endpoints = require('./endpoints');
const staticRoute = require('./static');
@@ -33,7 +32,6 @@ module.exports = {
mcp,
edit,
auth,
adminAuth,
keys,
user,
tags,

View File

@@ -1,12 +1,12 @@
const { Router } = require('express');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { requireJwtAuth } = require('~/server/middleware');
const { findPluginAuthsByKeys } = require('~/models');
@@ -144,10 +144,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
// clear any reconnection attempts
const oauthReconnectionManager = getOAuthReconnectionManager();
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
const tools = await userConnection.fetchTools();
await updateMCPUserTools({
userId: flowState.userId,

View File

@@ -4,9 +4,10 @@ const passport = require('passport');
const { randomState } = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSetBalanceConfig } = require('@librechat/api');
const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware');
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
const { isEnabled, createSetBalanceConfig } = require('@librechat/api');
const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { getAppConfig } = require('~/server/services/Config');
const { Balance } = require('~/db/models');
@@ -25,7 +26,32 @@ const domains = {
router.use(logHeaders);
router.use(loginLimiter);
const oauthHandler = createOAuthHandler();
const oauthHandler = async (req, res, next) => {
try {
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
}
if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
} else {
await setAuthTokens(req.user._id, res);
}
res.redirect(domains.client);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
router.get('/error', (req, res) => {
/** A single error message is pushed by passport when authentication fails. */

View File

@@ -357,18 +357,23 @@ const resetPassword = async (userId, token, password) => {
/**
* Set Auth Tokens
*
* @param {String | ObjectId} userId
* @param {ServerResponse} res
* @param {ISession | null} [session=null]
* @param {Object} res
* @param {String} sessionId
* @returns
*/
const setAuthTokens = async (userId, res, _session = null) => {
const setAuthTokens = async (userId, res, sessionId = null) => {
try {
let session = _session;
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let refreshToken;
let refreshTokenExpires;
if (session && session._id && session.expiration != null) {
if (sessionId) {
session = await findSession({ sessionId: sessionId }, { lean: false });
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
@@ -378,9 +383,6 @@ const setAuthTokens = async (userId, res, _session = null) => {
refreshTokenExpires = session.expiration.getTime();
}
const user = await getUserById(userId);
const token = await generateToken(user);
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),
httpOnly: true,

View File

@@ -702,6 +702,8 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
returnFile: true,
});
filepath = result.filepath;
width = result.width;
height = result.height;
}
const fileInfo = removeNullishValues({

View File

@@ -0,0 +1,520 @@
const { EToolResources, FileSources, FileContext } = require('librechat-data-provider');
jest.mock('~/server/services/Files/strategies', () => {
const mockHandleFileUpload = jest.fn();
const mockHandleImageUpload = jest.fn();
return {
getStrategyFunctions: jest.fn((source) => ({
handleFileUpload: mockHandleFileUpload.mockImplementation(({ file, file_id }) =>
Promise.resolve({
filepath: `/uploads/${source}/${file_id}`,
bytes: file?.size || 20,
}),
),
handleImageUpload: mockHandleImageUpload.mockImplementation(({ file, file_id }) =>
Promise.resolve({
filepath: `/uploads/${source}/images/${file_id}`,
bytes: file.size,
width: 800,
height: 600,
}),
),
})),
};
});
jest.mock('~/models/File', () => {
const mockCreateFile = jest.fn();
return {
createFile: mockCreateFile.mockImplementation((fileInfo) =>
Promise.resolve({ _id: 'test-file-id', ...fileInfo }),
),
updateFileUsage: jest.fn().mockResolvedValue(),
};
});
jest.mock('~/models/Agent', () => ({
addAgentResourceFile: jest.fn().mockResolvedValue(),
}));
jest.mock('~/server/services/Config/getEndpointsConfig', () => ({
checkCapability: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/server/utils/getFileStrategy', () => ({
getFileStrategy: jest.fn(() => {
return 'local';
}),
}));
jest.mock('~/server/services/Files/VectorDB/crud', () => ({
uploadVectors: jest.fn(({ file_id }) =>
Promise.resolve({
success: true,
vectorIds: [`vector-${file_id}-1`, `vector-${file_id}-2`],
}),
),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({
getOpenAIClient: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('fs', () => ({
...jest.requireActual('fs'),
createReadStream: jest.fn(() => 'mock-stream'),
}));
jest.mock('~/server/utils/queue', () => ({
LB_QueueAsyncCall: jest.fn((fn, args, callback) => {
if (callback) {
callback(null, { success: true });
}
return Promise.resolve({ success: true });
}),
}));
jest.mock('~/server/services/Config/app', () => ({
getAppConfig: jest.fn().mockResolvedValue({
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
}),
}));
jest.mock('~/server/services/Files/images', () => ({
processImageFile: jest.fn().mockResolvedValue({
filepath: '/test/image/path',
width: 800,
height: 600,
}),
handleImageUpload: jest.fn().mockResolvedValue({
filepath: '/test/image/uploaded/path',
bytes: 1024,
width: 800,
height: 600,
}),
}));
describe('File Processing - processAgentFileUpload', () => {
let processAgentFileUpload;
let mockHandleFileUpload;
let mockHandleImageUpload;
let mockCreateFile;
let mockAddAgentResourceFile;
let mockUploadVectors;
let mockCheckCapability;
let mockGetFileStrategy;
beforeAll(() => {
const processModule = require('./process');
processAgentFileUpload = processModule.processAgentFileUpload;
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const mockStrategies = getStrategyFunctions();
mockHandleFileUpload = mockStrategies.handleFileUpload;
mockHandleImageUpload = mockStrategies.handleImageUpload;
mockCreateFile = require('~/models/File').createFile;
mockAddAgentResourceFile = require('~/models/Agent').addAgentResourceFile;
mockUploadVectors = require('~/server/services/Files/VectorDB/crud').uploadVectors;
mockCheckCapability = require('~/server/services/Config/getEndpointsConfig').checkCapability;
mockGetFileStrategy = require('~/server/utils/getFileStrategy').getFileStrategy;
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('processAgentFileUpload', () => {
it('should process image file upload for agent with proper file handling', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('test image data'),
mimetype: 'image/jpeg',
size: 1024,
originalname: 'test-image.jpg',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
agent_id: 'test-agent-id',
tool_resource: EToolResources.image_edit,
file_id: 'test-file-id',
};
await processAgentFileUpload({ req: mockReq, res: mockRes, metadata });
expect(mockGetFileStrategy).toHaveBeenCalledWith(mockReq.config, { isImage: true });
expect(mockHandleImageUpload).toHaveBeenCalledWith(
expect.objectContaining({
req: mockReq,
file: mockReq.file,
file_id: expect.any(String),
}),
);
expect(mockCreateFile).toHaveBeenCalledWith(
expect.objectContaining({
user: 'test-user-id',
file_id: 'test-file-id',
bytes: 1024,
filename: 'test-image.jpg',
context: FileContext.agents,
type: 'image/jpeg',
source: FileSources.local,
width: 800,
height: 600,
}),
true,
);
expect(mockAddAgentResourceFile).toHaveBeenCalledWith(
expect.objectContaining({
agent_id: 'test-agent-id',
file_id: 'test-file-id',
tool_resource: EToolResources.image_edit,
req: mockReq,
}),
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Agent file uploaded and processed successfully',
}),
);
});
it('should process file_search tool resource with dual storage (file + vector)', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('test file data'),
mimetype: 'application/pdf',
size: 2048,
originalname: 'test-document.pdf',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
agent_id: 'test-agent-id',
tool_resource: EToolResources.file_search,
file_id: 'test-file-id',
};
await processAgentFileUpload({ req: mockReq, res: mockRes, metadata });
expect(mockGetFileStrategy).toHaveBeenCalledWith(mockReq.config, { isImage: false });
expect(mockHandleFileUpload).toHaveBeenCalledWith({
req: mockReq,
file: mockReq.file,
file_id: 'test-file-id',
basePath: 'uploads',
entity_id: 'test-agent-id',
});
expect(mockUploadVectors).toHaveBeenCalledWith({
req: mockReq,
file: mockReq.file,
file_id: 'test-file-id',
entity_id: 'test-agent-id',
});
expect(mockCreateFile).toHaveBeenCalledWith(
expect.objectContaining({
user: 'test-user-id',
file_id: 'test-file-id',
filename: 'test-document.pdf',
context: FileContext.agents,
type: 'application/pdf',
source: FileSources.local,
bytes: 2048,
filepath: '/uploads/local/test-file-id',
metadata: {},
}),
true,
);
expect(mockAddAgentResourceFile).toHaveBeenCalledWith(
expect.objectContaining({
agent_id: 'test-agent-id',
file_id: 'test-file-id',
tool_resource: EToolResources.file_search,
req: mockReq,
}),
);
});
it('should handle missing tool_resource parameter', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('test file data'),
mimetype: 'application/pdf',
size: 2048,
originalname: 'test-document.pdf',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
agent_id: 'test-agent-id',
file_id: 'test-file-id',
};
await expect(
processAgentFileUpload({ req: mockReq, res: mockRes, metadata }),
).rejects.toThrow('No tool resource provided for agent file upload');
});
it('should handle missing agent_id parameter', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('test file data'),
mimetype: 'application/pdf',
size: 2048,
originalname: 'test-document.pdf',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
tool_resource: EToolResources.file_search,
file_id: 'test-file-id',
};
await expect(
processAgentFileUpload({ req: mockReq, res: mockRes, metadata }),
).rejects.toThrow('No agent ID provided for agent file upload');
});
it('should handle image uploads for non-image tool resources', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('test image data'),
mimetype: 'image/jpeg',
size: 1024,
originalname: 'test-image.jpg',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
agent_id: 'test-agent-id',
tool_resource: EToolResources.file_search,
file_id: 'test-file-id',
};
await expect(
processAgentFileUpload({ req: mockReq, res: mockRes, metadata }),
).rejects.toThrow('Image uploads are not supported for file search tool resources');
});
it('should check execute_code capability and load auth values when processing code files', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('print("hello world")'),
mimetype: 'text/x-python',
size: 20,
originalname: 'test.py',
path: '/tmp/test-file.py',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
agent_id: 'test-agent-id',
tool_resource: EToolResources.execute_code,
file_id: 'test-file-id',
};
const mockLoadAuthValues = require('~/server/services/Tools/credentials').loadAuthValues;
mockLoadAuthValues.mockResolvedValue({ CODE_API_KEY: 'test-key' });
await processAgentFileUpload({ req: mockReq, res: mockRes, metadata });
expect(mockCheckCapability).toHaveBeenCalledWith(mockReq, 'execute_code');
expect(mockLoadAuthValues).toHaveBeenCalledWith({
userId: 'test-user-id',
authFields: ['LIBRECHAT_CODE_API_KEY'],
});
expect(mockHandleFileUpload).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
req: mockReq,
stream: 'mock-stream',
filename: 'test.py',
entity_id: 'test-agent-id',
apiKey: undefined,
}),
);
expect(mockHandleFileUpload).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
req: mockReq,
file: mockReq.file,
file_id: 'test-file-id',
basePath: 'uploads',
entity_id: 'test-agent-id',
}),
);
expect(mockAddAgentResourceFile).toHaveBeenCalledWith(
expect.objectContaining({
agent_id: 'test-agent-id',
file_id: 'test-file-id',
tool_resource: EToolResources.execute_code,
req: mockReq,
}),
);
});
it('should throw error when example capability (execute_code) is not enabled', async () => {
const mockReq = {
user: { id: 'test-user-id' },
file: {
buffer: Buffer.from('print("hello world")'),
mimetype: 'text/x-python',
size: 20,
originalname: 'test.py',
},
body: {
file_id: 'test-file-id',
},
config: {
fileStrategy: 'local',
fileStrategies: {
agents: 'local',
},
imageOutputType: 'jpeg',
},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const metadata = {
agent_id: 'test-agent-id',
tool_resource: EToolResources.execute_code,
file_id: 'test-file-id',
};
mockCheckCapability.mockResolvedValueOnce(false);
await expect(
processAgentFileUpload({ req: mockReq, res: mockRes, metadata }),
).rejects.toThrow('Code execution is not enabled for Agents');
expect(mockCheckCapability).toHaveBeenCalledWith(mockReq, 'execute_code');
expect(mockHandleFileUpload).not.toHaveBeenCalled();
expect(mockCreateFile).not.toHaveBeenCalled();
expect(mockAddAgentResourceFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -20,8 +20,8 @@ const {
ContentTypes,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
const { findToken, createToken, updateToken } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, getAppConfig } = require('./Config');
const { reinitMCPServer } = require('./Tools/mcp');
const { getLogStores } = require('~/cache');
@@ -538,20 +538,13 @@ async function getServerConnectionStatus(
const baseConnectionState = getConnectionState();
let finalConnectionState = baseConnectionState;
// connection state overrides specific to OAuth servers
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
// check if server is actively being reconnected
const oauthReconnectionManager = getOAuthReconnectionManager();
if (oauthReconnectionManager.isReconnecting(userId, serverName)) {
finalConnectionState = 'connecting';
} else {
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
finalConnectionState = 'error';
} else if (hasActiveFlow) {
finalConnectionState = 'connecting';
}
if (hasFailedFlow) {
finalConnectionState = 'error';
} else if (hasActiveFlow) {
finalConnectionState = 'connecting';
}
}

View File

@@ -31,7 +31,6 @@ jest.mock('./Config', () => ({
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getOAuthReconnectionManager: jest.fn(),
}));
jest.mock('~/cache', () => ({
@@ -49,7 +48,6 @@ describe('tests for the new helper functions used by the MCP connection status e
let mockGetMCPManager;
let mockGetFlowStateManager;
let mockGetLogStores;
let mockGetOAuthReconnectionManager;
beforeEach(() => {
jest.clearAllMocks();
@@ -58,7 +56,6 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetMCPManager = require('~/config').getMCPManager;
mockGetFlowStateManager = require('~/config').getFlowStateManager;
mockGetLogStores = require('~/cache').getLogStores;
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
});
describe('getMCPSetupData', () => {
@@ -357,12 +354,6 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
@@ -379,12 +370,6 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
// Mock flow state to return failed flow
const mockFlowManager = {
getFlowState: jest.fn(() => ({
@@ -416,12 +401,6 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
// Mock flow state to return active flow
const mockFlowManager = {
getFlowState: jest.fn(() => ({
@@ -453,12 +432,6 @@ describe('tests for the new helper functions used by the MCP connection status e
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => false),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
// Mock flow state to return no flow
const mockFlowManager = {
getFlowState: jest.fn(() => null),
@@ -481,35 +454,6 @@ describe('tests for the new helper functions used by the MCP connection status e
});
});
it('should return connecting state when OAuth server is reconnecting', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock OAuthReconnectionManager to return true for isReconnecting
const mockOAuthReconnectionManager = {
isReconnecting: jest.fn(() => true),
};
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connecting',
});
expect(mockOAuthReconnectionManager.isReconnecting).toHaveBeenCalledWith(
mockUserId,
mockServerName,
);
});
it('should not check OAuth flow status when server is connected', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),

View File

@@ -313,7 +313,7 @@ const ensurePrincipalExists = async function (principal) {
idOnTheSource: principal.idOnTheSource,
};
const userId = await createUser(userData, true, true);
const userId = await createUser(userData, true, false);
return userId.toString();
}

View File

@@ -1,26 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { createOAuthReconnectionManager, getFlowStateManager } = require('~/config');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { getLogStores } = require('~/cache');
/**
* Initialize OAuth reconnect manager
*/
async function initializeOAuthReconnectManager() {
try {
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const tokenMethods = {
findToken,
updateToken,
createToken,
deleteTokens,
};
await createOAuthReconnectionManager(flowManager, tokenMethods);
logger.info(`OAuth reconnect manager initialized successfully.`);
} catch (error) {
logger.error('Failed to initialize OAuth reconnect manager:', error);
}
}
module.exports = initializeOAuthReconnectManager;

View File

@@ -1,14 +1,14 @@
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
const facebookLogin = require('./facebookStrategy');
const discordLogin = require('./discordStrategy');
const appleLogin = require('./appleStrategy');
const passportLogin = require('./localStrategy');
const googleLogin = require('./googleStrategy');
const githubLogin = require('./githubStrategy');
const { setupSaml } = require('./samlStrategy');
const appleLogin = require('./appleStrategy');
const ldapLogin = require('./ldapStrategy');
const discordLogin = require('./discordStrategy');
const facebookLogin = require('./facebookStrategy');
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const jwtLogin = require('./jwtStrategy');
const ldapLogin = require('./ldapStrategy');
const { setupSaml } = require('./samlStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
module.exports = {
appleLogin,

View File

@@ -281,221 +281,6 @@ function convertToUsername(input, defaultValue = '') {
return defaultValue;
}
/**
* Process OpenID authentication tokenset and userinfo
* This is the core logic extracted from the passport strategy callback
* Can be reused by both the passport strategy and proxy authentication
*
* @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc.
* @param {boolean} existingUsersOnly - If true, only existing users will be processed
* @returns {Promise<Object>} The authenticated user object with tokenset
*/
async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
const claims = tokenset.claims ? tokenset.claims() : tokenset;
const userinfo = {
...claims,
};
// Get userinfo from provider if we have access_token and haven't already
if (tokenset.access_token) {
const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub);
Object.assign(userinfo, providerUserinfo);
}
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Auth] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
);
throw new Error('Email domain not allowed');
}
const result = await findOpenIDUser({
openidId: claims.sub || userinfo.sub,
email: claims.email || userinfo.email,
strategyName: 'openidStrategy',
findUser,
});
let user = result.user;
const error = result.error;
if (error) {
throw new Error(ErrorTypes.AUTH_FAILED);
}
const fullName = getFullName(userinfo);
/** Required role if configured */
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
if (requiredRole) {
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
let decodedToken = '';
if (requiredRoleTokenKind === 'access' && tokenset.access_token) {
decodedToken = jwtDecode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id' && tokenset.id_token) {
decodedToken = jwtDecode(tokenset.id_token);
} else if (userinfo.roles) {
// If roles are already in userinfo, use them directly
const roles = Array.isArray(userinfo.roles) ? userinfo.roles : [userinfo.roles];
if (!roles.includes(requiredRole)) {
throw new Error(`You must have the "${requiredRole}" role to log in.`);
}
} else if (requiredRoleParameterPath) {
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
return o[key];
}, decodedToken);
if (!found) {
logger.error(
`[OpenID Auth] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
}
if (!roles.includes(requiredRole)) {
throw new Error(`You must have the "${requiredRole}" role to log in.`);
}
}
}
let username = '';
if (process.env.OPENID_USERNAME_CLAIM) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
} else {
username = convertToUsername(
userinfo.preferred_username || userinfo.username || userinfo.email,
);
}
if (existingUsersOnly && !user) {
throw new Error('User does not exist');
}
if (!user) {
user = {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
};
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
}
// Handle avatar
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
const imageUrl = userinfo.picture;
let fileName;
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
tokenset.access_token,
userinfo.sub,
);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
logger.info(
`[OpenID Auth] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username}`,
{
user: {
openidId: user.openidId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
return { ...user, tokenset };
}
/**
* @param {boolean | undefined} [existingUsersOnly]
*/
function createOpenIDCallback(existingUsersOnly) {
return async (tokenset, done) => {
try {
const user = await processOpenIDAuth(tokenset, existingUsersOnly);
done(null, user);
} catch (err) {
if (err.message === 'Email domain not allowed') {
return done(null, false, { message: err.message });
}
if (err.message === ErrorTypes.AUTH_FAILED) {
return done(null, false, { message: err.message });
}
if (err.message && err.message.includes('role to log in')) {
return done(null, false, { message: err.message });
}
logger.error('[openidStrategy] login failed', err);
done(err);
}
};
}
/**
* Sets up the OpenID strategy specifically for admin authentication.
* @param {Configuration} openidConfig
*/
const setupOpenIdAdmin = (openidConfig) => {
try {
if (!openidConfig) {
throw new Error('OpenID configuration not initialized');
}
const openidAdminLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
scope: process.env.OPENID_SCOPE,
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback',
},
createOpenIDCallback(true),
);
passport.use('openidAdmin', openidAdminLogin);
} catch (err) {
logger.error('[openidStrategy] setupOpenIdAdmin', err);
}
};
/**
* Sets up the OpenID strategy for authentication.
* This function configures the OpenID client, handles proxy settings,
@@ -533,6 +318,10 @@ async function setupOpenId() {
},
);
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
const usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
logger.info(`[openidStrategy] OpenID authentication configuration`, {
generateNonce: shouldGenerateNonce,
reason: shouldGenerateNonce
@@ -546,19 +335,159 @@ async function setupOpenId() {
scope: process.env.OPENID_SCOPE,
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
usePKCE,
},
async (tokenset, done) => {
try {
const claims = tokenset.claims();
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
const result = await findOpenIDUser({
openidId: claims.sub,
email: claims.email,
strategyName: 'openidStrategy',
findUser,
});
let user = result.user;
const error = result.error;
if (error) {
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullName = getFullName(userinfo);
if (requiredRole) {
let decodedToken = '';
if (requiredRoleTokenKind === 'access') {
decodedToken = jwtDecode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token);
}
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
return o[key];
}, decodedToken);
if (!found) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
}
if (!roles.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
});
}
}
let username = '';
if (process.env.OPENID_USERNAME_CLAIM) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
} else {
username = convertToUsername(
userinfo.preferred_username || userinfo.username || userinfo.email,
);
}
if (!user) {
user = {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
};
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
tokenset.access_token,
userinfo.sub,
);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
user: {
openidId: user.openidId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
done(null, { ...user, tokenset });
} catch (err) {
logger.error('[openidStrategy] login failed', err);
done(err);
}
},
createOpenIDCallback(),
);
passport.use('openid', openidLogin);
setupOpenIdAdmin(openidConfig);
return openidConfig;
} catch (err) {
logger.error('[openidStrategy]', err);
return null;
}
}
/**
* @function getOpenIdConfig
* @description Returns the OpenID client instance.

View File

@@ -873,13 +873,6 @@
* @typedef {import('@librechat/data-schemas').IMongoFile} MongoFile
* @memberof typedefs
*/
/**
* @exports ISession
* @typedef {import('@librechat/data-schemas').ISession} ISession
* @memberof typedefs
*/
/**
* @exports IBalance
* @typedef {import('@librechat/data-schemas').IBalance} IBalance

View File

@@ -1,4 +1,3 @@
/** v0.8.0-rc4 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
@@ -29,8 +28,7 @@ module.exports = {
'jest-file-loader',
'^test/(.*)$': '<rootDir>/test/$1',
'^~/(.*)$': '<rootDir>/src/$1',
'^librechat-data-provider/react-query$':
'<rootDir>/../node_modules/librechat-data-provider/src/react-query',
'^librechat-data-provider/react-query$': '<rootDir>/../node_modules/librechat-data-provider/src/react-query',
},
restoreMocks: true,
testResultsProcessor: 'jest-junit',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0-rc4",
"version": "v0.8.0-rc3",
"description": "",
"type": "module",
"scripts": {

View File

@@ -1,15 +1,10 @@
import { createContext, useContext } from 'react';
type MessageContext = {
messageId: string;
nextType?: string;
partIndex?: number;
isExpanded: boolean;
conversationId?: string | null;
/** Submission state for cursor display - only true for latest message when submitting */
isSubmitting?: boolean;
/** Whether this is the latest message in the conversation */
isLatestMessage?: boolean;
};
export const MessageContext = createContext<MessageContext>({} as MessageContext);

View File

@@ -1,150 +0,0 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useAddedChatContext } from './AddedChatContext';
import { useChatContext } from './ChatContext';
interface MessagesViewContextValue {
/** Core conversation data */
conversation: ReturnType<typeof useChatContext>['conversation'];
conversationId: string | null | undefined;
/** Submission and control states */
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
isSubmittingFamily: boolean;
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
/** Message operations */
ask: ReturnType<typeof useChatContext>['ask'];
regenerate: ReturnType<typeof useChatContext>['regenerate'];
handleContinue: ReturnType<typeof useChatContext>['handleContinue'];
/** Message state management */
index: ReturnType<typeof useChatContext>['index'];
latestMessage: ReturnType<typeof useChatContext>['latestMessage'];
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
getMessages: ReturnType<typeof useChatContext>['getMessages'];
setMessages: ReturnType<typeof useChatContext>['setMessages'];
}
const MessagesViewContext = createContext<MessagesViewContextValue | undefined>(undefined);
export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
const chatContext = useChatContext();
const addedChatContext = useAddedChatContext();
const {
ask,
index,
regenerate,
isSubmitting: isSubmittingRoot,
conversation,
latestMessage,
setAbortScroll,
handleContinue,
setLatestMessage,
abortScroll,
getMessages,
setMessages,
} = chatContext;
const { isSubmitting: isSubmittingAdditional } = addedChatContext;
/** Memoize conversation-related values */
const conversationValues = useMemo(
() => ({
conversation,
conversationId: conversation?.conversationId,
}),
[conversation],
);
/** Memoize submission states */
const submissionStates = useMemo(
() => ({
isSubmitting: isSubmittingRoot,
isSubmittingFamily: isSubmittingRoot || isSubmittingAdditional,
abortScroll,
setAbortScroll,
}),
[isSubmittingRoot, isSubmittingAdditional, abortScroll, setAbortScroll],
);
/** Memoize message operations (these are typically stable references) */
const messageOperations = useMemo(
() => ({
ask,
regenerate,
getMessages,
setMessages,
handleContinue,
}),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
/** Memoize message state values */
const messageState = useMemo(
() => ({
index,
latestMessage,
setLatestMessage,
}),
[index, latestMessage, setLatestMessage],
);
/** Combine all values into final context value */
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
...conversationValues,
...submissionStates,
...messageOperations,
...messageState,
}),
[conversationValues, submissionStates, messageOperations, messageState],
);
return (
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
);
}
export function useMessagesViewContext() {
const context = useContext(MessagesViewContext);
if (!context) {
throw new Error('useMessagesViewContext must be used within MessagesViewProvider');
}
return context;
}
/** Hook for components that only need conversation data */
export function useMessagesConversation() {
const { conversation, conversationId } = useMessagesViewContext();
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
/** Hook for components that only need submission states */
export function useMessagesSubmission() {
const { isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll } =
useMessagesViewContext();
return useMemo(
() => ({ isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll }),
[isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll],
);
}
/** Hook for components that only need message operations */
export function useMessagesOperations() {
const { ask, regenerate, handleContinue, getMessages, setMessages } = useMessagesViewContext();
return useMemo(
() => ({ ask, regenerate, handleContinue, getMessages, setMessages }),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
}
/** Hook for components that only need message state */
export function useMessagesState() {
const { index, latestMessage, setLatestMessage } = useMessagesViewContext();
return useMemo(
() => ({ index, latestMessage, setLatestMessage }),
[index, latestMessage, setLatestMessage],
);
}

View File

@@ -26,5 +26,4 @@ export * from './SidePanelContext';
export * from './MCPPanelContext';
export * from './ArtifactsContext';
export * from './PromptGroupsContext';
export * from './MessagesViewContext';
export { default as BadgeRowProvider } from './BadgeRowContext';

View File

@@ -350,6 +350,7 @@ export type TAskProps = {
conversationId?: string | null;
messageId?: string | null;
clientTimestamp?: string;
toolResources?: t.AgentToolResources;
};
export type TOptions = {
@@ -642,3 +643,10 @@ declare global {
google_tag_manager?: unknown;
}
}
export type UIResource = {
uri: string;
mimeType: string;
text: string;
[key: string]: unknown;
};

View File

@@ -13,7 +13,7 @@ interface AgentGridProps {
category: string; // Currently selected category
searchQuery: string; // Current search query
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
scrollElementRef?: React.RefObject<HTMLElement>; // Parent scroll container ref for infinite scroll
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
}
/**
@@ -23,7 +23,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
category,
searchQuery,
onSelectAgent,
scrollElementRef,
scrollElement,
}) => {
const localize = useLocalize();
@@ -87,7 +87,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
// Set up infinite scroll
const { setScrollElement } = useInfiniteScroll({
hasNextPage,
isLoading: isFetching || isFetchingNextPage,
isFetchingNextPage,
fetchNextPage: () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
@@ -99,11 +99,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({
// Connect the scroll element when it's provided
useEffect(() => {
const scrollElement = scrollElementRef?.current;
if (scrollElement) {
setScrollElement(scrollElement);
}
}, [scrollElementRef, setScrollElement]);
}, [scrollElement, setScrollElement]);
/**
* Get category display name from API data or use fallback

View File

@@ -197,21 +197,21 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
*/
const handleSearch = (query: string) => {
const newParams = new URLSearchParams(searchParams);
const currentCategory = displayCategory;
if (query.trim()) {
newParams.set('q', query.trim());
// Switch to "all" category when starting a new search
navigate(`/agents/all?${newParams.toString()}`);
} else {
newParams.delete('q');
}
// Always preserve current category when searching or clearing search
if (currentCategory === 'promoted') {
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
} else {
navigate(
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
);
// Preserve current category when clearing search
const currentCategory = displayCategory;
if (currentCategory === 'promoted') {
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
} else {
navigate(
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
);
}
}
};
@@ -427,7 +427,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
category={displayCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElementRef={scrollContainerRef}
scrollElement={scrollContainerRef.current}
/>
</div>
@@ -507,7 +507,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElementRef={scrollContainerRef}
scrollElement={scrollContainerRef.current}
/>
</div>
)}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid';
import type t from 'librechat-data-provider';
@@ -82,115 +81,6 @@ import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
// Helper to create mock API response
const createMockResponse = (
agentIds: string[],
hasMore: boolean,
afterCursor?: string,
): t.AgentListResponse => ({
object: 'list',
data: agentIds.map(
(id) =>
({
id,
name: `Agent ${id}`,
description: `Description for ${id}`,
created_at: Date.now(),
model: 'gpt-4',
tools: [],
instructions: '',
avatar: null,
provider: 'openai',
model_parameters: {
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
maxContextTokens: 2000,
max_context_tokens: 2000,
max_output_tokens: 2000,
},
}) as t.Agent,
),
first_id: agentIds[0] || '',
last_id: agentIds[agentIds.length - 1] || '',
has_more: hasMore,
after: afterCursor,
});
// Helper to setup mock viewport
const setupViewport = (scrollHeight: number, clientHeight: number) => {
const listeners: { [key: string]: EventListener[] } = {};
return {
scrollHeight,
clientHeight,
scrollTop: 0,
addEventListener: jest.fn((event: string, listener: EventListener) => {
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(listener);
}),
removeEventListener: jest.fn((event: string, listener: EventListener) => {
if (listeners[event]) {
listeners[event] = listeners[event].filter((l) => l !== listener);
}
}),
dispatchEvent: jest.fn((event: Event) => {
const eventListeners = listeners[event.type];
if (eventListeners) {
eventListeners.forEach((listener) => listener(event));
}
return true;
}),
} as unknown as HTMLElement;
};
// Helper to create mock infinite query return value
const createMockInfiniteQuery = (
pages: t.AgentListResponse[],
options?: {
isLoading?: boolean;
hasNextPage?: boolean;
fetchNextPage?: jest.Mock;
isFetchingNextPage?: boolean;
},
) =>
({
data: {
pages,
pageParams: pages.map((_, i) => (i === 0 ? undefined : `cursor-${i * 6}`)),
},
isLoading: options?.isLoading ?? false,
error: null,
isFetching: false,
hasNextPage: options?.hasNextPage ?? pages[pages.length - 1]?.has_more ?? false,
isFetchingNextPage: options?.isFetchingNextPage ?? false,
fetchNextPage: options?.fetchNextPage ?? jest.fn(),
refetch: jest.fn(),
// Add missing required properties for UseInfiniteQueryResult
isError: false,
isLoadingError: false,
isRefetchError: false,
isSuccess: true,
status: 'success' as const,
dataUpdatedAt: Date.now(),
errorUpdateCount: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
fetchStatus: 'idle' as const,
isFetched: true,
isFetchedAfterMount: true,
isInitialLoading: false,
isPaused: false,
isPlaceholderData: false,
isPending: false,
isRefetching: false,
isStale: false,
remove: jest.fn(),
}) as any;
describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
const mockOnSelectAgent = jest.fn();
@@ -453,15 +343,6 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
});
describe('Infinite Scroll Functionality', () => {
beforeEach(() => {
// Silence console.log in tests
jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should show loading indicator when fetching next page', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
@@ -515,358 +396,5 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
});
describe('Auto-fetch to fill viewport', () => {
it('should NOT auto-fetch when viewport is filled (5 agents, has_more=false)', async () => {
const mockResponse = createMockResponse(['1', '2', '3', '4', '5'], false);
const fetchNextPage = jest.fn();
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(
createMockInfiniteQuery([mockResponse], { fetchNextPage }),
);
const scrollElement = setupViewport(500, 1000); // Content smaller than viewport
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial render
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(5);
});
// Wait to ensure no auto-fetch happens
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// fetchNextPage should NOT be called since has_more is false
expect(fetchNextPage).not.toHaveBeenCalled();
});
it('should auto-fetch when viewport not filled (7 agents, big viewport)', async () => {
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
const secondPage = createMockResponse(['7'], false);
let currentPages = [firstPage];
const fetchNextPage = jest.fn();
// Mock that updates pages when fetchNextPage is called
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
createMockInfiniteQuery(currentPages, {
fetchNextPage: jest.fn().mockImplementation(() => {
fetchNextPage();
currentPages = [firstPage, secondPage];
return Promise.resolve();
}),
hasNextPage: true,
}),
);
const scrollElement = setupViewport(400, 1200); // Large viewport (content < viewport)
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Wait for ResizeObserver and auto-fetch to trigger
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
// Auto-fetch should have been triggered (multiple times due to reliability checks)
expect(fetchNextPage).toHaveBeenCalled();
expect(fetchNextPage.mock.calls.length).toBeGreaterThanOrEqual(1);
// Update mock data and re-render
currentPages = [firstPage, secondPage];
rerender(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Should now show all 7 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(7);
});
});
it('should NOT auto-fetch when viewport is filled (7 agents, small viewport)', async () => {
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
const fetchNextPage = jest.fn();
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(
createMockInfiniteQuery([firstPage], { fetchNextPage, hasNextPage: true }),
);
const scrollElement = setupViewport(1200, 600); // Small viewport, content fills it
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Wait to ensure no auto-fetch happens
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// Should NOT auto-fetch since viewport is filled
expect(fetchNextPage).not.toHaveBeenCalled();
});
it('should auto-fetch once to fill viewport then stop (20 agents)', async () => {
const allPages = [
createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'),
createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12'),
createMockResponse(['13', '14', '15', '16', '17', '18'], true, 'cursor-18'),
createMockResponse(['19', '20'], false),
];
let currentPages = [allPages[0]];
let fetchCount = 0;
const fetchNextPage = jest.fn();
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
createMockInfiniteQuery(currentPages, {
fetchNextPage: jest.fn().mockImplementation(() => {
fetchCount++;
fetchNextPage();
if (currentPages.length < 2) {
currentPages = allPages.slice(0, 2);
}
return Promise.resolve();
}),
hasNextPage: currentPages.length < 2,
}),
);
const scrollElement = setupViewport(600, 1000); // Viewport fits ~12 agents
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Should auto-fetch to fill viewport
await waitFor(
() => {
expect(fetchNextPage).toHaveBeenCalledTimes(1);
},
{ timeout: 500 },
);
// Simulate viewport being filled after 12 agents
Object.defineProperty(scrollElement, 'scrollHeight', {
value: 1200,
writable: true,
configurable: true,
});
currentPages = allPages.slice(0, 2);
rerender(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Should show 12 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
});
// Wait to ensure no additional auto-fetch
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// Should only have fetched once (to fill viewport)
expect(fetchCount).toBe(1);
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
it('should auto-fetch when viewport resizes to be taller (window resize)', async () => {
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
const secondPage = createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12');
let currentPages = [firstPage];
const fetchNextPage = jest.fn();
let resizeObserverCallback: ResizeObserverCallback | null = null;
// Mock that updates pages when fetchNextPage is called
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
createMockInfiniteQuery(currentPages, {
fetchNextPage: jest.fn().mockImplementation(() => {
fetchNextPage();
if (currentPages.length === 1) {
currentPages = [firstPage, secondPage];
}
return Promise.resolve();
}),
hasNextPage: currentPages.length === 1,
}),
);
// Mock ResizeObserver to capture the callback
const ResizeObserverMock = jest.fn().mockImplementation((callback) => {
resizeObserverCallback = callback;
return {
observe: jest.fn(),
disconnect: jest.fn(),
unobserve: jest.fn(),
};
});
global.ResizeObserver = ResizeObserverMock as any;
// Start with a small viewport that fits the content
const scrollElement = setupViewport(800, 600);
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Verify ResizeObserver was set up
expect(ResizeObserverMock).toHaveBeenCalled();
expect(resizeObserverCallback).not.toBeNull();
// Initially no fetch should happen as viewport is filled
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(fetchNextPage).not.toHaveBeenCalled();
// Simulate window resize - make viewport taller
Object.defineProperty(scrollElement, 'clientHeight', {
value: 1200, // Now taller than content
writable: true,
configurable: true,
});
// Trigger ResizeObserver callback to simulate resize detection
act(() => {
if (resizeObserverCallback) {
resizeObserverCallback(
[
{
target: scrollElement,
contentRect: {
x: 0,
y: 0,
width: 800,
height: 1200,
top: 0,
right: 800,
bottom: 1200,
left: 0,
} as DOMRectReadOnly,
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
} as ResizeObserverEntry,
],
{} as ResizeObserver,
);
}
});
// Should trigger auto-fetch due to viewport now being larger than content
await waitFor(
() => {
expect(fetchNextPage).toHaveBeenCalledTimes(1);
},
{ timeout: 500 },
);
// Update the component with new data
rerender(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Should now show 12 agents after fetching
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
});
});
});
});
});

View File

@@ -3,7 +3,7 @@ import { AutoSizer, List } from 'react-virtualized';
import { Spinner, useCombobox } from '@librechat/client';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import type { TPromptGroup, AgentToolResources } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import { removeCharIfLast, detectVariables } from '~/utils';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
@@ -51,7 +51,7 @@ function PromptsCommand({
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
submitPrompt: (textPrompt: string) => void;
submitPrompt: (textPrompt: string, toolResources?: AgentToolResources) => void;
}) {
const localize = useLocalize();
const hasAccess = useHasAccess({
@@ -95,7 +95,6 @@ function PromptsCommand({
if (!group) {
return;
}
const hasVariables = detectVariables(group.productionPrompt?.prompt ?? '');
if (hasVariables) {
if (e && e.key === 'Tab') {
@@ -105,7 +104,7 @@ function PromptsCommand({
setVariableDialogOpen(true);
return;
} else {
submitPrompt(group.productionPrompt?.prompt ?? '');
submitPrompt(group.productionPrompt?.prompt ?? '', group.productionPrompt?.tool_resources);
}
},
[setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt],

View File

@@ -26,7 +26,6 @@ type ContentPartsProps = {
isCreatedByUser: boolean;
isLast: boolean;
isSubmitting: boolean;
isLatestMessage?: boolean;
edit?: boolean;
enterEdit?: (cancel?: boolean) => void | null | undefined;
siblingIdx?: number;
@@ -46,7 +45,6 @@ const ContentParts = memo(
isCreatedByUser,
isLast,
isSubmitting,
isLatestMessage,
edit,
enterEdit,
siblingIdx,
@@ -57,8 +55,6 @@ const ContentParts = memo(
const [isExpanded, setIsExpanded] = useState(showThinking);
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const hasReasoningParts = useMemo(() => {
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
const allThinkPartsHaveContent =
@@ -138,9 +134,7 @@ const ContentParts = memo(
})
}
label={
effectiveIsSubmitting && isLast
? localize('com_ui_thinking')
: localize('com_ui_thoughts')
isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts')
}
/>
</div>
@@ -161,14 +155,12 @@ const ContentParts = memo(
conversationId,
partIndex: idx,
nextType: content[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={attachments}
isSubmitting={effectiveIsSubmitting}
isSubmitting={isSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={idx === content.length - 1}

View File

@@ -4,7 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { TextareaAutosize, TooltipAnchor } from '@librechat/client';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import type { TEditProps } from '~/common';
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks';
import Container from './Container';
@@ -22,8 +22,7 @@ const EditMessage = ({
const { addedIndex } = useAddedChatContext();
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
const { conversation } = useMessagesConversation();
const { getMessages, setMessages } = useMessagesOperations();
const { getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);

View File

@@ -5,7 +5,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { TMessageContentProps, TDisplayProps } from '~/common';
import Error from '~/components/Messages/Content/Error';
import Thinking from '~/components/Artifacts/Thinking';
import { useMessageContext } from '~/Providers';
import { useChatContext } from '~/Providers';
import MarkdownLite from './MarkdownLite';
import EditMessage from './EditMessage';
import { useLocalize } from '~/hooks';
@@ -70,12 +70,16 @@ export const ErrorMessage = ({
};
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const { isSubmitting, latestMessage } = useChatContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(
() => showCursor === true && isSubmitting,
[showCursor, isSubmitting],
);
const isLatestMessage = useMemo(
() => message.messageId === latestMessage?.messageId,
[message.messageId, latestMessage?.messageId],
);
let content: React.ReactElement;
if (!isCreatedByUser) {

View File

@@ -85,14 +85,13 @@ 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
attachments={attachments}
isSubmitting={isSubmitting}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
attachments={attachments}
/>
);
} else if (

View File

@@ -6,8 +6,8 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { Agents } from 'librechat-data-provider';
import type { TEditProps } from '~/common';
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
import Container from '~/components/Chat/Messages/Content/Container';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -25,8 +25,7 @@ const EditTextPart = ({
}) => {
const localize = useLocalize();
const { addedIndex } = useAddedChatContext();
const { conversation } = useMessagesConversation();
const { ask, getMessages, setMessages } = useMessagesOperations();
const { ask, getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);

View File

@@ -45,28 +45,26 @@ export function useParseArgs(args?: string): ParsedArgs | null {
}
export default function ExecuteCode({
isSubmitting,
initialProgress = 0.1,
args,
output = '',
attachments,
}: {
initialProgress: number;
isSubmitting: boolean;
args?: string;
output?: string;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const hasOutput = output.length > 0;
const outputRef = useRef<string>(output);
const codeContentRef = useRef<HTMLDivElement>(null);
const [isAnimating, setIsAnimating] = useState(false);
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const codeContentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const hasOutput = output.length > 0;
const outputRef = useRef<string>(output);
const prevShowCodeRef = useRef<boolean>(showCode);
const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs);
const progress = useProgress(initialProgress);
@@ -138,8 +136,6 @@ export default function ExecuteCode({
};
}, [showCode, isAnimating]);
const cancelled = !isSubmitting && progress < 1;
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
@@ -147,12 +143,9 @@ export default function ExecuteCode({
progress={progress}
onClick={() => setShowCode((prev) => !prev)}
inProgressText={localize('com_ui_analyzing')}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
}
finishedText={localize('com_ui_analyzing_finished')}
hasInput={!!code?.length}
isExpanded={showCode}
error={cancelled}
/>
</div>
<div

View File

@@ -2,7 +2,7 @@ import { memo, useMemo, ReactElement } from 'react';
import { useRecoilValue } from 'recoil';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import Markdown from '~/components/Chat/Messages/Content/Markdown';
import { useMessageContext } from '~/Providers';
import { useChatContext, useMessageContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
@@ -18,9 +18,14 @@ type ContentType =
| ReactElement;
const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const { messageId } = useMessageContext();
const { isSubmitting, latestMessage } = useChatContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
const isLatestMessage = useMemo(
() => messageId === latestMessage?.messageId,
[messageId, latestMessage?.messageId],
);
const content: ContentType = useMemo(() => {
if (!isCreatedByUser) {

View File

@@ -211,7 +211,6 @@ export default function ToolCall({
domain={authDomain || (domain ?? '')}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
attachments={attachments}
/>
)}
</div>

View File

@@ -1,9 +1,8 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from './UIResourceCarousel';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import type { UIResource } from '~/common';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
return (
@@ -28,14 +27,12 @@ export default function ToolCallInfo({
domain,
function_name,
pendingAuth,
attachments,
}: {
input: string;
function_name: string;
output?: string | null;
domain?: string;
pendingAuth?: boolean;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const formatText = (text: string) => {
@@ -57,12 +54,25 @@ export default function ToolCallInfo({
: localize('com_assistants_attempt_info');
}
const uiResources: UIResource[] =
attachments
?.filter((attachment) => attachment.type === Tools.ui_resources)
.flatMap((attachment) => {
return attachment[Tools.ui_resources] as UIResource[];
}) ?? [];
// Extract ui_resources from the output to display them in the UI
let uiResources: UIResource[] = [];
if (output?.includes('ui_resources')) {
try {
const parsedOutput = JSON.parse(output);
const uiResourcesItem = parsedOutput.find(
(contentItem) => contentItem.metadata?.type === 'ui_resources',
);
if (uiResourcesItem?.metadata?.data) {
uiResources = uiResourcesItem.metadata.data;
output = JSON.stringify(
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
);
}
} catch (error) {
// If JSON parsing fails, keep original output
console.error('Failed to parse output:', error);
}
}
return (
<div className="w-full p-2">

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import type { UIResource } from '~/common';
import React, { useState } from 'react';
interface UIResourceCarouselProps {
uiResources: UIResource[];

View File

@@ -1,382 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
import ToolCall from '../ToolCall';
// Mock dependencies
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
const translations: Record<string, string> = {
com_assistants_function_use: `Used ${values?.[0]}`,
com_assistants_completed_function: `Completed ${values?.[0]}`,
com_assistants_completed_action: `Completed action on ${values?.[0]}`,
com_assistants_running_var: `Running ${values?.[0]}`,
com_assistants_running_action: 'Running action',
com_ui_sign_in_to_domain: `Sign in to ${values?.[0]}`,
com_ui_cancelled: 'Cancelled',
com_ui_requires_auth: 'Requires authentication',
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div data-testid="message-content">{content}</div>,
}));
jest.mock('../ToolCallInfo', () => ({
__esModule: true,
default: ({ attachments, ...props }: any) => (
<div data-testid="tool-call-info" data-attachments={JSON.stringify(attachments)}>
{JSON.stringify(props)}
</div>
),
}));
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
<div onClick={onClick}>{finishedText || inProgressText}</div>
),
}));
jest.mock('../Parts', () => ({
AttachmentGroup: ({ attachments }: any) => (
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
),
}));
jest.mock('~/components/ui', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span>{'ChevronDown'}</span>,
ChevronUp: () => <span>{'ChevronUp'}</span>,
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
}));
jest.mock('~/utils', () => ({
logger: {
error: jest.fn(),
},
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}));
describe('ToolCall', () => {
const mockProps = {
args: '{"test": "input"}',
name: 'testFunction',
output: 'Test output',
initialProgress: 1,
isSubmitting: false,
};
const renderWithRecoil = (component: React.ReactElement) => {
return render(<RecoilRoot>{component}</RecoilRoot>);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('attachments prop passing', () => {
it('should pass attachments to ToolCallInfo when provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Click me' },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
expect(toolCallInfo).toBeInTheDocument();
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBe(JSON.stringify(attachments));
});
it('should pass empty array when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBeNull(); // JSON.stringify(undefined) returns undefined, so attribute is not set
});
it('should pass multiple attachments of different types', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: {
'0': { type: 'form', fields: [] },
},
},
{
type: Tools.web_search,
messageId: 'msg2',
toolCallId: 'tool2',
conversationId: 'conv2',
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(attachments);
});
});
describe('attachment group rendering', () => {
it('should render AttachmentGroup when attachments are provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'chart', data: [] },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(attachmentGroup).toBeInTheDocument();
expect(attachmentGroup.textContent).toBe(JSON.stringify(attachments));
});
it('should not render AttachmentGroup when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
it('should not render AttachmentGroup when attachments is empty array', () => {
renderWithRecoil(<ToolCall {...mockProps} attachments={[]} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
});
describe('tool call info visibility', () => {
it('should toggle tool call info when clicking header', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
// Initially closed
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
// Click to open
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
// Click to close
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
});
it('should pass all required props to ToolCallInfo', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Test' },
},
},
];
// Use a name with domain separator (_action_) and domain separator (---)
const propsWithDomain = {
...mockProps,
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
attachments,
};
renderWithRecoil(<ToolCall {...propsWithDomain} />);
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('{"test": "input"}');
expect(props.output).toBe('Test output');
expect(props.function_name).toBe('testFunction');
// Domain is extracted from name and --- are replaced with dots
expect(props.domain).toBe('test.domain.com');
expect(props.pendingAuth).toBe(false);
});
});
describe('authentication flow', () => {
it('should show sign-in button when auth URL is provided', () => {
const originalOpen = window.open;
window.open = jest.fn();
renderWithRecoil(
<ToolCall
{...mockProps}
initialProgress={0.5} // Less than 1 so it's not complete
auth="https://auth.example.com"
isSubmitting={true} // Should be submitting for auth to show
/>,
);
const signInButton = screen.getByText('Sign in to auth.example.com');
expect(signInButton).toBeInTheDocument();
fireEvent.click(signInButton);
expect(window.open).toHaveBeenCalledWith(
'https://auth.example.com',
'_blank',
'noopener,noreferrer',
);
window.open = originalOpen;
});
it('should pass pendingAuth as true when auth is pending', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com" // Need auth URL to extract domain
initialProgress={0.5} // Less than 1
isSubmitting={true} // Still submitting
/>,
);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.pendingAuth).toBe(true);
});
it('should not show auth section when cancelled', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={0.5}
cancelled={true}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
it('should not show auth section when progress is complete', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={1}
cancelled={false}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('');
});
it('should handle null output', () => {
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.output).toBeNull();
});
it('should handle missing domain', () => {
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.domain).toBe('');
});
it('should handle complex nested attachments', () => {
const complexAttachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': {
type: 'nested',
data: {
deep: {
value: 'test',
array: [1, 2, 3],
object: { key: 'value' },
},
},
},
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(complexAttachments);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
});
});
});

View File

@@ -1,10 +1,8 @@
import React from 'react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import { render, screen } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
import ToolCallInfo from '../ToolCallInfo';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from '../UIResourceCarousel';
// Mock the dependencies
jest.mock('~/hooks', () => ({
@@ -48,25 +46,24 @@ describe('ToolCallInfo', () => {
jest.clearAllMocks();
});
describe('ui_resources from attachments', () => {
it('should render single ui_resource from attachments', () => {
describe('ui_resources extraction', () => {
it('should extract single ui_resource from output', () => {
const uiResource = {
type: 'text',
data: 'Test resource',
};
const attachments: TAttachment[] = [
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
metadata: {
type: 'ui_resources',
data: [uiResource],
},
},
];
]);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
render(<ToolCallInfo {...mockProps} output={output} />);
// Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith(
@@ -84,33 +81,29 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should render carousel for multiple ui_resources from attachments', () => {
// To test multiple resources, we can use a single attachment with multiple resources
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
},
it('should extract multiple ui_resources from output', () => {
const uiResources = [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
];
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: uiResources,
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
uiResources,
}),
expect.any(Object),
);
@@ -119,38 +112,34 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('should handle attachments with normal output', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
},
it('should filter out ui_resources from displayed output', () => {
const regularContent = [
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
];
const output = JSON.stringify([
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
...regularContent,
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]);
const { container } = render(
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
// Check that the output is displayed normally
// Check that the displayed output doesn't contain ui_resources
const codeBlocks = container.querySelectorAll('code');
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
expect(outputCode).toContain('Regular output 1');
expect(outputCode).toContain('Regular output 2');
// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
expect(outputCode).not.toContain('ui_resources');
});
it('should handle no attachments', () => {
it('should handle output without ui_resources', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
@@ -159,56 +148,66 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle empty attachments array', () => {
const attachments: TAttachment[] = [];
it('should handle malformed ui_resources gracefully', () => {
const output = JSON.stringify([
{
metadata: 'ui_resources', // metadata should be an object, not a string
text: 'some text content',
},
]);
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Component should not throw error and should render without UI resources
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
expect(UIResourceRenderer).not.toHaveBeenCalled();
// Should render the component without crashing
expect(container).toBeTruthy();
// UIResourceCarousel should not be called since the metadata structure is invalid
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle attachments with non-ui_resources type', () => {
const attachments: TAttachment[] = [
{
type: Tools.web_search as any,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.web_search]: {
organic: [],
},
},
];
it('should handle ui_resources as plain text without breaking', () => {
const outputWithTextOnly =
'This output contains ui_resources as plain text but not as a proper structure';
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
// Should not render UI resources components for non-ui_resources attachments
// Should render normally without errors
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
expect(screen.getByText('Result')).toBeInTheDocument();
// The output text should be displayed in a code block
const codeBlocks = screen.getAllByText((content, element) => {
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
});
expect(codeBlocks.length).toBeGreaterThan(0);
// Should not render UI resources components
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist in attachments', () => {
const attachments: TAttachment[] = [
it('should render UI Resources heading when ui_resources exist', () => {
const output = JSON.stringify([
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
},
];
]);
// Need output for ui_resources section to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument();
});
it('should not render UI Resources heading when no ui_resources in attachments', () => {
render(<ToolCallInfo {...mockProps} />);
it('should not render UI Resources heading when no ui_resources', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});
@@ -219,18 +218,16 @@ describe('ToolCallInfo', () => {
data: { fields: [{ name: 'test', type: 'text' }] },
};
const attachments: TAttachment[] = [
const output = JSON.stringify([
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
metadata: {
type: 'ui_resources',
data: [uiResource],
},
},
];
]);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
@@ -247,18 +244,16 @@ describe('ToolCallInfo', () => {
it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const attachments: TAttachment[] = [
const output = JSON.stringify([
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
},
];
]);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
render(<ToolCallInfo {...mockProps} output={output} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
@@ -275,55 +270,4 @@ describe('ToolCallInfo', () => {
consoleSpy.mockRestore();
});
});
describe('backward compatibility', () => {
it('should handle output with ui_resources for backward compatibility', () => {
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Since we now use attachments, ui_resources in output should be ignored
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should prioritize attachments over output ui_resources', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
},
];
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [{ type: 'output', data: 'From output' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} attachments={attachments} />);
// Should use attachments, not output
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
}),
expect.any(Object),
);
});
});
});

View File

@@ -1,8 +1,8 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import '@testing-library/jest-dom';
import UIResourceCarousel from '../UIResourceCarousel';
import type { UIResource } from '~/common';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({

View File

@@ -21,7 +21,7 @@ type THoverButtons = {
latestMessage: TMessage | null;
isLast: boolean;
index: number;
handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void;
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
};
type HoverButtonProps = {
@@ -238,7 +238,7 @@ const HoverButtons = ({
/>
{/* Feedback Buttons */}
{!isCreatedByUser && handleFeedback != null && (
{!isCreatedByUser && (
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
)}

View File

@@ -73,7 +73,7 @@ export default function Message(props: TMessageProps) {
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<MessageRender {...props} />
</div>
)}

View File

@@ -125,7 +125,6 @@ export default function Message(props: TMessageProps) {
setSiblingIdx={setSiblingIdx}
isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId}
isLatestMessage={messageId === latestMessage?.messageId}
content={message.content as Array<TMessageContentParts | undefined>}
/>
</div>

View File

@@ -4,12 +4,11 @@ import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { MessagesViewProvider } from '~/Providers';
import MultiMessage from './MultiMessage';
import { cn } from '~/utils';
import store from '~/store';
function MessagesViewContent({
export default function MessagesView({
messagesTree: _messagesTree,
}: {
messagesTree?: TMessage[] | null;
@@ -93,11 +92,3 @@ function MessagesViewContent({
</>
);
}
export default function MessagesView({ messagesTree }: { messagesTree?: TMessage[] | null }) {
return (
<MessagesViewProvider>
<MessagesViewContent messagesTree={messagesTree} />
</MessagesViewProvider>
);
}

View File

@@ -27,7 +27,7 @@ export default function MultiMessage({
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
setSiblingIdx(0);
}, [messagesTree?.length, setSiblingIdx]);
}, [messagesTree?.length]);
useEffect(() => {
if (messagesTree?.length && siblingIdx >= messagesTree.length) {

View File

@@ -71,9 +71,6 @@ const MessageRender = memo(
const showCardRender = isLast && !isSubmittingFamily && isCard;
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo(
() => ({
endpoint: msg?.endpoint ?? conversation?.endpoint,
@@ -169,8 +166,6 @@ const MessageRender = memo(
messageId: msg.messageId,
conversationId: conversation?.conversationId,
isExpanded: false,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
{msg.plugin && <Plugin plugin={msg.plugin} />}
@@ -182,7 +177,7 @@ const MessageRender = memo(
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={effectiveIsSubmitting}
isSubmitting={isSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
@@ -191,7 +186,7 @@ const MessageRender = memo(
</MessageContext.Provider>
</div>
{hasNoChildren && (isSubmittingFamily === true || effectiveIsSubmitting) ? (
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">

View File

@@ -1,5 +1,6 @@
import { useMemo, memo, type FC, useCallback } from 'react';
import throttle from 'lodash/throttle';
import { parseISO, isToday } from 'date-fns';
import { Spinner, useMediaQuery } from '@librechat/client';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { TConversation } from 'librechat-data-provider';
@@ -49,17 +50,27 @@ const MemoizedConvo = memo(
conversation,
retainView,
toggleNav,
isLatestConvo,
}: {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isLatestConvo: boolean;
}) => {
return <Convo conversation={conversation} retainView={retainView} toggleNav={toggleNav} />;
return (
<Convo
conversation={conversation}
retainView={retainView}
toggleNav={toggleNav}
isLatestConvo={isLatestConvo}
/>
);
},
(prevProps, nextProps) => {
return (
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
prevProps.conversation.title === nextProps.conversation.title &&
prevProps.isLatestConvo === nextProps.isLatestConvo &&
prevProps.conversation.endpoint === nextProps.conversation.endpoint
);
},
@@ -87,6 +98,13 @@ const Conversations: FC<ConversationsProps> = ({
[filteredConversations],
);
const firstTodayConvoId = useMemo(
() =>
filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt)))
?.conversationId ?? undefined,
[filteredConversations],
);
const flattenedItems = useMemo(() => {
const items: FlattenedItem[] = [];
groupedConversations.forEach(([groupName, convos]) => {
@@ -136,25 +154,26 @@ const Conversations: FC<ConversationsProps> = ({
</CellMeasurer>
);
}
let rendering: JSX.Element;
if (item.type === 'header') {
rendering = <DateLabel groupName={item.groupName} />;
} else if (item.type === 'convo') {
rendering = (
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
);
}
return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
{rendering}
{item.type === 'header' ? (
<DateLabel groupName={item.groupName} />
) : item.type === 'convo' ? (
<MemoizedConvo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
/>
) : null}
</div>
)}
</CellMeasurer>
);
},
[cache, flattenedItems, moveToTop, toggleNav],
[cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav],
);
const getRowHeight = useCallback(

View File

@@ -11,17 +11,23 @@ import { useGetEndpointsQuery } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { ConvoOptions } from './ConvoOptions';
import RenameForm from './RenameForm';
import { cn, logger } from '~/utils';
import ConvoLink from './ConvoLink';
import { cn } from '~/utils';
import store from '~/store';
interface ConversationProps {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isLatestConvo: boolean;
}
export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) {
export default function Conversation({
conversation,
retainView,
toggleNav,
isLatestConvo,
}: ConversationProps) {
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
@@ -78,7 +84,6 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
});
setRenaming(false);
} catch (error) {
logger.error('Error renaming conversation', error);
setTitleInput(title as string);
showToast({
message: localize('com_ui_rename_failed'),

View File

@@ -173,7 +173,6 @@ const ContentRender = memo(
isSubmitting={isSubmitting}
searchResults={searchResults}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}

View File

@@ -0,0 +1,166 @@
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo, useCallback } from 'react';
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { FileUpload, DropdownPopup, AttachmentIcon, SharePointIcon } from '@librechat/client';
import {
useLocalize,
useAgentCapabilities,
useGetAgentsConfig,
useSharePointFileHandling,
} from '~/hooks';
import { SharePointPickerDialog } from '~/components/SharePoint';
import { useGetStartupConfig } from '~/data-provider';
import { MenuItemProps } from '~/common';
interface AttachFileButtonProps {
handleFileChange?: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
disabled?: boolean | null;
}
const AttachFileButton = ({ handleFileChange, disabled }: AttachFileButtonProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
toolResource,
});
const { data: startupConfig } = useGetStartupConfig();
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const { agentsConfig } = useGetAgentsConfig();
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const handleUploadClick = useCallback((isImage?: boolean) => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.accept = isImage === true ? 'image/*' : '';
inputRef.current.click();
inputRef.current.accept = '';
}, []);
const dropdownItems = useMemo(() => {
const createMenuItems = (onAction: (isImage?: boolean) => void) => {
const items: MenuItemProps[] = [
{
label: localize('com_ui_upload_image_input'),
onClick: () => {
setToolResource(EToolResources.image_edit);
onAction(true);
},
icon: <ImageUpIcon className="icon-md" />,
},
];
if (capabilities.ocrEnabled) {
items.push({
label: localize('com_ui_upload_ocr_text'),
onClick: () => {
setToolResource(EToolResources.ocr);
onAction();
},
icon: <FileType2Icon className="icon-md" />,
});
}
if (capabilities.fileSearchEnabled) {
items.push({
label: localize('com_ui_upload_file_search'),
onClick: () => {
setToolResource(EToolResources.file_search);
onAction();
},
icon: <FileSearch className="icon-md" />,
});
}
if (capabilities.codeEnabled) {
items.push({
label: localize('com_ui_upload_code_files'),
onClick: () => {
setToolResource(EToolResources.execute_code);
onAction();
},
icon: <TerminalSquareIcon className="icon-md" />,
});
}
return items;
};
const localItems = createMenuItems(handleUploadClick);
if (sharePointEnabled) {
const sharePointItems = createMenuItems(() => {
setIsSharePointDialogOpen(true);
});
localItems.push({
label: localize('com_files_upload_sharepoint'),
onClick: () => {},
icon: <SharePointIcon className="icon-md" />,
subItems: sharePointItems,
});
return localItems;
}
return localItems;
}, [capabilities, localize, handleUploadClick, sharePointEnabled, setIsSharePointDialogOpen]);
const menuTrigger = (
<Ariakit.MenuButton
disabled={isUploadDisabled}
id="attach-file-button-menu"
aria-label="Attach File Options"
className="flex items-center gap-2 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50"
>
<AttachmentIcon className="h-4 w-4" />
{localize('com_ui_attach_files')}
</Ariakit.MenuButton>
);
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {
await handleSharePointFiles(sharePointFiles);
setIsSharePointDialogOpen(false);
} catch (error) {
console.error('SharePoint file processing error:', error);
}
};
return (
<>
<FileUpload
ref={inputRef}
handleFileChange={(e) => {
handleFileChange?.(e, toolResource);
}}
>
<DropdownPopup
menuId="attach-file-button"
className="overflow-visible"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
unmountOnHide={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</FileUpload>
<SharePointPickerDialog
isOpen={isSharePointDialogOpen}
onOpenChange={setIsSharePointDialogOpen}
onFilesSelected={handleSharePointFilesSelected}
isDownloading={isProcessing}
downloadProgress={downloadProgress}
/>
</>
);
};
export default React.memo(AttachFileButton);

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@librechat/client';
import { PermissionBits } from 'librechat-data-provider';
import { PermissionBits, ResourceType } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import { useLocalize, useSubmitMessage, useCustomLink, useResourcePermissions } from '~/hooks';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
@@ -34,9 +34,18 @@ function ChatGroupItem({
);
// Check permissions for the promptGroup
const { hasPermission } = useResourcePermissions('promptGroup', group._id || '');
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
const canEdit = hasPermission(PermissionBits.EDIT);
const hasFiles = useMemo(() => {
const toolResources = group.productionPrompt?.tool_resources;
if (!toolResources) return false;
return Object.values(toolResources).some(
(resource) => resource?.file_ids && resource.file_ids.length > 0,
);
}, [group.productionPrompt?.tool_resources]);
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
const text = group.productionPrompt?.prompt;
if (!text?.trim()) {
@@ -48,7 +57,7 @@ function ChatGroupItem({
return;
}
submitPrompt(text);
submitPrompt(text, group.productionPrompt?.tool_resources);
};
return (
@@ -57,6 +66,7 @@ function ChatGroupItem({
name={group.name}
category={group.category ?? ''}
onClick={onCardClick}
hasFiles={hasFiles}
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner

View File

@@ -3,11 +3,12 @@ import { useNavigate } from 'react-router-dom';
import { Button, TextareaAutosize, Input } from '@librechat/client';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { AgentToolResources } from 'librechat-data-provider';
import PromptVariablesAndFiles from '~/components/Prompts/PromptVariablesAndFiles';
import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
import { useLocalize, useHasAccess, usePromptFileHandling } from '~/hooks';
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
import PromptVariables from '~/components/Prompts/PromptVariables';
import Description from '~/components/Prompts/Description';
import { useLocalize, useHasAccess } from '~/hooks';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
@@ -19,6 +20,7 @@ type CreateFormValues = {
category: string;
oneliner?: string;
command?: string;
tool_resources?: AgentToolResources;
};
const defaultPrompt: CreateFormValues = {
@@ -37,6 +39,14 @@ const CreatePromptForm = ({
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const {
promptFiles: files,
setFiles,
handleFileChange,
getToolResources,
} = usePromptFileHandling();
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
@@ -88,8 +98,15 @@ const CreatePromptForm = ({
if ((command?.length ?? 0) > 0) {
groupData.command = command;
}
const promptData = { ...rest };
const toolResources = getToolResources();
if (toolResources) {
promptData.tool_resources = toolResources;
}
createPromptMutation.mutate({
prompt: rest,
prompt: promptData,
group: groupData,
});
};
@@ -161,7 +178,13 @@ const CreatePromptForm = ({
/>
</div>
</div>
<PromptVariables promptText={promptText} />
<PromptVariablesAndFiles
promptText={promptText}
files={files}
onFilesChange={setFiles}
handleFileChange={handleFileChange}
disabled={isSubmitting}
/>
<Description
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={0}

View File

@@ -93,7 +93,11 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 truncate pr-2">
<CategoryIcon category={group.category ?? ''} className="icon-lg" aria-hidden="true" />
<CategoryIcon
category={group.category ?? ''}
className="icon-lg flex-shrink-0"
aria-hidden="true"
/>
<Label className="text-md cursor-pointer truncate font-semibold text-text-primary">
{group.name}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Label } from '@librechat/client';
import { Paperclip } from 'lucide-react';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
export default function ListCard({
@@ -8,12 +9,14 @@ export default function ListCard({
snippet,
onClick,
children,
hasFiles,
}: {
category: string;
name: string;
snippet: string;
onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>;
children?: React.ReactNode;
hasFiles?: boolean;
}) {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement | HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -35,7 +38,7 @@ export default function ListCard({
>
<div className="flex w-full justify-between gap-2">
<div className="flex flex-row gap-2">
<CategoryIcon category={category} className="icon-md" aria-hidden="true" />
<CategoryIcon category={category} className="icon-md flex-shrink-0" aria-hidden="true" />
<Label
id={`card-title-${name}`}
className="break-word select-none text-balance text-sm font-semibold text-text-primary"
@@ -43,6 +46,7 @@ export default function ListCard({
>
{name}
</Label>
{hasFiles && <Paperclip className="icon-xs mt-1 flex-shrink-0 text-text-secondary" />}
</div>
<div>{children}</div>
</div>

View File

@@ -133,7 +133,7 @@ export default function VariableForm({
text = text.replace(regex, value);
});
submitPrompt(text);
submitPrompt(text, group.productionPrompt?.tool_resources);
onClose();
};

View File

@@ -7,9 +7,10 @@ import supersub from 'remark-supersub';
import { Label } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import type { TPromptGroup, AgentToolResources } from 'librechat-data-provider';
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
import { useLocalize, useAuthContext } from '~/hooks';
import PromptFilesPreview from './PromptFilesPreview';
import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import { PromptVariableGfm } from './Markdown';
@@ -25,6 +26,17 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
return replaceSpecialVars({ text: initialText, user });
}, [group?.productionPrompt?.prompt, user]);
const toolResources = useMemo((): AgentToolResources | undefined => {
return group?.productionPrompt?.tool_resources;
}, [group?.productionPrompt?.tool_resources]);
const hasFiles = useMemo(() => {
if (!toolResources) return false;
return Object.values(toolResources).some(
(resource) => resource?.file_ids && resource.file_ids.length > 0,
);
}, [toolResources]);
if (!group) {
return null;
}
@@ -72,6 +84,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
</div>
</div>
<PromptVariables promptText={mainText} showInfo={false} />
{hasFiles && toolResources && <PromptFilesPreview toolResources={toolResources} />}
<Description initialValue={group.oneliner} disabled={true} />
<Command initialValue={group.command} disabled={true} />
</div>

View File

@@ -0,0 +1,141 @@
import { useEffect } from 'react';
import { useToastContext } from '@librechat/client';
import type { ExtendedFile } from '~/common';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import Image from '~/components/Chat/Input/Files/Image';
import { useLocalize } from '~/hooks';
export default function PromptFile({
files: _files,
setFiles,
abortUpload,
setFilesLoading,
fileFilter,
isRTL = false,
Wrapper,
}: {
files: Map<string, ExtendedFile> | undefined;
abortUpload?: () => void;
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
fileFilter?: (file: ExtendedFile) => boolean;
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,
);
useEffect(() => {
if (files.length === 0) {
setFilesLoading(false);
return;
}
if (files.some((file) => file.progress < 1)) {
setFilesLoading(true);
return;
}
if (files.every((file) => file.progress === 1)) {
setFilesLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files]);
if (files.length === 0) {
return null;
}
const renderFiles = () => {
const rowStyle = isRTL
? {
display: 'flex',
flexDirection: 'row-reverse',
flexWrap: 'wrap',
gap: '4px',
width: '100%',
maxWidth: '100%',
}
: {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
width: '100%',
maxWidth: '100%',
};
return (
<div style={rowStyle as React.CSSProperties}>
{files
.reduce(
(acc, current) => {
if (!acc.map.has(current.file_id)) {
acc.map.set(current.file_id, true);
acc.uniqueFiles.push(current);
}
return acc;
},
{ map: new Map(), uniqueFiles: [] as ExtendedFile[] },
)
.uniqueFiles.map((file: ExtendedFile, index: number) => {
const handleDelete = () => {
showToast({
message: localize('com_ui_deleting_file'),
status: 'info',
});
if (abortUpload && file.progress < 1) {
abortUpload();
}
if (file.preview && file.preview.startsWith('blob:')) {
URL.revokeObjectURL(file.preview);
}
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.delete(file.file_id);
if (file.temp_file_id) {
updatedFiles.delete(file.temp_file_id);
}
return updatedFiles;
});
};
const isImage = file.type?.startsWith('image') ?? false;
return (
<div
key={index}
style={{
flexBasis: '70px',
flexGrow: 0,
flexShrink: 0,
}}
>
{isImage ? (
<Image
url={file.preview ?? file.filepath}
onDelete={handleDelete}
progress={file.progress}
source={file.source}
/>
) : (
<FileContainer file={file} onDelete={handleDelete} />
)}
</div>
);
})}
</div>
);
};
if (Wrapper) {
return <Wrapper>{renderFiles()}</Wrapper>;
}
return renderFiles();
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { FileText } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { Separator } from '@librechat/client';
import type { ExtendedFile } from '~/common';
import AttachFileButton from '~/components/Prompts/Files/AttachFileButton';
import PromptFile from '~/components/Prompts/PromptFile';
import { useLocalize } from '~/hooks';
const PromptFiles = ({
files,
onFilesChange,
handleFileChange,
disabled,
}: {
files: ExtendedFile[];
onFilesChange?: (files: ExtendedFile[]) => void;
handleFileChange?: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
disabled?: boolean;
}) => {
const localize = useLocalize();
const filesMap = useMemo(() => {
const map = new Map<string, ExtendedFile>();
files.forEach((file) => {
const key = file.file_id || file.temp_file_id || '';
if (key) {
map.set(key, file);
}
});
return map;
}, [files]);
return (
<div className="flex h-full flex-col rounded-xl border border-border-light bg-transparent p-4 shadow-md">
<h3 className="flex items-center gap-2 py-2 text-lg font-semibold text-text-primary">
<FileText className="icon-sm" aria-hidden="true" />
{localize('com_ui_files')}
</h3>
<div className="flex flex-1 flex-col space-y-4">
<div className="flex-1">
{!files.length && (
<>
<div className="text-sm text-text-secondary">
<ReactMarkdown className="markdown prose dark:prose-invert">
{localize('com_ui_files_info')}
</ReactMarkdown>
</div>
<Separator className="my-3 text-text-primary" />
</>
)}
{files.length > 0 && (
<div className="mb-3">
<PromptFile
files={filesMap}
setFiles={(newMapOrUpdater) => {
const newMap =
typeof newMapOrUpdater === 'function'
? newMapOrUpdater(filesMap)
: newMapOrUpdater;
const newFiles = Array.from(newMap.values()) as ExtendedFile[];
onFilesChange?.(newFiles);
}}
setFilesLoading={() => {}}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
/>
</div>
)}
</div>
<div className="flex justify-start text-text-secondary">
<AttachFileButton handleFileChange={handleFileChange} disabled={disabled} />
</div>
</div>
</div>
);
};
export default PromptFiles;

View File

@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { Paperclip, FileText, Image, FileType } from 'lucide-react';
import type { AgentToolResources } from 'librechat-data-provider';
import { useGetFiles } from '~/data-provider';
import { useLocalize } from '~/hooks';
interface PromptFilesPreviewProps {
toolResources: AgentToolResources;
}
const PromptFilesPreview: React.FC<PromptFilesPreviewProps> = ({ toolResources }) => {
const localize = useLocalize();
const { data: allFiles } = useGetFiles();
const fileMap = useMemo(() => {
const map: Record<string, any> = {};
if (Array.isArray(allFiles)) {
allFiles.forEach((file) => {
if (file.file_id) {
map[file.file_id] = file;
}
});
}
return map;
}, [allFiles]);
const attachedFiles = useMemo(() => {
const files: Array<{ file: any; toolResource: string }> = [];
Object.entries(toolResources).forEach(([toolResource, resource]) => {
if (resource?.file_ids) {
resource.file_ids.forEach((fileId) => {
const dbFile = fileMap[fileId];
if (dbFile) {
files.push({ file: dbFile, toolResource });
}
});
}
});
return files;
}, [toolResources, fileMap]);
const getFileIcon = (type: string) => {
if (type?.startsWith('image/')) {
return <Image className="h-4 w-4" />;
}
if (type?.includes('text') || type?.includes('document')) {
return <FileText className="h-4 w-4" />;
}
return <FileType className="h-4 w-4" />;
};
const getToolResourceLabel = (toolResource: string) => {
if (toolResource === 'file_search') {
return localize('com_ui_upload_file_search');
}
if (toolResource === 'execute_code') {
return localize('com_ui_upload_code_files');
}
if (toolResource === 'ocr') {
return localize('com_ui_upload_ocr_text');
}
if (toolResource === 'image_edit') {
return localize('com_ui_upload_image_input');
}
return toolResource;
};
if (attachedFiles.length === 0) {
return null;
}
return (
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
{localize('com_ui_files')} ({attachedFiles.length})
</div>
</h2>
<div className="rounded-b-lg border border-border-light p-4">
<div className="space-y-3">
{attachedFiles.map(({ file, toolResource }, index) => (
<div
key={`${file.file_id}-${index}`}
className="flex items-center justify-between rounded-lg border border-border-light p-3 transition-colors hover:bg-surface-tertiary"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-secondary text-text-secondary">
{getFileIcon(file.type)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-text-primary" title={file.filename}>
{file.filename}
</span>
<div className="flex items-center gap-2 text-xs text-text-secondary">
<span>{getToolResourceLabel(toolResource)}</span>
{file.bytes && (
<>
<span></span>
<span>{(file.bytes / 1024).toFixed(1)} KB</span>
</>
)}
</div>
</div>
</div>
{file.type?.startsWith('image/') && file.width && file.height && (
<div className="text-xs text-text-secondary">
{file.width} × {file.height}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
};
export default PromptFilesPreview;

View File

@@ -12,7 +12,13 @@ import {
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import type {
TCreatePrompt,
TPrompt,
TPromptGroup,
AgentToolResources,
} from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import {
useGetPrompts,
useGetPromptGroup,
@@ -20,11 +26,11 @@ import {
useUpdatePromptGroup,
useMakePromptProduction,
} from '~/data-provider';
import { useResourcePermissions, useHasAccess, useLocalize } from '~/hooks';
import { useResourcePermissions, useHasAccess, useLocalize, usePromptFileHandling } from '~/hooks';
import PromptVariablesAndFiles from './PromptVariablesAndFiles';
import CategorySelector from './Groups/CategorySelector';
import { usePromptGroupsContext } from '~/Providers';
import NoPromptGroup from './Groups/NoPromptGroup';
import PromptVariables from './PromptVariables';
import { cn, findPromptGroup } from '~/utils';
import PromptVersions from './PromptVersions';
import { PromptsEditorMode } from '~/common';
@@ -119,7 +125,12 @@ const RightPanel = React.memo(
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: { prompt },
productionPrompt: {
prompt,
...(selectedPrompt.tool_resources && {
tool_resources: selectedPrompt.tool_resources,
}),
},
});
}}
disabled={
@@ -179,6 +190,21 @@ const PromptForm = () => {
const [showSidePanel, setShowSidePanel] = useState(false);
const sidePanelWidth = '320px';
const {
loadFromToolResources,
getToolResources,
promptFiles: hookPromptFiles,
handleFileChange,
setFiles,
} = usePromptFileHandling({
onFileChange: (updatedFiles) => {
if (canEdit && selectedPrompt) {
const currentPromptText = getValues('prompt');
onSave(currentPromptText, updatedFiles);
}
},
});
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId);
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
{ groupId: promptId },
@@ -200,7 +226,7 @@ const PromptForm = () => {
category: group ? group.category : '',
},
});
const { handleSubmit, setValue, reset, watch } = methods;
const { handleSubmit, setValue, reset, watch, getValues } = methods;
const promptText = watch('prompt');
const selectedPrompt = useMemo(
@@ -237,7 +263,10 @@ const PromptForm = () => {
makeProductionMutation.mutate({
id: data.prompt._id,
groupId: data.prompt.groupId,
productionPrompt: { prompt: data.prompt.prompt },
productionPrompt: {
prompt: data.prompt.prompt,
...(data.prompt.tool_resources && { tool_resources: data.prompt.tool_resources }),
},
});
}
@@ -249,8 +278,30 @@ const PromptForm = () => {
},
});
const getToolResourcesFromFiles = useCallback((files: ExtendedFile[]) => {
if (files.length === 0) {
return undefined;
}
const toolResources: AgentToolResources = {};
files.forEach((file) => {
if (!file.file_id || !file.tool_resource) return; // Skip files that haven't been uploaded yet
if (!toolResources[file.tool_resource]) {
toolResources[file.tool_resource] = { file_ids: [] };
}
if (!toolResources[file.tool_resource]!.file_ids!.includes(file.file_id)) {
toolResources[file.tool_resource]!.file_ids!.push(file.file_id);
}
});
return Object.keys(toolResources).length > 0 ? toolResources : undefined;
}, []);
const onSave = useCallback(
(value: string) => {
(value: string, updatedFiles?: ExtendedFile[]) => {
if (!canEdit) {
return;
}
@@ -268,22 +319,36 @@ const PromptForm = () => {
return;
}
const toolResources = updatedFiles
? getToolResourcesFromFiles(updatedFiles)
: getToolResources();
const tempPrompt: TCreatePrompt = {
prompt: {
type: selectedPrompt.type ?? 'text',
groupId: groupId,
prompt: value,
...(toolResources && { tool_resources: toolResources }),
},
};
if (value === selectedPrompt.prompt) {
const promptTextChanged = value !== selectedPrompt.prompt;
const toolResourcesChanged =
JSON.stringify(toolResources) !== JSON.stringify(selectedPrompt.tool_resources);
if (!promptTextChanged && !toolResourcesChanged) {
return;
}
// We're adding to an existing group, so use the addPromptToGroup mutation
addPromptToGroupMutation.mutate({ ...tempPrompt, groupId });
},
[selectedPrompt, group, addPromptToGroupMutation, canEdit],
[
selectedPrompt,
group,
addPromptToGroupMutation,
canEdit,
getToolResources,
getToolResourcesFromFiles,
],
);
const handleLoadingComplete = useCallback(() => {
@@ -307,7 +372,13 @@ const PromptForm = () => {
useEffect(() => {
setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false });
setValue('category', group ? group.category : '', { shouldDirty: false });
}, [selectedPrompt, group, setValue]);
if (selectedPrompt?.tool_resources) {
loadFromToolResources(selectedPrompt.tool_resources);
} else {
loadFromToolResources(undefined);
}
}, [selectedPrompt, group, setValue, loadFromToolResources]);
useEffect(() => {
const handleResize = () => {
@@ -447,7 +518,19 @@ const PromptForm = () => {
isEditing={isEditing}
setIsEditing={(value) => canEdit && setIsEditing(value)}
/>
<PromptVariables promptText={promptText} />
<PromptVariablesAndFiles
promptText={promptText}
files={hookPromptFiles}
onFilesChange={(files) => {
setFiles(files);
if (canEdit && selectedPrompt) {
const currentPromptText = getValues('prompt');
onSave(currentPromptText, files);
}
}}
handleFileChange={handleFileChange}
disabled={!canEdit}
/>
<Description
initialValue={group.oneliner ?? ''}
onValueChange={canEdit ? handleUpdateOneliner : undefined}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import type { ExtendedFile } from '~/common';
import PromptVariables from './PromptVariables';
import PromptFiles from './PromptFiles';
interface PromptVariablesAndFilesProps {
promptText: string;
files?: ExtendedFile[];
onFilesChange?: (files: ExtendedFile[]) => void;
handleFileChange?: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
disabled?: boolean;
showVariablesInfo?: boolean;
}
const PromptVariablesAndFiles: React.FC<PromptVariablesAndFilesProps> = ({
promptText,
files = [],
onFilesChange,
handleFileChange,
disabled,
showVariablesInfo = true,
}) => {
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-stretch">
{/* Variables Section */}
<div className="w-full">
<PromptVariables promptText={promptText} showInfo={showVariablesInfo} />
</div>
{/* Files Section */}
<div className="w-full">
<PromptFiles
files={files}
onFilesChange={onFilesChange}
handleFileChange={handleFileChange}
disabled={disabled}
/>
</div>
</div>
);
};
export default PromptVariablesAndFiles;

View File

@@ -8,3 +8,5 @@ export { default as DashGroupItem } from './Groups/DashGroupItem';
export { default as EmptyPromptPreview } from './EmptyPromptPreview';
export { default as PromptSidePanel } from './Groups/GroupSidePanel';
export { default as CreatePromptForm } from './Groups/CreatePromptForm';
export { default as PromptVariablesAndFiles } from './PromptVariablesAndFiles';
export { default as PromptFiles } from './PromptFiles';

View File

@@ -76,8 +76,6 @@ export default function Message(props: TMessageProps) {
messageId,
isExpanded: false,
conversationId: conversation?.conversationId,
isSubmitting: false, // Share view is always read-only
isLatestMessage: false, // No concept of latest message in share view
}}
>
{/* Legacy Plugins */}

View File

@@ -4,6 +4,7 @@ import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
Checkbox,
@@ -13,18 +14,20 @@ import {
AccordionItem,
CircleHelpIcon,
OGDialogTrigger,
useToastContext,
AccordionContent,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const localize = useLocalize();
const { removeTool } = useRemoveMCPTool();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
@@ -53,6 +56,36 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
);
};
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentServerName;

View File

@@ -1,12 +1,25 @@
import React, { useState } from 'react';
import { CircleX } from 'lucide-react';
import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
import { useLocalize, useRemoveMCPTool } from '~/hooks';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
useToastContext,
OGDialogTrigger,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) {
const localize = useLocalize();
const { removeTool } = useRemoveMCPTool();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
@@ -15,6 +28,36 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
return null;
}
const removeTool = () => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
return (
<OGDialog>
<div
@@ -73,7 +116,7 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
</Label>
}
selection={{
selectHandler: () => removeTool(serverName || ''),
selectHandler: () => removeTool(),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),

View File

@@ -1,18 +1,29 @@
import React, { useState } from 'react';
import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
import type { MCPServerInfo } from '~/common';
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const localize = useLocalize();
const { removeTool } = useRemoveMCPTool();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { initializeServer, isInitializing, getServerStatusIconProps, getConfigDialogProps } =
useMCPServerManager();
@@ -20,6 +31,39 @@ export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPS
return null;
}
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
const serverName = serverInfo.serverName;
const isServerInitializing = isInitializing(serverName);
const statusIconProps = getServerStatusIconProps(serverName);

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { ChevronLeft, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button, useToastContext } from '@librechat/client';
@@ -12,8 +12,6 @@ import { useLocalize, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
const POLL_FOR_CONNECTION_STATUS_INTERVAL = 2_000; // ms
function MCPPanelContent() {
const localize = useLocalize();
const queryClient = useQueryClient();
@@ -28,29 +26,6 @@ function MCPPanelContent() {
null,
);
// Check if any connections are in 'connecting' state
const hasConnectingServers = useMemo(() => {
if (!connectionStatus) {
return false;
}
return Object.values(connectionStatus).some(
(status) => status?.connectionState === 'connecting',
);
}, [connectionStatus]);
// Set up polling when servers are connecting
useEffect(() => {
if (!hasConnectingServers) {
return;
}
const intervalId = setInterval(() => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
}, POLL_FOR_CONNECTION_STATUS_INTERVAL);
return () => clearInterval(intervalId);
}, [hasConnectingServers, queryClient]);
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: async () => {
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });

View File

@@ -118,6 +118,8 @@ export const useCreatePrompt = (
},
);
queryClient.invalidateQueries([QueryKeys.files]);
if (group) {
queryClient.setQueryData<t.PromptGroupListData>(
[QueryKeys.promptGroups, name, category, pageSize],
@@ -163,6 +165,8 @@ export const useAddPromptToGroup = (
},
);
queryClient.invalidateQueries([QueryKeys.files]);
if (onSuccess) {
onSuccess(response, variables, context);
}

View File

@@ -1,440 +0,0 @@
import { renderHook } from '@testing-library/react';
import { Tools, Constants } from 'librechat-data-provider';
import useAgentToolPermissions from '../useAgentToolPermissions';
// Mock dependencies
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(),
}));
jest.mock('~/Providers', () => ({
useAgentsMapContext: jest.fn(),
}));
// Import mocked functions after mocking
import { useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers';
describe('useAgentToolPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Ephemeral Agent Scenarios', () => {
it('should return true for all tools when agentId is null', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(null));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should return true for all tools when agentId is undefined', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(undefined));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should return true for all tools when agentId is empty string', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(''));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should return true for all tools when agentId is EPHEMERAL_AGENT_ID', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() =>
useAgentToolPermissions(Constants.EPHEMERAL_AGENT_ID)
);
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
});
describe('Regular Agent with Tools', () => {
it('should allow file_search when agent has the tool', () => {
const agentId = 'agent-123';
const mockAgent = {
id: agentId,
tools: [Tools.file_search, 'other_tool'],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([Tools.file_search, 'other_tool']);
});
it('should allow execute_code when agent has the tool', () => {
const agentId = 'agent-456';
const mockAgent = {
id: agentId,
tools: [Tools.execute_code, 'another_tool'],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toEqual([Tools.execute_code, 'another_tool']);
});
it('should allow both tools when agent has both', () => {
const agentId = 'agent-789';
const mockAgent = {
id: agentId,
tools: [Tools.file_search, Tools.execute_code, 'custom_tool'],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toEqual([Tools.file_search, Tools.execute_code, 'custom_tool']);
});
it('should disallow both tools when agent has neither', () => {
const agentId = 'agent-no-tools';
const mockAgent = {
id: agentId,
tools: ['custom_tool1', 'custom_tool2'],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual(['custom_tool1', 'custom_tool2']);
});
it('should handle agent with empty tools array', () => {
const agentId = 'agent-empty-tools';
const mockAgent = {
id: agentId,
tools: [],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([]);
});
it('should handle agent with undefined tools', () => {
const agentId = 'agent-undefined-tools';
const mockAgent = {
id: agentId,
tools: undefined,
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
});
describe('Agent Data from Query', () => {
it('should prioritize agentData tools over selectedAgent tools', () => {
const agentId = 'agent-with-query-data';
const mockAgent = {
id: agentId,
tools: ['old_tool'],
};
const mockAgentData = {
id: agentId,
tools: [Tools.file_search, Tools.execute_code],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: mockAgentData });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toEqual([Tools.file_search, Tools.execute_code]);
});
it('should fallback to selectedAgent tools when agentData has no tools', () => {
const agentId = 'agent-fallback';
const mockAgent = {
id: agentId,
tools: [Tools.file_search],
};
const mockAgentData = {
id: agentId,
tools: undefined,
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: mockAgentData });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([Tools.file_search]);
});
});
describe('Agent Not Found Scenarios', () => {
it('should disallow all tools when agent is not found in map', () => {
const agentId = 'non-existent-agent';
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should disallow all tools when agentsMap is null', () => {
const agentId = 'agent-with-null-map';
(useAgentsMapContext as jest.Mock).mockReturnValue(null);
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should disallow all tools when agentsMap is undefined', () => {
const agentId = 'agent-with-undefined-map';
(useAgentsMapContext as jest.Mock).mockReturnValue(undefined);
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
});
describe('Memoization and Performance', () => {
it('should memoize results when inputs do not change', () => {
const agentId = 'memoized-agent';
const mockAgent = {
id: agentId,
tools: [Tools.file_search],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result, rerender } = renderHook(() => useAgentToolPermissions(agentId));
const firstResult = result.current;
// Rerender without changing inputs
rerender();
const secondResult = result.current;
// The hook returns a new object each time, but the values should be equal
expect(firstResult.fileSearchAllowedByAgent).toBe(secondResult.fileSearchAllowedByAgent);
expect(firstResult.codeAllowedByAgent).toBe(secondResult.codeAllowedByAgent);
// Tools array reference should be the same since it comes from useMemo
expect(firstResult.tools).toBe(secondResult.tools);
// Verify the actual values are correct
expect(secondResult.fileSearchAllowedByAgent).toBe(true);
expect(secondResult.codeAllowedByAgent).toBe(false);
expect(secondResult.tools).toEqual([Tools.file_search]);
});
it('should recompute when agentId changes', () => {
const agentId1 = 'agent-1';
const agentId2 = 'agent-2';
const mockAgents = {
[agentId1]: { id: agentId1, tools: [Tools.file_search] },
[agentId2]: { id: agentId2, tools: [Tools.execute_code] },
};
(useAgentsMapContext as jest.Mock).mockReturnValue(mockAgents);
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result, rerender } = renderHook(
({ agentId }) => useAgentToolPermissions(agentId),
{ initialProps: { agentId: agentId1 } }
);
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
// Change agentId
rerender({ agentId: agentId2 });
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true);
});
it('should handle switching between ephemeral and regular agents', () => {
const regularAgentId = 'regular-agent';
const mockAgent = {
id: regularAgentId,
tools: [],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[regularAgentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result, rerender } = renderHook(
({ agentId }) => useAgentToolPermissions(agentId),
{ initialProps: { agentId: null } }
);
// Start with ephemeral agent (null)
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
// Switch to regular agent
rerender({ agentId: regularAgentId });
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
// Switch back to ephemeral
rerender({ agentId: '' });
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle agents with null tools gracefully', () => {
const agentId = 'agent-null-tools';
const mockAgent = {
id: agentId,
tools: null as any,
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeNull();
});
it('should handle whitespace-only agentId as ephemeral', () => {
// Note: Based on the current implementation, only empty string is treated as ephemeral
// Whitespace-only strings would be treated as regular agent IDs
const whitespaceId = ' ';
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(whitespaceId));
// Whitespace ID is not considered ephemeral in current implementation
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
});
it('should handle query loading state', () => {
const agentId = 'loading-agent';
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
});
const { result } = renderHook(() => useAgentToolPermissions(agentId));
// During loading, should return false for non-ephemeral agents
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should handle query error state', () => {
const agentId = 'error-agent';
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to fetch agent'),
});
const { result } = renderHook(() => useAgentToolPermissions(agentId));
// On error, should return false for non-ephemeral agents
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
});
});

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { Tools, Constants } from 'librechat-data-provider';
import { Tools } from 'librechat-data-provider';
import { useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers';
@@ -9,10 +9,6 @@ interface AgentToolPermissionsResult {
tools: string[] | undefined;
}
function isEphemeralAgent(agentId: string | null | undefined): boolean {
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
}
/**
* Hook to determine whether specific tools are allowed for a given agent.
*
@@ -37,8 +33,8 @@ export default function useAgentToolPermissions(
);
const fileSearchAllowedByAgent = useMemo(() => {
// Allow for ephemeral agents
if (isEphemeralAgent(agentId)) return true;
// If no agentId, allow for ephemeral agents
if (!agentId) return true;
// If agentId exists but agent not found, disallow
if (!selectedAgent) return false;
// Check if the agent has the file_search tool
@@ -46,8 +42,8 @@ export default function useAgentToolPermissions(
}, [agentId, selectedAgent, tools]);
const codeAllowedByAgent = useMemo(() => {
// Allow for ephemeral agents
if (isEphemeralAgent(agentId)) return true;
// If no agentId, allow for ephemeral agents
if (!agentId) return true;
// If agentId exists but agent not found, disallow
if (!selectedAgent) return false;
// Check if the agent has the execute_code tool

View File

@@ -79,6 +79,7 @@ export default function useChatFunctions({
parentMessageId = null,
conversationId = null,
messageId = null,
toolResources,
},
{
editedContent = null,
@@ -204,6 +205,7 @@ export default function useChatFunctions({
messageId: isContinued && messageId != null && messageId ? messageId : intermediateId,
thread_id,
error: false,
...(toolResources && { tool_resources: toolResources }),
};
const submissionFiles = overrideFiles ?? targetParentMessage?.files;

View File

@@ -1,495 +0,0 @@
import React from 'react';
import { Provider, createStore } from 'jotai';
import { renderHook, act, waitFor } from '@testing-library/react';
import { RecoilRoot, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { setTimestamp } from '~/utils/timestamps';
import { useMCPSelect } from '../useMCPSelect';
// Mock dependencies
jest.mock('~/utils/timestamps', () => ({
setTimestamp: jest.fn(),
}));
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
const createWrapper = () => {
// Create a new Jotai store for each test to ensure clean state
const store = createStore();
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<RecoilRoot>
<Provider store={store}>{children}</Provider>
</RecoilRoot>
);
return Wrapper;
};
describe('useMCPSelect', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
});
describe('Basic Functionality', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
expect(result.current.mcpValues).toEqual([]);
expect(result.current.isPinned).toBe(true); // Default value from mcpPinnedAtom is true
expect(typeof result.current.setMCPValues).toBe('function');
expect(typeof result.current.setIsPinned).toBe('function');
});
it('should use conversationId when provided', () => {
const conversationId = 'test-convo-123';
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
wrapper: createWrapper(),
});
expect(result.current.mcpValues).toEqual([]);
});
it('should use NEW_CONVO constant when conversationId is null', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: null }), {
wrapper: createWrapper(),
});
expect(result.current.mcpValues).toEqual([]);
});
});
describe('State Updates', () => {
it('should update mcpValues when setMCPValues is called', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
const newValues = ['value1', 'value2'];
act(() => {
result.current.setMCPValues(newValues);
});
await waitFor(() => {
expect(result.current.mcpValues).toEqual(newValues);
});
});
it('should not update mcpValues if non-array is passed', () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
act(() => {
// @ts-ignore - Testing invalid input
result.current.setMCPValues('not-an-array');
});
expect(result.current.mcpValues).toEqual([]);
});
it('should update isPinned state', () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
// Default is true
expect(result.current.isPinned).toBe(true);
// Toggle to false
act(() => {
result.current.setIsPinned(false);
});
expect(result.current.isPinned).toBe(false);
// Toggle back to true
act(() => {
result.current.setIsPinned(true);
});
expect(result.current.isPinned).toBe(true);
});
});
describe('Timestamp Management', () => {
it('should set timestamp when mcpValues is updated with values', async () => {
const conversationId = 'test-convo';
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
wrapper: createWrapper(),
});
const newValues = ['value1', 'value2'];
act(() => {
result.current.setMCPValues(newValues);
});
await waitFor(() => {
const expectedKey = `${LocalStorageKeys.LAST_MCP_}${conversationId}`;
expect(setTimestamp).toHaveBeenCalledWith(expectedKey);
});
});
it('should not set timestamp when mcpValues is empty', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
act(() => {
result.current.setMCPValues([]);
});
await waitFor(() => {
expect(setTimestamp).not.toHaveBeenCalled();
});
});
});
describe('Race Conditions and Infinite Loops Prevention', () => {
it('should not create infinite loop when syncing between Jotai and Recoil states', async () => {
const { result, rerender } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
let renderCount = 0;
const maxRenders = 10;
// Track renders to detect infinite loops
const trackRender = () => {
renderCount++;
if (renderCount > maxRenders) {
throw new Error('Potential infinite loop detected');
}
};
// Set initial value
act(() => {
trackRender();
result.current.setMCPValues(['initial']);
});
// Trigger multiple rerenders
for (let i = 0; i < 3; i++) {
rerender();
trackRender();
}
// Should not exceed max renders
expect(renderCount).toBeLessThanOrEqual(maxRenders);
});
it('should handle rapid consecutive updates without race conditions', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
const updates = [
['value1'],
['value1', 'value2'],
['value1', 'value2', 'value3'],
['value4'],
[],
];
// Rapid fire updates
act(() => {
updates.forEach((update) => {
result.current.setMCPValues(update);
});
});
await waitFor(() => {
// Should settle on the last update
expect(result.current.mcpValues).toEqual([]);
});
});
it('should maintain stable setter function reference', () => {
const { result, rerender } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
const firstSetMCPValues = result.current.setMCPValues;
// Trigger multiple rerenders
rerender();
rerender();
rerender();
// Setter should remain the same reference (memoized)
expect(result.current.setMCPValues).toBe(firstSetMCPValues);
});
it('should handle switching conversation IDs without issues', async () => {
const { result, rerender } = renderHook(
({ conversationId }) => useMCPSelect({ conversationId }),
{
wrapper: createWrapper(),
initialProps: { conversationId: 'convo1' },
},
);
// Set values for first conversation
act(() => {
result.current.setMCPValues(['convo1-value']);
});
await waitFor(() => {
expect(result.current.mcpValues).toEqual(['convo1-value']);
});
// Switch to different conversation
rerender({ conversationId: 'convo2' });
// Should have different state for new conversation
expect(result.current.mcpValues).toEqual([]);
// Set values for second conversation
act(() => {
result.current.setMCPValues(['convo2-value']);
});
await waitFor(() => {
expect(result.current.mcpValues).toEqual(['convo2-value']);
});
// Switch back to first conversation
rerender({ conversationId: 'convo1' });
// Should maintain separate state
await waitFor(() => {
expect(result.current.mcpValues).toEqual(['convo1-value']);
});
});
});
describe('Ephemeral Agent Synchronization', () => {
it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context
const wrapper = createWrapper();
// Create a component that uses both hooks to ensure they share state
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
ephemeralAgentByConvoId(Constants.NEW_CONVO),
);
return { mcpHook, ephemeralAgent, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
// Simulate external update to ephemeralAgent (e.g., from another component)
const externalMcpValues = ['external-value1', 'external-value2'];
act(() => {
result.current.setEphemeralAgent({
mcp: externalMcpValues,
});
});
// The hook should sync with the external update
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(externalMcpValues);
});
});
it('should update ephemeralAgent when mcpValues changes through hook', async () => {
// Create a shared wrapper for both hooks
const wrapper = createWrapper();
// Create a component that uses both the hook and accesses Recoil state
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, ephemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
const newValues = ['hook-value1', 'hook-value2'];
act(() => {
result.current.mcpHook.setMCPValues(newValues);
});
// Verify both mcpValues and ephemeralAgent are updated
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(newValues);
expect(result.current.ephemeralAgent?.mcp).toEqual(newValues);
});
});
it('should handle empty ephemeralAgent.mcp array correctly', async () => {
// Create a shared wrapper
const wrapper = createWrapper();
// Create a component that uses both hooks
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
// Set initial values
act(() => {
result.current.mcpHook.setMCPValues(['initial-value']);
});
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(['initial-value']);
});
// Try to set empty array externally
act(() => {
result.current.setEphemeralAgent({
mcp: [],
});
});
// Values should remain unchanged since empty mcp array doesn't trigger update
// (due to the condition: ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0)
expect(result.current.mcpHook.mcpValues).toEqual(['initial-value']);
});
it('should properly sync non-empty arrays from ephemeralAgent', async () => {
// Additional test to ensure non-empty arrays DO sync
const wrapper = createWrapper();
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
// Set initial values through ephemeralAgent with non-empty array
const initialValues = ['value1', 'value2'];
act(() => {
result.current.setEphemeralAgent({
mcp: initialValues,
});
});
// Should sync since it's non-empty
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(initialValues);
});
// Update with different non-empty values
const updatedValues = ['value3', 'value4', 'value5'];
act(() => {
result.current.setEphemeralAgent({
mcp: updatedValues,
});
});
// Should sync the new values
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(updatedValues);
});
});
});
describe('Edge Cases', () => {
it('should handle undefined conversationId', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), {
wrapper: createWrapper(),
});
expect(result.current.mcpValues).toEqual([]);
act(() => {
result.current.setMCPValues(['test']);
});
expect(() => result.current).not.toThrow();
});
it('should handle empty string conversationId', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: '' }), {
wrapper: createWrapper(),
});
expect(result.current.mcpValues).toEqual([]);
});
it('should handle very large arrays without performance issues', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
const startTime = performance.now();
act(() => {
result.current.setMCPValues(largeArray);
});
const endTime = performance.now();
const executionTime = endTime - startTime;
// Should complete within reasonable time (< 100ms)
expect(executionTime).toBeLessThan(100);
await waitFor(() => {
expect(result.current.mcpValues).toEqual(largeArray);
});
});
it('should cleanup properly on unmount', () => {
const { unmount } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
// Should unmount without errors
expect(() => unmount()).not.toThrow();
});
});
describe('Memory Leak Prevention', () => {
it('should not leak memory on repeated updates', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
// Perform many updates to test for memory leaks
for (let i = 0; i < 100; i++) {
act(() => {
result.current.setMCPValues([`value-${i}`]);
});
}
// If we get here without crashing, memory management is likely OK
expect(result.current.mcpValues).toEqual(['value-99']);
});
it('should handle component remounting', () => {
const { result, unmount } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
act(() => {
result.current.setMCPValues(['before-unmount']);
});
unmount();
// Remount
const { result: newResult } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
// Should handle remounting gracefully
expect(newResult.current.mcpValues).toBeDefined();
});
});
});

View File

@@ -3,4 +3,3 @@ export * from './useMCPConnectionStatus';
export * from './useMCPSelect';
export * from './useVisibleTools';
export { useMCPServerManager } from './useMCPServerManager';
export { useRemoveMCPTool } from './useRemoveMCPTool';

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect } from 'react';
import { useAtom } from 'jotai';
import isEqual from 'lodash/isEqual';
import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
@@ -20,14 +19,15 @@ export function useMCPSelect({ conversationId }: { conversationId?: string | nul
}
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
// Update ephemeral agent when Jotai state changes
useEffect(() => {
setEphemeralAgent((prev) => {
if (!isEqual(prev?.mcp, mcpValues)) {
return { ...(prev ?? {}), mcp: mcpValues };
}
return prev;
});
}, [mcpValues, setEphemeralAgent]);
if (mcpValues.length > 0 && JSON.stringify(mcpValues) !== JSON.stringify(ephemeralAgent?.mcp)) {
setEphemeralAgent((prev) => ({
...prev,
mcp: mcpValues,
}));
}
}, [mcpValues, ephemeralAgent?.mcp, setEphemeralAgent]);
useEffect(() => {
const mcpStorageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;

View File

@@ -1,61 +0,0 @@
import { useCallback } from 'react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
/**
* Hook for removing MCP tools/servers from an agent
* Provides unified logic for MCPTool, UninitializedMCPTool, and UnconfiguredMCPTool components
*/
export function useRemoveMCPTool() {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const removeTool = useCallback(
(serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds, { shouldDirty: true });
showToast({
message: localize('com_ui_delete_tool_save_reminder'),
status: 'warning',
});
},
},
);
},
[getValues, setValue, updateUserPlugins, showToast, localize],
);
return { removeTool };
}

View File

@@ -2,7 +2,7 @@ import throttle from 'lodash/throttle';
import { useEffect, useRef, useCallback, useMemo } from 'react';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import useCopyToClipboard from './useCopyToClipboard';
import { getTextKey, logger } from '~/utils';
@@ -20,9 +20,9 @@ export default function useMessageHelpers(props: TMessageProps) {
setAbortScroll,
handleContinue,
setLatestMessage,
} = useMessagesViewContext();
const agentsMap = useAgentsMapContext();
} = useChatContext();
const assistantMap = useAssistantsMapContext();
const agentsMap = useAgentsMapContext();
const { text, content, children, messageId = null, isCreatedByUser } = message ?? {};
const edit = messageId === currentEditId;

View File

@@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useMessagesViewContext } from '~/Providers';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { getTextKey, logger } from '~/utils';
import store from '~/store';
@@ -18,9 +18,14 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
latestMessage,
setAbortScroll,
setLatestMessage,
isSubmittingFamily,
} = useMessagesViewContext();
isSubmitting: isSubmittingRoot,
} = useChatContext();
const { isSubmitting: isSubmittingAdditional } = useAddedChatContext();
const latestMultiMessage = useRecoilValue(store.latestMessageFamily(index + 1));
const isSubmittingFamily = useMemo(
() => isSubmittingRoot || isSubmittingAdditional,
[isSubmittingRoot, isSubmittingAdditional],
);
useEffect(() => {
const convoId = conversation?.conversationId;

View File

@@ -2,8 +2,8 @@ import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { useState, useRef, useCallback, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useMessagesConversation, useMessagesSubmission } from '~/Providers';
import useScrollToRef from '~/hooks/useScrollToRef';
import { useChatContext } from '~/Providers';
import store from '~/store';
const threshold = 0.85;
@@ -15,8 +15,8 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const { conversation, conversationId } = useMessagesConversation();
const { setAbortScroll, isSubmitting, abortScroll } = useMessagesSubmission();
const { conversation, setAbortScroll, isSubmitting, abortScroll } = useChatContext();
const { conversationId } = conversation ?? {};
const timeoutIdRef = useRef<NodeJS.Timeout>();

View File

@@ -1,9 +1,13 @@
import { v4 } from 'uuid';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Constants, replaceSpecialVars } from 'librechat-data-provider';
import type { AgentToolResources, TFile } from 'librechat-data-provider';
import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers';
import useUpdateFiles from '~/hooks/Files/useUpdateFiles';
import { useAuthContext } from '~/hooks/AuthContext';
import { useGetFiles } from '~/data-provider';
import type { ExtendedFile } from '~/common';
import store from '~/store';
const appendIndex = (index: number, value?: string) => {
@@ -16,15 +20,67 @@ const appendIndex = (index: number, value?: string) => {
export default function useSubmitMessage() {
const { user } = useAuthContext();
const methods = useChatFormContext();
const { ask, index, getMessages, setMessages, latestMessage } = useChatContext();
const { ask, index, getMessages, setMessages, latestMessage, setFiles } = useChatContext();
const { addedIndex, ask: askAdditional, conversation: addedConvo } = useAddedChatContext();
const { data: allFiles = [] } = useGetFiles();
const { addFile } = useUpdateFiles(setFiles);
const autoSendPrompts = useRecoilValue(store.autoSendPrompts);
const activeConvos = useRecoilValue(store.allConversationsSelector);
const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index));
const fileMap = useMemo(() => {
const map: Record<string, TFile> = {};
if (Array.isArray(allFiles)) {
allFiles.forEach((file) => {
if (file.file_id) {
map[file.file_id] = file;
}
});
}
return map;
}, [allFiles]);
const convertToolResourcesToFiles = useCallback(
(toolResources: AgentToolResources): ExtendedFile[] => {
const promptFiles: ExtendedFile[] = [];
Object.entries(toolResources).forEach(([toolResource, resource]) => {
if (resource?.file_ids) {
resource.file_ids.forEach((fileId) => {
const dbFile = fileMap[fileId];
if (dbFile) {
const extendedFile = {
file_id: dbFile.file_id,
temp_file_id: dbFile.file_id,
filename: dbFile.filename,
filepath: dbFile.filepath,
type: dbFile.type,
size: dbFile.bytes,
width: dbFile.width,
height: dbFile.height,
progress: 1, // Already uploaded
attached: true,
tool_resource: toolResource,
preview: dbFile.type?.startsWith('image/') ? dbFile.filepath : undefined,
};
promptFiles.push(extendedFile);
} else {
console.warn(`File not found in fileMap: ${fileId}`);
}
});
} else {
console.warn(`No file_ids in resource "${toolResource}"`);
}
});
return promptFiles;
},
[fileMap],
);
const submitMessage = useCallback(
(data?: { text: string }) => {
(data?: { text: string; toolResources?: AgentToolResources; files?: ExtendedFile[] }) => {
if (!data) {
return console.warn('No data provided to submitMessage');
}
@@ -46,12 +102,18 @@ export default function useSubmitMessage() {
const rootIndex = addedIndex - 1;
const clientTimestamp = new Date().toISOString();
ask({
text: data.text,
overrideConvoId: appendIndex(rootIndex, overrideConvoId),
overrideUserMessageId: appendIndex(rootIndex, overrideUserMessageId),
clientTimestamp,
});
ask(
{
text: data.text,
overrideConvoId: appendIndex(rootIndex, overrideConvoId),
overrideUserMessageId: appendIndex(rootIndex, overrideUserMessageId),
clientTimestamp,
toolResources: data.toolResources,
},
{
overrideFiles: data.files,
},
);
if (hasAdded) {
askAdditional(
@@ -60,8 +122,12 @@ export default function useSubmitMessage() {
overrideConvoId: appendIndex(addedIndex, overrideConvoId),
overrideUserMessageId: appendIndex(addedIndex, overrideUserMessageId),
clientTimestamp,
toolResources: data.toolResources,
},
{
overrideMessages: rootMessages,
overrideFiles: data.files,
},
{ overrideMessages: rootMessages },
);
}
methods.reset();
@@ -80,18 +146,36 @@ export default function useSubmitMessage() {
);
const submitPrompt = useCallback(
(text: string) => {
(text: string, toolResources?: AgentToolResources) => {
const parsedText = replaceSpecialVars({ text, user });
if (autoSendPrompts) {
submitMessage({ text: parsedText });
const promptFiles = toolResources ? convertToolResourcesToFiles(toolResources) : [];
submitMessage({ text: parsedText, toolResources, files: promptFiles });
return;
}
if (toolResources) {
const promptFiles = convertToolResourcesToFiles(toolResources);
promptFiles.forEach((file, _index) => {
addFile(file);
});
}
const currentText = methods.getValues('text');
const newText = currentText.trim().length > 1 ? `\n${parsedText}` : parsedText;
setActivePrompt(newText);
},
[autoSendPrompts, submitMessage, setActivePrompt, methods, user],
[
autoSendPrompts,
submitMessage,
setActivePrompt,
methods,
user,
addFile,
convertToolResourcesToFiles,
],
);
return { submitMessage, submitPrompt };

View File

@@ -1,2 +1,3 @@
export { default as useCategories } from './useCategories';
export { default as usePromptGroupsNav } from './usePromptGroupsNav';
export { default as usePromptFileHandling } from './usePromptFileHandling';

View File

@@ -0,0 +1,398 @@
import { v4 } from 'uuid';
import { useToastContext } from '@librechat/client';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { EModelEndpoint, EToolResources, FileSources } from 'librechat-data-provider';
import type { AgentToolResources, TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useUploadFileMutation, useGetFiles } from '~/data-provider';
import { logger } from '~/utils';
interface UsePromptFileHandling {
fileSetter?: (files: ExtendedFile[]) => void;
initialFiles?: ExtendedFile[];
onFileChange?: (updatedFiles: ExtendedFile[]) => void;
}
export const usePromptFileHandling = (params?: UsePromptFileHandling) => {
const { showToast } = useToastContext();
const { data: allFiles = [] } = useGetFiles();
const fileMap = useMemo(() => {
const map: Record<string, TFile> = {};
if (Array.isArray(allFiles)) {
allFiles.forEach((file) => {
if (file.file_id) {
map[file.file_id] = file;
}
});
}
return map;
}, [allFiles]);
const [files, setFiles] = useState<ExtendedFile[]>(() => {
return params?.initialFiles || [];
});
const [, setFilesLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const uploadFile = useUploadFileMutation({
onSuccess: (data) => {
logger.log('File uploaded successfully', data);
setFiles((prev) => {
return prev.map((file) => {
if (file.temp_file_id === data.temp_file_id) {
return {
...file,
file_id: data.file_id,
filepath: data.filepath,
progress: 1,
attached: true,
preview: data.filepath || file.preview,
filename: data.filename || file.filename,
type: data.type || file.type,
size: data.bytes || file.size,
width: data.width || file.width,
height: data.height || file.height,
source: data.source || file.source,
};
}
return file;
});
});
setFilesLoading(false);
showToast({
message: 'File uploaded successfully',
status: 'success',
});
const updatedFiles = files.map((file) => {
if (file.temp_file_id === data.temp_file_id) {
return {
...file,
file_id: data.file_id,
filepath: data.filepath,
progress: 1,
attached: true,
preview: data.filepath || file.preview,
filename: data.filename || file.filename,
type: data.type || file.type,
size: data.bytes || file.size,
width: data.width || file.width,
height: data.height || file.height,
source: data.source || file.source,
};
}
return file;
});
params?.onFileChange?.(updatedFiles);
},
onError: (error, body) => {
logger.error('File upload error:', error);
setFilesLoading(false);
const file_id = body.get('file_id');
if (file_id) {
setFiles((prev) => {
return prev.filter((file) => {
if (file.file_id === file_id || file.temp_file_id === file_id) {
if (file.preview && file.preview.startsWith('blob:')) {
URL.revokeObjectURL(file.preview);
}
return false;
}
return true;
});
});
}
let errorMessage = 'Failed to upload file';
if ((error as any)?.response?.data?.message) {
errorMessage = (error as any).response.data.message;
} else if ((error as any)?.message) {
errorMessage = (error as any).message;
}
showToast({
message: errorMessage,
status: 'error',
});
},
});
const promptFiles = files;
useEffect(() => {
if (params?.fileSetter) {
params.fileSetter(files);
}
}, [files, params]);
const loadImage = useCallback(
(extendedFile: ExtendedFile, preview: string) => {
const img = new Image();
img.onload = async () => {
extendedFile.width = img.width;
extendedFile.height = img.height;
extendedFile.progress = 0.6;
const updatedFile = {
...extendedFile,
};
setFiles((prev) =>
prev.map((file) => (file.file_id === extendedFile.file_id ? updatedFile : file)),
);
const formData = new FormData();
formData.append('endpoint', EModelEndpoint.agents);
formData.append(
'file',
extendedFile.file!,
encodeURIComponent(extendedFile.filename || ''),
);
formData.append('file_id', extendedFile.file_id);
formData.append('message_file', 'true');
formData.append('width', img.width.toString());
formData.append('height', img.height.toString());
if (extendedFile.tool_resource) {
formData.append('tool_resource', extendedFile.tool_resource.toString());
}
uploadFile.mutate(formData);
};
img.src = preview;
},
[uploadFile],
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>, toolResource?: EToolResources | string) => {
event.stopPropagation();
if (!event.target.files) return;
const fileList = Array.from(event.target.files);
setFilesLoading(true);
fileList.forEach(async (file) => {
const file_id = v4();
const temp_file_id = file_id;
const extendedFile: ExtendedFile = {
file_id,
temp_file_id,
type: file.type,
filename: file.name,
filepath: '',
progress: 0,
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
size: file.size,
width: undefined,
height: undefined,
attached: false,
file,
tool_resource: typeof toolResource === 'string' ? toolResource : undefined,
};
setFiles((prev) => [...prev, extendedFile]);
if (file.type.startsWith('image/') && extendedFile.preview) {
loadImage(extendedFile, extendedFile.preview);
} else {
const formData = new FormData();
formData.append('endpoint', EModelEndpoint.agents);
formData.append('file', file, encodeURIComponent(file.name));
formData.append('file_id', file_id);
formData.append('message_file', 'true');
if (toolResource) {
formData.append('tool_resource', toolResource.toString());
}
uploadFile.mutate(formData);
}
});
event.target.value = '';
},
[uploadFile, loadImage],
);
const handleFileRemove = useCallback(
(fileId: string) => {
setFiles((prev) => {
return prev.filter((file) => {
if (file.file_id === fileId || file.temp_file_id === fileId) {
if (file.preview && file.preview.startsWith('blob:')) {
URL.revokeObjectURL(file.preview);
}
return false;
}
return true;
});
});
const updatedFiles = files.filter((file) => {
if (file.file_id === fileId || file.temp_file_id === fileId) {
return false;
}
return true;
});
params?.onFileChange?.(updatedFiles);
},
[files, params],
);
useEffect(() => {
if (params?.fileSetter) {
params.fileSetter(promptFiles);
}
}, [promptFiles, params]);
useEffect(() => {
return () => {
files.forEach((file) => {
if (file.preview && file.preview.startsWith('blob:')) {
URL.revokeObjectURL(file.preview);
}
});
};
}, [files]);
const getToolResources = useCallback((): AgentToolResources | undefined => {
if (promptFiles.length === 0) {
return undefined;
}
const toolResources: AgentToolResources = {};
promptFiles.forEach((file) => {
if (!file.file_id || !file.tool_resource) return;
if (!toolResources[file.tool_resource]) {
toolResources[file.tool_resource] = { file_ids: [] };
}
if (!toolResources[file.tool_resource]!.file_ids!.includes(file.file_id)) {
toolResources[file.tool_resource]!.file_ids!.push(file.file_id);
}
});
return Object.keys(toolResources).length > 0 ? toolResources : undefined;
}, [promptFiles]);
const loadFromToolResources = useCallback(
async (toolResources?: AgentToolResources) => {
if (!toolResources) {
setFiles([]);
return;
}
const filesArray: ExtendedFile[] = [];
for (const [toolResource, resource] of Object.entries(toolResources)) {
if (resource?.file_ids) {
for (const fileId of resource.file_ids) {
const dbFile = fileMap[fileId];
const source =
toolResource === EToolResources.file_search
? FileSources.vectordb
: (dbFile?.source ?? FileSources.local);
let file: ExtendedFile;
if (dbFile) {
file = {
file_id: dbFile.file_id,
temp_file_id: dbFile.file_id,
type: dbFile.type,
filename: dbFile.filename,
filepath: dbFile.filepath,
progress: 1,
preview: dbFile.filepath,
size: dbFile.bytes || 0,
width: dbFile.width,
height: dbFile.height,
attached: true,
tool_resource: toolResource,
metadata: dbFile.metadata,
source,
};
} else {
file = {
file_id: fileId,
temp_file_id: fileId,
type: 'application/octet-stream',
filename: `File ${fileId}`,
filepath: '',
progress: 1,
preview: '',
size: 0,
width: undefined,
height: undefined,
attached: true,
tool_resource: toolResource,
source,
};
}
filesArray.push(file);
}
}
}
setFiles(filesArray);
},
[fileMap],
);
const areFilesReady = useMemo(() => {
return promptFiles.every((file) => file.file_id && file.progress === 1);
}, [promptFiles]);
const fileStats = useMemo(() => {
const stats = {
total: promptFiles.length,
images: 0,
documents: 0,
uploading: 0,
};
promptFiles.forEach((file) => {
if (file.progress < 1) {
stats.uploading++;
} else if (file.type?.startsWith('image/')) {
stats.images++;
} else {
stats.documents++;
}
});
return stats;
}, [promptFiles]);
const abortUpload = useCallback(() => {
if (abortControllerRef.current) {
logger.log('files', 'Aborting upload');
abortControllerRef.current.abort('User aborted upload');
abortControllerRef.current = null;
}
}, []);
return {
handleFileChange,
abortUpload,
files,
setFiles,
promptFiles,
getToolResources,
loadFromToolResources,
areFilesReady,
fileStats,
handleFileRemove,
};
};
export default usePromptFileHandling;

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { throttle } from 'lodash';
interface UseInfiniteScrollOptions {
hasNextPage?: boolean;
isLoading?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage: () => void;
threshold?: number; // Percentage of scroll position to trigger fetch (0-1)
throttleMs?: number; // Throttle delay in milliseconds
@@ -15,95 +15,77 @@ interface UseInfiniteScrollOptions {
*/
export const useInfiniteScroll = ({
hasNextPage = false,
isLoading = false,
isFetchingNextPage = false,
fetchNextPage,
threshold = 0.8, // Trigger when 80% scrolled
throttleMs = 200,
}: UseInfiniteScrollOptions) => {
// Monitor resizing of the scroll container
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [scrollElement, setScrollElementState] = useState<HTMLElement | null>(null);
const scrollElementRef = useRef<HTMLElement | null>(null);
// Handler to check if we need to fetch more data
const handleNeedToFetch = useCallback(() => {
if (!scrollElement) return;
// Throttled scroll handler to prevent excessive API calls
const handleScroll = useCallback(
throttle(() => {
const element = scrollElementRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const { scrollTop, scrollHeight, clientHeight } = element;
// Calculate scroll position as percentage
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
// Calculate scroll position as percentage
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
// Check if we've scrolled past the threshold and conditions are met
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isLoading;
// Check if we've scrolled past the threshold and conditions are met
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage;
if (shouldFetch) {
fetchNextPage();
}
}, [scrollElement, hasNextPage, isLoading, fetchNextPage, threshold]);
// Create a throttled version - using useMemo to ensure it's created synchronously
const throttledHandleNeedToFetch = useMemo(
() => throttle(handleNeedToFetch, throttleMs),
[handleNeedToFetch, throttleMs],
if (shouldFetch) {
fetchNextPage();
}
}, throttleMs),
[hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs],
);
// Clean up throttled function on unmount
// Set up scroll listener
useEffect(() => {
return () => {
throttledHandleNeedToFetch.cancel?.();
};
}, [throttledHandleNeedToFetch]);
// Check if we need to fetch more data when loading state changes (useful to fill content on first load)
useEffect(() => {
if (isLoading === false && scrollElement) {
// Use requestAnimationFrame to ensure DOM is ready after loading completes
const rafId = requestAnimationFrame(() => {
throttledHandleNeedToFetch();
});
return () => cancelAnimationFrame(rafId);
}
}, [isLoading, scrollElement, throttledHandleNeedToFetch]);
// Set up scroll listener and ResizeObserver
useEffect(() => {
const element = scrollElement;
const element = scrollElementRef.current;
if (!element) return;
// Add the scroll listener
element.addEventListener('scroll', throttledHandleNeedToFetch, { passive: true });
// Remove any existing listener first
element.removeEventListener('scroll', handleScroll);
// Set up ResizeObserver to detect size changes
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
resizeObserverRef.current = new ResizeObserver(() => {
// Check if we need to fetch more data when container resizes
throttledHandleNeedToFetch();
});
resizeObserverRef.current.observe(element);
// Check immediately when element changes
throttledHandleNeedToFetch();
// Add the new listener
element.addEventListener('scroll', handleScroll, { passive: true });
return () => {
element.removeEventListener('scroll', throttledHandleNeedToFetch);
// Clean up ResizeObserver
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
element.removeEventListener('scroll', handleScroll);
// Clean up throttled function
handleScroll.cancel?.();
};
}, [scrollElement, throttledHandleNeedToFetch]);
}, [handleScroll]);
// Additional effect to re-setup listeners when scroll element changes
useEffect(() => {
const element = scrollElementRef.current;
if (!element) return;
// Remove any existing listener first
element.removeEventListener('scroll', handleScroll);
// Add the new listener
element.addEventListener('scroll', handleScroll, { passive: true });
return () => {
element.removeEventListener('scroll', handleScroll);
// Clean up throttled function
handleScroll.cancel?.();
};
}, [scrollElementRef.current, handleScroll]);
// Function to manually set the scroll container
const setScrollElement = useCallback((element: HTMLElement | null) => {
setScrollElementState(element);
scrollElementRef.current = element;
}, []);
return {
setScrollElement,
scrollElementRef,
};
};
export default useInfiniteScroll;

View File

@@ -833,6 +833,7 @@
"com_ui_delete_tool": "Werkzeug löschen",
"com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?",
"com_ui_delete_tool_error": "Fehler beim Löschen des Tools: {{error}}",
"com_ui_delete_tool_success": "Tool erfolgreich gelöscht",
"com_ui_deleted": "Gelöscht",
"com_ui_deleting_file": "Lösche Datei...",
"com_ui_descending": "Absteigend",

View File

@@ -703,6 +703,7 @@
"com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints",
"com_ui_attach_error_size": "File size limit exceeded for endpoint:",
"com_ui_attach_error_type": "Unsupported file type for endpoint:",
"com_ui_attach_files": "Attach Files",
"com_ui_attach_remove": "Remove file",
"com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool",
"com_ui_attachment": "Attachment",
@@ -834,7 +835,7 @@
"com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
"com_ui_delete_tool_error": "Error while deleting the tool: {{error}}",
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
"com_ui_delete_tool_success": "Tool deleted successfully",
"com_ui_deleted": "Deleted",
"com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc",
@@ -897,6 +898,7 @@
"com_ui_file_token_limit": "File Token Limit",
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
"com_ui_files": "Files",
"com_ui_files_info": "Attach files to enhance your prompt with additional context",
"com_ui_filter_prompts": "Filter Prompts",
"com_ui_filter_prompts_name": "Filter prompts by name",
"com_ui_final_touch": "Final touch",
@@ -1044,9 +1046,9 @@
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
"com_ui_oauth_error_title": "Authentication Failed",
"com_ui_oauth_revoke": "Revoke",
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
"com_ui_oauth_success_title": "Authentication Successful",
"com_ui_oauth_revoke": "Revoke",
"com_ui_of": "of",
"com_ui_off": "Off",
"com_ui_offline": "Offline",

View File

@@ -35,7 +35,7 @@
"com_agents_created_by": "izveidojis",
"com_agents_description_placeholder": "Pēc izvēles: aprakstiet savu aģentu šeit",
"com_agents_empty_state_heading": "Nav atrasts neviens aģents",
"com_agents_enable_file_search": "Iespējot faila augšupielādi informācijas iegūšanai no tā",
"com_agents_enable_file_search": "Iespējot failu meklēšanu",
"com_agents_error_bad_request_message": "Pieprasījumu nevarēja apstrādāt.",
"com_agents_error_bad_request_suggestion": "Lūdzu, pārbaudiet ievadītos datus un mēģiniet vēlreiz.",
"com_agents_error_category_title": "Kategorija Kļūda",
@@ -62,7 +62,7 @@
"com_agents_file_context": "Failu konteksts (OCR)",
"com_agents_file_context_disabled": "Pirms failu augšupielādes failu kontekstam ir jāizveido aģents.",
"com_agents_file_context_info": "Faili, kas augšupielādēti kā “Konteksts”, tiek apstrādāti, izmantojot OCR, lai iegūtu tekstu, kas pēc tam tiek pievienots aģenta norādījumiem. Ideāli piemērots dokumentiem, attēliem ar tekstu vai PDF failiem, kuriem nepieciešams pilns faila teksta saturs.",
"com_agents_file_search_disabled": "Lai varētu iespējot faila augšupielādi informācijas iegūšanai no tā ir jāizveido aģents.",
"com_agents_file_search_disabled": "Pirms failu augšupielādes failu meklēšanai ir jāizveido aģents.",
"com_agents_file_search_info": "Kad šī opcija ir iespējota, aģents tiks informēts par precīziem tālāk norādītajiem failu nosaukumiem, ļaujot tam izgūt atbilstošu kontekstu no šiem failiem.",
"com_agents_grid_announcement": "Rādu {{count}} aģentus {{category}} kategorijā",
"com_agents_instructions_placeholder": "Sistēmas instrukcijas, ko izmantos aģents",
@@ -124,17 +124,17 @@
"com_assistants_delete_actions_success": "Darbība veiksmīgi dzēsta no asistenta",
"com_assistants_description_placeholder": "Pēc izvēles: Šeit aprakstiet savu asistentu",
"com_assistants_domain_info": "Asistents nosūtīja šo informāciju {{0}}",
"com_assistants_file_search": "Faila augšupielāde informācijas iegūšanai no tā",
"com_assistants_file_search_info": "Faila augšupielāde informācijas iegūšanai no tā ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.",
"com_assistants_file_search": "Failu meklēšana",
"com_assistants_file_search_info": "Failu meklēšana ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.",
"com_assistants_function_use": "Izmantotais asistents {{0}}",
"com_assistants_image_vision": "Attēla redzējums",
"com_assistants_instructions_placeholder": "Sistēmas norādījumi, ko izmanto asistents",
"com_assistants_knowledge": "Zināšanas",
"com_assistants_knowledge_disabled": "Pirms failu var augšupielādēt zināšanu krātuvē ir jāizveido asistents un jāaktivizē koda interpretētājs vai failu atgūšana.",
"com_assistants_knowledge_disabled": "Pirms failu augšupielādes kā Zināšanas ir jāizveido asistents un jāaktivizē un jāsaglabā koda interpretētājs vai failu atgūšana.",
"com_assistants_knowledge_info": "Ja augšupielādējat failus sadaļā Zināšanas, sarunās ar asistentu var tikt iekļauts faila saturs.",
"com_assistants_max_starters_reached": "Sasniegts maksimālais sarunu uzsākšanas iespēju skaits",
"com_assistants_name_placeholder": "Pēc izvēles: Asistenta nosaukums",
"com_assistants_non_retrieval_model": "Šajā modelī faila augšupielāde informācijas iegūšana no tā nav iespējota. Lūdzu, izvēlieties citu modeli.",
"com_assistants_non_retrieval_model": "Šajā modelī failu meklēšana nav iespējota. Lūdzu, izvēlieties citu modeli.",
"com_assistants_retrieval": "Atgūšana",
"com_assistants_running_action": "Darbība palaista",
"com_assistants_running_var": "Strādā {{0}}",
@@ -143,7 +143,7 @@
"com_assistants_update_actions_success": "Veiksmīgi izveidota vai atjaunināta darbība",
"com_assistants_update_error": "Jūsu asistenta atjaunināšanā notika kļūda.",
"com_assistants_update_success": "Veiksmīgi atjaunināts",
"com_auth_already_have_account": "Vai jums jau ir konts?",
"com_auth_already_have_account": "Jau ir konts?",
"com_auth_apple_login": "Pierakstīties, izmantojot Apple",
"com_auth_back_to_login": "Atgriezties pie pieteikšanās",
"com_auth_click": "Noklikšķiniet uz",
@@ -337,7 +337,7 @@
"com_endpoint_prompt_prefix_assistants": "Papildu instrukcijas",
"com_endpoint_prompt_prefix_assistants_placeholder": "Iestatiet papildu norādījumus vai kontekstu virs Asistenta galvenajiem norādījumiem. Ja lauks ir tukšs, tas tiek ignorēts.",
"com_endpoint_prompt_prefix_placeholder": "Iestatiet pielāgotas instrukcijas vai kontekstu. Ja lauks ir tukšs, tas tiek ignorēts.",
"com_endpoint_reasoning_effort": "Spriešanas līmenis",
"com_endpoint_reasoning_effort": "Spriešanas piepūle",
"com_endpoint_reasoning_summary": "Spriešanas kopsavilkums",
"com_endpoint_save_as_preset": "Saglabāt kā iestatījumu",
"com_endpoint_search": "Meklēt galapunktu pēc nosaukuma",
@@ -650,7 +650,7 @@
"com_ui_agent_category_rd": "Pētniecība un attīstība",
"com_ui_agent_category_sales": "Pārdošana",
"com_ui_agent_category_selector_aria": "Aģenta kategorijas atlasītājs",
"com_ui_agent_chain": "Aģentu ķēde (Vairāki aģenti kopā sasaistīti)",
"com_ui_agent_chain": "Aģentu ķēde (aģentu maisījums)",
"com_ui_agent_chain_info": "Ļauj izveidot aģentu secību ķēdes. Katrs aģents var piekļūt iepriekšējo ķēdē esošo aģentu izvades datiem. Balstīts uz \"Aģentu sajaukuma\" arhitektūru, kurā aģenti izmanto iepriekšējos izvades datus kā palīginformāciju.",
"com_ui_agent_chain_max": "Jūs esat sasniedzis maksimālo skaitu {{0}} aģentu.",
"com_ui_agent_delete_error": "Dzēšot aģentu, radās kļūda.",
@@ -730,7 +730,7 @@
"com_ui_bearer": "Nesējs",
"com_ui_bookmark_delete_confirm": "Vai tiešām vēlaties dzēst šo grāmatzīmi?",
"com_ui_bookmarks": "Grāmatzīmes",
"com_ui_bookmarks_add": "Pievienot grāmatzīmi",
"com_ui_bookmarks_add": "Pievienot grāmatzīmes",
"com_ui_bookmarks_add_to_conversation": "Pievienot pašreizējai sarunai",
"com_ui_bookmarks_count": "Grāfs",
"com_ui_bookmarks_create_error": "Veidojot grāmatzīmi, radās kļūda",
@@ -807,8 +807,8 @@
"com_ui_date_may": "Maijs",
"com_ui_date_november": "Novembris",
"com_ui_date_october": "Oktobris",
"com_ui_date_previous_30_days": "Pēdējās 30 dienas",
"com_ui_date_previous_7_days": "Pēdējās 7 dienas",
"com_ui_date_previous_30_days": "Iepriekšējās 30 dienas",
"com_ui_date_previous_7_days": "Iepriekšējās 7 dienas",
"com_ui_date_september": "Septembris",
"com_ui_date_today": "Šodien",
"com_ui_date_yesterday": "Vakar",
@@ -834,7 +834,7 @@
"com_ui_delete_tool": "Dzēst rīku",
"com_ui_delete_tool_confirm": "Vai tiešām vēlaties dzēst šo rīku?",
"com_ui_delete_tool_error": "Kļūda, dzēšot rīku: {{error}}",
"com_ui_delete_tool_save_reminder": "Rīks noņemts. Saglabājiet aģentu, lai piemērotu izmaiņas.",
"com_ui_delete_tool_success": "Rīks veiksmīgi izdzēsts",
"com_ui_deleted": "Dzēsts",
"com_ui_deleting_file": "Dzēšu failu...",
"com_ui_descending": "Dilstošs",
@@ -848,7 +848,7 @@
"com_ui_download_backup": "Lejupielādēt rezerves kodus",
"com_ui_download_backup_tooltip": "Pirms turpināt, lejupielādējiet rezerves kodus. Tie būs nepieciešami, lai atgūtu piekļuvi, ja pazaudēsiet autentifikatora ierīci.",
"com_ui_download_error": "Kļūda, lejupielādējot failu. Iespējams, fails ir izdzēsts.",
"com_ui_drag_drop": "Ievietojiet šeit jebkuru failu, lai pievienotu to sarunai",
"com_ui_drag_drop": "Nav rezultātu",
"com_ui_dropdown_variables": "Nolaižamās izvēlnes mainīgie:",
"com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}` (mainīgā_nosakums:opcija1|opcija2|opcija3)",
"com_ui_duplicate": "Dublicēt",
@@ -877,9 +877,9 @@
"com_ui_export_convo_modal": "Eksportēt sarunas modālo logu",
"com_ui_feedback_more": "Vairāk...",
"com_ui_feedback_more_information": "Sniegt papildu atsauksmes",
"com_ui_feedback_negative": "Atbildei nepieciešami uzlabojumi",
"com_ui_feedback_negative": "Nepieciešami uzlabojumi",
"com_ui_feedback_placeholder": "Lūdzu, sniedziet šeit jebkādas papildu atsauksmes.",
"com_ui_feedback_positive": "Lieliska atbilde",
"com_ui_feedback_positive": "Man tas patīk",
"com_ui_feedback_tag_accurate_reliable": "Precīzs un uzticams",
"com_ui_feedback_tag_attention_to_detail": "Uzmanība detaļām",
"com_ui_feedback_tag_bad_style": "Slikts stils vai tonis",
@@ -1012,7 +1012,7 @@
"com_ui_memory_would_exceed": "Nevar saglabāt - pārsniegtu tokenu limitu par {{tokens}}. Izdzēsiet esošās atmiņas, lai atbrīvotu vietu.",
"com_ui_mention": "Pieminiet galapunktu, assistentu vai iestatījumu, lai ātri uz to pārslēgtos",
"com_ui_min_tags": "Nevar noņemt vairāk vērtību, vismaz {{0}} ir nepieciešamas.",
"com_ui_minimal": "Minimāls",
"com_ui_minimal": "Minimāla",
"com_ui_misc": "Dažādi",
"com_ui_model": "Modelis",
"com_ui_model_parameters": "Modeļa Parametrus",
@@ -1035,7 +1035,7 @@
"com_ui_no_results_found": "Nav atrastu rezultātu",
"com_ui_no_terms_content": "Nav noteikumu un nosacījumu satura, ko parādīt",
"com_ui_no_valid_items": "Nav rezultātu",
"com_ui_none": "Nekāds",
"com_ui_none": "Neviens",
"com_ui_not_used": "Nav izmantots",
"com_ui_nothing_found": "Nekas nav atrasts",
"com_ui_oauth": "OAuth",
@@ -1046,7 +1046,6 @@
"com_ui_oauth_error_missing_code": "Trūkst autorizācijas koda. Lūdzu, mēģiniet vēlreiz.",
"com_ui_oauth_error_missing_state": "Trūkst stāvokļa parametrs. Lūdzu, mēģiniet vēlreiz.",
"com_ui_oauth_error_title": "Autentifikācija neizdevās",
"com_ui_oauth_revoke": "Atsaukt",
"com_ui_oauth_success_description": "Jūsu autentifikācija bija veiksmīga. Šis logs aizvērsies pēc",
"com_ui_oauth_success_title": "Autentifikācija veiksmīga",
"com_ui_of": "no",
@@ -1120,7 +1119,7 @@
"com_ui_role_viewer": "Skatītājs",
"com_ui_role_viewer_desc": "Var skatīt un izmantot aģentu, bet nevar to rediģēt",
"com_ui_roleplay": "Lomu spēle",
"com_ui_run_code": "Palaist kodu",
"com_ui_run_code": "Palaišanas kods",
"com_ui_run_code_error": "Radās kļūda, izpildot kodu",
"com_ui_save": "Saglabāt",
"com_ui_save_badge_changes": "Vai saglabāt emblēmas izmaiņas?",
@@ -1188,7 +1187,7 @@
"com_ui_support_contact": "Atbalsta kontaktinformācija",
"com_ui_support_contact_email": "E-pasts",
"com_ui_support_contact_email_invalid": "Lūdzu, ievadiet derīgu e-pasta adresi",
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.lv",
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.com",
"com_ui_support_contact_name": "Vārds",
"com_ui_support_contact_name_min_length": "Vārdam jābūt vismaz {{minLength}} rakstu zīmēm",
"com_ui_support_contact_name_placeholder": "Atbalsta kontaktpersonas vārds",
@@ -1221,19 +1220,19 @@
"com_ui_update_mcp_success": "Veiksmīgi izveidots vai atjaunināts MCP",
"com_ui_upload": "Augšupielādēt",
"com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts",
"com_ui_upload_code_files": "Augšupielādēt failu koda interpretētājam",
"com_ui_upload_code_files": "Augšupielādēt koda interpretētājam",
"com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.",
"com_ui_upload_error": "Augšupielādējot failu, radās kļūda.",
"com_ui_upload_file_context": "Augšupielādēt failu kontekstu",
"com_ui_upload_file_search": "Augšupielādēt failu informācijas iegūšanai no tā",
"com_ui_upload_file_context": "Augšupielādēt failu kontekstā",
"com_ui_upload_file_search": "Augšupielādēt failu meklēšanai",
"com_ui_upload_files": "Augšupielādēt failus",
"com_ui_upload_image": "Augšupielādēt failu kā attēlu",
"com_ui_upload_image_input": "Augšupielādēt failu kā attēlu",
"com_ui_upload_image": "Augšupielādēt attēlu",
"com_ui_upload_image_input": "Augšupielādēt attēlu",
"com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.",
"com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB",
"com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu",
"com_ui_upload_ocr_text": "Augšupielādēt kā tekstu",
"com_ui_upload_success": "Fails veiksmīgi augšupielādēts",
"com_ui_upload_type": "Izvēlieties augšupielādes veidu",
"com_ui_upload_type": "Atlasiet augšupielādes veidu",
"com_ui_usage": "Izmantošana",
"com_ui_use_2fa_code": "Izmantojiet 2FA kodu",
"com_ui_use_backup_code": "Izmantojiet rezerves kodu",

View File

@@ -834,6 +834,7 @@
"com_ui_delete_tool": "Slett verktøy",
"com_ui_delete_tool_confirm": "Er du sikker på at du vil slette dette verktøyet?",
"com_ui_delete_tool_error": "En feil oppstod ved sletting av verktøyet: {{error}}",
"com_ui_delete_tool_success": "Verktøyet ble slettet",
"com_ui_deleted": "Slettet",
"com_ui_deleting_file": "Sletter fil ...",
"com_ui_descending": "Synkende",

View File

@@ -4,7 +4,7 @@
"com_a11y_ai_composing": "De AI is nog bezig met het formuleren van een antwoord.",
"com_a11y_end": "De AI is klaar met het antwoord.",
"com_a11y_start": "De AI is begonnen met antwoorden.",
"com_agents_all": "Alle Agents\n",
"com_agents_all": "Alle Agents\n\n",
"com_agents_by_librechat": "door LibreChat",
"com_agents_category_empty": "Geen agents gevonden in de {{category}} categorie",
"com_agents_category_tab_label": "{{category}} categorie, {{position}} of {{total}}",
@@ -27,13 +27,6 @@
"com_agents_file_search_disabled": "Maak eerst een Agent aan voordat je bestanden uploadt voor File Search.",
"com_agents_file_search_info": "Als deze functie is ingeschakeld, krijgt de agent informatie over de exacte bestandsnamen die hieronder staan vermeld, zodat deze relevante context uit deze bestanden kan ophalen.",
"com_agents_instructions_placeholder": "De systeeminstructies die de agent gebruikt",
"com_agents_link_copied": "Link gekopieerd",
"com_agents_link_copy_failed": "Link niet gekopieerd",
"com_agents_load_more_label": "Laad meer agenten van {{category}} categorie",
"com_agents_loading": "Aan het laden...",
"com_agents_mcp_icon_size": "Minimum formaat 128 x 128 px",
"com_agents_mcp_info": "MCP-servers toevoegen aan je agent zodat deze taken kan uitvoeren en kan communiceren met externe services",
"com_agents_mcp_name_placeholder": "Aangepast hulpmiddel",
"com_agents_missing_provider_model": "Selecteer een provider en model voordat je een agent aanmaakt.",
"com_agents_name_placeholder": "De naam van de agent",
"com_agents_no_access": "Je hebt geen toegang om deze agent te bewerken.",
@@ -222,7 +215,7 @@
"com_endpoint_openai_max": "Het max. aantal tokens dat kan worden gegenereerd. De totale lengte van invoer-tokens en gegenereerde tokens is beperkt door de contextlengte van het model.",
"com_endpoint_openai_pres": "Getal tussen -2,0 en 2,0. Positieve waarden straffen nieuwe tokens op basis van of ze al voorkomen in de tekst tot nu toe, waardoor de kans dat het model over nieuwe onderwerpen praat toeneemt.",
"com_endpoint_openai_prompt_prefix_placeholder": "Stel aangepaste instructies in om op te nemen in Systeembericht. Standaard: geen",
"com_endpoint_openai_reasoning_effort": "Alleen redeneermodellen: beperkt de inspanning voor redeneren. Vermindering van redeneerinspanning kan resulteren in snellere reacties en minder tokens gebruikt voor redeneren in een reactie. Minimaal' produceert zeer weinig redeneertokens voor de snelste tijd-tot-eerste-tokens, vooral geschikt voor codering en het volgen van instructies.",
"com_endpoint_openai_reasoning_effort": "Alleen voor redeneringsmodellen: beperkt de inspanning voor redeneren. Het verminderen van de redeneringsinspanning kan leiden tot snellere antwoorden en minder tokens die worden gebruikt voor redeneren in een antwoord. 'Minimaal' produceert zeer weinig redeneringstokens voor de snelste tijd tot het eerste token, vooral geschikt voor coderen en instructies volgen.",
"com_endpoint_openai_temp": "Hogere waarden = meer willekeurig, terwijl lagere waarden = meer gericht en deterministisch. We raden aan dit of Top P te wijzigen, maar niet beide.",
"com_endpoint_openai_topp": "Een alternatief voor sampling met temperatuur, genaamd nucleus sampling, waarbij het model de resultaten van de tokens met de top_p waarschijnlijkheidsmassa in overweging neemt. Dus 0,1 betekent dat alleen de tokens die de bovenste 10% waarschijnlijkheidsmassa omvatten, in overweging worden genomen. We raden aan dit of temperatuur te wijzigen, maar niet beide.",
"com_endpoint_output": "Uitvoer",

View File

@@ -834,6 +834,7 @@
"com_ui_delete_tool": "Видалити інструмент",
"com_ui_delete_tool_confirm": "Ви дійсно хочете видалити цей інструмент?",
"com_ui_delete_tool_error": "Помилка під час видалення інструменту: {{error}}",
"com_ui_delete_tool_success": "Інструмент успішно видалено",
"com_ui_deleted": "Видалено",
"com_ui_deleting_file": "Видалення файлу...",
"com_ui_descending": "За спаданням",

Some files were not shown because too many files have changed in this diff Show More