From 49c57b27fd8a5baaff07501e8e562c9671c77d8f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Nov 2025 13:12:16 -0500 Subject: [PATCH 01/78] =?UTF-8?q?=E2=9E=BF=20fix:=20`createFileSearchTool`?= =?UTF-8?q?=20to=20return=20tuples=20for=20error=20messages=20(#10547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/clients/tools/util/fileSearch.js | 6 +- .../app/clients/tools/util/fileSearch.test.js | 254 ++++++++++++++---- 2 files changed, 203 insertions(+), 57 deletions(-) diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 01e6384c94..5ebf4bc379 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -78,11 +78,11 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = return tool( async ({ query }) => { if (files.length === 0) { - return 'No files to search. Instruct the user to add files for the search.'; + return ['No files to search. Instruct the user to add files for the search.', undefined]; } const jwtToken = generateShortLivedToken(userId); if (!jwtToken) { - return 'There was an error authenticating the file search request.'; + return ['There was an error authenticating the file search request.', undefined]; } /** @@ -122,7 +122,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = const validResults = results.filter((result) => result !== null); if (validResults.length === 0) { - return 'No results found or errors occurred while searching the files.'; + return ['No results found or errors occurred while searching the files.', undefined]; } const formattedResults = validResults diff --git a/api/test/app/clients/tools/util/fileSearch.test.js b/api/test/app/clients/tools/util/fileSearch.test.js index 9a2ab112af..72353bd296 100644 --- a/api/test/app/clients/tools/util/fileSearch.test.js +++ b/api/test/app/clients/tools/util/fileSearch.test.js @@ -1,17 +1,11 @@ -const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch'); +const axios = require('axios'); -// Mock dependencies -jest.mock('../../../../../models', () => ({ - Files: { - find: jest.fn(), - }, +jest.mock('axios'); +jest.mock('@librechat/api', () => ({ + generateShortLivedToken: jest.fn(), })); -jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({ - queryVectors: jest.fn(), -})); - -jest.mock('../../../../../config', () => ({ +jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), error: jest.fn(), @@ -19,68 +13,220 @@ jest.mock('../../../../../config', () => ({ }, })); -const { queryVectors } = require('../../../../../server/services/Files/VectorDB/crud'); +jest.mock('~/models/File', () => ({ + getFiles: jest.fn().mockResolvedValue([]), +})); -describe('fileSearch.js - test only new file_id and page additions', () => { +jest.mock('~/server/services/Files/permissions', () => ({ + filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)), +})); + +const { createFileSearchTool } = require('~/app/clients/tools/util/fileSearch'); +const { generateShortLivedToken } = require('@librechat/api'); + +describe('fileSearch.js - tuple return validation', () => { beforeEach(() => { jest.clearAllMocks(); + process.env.RAG_API_URL = 'http://localhost:8000'; }); - // Test only the specific changes: file_id and page metadata additions - it('should add file_id and page to search result format', async () => { - const mockFiles = [{ file_id: 'test-file-123' }]; - const mockResults = [ - { + describe('error cases should return tuple with undefined as second value', () => { + it('should return tuple when no files provided', async () => { + const fileSearchTool = await createFileSearchTool({ + userId: 'user1', + files: [], + }); + + const result = await fileSearchTool.func({ query: 'test query' }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toBe('No files to search. Instruct the user to add files for the search.'); + expect(result[1]).toBeUndefined(); + }); + + it('should return tuple when JWT token generation fails', async () => { + generateShortLivedToken.mockReturnValue(null); + + const fileSearchTool = await createFileSearchTool({ + userId: 'user1', + files: [{ file_id: 'file-1', filename: 'test.pdf' }], + }); + + const result = await fileSearchTool.func({ query: 'test query' }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toBe('There was an error authenticating the file search request.'); + expect(result[1]).toBeUndefined(); + }); + + it('should return tuple when no valid results found', async () => { + generateShortLivedToken.mockReturnValue('mock-jwt-token'); + axios.post.mockRejectedValue(new Error('API Error')); + + const fileSearchTool = await createFileSearchTool({ + userId: 'user1', + files: [{ file_id: 'file-1', filename: 'test.pdf' }], + }); + + const result = await fileSearchTool.func({ query: 'test query' }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toBe('No results found or errors occurred while searching the files.'); + expect(result[1]).toBeUndefined(); + }); + }); + + describe('success cases should return tuple with artifact object', () => { + it('should return tuple with formatted results and sources artifact', async () => { + generateShortLivedToken.mockReturnValue('mock-jwt-token'); + + const mockApiResponse = { data: [ [ { - page_content: 'test content', - metadata: { source: 'test.pdf', page: 1 }, + page_content: 'This is test content from the document', + metadata: { source: '/path/to/test.pdf', page: 1 }, }, - 0.3, + 0.2, + ], + [ + { + page_content: 'Additional relevant content', + metadata: { source: '/path/to/test.pdf', page: 2 }, + }, + 0.35, ], ], - }, - ]; + }; - queryVectors.mockResolvedValue(mockResults); + axios.post.mockResolvedValue(mockApiResponse); - const fileSearchTool = await createFileSearchTool({ - userId: 'user1', - files: mockFiles, - entity_id: 'agent-123', + const fileSearchTool = await createFileSearchTool({ + userId: 'user1', + files: [{ file_id: 'file-123', filename: 'test.pdf' }], + entity_id: 'agent-456', + }); + + const result = await fileSearchTool.func({ query: 'test query' }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + + const [formattedString, artifact] = result; + + expect(typeof formattedString).toBe('string'); + expect(formattedString).toContain('File: test.pdf'); + expect(formattedString).toContain('Relevance:'); + expect(formattedString).toContain('This is test content from the document'); + expect(formattedString).toContain('Additional relevant content'); + + expect(artifact).toBeDefined(); + expect(artifact).toHaveProperty('file_search'); + expect(artifact.file_search).toHaveProperty('sources'); + expect(artifact.file_search).toHaveProperty('fileCitations', false); + expect(Array.isArray(artifact.file_search.sources)).toBe(true); + expect(artifact.file_search.sources.length).toBe(2); + + const source = artifact.file_search.sources[0]; + expect(source).toMatchObject({ + type: 'file', + fileId: 'file-123', + fileName: 'test.pdf', + content: expect.any(String), + relevance: expect.any(Number), + pages: [1], + pageRelevance: { 1: expect.any(Number) }, + }); }); - // Mock the tool's function to return the formatted result - fileSearchTool.func = jest.fn().mockImplementation(async () => { - // Simulate the new format with file_id and page - const formattedResults = [ - { - filename: 'test.pdf', - content: 'test content', - distance: 0.3, - file_id: 'test-file-123', // NEW: added file_id - page: 1, // NEW: added page - }, - ]; + it('should include file citations in description when enabled', async () => { + generateShortLivedToken.mockReturnValue('mock-jwt-token'); - // NEW: Internal data section for processAgentResponse - const internalData = formattedResults - .map( - (result) => - `File: ${result.filename}\nFile_ID: ${result.file_id}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nPage: ${result.page || 'N/A'}\nContent: ${result.content}\n`, - ) - .join('\n---\n'); + const mockApiResponse = { + data: [ + [ + { + page_content: 'Content with citations', + metadata: { source: '/path/to/doc.pdf', page: 3 }, + }, + 0.15, + ], + ], + }; - return `File: test.pdf\nRelevance: 0.7000\nContent: test content\n\n\n${internalData}\n`; + axios.post.mockResolvedValue(mockApiResponse); + + const fileSearchTool = await createFileSearchTool({ + userId: 'user1', + files: [{ file_id: 'file-789', filename: 'doc.pdf' }], + fileCitations: true, + }); + + const result = await fileSearchTool.func({ query: 'test query' }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + + const [formattedString, artifact] = result; + + expect(formattedString).toContain('Anchor:'); + expect(formattedString).toContain('\\ue202turn0file0'); + expect(artifact.file_search.fileCitations).toBe(true); }); - const result = await fileSearchTool.func('test'); + it('should handle multiple files correctly', async () => { + generateShortLivedToken.mockReturnValue('mock-jwt-token'); - // Verify the new additions - expect(result).toContain('File_ID: test-file-123'); - expect(result).toContain('Page: 1'); - expect(result).toContain(''); - expect(result).toContain(''); + const mockResponse1 = { + data: [ + [ + { + page_content: 'Content from file 1', + metadata: { source: '/path/to/file1.pdf', page: 1 }, + }, + 0.25, + ], + ], + }; + + const mockResponse2 = { + data: [ + [ + { + page_content: 'Content from file 2', + metadata: { source: '/path/to/file2.pdf', page: 1 }, + }, + 0.15, + ], + ], + }; + + axios.post.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2); + + const fileSearchTool = await createFileSearchTool({ + userId: 'user1', + files: [ + { file_id: 'file-1', filename: 'file1.pdf' }, + { file_id: 'file-2', filename: 'file2.pdf' }, + ], + }); + + const result = await fileSearchTool.func({ query: 'test query' }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + + const [formattedString, artifact] = result; + + expect(formattedString).toContain('file1.pdf'); + expect(formattedString).toContain('file2.pdf'); + expect(artifact.file_search.sources).toHaveLength(2); + // Results are sorted by distance (ascending), so file-2 (0.15) comes before file-1 (0.25) + expect(artifact.file_search.sources[0].fileId).toBe('file-2'); + expect(artifact.file_search.sources[1].fileId).toBe('file-1'); + }); }); }); From bdc47dbe47dc011d5cfa634c3305c0e57e0219b2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Nov 2025 16:37:40 -0500 Subject: [PATCH 02/78] =?UTF-8?q?=E2=8F=B3=20fix:=20Async=20Model=20End=20?= =?UTF-8?q?Events,=20Await=20Tool=20Call=20and=20Dispatch=20Handling=20(#1?= =?UTF-8?q?0552)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/agents/callbacks.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 65f5501416..7d82a24b4b 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -39,9 +39,9 @@ class ModelEndHandler { * @param {ModelEndData | undefined} data * @param {Record | undefined} metadata * @param {StandardGraph} graph - * @returns + * @returns {Promise} */ - handle(event, data, metadata, graph) { + async handle(event, data, metadata, graph) { if (!graph || !metadata) { console.warn(`Graph or metadata not found in ${event} event`); return; @@ -79,7 +79,7 @@ class ModelEndHandler { } } if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) { - handleToolCalls(toolCalls, metadata, graph); + await handleToolCalls(toolCalls, metadata, graph); } const usage = data?.output?.usage_metadata; @@ -101,7 +101,7 @@ class ModelEndHandler { const stepKey = graph.getStepKey(metadata); const message_id = getMessageId(stepKey, graph) ?? ''; if (message_id) { - graph.dispatchRunStep(stepKey, { + await graph.dispatchRunStep(stepKey, { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id, @@ -111,7 +111,7 @@ class ModelEndHandler { const stepId = graph.getStepIdByKey(stepKey); const content = data.output.content; if (typeof content === 'string') { - graph.dispatchMessageDelta(stepId, { + await graph.dispatchMessageDelta(stepId, { content: [ { type: 'text', @@ -120,7 +120,7 @@ class ModelEndHandler { ], }); } else if (content.every((c) => c.type?.startsWith('text'))) { - graph.dispatchMessageDelta(stepId, { + await graph.dispatchMessageDelta(stepId, { content, }); } From c0cb48256ec2dde1699c7abf3c43ce876fe17894 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Nov 2025 16:57:51 -0500 Subject: [PATCH 03/78] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20Improve=20Agen?= =?UTF-8?q?t=20Handoff=20Context=20Tracking=20(#10553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update @librechat/agents dependency to version 3.0.18 * refactor: add optional metadata field to message schema and types * chore: update @librechat/agents to v3.0.19 * refactor: update return type of sendCompletion method to include metadata * chore: linting * chore: update @librechat/agents dependency to v3.0.20 * refactor: implement agent labeling for conversation history in multi-agent scenarios * refactor: improve error handling for capturing agent ID map in AgentClient * refactor: clear agentIdMap and related properties during client disposal to prevent memory leaks * chore: update sendCompletion method for FakeClient to return an object with completion and metadata fields --- api/app/clients/BaseClient.js | 5 +- .../prompts/formatAgentMessages.spec.js | 20 ++-- api/app/clients/specs/FakeClient.js | 5 +- api/package.json | 2 +- api/server/cleanup.js | 3 + api/server/controllers/agents/client.js | 91 ++++++++++++++++++- api/typedefs.js | 2 +- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/data-provider/src/schemas.ts | 2 + packages/data-schemas/src/schema/message.ts | 1 + packages/data-schemas/src/types/message.ts | 1 + 12 files changed, 122 insertions(+), 22 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 185e1c964f..149d331df1 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -81,6 +81,7 @@ class BaseClient { throw new Error("Method 'getCompletion' must be implemented."); } + /** @type {sendCompletion} */ async sendCompletion() { throw new Error("Method 'sendCompletion' must be implemented."); } @@ -689,8 +690,7 @@ class BaseClient { }); } - /** @type {string|string[]|undefined} */ - const completion = await this.sendCompletion(payload, opts); + const { completion, metadata } = await this.sendCompletion(payload, opts); if (this.abortController) { this.abortController.requestCompleted = true; } @@ -708,6 +708,7 @@ class BaseClient { iconURL: this.options.iconURL, endpoint: this.options.endpoint, ...(this.metadata ?? {}), + metadata, }; if (typeof completion === 'string') { diff --git a/api/app/clients/prompts/formatAgentMessages.spec.js b/api/app/clients/prompts/formatAgentMessages.spec.js index 360fa00a34..1aee3edf71 100644 --- a/api/app/clients/prompts/formatAgentMessages.spec.js +++ b/api/app/clients/prompts/formatAgentMessages.spec.js @@ -130,7 +130,7 @@ describe('formatAgentMessages', () => { content: [ { type: ContentTypes.TEXT, - [ContentTypes.TEXT]: 'I\'ll search for that information.', + [ContentTypes.TEXT]: "I'll search for that information.", tool_call_ids: ['search_1'], }, { @@ -144,7 +144,7 @@ describe('formatAgentMessages', () => { }, { type: ContentTypes.TEXT, - [ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.', + [ContentTypes.TEXT]: "Now, I'll convert the temperature.", tool_call_ids: ['convert_1'], }, { @@ -156,7 +156,7 @@ describe('formatAgentMessages', () => { output: '23.89°C', }, }, - { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." }, ], }, ]; @@ -171,7 +171,7 @@ describe('formatAgentMessages', () => { expect(result[4]).toBeInstanceOf(AIMessage); // Check first AIMessage - expect(result[0].content).toBe('I\'ll search for that information.'); + expect(result[0].content).toBe("I'll search for that information."); expect(result[0].tool_calls).toHaveLength(1); expect(result[0].tool_calls[0]).toEqual({ id: 'search_1', @@ -187,7 +187,7 @@ describe('formatAgentMessages', () => { ); // Check second AIMessage - expect(result[2].content).toBe('Now, I\'ll convert the temperature.'); + expect(result[2].content).toBe("Now, I'll convert the temperature."); expect(result[2].tool_calls).toHaveLength(1); expect(result[2].tool_calls[0]).toEqual({ id: 'convert_1', @@ -202,7 +202,7 @@ describe('formatAgentMessages', () => { // Check final AIMessage expect(result[4].content).toStrictEqual([ - { [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT }, + { [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT }, ]); }); @@ -217,7 +217,7 @@ describe('formatAgentMessages', () => { role: 'assistant', content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }], }, - { role: 'user', content: 'What\'s the weather?' }, + { role: 'user', content: "What's the weather?" }, { role: 'assistant', content: [ @@ -240,7 +240,7 @@ describe('formatAgentMessages', () => { { role: 'assistant', content: [ - { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." }, ], }, ]; @@ -265,12 +265,12 @@ describe('formatAgentMessages', () => { { [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT }, ]); expect(result[2].content).toStrictEqual([ - { [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT }, + { [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT }, ]); expect(result[3].content).toBe('Let me check that for you.'); expect(result[4].content).toBe('Sunny, 75°F'); expect(result[5].content).toStrictEqual([ - { [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT }, + { [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT }, ]); // Check that there are no consecutive AIMessages diff --git a/api/app/clients/specs/FakeClient.js b/api/app/clients/specs/FakeClient.js index 8c79847069..d1d07a967d 100644 --- a/api/app/clients/specs/FakeClient.js +++ b/api/app/clients/specs/FakeClient.js @@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => { }); TestClient.sendCompletion = jest.fn(async () => { - return 'Mock response text'; + return { + completion: 'Mock response text', + metadata: undefined, + }; }); TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => { diff --git a/api/package.json b/api/package.json index 6b47e6656c..e64165348b 100644 --- a/api/package.json +++ b/api/package.json @@ -47,7 +47,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.17", + "@librechat/agents": "^3.0.20", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/cleanup.js b/api/server/cleanup.js index c482a2267e..8e19c853ea 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -350,6 +350,9 @@ function disposeClient(client) { if (client.agentConfigs) { client.agentConfigs = null; } + if (client.agentIdMap) { + client.agentIdMap = null; + } if (client.artifactPromises) { client.artifactPromises = null; } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index d76dc4bb6e..5dea281bb0 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -20,6 +20,7 @@ const { Providers, TitleMethod, formatMessage, + labelContentByAgent, formatAgentMessages, getTokenCountForMessage, createMetadataAggregator, @@ -92,6 +93,61 @@ function logToolError(graph, error, toolId) { }); } +/** + * Applies agent labeling to conversation history when multi-agent patterns are detected. + * Labels content parts by their originating agent to prevent identity confusion. + * + * @param {TMessage[]} orderedMessages - The ordered conversation messages + * @param {Agent} primaryAgent - The primary agent configuration + * @param {Map} agentConfigs - Map of additional agent configurations + * @returns {TMessage[]} Messages with agent labels applied where appropriate + */ +function applyAgentLabelsToHistory(orderedMessages, primaryAgent, agentConfigs) { + const shouldLabelByAgent = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0; + + if (!shouldLabelByAgent) { + return orderedMessages; + } + + const processedMessages = []; + + for (let i = 0; i < orderedMessages.length; i++) { + const message = orderedMessages[i]; + + /** @type {Record} */ + const agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' }; + + if (agentConfigs) { + for (const [agentId, agentConfig] of agentConfigs.entries()) { + agentNames[agentId] = agentConfig.name || agentConfig.id; + } + } + + if ( + !message.isCreatedByUser && + message.metadata?.agentIdMap && + Array.isArray(message.content) + ) { + try { + const labeledContent = labelContentByAgent( + message.content, + message.metadata.agentIdMap, + agentNames, + ); + + processedMessages.push({ ...message, content: labeledContent }); + } catch (error) { + logger.error('[AgentClient] Error applying agent labels to message:', error); + processedMessages.push(message); + } + } else { + processedMessages.push(message); + } + } + + return processedMessages; +} + class AgentClient extends BaseClient { constructor(options = {}) { super(null, options); @@ -141,6 +197,8 @@ class AgentClient extends BaseClient { this.indexTokenCountMap = {}; /** @type {(messages: BaseMessage[]) => Promise} */ this.processMemory; + /** @type {Record | null} */ + this.agentIdMap = null; } /** @@ -233,6 +291,12 @@ class AgentClient extends BaseClient { summary: this.shouldSummarize, }); + orderedMessages = applyAgentLabelsToHistory( + orderedMessages, + this.options.agent, + this.agentConfigs, + ); + let payload; /** @type {number | undefined} */ let promptTokens; @@ -612,7 +676,11 @@ class AgentClient extends BaseClient { userMCPAuthMap: opts.userMCPAuthMap, abortController: opts.abortController, }); - return filterMalformedContentParts(this.contentParts); + + const completion = filterMalformedContentParts(this.contentParts); + const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined; + + return { completion, metadata }; } /** @@ -902,6 +970,24 @@ class AgentClient extends BaseClient { ); }); } + + try { + /** Capture agent ID map if we have edges or multiple agents */ + const shouldStoreAgentMap = + (this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0; + if (shouldStoreAgentMap && run?.Graph) { + const contentPartAgentMap = run.Graph.getContentPartAgentMap(); + if (contentPartAgentMap && contentPartAgentMap.size > 0) { + this.agentIdMap = Object.fromEntries(contentPartAgentMap); + logger.debug('[AgentClient] Captured agent ID map:', { + totalParts: this.contentParts.length, + mappedParts: Object.keys(this.agentIdMap).length, + }); + } + } + } catch (error) { + logger.error('[AgentClient] Error capturing agent ID map:', error); + } } catch (err) { logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', @@ -935,6 +1021,9 @@ class AgentClient extends BaseClient { err, ); } + run = null; + config = null; + memoryPromise = null; } } diff --git a/api/typedefs.js b/api/typedefs.js index a75ab7d8c1..b6385c69a9 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1828,7 +1828,7 @@ * @param {onTokenProgress} opts.onProgress - Callback function to handle token progress * @param {AbortController} opts.abortController - AbortController instance * @param {Record>} [opts.userMCPAuthMap] - * @returns {Promise} + * @returns {Promise<{ content: Promise; metadata: Record; }>} * @memberof typedefs */ diff --git a/package-lock.json b/package-lock.json index e6d3853bd3..3bc7520a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.17", + "@librechat/agents": "^3.0.20", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -16351,9 +16351,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.17.tgz", - "integrity": "sha512-gnom77oW10cdGmmQ6rExc+Blrfzib9JqrZk2fDPwkSOWXQIK4nMm6vWS+UkhH/YfN996mMnffpWoQQ6QQvkTGg==", + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.20.tgz", + "integrity": "sha512-GSFpTYIylN/01c4QksMlsKBkptB4v9qYHzcT7rXxHzj568W7mz0ZPBfs1N6PwZRyp+1RgDwo3v38CS4oQmdOTQ==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -47334,7 +47334,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.17", + "@librechat/agents": "^3.0.20", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 44fe3963ac..1197aa9881 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -84,7 +84,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.17", + "@librechat/agents": "^3.0.20", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 9d1761d468..afc07880da 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -610,6 +610,8 @@ export const tMessageSchema = z.object({ /* frontend components */ iconURL: z.string().nullable().optional(), feedback: feedbackSchema.optional(), + /** metadata */ + metadata: z.record(z.unknown()).optional(), }); export type MemoryArtifact = { diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index c11252cb87..f287f14ca4 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -132,6 +132,7 @@ const messageSchema: Schema = new Schema( iconURL: { type: String, }, + metadata: { type: mongoose.Schema.Types.Mixed }, attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, /* attachments: { diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index 2743f7242e..f69bcff6b9 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -37,6 +37,7 @@ export interface IMessage extends Document { content?: unknown[]; thread_id?: string; iconURL?: string; + metadata?: Record; attachments?: unknown[]; expiredAt?: Date; createdAt?: Date; From 8907bd5d7c7b94d6cf5eff4ea4fa7070b75d43ae Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 17 Nov 2025 23:04:01 +0100 Subject: [PATCH 04/78] =?UTF-8?q?=F0=9F=91=A4=20feat:=20Agent=20Avatar=20R?= =?UTF-8?q?emoval=20and=20Decouple=20upload/reset=20from=20Agent=20Updates?= =?UTF-8?q?=20(#10527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Enhance agent avatar management with upload and reset functionality * ✨ feat: Refactor AvatarMenu to use DropdownPopup for improved UI and functionality * ✨ feat: Improve avatar upload handling in AgentPanel to suppress misleading "no changes" toast * ✨ feat: Refactor toast message handling and payload composition in AgentPanel for improved clarity and functionality * ✨ feat: Enhance agent avatar functionality with upload, reset, and validation improvements * ✨ feat: Refactor agent avatar upload handling and enhance related components for improved functionality and user experience * feat(agents): tighten ACL, harden GETs/search, and sanitize action metadata stop persisting refreshed S3 URLs on GET; compute per-response only enforce ACL EDIT on revert route; remove legacy admin/author/collab checks sanitize action metadata before persisting during duplication (api_key, oauth_client_id, oauth_client_secret) escape user search input, cap length (100), and use Set for public flag mapping add explicit req.file guard in avatar upload; fix empty catch lint; remove unused imports * feat: Remove outdated avatar-related translation keys * feat: Improve error logging for avatar updates and streamline file input handling * feat(agents): implement caching for S3 avatar refresh in agent list responses * fix: replace unconventional 'void e' with explicit comment to clarify intentionally ignored error Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(agents): enhance avatar handling and improve search functionality * fix: clarify intentionally ignored error in agent list handler --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/server/controllers/agents/v1.js | 161 +++++--- api/server/controllers/agents/v1.spec.js | 63 +++ api/server/routes/agents/v1.js | 10 +- client/src/common/agents-types.ts | 4 + .../SidePanel/Agents/AgentAvatar.tsx | 243 ++++-------- .../SidePanel/Agents/AgentConfig.tsx | 10 +- .../SidePanel/Agents/AgentFooter.tsx | 9 +- .../SidePanel/Agents/AgentPanel.tsx | 371 ++++++++++++++---- .../SidePanel/Agents/AgentSelect.tsx | 3 + .../components/SidePanel/Agents/Images.tsx | 159 ++++---- .../Agents/__tests__/AgentAvatar.spec.tsx | 95 +++++ .../Agents/__tests__/AgentFooter.spec.tsx | 18 +- .../__tests__/AgentPanel.helpers.spec.ts | 141 +++++++ client/src/data-provider/Agents/mutations.ts | 36 +- client/src/utils/forms.tsx | 3 + packages/api/src/agents/validation.ts | 1 + .../client/src/components/DropdownPopup.tsx | 2 +- 17 files changed, 931 insertions(+), 398 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/__tests__/AgentAvatar.spec.tsx create mode 100644 client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index d623603a28..b7b2dbf367 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -11,7 +11,6 @@ const { const { Tools, Constants, - SystemRoles, FileSources, ResourceType, AccessRoleIds, @@ -20,6 +19,8 @@ const { PermissionBits, actionDelimiter, removeNullishValues, + CacheKeys, + Time, } = require('librechat-data-provider'); const { getListAgentsByAccess, @@ -45,6 +46,7 @@ const { updateAction, getActions } = require('~/models/Action'); const { getCachedTools } = require('~/server/services/Config'); const { deleteFileByFilter } = require('~/models/File'); const { getCategoriesWithCounts } = require('~/models'); +const { getLogStores } = require('~/cache'); const systemTools = { [Tools.execute_code]: true, @@ -52,6 +54,49 @@ const systemTools = { [Tools.web_search]: true, }; +const MAX_SEARCH_LEN = 100; +const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Opportunistically refreshes S3-backed avatars for agent list responses. + * Only list responses are refreshed because they're the highest-traffic surface and + * the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes + * via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most. + * @param {Array} agents - Agents being enriched with S3-backed avatars + * @param {string} userId - User identifier used for the cache refresh key + */ +const refreshListAvatars = async (agents, userId) => { + if (!agents?.length) { + return; + } + + const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); + const refreshKey = `${userId}:agents_list`; + const alreadyChecked = await cache.get(refreshKey); + if (alreadyChecked) { + return; + } + + await Promise.all( + agents.map(async (agent) => { + if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) { + return; + } + + try { + const newPath = await refreshS3Url(agent.avatar); + if (newPath && newPath !== agent.avatar.filepath) { + agent.avatar = { ...agent.avatar, filepath: newPath }; + } + } catch (err) { + logger.debug('[/Agents] Avatar refresh error for list item', err); + } + }), + ); + + await cache.set(refreshKey, true, Time.THIRTY_MINUTES); +}; + /** * Creates an Agent. * @route POST /Agents @@ -142,10 +187,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => { agent.version = agent.versions ? agent.versions.length : 0; if (agent.avatar && agent.avatar?.source === FileSources.s3) { - const originalUrl = agent.avatar.filepath; - agent.avatar.filepath = await refreshS3Url(agent.avatar); - if (originalUrl !== agent.avatar.filepath) { - await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id }); + try { + agent.avatar = { + ...agent.avatar, + filepath: await refreshS3Url(agent.avatar), + }; + } catch (e) { + logger.warn('[/Agents/:id] Failed to refresh S3 URL', e); } } @@ -209,7 +257,12 @@ const updateAgentHandler = async (req, res) => { try { const id = req.params.id; const validatedData = agentUpdateSchema.parse(req.body); - const { _id, ...updateData } = removeNullishValues(validatedData); + // Preserve explicit null for avatar to allow resetting the avatar + const { avatar: avatarField, _id, ...rest } = validatedData; + const updateData = removeNullishValues(rest); + if (avatarField === null) { + updateData.avatar = avatarField; + } // Convert OCR to context in incoming updateData convertOcrToContextInPlace(updateData); @@ -342,21 +395,21 @@ const duplicateAgentHandler = async (req, res) => { const [domain] = action.action_id.split(actionDelimiter); const fullActionId = `${domain}${actionDelimiter}${newActionId}`; + // Sanitize sensitive metadata before persisting + const filteredMetadata = { ...(action.metadata || {}) }; + for (const field of sensitiveFields) { + delete filteredMetadata[field]; + } + const newAction = await updateAction( { action_id: newActionId }, { - metadata: action.metadata, + metadata: filteredMetadata, agent_id: newAgentId, user: userId, }, ); - const filteredMetadata = { ...newAction.metadata }; - for (const field of sensitiveFields) { - delete filteredMetadata[field]; - } - - newAction.metadata = filteredMetadata; newActionsList.push(newAction); return fullActionId; }; @@ -463,13 +516,13 @@ const getListAgentsHandler = async (req, res) => { filter.is_promoted = { $ne: true }; } - // Handle search filter + // Handle search filter (escape regex and cap length) if (search && search.trim() !== '') { - filter.$or = [ - { name: { $regex: search.trim(), $options: 'i' } }, - { description: { $regex: search.trim(), $options: 'i' } }, - ]; + const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN)); + const regex = new RegExp(safeSearch, 'i'); + filter.$or = [{ name: regex }, { description: regex }]; } + // Get agent IDs the user has VIEW access to via ACL const accessibleIds = await findAccessibleResources({ userId, @@ -477,10 +530,12 @@ const getListAgentsHandler = async (req, res) => { resourceType: ResourceType.AGENT, requiredPermissions: requiredPermission, }); + const publiclyAccessibleIds = await findPubliclyAccessibleResources({ resourceType: ResourceType.AGENT, requiredPermissions: PermissionBits.VIEW, }); + // Use the new ACL-aware function const data = await getListAgentsByAccess({ accessibleIds, @@ -488,13 +543,31 @@ const getListAgentsHandler = async (req, res) => { limit, after: cursor, }); - if (data?.data?.length) { - data.data = data.data.map((agent) => { - if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) { + + const agents = data?.data ?? []; + if (!agents.length) { + return res.json(data); + } + + const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString())); + + data.data = agents.map((agent) => { + try { + if (agent?._id && publicSet.has(agent._id.toString())) { agent.isPublic = true; } - return agent; - }); + } catch (e) { + // Silently ignore mapping errors + void e; + } + return agent; + }); + + // Opportunistically refresh S3 avatar URLs for list results with caching + try { + await refreshListAvatars(data.data, req.user.id); + } catch (err) { + logger.debug('[/Agents] Skipping avatar refresh for list', err); } return res.json(data); } catch (error) { @@ -517,28 +590,21 @@ const getListAgentsHandler = async (req, res) => { const uploadAgentAvatarHandler = async (req, res) => { try { const appConfig = req.config; + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } filterFile({ req, file: req.file, image: true, isAvatar: true }); const { agent_id } = req.params; if (!agent_id) { return res.status(400).json({ message: 'Agent ID is required' }); } - const isAdmin = req.user.role === SystemRoles.ADMIN; const existingAgent = await getAgent({ id: agent_id }); if (!existingAgent) { return res.status(404).json({ error: 'Agent not found' }); } - const isAuthor = existingAgent.author.toString() === req.user.id.toString(); - const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor; - - if (!hasEditPermission) { - return res.status(403).json({ - error: 'You do not have permission to modify this non-collaborative agent', - }); - } - const buffer = await fs.readFile(req.file.path); const fileStrategy = getFileStrategy(appConfig, { isAvatar: true }); const resizedBuffer = await resizeAvatar({ @@ -571,8 +637,6 @@ const uploadAgentAvatarHandler = async (req, res) => { } } - const promises = []; - const data = { avatar: { filepath: image.filepath, @@ -580,17 +644,16 @@ const uploadAgentAvatarHandler = async (req, res) => { }, }; - promises.push( - await updateAgent({ id: agent_id }, data, { - updatingUserId: req.user.id, - }), - ); - - const resolved = await Promise.all(promises); - res.status(201).json(resolved[0]); + const updatedAgent = await updateAgent({ id: agent_id }, data, { + updatingUserId: req.user.id, + }); + res.status(201).json(updatedAgent); } catch (error) { const message = 'An error occurred while updating the Agent Avatar'; - logger.error(message, error); + logger.error( + `[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`, + error, + ); res.status(500).json({ message }); } finally { try { @@ -629,21 +692,13 @@ const revertAgentVersionHandler = async (req, res) => { return res.status(400).json({ error: 'version_index is required' }); } - const isAdmin = req.user.role === SystemRoles.ADMIN; const existingAgent = await getAgent({ id }); if (!existingAgent) { return res.status(404).json({ error: 'Agent not found' }); } - const isAuthor = existingAgent.author.toString() === req.user.id.toString(); - const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor; - - if (!hasEditPermission) { - return res.status(403).json({ - error: 'You do not have permission to modify this non-collaborative agent', - }); - } + // Permissions are enforced via route middleware (ACL EDIT) const updatedAgent = await revertAgentVersion({ id }, version_index); diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index b8d4d50ee6..bfdee7eb79 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -47,6 +47,7 @@ jest.mock('~/server/services/PermissionService', () => ({ findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]), grantPermission: jest.fn(), hasPublicPermission: jest.fn().mockResolvedValue(false), + checkPermission: jest.fn().mockResolvedValue(true), })); jest.mock('~/models', () => ({ @@ -573,6 +574,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(updatedAgent.version).toBe(agentInDb.versions.length); }); + test('should allow resetting avatar when value is explicitly null', async () => { + await Agent.updateOne( + { id: existingAgentId }, + { + avatar: { + filepath: 'https://example.com/avatar.png', + source: 's3', + }, + }, + ); + + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + avatar: null, + }; + + await updateAgentHandler(mockReq, mockRes); + + const updatedAgent = mockRes.json.mock.calls[0][0]; + expect(updatedAgent.avatar).toBeNull(); + + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.avatar).toBeNull(); + }); + + test('should ignore avatar field when value is undefined', async () => { + const originalAvatar = { + filepath: 'https://example.com/original.png', + source: 's3', + }; + await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar }); + + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + avatar: undefined, + }; + + await updateAgentHandler(mockReq, mockRes); + + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath); + expect(agentInDb.avatar.source).toBe(originalAvatar.source); + }); + + test('should not bump version when no mutable fields change', async () => { + const existingAgent = await Agent.findOne({ id: existingAgentId }); + const originalVersionCount = existingAgent.versions.length; + + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + avatar: undefined, + }; + + await updateAgentHandler(mockReq, mockRes); + + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.versions.length).toBe(originalVersionCount); + }); + test('should handle validation errors properly', async () => { mockReq.user.id = existingAgentAuthorId.toString(); mockReq.params.id = existingAgentId; diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index ef0535c4db..1e4f1c0118 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -146,7 +146,15 @@ router.delete( * @param {number} req.body.version_index - Index of the version to revert to. * @returns {Agent} 200 - success response - application/json */ -router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion); +router.post( + '/:id/revert', + checkGlobalAgentShare, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'id', + }), + v1.revertAgentVersion, +); /** * Returns a list of agents. diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 43448a478f..9ac6b440a3 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -41,4 +41,8 @@ export type AgentForm = { recursion_limit?: number; support_contact?: SupportContact; category: string; + // Avatar management fields + avatar_file?: File | null; + avatar_preview?: string | null; + avatar_action?: 'upload' | 'reset' | null; } & TAgentCapabilities; diff --git a/client/src/components/SidePanel/Agents/AgentAvatar.tsx b/client/src/components/SidePanel/Agents/AgentAvatar.tsx index ff396a6be2..bb1d44dfdc 100644 --- a/client/src/components/SidePanel/Agents/AgentAvatar.tsx +++ b/client/src/components/SidePanel/Agents/AgentAvatar.tsx @@ -1,202 +1,101 @@ -import { useState, useEffect, useRef } from 'react'; -import * as Popover from '@radix-ui/react-popover'; +import { useEffect, useCallback } from 'react'; import { useToastContext } from '@librechat/client'; -import { useQueryClient } from '@tanstack/react-query'; -import { - QueryKeys, - mergeFileConfig, - fileConfig as defaultFileConfig, -} from 'librechat-data-provider'; -import type { UseMutationResult } from '@tanstack/react-query'; -import type { - Agent, - AgentAvatar, - AgentCreateParams, - AgentListResponse, -} from 'librechat-data-provider'; -import { - useUploadAgentAvatarMutation, - useGetFileConfig, - allAgentViewAndEditQueryKeys, - invalidateAgentMarketplaceQueries, -} from '~/data-provider'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { mergeFileConfig, fileConfig as defaultFileConfig } from 'librechat-data-provider'; +import type { AgentAvatar } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; import { AgentAvatarRender, NoImage, AvatarMenu } from './Images'; +import { useGetFileConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; -import { formatBytes } from '~/utils'; -function Avatar({ - agent_id = '', - avatar, - createMutation, -}: { - agent_id: string | null; - avatar: null | AgentAvatar; - createMutation: UseMutationResult; -}) { - const queryClient = useQueryClient(); - const [menuOpen, setMenuOpen] = useState(false); - const [previewUrl, setPreviewUrl] = useState(''); - const [progress, setProgress] = useState(1); - const [input, setInput] = useState(null); - const lastSeenCreatedId = useRef(null); +function Avatar({ avatar }: { avatar: AgentAvatar | null }) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { control, setValue } = useFormContext(); + const avatarPreview = useWatch({ control, name: 'avatar_preview' }) ?? ''; + const avatarAction = useWatch({ control, name: 'avatar_action' }); const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); - const localize = useLocalize(); - const { showToast } = useToastContext(); - - const { mutate: uploadAvatar } = useUploadAgentAvatarMutation({ - onMutate: () => { - setProgress(0.4); - }, - onSuccess: (data) => { - if (lastSeenCreatedId.current !== createMutation.data?.id) { - lastSeenCreatedId.current = createMutation.data?.id ?? ''; - } - showToast({ message: localize('com_ui_upload_agent_avatar') }); - - setInput(null); - const newUrl = data.avatar?.filepath ?? ''; - setPreviewUrl(newUrl); - - ((keys) => { - keys.forEach((key) => { - const res = queryClient.getQueryData([QueryKeys.agents, key]); - - if (!res?.data) { - return; - } - - const agents = res.data.map((agent) => { - if (agent.id === agent_id) { - return { - ...agent, - ...data, - }; - } - return agent; - }); - - queryClient.setQueryData([QueryKeys.agents, key], { - ...res, - data: agents, - }); - }); - })(allAgentViewAndEditQueryKeys); - invalidateAgentMarketplaceQueries(queryClient); - setProgress(1); - }, - onError: (error) => { - console.error('Error:', error); - setInput(null); - setPreviewUrl(''); - showToast({ message: localize('com_ui_upload_error'), status: 'error' }); - setProgress(1); - }, - }); + // Derive whether agent has a remote avatar from the avatar prop + const hasRemoteAvatar = Boolean(avatar?.filepath); useEffect(() => { - if (input) { - const reader = new FileReader(); - reader.onloadend = () => { - setPreviewUrl(reader.result as string); - }; - reader.readAsDataURL(input); - } - }, [input]); - - useEffect(() => { - if (avatar && avatar.filepath) { - setPreviewUrl(avatar.filepath); - } else { - setPreviewUrl(''); - } - }, [avatar]); - - useEffect(() => { - /** Experimental: Condition to prime avatar upload before Agent Creation - * - If the createMutation state Id was last seen (current) and the createMutation is successful - * we can assume that the avatar upload has already been initiated and we can skip the upload - * - * The mutation state is not reset until the user deliberately selects a new agent or an agent is deleted - * - * This prevents the avatar from being uploaded multiple times before the user selects a new agent - * while allowing the user to upload to prime the avatar and other values before the agent is created. - */ - const sharedUploadCondition = !!( - createMutation.isSuccess && - input && - previewUrl && - previewUrl.includes('base64') - ); - if (sharedUploadCondition && lastSeenCreatedId.current === createMutation.data.id) { + if (avatarAction) { return; } - if (sharedUploadCondition && createMutation.data.id) { - const formData = new FormData(); - formData.append('file', input, input.name); - formData.append('agent_id', createMutation.data.id); - - uploadAvatar({ - agent_id: createMutation.data.id, - formData, - }); + if (avatar?.filepath && avatarPreview !== avatar.filepath) { + setValue('avatar_preview', avatar.filepath); } - }, [createMutation.data, createMutation.isSuccess, input, previewUrl, uploadAvatar]); - const handleFileChange = (event: React.ChangeEvent): void => { - const file = event.target.files?.[0]; - const sizeLimit = fileConfig.avatarSizeLimit ?? 0; + if (!avatar?.filepath && avatarPreview !== '') { + setValue('avatar_preview', ''); + } + }, [avatar?.filepath, avatarAction, avatarPreview, setValue]); - if (sizeLimit && file && file.size <= sizeLimit) { - setInput(file); - setMenuOpen(false); + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + const sizeLimit = fileConfig.avatarSizeLimit ?? 0; - const currentId = agent_id ?? ''; - if (!currentId) { + if (!file) { return; } - const formData = new FormData(); - formData.append('file', file, file.name); - formData.append('agent_id', currentId); - - if (typeof avatar === 'object') { - formData.append('avatar', JSON.stringify(avatar)); + if (sizeLimit && file.size > sizeLimit) { + const limitInMb = sizeLimit / (1024 * 1024); + const displayLimit = Number.isInteger(limitInMb) + ? limitInMb + : parseFloat(limitInMb.toFixed(1)); + showToast({ + message: localize('com_ui_upload_invalid_var', { 0: displayLimit }), + status: 'error', + }); + return; } - uploadAvatar({ - agent_id: currentId, - formData, - }); - } else { - const megabytes = sizeLimit ? formatBytes(sizeLimit) : 2; - showToast({ - message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }), - status: 'error', - }); - } + const reader = new FileReader(); + reader.onloadend = () => { + setValue('avatar_file', file, { shouldDirty: true }); + setValue('avatar_preview', (reader.result as string) ?? '', { shouldDirty: true }); + setValue('avatar_action', 'upload', { shouldDirty: true }); + }; + reader.readAsDataURL(file); + }, + [fileConfig.avatarSizeLimit, localize, setValue, showToast], + ); - setMenuOpen(false); - }; + const handleReset = useCallback(() => { + const remoteAvatarExists = Boolean(avatar?.filepath); + setValue('avatar_preview', '', { shouldDirty: true }); + setValue('avatar_file', null, { shouldDirty: true }); + setValue('avatar_action', remoteAvatarExists ? 'reset' : null, { shouldDirty: true }); + }, [avatar?.filepath, setValue]); + + const hasIcon = Boolean(avatarPreview) || hasRemoteAvatar; + const canReset = hasIcon; return ( - + <>
- - - + + {avatarPreview ? : } + + } + handleFileChange={handleFileChange} + onReset={handleReset} + canReset={canReset} + />
- {} -
+ ); } diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 342b8c0da7..abee8f0c23 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback } from 'react'; import { useToastContext } from '@librechat/client'; import { Controller, useWatch, useFormContext } from 'react-hook-form'; import { EModelEndpoint, getEndpointField } from 'librechat-data-provider'; -import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common'; +import type { AgentForm, IconComponentTypes } from '~/common'; import { removeFocusOutlines, processAgentOption, @@ -37,7 +37,7 @@ const inputClass = cn( removeFocusOutlines, ); -export default function AgentConfig({ createMutation }: Pick) { +export default function AgentConfig() { const localize = useLocalize(); const fileMap = useFileMapContext(); const { showToast } = useToastContext(); @@ -183,11 +183,7 @@ export default function AgentConfig({ createMutation }: Pick {/* Avatar & Name */}
- +
); }; export function AvatarMenu({ + trigger, handleFileChange, + onReset, + canReset, }: { + trigger: ReactElement; handleFileChange: (event: React.ChangeEvent) => void; + onReset: () => void; + canReset: boolean; }) { const localize = useLocalize(); const fileInputRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); const onItemClick = () => { if (fileInputRef.current) { @@ -98,40 +78,61 @@ export function AvatarMenu({ fileInputRef.current?.click(); }; + const uploadLabel = localize('com_ui_upload_image'); + + const items: MenuItemProps[] = [ + { + id: 'upload-avatar', + label: uploadLabel, + onClick: () => onItemClick(), + }, + ]; + + if (canReset) { + items.push( + { separate: true }, + { + id: 'reset-avatar', + label: localize('com_ui_reset_var', { 0: 'Avatar' }), + onClick: () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + onReset(); + }, + }, + ); + } + return ( - - - - {/* - Use DALL·E - */} - - - + <> + } + items={items} + isOpen={isOpen} + setIsOpen={setIsOpen} + menuId="agent-avatar-menu" + placement="bottom" + gutter={8} + portal + mountByState + /> + { + handleFileChange(event); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } else { + event.currentTarget.value = ''; + } + }} + ref={fileInputRef} + tabIndex={-1} + /> + ); } diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentAvatar.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentAvatar.spec.tsx new file mode 100644 index 0000000000..c12caf42d4 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/AgentAvatar.spec.tsx @@ -0,0 +1,95 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable i18next/no-literal-string */ +import { describe, it, expect } from '@jest/globals'; +import { render, fireEvent } from '@testing-library/react'; +import { FormProvider, useForm, type UseFormReturn } from 'react-hook-form'; +import type { AgentForm } from '~/common'; +import AgentAvatar from '../AgentAvatar'; + +jest.mock('@librechat/client', () => ({ + useToastContext: () => ({ + showToast: jest.fn(), + }), +})); + +jest.mock('~/data-provider', () => ({ + useGetFileConfig: () => ({ + data: { avatarSizeLimit: 1024 * 1024 }, + }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('../Images', () => ({ + AgentAvatarRender: () =>
, + NoImage: () =>
, + AvatarMenu: ({ onReset }: { onReset: () => void }) => ( + + ), +})); + +const defaultFormValues: AgentForm = { + agent: undefined, + id: 'agent_123', + name: 'Agent', + description: null, + instructions: null, + model: 'gpt-4', + model_parameters: {}, + tools: [], + provider: 'openai', + agent_ids: [], + edges: [], + end_after_tools: false, + hide_sequential_outputs: false, + recursion_limit: undefined, + category: 'general', + support_contact: undefined, + artifacts: '', + execute_code: false, + file_search: false, + web_search: false, + avatar_file: null, + avatar_preview: '', + avatar_action: null, +}; + +describe('AgentAvatar reset menu', () => { + it('clears preview and file state when reset is triggered', () => { + let methodsRef: UseFormReturn; + const Wrapper = () => { + methodsRef = useForm({ + defaultValues: { + ...defaultFormValues, + avatar_preview: 'data:image/png;base64,abc', + avatar_file: new File(['avatar'], 'avatar.png', { type: 'image/png' }), + avatar_action: 'upload', + }, + }); + + return ( + + + + ); + }; + + const { getByTestId } = render(); + fireEvent.click(getByTestId('reset-avatar')); + + expect(methodsRef.getValues('avatar_preview')).toBe(''); + expect(methodsRef.getValues('avatar_file')).toBeNull(); + expect(methodsRef.getValues('avatar_action')).toBe('reset'); + }); +}); diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx index d861baee9f..3425f5a75c 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx @@ -157,7 +157,7 @@ jest.mock('../DuplicateAgent', () => ({ ), })); -jest.mock('~/components', () => ({ +jest.mock('@librechat/client', () => ({ Spinner: () =>
, })); @@ -225,6 +225,7 @@ describe('AgentFooter', () => { updateMutation: mockUpdateMutation, setActivePanel: mockSetActivePanel, setCurrentAgentId: mockSetCurrentAgentId, + isAvatarUploading: false, }; beforeEach(() => { @@ -275,14 +276,14 @@ describe('AgentFooter', () => { expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument(); expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument(); expect(screen.getByTestId('duplicate-button')).toBeInTheDocument(); - expect(document.querySelector('.spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument(); }); test('handles loading states for createMutation', () => { const { unmount } = render( , ); - expect(document.querySelector('.spinner')).toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); expect(screen.queryByText('Save')).not.toBeInTheDocument(); // Find the submit button (the one with aria-busy attribute) const buttons = screen.getAllByRole('button'); @@ -294,9 +295,18 @@ describe('AgentFooter', () => { test('handles loading states for updateMutation', () => { render(); - expect(document.querySelector('.spinner')).toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); expect(screen.queryByText('Save')).not.toBeInTheDocument(); }); + + test('handles loading state when avatar upload is in progress', () => { + render(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + const buttons = screen.getAllByRole('button'); + const submitButton = buttons.find((button) => button.getAttribute('type') === 'submit'); + expect(submitButton).toBeDisabled(); + expect(submitButton).toHaveAttribute('aria-busy', 'true'); + }); }); describe('Conditional Rendering', () => { diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts new file mode 100644 index 0000000000..988796cdc3 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts @@ -0,0 +1,141 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect, jest } from '@jest/globals'; +import { Constants, type Agent } from 'librechat-data-provider'; +import type { FieldNamesMarkedBoolean } from 'react-hook-form'; +import type { AgentForm } from '~/common'; +import { + composeAgentUpdatePayload, + persistAvatarChanges, + isAvatarUploadOnlyDirty, +} from '../AgentPanel'; + +const createForm = (): AgentForm => ({ + agent: undefined, + id: 'agent_123', + name: 'Agent', + description: null, + instructions: null, + model: 'gpt-4', + model_parameters: {}, + tools: [], + provider: 'openai', + agent_ids: [], + edges: [], + end_after_tools: false, + hide_sequential_outputs: false, + recursion_limit: undefined, + category: 'general', + support_contact: undefined, + artifacts: '', + execute_code: false, + file_search: false, + web_search: false, + avatar_file: null, + avatar_preview: '', + avatar_action: null, +}); + +describe('composeAgentUpdatePayload', () => { + it('includes avatar: null when resetting a persistent agent', () => { + const form = createForm(); + form.avatar_action = 'reset'; + + const { payload } = composeAgentUpdatePayload(form, 'agent_123'); + + expect(payload.avatar).toBeNull(); + }); + + it('omits avatar when resetting an ephemeral agent', () => { + const form = createForm(); + form.avatar_action = 'reset'; + + const { payload } = composeAgentUpdatePayload(form, Constants.EPHEMERAL_AGENT_ID); + + expect(payload.avatar).toBeUndefined(); + }); + + it('never adds avatar during upload actions', () => { + const form = createForm(); + form.avatar_action = 'upload'; + + const { payload } = composeAgentUpdatePayload(form, 'agent_123'); + + expect(payload.avatar).toBeUndefined(); + }); +}); + +describe('persistAvatarChanges', () => { + it('returns false for ephemeral agents', async () => { + const uploadAvatar = jest.fn(); + const result = await persistAvatarChanges({ + agentId: Constants.EPHEMERAL_AGENT_ID, + avatarActionState: 'upload', + avatarFile: new File(['avatar'], 'avatar.png', { type: 'image/png' }), + uploadAvatar, + }); + + expect(result).toBe(false); + expect(uploadAvatar).not.toHaveBeenCalled(); + }); + + it('returns false when no upload is pending', async () => { + const uploadAvatar = jest.fn(); + const result = await persistAvatarChanges({ + agentId: 'agent_123', + avatarActionState: null, + avatarFile: null, + uploadAvatar, + }); + + expect(result).toBe(false); + expect(uploadAvatar).not.toHaveBeenCalled(); + }); + + it('uploads avatar when all prerequisites are met', async () => { + const uploadAvatar = jest.fn().mockResolvedValue({} as Agent); + const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + + const result = await persistAvatarChanges({ + agentId: 'agent_123', + avatarActionState: 'upload', + avatarFile: file, + uploadAvatar, + }); + + expect(result).toBe(true); + expect(uploadAvatar).toHaveBeenCalledTimes(1); + const callArgs = uploadAvatar.mock.calls[0][0]; + expect(callArgs.agent_id).toBe('agent_123'); + expect(callArgs.formData).toBeInstanceOf(FormData); + }); +}); + +describe('isAvatarUploadOnlyDirty', () => { + it('detects avatar-only dirty state', () => { + const dirtyFields = { + avatar_action: true, + avatar_preview: true, + } as FieldNamesMarkedBoolean; + + expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(true); + }); + + it('ignores agent field when checking dirty state', () => { + const dirtyFields = { + agent: { value: true } as any, + avatar_file: true, + } as FieldNamesMarkedBoolean; + + expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(true); + }); + + it('returns false when other fields are dirty', () => { + const dirtyFields = { + name: true, + } as FieldNamesMarkedBoolean; + + expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(false); + }); +}); diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 7ffcfa10ba..f49c910c98 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -188,9 +188,41 @@ export const useUploadAgentAvatarMutation = ( t.AgentAvatarVariables, // request unknown // context > => { - return useMutation([MutationKeys.agentAvatarUpload], { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [MutationKeys.agentAvatarUpload], mutationFn: (variables: t.AgentAvatarVariables) => dataService.uploadAgentAvatar(variables), - ...(options || {}), + onMutate: (variables) => options?.onMutate?.(variables), + onError: (error, variables, context) => options?.onError?.(error, variables, context), + onSuccess: (updatedAgent, variables, context) => { + ((keys: t.AgentListParams[]) => { + keys.forEach((key) => { + const listRes = queryClient.getQueryData([QueryKeys.agents, key]); + if (!listRes) { + return; + } + + queryClient.setQueryData([QueryKeys.agents, key], { + ...listRes, + data: listRes.data.map((agent) => { + if (agent.id === variables.agent_id) { + return updatedAgent; + } + return agent; + }), + }); + }); + })(allAgentViewAndEditQueryKeys); + + queryClient.setQueryData([QueryKeys.agent, variables.agent_id], updatedAgent); + queryClient.setQueryData( + [QueryKeys.agent, variables.agent_id, 'expanded'], + updatedAgent, + ); + invalidateAgentMarketplaceQueries(queryClient); + + return options?.onSuccess?.(updatedAgent, variables, context); + }, }); }; diff --git a/client/src/utils/forms.tsx b/client/src/utils/forms.tsx index 68e9db2fde..2b13388f46 100644 --- a/client/src/utils/forms.tsx +++ b/client/src/utils/forms.tsx @@ -52,6 +52,9 @@ export const getDefaultAgentFormValues = () => ({ ...defaultAgentFormValues, model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '', provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''), + avatar_file: null, + avatar_preview: '', + avatar_action: null, }); export const processAgentOption = ({ diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index cbbdebb76e..4798ffeb80 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -81,6 +81,7 @@ export const agentCreateSchema = agentBaseSchema.extend({ /** Update schema extends base with all fields optional and additional update-only fields */ export const agentUpdateSchema = agentBaseSchema.extend({ + avatar: z.union([agentAvatarSchema, z.null()]).optional(), provider: z.string().optional(), model: z.string().nullable().optional(), projectIds: z.array(z.string()).optional(), diff --git a/packages/client/src/components/DropdownPopup.tsx b/packages/client/src/components/DropdownPopup.tsx index 30ed030677..a3014c8f7f 100644 --- a/packages/client/src/components/DropdownPopup.tsx +++ b/packages/client/src/components/DropdownPopup.tsx @@ -93,7 +93,7 @@ const Menu: React.FC = ({ .map((item, index) => { const { subItems } = item; if (item.separate === true) { - return ; + return ; } if (subItems && subItems.length > 0) { return ( From 3dd827e9d299b0f88d0a7049b5be6c893f8ab133 Mon Sep 17 00:00:00 2001 From: Joseph Licata <54822374+usnavy13@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:11:48 -0500 Subject: [PATCH 05/78] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Update=20Avata?= =?UTF-8?q?r=20component=20to=20improve=20file=20selection=20handling=20(#?= =?UTF-8?q?10555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `openFileDialog` to use `useCallback` for better performance. * Introduced `handleSelectFileClick` to manage file selection click events, enhancing user interaction. --- .../src/components/Nav/SettingsTabs/Account/Avatar.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx index ed677f771a..e8b4437368 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx @@ -134,8 +134,13 @@ function Avatar() { e.preventDefault(); }, []); - const openFileDialog = () => { + const openFileDialog = useCallback(() => { fileInputRef.current?.click(); + }, []); + + const handleSelectFileClick = (event: React.MouseEvent) => { + event.stopPropagation(); + openFileDialog(); }; const resetImage = useCallback(() => { @@ -341,7 +346,7 @@ function Avatar() { : '2MB', })}

- Date: Tue, 18 Nov 2025 13:00:33 -0500 Subject: [PATCH 06/78] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Catch=20Errors=20in?= =?UTF-8?q?=20ToolEndHandler=20and=20Pass=20Logger=20(#10565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- api/server/controllers/agents/callbacks.js | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/package.json b/api/package.json index e64165348b..f8d9b6e642 100644 --- a/api/package.json +++ b/api/package.json @@ -47,7 +47,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.20", + "@librechat/agents": "^3.0.21", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 7d82a24b4b..4742495fc7 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -162,7 +162,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU } const handlers = { [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage), - [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback), + [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger), [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(), [GraphEvents.ON_RUN_STEP]: { /** diff --git a/package-lock.json b/package-lock.json index 3bc7520a70..9e0910e69d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.20", + "@librechat/agents": "^3.0.21", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -16351,9 +16351,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.20.tgz", - "integrity": "sha512-GSFpTYIylN/01c4QksMlsKBkptB4v9qYHzcT7rXxHzj568W7mz0ZPBfs1N6PwZRyp+1RgDwo3v38CS4oQmdOTQ==", + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.21.tgz", + "integrity": "sha512-IZmq0Z5xplBHk2bHTM85h3222OCh5/doYvmVLyYtSjjvoSa3HDsTMxDtYUV5bs0wRIqddrRslq6qwDN7/UWuvQ==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -47334,7 +47334,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.20", + "@librechat/agents": "^3.0.21", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 1197aa9881..0900598bac 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -84,7 +84,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.20", + "@librechat/agents": "^3.0.21", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", From 4a13867a47639be92f2b1e17026705398c2d1534 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 18 Nov 2025 13:09:41 -0500 Subject: [PATCH 07/78] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`@librecha?= =?UTF-8?q?t/agents`=20to=20v3.0.22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index f8d9b6e642..a6e548bbfb 100644 --- a/api/package.json +++ b/api/package.json @@ -47,7 +47,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.21", + "@librechat/agents": "^3.0.22", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 9e0910e69d..dba0f9fc75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.21", + "@librechat/agents": "^3.0.22", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -16351,9 +16351,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.21.tgz", - "integrity": "sha512-IZmq0Z5xplBHk2bHTM85h3222OCh5/doYvmVLyYtSjjvoSa3HDsTMxDtYUV5bs0wRIqddrRslq6qwDN7/UWuvQ==", + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.22.tgz", + "integrity": "sha512-F55L/McFMeEXZgE3L7Uzujqdc1XNHJCPGlo7ciaiaKYN6MWNj2mcs8/dJbwDvvTdIGjwJKIT5sd3K6ugb1BEVw==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -47334,7 +47334,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.21", + "@librechat/agents": "^3.0.22", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 0900598bac..f079cccb87 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -84,7 +84,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.21", + "@librechat/agents": "^3.0.22", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", From ce1812b7c23ef8bcdf7c67920859e1cc6cf85655 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 18 Nov 2025 18:28:57 -0500 Subject: [PATCH 08/78] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Error=20Handling=20?= =?UTF-8?q?in=20MCP=20Tool=20List=20Controller=20(#10570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Handle errors when fetching server tools and log missing tools in MCP tools controller, to prevent all MCP tools from not getting listed * 🔧 fix: Remove trailing colons from error messages in MCPConnection class * chore: Update test command patterns in package.json for cache integration tests --- api/server/controllers/mcp.js | 8 +++++++- api/server/services/Config/mcp.js | 5 +++++ packages/api/package.json | 6 +++--- packages/api/src/mcp/connection.ts | 8 ++++---- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index e113b01f17..5bc6f8f23c 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -44,7 +44,13 @@ const getMCPTools = async (req, res) => { continue; } - const serverTools = await mcpManager.getServerToolFunctions(userId, serverName); + let serverTools; + try { + serverTools = await mcpManager.getServerToolFunctions(userId, serverName); + } catch (error) { + logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error); + continue; + } if (!serverTools) { logger.debug(`[getMCPTools] No tools found for server ${serverName}`); continue; diff --git a/api/server/services/Config/mcp.js b/api/server/services/Config/mcp.js index 7f4210f8c9..15ea62a028 100644 --- a/api/server/services/Config/mcp.js +++ b/api/server/services/Config/mcp.js @@ -16,6 +16,11 @@ async function updateMCPServerTools({ userId, serverName, tools }) { const serverTools = {}; const mcpDelimiter = Constants.mcp_delimiter; + if (tools == null || tools.length === 0) { + logger.debug(`[MCP Cache] No tools to update for server ${serverName} (user: ${userId})`); + return serverTools; + } + for (const tool of tools) { const name = `${tool.name}${mcpDelimiter}${serverName}`; serverTools[name] = { diff --git a/packages/api/package.json b/packages/api/package.json index f079cccb87..600f88b088 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -20,9 +20,9 @@ "build:watch:prod": "rollup -c -w --bundleConfigAsCjs", "test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.*integration\\.|\\.*helper\\.\"", "test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.*integration\\.|\\.*helper\\.\"", - "test:cache-integration:core": "jest --testPathPattern=\"src/cache/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false", - "test:cache-integration:cluster": "jest --testPathPattern=\"src/cluster/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false --runInBand", - "test:cache-integration:mcp": "jest --testPathPattern=\"src/mcp/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false", + "test:cache-integration:core": "jest --testPathPatterns=\"src/cache/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false", + "test:cache-integration:cluster": "jest --testPathPatterns=\"src/cluster/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false --runInBand", + "test:cache-integration:mcp": "jest --testPathPatterns=\"src/mcp/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false", "test:cache-integration": "npm run test:cache-integration:core && npm run test:cache-integration:cluster && npm run test:cache-integration:mcp", "verify": "npm run test:ci", "b:clean": "bun run rimraf dist", diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 7e75acf751..c130b3a467 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -336,7 +336,7 @@ export class MCPConnection extends EventEmitter { } } } catch (error) { - this.emitError(error, 'Failed to construct transport:'); + this.emitError(error, 'Failed to construct transport'); throw error; } } @@ -631,7 +631,7 @@ export class MCPConnection extends EventEmitter { const { resources } = await this.client.listResources(); return resources; } catch (error) { - this.emitError(error, 'Failed to fetch resources:'); + this.emitError(error, 'Failed to fetch resources'); return []; } } @@ -641,7 +641,7 @@ export class MCPConnection extends EventEmitter { const { tools } = await this.client.listTools(); return tools; } catch (error) { - this.emitError(error, 'Failed to fetch tools:'); + this.emitError(error, 'Failed to fetch tools'); return []; } } @@ -651,7 +651,7 @@ export class MCPConnection extends EventEmitter { const { prompts } = await this.client.listPrompts(); return prompts; } catch (error) { - this.emitError(error, 'Failed to fetch prompts:'); + this.emitError(error, 'Failed to fetch prompts'); return []; } } From 69c6d023e194b16faca02db9b1a90f750083acaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anthony=20Qu=C3=A9r=C3=A9?= <47711333+Anthony-Jhoiro@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:49:51 +0100 Subject: [PATCH 09/78] =?UTF-8?q?=F0=9F=93=A8=20feat:=20Pass=20Custom=20He?= =?UTF-8?q?aders=20to=20Model=20Discovery=20(`v1/models`)=20(#10564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/ModelService.js | 4 +- api/server/services/ModelService.spec.js | 122 +++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js index 6cbc018824..28660c4795 100644 --- a/api/server/services/ModelService.js +++ b/api/server/services/ModelService.js @@ -80,7 +80,9 @@ const fetchModels = async ({ try { const options = { - headers: {}, + headers: { + ...(headers ?? {}), + }, timeout: 5000, }; diff --git a/api/server/services/ModelService.spec.js b/api/server/services/ModelService.spec.js index 81c1203461..ca07d9ee71 100644 --- a/api/server/services/ModelService.spec.js +++ b/api/server/services/ModelService.spec.js @@ -81,6 +81,70 @@ describe('fetchModels', () => { ); }); + it('should pass custom headers to the API request', async () => { + const customHeaders = { + 'X-Custom-Header': 'custom-value', + 'X-API-Version': 'v2', + }; + + await fetchModels({ + user: 'user123', + apiKey: 'testApiKey', + baseURL: 'https://api.test.com', + name: 'TestAPI', + headers: customHeaders, + }); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('https://api.test.com/models'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Custom-Header': 'custom-value', + 'X-API-Version': 'v2', + Authorization: 'Bearer testApiKey', + }), + }), + ); + }); + + it('should handle null headers gracefully', async () => { + await fetchModels({ + user: 'user123', + apiKey: 'testApiKey', + baseURL: 'https://api.test.com', + name: 'TestAPI', + headers: null, + }); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('https://api.test.com/models'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer testApiKey', + }), + }), + ); + }); + + it('should handle undefined headers gracefully', async () => { + await fetchModels({ + user: 'user123', + apiKey: 'testApiKey', + baseURL: 'https://api.test.com', + name: 'TestAPI', + headers: undefined, + }); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('https://api.test.com/models'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer testApiKey', + }), + }), + ); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -410,6 +474,64 @@ describe('getAnthropicModels', () => { const models = await getAnthropicModels(); expect(models).toEqual(['claude-1', 'claude-2']); }); + + it('should use Anthropic-specific headers when fetching models', async () => { + delete process.env.ANTHROPIC_MODELS; + process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; + + axios.get.mockResolvedValue({ + data: { + data: [{ id: 'claude-3' }, { id: 'claude-4' }], + }, + }); + + await fetchModels({ + user: 'user123', + apiKey: 'test-anthropic-key', + baseURL: 'https://api.anthropic.com/v1', + name: EModelEndpoint.anthropic, + }); + + expect(axios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { + 'x-api-key': 'test-anthropic-key', + 'anthropic-version': expect.any(String), + }, + }), + ); + }); + + it('should pass custom headers for Anthropic endpoint', async () => { + const customHeaders = { + 'X-Custom-Header': 'custom-value', + }; + + axios.get.mockResolvedValue({ + data: { + data: [{ id: 'claude-3' }], + }, + }); + + await fetchModels({ + user: 'user123', + apiKey: 'test-anthropic-key', + baseURL: 'https://api.anthropic.com/v1', + name: EModelEndpoint.anthropic, + headers: customHeaders, + }); + + expect(axios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { + 'x-api-key': 'test-anthropic-key', + 'anthropic-version': expect.any(String), + }, + }), + ); + }); }); describe('getGoogleModels', () => { From e1fdd5b7e871267150dcb8fdfa5ecfb1c693a980 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Wed, 19 Nov 2025 15:05:00 +0100 Subject: [PATCH 10/78] =?UTF-8?q?=F0=9F=9A=A9=20feat:=20Add=20`--provider`?= =?UTF-8?q?=20flag=20to=20create-user=20script=20(#10572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As we're using google authentication without automatic sign-up, we need a way to pass the provider to the user creation. --- api/server/services/AuthService.js | 4 ++-- config/create-user.js | 32 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 0098e54124..66766837a0 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => { return { status: 404, message: errorMessage }; } - const { email, password, name, username } = user; + const { email, password, name, username, provider } = user; let newUserId; try { @@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => { const salt = bcrypt.genSaltSync(10); const newUserData = { - provider: 'local', + provider: provider ?? 'local', email, username, name, diff --git a/config/create-user.js b/config/create-user.js index ad3a450d42..0e8fdb2b4c 100644 --- a/config/create-user.js +++ b/config/create-user.js @@ -23,32 +23,32 @@ const connect = require('./connect'); console.purple('--------------------------'); } - let email = ''; - let password = ''; - let name = ''; - let username = ''; - let emailVerified = true; - // Parse command line arguments + let email, password, name, username, emailVerified, provider; for (let i = 2; i < process.argv.length; i++) { if (process.argv[i].startsWith('--email-verified=')) { emailVerified = process.argv[i].split('=')[1].toLowerCase() !== 'false'; continue; } - if (!email) { + if (process.argv[i].startsWith('--provider=')) { + provider = process.argv[i].split('=')[1]; + continue; + } + + if (email === undefined) { email = process.argv[i]; - } else if (!name) { + } else if (name === undefined) { name = process.argv[i]; - } else if (!username) { + } else if (username === undefined) { username = process.argv[i]; - } else if (!password) { + } else if (password === undefined) { console.red('Warning: password passed in as argument, this is not secure!'); password = process.argv[i]; } } - if (!email) { + if (email === undefined) { email = await askQuestion('Email:'); } if (!email.includes('@')) { @@ -57,19 +57,19 @@ const connect = require('./connect'); } const defaultName = email.split('@')[0]; - if (!name) { + if (name === undefined) { name = await askQuestion('Name: (default is: ' + defaultName + ')'); if (!name) { name = defaultName; } } - if (!username) { + if (username === undefined) { username = await askQuestion('Username: (default is: ' + defaultName + ')'); if (!username) { username = defaultName; } } - if (!password) { + if (password === undefined) { password = await askQuestion('Password: (leave blank, to generate one)'); if (!password) { password = Math.random().toString(36).slice(-18); @@ -78,7 +78,7 @@ const connect = require('./connect'); } // Only prompt for emailVerified if it wasn't set via CLI - if (!process.argv.some((arg) => arg.startsWith('--email-verified='))) { + if (emailVerified === undefined){ const emailVerifiedInput = await askQuestion(`Email verified? (Y/n, default is Y): If \`y\`, the user's email will be considered verified. @@ -99,7 +99,7 @@ or the user will need to attempt logging in to have a verification link sent to silentExit(1); } - const user = { email, password, name, username, confirm_password: password }; + const user = { email, password, name, username, confirm_password: password, provider }; let result; try { result = await registerUser(user, { emailVerified }); From 4c2719a37e5b48c3852ed3f08a11956b7bd4ef3f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 19 Nov 2025 09:20:44 -0500 Subject: [PATCH 11/78] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20chore:=20Enhance?= =?UTF-8?q?=20Agents=20Error=20Handling=20via=20`@librechat/agents@v3.0.25?= =?UTF-8?q?`=20(#10577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Enhance error handling for agents system in uncaughtException logger * Added specific logging for errors originating from the agents system to improve debugging and maintain application stability. * 📦 chore: Update dependencies for `@librechat/agents` and related packages to v3.0.25 and improve version consistency across modules --- api/package.json | 2 +- api/server/index.js | 11 +++ package-lock.json | 190 +++++++++++++++++++++++++++++--------- packages/api/package.json | 2 +- 4 files changed, 161 insertions(+), 44 deletions(-) diff --git a/api/package.json b/api/package.json index a6e548bbfb..e5a4525ad1 100644 --- a/api/package.json +++ b/api/package.json @@ -47,7 +47,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.22", + "@librechat/agents": "^3.0.25", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/index.js b/api/server/index.js index 4f6721eb5c..311b9a796f 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -213,6 +213,17 @@ process.on('uncaughtException', (err) => { return; } + if (err.stack && err.stack.includes('@librechat/agents')) { + logger.error( + '\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.', + { + message: err.message, + stack: err.stack, + }, + ); + return; + } + process.exit(1); }); diff --git a/package-lock.json b/package-lock.json index dba0f9fc75..e8a1826f86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.22", + "@librechat/agents": "^3.0.25", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -15851,9 +15851,9 @@ } }, "node_modules/@langchain/google-common": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/@langchain/google-common/-/google-common-0.2.13.tgz", - "integrity": "sha512-Wd254vAajKxK3bIYPmuFRrk90oN3YIDzwwiO+3ojYKoWP+EBzW3eg3B4f8ofvGXUkJPxEwp/u8ymSsVUElUGlw==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@langchain/google-common/-/google-common-0.2.18.tgz", + "integrity": "sha512-HjWB6Bx4zj7KkiHnqRpx8YNaXdA97sKQMQ17keyWl7nQJlRauNyymm8QGeduKSEfECDr2nGzY8Y/SNY64X6cSA==", "license": "MIT", "dependencies": { "uuid": "^10.0.0" @@ -15879,12 +15879,12 @@ } }, "node_modules/@langchain/google-gauth": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/@langchain/google-gauth/-/google-gauth-0.2.13.tgz", - "integrity": "sha512-JAIMtdmN+6/5aPRz3XUCFQ8+4TP272V8QCLhcyZ9LhDlnmY5DJv+LhzjMk9L5XZx9sRnKRvthVWiAY0Xbs3qAg==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@langchain/google-gauth/-/google-gauth-0.2.18.tgz", + "integrity": "sha512-xof4jBnPB0YI6OlFuETdbODoM05XBTJoC+qQKJ4qNOcWI7u760sRKm57cvG+jzjParojAxdCdrNEKV47wUpoKg==", "license": "MIT", "dependencies": { - "@langchain/google-common": "^0.2.13", + "@langchain/google-common": "^0.2.18", "google-auth-library": "^10.1.0" }, "engines": { @@ -15895,32 +15895,42 @@ } }, "node_modules/@langchain/google-gauth/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" } }, + "node_modules/@langchain/google-gauth/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@langchain/google-gauth/node_modules/gaxios": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.0.tgz", - "integrity": "sha512-y1Q0MX1Ba6eg67Zz92kW0MHHhdtWksYckQy1KJsI6P4UlDQ8cvdvpLEPslD/k7vFkdPppMESFGTvk7XpSiKj8g==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" }, "engines": { "node": ">=18" } }, "node_modules/@langchain/google-gauth/node_modules/gcp-metadata": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.0.tgz", - "integrity": "sha512-3PfRTzvT3Msu0Hy8Gf9ypxJvaClG2IB9pyH0r8QOmRBW5mUcrHgYpF4GYP+XulDbfhxEhBYtJtJJQb5S2wM+LA==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -15931,16 +15941,36 @@ "node": ">=18" } }, + "node_modules/@langchain/google-gauth/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@langchain/google-gauth/node_modules/google-auth-library": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.1.0.tgz", - "integrity": "sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", - "gcp-metadata": "^7.0.0", + "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" @@ -15950,9 +15980,9 @@ } }, "node_modules/@langchain/google-gauth/node_modules/google-logging-utils": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz", - "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -15984,6 +16014,36 @@ "node": ">= 14" } }, + "node_modules/@langchain/google-gauth/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@langchain/google-gauth/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@langchain/google-gauth/node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", @@ -16002,10 +16062,25 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/@langchain/google-gauth/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@langchain/google-genai": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-0.2.13.tgz", - "integrity": "sha512-ReZe4oNUhPNEijYo9CGA3/CJUwVPaaoYnyplZyYTbUNPAwwRH5aR1e6bppKFBb+ZZeTRCR25JFDIPnXJFfjaBg==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-0.2.18.tgz", + "integrity": "sha512-m9EiN3VKC01A7/625YQ6Q1Lqq8zueewADX4W5Tcme4RImN75zkg2Z7FYbD1Fo6Zwolc4wBNO6LUtbg3no4rv1Q==", "license": "MIT", "dependencies": { "@google/generative-ai": "^0.24.0", @@ -16031,12 +16106,12 @@ } }, "node_modules/@langchain/google-vertexai": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/@langchain/google-vertexai/-/google-vertexai-0.2.13.tgz", - "integrity": "sha512-Y97f0IBr4uWsyJTcDJROWXuu+qh4elSDLK1e6MD+mrxCx+UlgcXCReg4zvEFJzqpBKrfFt+lvXstJ6XTR6Zfyg==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@langchain/google-vertexai/-/google-vertexai-0.2.18.tgz", + "integrity": "sha512-oZsOp9Sx4rsFpHH5UiuObo5NYCAqhhmroL3f3pDZ06DB6hpfnNc6XNjdpbmt0AemP6PO/52UlKHeSYtnYlBzIQ==", "license": "MIT", "dependencies": { - "@langchain/google-gauth": "^0.2.10" + "@langchain/google-gauth": "^0.2.18" }, "engines": { "node": ">=18" @@ -16351,17 +16426,17 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.22.tgz", - "integrity": "sha512-F55L/McFMeEXZgE3L7Uzujqdc1XNHJCPGlo7ciaiaKYN6MWNj2mcs8/dJbwDvvTdIGjwJKIT5sd3K6ugb1BEVw==", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.25.tgz", + "integrity": "sha512-2TQETX++K75TEEG/30KZdwsPzUljH1MCrKkh55UcHM7OWvUNZCIRlgiWqov731NPwp1bCI3fbXoncbnWzL8NDA==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", "@langchain/aws": "^0.1.15", "@langchain/core": "^0.3.79", "@langchain/deepseek": "^0.0.2", - "@langchain/google-genai": "^0.2.13", - "@langchain/google-vertexai": "^0.2.13", + "@langchain/google-genai": "^0.2.18", + "@langchain/google-vertexai": "^0.2.18", "@langchain/langgraph": "^0.4.9", "@langchain/mistralai": "^0.2.1", "@langchain/ollama": "^0.2.3", @@ -25733,7 +25808,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, + "devOptional": true, "dependencies": { "debug": "4" }, @@ -30998,6 +31073,36 @@ "node": ">= 14" } }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/generic-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", @@ -31914,7 +32019,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, + "devOptional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -37746,6 +37851,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "funding": [ { "type": "github", @@ -37756,6 +37862,7 @@ "url": "https://paypal.me/jimmywarting" } ], + "license": "MIT", "engines": { "node": ">=10.5.0" } @@ -38417,8 +38524,7 @@ "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, "node_modules/pako": { "version": "1.0.11", @@ -47334,7 +47440,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.22", + "@librechat/agents": "^3.0.25", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 600f88b088..a48d8c74f1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -84,7 +84,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.22", + "@librechat/agents": "^3.0.25", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", From 8b9afd5965f5328471f55f35d69d810665e50a70 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 19 Nov 2025 15:05:37 -0500 Subject: [PATCH 12/78] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Gemini=203=20Suppo?= =?UTF-8?q?rt=20(#10584)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for model in token configurations and tests * chore: Update @librechat/agents to version 3.0.26 in package.json and package-lock.json --- api/models/tx.js | 1 + api/models/tx.spec.js | 2 ++ api/package.json | 2 +- api/utils/tokens.spec.js | 3 +++ package-lock.json | 10 +++++----- packages/api/package.json | 2 +- packages/api/src/utils/tokens.ts | 1 + 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/api/models/tx.js b/api/models/tx.js index 92f2432d0e..48d2801dbd 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -156,6 +156,7 @@ const tokenValues = Object.assign( 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, + 'gemini-3': { prompt: 2, completion: 12 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2 'grok-beta': { prompt: 5.0, completion: 15.0 }, diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 670ea9d5ec..7f11e4e466 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -1040,6 +1040,7 @@ describe('getCacheMultiplier', () => { describe('Google Model Tests', () => { const googleModels = [ + 'gemini-3', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', @@ -1083,6 +1084,7 @@ describe('Google Model Tests', () => { it('should map to the correct model keys', () => { const expected = { + 'gemini-3': 'gemini-3', 'gemini-2.5-pro': 'gemini-2.5-pro', 'gemini-2.5-flash': 'gemini-2.5-flash', 'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite', diff --git a/api/package.json b/api/package.json index e5a4525ad1..1c153b4bff 100644 --- a/api/package.json +++ b/api/package.json @@ -47,7 +47,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.25", + "@librechat/agents": "^3.0.26", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 12daf64e47..dd01f4cb07 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -275,6 +275,9 @@ describe('getModelMaxTokens', () => { expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-1.5'], ); + expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-3'], + ); expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'], ); diff --git a/package-lock.json b/package-lock.json index e8a1826f86..0799d241de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^3.0.25", + "@librechat/agents": "^3.0.26", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -16426,9 +16426,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.25", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.25.tgz", - "integrity": "sha512-2TQETX++K75TEEG/30KZdwsPzUljH1MCrKkh55UcHM7OWvUNZCIRlgiWqov731NPwp1bCI3fbXoncbnWzL8NDA==", + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.26.tgz", + "integrity": "sha512-ALJQlry68RjxHE6Jq1S7l8M3bmBTrikkT5C6NhN8SRgq1DFoov383wDiHqOs7WwxG29Zh2FmBEGKd23bkjiTcw==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -47440,7 +47440,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.25", + "@librechat/agents": "^3.0.26", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index a48d8c74f1..92a31068f9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -84,7 +84,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.79", - "@librechat/agents": "^3.0.25", + "@librechat/agents": "^3.0.26", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.21.0", "axios": "^1.12.1", diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index 32921ca851..3842e7bf3e 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -91,6 +91,7 @@ const googleModels = { gemini: 30720, // -2048 from max 'gemini-pro-vision': 12288, 'gemini-exp': 2000000, + 'gemini-3': 1000000, // 1M input tokens, 64k output tokens 'gemini-2.5': 1000000, // 1M input tokens, 64k output tokens 'gemini-2.5-pro': 1000000, 'gemini-2.5-flash': 1000000, From 014eb106622d926e4592bfce4e4f6562e002b652 Mon Sep 17 00:00:00 2001 From: Daniel Lew Date: Wed, 19 Nov 2025 16:10:10 -0600 Subject: [PATCH 13/78] =?UTF-8?q?=F0=9F=93=A2=20fix:=20Resolved=20Screen?= =?UTF-8?q?=20Reader=20Issues=20with=20`TooltipAnchor`=20(#10580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TooltipAnchor was automatically adding an `aria-describedby` tag which often duplicated the labeling already present inside of the anchor. E.g., the screen reader might say "New Chat, New Chat, button" instead of just "New Chat, button." I've removed the TooltipAnchor's automatic `aria-describedby` and worked to make sure that anyone using TooltipAnchor properly defines its labeling. --- .../components/Artifacts/ArtifactVersion.tsx | 7 ++++- client/src/components/Chat/Landing.tsx | 1 + .../Chat/Messages/Content/DialogImage.tsx | 26 ++++++++++++++----- .../ConvoOptions/SharedLinkButton.tsx | 19 +++++++++++--- .../Builder/AssistantConversationStarters.tsx | 12 +++++---- packages/client/src/components/Tooltip.tsx | 3 --- 6 files changed, 49 insertions(+), 19 deletions(-) diff --git a/client/src/components/Artifacts/ArtifactVersion.tsx b/client/src/components/Artifacts/ArtifactVersion.tsx index 1998ff02d1..7d17416cdf 100644 --- a/client/src/components/Artifacts/ArtifactVersion.tsx +++ b/client/src/components/Artifacts/ArtifactVersion.tsx @@ -59,7 +59,12 @@ export default function ArtifactVersion({ + @@ -208,7 +213,12 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm + } @@ -217,22 +227,24 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm downloadImage()} variant="ghost" className="h-10 w-10 p-0"> + } /> setIsPromptOpen(!isPromptOpen)} variant="ghost" className="h-10 w-10 p-0" + aria-label={imageDetailsLabel} > {isPromptOpen ? ( diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 9a2b740985..e5d1dbfb20 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -113,6 +113,8 @@ export default function SharedLinkButton({ } }; + const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr'); + return ( <>
@@ -130,6 +132,7 @@ export default function SharedLinkButton({ )} @@ -154,7 +162,12 @@ export default function SharedLinkButton({ ( - )} diff --git a/client/src/components/SidePanel/Builder/AssistantConversationStarters.tsx b/client/src/components/SidePanel/Builder/AssistantConversationStarters.tsx index 9c8da37b1b..01b45c27d5 100644 --- a/client/src/components/SidePanel/Builder/AssistantConversationStarters.tsx +++ b/client/src/components/SidePanel/Builder/AssistantConversationStarters.tsx @@ -59,6 +59,10 @@ const AssistantConversationStarters: React.FC= Constants.MAX_CONVO_STARTERS; + const addConversationStarterLabel = hasReachedMax + ? localize('com_assistants_max_starters_reached') + : localize('com_ui_add'); + return (