Compare commits

..

36 Commits

Author SHA1 Message Date
Dustin Healy
b034624690 refactor: cost bar uses new cost table 2025-09-13 13:30:00 -07:00
Dustin Healy
adff605c50 feat: add target model to messages so we dont have to peek into child ai message responses every time to know what model the user sent their message to for tokenValue computations 2025-09-13 13:02:04 -07:00
Dustin Healy
465c81adee feat: add addToSet update operation to saveConvo to efficiently update modelHistory whenever a new model/endpoint combo is used 2025-09-13 12:59:33 -07:00
Dustin Healy
cb8e76e27e feat: add modelHistory to conversations
modelHistory keeps track of all the models and their respective endpoints that are used in a conversation, so we can quickly fetch the right values to populate our cost table whenever we load a conversation rather than having to recompute the list every time
2025-09-13 12:49:44 -07:00
Dustin Healy
4d9e17efe1 feat: add modelCosts to QueryKeys 2025-09-13 12:46:02 -07:00
Dustin Healy
95ebef13df refactor: swap conversationCosts query hooks out for new modelCosts query and move to react-query-service 2025-09-13 12:42:43 -07:00
Dustin Healy
4fb9d7bdff refactor: swap data-service and types to use new cost table 2025-09-13 12:26:39 -07:00
Dustin Healy
0edfecf44a refactor: swap old ConversationCosts endpoint out for much simpler 'costs' endpoint
/costs just recieves an array of { model, endpoint }  and returns the related prompt and completion token rates, leaving the actual message processing to be done later on rather than on the backend.
2025-09-13 12:19:26 -07:00
Dustin Healy
ba8c09b361 feat: stop costbox from displaying during responses and make scroll to bottom ux more consistent 2025-09-13 01:51:17 -07:00
Dustin Healy
794fe6fd11 test: add unit tests for costs endpoint with various scenarios 2025-09-13 01:51:17 -07:00
Dustin Healy
97ac52fc6c feat: add toggle for cost tracking 2025-09-13 01:51:17 -07:00
Dustin Healy
1a947607a5 feat: add localization strings for the token abbreviation 't' 2025-09-13 01:51:17 -07:00
Dustin Healy
1745708418 refactor: move arrow svgs out to their own component 2025-09-13 01:51:17 -07:00
Dustin Healy
14aedac1e1 refactor: move CostBar into its own component 2025-09-13 01:51:17 -07:00
Dustin Healy
a820d79bfc feat: pull up the cost hooks into chatView to reduce api calls 2025-09-13 01:51:17 -07:00
Dustin Healy
3b1c07ff46 feat: add frontend component for displaying convo total, per message cost and tokens, and hide total block on scroll 2025-09-13 01:51:17 -07:00
Dustin Healy
c1b0f13360 feat: add queryhook layer and invalidation so we keep tokens fresh during conversation turns 2025-09-13 01:51:17 -07:00
Dustin Healy
637bbd2e29 refactor: move endpoint up so it doesn't get eclipsed by get route for conversationId 2025-09-13 01:51:17 -07:00
Dustin Healy
30e1b421ba feat: add data-service layer 2025-09-13 01:51:17 -07:00
Dustin Healy
fb89f60470 feat: add transaction costs endpoint 2025-09-13 01:51:17 -07:00
Danny Avila
5245aeea8f 🔧 refactor: Consolidate MCP tool removal and Improve UX (#9609)
* 🔧 refactor: Consolidate MCP tool removal and Improve UX

- Removed redundant tool removal logic from MCPTool, UnconfiguredMCPTool, and UninitializedMCPTool components.
- Introduced `useRemoveMCPTool` hook to handle tool removal and toast notifications.
- Updated translation.json to include a reminder message for saving changes after tool removal.

* chore: remove unused i18n key
2025-09-12 21:37:07 -04:00
Jesse Bye
dd93db40bc ⛑️ feat: Helm serviceAccount Config (#9606) 2025-09-12 17:37:29 -04:00
Muhammad Azhdari
136cf1d5a8 ⛑️ fix: follow postgres bitnami values schema in rag-api helm chart (#7782) 2025-09-12 17:36:38 -04:00
Danny Avila
751522087a v0.8.0-rc4 (#9601)
*  v0.8.0-rc4

* chore: update jest.config.cjs to include release comment and linting

* chore: bump CONFIG_VERSION to 1.2.9
2025-09-12 13:37:10 -04:00
github-actions[bot]
7fe830acfc 🌍 i18n: Update translation.json with latest translations (#9599)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 13:00:30 -04:00
Danny Avila
cdfe686987 📦 chore: bump axios to v1.12.1 (#9600) 2025-09-12 13:00:12 -04:00
Danny Avila
5b5723343c 🔍 refactor: Preserve Category in Agent Marketplace Search (#9598) 2025-09-12 11:36:42 -04:00
Sebastien Bruel
30c24a66f6 🪟 fix: Auto-fetch agents to fill Viewport in Marketplace Scroll (#9591) 2025-09-12 10:59:15 -04:00
Christian Geisler
ecf9733bc1 🐳 fix: Add missing uploads directory to Dockerfile (#9590) 2025-09-12 10:56:14 -04:00
Danny Avila
133312fb40 🔐 fix: Remove OAuth handler cleanup at Connection Change (#9589) 2025-09-12 00:34:45 -04:00
github-actions[bot]
b62ffb533c 🌍 i18n: Update translation.json with latest translations (#9586)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 00:29:47 -04:00
Danny Avila
d75fb76338 refactor: Add Effective Timeout for MCP Fetch (#9585) 2025-09-11 19:09:13 -04:00
Danny Avila
51f2d43fed 🔐 refactor: Improve MCP OAuth Event Handler Cleanup (#9584)
* 🔐 refactor: Improve MCP OAuth event handling and cleanup

* ci: MCPConnection mock with additional event handling methods
2025-09-11 18:54:43 -04:00
Danny Avila
e3a645e8fb 🔃 fix: Token Refresh in Browser Only, Redirect on Refresh Failure (#9583)
* 🔃 fix: Token Refresh in Browser Only, Redirect on Refresh Failure

* chore: Update import for SearchResultData and fix FormattedToolResponse type build warning
2025-09-11 16:51:40 -04:00
Danny Avila
180046a3c5 ✂️ refactor: Artifacts and Tool Callbacks to Pass UI Resources (#9581)
* ✂️ refactor: use artifacts and callbacks to pass UI resources

* chore: imports

* refactor: Update UIResource type imports and definitions across components and tests

* refactor: Update ToolCallInfo test data structure and enhance TAttachment type definition

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
2025-09-11 14:34:07 -04:00
github-actions[bot]
916742ab9d 🌍 i18n: Update translation.json with latest translations (#9570)
* 🌍 i18n: Update translation.json with latest translations

* Update drag and drop tooltip text

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-11 08:13:43 -04:00
108 changed files with 3457 additions and 3206 deletions

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc3
# v0.8.0-rc4
# 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 ; \
mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
npm config set fetch-retry-maxtimeout 600000 ; \
npm config set fetch-retries 5 ; \
npm config set fetch-retry-mintimeout 15000 ; \
@@ -44,8 +44,6 @@ 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-rc3
# v0.8.0-rc4
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -233,6 +233,7 @@ class BaseClient {
sender: 'User',
text,
isCreatedByUser: true,
targetModel: this.modelOptions?.model ?? this.model,
};
}

View File

@@ -112,8 +112,17 @@ module.exports = {
update.expiredAt = null;
}
/** @type {{ $set: Partial<TConversation>; $unset?: Record<keyof TConversation, number> }} */
/** @type {{ $set: Partial<TConversation>; $addToSet?: Record<string, any>; $unset?: Record<keyof TConversation, number> }} */
const updateOperation = { $set: update };
if (convo.model && convo.endpoint) {
updateOperation.$addToSet = {
modelHistory: {
model: convo.model,
endpoint: convo.endpoint,
},
};
}
if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
updateOperation.$unset = metadata.unsetFields;
}

View File

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

View File

@@ -562,884 +562,3 @@ 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-rc3",
"version": "v0.8.0-rc4",
"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.8.2",
"axios": "^1.12.1",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",

View File

@@ -0,0 +1,342 @@
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,6 +265,30 @@ 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

@@ -0,0 +1,680 @@
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
createMethods: jest.fn(() => ({})),
createModels: jest.fn(() => ({})),
}));
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
validateMessageReq: (req, res, next) => next(),
}));
jest.mock('~/models', () => ({
getConvo: jest.fn(),
saveConvo: jest.fn(),
saveMessage: jest.fn(),
getMessage: jest.fn(),
getMessages: jest.fn(),
updateMessage: jest.fn(),
deleteMessages: jest.fn(),
}));
jest.mock('~/db/models', () => {
let User, Message, Transaction, Conversation;
return {
get User() {
return User;
},
get Message() {
return Message;
},
get Transaction() {
return Transaction;
},
get Conversation() {
return Conversation;
},
setUser: (model) => {
User = model;
},
setMessage: (model) => {
Message = model;
},
setTransaction: (model) => {
Transaction = model;
},
setConversation: (model) => {
Conversation = model;
},
};
});
describe('Costs Endpoint', () => {
let app;
let mongoServer;
let messagesRouter;
let User, Message, Transaction, Conversation;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const userSchema = new mongoose.Schema({
_id: String,
name: String,
email: String,
});
const conversationSchema = new mongoose.Schema({
conversationId: String,
user: String,
title: String,
createdAt: Date,
});
const messageSchema = new mongoose.Schema({
messageId: String,
conversationId: String,
user: String,
isCreatedByUser: Boolean,
tokenCount: Number,
createdAt: Date,
});
const transactionSchema = new mongoose.Schema({
conversationId: String,
user: String,
tokenType: String,
tokenValue: Number,
createdAt: Date,
});
User = mongoose.model('User', userSchema);
Conversation = mongoose.model('Conversation', conversationSchema);
Message = mongoose.model('Message', messageSchema);
Transaction = mongoose.model('Transaction', transactionSchema);
const dbModels = require('~/db/models');
dbModels.setUser(User);
dbModels.setMessage(Message);
dbModels.setTransaction(Transaction);
dbModels.setConversation(Conversation);
require('~/db/models');
try {
messagesRouter = require('../messages');
} catch (error) {
console.error('Error loading messages router:', error);
throw error;
}
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = { id: 'test-user-id' };
next();
});
app.use('/api/messages', messagesRouter);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await User.deleteMany({});
await Conversation.deleteMany({});
await Message.deleteMany({});
await Transaction.deleteMany({});
});
describe('GET /:conversationId/costs', () => {
const conversationId = 'test-conversation-123';
const userId = 'test-user-id';
it('should return cost data for valid conversation', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const aiMessage = new Message({
messageId: 'ai-msg-1',
conversationId,
user: userId,
isCreatedByUser: false,
tokenCount: 150,
createdAt: new Date('2024-01-01T10:01:00Z'),
});
await Promise.all([userMessage.save(), aiMessage.save()]);
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const completionTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'completion',
tokenValue: 750000,
createdAt: new Date('2024-01-01T10:01:30Z'),
});
await Promise.all([promptTransaction.save(), completionTransaction.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
conversationId,
totals: {
prompt: { usd: 0.5, tokenCount: 100 },
completion: { usd: 0.75, tokenCount: 150 },
total: { usd: 1.25, tokenCount: 250 },
},
perMessage: [
{ messageId: 'user-msg-1', tokenType: 'prompt', tokenCount: 100, usd: 0.5 },
{ messageId: 'ai-msg-1', tokenType: 'completion', tokenCount: 150, usd: 0.75 },
],
});
});
it('should return empty data for conversation with no messages', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
conversationId,
totals: {
prompt: { usd: 0, tokenCount: 0 },
completion: { usd: 0, tokenCount: 0 },
total: { usd: 0, tokenCount: 0 },
},
perMessage: [],
});
});
it('should handle messages without transactions', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const aiMessage = new Message({
messageId: 'ai-msg-1',
conversationId,
user: userId,
isCreatedByUser: false,
tokenCount: 150,
createdAt: new Date('2024-01-01T10:01:00Z'),
});
await Promise.all([userMessage.save(), aiMessage.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0);
expect(response.body.totals.completion.usd).toBe(0);
expect(response.body.totals.total.usd).toBe(0);
});
it('should aggregate multiple transactions correctly', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction1 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 300000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const promptTransaction2 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 200000,
createdAt: new Date('2024-01-01T10:00:45Z'),
});
await Promise.all([promptTransaction1.save(), promptTransaction2.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
expect(response.body.perMessage[0].usd).toBe(0.5);
});
it('should handle null tokenCount values', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: null,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.tokenCount).toBe(0);
});
it('should handle null tokenValue in transactions', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: null,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await promptTransaction.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0);
});
it('should handle negative tokenValue using Math.abs', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: -500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await promptTransaction.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
});
it('should filter by user correctly', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const otherUserId = 'other-user-id';
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const otherUserMessage = new Message({
messageId: 'other-user-msg-1',
conversationId,
user: otherUserId,
isCreatedByUser: true,
tokenCount: 200,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await Promise.all([userMessage.save(), otherUserMessage.save()]);
const userTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const otherUserTransaction = new Transaction({
conversationId,
user: otherUserId,
tokenType: 'prompt',
tokenValue: 1000000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await Promise.all([userTransaction.save(), otherUserTransaction.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
expect(response.body.perMessage).toHaveLength(1);
expect(response.body.perMessage[0].messageId).toBe('user-msg-1');
});
it('should filter transactions by tokenType', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const otherTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'other',
tokenValue: 1000000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await Promise.all([promptTransaction.save(), otherTransaction.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
expect(response.body.totals.completion.usd).toBe(0);
expect(response.body.totals.total.usd).toBe(0.5);
});
it('should map transactions to messages chronologically', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage1 = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const userMessage2 = new Message({
messageId: 'user-msg-2',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 200,
createdAt: new Date('2024-01-01T10:01:00Z'),
});
await Promise.all([userMessage1.save(), userMessage2.save()]);
const promptTransaction1 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const promptTransaction2 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 1000000,
createdAt: new Date('2024-01-01T10:01:30Z'),
});
await Promise.all([promptTransaction1.save(), promptTransaction2.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.perMessage).toHaveLength(2);
expect(response.body.perMessage[0].messageId).toBe('user-msg-1');
expect(response.body.perMessage[0].usd).toBe(0.5);
expect(response.body.perMessage[1].messageId).toBe('user-msg-2');
expect(response.body.perMessage[1].usd).toBe(1.0);
});
it('should handle database errors', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
await mongoose.connection.close();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(500);
expect(response.body).toHaveProperty('error');
});
});
});

View File

@@ -11,6 +11,7 @@ const {
} = require('~/models');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { tokenValues, getValueKey, defaultRate } = require('~/models/tx');
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
const { getConvosQueried } = require('~/models/Conversation');
const { countTokens } = require('~/server/utils');
@@ -160,6 +161,41 @@ router.post('/artifact/:messageId', async (req, res) => {
}
});
/**
* POST /costs
* Get cost information for models in modelHistory array
*/
router.post('/costs', async (req, res) => {
try {
const { modelHistory } = req.body;
if (!Array.isArray(modelHistory)) {
return res.status(400).json({ error: 'modelHistory must be an array' });
}
const modelCostTable = {};
modelHistory.forEach((modelEntry) => {
if (modelEntry && typeof modelEntry === 'object' && modelEntry.model && modelEntry.endpoint) {
const { model, endpoint } = modelEntry;
const valueKey = getValueKey(model, endpoint);
const pricing = tokenValues[valueKey];
modelCostTable[model] = {
prompt: pricing?.prompt ?? defaultRate,
completion: pricing?.completion ?? defaultRate,
};
}
});
res.status(200).json({ modelCostTable });
} catch (error) {
logger.error('Error fetching model costs:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
router.get('/:conversationId', validateMessageReq, async (req, res) => {
try {

View File

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

View File

@@ -1,520 +0,0 @@
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

@@ -88,6 +88,7 @@ async function saveUserMessage(req, params) {
parentMessageId: params.parentMessageId ?? Constants.NO_PARENT,
/* For messages, use the assistant_id instead of model */
model: params.assistant_id,
targetModel: params.model,
thread_id: params.thread_id,
sender: 'User',
text: params.text,

View File

@@ -1,3 +1,4 @@
/** v0.8.0-rc4 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
@@ -28,7 +29,8 @@ 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-rc3",
"version": "v0.8.0-rc4",
"description": "",
"type": "module",
"scripts": {

View File

@@ -350,7 +350,6 @@ export type TAskProps = {
conversationId?: string | null;
messageId?: string | null;
clientTimestamp?: string;
toolResources?: t.AgentToolResources;
};
export type TOptions = {
@@ -643,10 +642,3 @@ 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
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
scrollElementRef?: React.RefObject<HTMLElement>; // Parent scroll container ref for infinite scroll
}
/**
@@ -23,7 +23,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
category,
searchQuery,
onSelectAgent,
scrollElement,
scrollElementRef,
}) => {
const localize = useLocalize();
@@ -87,7 +87,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
// Set up infinite scroll
const { setScrollElement } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
isLoading: isFetching || isFetchingNextPage,
fetchNextPage: () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
@@ -99,10 +99,11 @@ const AgentGrid: React.FC<AgentGridProps> = ({
// Connect the scroll element when it's provided
useEffect(() => {
const scrollElement = scrollElementRef?.current;
if (scrollElement) {
setScrollElement(scrollElement);
}
}, [scrollElement, setScrollElement]);
}, [scrollElementRef, 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');
// 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()}` : ''}`,
);
}
}
// 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()}` : ''}`,
);
}
};
@@ -427,7 +427,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
category={displayCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current}
scrollElementRef={scrollContainerRef}
/>
</div>
@@ -507,7 +507,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current}
scrollElementRef={scrollContainerRef}
/>
</div>
)}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid';
import type t from 'librechat-data-provider';
@@ -81,6 +82,115 @@ 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();
@@ -343,6 +453,15 @@ 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,
@@ -396,5 +515,358 @@ 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

@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo, useCallback, useState, useEffect, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { Spinner } from '@librechat/client';
@@ -13,6 +13,7 @@ import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
import CostBar from './CostBar';
import Landing from './Landing';
import Header from './Header';
import Footer from './Footer';
@@ -29,7 +30,13 @@ function LoadingSpinner() {
);
}
function ChatView({ index = 0 }: { index?: number }) {
function ChatView({
index = 0,
modelCosts,
}: {
index?: number;
modelCosts?: { modelCostTable: Record<string, { prompt: number; completion: number }> };
}) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
@@ -37,6 +44,9 @@ function ChatView({ index = 0 }: { index?: number }) {
const fileMap = useFileMapContext();
const [showCostBar, setShowCostBar] = useState(false);
const lastScrollY = useRef(0);
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
select: useCallback(
(data: TMessage[]) => {
@@ -54,6 +64,58 @@ function ChatView({ index = 0 }: { index?: number }) {
useSSE(rootSubmission, chatHelpers, false);
useSSE(addedSubmission, addedChatHelpers, true);
const checkIfAtBottom = useCallback(
(container: HTMLElement) => {
const currentScrollY = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollY - clientHeight;
const isAtBottom = distanceFromBottom < 10;
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
setShowCostBar(isAtBottom && !isStreaming);
lastScrollY.current = currentScrollY;
},
[chatHelpers.isSubmitting, addedChatHelpers.isSubmitting],
);
useEffect(() => {
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement;
checkIfAtBottom(target);
};
const findAndAttachScrollListener = () => {
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
if (messagesContainer) {
checkIfAtBottom(messagesContainer as HTMLElement);
messagesContainer.addEventListener('scroll', handleScroll, { passive: true });
return () => {
messagesContainer.removeEventListener('scroll', handleScroll);
};
}
setTimeout(findAndAttachScrollListener, 100);
};
const cleanup = findAndAttachScrollListener();
return cleanup;
}, [messagesTree, checkIfAtBottom]);
useEffect(() => {
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
if (isStreaming) {
setShowCostBar(false);
} else {
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
if (messagesContainer) {
checkIfAtBottom(messagesContainer as HTMLElement);
}
}
}, [chatHelpers.isSubmitting, addedChatHelpers.isSubmitting, checkIfAtBottom]);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
@@ -69,7 +131,22 @@ function ChatView({ index = 0 }: { index?: number }) {
} else if ((isLoading || isNavigating) && !isLandingPage) {
content = <LoadingSpinner />;
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
content = (
<MessagesView
messagesTree={messagesTree}
costBar={
!isLandingPage &&
modelCosts && (
<CostBar
messagesTree={messagesTree}
modelCosts={modelCosts}
showCostBar={showCostBar && !isStreaming}
/>
)
}
/>
);
} else {
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { ArrowIcon } from '@librechat/client';
import { TModelCosts, TMessage } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
interface CostBarProps {
messagesTree: TMessage[];
modelCosts: TModelCosts;
showCostBar: boolean;
}
export default function CostBar({ messagesTree, modelCosts, showCostBar }: CostBarProps) {
const localize = useLocalize();
const showCostTracking = useRecoilValue(store.showCostTracking);
const conversationCosts = useMemo(() => {
if (!modelCosts?.modelCostTable || !messagesTree) {
return null;
}
let totalPromptTokens = 0;
let totalCompletionTokens = 0;
let totalPromptUSD = 0;
let totalCompletionUSD = 0;
const flattenMessages = (messages: TMessage[]) => {
const flattened: TMessage[] = [];
messages.forEach((message: TMessage) => {
flattened.push(message);
if (message.children && message.children.length > 0) {
flattened.push(...flattenMessages(message.children));
}
});
return flattened;
};
const allMessages = flattenMessages(messagesTree);
allMessages.forEach((message) => {
if (!message.tokenCount) {
return null;
}
const modelToUse = message.isCreatedByUser ? message.targetModel : message.model;
const modelPricing = modelCosts.modelCostTable[modelToUse];
if (message.isCreatedByUser) {
totalPromptTokens += message.tokenCount;
totalPromptUSD += (message.tokenCount / 1000000) * modelPricing.prompt;
} else {
totalCompletionTokens += message.tokenCount;
totalCompletionUSD += (message.tokenCount / 1000000) * modelPricing.completion;
}
});
const totalTokens = totalPromptTokens + totalCompletionTokens;
const totalUSD = totalPromptUSD + totalCompletionUSD;
return {
totals: {
prompt: { tokenCount: totalPromptTokens, usd: totalPromptUSD },
completion: { tokenCount: totalCompletionTokens, usd: totalCompletionUSD },
total: { tokenCount: totalTokens, usd: totalUSD },
},
};
}, [modelCosts, messagesTree]);
if (!showCostTracking || !conversationCosts || !conversationCosts.totals) {
return null;
}
return (
<div
className={cn(
'mx-auto w-full max-w-md px-4 text-xs text-muted-foreground transition-all duration-300 ease-in-out',
showCostBar ? 'opacity-100' : 'opacity-0',
)}
>
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<div>
<ArrowIcon direction="up" />
{localize('com_ui_token_abbreviation', {
0: conversationCosts.totals.prompt.tokenCount,
})}
</div>
<div>${Math.abs(conversationCosts.totals.prompt.usd).toFixed(6)}</div>
</div>
<div>
<div>
{localize('com_ui_token_abbreviation', {
0: conversationCosts.totals.total.tokenCount,
})}
</div>
<div>${Math.abs(conversationCosts.totals.total.usd).toFixed(6)}</div>
</div>
<div>
<div>
<ArrowIcon direction="down" />
{localize('com_ui_token_abbreviation', {
0: conversationCosts.totals.completion.tokenCount,
})}
</div>
<div>${Math.abs(conversationCosts.totals.completion.usd).toFixed(6)}</div>
</div>
</div>
</div>
);
}

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, AgentToolResources } from 'librechat-data-provider';
import type { TPromptGroup } 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, toolResources?: AgentToolResources) => void;
submitPrompt: (textPrompt: string) => void;
}) {
const localize = useLocalize();
const hasAccess = useHasAccess({
@@ -95,6 +95,7 @@ function PromptsCommand({
if (!group) {
return;
}
const hasVariables = detectVariables(group.productionPrompt?.prompt ?? '');
if (hasVariables) {
if (e && e.key === 'Tab') {
@@ -104,7 +105,7 @@ function PromptsCommand({
setVariableDialogOpen(true);
return;
} else {
submitPrompt(group.productionPrompt?.prompt ?? '', group.productionPrompt?.tool_resources);
submitPrompt(group.productionPrompt?.prompt ?? '');
}
},
[setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt],

View File

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

View File

@@ -1,8 +1,9 @@
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 { UIResource } from '~/common';
import type { TAttachment, UIResource } from 'librechat-data-provider';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
return (
@@ -27,12 +28,14 @@ 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) => {
@@ -54,25 +57,12 @@ export default function ToolCallInfo({
: localize('com_assistants_attempt_info');
}
// 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);
}
}
const uiResources: UIResource[] =
attachments
?.filter((attachment) => attachment.type === Tools.ui_resources)
.flatMap((attachment) => {
return attachment[Tools.ui_resources] as UIResource[];
}) ?? [];
return (
<div className="w-full p-2">

View File

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

View File

@@ -0,0 +1,382 @@
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,8 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ToolCallInfo from '../ToolCallInfo';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from '../UIResourceCarousel';
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';
// Mock the dependencies
jest.mock('~/hooks', () => ({
@@ -46,24 +48,25 @@ describe('ToolCallInfo', () => {
jest.clearAllMocks();
});
describe('ui_resources extraction', () => {
it('should extract single ui_resource from output', () => {
describe('ui_resources from attachments', () => {
it('should render single ui_resource from attachments', () => {
const uiResource = {
type: 'text',
data: 'Test resource',
};
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith(
@@ -81,29 +84,33 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
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' },
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' },
],
},
];
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: uiResources,
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources,
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
}),
expect.any(Object),
);
@@ -112,34 +119,38 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('should filter out ui_resources from displayed output', () => {
const regularContent = [
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
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' }],
},
];
const output = JSON.stringify([
...regularContent,
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
]);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
const { container } = render(
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
);
// Check that the displayed output doesn't contain ui_resources
// Check that the output is displayed normally
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');
expect(outputCode).not.toContain('ui_resources');
// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
});
it('should handle output without ui_resources', () => {
it('should handle no attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
@@ -148,66 +159,56 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
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',
},
]);
it('should handle empty attachments array', () => {
const attachments: TAttachment[] = [];
// Component should not throw error and should render without UI resources
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should render the component without crashing
expect(container).toBeTruthy();
// UIResourceCarousel should not be called since the metadata structure is invalid
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
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';
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: [],
},
},
];
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
render(<ToolCallInfo {...mockProps} attachments={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
// Should not render UI resources components for non-ui_resources attachments
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist', () => {
const output = JSON.stringify([
it('should render UI Resources heading when ui_resources exist in attachments', () => {
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources section to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument();
});
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} />);
it('should not render UI Resources heading when no ui_resources in attachments', () => {
render(<ToolCallInfo {...mockProps} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});
@@ -218,16 +219,18 @@ describe('ToolCallInfo', () => {
data: { fields: [{ name: 'test', type: 'text' }] },
};
const output = JSON.stringify([
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
@@ -244,16 +247,18 @@ describe('ToolCallInfo', () => {
it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const output = JSON.stringify([
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
@@ -270,4 +275,55 @@ 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 { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UIResourceCarousel from '../UIResourceCarousel';
import type { UIResource } from '~/common';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { useMessageProcess } from '~/hooks';
import type { TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageRender from './ui/MessageRender';
// eslint-disable-next-line import/no-cycle
@@ -28,7 +29,7 @@ const MessageContainer = React.memo(
},
);
export default function Message(props: TMessageProps) {
export default function Message(props: TMessageProps & { costs?: TConversationCosts }) {
const {
showSibling,
conversation,
@@ -37,7 +38,7 @@ export default function Message(props: TMessageProps) {
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props;
const { message, currentEditId, setCurrentEditId, costs } = props;
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
if (!message || typeof message !== 'object') {
@@ -62,6 +63,7 @@ export default function Message(props: TMessageProps) {
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
costs={costs}
/>
<MessageRender
{...props}
@@ -69,12 +71,13 @@ export default function Message(props: TMessageProps) {
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
costs={costs}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<MessageRender {...props} />
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} costs={costs} />
</div>
)}
</MessageContainer>
@@ -85,6 +88,7 @@ export default function Message(props: TMessageProps) {
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
costs={costs}
/>
</>
);

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageContentParts, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
@@ -12,10 +12,17 @@ import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message(props: TMessageProps) {
export default function Message(props: TMessageProps & { costs?: TConversationCosts }) {
const localize = useLocalize();
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
const {
message,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId,
setCurrentEditId,
costs,
} = props;
const { attachments, searchResults } = useAttachments({
messageId: message?.messageId,
attachments: message?.attachments,
@@ -164,6 +171,7 @@ export default function Message(props: TMessageProps) {
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
costs={costs}
/>
</>
);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import type { TMessage, TConversationCosts } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import MultiMessage from './MultiMessage';
@@ -10,8 +10,12 @@ import store from '~/store';
export default function MessagesView({
messagesTree: _messagesTree,
costBar,
costs,
}: {
messagesTree?: TMessage[] | null;
costBar?: React.ReactNode;
costs?: TConversationCosts;
}) {
const localize = useLocalize();
const fontSize = useRecoilValue(store.fontSize);
@@ -44,7 +48,7 @@ export default function MessagesView({
width: '100%',
}}
>
<div className="flex flex-col pb-9 dark:bg-transparent">
<div className="flex flex-col dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(
@@ -63,18 +67,25 @@ export default function MessagesView({
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
costs={costs}
/>
</div>
</>
)}
<div
id="messages-end"
className="group h-0 w-full flex-shrink-0"
className="group h-1 w-full flex-shrink-0 pb-7"
ref={messagesEndRef}
/>
</div>
</div>
{costBar && (
<div className="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2">
{costBar}
</div>
)}
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{

View File

@@ -1,7 +1,7 @@
import { useRecoilState } from 'recoil';
import { useEffect, useCallback } from 'react';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { TMessage, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageContent from '~/components/Messages/MessageContent';
import MessageParts from './MessageParts';
@@ -14,7 +14,8 @@ export default function MultiMessage({
messagesTree,
currentEditId,
setCurrentEditId,
}: TMessageProps) {
costs,
}: TMessageProps & { costs?: TConversationCosts }) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = useCallback(
@@ -55,6 +56,7 @@ export default function MultiMessage({
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
costs={costs}
/>
);
} else if (message.content) {
@@ -67,6 +69,7 @@ export default function MultiMessage({
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
costs={costs}
/>
);
}
@@ -80,6 +83,7 @@ export default function MultiMessage({
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
costs={costs}
/>
);
}

View File

@@ -1,16 +1,17 @@
import React, { useCallback, useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { type TMessage } from 'librechat-data-provider';
import { ArrowIcon } from '@librechat/client';
import { type TMessage, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useMessageActions, useLocalize } from '~/hooks';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { MessageContext } from '~/Providers';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
@@ -19,6 +20,7 @@ type MessageRenderProps = {
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
costs?: TConversationCosts;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
@@ -35,7 +37,9 @@ const MessageRender = memo(
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily = false,
costs,
}: MessageRenderProps) => {
const localize = useLocalize();
const {
ask,
edit,
@@ -60,6 +64,18 @@ const MessageRender = memo(
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const showCostTracking = useRecoilValue(store.showCostTracking);
const perMessageCost = useMemo(() => {
if (!showCostTracking || !costs || !costs.perMessage || !msg?.messageId) {
return null;
}
const entry = costs.perMessage.find((p) => p.messageId === msg.messageId);
if (!entry) {
return null;
}
return entry;
}, [showCostTracking, costs, msg?.messageId]);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const hasNoChildren = !(msg?.children?.length ?? 0);
@@ -157,7 +173,26 @@ const MessageRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
{perMessageCost && (
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
{perMessageCost.tokenCount > 0 && (
<span>
{perMessageCost.tokenType === 'prompt' ? (
<ArrowIcon direction="up" className="inline" />
) : (
<ArrowIcon direction="down" className="inline" />
)}
{localize('com_ui_token_abbreviation', {
0: perMessageCost.tokenCount,
})}
</span>
)}
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
</span>
)}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View File

@@ -1,13 +1,14 @@
import { useRecoilValue } from 'recoil';
import { ArrowIcon } from '@librechat/client';
import { useCallback, useMemo, memo } from 'react';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import { useAttachments, useMessageActions, useLocalize } from '~/hooks';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAttachments, useMessageActions } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, logger } from '~/utils';
import store from '~/store';
@@ -17,6 +18,7 @@ type ContentRenderProps = {
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
costs?: TConversationCosts;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
@@ -33,7 +35,9 @@ const ContentRender = memo(
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily = false,
costs,
}: ContentRenderProps) => {
const localize = useLocalize();
const { attachments, searchResults } = useAttachments({
messageId: msg?.messageId,
attachments: msg?.attachments,
@@ -62,6 +66,14 @@ const ContentRender = memo(
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const showCostTracking = useRecoilValue(store.showCostTracking);
const perMessageCost = useMemo(() => {
if (!showCostTracking || !costs || !costs.perMessage || !msg?.messageId) {
return null;
}
return costs.perMessage.find((p) => p.messageId === msg.messageId) ?? null;
}, [showCostTracking, costs, msg?.messageId]);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo(
@@ -159,7 +171,26 @@ const ContentRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
{perMessageCost && (
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
{perMessageCost.tokenCount > 0 && (
<span className="mr-2">
{perMessageCost.tokenType === 'prompt' ? (
<ArrowIcon direction="up" className="inline" />
) : (
<ArrowIcon direction="down" className="inline" />
)}
{localize('com_ui_token_abbreviation', {
0: perMessageCost.tokenCount,
})}
</span>
)}
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
</span>
)}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useMessageProcess } from '~/hooks';
import type { TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
@@ -25,7 +26,7 @@ const MessageContainer = React.memo(
},
);
export default function MessageContent(props: TMessageProps) {
export default function MessageContent(props: TMessageProps & { costs?: TConversationCosts }) {
const {
showSibling,
conversation,
@@ -34,7 +35,7 @@ export default function MessageContent(props: TMessageProps) {
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props;
const { message, currentEditId, setCurrentEditId, costs } = props;
if (!message || typeof message !== 'object') {
return null;
@@ -53,6 +54,7 @@ export default function MessageContent(props: TMessageProps) {
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
costs={costs}
/>
<ContentRender
{...props}
@@ -60,12 +62,13 @@ export default function MessageContent(props: TMessageProps) {
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
costs={costs}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<ContentRender {...props} />
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender {...props} costs={costs} />
</div>
)}
</MessageContainer>
@@ -76,6 +79,7 @@ export default function MessageContent(props: TMessageProps) {
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
costs={costs}
/>
</>
);

View File

@@ -76,6 +76,13 @@ const toggleSwitchConfigs = [
hoverCardText: undefined,
key: 'modularChat',
},
{
stateAtom: store.showCostTracking,
localizationKey: 'com_nav_show_cost_tracking',
switchId: 'showCostTracking',
hoverCardText: 'com_nav_info_show_cost_tracking',
key: 'showCostTracking',
},
];
function Chat() {

View File

@@ -1,166 +0,0 @@
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, ResourceType } from 'librechat-data-provider';
import { PermissionBits } 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,18 +34,9 @@ function ChatGroupItem({
);
// Check permissions for the promptGroup
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
const { hasPermission } = useResourcePermissions('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()) {
@@ -57,7 +48,7 @@ function ChatGroupItem({
return;
}
submitPrompt(text, group.productionPrompt?.tool_resources);
submitPrompt(text);
};
return (
@@ -66,7 +57,6 @@ 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,12 +3,11 @@ 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';
@@ -20,7 +19,6 @@ type CreateFormValues = {
category: string;
oneliner?: string;
command?: string;
tool_resources?: AgentToolResources;
};
const defaultPrompt: CreateFormValues = {
@@ -39,14 +37,6 @@ const CreatePromptForm = ({
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const {
promptFiles: files,
setFiles,
handleFileChange,
getToolResources,
} = usePromptFileHandling();
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
@@ -98,15 +88,8 @@ 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: promptData,
prompt: rest,
group: groupData,
});
};
@@ -178,13 +161,7 @@ const CreatePromptForm = ({
/>
</div>
</div>
<PromptVariablesAndFiles
promptText={promptText}
files={files}
onFilesChange={setFiles}
handleFileChange={handleFileChange}
disabled={isSubmitting}
/>
<PromptVariables promptText={promptText} />
<Description
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={0}

View File

@@ -93,11 +93,7 @@ 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 flex-shrink-0"
aria-hidden="true"
/>
<CategoryIcon category={group.category ?? ''} className="icon-lg" aria-hidden="true" />
<Label className="text-md cursor-pointer truncate font-semibold text-text-primary">
{group.name}

View File

@@ -1,6 +1,5 @@
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({
@@ -9,14 +8,12 @@ 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 === ' ') {
@@ -38,7 +35,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 flex-shrink-0" aria-hidden="true" />
<CategoryIcon category={category} className="icon-md" aria-hidden="true" />
<Label
id={`card-title-${name}`}
className="break-word select-none text-balance text-sm font-semibold text-text-primary"
@@ -46,7 +43,6 @@ 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, group.productionPrompt?.tool_resources);
submitPrompt(text);
onClose();
};

View File

@@ -7,10 +7,9 @@ import supersub from 'remark-supersub';
import { Label } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import type { TPromptGroup, AgentToolResources } from 'librechat-data-provider';
import type { TPromptGroup } 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';
@@ -26,17 +25,6 @@ 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;
}
@@ -84,7 +72,6 @@ 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

@@ -1,141 +0,0 @@
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

@@ -1,80 +0,0 @@
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

@@ -1,121 +0,0 @@
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,13 +12,7 @@ import {
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import type {
TCreatePrompt,
TPrompt,
TPromptGroup,
AgentToolResources,
} from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import {
useGetPrompts,
useGetPromptGroup,
@@ -26,11 +20,11 @@ import {
useUpdatePromptGroup,
useMakePromptProduction,
} from '~/data-provider';
import { useResourcePermissions, useHasAccess, useLocalize, usePromptFileHandling } from '~/hooks';
import PromptVariablesAndFiles from './PromptVariablesAndFiles';
import { useResourcePermissions, useHasAccess, useLocalize } from '~/hooks';
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';
@@ -125,12 +119,7 @@ const RightPanel = React.memo(
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: {
prompt,
...(selectedPrompt.tool_resources && {
tool_resources: selectedPrompt.tool_resources,
}),
},
productionPrompt: { prompt },
});
}}
disabled={
@@ -190,21 +179,6 @@ 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 },
@@ -226,7 +200,7 @@ const PromptForm = () => {
category: group ? group.category : '',
},
});
const { handleSubmit, setValue, reset, watch, getValues } = methods;
const { handleSubmit, setValue, reset, watch } = methods;
const promptText = watch('prompt');
const selectedPrompt = useMemo(
@@ -263,10 +237,7 @@ const PromptForm = () => {
makeProductionMutation.mutate({
id: data.prompt._id,
groupId: data.prompt.groupId,
productionPrompt: {
prompt: data.prompt.prompt,
...(data.prompt.tool_resources && { tool_resources: data.prompt.tool_resources }),
},
productionPrompt: { prompt: data.prompt.prompt },
});
}
@@ -278,30 +249,8 @@ 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, updatedFiles?: ExtendedFile[]) => {
(value: string) => {
if (!canEdit) {
return;
}
@@ -319,36 +268,22 @@ 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 }),
},
};
const promptTextChanged = value !== selectedPrompt.prompt;
const toolResourcesChanged =
JSON.stringify(toolResources) !== JSON.stringify(selectedPrompt.tool_resources);
if (!promptTextChanged && !toolResourcesChanged) {
if (value === selectedPrompt.prompt) {
return;
}
// We're adding to an existing group, so use the addPromptToGroup mutation
addPromptToGroupMutation.mutate({ ...tempPrompt, groupId });
},
[
selectedPrompt,
group,
addPromptToGroupMutation,
canEdit,
getToolResources,
getToolResourcesFromFiles,
],
[selectedPrompt, group, addPromptToGroupMutation, canEdit],
);
const handleLoadingComplete = useCallback(() => {
@@ -372,13 +307,7 @@ const PromptForm = () => {
useEffect(() => {
setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false });
setValue('category', group ? group.category : '', { shouldDirty: false });
if (selectedPrompt?.tool_resources) {
loadFromToolResources(selectedPrompt.tool_resources);
} else {
loadFromToolResources(undefined);
}
}, [selectedPrompt, group, setValue, loadFromToolResources]);
}, [selectedPrompt, group, setValue]);
useEffect(() => {
const handleResize = () => {
@@ -518,19 +447,7 @@ const PromptForm = () => {
isEditing={isEditing}
setIsEditing={(value) => canEdit && setIsEditing(value)}
/>
<PromptVariablesAndFiles
promptText={promptText}
files={hookPromptFiles}
onFilesChange={(files) => {
setFiles(files);
if (canEdit && selectedPrompt) {
const currentPromptText = getValues('prompt');
onSave(currentPromptText, files);
}
}}
handleFileChange={handleFileChange}
disabled={!canEdit}
/>
<PromptVariables promptText={promptText} />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={canEdit ? handleUpdateOneliner : undefined}

View File

@@ -1,43 +0,0 @@
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,5 +8,3 @@ 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

@@ -4,7 +4,6 @@ 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,
@@ -14,20 +13,18 @@ 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 { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { removeTool } = useRemoveMCPTool();
const { getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
@@ -56,36 +53,6 @@ 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,25 +1,12 @@
import React, { useState } from 'react';
import { CircleX } from 'lucide-react';
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 { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
import { useLocalize, useRemoveMCPTool } from '~/hooks';
import { cn } from '~/utils';
export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { removeTool } = useRemoveMCPTool();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
@@ -28,36 +15,6 @@ 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
@@ -116,7 +73,7 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
</Label>
}
selection={{
selectHandler: () => removeTool(),
selectHandler: () => removeTool(serverName || ''),
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,29 +1,18 @@
import React, { useState } from 'react';
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 { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
import type { 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 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();
@@ -31,39 +20,6 @@ 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

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

View File

@@ -79,7 +79,6 @@ export default function useChatFunctions({
parentMessageId = null,
conversationId = null,
messageId = null,
toolResources,
},
{
editedContent = null,
@@ -205,7 +204,6 @@ 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

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

View File

@@ -0,0 +1,61 @@
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

@@ -19,6 +19,7 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const { conversationId } = conversation ?? {};
const timeoutIdRef = useRef<NodeJS.Timeout>();
const prevIsSubmittingRef = useRef<boolean>(false);
const debouncedSetShowScrollButton = useCallback((value: boolean) => {
clearTimeout(timeoutIdRef.current);
@@ -60,7 +61,10 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
}
}, [debouncedSetShowScrollButton]);
const scrollCallback = () => debouncedSetShowScrollButton(false);
const scrollCallback = useCallback(
() => debouncedSetShowScrollButton(false),
[debouncedSetShowScrollButton],
);
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
targetRef: messagesEndRef,
@@ -71,6 +75,18 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
},
});
const smoothScrollToBottom = useCallback(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest',
});
scrollCallback();
setAbortScroll(false);
}
}, [scrollCallback, setAbortScroll]);
useEffect(() => {
if (!messagesTree || messagesTree.length === 0) {
return;
@@ -91,6 +107,20 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
};
}, [isSubmitting, messagesTree, scrollToBottom, abortScroll]);
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
return;
}
if (prevIsSubmittingRef.current && !isSubmitting && abortScroll !== true) {
setTimeout(() => {
smoothScrollToBottom();
}, 100);
}
prevIsSubmittingRef.current = isSubmitting;
}, [isSubmitting, smoothScrollToBottom, abortScroll]);
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
return;

View File

@@ -1,13 +1,9 @@
import { v4 } from 'uuid';
import { useCallback, useMemo } from 'react';
import { useCallback } 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) => {
@@ -20,67 +16,15 @@ const appendIndex = (index: number, value?: string) => {
export default function useSubmitMessage() {
const { user } = useAuthContext();
const methods = useChatFormContext();
const { ask, index, getMessages, setMessages, latestMessage, setFiles } = useChatContext();
const { ask, index, getMessages, setMessages, latestMessage } = 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; toolResources?: AgentToolResources; files?: ExtendedFile[] }) => {
(data?: { text: string }) => {
if (!data) {
return console.warn('No data provided to submitMessage');
}
@@ -102,18 +46,12 @@ 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,
toolResources: data.toolResources,
},
{
overrideFiles: data.files,
},
);
ask({
text: data.text,
overrideConvoId: appendIndex(rootIndex, overrideConvoId),
overrideUserMessageId: appendIndex(rootIndex, overrideUserMessageId),
clientTimestamp,
});
if (hasAdded) {
askAdditional(
@@ -122,12 +60,8 @@ 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();
@@ -146,36 +80,18 @@ export default function useSubmitMessage() {
);
const submitPrompt = useCallback(
(text: string, toolResources?: AgentToolResources) => {
(text: string) => {
const parsedText = replaceSpecialVars({ text, user });
if (autoSendPrompts) {
const promptFiles = toolResources ? convertToolResourcesToFiles(toolResources) : [];
submitMessage({ text: parsedText, toolResources, files: promptFiles });
submitMessage({ text: parsedText });
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,
addFile,
convertToolResourcesToFiles,
],
[autoSendPrompts, submitMessage, setActivePrompt, methods, user],
);
return { submitMessage, submitPrompt };

View File

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

View File

@@ -1,398 +0,0 @@
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

@@ -232,8 +232,14 @@ export default function useEventHandlers({
},
]);
}
if (userMessage?.conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, userMessage.conversationId, 'costs'],
});
}
},
[setMessages, announcePolite, setIsSubmitting],
[setMessages, announcePolite, setIsSubmitting, queryClient],
);
const cancelHandler = useCallback(
@@ -275,6 +281,12 @@ export default function useEventHandlers({
});
}
if (convoUpdate?.conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, convoUpdate.conversationId, 'costs'],
});
}
setIsSubmitting(false);
},
[setMessages, setConversation, genTitle, isAddedRequest, queryClient, setIsSubmitting],
@@ -341,6 +353,12 @@ export default function useEventHandlers({
if (resetLatestMessage) {
resetLatestMessage();
}
if (conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, conversationId, 'costs'],
});
}
},
[
queryClient,
@@ -527,6 +545,12 @@ export default function useEventHandlers({
);
}
if (conversation.conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, conversation.conversationId, 'costs'],
});
}
if (isNewConvo && submissionConvo.conversationId) {
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId);
}

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from 'lodash';
interface UseInfiniteScrollOptions {
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isLoading?: boolean;
fetchNextPage: () => void;
threshold?: number; // Percentage of scroll position to trigger fetch (0-1)
throttleMs?: number; // Throttle delay in milliseconds
@@ -15,77 +15,95 @@ interface UseInfiniteScrollOptions {
*/
export const useInfiniteScroll = ({
hasNextPage = false,
isFetchingNextPage = false,
isLoading = false,
fetchNextPage,
threshold = 0.8, // Trigger when 80% scrolled
throttleMs = 200,
}: UseInfiniteScrollOptions) => {
const scrollElementRef = useRef<HTMLElement | null>(null);
// Monitor resizing of the scroll container
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [scrollElement, setScrollElementState] = useState<HTMLElement | null>(null);
// Throttled scroll handler to prevent excessive API calls
const handleScroll = useCallback(
throttle(() => {
const element = scrollElementRef.current;
if (!element) return;
// Handler to check if we need to fetch more data
const handleNeedToFetch = useCallback(() => {
if (!scrollElement) return;
const { scrollTop, scrollHeight, clientHeight } = element;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
// 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 && !isFetchingNextPage;
// Check if we've scrolled past the threshold and conditions are met
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isLoading;
if (shouldFetch) {
fetchNextPage();
}
}, throttleMs),
[hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs],
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],
);
// Set up scroll listener
// Clean up throttled function on unmount
useEffect(() => {
const element = scrollElementRef.current;
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;
if (!element) return;
// Remove any existing listener first
element.removeEventListener('scroll', handleScroll);
// Add the scroll listener
element.addEventListener('scroll', throttledHandleNeedToFetch, { passive: true });
// Add the new listener
element.addEventListener('scroll', handleScroll, { passive: true });
// 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();
return () => {
element.removeEventListener('scroll', handleScroll);
// Clean up throttled function
handleScroll.cancel?.();
element.removeEventListener('scroll', throttledHandleNeedToFetch);
// Clean up ResizeObserver
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
};
}, [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]);
}, [scrollElement, throttledHandleNeedToFetch]);
// Function to manually set the scroll container
const setScrollElement = useCallback((element: HTMLElement | null) => {
scrollElementRef.current = element;
setScrollElementState(element);
}, []);
return {
setScrollElement,
scrollElementRef,
};
};
export default useInfiniteScroll;

View File

@@ -31,7 +31,7 @@ export default function useScrollToRef({
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToRef = useCallback(
throttle(() => logAndScroll('instant', callback), 145, { leading: true }),
throttle(() => logAndScroll('instant', callback), 100, { leading: true }),
[targetRef],
);

View File

@@ -568,6 +568,8 @@
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
"com_nav_show_code": "Always show code when using code interpreter",
"com_nav_show_cost_tracking": "Show cost tracking",
"com_nav_info_show_cost_tracking": "Display conversation costs and per-message cost breakdowns",
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
@@ -703,7 +705,6 @@
"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",
@@ -835,7 +836,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_success": "Tool deleted successfully",
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
"com_ui_deleted": "Deleted",
"com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc",
@@ -898,7 +899,6 @@
"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",
@@ -1046,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",
@@ -1199,6 +1199,7 @@
"com_ui_thinking": "Thinking...",
"com_ui_thoughts": "Thoughts",
"com_ui_token": "token",
"com_ui_token_abbreviation": "{{0}}t",
"com_ui_token_exchange_method": "Token Exchange Method",
"com_ui_token_url": "Token URL",
"com_ui_tokens": "tokens",

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 failu meklēšanu",
"com_agents_enable_file_search": "Iespējot faila augšupielādi informācijas iegūšanai no tā",
"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": "Pirms failu augšupielādes failu meklēšanai ir jāizveido aģents.",
"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_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": "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_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_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 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_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_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ī failu meklēšana nav iespējota. Lūdzu, izvēlieties citu modeli.",
"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_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": "Jau ir konts?",
"com_auth_already_have_account": "Vai jums 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",
@@ -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 (aģentu maisījums)",
"com_ui_agent_chain": "Aģentu ķēde (Vairāki aģenti kopā sasaistīti)",
"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īmes",
"com_ui_bookmarks_add": "Pievienot grāmatzīmi",
"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": "Iepriekšējās 30 dienas",
"com_ui_date_previous_7_days": "Iepriekšējās 7 dienas",
"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_september": "Septembris",
"com_ui_date_today": "Šodien",
"com_ui_date_yesterday": "Vakar",
@@ -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": "Nav rezultātu",
"com_ui_drag_drop": "Ievietojiet šeit jebkuru failu, lai pievienotu to sarunai",
"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": "Nepieciešami uzlabojumi",
"com_ui_feedback_negative": "Atbildei nepieciešami uzlabojumi",
"com_ui_feedback_placeholder": "Lūdzu, sniedziet šeit jebkādas papildu atsauksmes.",
"com_ui_feedback_positive": "Man tas patīk",
"com_ui_feedback_positive": "Lieliska atbilde",
"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",
@@ -1046,6 +1046,7 @@
"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",
@@ -1119,7 +1120,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": "Palaišanas kods",
"com_ui_run_code": "Palaist kodu",
"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?",
@@ -1187,7 +1188,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.com",
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.lv",
"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",
@@ -1220,19 +1221,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 koda interpretētājam",
"com_ui_upload_code_files": "Augšupielādēt failu 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 kontekstā",
"com_ui_upload_file_search": "Augšupielādēt failu meklēšanai",
"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_files": "Augšupielādēt failus",
"com_ui_upload_image": "Augšupielādēt attēlu",
"com_ui_upload_image_input": "Augšupielādēt attēlu",
"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_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 kā tekstu",
"com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu",
"com_ui_upload_success": "Fails veiksmīgi augšupielādēts",
"com_ui_upload_type": "Atlasiet augšupielādes veidu",
"com_ui_upload_type": "Izvēlieties 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

@@ -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\n",
"com_agents_all": "Alle Agents\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}}",
@@ -215,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 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_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_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

@@ -848,7 +848,7 @@
"com_ui_download_backup": "下载备份代码",
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
"com_ui_drag_drop": "这里需要放点东西,当前是空的",
"com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
"com_ui_dropdown_variables": "下拉变量:",
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "复制",
@@ -1044,6 +1044,7 @@
"com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。",
"com_ui_oauth_error_missing_state": "缺少状态参数,请重试。",
"com_ui_oauth_error_title": "认证失败",
"com_ui_oauth_revoke": "撤销",
"com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:",
"com_ui_oauth_success_title": "认证成功",
"com_ui_of": "/",
@@ -1252,6 +1253,7 @@
"com_ui_web_search_cohere_key": "输入 Cohere API Key",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL可选",
"com_ui_web_search_jina_key": "输入 Jina API Key",
"com_ui_web_search_jina_url": "Jina API URL可选",
"com_ui_web_search_processing": "正在处理结果",
"com_ui_web_search_provider": "搜索提供商",
"com_ui_web_search_provider_searxng": "SearXNG",
@@ -1263,6 +1265,7 @@
"com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key",
"com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key",
"com_ui_web_search_reranker_jina_url_help": "了解 Jina Rerank API",
"com_ui_web_search_scraper": "抓取器",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",

View File

@@ -6,6 +6,7 @@ import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import type { TPreset } from 'librechat-data-provider';
import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider';
import { useNewConvo, useAppStartup, useAssistantListMap, useIdChangeEffect } from '~/hooks';
import { useGetModelCostsQuery } from 'librechat-data-provider/react-query';
import { getDefaultModelSpec, getModelSpecPreset, logger } from '~/utils';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
@@ -44,6 +45,10 @@ export default function ChatRoute() {
const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated });
const assistantListMap = useAssistantListMap();
const modelCostsQuery = useGetModelCostsQuery(initialConvoQuery.data?.modelHistory || [], {
enabled: !!initialConvoQuery.data?.modelHistory?.length,
});
const isTemporaryChat = conversation && conversation.expiredAt ? true : false;
useEffect(() => {
@@ -148,7 +153,7 @@ export default function ChatRoute() {
return (
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
<ChatView index={index} />
<ChatView index={index} modelCosts={modelCostsQuery.data} />
</ToolCallsMapProvider>
);
}

View File

@@ -34,6 +34,7 @@ const localStorageAtoms = {
showCode: atomWithLocalStorage(LocalStorageKeys.SHOW_ANALYSIS_CODE, true),
saveDrafts: atomWithLocalStorage('saveDrafts', true),
showScrollButton: atomWithLocalStorage('showScrollButton', true),
showCostTracking: atomWithLocalStorage('showCostTracking', true),
forkSetting: atomWithLocalStorage('forkSetting', ''),
splitAtTarget: atomWithLocalStorage('splitAtTarget', false),
rememberDefaultFork: atomWithLocalStorage(LocalStorageKeys.REMEMBER_FORK_OPTION, false),

View File

@@ -1,3 +1,3 @@
// v0.8.0-rc3
// v0.8.0-rc4
// See .env.test.example for an example of the '.env.test' file.
require('dotenv').config({ path: './e2e/.env.test' });

View File

@@ -57,7 +57,7 @@ spec:
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.auth.existingSecret }}
key: {{ .Values.postgresql.auth.existingSecretKey }}
key: {{ .Values.postgresql.auth.secretKeys.userPasswordKey }}
{{- end }}
{{- if .Values.global.librechat.existingSecretName }}
- name: OPENAI_API_KEY

View File

@@ -14,6 +14,7 @@ image:
# Overrides the image tag whose default is the chart appVersion.
tag: latest
# See https://github.com/bitnami/charts/blob/main/bitnami/postgresql/values.yaml for more details.
postgresql:
enabled: true
# nameOverride: vectordb
@@ -27,7 +28,10 @@ postgresql:
# define a secret with values for "postgres-password", "password" (user Password) and "replication-password" or add values directly
existingSecret: librechat-vectordb
# postgres-password is needed to enable pgvector extension. If you enable it manually you can use "password" and user "librechat"
existingSecretKey: postgres-password
secretKeys:
userPasswordKey: postgres-password
adminPasswordKey: postgres-password
replicationPasswordKey: postgres-password
imagePullSecrets: []
nameOverride: ''

View File

@@ -23,7 +23,7 @@ version: 1.8.10
# It is recommended to use it with quotes.
# renovate: image=ghcr.io/danny-avila/librechat
appVersion: "v0.8.0-rc3"
appVersion: "v0.8.0-rc4"
home: https://www.librechat.ai

View File

@@ -30,6 +30,7 @@ spec:
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "librechat.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:

View File

@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "librechat.serviceAccountName" . }}
labels:
{{- include "librechat.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

View File

@@ -115,6 +115,17 @@ imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
serviceAccount:
# Specifies whether a service account should be created
create: true
# Automatically mount a ServiceAccount's API credentials?
automount: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
lifecycle: {}
# # base for adding a custom banner // see https://github.com/danny-avila/LibreChat/pull/3952 for an example

31
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "LibreChat",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "LibreChat",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"license": "ISC",
"workspaces": [
"api",
@@ -47,7 +47,7 @@
},
"api": {
"name": "@librechat/backend",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
@@ -72,7 +72,7 @@
"@modelcontextprotocol/sdk": "^1.17.1",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"axios": "^1.12.1",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",
@@ -2623,7 +2623,7 @@
},
"client": {
"name": "@librechat/frontend",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"license": "ISC",
"dependencies": {
"@ariakit/react": "^0.4.15",
@@ -31025,12 +31025,13 @@
}
},
"node_modules/axios": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz",
"integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -51607,7 +51608,7 @@
},
"packages/api": {
"name": "@librechat/api",
"version": "1.3.5",
"version": "1.4.0",
"license": "ISC",
"devDependencies": {
"@babel/preset-env": "^7.21.5",
@@ -51643,7 +51644,7 @@
"@librechat/agents": "^2.4.79",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",
"axios": "^1.8.2",
"axios": "^1.12.1",
"diff": "^7.0.0",
"eventsource": "^3.0.2",
"express": "^4.21.2",
@@ -51735,7 +51736,7 @@
},
"packages/client": {
"name": "@librechat/client",
"version": "0.2.8",
"version": "0.3.0",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
@@ -52037,10 +52038,10 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.8.006",
"version": "0.8.010",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",
"axios": "^1.12.1",
"dayjs": "^1.11.13",
"js-yaml": "^4.1.0",
"zod": "^3.22.4"
@@ -52141,7 +52142,7 @@
},
"packages/data-schemas": {
"name": "@librechat/data-schemas",
"version": "0.0.21",
"version": "0.0.22",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "LibreChat",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"description": "",
"workspaces": [
"api",
@@ -125,7 +125,7 @@
},
"overrides": {
"@langchain/openai": "0.5.18",
"axios": "1.8.2",
"axios": "1.12.1",
"elliptic": "^6.6.1",
"form-data": "^4.0.4",
"mdast-util-gfm-autolink-literal": "2.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/api",
"version": "1.3.5",
"version": "1.4.0",
"type": "commonjs",
"description": "MCP services for LibreChat",
"main": "dist/index.js",
@@ -76,7 +76,7 @@
"@librechat/agents": "^2.4.79",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",
"axios": "^1.8.2",
"axios": "^1.12.1",
"diff": "^7.0.0",
"eventsource": "^3.0.2",
"express": "^4.21.2",

View File

@@ -74,9 +74,23 @@ export class MCPConnectionFactory {
oauthTokens,
});
if (this.useOAuth) this.handleOAuthEvents(connection);
await this.attemptToConnect(connection);
return connection;
let cleanupOAuthHandlers: (() => void) | null = null;
if (this.useOAuth) {
cleanupOAuthHandlers = this.handleOAuthEvents(connection);
}
try {
await this.attemptToConnect(connection);
if (cleanupOAuthHandlers) {
cleanupOAuthHandlers();
}
return connection;
} catch (error) {
if (cleanupOAuthHandlers) {
cleanupOAuthHandlers();
}
throw error;
}
}
/** Retrieves existing OAuth tokens from storage or returns null */
@@ -133,8 +147,8 @@ export class MCPConnectionFactory {
}
/** Sets up OAuth event handlers for the connection */
protected handleOAuthEvents(connection: MCPConnection): void {
connection.on('oauthRequired', async (data) => {
protected handleOAuthEvents(connection: MCPConnection): () => void {
const oauthHandler = async (data: { serverUrl?: string }) => {
logger.info(`${this.logPrefix} oauthRequired event received`);
// If we just want to initiate OAuth and return, handle it differently
@@ -202,7 +216,13 @@ export class MCPConnectionFactory {
logger.warn(`${this.logPrefix} OAuth failed, emitting oauthFailed event`);
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
}
});
};
connection.on('oauthRequired', oauthHandler);
return () => {
connection.removeListener('oauthRequired', oauthHandler);
};
}
/** Attempts to establish connection with timeout handling */

View File

@@ -56,6 +56,9 @@ describe('MCPConnectionFactory', () => {
isConnected: jest.fn(),
setOAuthTokens: jest.fn(),
on: jest.fn().mockReturnValue(mockConnectionInstance),
once: jest.fn().mockReturnValue(mockConnectionInstance),
off: jest.fn().mockReturnValue(mockConnectionInstance),
removeListener: jest.fn().mockReturnValue(mockConnectionInstance),
emit: jest.fn(),
} as unknown as jest.Mocked<MCPConnection>;

View File

@@ -161,7 +161,7 @@ describe('formatToolContent', () => {
});
describe('resource handling', () => {
it('should handle UI resources', () => {
it('should handle UI resources in artifacts', () => {
const result: t.MCPToolCallResponse = {
content: [
{
@@ -181,22 +181,27 @@ describe('formatToolContent', () => {
expect(content).toEqual([
{
type: 'text',
text: '',
metadata: {
type: 'ui_resources',
data: [
{
uri: 'ui://carousel',
mimeType: 'application/json',
text: '{"items": []}',
name: 'carousel',
description: 'A carousel component',
},
],
},
text:
'Resource Text: {"items": []}\n' +
'Resource URI: ui://carousel\n' +
'Resource: carousel\n' +
'Resource Description: A carousel component\n' +
'Resource MIME Type: application/json',
},
]);
expect(artifacts).toBeUndefined();
expect(artifacts).toEqual({
ui_resources: {
data: [
{
uri: 'ui://carousel',
mimeType: 'application/json',
text: '{"items": []}',
name: 'carousel',
description: 'A carousel component',
},
],
},
});
});
it('should handle regular resources', () => {
@@ -281,24 +286,75 @@ describe('formatToolContent', () => {
expect(content).toEqual([
{
type: 'text',
text: 'Some text\n\n' + 'Resource URI: file://data.csv\n' + 'Resource: Data file',
text:
'Some text\n\n' +
'Resource Text: {"label": "Click me"}\n' +
'Resource URI: ui://button\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: file://data.csv\n' +
'Resource: Data file',
},
]);
expect(artifacts).toEqual({
ui_resources: {
data: [
{
uri: 'ui://button',
mimeType: 'application/json',
text: '{"label": "Click me"}',
},
],
},
});
});
it('should handle both images and UI resources in artifacts', () => {
const result: t.MCPToolCallResponse = {
content: [
{ type: 'text', text: 'Content with multimedia' },
{ type: 'image', data: 'base64imagedata', mimeType: 'image/png' },
{
type: 'resource',
resource: {
uri: 'ui://graph',
mimeType: 'application/json',
text: '{"type": "line"}',
},
},
],
};
const [content, artifacts] = formatToolContent(result, 'openai');
expect(content).toEqual([
{
type: 'text',
text: 'Content with multimedia',
},
{
type: 'text',
text: '',
metadata: {
type: 'ui_resources',
data: [
{
uri: 'ui://button',
mimeType: 'application/json',
text: '{"label": "Click me"}',
},
],
},
text:
'Resource Text: {"type": "line"}\n' +
'Resource URI: ui://graph\n' +
'Resource MIME Type: application/json',
},
]);
expect(artifacts).toBeUndefined();
expect(artifacts).toEqual({
content: [
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,base64imagedata' },
},
],
ui_resources: {
data: [
{
uri: 'ui://graph',
mimeType: 'application/json',
text: '{"type": "line"}',
},
],
},
});
});
});
@@ -358,25 +414,14 @@ describe('formatToolContent', () => {
type: 'text',
text:
'Middle section\n\n' +
'Resource Text: {"type": "bar"}\n' +
'Resource URI: ui://chart\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: https://api.example.com/data\n' +
'Resource: API Data\n' +
'Resource Description: External data source',
},
{ type: 'text', text: 'Conclusion' },
{
type: 'text',
text: '',
metadata: {
type: 'ui_resources',
data: [
{
uri: 'ui://chart',
mimeType: 'application/json',
text: '{"type": "bar"}',
},
],
},
},
]);
expect(artifacts).toEqual({
content: [
@@ -389,6 +434,15 @@ describe('formatToolContent', () => {
image_url: { url: 'https://example.com/image2.jpg' },
},
],
ui_resources: {
data: [
{
uri: 'ui://chart',
mimeType: 'application/json',
text: '{"type": "bar"}',
},
],
},
});
});

View File

@@ -65,6 +65,7 @@ function isStreamableHTTPOptions(options: t.MCPOptions): options is t.Streamable
}
const FIVE_MINUTES = 5 * 60 * 1000;
const DEFAULT_TIMEOUT = 60000;
interface MCPConnectionParams {
serverName: string;
@@ -145,19 +146,22 @@ export class MCPConnection extends EventEmitter {
* This helps prevent memory leaks by only passing necessary dependencies.
*
* @param getHeaders Function to retrieve request headers
* @param timeout Timeout value for the agent (in milliseconds)
* @returns A fetch function that merges headers appropriately
*/
private createFetchFunction(
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
return function customFetch(
input: UndiciRequestInfo,
init?: UndiciRequestInit,
): Promise<UndiciResponse> {
const requestHeaders = getHeaders();
const effectiveTimeout = timeout || DEFAULT_TIMEOUT;
const agent = new Agent({
bodyTimeout: 0,
headersTimeout: 0,
bodyTimeout: effectiveTimeout,
headersTimeout: effectiveTimeout,
});
if (!requestHeaders) {
return undiciFetch(input, { ...init, dispatcher: agent });
@@ -243,6 +247,7 @@ export class MCPConnection extends EventEmitter {
headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`;
}
const timeoutValue = this.timeout || DEFAULT_TIMEOUT;
const transport = new SSEClientTransport(url, {
requestInit: {
headers,
@@ -252,8 +257,8 @@ export class MCPConnection extends EventEmitter {
fetch: (url, init) => {
const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers));
const agent = new Agent({
bodyTimeout: 0,
headersTimeout: 0,
bodyTimeout: timeoutValue,
headersTimeout: timeoutValue,
});
return undiciFetch(url, {
...init,
@@ -264,6 +269,7 @@ export class MCPConnection extends EventEmitter {
},
fetch: this.createFetchFunction(
this.getRequestHeaders.bind(this),
this.timeout,
) as unknown as FetchLike,
});
@@ -304,6 +310,7 @@ export class MCPConnection extends EventEmitter {
},
fetch: this.createFetchFunction(
this.getRequestHeaders.bind(this),
this.timeout,
) as unknown as FetchLike,
});

View File

@@ -1,4 +1,7 @@
import { Tools } from 'librechat-data-provider';
import type { UIResource } from 'librechat-data-provider';
import type * as t from './types';
const RECOGNIZED_PROVIDERS = new Set([
'google',
'anthropic',
@@ -111,7 +114,7 @@ export function formatToolContent(
const formattedContent: t.FormattedContent[] = [];
const imageUrls: t.FormattedContent[] = [];
let currentTextBlock = '';
const uiResources: t.UIResource[] = [];
const uiResources: UIResource[] = [];
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
@@ -144,8 +147,7 @@ export function formatToolContent(
resource: (item) => {
if (item.resource.uri.startsWith('ui://')) {
uiResources.push(item.resource as t.UIResource);
return;
uiResources.push(item.resource as UIResource);
}
const resourceText = [];
@@ -182,18 +184,14 @@ export function formatToolContent(
formattedContent.push({ type: 'text', text: currentTextBlock });
}
if (uiResources.length) {
formattedContent.push({
type: 'text',
metadata: {
type: 'ui_resources',
data: uiResources,
},
text: '',
});
let artifacts: t.Artifacts = undefined;
if (imageUrls.length || uiResources.length) {
artifacts = {
...(imageUrls.length && { content: imageUrls }),
...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }),
};
}
const artifacts = imageUrls.length ? { content: imageUrls } : undefined;
if (CONTENT_ARRAY_PROVIDERS.has(provider)) {
return [formattedContent, artifacts];
}

View File

@@ -6,8 +6,9 @@ import {
StdioOptionsSchema,
WebSocketOptionsSchema,
StreamableHTTPOptionsSchema,
Tools,
} from 'librechat-data-provider';
import type { TPlugin, TUser } from 'librechat-data-provider';
import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider';
import type * as t from '@modelcontextprotocol/sdk/types.js';
import type { TokenMethods } from '@librechat/data-schemas';
import type { FlowStateManager } from '~/flow/manager';
@@ -86,7 +87,7 @@ export type FormattedContent =
metadata?: {
type: string;
data: UIResource[];
}
};
text?: string;
}
| {
@@ -111,24 +112,39 @@ export type FormattedContent =
};
};
export type FormattedContentResult = [
string | FormattedContent[],
undefined | { content: FormattedContent[] },
];
export type UIResource = {
uri: string;
mimeType: string;
text: string;
export type FileSearchSource = {
fileId: string;
relevance: number;
fileName?: string;
metadata?: {
storageType?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type Artifacts =
| {
content?: FormattedContent[];
[Tools.ui_resources]?: {
data: UIResource[];
};
[Tools.file_search]?: {
sources: FileSearchSource[];
fileCitations?: boolean;
};
[Tools.web_search]?: SearchResultData;
files?: Array<{ id: string; name: string }>;
session_id?: string;
file_ids?: string[];
}
| undefined;
export type FormattedContentResult = [string | FormattedContent[], undefined | Artifacts];
export type ImageFormatter = (item: ImageContent) => FormattedContent;
export type FormattedToolResponse = [
string | FormattedContent[],
{ content: FormattedContent[] } | undefined,
];
export type FormattedToolResponse = FormattedContentResult;
export type ParsedServerConfig = MCPOptions & {
url?: string;

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.2.8",
"version": "0.3.0",
"description": "React components for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",

View File

@@ -0,0 +1,42 @@
interface ArrowIconProps {
direction: 'up' | 'down';
className?: string;
}
export default function ArrowIcon({ direction, className = 'inline' }: ArrowIconProps) {
if (direction === 'up') {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className={className}
>
<path
fillRule="evenodd"
d="M11.293 5.293a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1-1.414 1.414L13 8.414V18a1 1 0 1 1-2 0V8.414l-3.293 3.293a1 1 0 0 1-1.414-1.414l5-5Z"
clipRule="evenodd"
/>
</svg>
);
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className={className}
>
<path
fillRule="evenodd"
d="M12.707 18.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 1 1 1.414-1.414L11 15.586V6a1 1 0 1 1 2 0v9.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-5 5Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -1,4 +1,5 @@
export { default as ArchiveIcon } from './ArchiveIcon';
export { default as ArrowIcon } from './ArrowIcon';
export { default as Blocks } from './Blocks';
export { default as Plugin } from './Plugin';
export { default as GPTIcon } from './GPTIcon';

View File

@@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.8.006",
"version": "0.8.010",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",
@@ -39,7 +39,7 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"axios": "^1.8.2",
"axios": "^1.12.1",
"dayjs": "^1.11.13",
"js-yaml": "^4.1.0",
"zod": "^3.22.4"

View File

@@ -0,0 +1,292 @@
{
"name": "librechat-data-provider/react-query",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "librechat-data-provider/react-query",
"dependencies": {
"axios": "^1.12.1"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz",
"integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
}
}
}

View File

@@ -5,6 +5,6 @@
"module": "./index.es.js",
"types": "../dist/types/react-query/index.d.ts",
"dependencies": {
"axios": "^1.7.7"
"axios": "^1.12.1"
}
}

View File

@@ -66,6 +66,8 @@ export const messages = (params: q.MessagesListParams) => {
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifacts/${messageId}`;
export const costs = () => `/api/messages/costs`;
const shareRoot = `${BASE_URL}/api/share`;
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;

View File

@@ -51,6 +51,7 @@ export const excludedKeys = new Set([
'_id',
'tools',
'model',
'modelHistory',
'files',
'spec',
'disableParams',
@@ -1526,9 +1527,9 @@ export enum TTSProviders {
/** Enum for app-wide constants */
export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.8.0-rc3',
VERSION = 'v0.8.0-rc4',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.2.8',
CONFIG_VERSION = '1.2.9',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value to use whatever the submission prelim. `responseMessageId` is */

View File

@@ -697,6 +697,12 @@ export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage
return request.get(endpoints.messages({ conversationId }));
}
export function getModelCosts(
modelHistory: Array<{ model: string; endpoint: string }>,
): Promise<t.TModelCosts> {
return request.post(endpoints.costs(), { modelHistory });
}
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {
return request.get(endpoints.getPrompt(id));
}

View File

@@ -6,6 +6,7 @@ export enum QueryKeys {
archivedConversations = 'archivedConversations',
searchConversations = 'searchConversations',
conversation = 'conversation',
modelCosts = 'modelCosts',
searchEnabled = 'searchEnabled',
user = 'user',
name = 'name', // user key name

View File

@@ -77,6 +77,23 @@ export const useGetConversationByIdQuery = (
);
};
export const useGetModelCostsQuery = (
modelHistory: Array<{ model: string; endpoint: string }>,
config?: UseQueryOptions<t.TModelCosts>,
): QueryObserverResult<t.TModelCosts> => {
return useQuery<t.TModelCosts>(
[QueryKeys.modelCosts, modelHistory],
() => dataService.getModelCosts(modelHistory),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
enabled: !!modelHistory && modelHistory.length > 0,
...config,
},
);
};
//This isn't ideal because its just a query and we're using mutation, but it was the only way
//to make it work with how the Chat component is structured
export const useGetConversationByIdMutation = (id: string): UseMutationResult<s.TConversation> => {

View File

@@ -83,70 +83,75 @@ const processQueue = (error: AxiosError | null, token: string | null = null) =>
failedQueue = [];
};
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (!error.response) {
return Promise.reject(error);
}
if (typeof window !== 'undefined') {
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (!error.response) {
return Promise.reject(error);
}
if (originalRequest.url?.includes('/api/auth/2fa') === true) {
return Promise.reject(error);
}
if (originalRequest.url?.includes('/api/auth/logout') === true) {
return Promise.reject(error);
}
if (originalRequest.url?.includes('/api/auth/2fa') === true) {
return Promise.reject(error);
}
if (originalRequest.url?.includes('/api/auth/logout') === true) {
return Promise.reject(error);
}
if (originalRequest.url?.includes('/api/auth/refresh') === true) {
// Refresh token itself failed - redirect to login
console.log('Refresh token request failed, redirecting to login...');
window.location.href = '/login';
return Promise.reject(error);
}
if (error.response.status === 401 && !originalRequest._retry) {
console.warn('401 error, refreshing token');
originalRequest._retry = true;
if (error.response.status === 401 && !originalRequest._retry) {
console.warn('401 error, refreshing token');
originalRequest._retry = true;
if (isRefreshing) {
try {
const token = await new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
});
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return await axios(originalRequest);
} catch (err) {
return Promise.reject(err);
}
}
isRefreshing = true;
if (isRefreshing) {
try {
const token = await new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
});
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return await axios(originalRequest);
const response = await refreshToken();
const token = response?.token ?? '';
if (token) {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
dispatchTokenUpdatedEvent(token);
processQueue(null, token);
return await axios(originalRequest);
} else if (window.location.href.includes('share/')) {
console.log(
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else {
window.location.href = '/login';
}
} catch (err) {
processQueue(err as AxiosError, null);
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
isRefreshing = true;
try {
const response = await refreshToken(
// Handle edge case where we get a blank screen if the initial 401 error is from a refresh token request
originalRequest.url?.includes('api/auth/refresh') === true ? true : false,
);
const token = response?.token ?? '';
if (token) {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
dispatchTokenUpdatedEvent(token);
processQueue(null, token);
return await axios(originalRequest);
} else if (window.location.href.includes('share/')) {
console.log(
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else {
window.location.href = '/login';
}
} catch (err) {
processQueue(err as AxiosError, null);
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
return Promise.reject(error);
},
);
}
export default {
get: _get,

View File

@@ -518,6 +518,7 @@ export const tMessageSchema = z.object({
overrideParentMessageId: z.string().nullable().optional(),
bg: z.string().nullable().optional(),
model: z.string().nullable().optional(),
targetModel: z.string().nullable().optional(),
title: z.string().nullable().or(z.literal('New Chat')).default('New Chat'),
sender: z.string().optional(),
text: z.string(),
@@ -552,20 +553,33 @@ export type MemoryArtifact = {
type: 'update' | 'delete' | 'error';
};
export type UIResource = {
type?: string;
data?: unknown;
uri?: string;
mimeType?: string;
text?: string;
[key: string]: unknown;
};
export type TAttachmentMetadata = {
type?: Tools;
messageId: string;
toolCallId: string;
[Tools.memory]?: MemoryArtifact;
[Tools.ui_resources]?: UIResource[];
[Tools.web_search]?: SearchResultData;
[Tools.file_search]?: SearchResultData;
[Tools.memory]?: MemoryArtifact;
};
export type TAttachment =
| (TFile & TAttachmentMetadata)
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
expiresAt: number;
} & TAttachmentMetadata);
} & TAttachmentMetadata)
| (Partial<Pick<TFile, 'filename' | 'filepath'>> &
Pick<TFile, 'conversationId'> &
TAttachmentMetadata);
export type TMessage = z.input<typeof tMessageSchema> & {
children?: TMessage[];
@@ -618,6 +632,7 @@ export const tConversationSchema = z.object({
modelLabel: z.string().nullable().optional(),
userLabel: z.string().optional(),
model: z.string().nullable().optional(),
modelHistory: z.array(z.object({ model: z.string(), endpoint: z.string() })).optional(),
promptPrefix: z.string().nullable().optional(),
temperature: z.number().nullable().optional(),
topP: z.number().optional(),

View File

@@ -9,10 +9,10 @@ import type {
TConversationTag,
TAttachment,
} from './schemas';
import type { Agent, AgentToolResources } from './types/assistants';
import type { SettingDefinition } from './generate';
import type { TMinimalFeedback } from './feedback';
import type { ContentTypes } from './types/runs';
import type { Agent } from './types/assistants';
export * from './schemas';
@@ -499,7 +499,6 @@ export type TPrompt = {
author: string;
prompt: string;
type: 'text' | 'chat';
tool_resources?: AgentToolResources;
createdAt: string;
updatedAt: string;
_id?: string;
@@ -513,7 +512,7 @@ export type TPromptGroup = {
category?: string;
projectIds?: string[];
productionId?: string | null;
productionPrompt?: Pick<TPrompt, 'prompt' | 'tool_resources'> | null;
productionPrompt?: Pick<TPrompt, 'prompt'> | null;
author: string;
authorName: string;
createdAt?: Date;
@@ -522,7 +521,7 @@ export type TPromptGroup = {
};
export type TCreatePrompt = {
prompt: Pick<TPrompt, 'prompt' | 'type' | 'tool_resources'> & { groupId?: string };
prompt: Pick<TPrompt, 'prompt' | 'type'> & { groupId?: string };
group?: { name: string; category?: string; oneliner?: string; command?: string };
};
@@ -592,7 +591,7 @@ export type TMakePromptProductionResponse = {
export type TMakePromptProductionRequest = {
id: string;
groupId: string;
productionPrompt: Pick<TPrompt, 'prompt' | 'tool_resources'>;
productionPrompt: Pick<TPrompt, 'prompt'>;
};
export type TUpdatePromptLabelsRequest = {
@@ -654,3 +653,15 @@ export type TBalanceResponse = {
lastRefill?: Date;
refillAmount?: number;
};
export type TConversationCosts = {
totals: {
prompt: { usd: number; tokenCount: number };
completion: { usd: number; tokenCount: number };
total: { usd: number; tokenCount: number };
};
};
export type TModelCosts = {
modelCostTable: Record<string, { prompt: number; completion: number }>;
};

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