diff --git a/.env.example b/.env.example index b98b7ac62f..90995be72f 100644 --- a/.env.example +++ b/.env.example @@ -785,3 +785,7 @@ OPENWEATHER_API_KEY= # Cache connection status checks for this many milliseconds to avoid expensive verification # MCP_CONNECTION_CHECK_TTL=60000 + +# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it) +# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration +# MCP_SKIP_CODE_CHALLENGE_CHECK=false diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml index bdd3f2e83d..1f056dd791 100644 --- a/.github/workflows/cache-integration-tests.yml +++ b/.github/workflows/cache-integration-tests.yml @@ -61,30 +61,23 @@ jobs: npm run build:data-schemas npm run build:api - - name: Run cache integration tests + - name: Run all cache integration tests (Single Redis Node) working-directory: packages/api env: NODE_ENV: test USE_REDIS: true + USE_REDIS_CLUSTER: false REDIS_URI: redis://127.0.0.1:6379 - REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 - run: npm run test:cache-integration:core + run: npm run test:cache-integration - - name: Run cluster integration tests + - name: Run all cache integration tests (Redis Cluster) working-directory: packages/api env: NODE_ENV: test USE_REDIS: true - REDIS_URI: redis://127.0.0.1:6379 - run: npm run test:cache-integration:cluster - - - name: Run mcp integration tests - working-directory: packages/api - env: - NODE_ENV: test - USE_REDIS: true - REDIS_URI: redis://127.0.0.1:6379 - run: npm run test:cache-integration:mcp + USE_REDIS_CLUSTER: true + REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 + run: npm run test:cache-integration - name: Stop Redis Cluster if: always() diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml index ea1a5f2416..9383dd939e 100644 --- a/.github/workflows/eslint-ci.yml +++ b/.github/workflows/eslint-ci.yml @@ -35,8 +35,6 @@ jobs: # Run ESLint on changed files within the api/ and client/ directories. - name: Run ESLint on changed files - env: - SARIF_ESLINT_IGNORE_SUPPRESSED: "true" run: | # Extract the base commit SHA from the pull_request event payload. BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") @@ -52,22 +50,10 @@ jobs: # Ensure there are files to lint before running ESLint if [[ -z "$CHANGED_FILES" ]]; then echo "No matching files changed. Skipping ESLint." - echo "UPLOAD_SARIF=false" >> $GITHUB_ENV exit 0 fi - # Set variable to allow SARIF upload - echo "UPLOAD_SARIF=true" >> $GITHUB_ENV - # Run ESLint npx eslint --no-error-on-unmatched-pattern \ --config eslint.config.mjs \ - --format @microsoft/eslint-formatter-sarif \ - --output-file eslint-results.sarif $CHANGED_FILES || true - - - name: Upload analysis results to GitHub - if: env.UPLOAD_SARIF == 'true' - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: eslint-results.sarif - wait-for-processing: true \ No newline at end of file + $CHANGED_FILES \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0796905501..d173d26b60 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,34 @@ helm/**/.values.yaml /.tabnine/ /.codeium *.local.md + + +# Removed Windows wrapper files per user request +hive-mind-prompt-*.txt + +# Claude Flow generated files +.claude/settings.local.json +.mcp.json +claude-flow.config.json +.swarm/ +.hive-mind/ +.claude-flow/ +memory/ +coordination/ +memory/claude-flow-data.json +memory/sessions/* +!memory/sessions/README.md +memory/agents/* +!memory/agents/README.md +coordination/memory_bank/* +coordination/subtasks/* +coordination/orchestration/* +*.db +*.db-journal +*.db-wal +*.sqlite +*.sqlite-journal +*.sqlite-wal +claude-flow +# Removed Windows wrapper files per user request +hive-mind-prompt-*.txt 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/output_parsers/addImages.js b/api/app/clients/output_parsers/addImages.js index 4b5019279a..f0860ef8bd 100644 --- a/api/app/clients/output_parsers/addImages.js +++ b/api/app/clients/output_parsers/addImages.js @@ -1,3 +1,4 @@ +const { getBasePath } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); /** @@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) { return; } + const basePath = getBasePath(); + // Correct any erroneous URLs in the responseMessage.text first intermediateSteps.forEach((step) => { const { observation } = step; @@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) { return; } const essentialImagePath = match[0]; + const fullImagePath = `${basePath}${essentialImagePath}`; const regex = /!\[.*?\]\((.*?)\)/g; let matchErroneous; while ((matchErroneous = regex.exec(responseMessage.text)) !== null) { - if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) { - responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath); + if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) { + // Replace with the full path including base path + responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath); } } }); @@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) { return; } const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g); - if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) { - responseMessage.text += '\n' + observedImagePath[0]; - logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]); + if (observedImagePath) { + // Fix the image path to include base path if it doesn't already + let imageMarkdown = observedImagePath[0]; + const urlMatch = imageMarkdown.match(/\(([^)]+)\)/); + if ( + urlMatch && + urlMatch[1] && + !urlMatch[1].startsWith(`${basePath}/images/`) && + urlMatch[1].startsWith('/images/') + ) { + imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`); + } + + if (!responseMessage.text.includes(imageMarkdown)) { + responseMessage.text += '\n' + imageMarkdown; + logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown); + } } }); } diff --git a/api/app/clients/output_parsers/addImages.spec.js b/api/app/clients/output_parsers/addImages.spec.js index 7c5a04137e..ef4dd22c0b 100644 --- a/api/app/clients/output_parsers/addImages.spec.js +++ b/api/app/clients/output_parsers/addImages.spec.js @@ -74,7 +74,7 @@ describe('addImages', () => { it('should append correctly from a real scenario', () => { responseMessage.text = - 'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?'; + "Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?"; const originalText = responseMessage.text; const imageMarkdown = '![generated image](/images/img-RnVWaYo2Yg4x3e0isICiMuf5.png)'; intermediateSteps.push({ observation: imageMarkdown }); @@ -139,4 +139,108 @@ describe('addImages', () => { addImages(intermediateSteps, responseMessage); expect(responseMessage.text).toBe('\n![image1](/images/image1.png)'); }); + + describe('basePath functionality', () => { + let originalDomainClient; + + beforeEach(() => { + originalDomainClient = process.env.DOMAIN_CLIENT; + }); + + afterEach(() => { + process.env.DOMAIN_CLIENT = originalDomainClient; + }); + + it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)'); + }); + + it('should not prepend base path when image URL already has base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](/librechat/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)'); + }); + + it('should correct erroneous URLs with base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + responseMessage.text = '![desc](sandbox:/images/test.png)'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('![desc](/librechat/images/test.png)'); + }); + + it('should handle empty base path (root deployment)', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/images/test.png)'); + }); + + it('should handle missing DOMAIN_CLIENT', () => { + delete process.env.DOMAIN_CLIENT; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/images/test.png)'); + }); + + it('should handle observation without image path match', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](not-an-image-path)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](not-an-image-path)'); + }); + + it('should handle nested subdirectories in base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/apps/librechat/images/test.png)'); + }); + + it('should handle multiple observations with mixed base path scenarios', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc1](/images/test1.png)' }); + intermediateSteps.push({ observation: '![desc2](/librechat/images/test2.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe( + '\n![desc1](/librechat/images/test1.png)\n![desc2](/librechat/images/test2.png)', + ); + }); + + it('should handle complex markdown with base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const complexMarkdown = ` + # Document Title + ![image1](/images/image1.png) + Some text between images + ![image2](/images/image2.png) + `; + intermediateSteps.push({ observation: complexMarkdown }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![image1](/librechat/images/image1.png)'); + }); + + it('should handle URLs that are already absolute', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](https://example.com/image.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](https://example.com/image.png)'); + }); + + it('should handle data URLs', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ + observation: + '![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)', + }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe( + '\n![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)', + ); + }); + }); }); 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/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index 05687923e6..3a1ea831d3 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { FileContext, ContentTypes } = require('librechat-data-provider'); +const { getBasePath } = require('@librechat/api'); const paths = require('~/config/paths'); const displayMessage = @@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool { this.description_for_model = `// Generate images and visuals using text. // Guidelines: // - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries. -// - ALWAYS include the markdown url in your final response to show the user: ![caption](/images/id.png) +// - ALWAYS include the markdown url in your final response to show the user: ![caption](${getBasePath()}/images/id.png) // - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes. // - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting. // - Here's an example for generating a realistic portrait photo of a man: 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/models/Agent.js b/api/models/Agent.js index b802ca187b..1cd6ba3ed9 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -12,8 +12,8 @@ const { } = require('./Project'); const { removeAllPermissions } = require('~/server/services/PermissionService'); const { getMCPServerTools } = require('~/server/services/Config'); +const { Agent, AclEntry } = require('~/db/models'); const { getActions } = require('./Action'); -const { Agent } = require('~/db/models'); /** * Create an agent with the provided data. @@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => { return agent; }; +/** + * Deletes all agents created by a specific user. + * @param {string} userId - The ID of the user whose agents should be deleted. + * @returns {Promise} A promise that resolves when all user agents have been deleted. + */ +const deleteUserAgents = async (userId) => { + try { + const userAgents = await getAgents({ author: userId }); + + if (userAgents.length === 0) { + return; + } + + const agentIds = userAgents.map((agent) => agent.id); + const agentObjectIds = userAgents.map((agent) => agent._id); + + for (const agentId of agentIds) { + await removeAgentFromAllProjects(agentId); + } + + await AclEntry.deleteMany({ + resourceType: ResourceType.AGENT, + resourceId: { $in: agentObjectIds }, + }); + + await Agent.deleteMany({ author: userId }); + } catch (error) { + logger.error('[deleteUserAgents] General error:', error); + } +}; + /** * Get agents by accessible IDs with optional cursor-based pagination. * @param {Object} params - The parameters for getting accessible agents. @@ -856,6 +887,7 @@ module.exports = { createAgent, updateAgent, deleteAgent, + deleteUserAgents, getListAgents, revertAgentVersion, updateAgentProjects, diff --git a/api/models/Message.js b/api/models/Message.js index 02b74ec71e..8fe04f6f54 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) { * * @async * @function deleteMessages - * @param {Object} filter - The filter criteria to find messages to delete. - * @returns {Promise} The metadata with count of deleted messages. + * @param {import('mongoose').FilterQuery} filter - The filter criteria to find messages to delete. + * @returns {Promise} The metadata with count of deleted messages. * @throws {Error} If there is an error in deleting messages. */ async function deleteMessages(filter) { diff --git a/api/models/Prompt.js b/api/models/Prompt.js index d96780a038..fbc161e97d 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -13,7 +13,7 @@ const { getProjectByName, } = require('./Project'); const { removeAllPermissions } = require('~/server/services/PermissionService'); -const { PromptGroup, Prompt } = require('~/db/models'); +const { PromptGroup, Prompt, AclEntry } = require('~/db/models'); const { escapeRegExp } = require('~/server/utils'); /** @@ -591,6 +591,36 @@ module.exports = { return { prompt: 'Prompt deleted successfully' }; } }, + /** + * Delete all prompts and prompt groups created by a specific user. + * @param {ServerRequest} req - The server request object. + * @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted. + */ + deleteUserPrompts: async (req, userId) => { + try { + const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) }); + + if (promptGroups.length === 0) { + return; + } + + const groupIds = promptGroups.map((group) => group._id); + + for (const groupId of groupIds) { + await removeGroupFromAllProjects(groupId); + } + + await AclEntry.deleteMany({ + resourceType: ResourceType.PROMPTGROUP, + resourceId: { $in: groupIds }, + }); + + await PromptGroup.deleteMany({ author: new ObjectId(userId) }); + await Prompt.deleteMany({ author: new ObjectId(userId) }); + } catch (error) { + logger.error('[deleteUserPrompts] General error:', error); + } + }, /** * Update prompt group * @param {Partial} filter - Filter to find prompt group diff --git a/api/models/tx.js b/api/models/tx.js index 92f2432d0e..328f2c2d4d 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -136,6 +136,7 @@ const tokenValues = Object.assign( 'claude-3.7-sonnet': { prompt: 3, completion: 15 }, 'claude-haiku-4-5': { prompt: 1, completion: 5 }, 'claude-opus-4': { prompt: 15, completion: 75 }, + 'claude-opus-4-5': { prompt: 5, completion: 25 }, 'claude-sonnet-4': { prompt: 3, completion: 15 }, 'command-r': { prompt: 0.5, completion: 1.5 }, 'command-r-plus': { prompt: 3, completion: 15 }, @@ -156,6 +157,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 }, @@ -237,8 +239,10 @@ const cacheTokenValues = { 'claude-3.5-haiku': { write: 1, read: 0.08 }, 'claude-3-5-haiku': { write: 1, read: 0.08 }, 'claude-3-haiku': { write: 0.3, read: 0.03 }, + 'claude-haiku-4-5': { write: 1.25, read: 0.1 }, 'claude-sonnet-4': { write: 3.75, read: 0.3 }, 'claude-opus-4': { write: 18.75, read: 1.5 }, + 'claude-opus-4-5': { write: 6.25, read: 0.5 }, }; /** diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 670ea9d5ec..b70f9572d0 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', @@ -1370,6 +1372,15 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct prompt and completion rates for Claude Opus 4.5', () => { + expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'prompt' })).toBe( + tokenValues['claude-opus-4-5'].prompt, + ); + expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'completion' })).toBe( + tokenValues['claude-opus-4-5'].completion, + ); + }); + it('should handle Claude Haiku 4.5 model name variations', () => { const modelVariations = [ 'claude-haiku-4-5', @@ -1392,6 +1403,28 @@ describe('Claude Model Tests', () => { }); }); + it('should handle Claude Opus 4.5 model name variations', () => { + const modelVariations = [ + 'claude-opus-4-5', + 'claude-opus-4-5-20250420', + 'claude-opus-4-5-latest', + 'anthropic/claude-opus-4-5', + 'claude-opus-4-5/anthropic', + 'claude-opus-4-5-preview', + ]; + + modelVariations.forEach((model) => { + const valueKey = getValueKey(model); + expect(valueKey).toBe('claude-opus-4-5'); + expect(getMultiplier({ model, tokenType: 'prompt' })).toBe( + tokenValues['claude-opus-4-5'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion' })).toBe( + tokenValues['claude-opus-4-5'].completion, + ); + }); + }); + it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', @@ -1438,6 +1471,15 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct cache rates for Claude Opus 4.5', () => { + expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'write' })).toBe( + cacheTokenValues['claude-opus-4-5'].write, + ); + expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'read' })).toBe( + cacheTokenValues['claude-opus-4-5'].read, + ); + }); + it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', diff --git a/api/package.json b/api/package.json index f085a5faa8..fd97150fa1 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.32", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -76,7 +76,7 @@ "handlebars": "^4.7.7", "https-proxy-agent": "^7.0.6", "ioredis": "^5.3.2", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", @@ -117,7 +117,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "jest": "^29.7.0", + "jest": "^30.2.0", "mongodb-memory-server": "^10.1.4", "nodemon": "^3.0.3", "supertest": "^7.1.0" 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/AuthController.js b/api/server/controllers/AuthController.js index 096727e977..dfef2bbfa1 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -82,7 +82,15 @@ const refreshController = async (req, res) => { if (error || !user) { return res.status(401).redirect('/login'); } - const token = setOpenIDAuthTokens(tokenset, res, user._id.toString()); + const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken); + + user.federatedTokens = { + access_token: tokenset.access_token, + id_token: tokenset.id_token, + refresh_token: refreshToken, + expires_at: claims.exp, + }; + return res.status(200).send({ token, user }); } catch (error) { logger.error('[refreshController] OpenID token refresh error', error); diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index b488864a93..9bdf6c5e28 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro const { MCPOAuthHandler, MCPTokenStorage, + mcpServersRegistry, normalizeHttpError, extractWebSearchEnvVars, } = require('@librechat/api'); const { - getFiles, - findToken, - updateUser, - deleteFiles, - deleteConvos, - deletePresets, - deleteMessages, - deleteUserById, - deleteAllSharedLinks, deleteAllUserSessions, + deleteAllSharedLinks, + deleteUserById, + deleteMessages, + deletePresets, + deleteConvos, + deleteFiles, + updateUser, + findToken, + getFiles, } = require('~/models'); +const { + ConversationTag, + Transaction, + MemoryEntry, + Assistant, + AclEntry, + Balance, + Action, + Group, + Token, + User, +} = require('~/db/models'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud'); const { processDeleteRequest } = require('~/server/services/Files/process'); -const { Transaction, Balance, User, Token } = require('~/db/models'); const { getMCPManager, getFlowStateManager } = require('~/config'); const { getAppConfig } = require('~/server/services/Config'); const { deleteToolCalls } = require('~/models/ToolCall'); +const { deleteUserPrompts } = require('~/models/Prompt'); +const { deleteUserAgents } = require('~/models/Agent'); const { getLogStores } = require('~/cache'); -const { mcpServersRegistry } = require('@librechat/api'); const getUserController = async (req, res) => { const appConfig = await getAppConfig({ role: req.user?.role }); @@ -237,7 +250,6 @@ const deleteUserController = async (req, res) => { await deleteUserKey({ userId: user.id, all: true }); // delete user keys await Balance.deleteMany({ user: user._id }); // delete user balances await deletePresets(user.id); // delete user presets - /* TODO: Delete Assistant Threads */ try { await deleteConvos(user.id); // delete user convos } catch (error) { @@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => { await deleteUserFiles(req); // delete user files await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps await deleteToolCalls(user.id); // delete user tool calls - /* TODO: queue job for cleaning actions and assistants of non-existant users */ + await deleteUserAgents(user.id); // delete user agents + await Assistant.deleteMany({ user: user.id }); // delete user assistants + await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags + await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries + await deleteUserPrompts(req, user.id); // delete user prompts + await Action.deleteMany({ user: user.id }); // delete user actions + await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens + await Group.updateMany( + // remove user from all groups + { memberIds: user.id }, + { $pull: { memberIds: user.id } }, + ); + await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`); res.status(200).send({ message: 'User deleted' }); } catch (err) { diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 65f5501416..4742495fc7 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, }); } @@ -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/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index d76dc4bb6e..baa9b7a37a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,6 +9,7 @@ const { logAxiosError, sanitizeTitle, resolveHeaders, + createSafeUser, getBalanceConfig, memoryInstructions, getTransactionsConfig, @@ -20,6 +21,7 @@ const { Providers, TitleMethod, formatMessage, + labelContentByAgent, formatAgentMessages, getTokenCountForMessage, createMetadataAggregator, @@ -92,6 +94,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 +198,8 @@ class AgentClient extends BaseClient { this.indexTokenCountMap = {}; /** @type {(messages: BaseMessage[]) => Promise} */ this.processMemory; + /** @type {Record | null} */ + this.agentIdMap = null; } /** @@ -233,6 +292,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 +677,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 }; } /** @@ -788,7 +857,7 @@ class AgentClient extends BaseClient { conversationId: this.conversationId, parentMessageId: this.parentMessageId, }, - user: this.options.req.user, + user: createSafeUser(this.options.req.user), }, recursionLimit: agentsEConfig?.recursionLimit ?? 25, signal: abortController.signal, @@ -864,6 +933,7 @@ class AgentClient extends BaseClient { signal: abortController.signal, customHandlers: this.options.eventHandlers, requestBody: config.configurable.requestBody, + user: createSafeUser(this.options.req?.user), tokenCounter: createTokenCounter(this.getEncoding()), }); @@ -902,6 +972,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 +1023,9 @@ class AgentClient extends BaseClient { err, ); } + run = null; + config = null; + memoryPromise = null; } } @@ -1063,6 +1154,7 @@ class AgentClient extends BaseClient { if (clientOptions?.configuration?.defaultHeaders != null) { clientOptions.configuration.defaultHeaders = resolveHeaders({ headers: clientOptions.configuration.defaultHeaders, + user: createSafeUser(this.options.req?.user), body: { messageId: this.responseMessageId, conversationId: this.conversationId, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index ac47dff66c..0ce59c5fbc 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -989,7 +989,7 @@ describe('AgentClient - titleConvo', () => { }; // Simulate the getOptions logic that handles GPT-5+ models - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens; delete clientOptions.maxTokens; @@ -1009,7 +1009,7 @@ describe('AgentClient - titleConvo', () => { useResponsesApi: true, }; - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; const paramName = clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens'; @@ -1034,7 +1034,7 @@ describe('AgentClient - titleConvo', () => { }; // Simulate the getOptions logic - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens; delete clientOptions.maxTokens; @@ -1055,7 +1055,7 @@ describe('AgentClient - titleConvo', () => { }; // Simulate the getOptions logic - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens; delete clientOptions.maxTokens; @@ -1068,6 +1068,9 @@ describe('AgentClient - titleConvo', () => { it('should handle various GPT-5+ model formats', () => { const testCases = [ + { model: 'gpt-5.1', shouldTransform: true }, + { model: 'gpt-5.1-chat-latest', shouldTransform: true }, + { model: 'gpt-5.1-codex', shouldTransform: true }, { model: 'gpt-5', shouldTransform: true }, { model: 'gpt-5-turbo', shouldTransform: true }, { model: 'gpt-6', shouldTransform: true }, @@ -1087,7 +1090,10 @@ describe('AgentClient - titleConvo', () => { }; // Simulate the getOptions logic - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if ( + /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && + clientOptions.maxTokens != null + ) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens; delete clientOptions.maxTokens; @@ -1105,6 +1111,9 @@ describe('AgentClient - titleConvo', () => { it('should not swap max token param for older models when using useResponsesApi', () => { const testCases = [ + { model: 'gpt-5.1', shouldTransform: true }, + { model: 'gpt-5.1-chat-latest', shouldTransform: true }, + { model: 'gpt-5.1-codex', shouldTransform: true }, { model: 'gpt-5', shouldTransform: true }, { model: 'gpt-5-turbo', shouldTransform: true }, { model: 'gpt-6', shouldTransform: true }, @@ -1124,7 +1133,10 @@ describe('AgentClient - titleConvo', () => { useResponsesApi: true, }; - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if ( + /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && + clientOptions.maxTokens != null + ) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; const paramName = clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens'; @@ -1157,7 +1169,10 @@ describe('AgentClient - titleConvo', () => { }; // Simulate the getOptions logic - if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) { + if ( + /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && + clientOptions.maxTokens != null + ) { clientOptions.modelKwargs = clientOptions.modelKwargs ?? {}; clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens; delete clientOptions.maxTokens; 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/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/experimental.js b/api/server/experimental.js new file mode 100644 index 0000000000..f61b52a9c4 --- /dev/null +++ b/api/server/experimental.js @@ -0,0 +1,416 @@ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +require('module-alias')({ base: path.resolve(__dirname, '..') }); +const cluster = require('cluster'); +const Redis = require('ioredis'); +const cors = require('cors'); +const axios = require('axios'); +const express = require('express'); +const passport = require('passport'); +const compression = require('compression'); +const cookieParser = require('cookie-parser'); +const { logger } = require('@librechat/data-schemas'); +const mongoSanitize = require('express-mongo-sanitize'); +const { + isEnabled, + ErrorController, + performStartupChecks, + initializeFileStorage, +} = require('@librechat/api'); +const { connectDb, indexSync } = require('~/db'); +const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); +const createValidateImageRequest = require('./middleware/validateImageRequest'); +const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); +const { updateInterfacePermissions } = require('~/models/interface'); +const { checkMigrations } = require('./services/start/migration'); +const initializeMCPs = require('./services/initializeMCPs'); +const configureSocialLogins = require('./socialLogins'); +const { getAppConfig } = require('./services/Config'); +const staticCache = require('./utils/staticCache'); +const noIndex = require('./middleware/noIndex'); +const { seedDatabase } = require('~/models'); +const routes = require('./routes'); + +const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; + +/** Allow PORT=0 to be used for automatic free port assignment */ +const port = isNaN(Number(PORT)) ? 3080 : Number(PORT); +const host = HOST || 'localhost'; +const trusted_proxy = Number(TRUST_PROXY) || 1; + +/** Number of worker processes to spawn (simulating multiple pods) */ +const workers = Number(process.env.CLUSTER_WORKERS) || 4; + +/** Helper to wrap log messages for better visibility */ +const wrapLogMessage = (msg) => { + return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`; +}; + +/** + * Flushes the Redis cache on startup + * This ensures a clean state for testing multi-pod MCP connection issues + */ +const flushRedisCache = async () => { + /** Skip cache flush if Redis is not enabled */ + if (!isEnabled(process.env.USE_REDIS)) { + logger.info('Redis is not enabled, skipping cache flush'); + return; + } + + const redisConfig = { + host: process.env.REDIS_HOST || 'localhost', + port: process.env.REDIS_PORT || 6379, + }; + + if (process.env.REDIS_PASSWORD) { + redisConfig.password = process.env.REDIS_PASSWORD; + } + + /** Handle Redis Cluster configuration */ + if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) { + logger.info('Detected Redis Cluster configuration'); + const uris = process.env.REDIS_URI?.split(',').map((uri) => { + const url = new URL(uri.trim()); + return { + host: url.hostname, + port: parseInt(url.port || '6379', 10), + }; + }); + const redis = new Redis.Cluster(uris, { + redisOptions: { + password: process.env.REDIS_PASSWORD, + }, + }); + + try { + logger.info('Attempting to connect to Redis Cluster...'); + await redis.ping(); + logger.info('Connected to Redis Cluster. Executing flushall...'); + const result = await Promise.race([ + redis.flushall(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)), + ]); + logger.info('Redis Cluster cache flushed successfully', { result }); + } catch (err) { + logger.error('Error while flushing Redis Cluster cache:', err); + throw err; + } finally { + redis.disconnect(); + } + return; + } + + /** Handle single Redis instance */ + const redis = new Redis(redisConfig); + + try { + logger.info('Attempting to connect to Redis...'); + await redis.ping(); + logger.info('Connected to Redis. Executing flushall...'); + const result = await Promise.race([ + redis.flushall(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)), + ]); + logger.info('Redis cache flushed successfully', { result }); + } catch (err) { + logger.error('Error while flushing Redis cache:', err); + throw err; + } finally { + redis.disconnect(); + } +}; + +/** + * Master process + * Manages worker processes and handles graceful shutdowns + */ +if (cluster.isMaster) { + logger.info(wrapLogMessage(`Master ${process.pid} is starting...`)); + logger.info(`Spawning ${workers} workers to simulate multi-pod environment`); + + let activeWorkers = 0; + const startTime = Date.now(); + + /** Flush Redis cache before starting workers */ + flushRedisCache() + .then(() => { + logger.info('Cache flushed, forking workers...'); + for (let i = 0; i < workers; i++) { + cluster.fork(); + } + }) + .catch((err) => { + logger.error('Unable to flush Redis cache, not forking workers:', err); + process.exit(1); + }); + + /** Track worker lifecycle */ + cluster.on('online', (worker) => { + activeWorkers++; + const uptime = ((Date.now() - startTime) / 1000).toFixed(2); + logger.info( + `Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`, + ); + + /** Notify the last worker to perform one-time initialization tasks */ + if (activeWorkers === workers) { + const allWorkers = Object.values(cluster.workers); + const lastWorker = allWorkers[allWorkers.length - 1]; + if (lastWorker) { + logger.info(wrapLogMessage(`All ${workers} workers are online`)); + lastWorker.send({ type: 'last-worker' }); + } + } + }); + + cluster.on('exit', (worker, code, signal) => { + activeWorkers--; + logger.error( + `Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`, + ); + logger.info('Starting a new worker to replace it...'); + cluster.fork(); + }); + + /** Graceful shutdown on SIGTERM/SIGINT */ + const shutdown = () => { + logger.info('Master received shutdown signal, terminating workers...'); + for (const id in cluster.workers) { + cluster.workers[id].kill(); + } + setTimeout(() => { + logger.info('Forcing shutdown after timeout'); + process.exit(0); + }, 10000); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} else { + /** + * Worker process + * Each worker runs a full Express server instance + */ + const app = express(); + + const startServer = async () => { + logger.info(`Worker ${process.pid} initializing...`); + + if (typeof Bun !== 'undefined') { + axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; + } + + /** Connect to MongoDB */ + await connectDb(); + logger.info(`Worker ${process.pid}: Connected to MongoDB`); + + /** Background index sync (non-blocking) */ + indexSync().catch((err) => { + logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err); + }); + + app.disable('x-powered-by'); + app.set('trust proxy', trusted_proxy); + + /** Seed database (idempotent) */ + await seedDatabase(); + + /** Initialize app configuration */ + const appConfig = await getAppConfig(); + initializeFileStorage(appConfig); + await performStartupChecks(appConfig); + await updateInterfacePermissions(appConfig); + + /** Load index.html for SPA serving */ + const indexPath = path.join(appConfig.paths.dist, 'index.html'); + let indexHTML = fs.readFileSync(indexPath, 'utf8'); + + /** Support serving in subdirectory if DOMAIN_CLIENT is set */ + if (process.env.DOMAIN_CLIENT) { + const clientUrl = new URL(process.env.DOMAIN_CLIENT); + const baseHref = clientUrl.pathname.endsWith('/') + ? clientUrl.pathname + : `${clientUrl.pathname}/`; + if (baseHref !== '/') { + logger.info(`Setting base href to ${baseHref}`); + indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`); + } + } + + /** Health check endpoint */ + app.get('/health', (_req, res) => res.status(200).send('OK')); + + /** Middleware */ + app.use(noIndex); + app.use(express.json({ limit: '3mb' })); + app.use(express.urlencoded({ extended: true, limit: '3mb' })); + app.use(mongoSanitize()); + app.use(cors()); + app.use(cookieParser()); + + if (!isEnabled(DISABLE_COMPRESSION)) { + app.use(compression()); + } else { + logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); + } + + app.use(staticCache(appConfig.paths.dist)); + app.use(staticCache(appConfig.paths.fonts)); + app.use(staticCache(appConfig.paths.assets)); + + if (!ALLOW_SOCIAL_LOGIN) { + logger.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); + } + + /** OAUTH */ + app.use(passport.initialize()); + passport.use(jwtLogin()); + passport.use(passportLogin()); + + /** LDAP Auth */ + if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) { + passport.use(ldapLogin); + } + + if (isEnabled(ALLOW_SOCIAL_LOGIN)) { + await configureSocialLogins(app); + } + + /** Routes */ + app.use('/oauth', routes.oauth); + app.use('/api/auth', routes.auth); + app.use('/api/actions', routes.actions); + app.use('/api/keys', routes.keys); + app.use('/api/user', routes.user); + app.use('/api/search', routes.search); + app.use('/api/edit', routes.edit); + app.use('/api/messages', routes.messages); + app.use('/api/convos', routes.convos); + app.use('/api/presets', routes.presets); + app.use('/api/prompts', routes.prompts); + app.use('/api/categories', routes.categories); + app.use('/api/tokenizer', routes.tokenizer); + app.use('/api/endpoints', routes.endpoints); + app.use('/api/balance', routes.balance); + app.use('/api/models', routes.models); + app.use('/api/plugins', routes.plugins); + app.use('/api/config', routes.config); + app.use('/api/assistants', routes.assistants); + app.use('/api/files', await routes.files.initialize()); + app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute); + app.use('/api/share', routes.share); + app.use('/api/roles', routes.roles); + app.use('/api/agents', routes.agents); + app.use('/api/banner', routes.banner); + app.use('/api/memories', routes.memories); + app.use('/api/permissions', routes.accessPermissions); + app.use('/api/tags', routes.tags); + app.use('/api/mcp', routes.mcp); + + /** Error handler */ + app.use(ErrorController); + + /** SPA fallback - serve index.html for all unmatched routes */ + app.use((req, res) => { + res.set({ + 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', + Pragma: process.env.INDEX_PRAGMA || 'no-cache', + Expires: process.env.INDEX_EXPIRES || '0', + }); + + const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; + const saneLang = lang.replace(/"/g, '"'); + let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); + + res.type('html'); + res.send(updatedIndexHtml); + }); + + /** Start listening on shared port (cluster will distribute connections) */ + app.listen(port, host, async () => { + logger.info( + `Worker ${process.pid} started: Server listening at http://${ + host == '0.0.0.0' ? 'localhost' : host + }:${port}`, + ); + + /** Initialize MCP servers and OAuth reconnection for this worker */ + await initializeMCPs(); + await initializeOAuthReconnectManager(); + await checkMigrations(); + }); + + /** Handle inter-process messages from master */ + process.on('message', async (msg) => { + if (msg.type === 'last-worker') { + logger.info( + wrapLogMessage( + `Worker ${process.pid} is the last worker and can perform special initialization tasks`, + ), + ); + /** Add any one-time initialization tasks here */ + /** For example: scheduled jobs, cleanup tasks, etc. */ + } + }); + }; + + startServer().catch((err) => { + logger.error(`Failed to start worker ${process.pid}:`, err); + process.exit(1); + }); + + /** Export app for testing purposes (only available in worker processes) */ + module.exports = app; +} + +/** + * Uncaught exception handler + * Filters out known non-critical errors + */ +let messageCount = 0; +process.on('uncaughtException', (err) => { + if (!err.message.includes('fetch failed')) { + logger.error('There was an uncaught error:', err); + } + + if (err.message && err.message?.toLowerCase()?.includes('abort')) { + logger.warn('There was an uncatchable abort error.'); + return; + } + + if (err.message.includes('GoogleGenerativeAI')) { + logger.warn( + '\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303', + ); + return; + } + + if (err.message.includes('fetch failed')) { + if (messageCount === 0) { + logger.warn('Meilisearch error, search will be disabled'); + messageCount++; + } + return; + } + + if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) { + logger.error( + '\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.', + ); + 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/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/api/server/middleware/spec/validateImages.spec.js b/api/server/middleware/spec/validateImages.spec.js index b35050a14e..ebf5eafc8a 100644 --- a/api/server/middleware/spec/validateImages.spec.js +++ b/api/server/middleware/spec/validateImages.spec.js @@ -1,11 +1,14 @@ const jwt = require('jsonwebtoken'); -const { isEnabled } = require('@librechat/api'); const createValidateImageRequest = require('~/server/middleware/validateImageRequest'); +// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), isEnabled: jest.fn(), })); +const { isEnabled } = require('@librechat/api'); + describe('validateImageRequest middleware', () => { let req, res, next, validateImageRequest; const validObjectId = '65cfb246f7ecadb8b1e8036b'; @@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => { next = jest.fn(); process.env.JWT_REFRESH_SECRET = 'test-secret'; process.env.OPENID_REUSE_TOKENS = 'false'; + delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath // Default: OpenID token reuse disabled isEnabled.mockReturnValue(false); @@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => { expect(res.send).toHaveBeenCalledWith('Access Denied'); }); }); + + describe('basePath functionality', () => { + let originalDomainClient; + + beforeEach(() => { + originalDomainClient = process.env.DOMAIN_CLIENT; + }); + + afterEach(() => { + process.env.DOMAIN_CLIENT = originalDomainClient; + }); + + test('should validate image paths with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should validate agent avatar paths with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Access Denied'); + }); + + test('should handle empty base path (root deployment)', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle missing DOMAIN_CLIENT', async () => { + delete process.env.DOMAIN_CLIENT; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle nested subdirectories in base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should prevent path traversal with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`; + + await validateImageRequest(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Access Denied'); + }); + + test('should handle URLs with query parameters and base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle URLs with fragments and base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle HTTPS URLs with base path', async () => { + process.env.DOMAIN_CLIENT = 'https://example.com/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle invalid DOMAIN_CLIENT gracefully', async () => { + process.env.DOMAIN_CLIENT = 'not-a-valid-url'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle OpenID flow with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + process.env.OPENID_REUSE_TOKENS = 'true'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + }); }); diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js index b456a4d572..b74ed7225e 100644 --- a/api/server/middleware/validateImageRequest.js +++ b/api/server/middleware/validateImageRequest.js @@ -1,7 +1,7 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); -const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { isEnabled, getBasePath } = require('@librechat/api'); const OBJECT_ID_LENGTH = 24; const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i; @@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) { return res.status(403).send('Access Denied'); } - const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/; + const basePath = getBasePath(); + const imagesPath = `${basePath}/images`; + + const agentAvatarPattern = new RegExp( + `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`, + ); if (agentAvatarPattern.test(fullPath)) { logger.debug('[validateImageRequest] Image request validated'); return next(); } const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`); + const pathPattern = new RegExp( + `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`, + ); if (pathPattern.test(fullPath)) { logger.debug('[validateImageRequest] Image request validated'); 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/api/server/routes/config.js b/api/server/routes/config.js index f1d2332047..6f97639dd1 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -30,11 +30,46 @@ const publicSharedLinksEnabled = const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); +/** + * Fetches MCP servers from registry and adds them to the payload. + * Registry now includes all configured servers (from YAML) plus inspection data when available. + * Always fetches fresh to avoid caching incomplete initialization state. + */ +const getMCPServers = async (payload, appConfig) => { + try { + if (appConfig?.mcpConfig == null) { + return; + } + const mcpManager = getMCPManager(); + if (!mcpManager) { + return; + } + const mcpServers = await mcpServersRegistry.getAllServerConfigs(); + if (!mcpServers) return; + for (const serverName in mcpServers) { + if (!payload.mcpServers) { + payload.mcpServers = {}; + } + const serverConfig = mcpServers[serverName]; + payload.mcpServers[serverName] = removeNullishValues({ + startup: serverConfig?.startup, + chatMenu: serverConfig?.chatMenu, + isOAuth: serverConfig.requiresOAuth, + customUserVars: serverConfig?.customUserVars, + }); + } + } catch (error) { + logger.error('Error loading MCP servers', error); + } +}; + router.get('/', async function (req, res) { const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG); if (cachedStartupConfig) { + const appConfig = await getAppConfig({ role: req.user?.role }); + await getMCPServers(cachedStartupConfig, appConfig); res.send(cachedStartupConfig); return; } @@ -126,35 +161,6 @@ router.get('/', async function (req, res) { payload.minPasswordLength = minPasswordLength; } - const getMCPServers = async () => { - try { - if (appConfig?.mcpConfig == null) { - return; - } - const mcpManager = getMCPManager(); - if (!mcpManager) { - return; - } - const mcpServers = await mcpServersRegistry.getAllServerConfigs(); - if (!mcpServers) return; - for (const serverName in mcpServers) { - if (!payload.mcpServers) { - payload.mcpServers = {}; - } - const serverConfig = mcpServers[serverName]; - payload.mcpServers[serverName] = removeNullishValues({ - startup: serverConfig?.startup, - chatMenu: serverConfig?.chatMenu, - isOAuth: serverConfig.requiresOAuth, - customUserVars: serverConfig?.customUserVars, - }); - } - } catch (error) { - logger.error('Error loading MCP servers', error); - } - }; - - await getMCPServers(); const webSearchConfig = appConfig?.webSearch; if ( webSearchConfig != null && @@ -184,6 +190,7 @@ router.get('/', async function (req, res) { } await cache.set(CacheKeys.STARTUP_CONFIG, payload); + await getMCPServers(payload, appConfig); return res.status(200).send(payload); } catch (err) { logger.error('Error in startup config', err); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 5d9761fc6d..7237729c87 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -10,8 +10,8 @@ const { ResourceType, EModelEndpoint, PermissionBits, - isAgentsEndpoint, checkOpenAIStorage, + isAssistantsEndpoint, } = require('librechat-data-provider'); const { filterFile, @@ -376,11 +376,11 @@ router.post('/', async (req, res) => { metadata.temp_file_id = metadata.file_id; metadata.file_id = req.file_id; - if (isAgentsEndpoint(metadata.endpoint)) { - return await processAgentFileUpload({ req, res, metadata }); + if (isAssistantsEndpoint(metadata.endpoint)) { + return await processFileUpload({ req, res, metadata }); } - await processFileUpload({ req, res, metadata }); + return await processAgentFileUpload({ req, res, metadata }); } catch (error) { let message = 'Error processing file'; logger.error('[/files] Error processing file:', error); diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 05d4e850c8..7efab9d026 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -8,7 +8,12 @@ const { deleteUserController, getUserController, } = require('~/server/controllers/UserController'); -const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware'); +const { + verifyEmailLimiter, + configMiddleware, + canDeleteAccount, + requireJwtAuth, +} = require('~/server/middleware'); const router = express.Router(); @@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController); router.get('/terms', requireJwtAuth, getTermsStatusController); router.post('/terms/accept', requireJwtAuth, acceptTermsController); router.post('/plugins', requireJwtAuth, updateUserPluginsController); -router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController); +router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController); router.post('/verify', verifyEmailController); router.post('/verify/resend', verifyEmailLimiter, resendVerificationController); diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 0098e54124..72bda67322 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, @@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => { * @param {string} [userId] - Optional MongoDB user ID for image path validation * @returns {String} - access token */ -const setOpenIDAuthTokens = (tokenset, res, userId) => { +const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => { try { if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); @@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); return; } - if (!tokenset.access_token || !tokenset.refresh_token) { - logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset'); + if (!tokenset.access_token) { + logger.error('[setOpenIDAuthTokens] No access token found in tokenset'); return; } - res.cookie('refreshToken', tokenset.refresh_token, { + + const refreshToken = tokenset.refresh_token || existingRefreshToken; + + if (!refreshToken) { + logger.error('[setOpenIDAuthTokens] No refresh token available'); + return; + } + + res.cookie('refreshToken', refreshToken, { + expires: expirationDate, + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + }); + res.cookie('openid_access_token', tokenset.access_token, { expires: expirationDate, httpOnly: true, secure: isProduction, 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/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index e6fbf65e77..5aa8b08a92 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -1,9 +1,4 @@ -const { - resolveHeaders, - isUserProvided, - getOpenAIConfig, - getCustomEndpointConfig, -} = require('@librechat/api'); +const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api'); const { CacheKeys, ErrorTypes, @@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey); const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL); - /** Intentionally excludes passing `body`, i.e. `req.body`, as - * values may not be accurate until `AgentClient` is initialized - */ - let resolvedHeaders = resolveHeaders({ - headers: endpointConfig.headers, - user: req.user, - }); - if (CUSTOM_API_KEY.match(envVarRegex)) { throw new Error(`Missing API Key for ${endpoint}.`); } @@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid } const customOptions = { - headers: resolvedHeaders, + headers: endpointConfig.headers, addParams: endpointConfig.addParams, dropParams: endpointConfig.dropParams, customParams: endpointConfig.customParams, diff --git a/api/server/services/Endpoints/custom/initialize.spec.js b/api/server/services/Endpoints/custom/initialize.spec.js index a69ff9ef58..d12906df9a 100644 --- a/api/server/services/Endpoints/custom/initialize.spec.js +++ b/api/server/services/Endpoints/custom/initialize.spec.js @@ -69,17 +69,21 @@ describe('custom/initializeClient', () => { }); }); - it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => { - const { resolveHeaders } = require('@librechat/api'); - await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }); - expect(resolveHeaders).toHaveBeenCalledWith({ - headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, - user: { id: 'user-123', email: 'test@example.com', role: 'user' }, - /** - * Note: Request-based Header Resolution is deferred until right before LLM request is made - body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders - */ + it('stores original template headers for deferred resolution', async () => { + /** + * Note: Request-based Header Resolution is deferred until right before LLM request is made + * in the OpenAIClient or AgentClient, not during initialization. + * This test verifies that the initialize function completes successfully with optionsOnly flag, + * and that headers are passed through to be resolved later during the actual LLM request. + */ + const result = await initializeClient({ + req: mockRequest, + res: mockResponse, + optionsOnly: true, }); + // Verify that options are returned for later use + expect(result).toBeDefined(); + expect(result).toHaveProperty('useLegacyContent', true); }); it('throws if endpoint config is missing', async () => { diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 39b47a7d64..c38aad7087 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1,9 +1,9 @@ const path = require('path'); const { v4 } = require('uuid'); const axios = require('axios'); -const { logAxiosError } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); +const { logAxiosError, getBasePath } = require('@librechat/api'); const { Tools, FileContext, @@ -41,11 +41,12 @@ const processCodeOutput = async ({ const appConfig = req.config; const currentDate = new Date(); const baseURL = getCodeBaseURL(); + const basePath = getBasePath(); const fileExt = path.extname(name); if (!fileExt || !imageExtRegex.test(name)) { return { filename: name, - filepath: `/api/files/code/download/${session_id}/${id}`, + filepath: `${basePath}/api/files/code/download/${session_id}/${id}`, /** Note: expires 24 hours after creation */ expiresAt: currentDate.getTime() + 86400000, conversationId, 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', () => { diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 94685fc86c..998a918c30 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -1,3 +1,4 @@ +const cookies = require('cookie'); const jwksRsa = require('jwks-rsa'); const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); @@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => { { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions), + passReqToCallback: true, }, /** + * @param {import('@librechat/api').ServerRequest} req * @param {import('openid-client').IDToken} payload * @param {import('passport-jwt').VerifyCallback} done */ - async (payload, done) => { + async (req, payload, done) => { try { + const authHeader = req.headers.authorization; + const rawToken = authHeader?.replace('Bearer ', ''); + const { user, error, migration } = await findOpenIDUser({ findUser, email: payload?.email, @@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => { await updateUser(user.id, updateData); } + const cookieHeader = req.headers.cookie; + const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; + const accessToken = parsedCookies.openid_access_token; + const refreshToken = parsedCookies.refreshToken; + + user.federatedTokens = { + access_token: accessToken || rawToken, + id_token: rawToken, + refresh_token: refreshToken, + expires_at: payload.exp, + }; + done(null, user); } else { logger.warn( diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 26143b226a..455ff1bd11 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -543,7 +543,15 @@ async function setupOpenId() { }, ); - done(null, { ...user, tokenset }); + done(null, { + ...user, + tokenset, + federatedTokens: { + access_token: tokenset.access_token, + refresh_token: tokenset.refresh_token, + expires_at: tokenset.expires_at, + }, + }); } catch (err) { logger.error('[openidStrategy] login failed', err); done(err); diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index fa6af7f40f..9ac22ff42f 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), isEnabled: jest.fn(() => false), + isEmailDomainAllowed: jest.fn(() => true), + findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser, getBalanceConfig: jest.fn(() => ({ enabled: false, })), @@ -446,6 +448,46 @@ describe('setupOpenId', () => { expect(callOptions.params?.code_challenge_method).toBeUndefined(); }); + it('should attach federatedTokens to user object for token propagation', async () => { + // Arrange - setup tokenset with access token, refresh token, and expiration + const tokensetWithTokens = { + ...tokenset, + access_token: 'mock_access_token_abc123', + refresh_token: 'mock_refresh_token_xyz789', + expires_at: 1234567890, + }; + + // Act - validate with the tokenset containing tokens + const { user } = await validate(tokensetWithTokens); + + // Assert - verify federatedTokens object is attached with correct values + expect(user.federatedTokens).toBeDefined(); + expect(user.federatedTokens).toEqual({ + access_token: 'mock_access_token_abc123', + refresh_token: 'mock_refresh_token_xyz789', + expires_at: 1234567890, + }); + }); + + it('should include tokenset along with federatedTokens', async () => { + // Arrange + const tokensetWithTokens = { + ...tokenset, + access_token: 'test_access_token', + refresh_token: 'test_refresh_token', + expires_at: 9999999999, + }; + + // Act + const { user } = await validate(tokensetWithTokens); + + // Assert - both tokenset and federatedTokens should be present + expect(user.tokenset).toBeDefined(); + expect(user.federatedTokens).toBeDefined(); + expect(user.tokenset.access_token).toBe('test_access_token'); + expect(user.federatedTokens.access_token).toBe('test_access_token'); + }); + it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { // Act const { user } = await validate(tokenset); diff --git a/api/test/__mocks__/openid-client.js b/api/test/__mocks__/openid-client.js index 4848a4799f..766d8a305d 100644 --- a/api/test/__mocks__/openid-client.js +++ b/api/test/__mocks__/openid-client.js @@ -40,6 +40,10 @@ module.exports = { clientId: 'fake_client_id', clientSecret: 'fake_client_secret', issuer: 'https://fake-issuer.com', + serverMetadata: jest.fn().mockReturnValue({ + jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json', + end_session_endpoint: 'https://fake-issuer.com/logout', + }), Client: jest.fn().mockImplementation(() => ({ authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'), callback: jest.fn().mockResolvedValue({ 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'); + }); }); }); 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/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 12daf64e47..c4589c610e 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'], ); @@ -861,6 +864,15 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct context length for Claude Opus 4.5', () => { + expect(getModelMaxTokens('claude-opus-4-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'], + ); + expect(getModelMaxTokens('claude-opus-4-5')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'], + ); + }); + it('should handle Claude Haiku 4.5 model name variations', () => { const modelVariations = [ 'claude-haiku-4-5', @@ -880,6 +892,25 @@ describe('Claude Model Tests', () => { }); }); + it('should handle Claude Opus 4.5 model name variations', () => { + const modelVariations = [ + 'claude-opus-4-5', + 'claude-opus-4-5-20250420', + 'claude-opus-4-5-latest', + 'anthropic/claude-opus-4-5', + 'claude-opus-4-5/anthropic', + 'claude-opus-4-5-preview', + ]; + + modelVariations.forEach((model) => { + const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]); + expect(modelKey).toBe('claude-opus-4-5'); + expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'], + ); + }); + }); + it('should match model names correctly for Claude Haiku 4.5', () => { const modelVariations = [ 'claude-haiku-4-5', @@ -895,6 +926,21 @@ describe('Claude Model Tests', () => { }); }); + it('should match model names correctly for Claude Opus 4.5', () => { + const modelVariations = [ + 'claude-opus-4-5', + 'claude-opus-4-5-20250420', + 'claude-opus-4-5-latest', + 'anthropic/claude-opus-4-5', + 'claude-opus-4-5/anthropic', + 'claude-opus-4-5-preview', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-5'); + }); + }); + it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', diff --git a/client/jest.config.cjs b/client/jest.config.cjs index bdb7cd8ff3..9405f2ab08 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -41,7 +41,6 @@ module.exports = { 'jest-file-loader', }, transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'], - preset: 'ts-jest', setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '/test/setupTests.js'], clearMocks: true, }; diff --git a/client/package.json b/client/package.json index 07b7e40ec0..1e8ef074fc 100644 --- a/client/package.json +++ b/client/package.json @@ -135,10 +135,10 @@ "babel-plugin-root-import": "^6.6.0", "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", - "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-jest": "^29.1.0", "fs-extra": "^11.3.2", "identity-obj-proxy": "^3.0.0", - "jest": "^29.7.0", + "jest": "^30.2.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", "jest-file-loader": "^1.0.3", @@ -147,7 +147,6 @@ "postcss-loader": "^7.1.0", "postcss-preset-env": "^8.2.0", "tailwindcss": "^3.4.1", - "ts-jest": "^29.2.5", "typescript": "^5.3.3", "vite": "^6.4.1", "vite-plugin-compression2": "^2.2.1", diff --git a/client/src/App.jsx b/client/src/App.jsx index eda775bc71..23651d750c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; +import WakeLockManager from '~/components/System/WakeLockManager'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; @@ -51,6 +52,7 @@ const App = () => { + 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/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index 82ed3d9da4..4ab2b182b8 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -179,9 +179,10 @@ export const ArtifactCodeEditor = function ({ bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, }; }, [config, template, fileKey]); - const [readOnly, setReadOnly] = useState(externalReadOnly ?? isSubmitting ?? false); + const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false); + const [readOnly, setReadOnly] = useState(initialReadOnly); useEffect(() => { - setReadOnly(externalReadOnly ?? isSubmitting ?? false); + setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false)); }, [isSubmitting, externalReadOnly]); if (Object.keys(files).length === 0) { 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({ + {localize('com_auth_back_to_login')} diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index 15e53abaf0..c0148a4373 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -166,6 +166,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: diff --git a/client/src/components/Chat/Messages/Content/DialogImage.tsx b/client/src/components/Chat/Messages/Content/DialogImage.tsx index 9eb5f9e71f..6923f15194 100644 --- a/client/src/components/Chat/Messages/Content/DialogImage.tsx +++ b/client/src/components/Chat/Messages/Content/DialogImage.tsx @@ -180,6 +180,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm } }, [isPromptOpen, zoom]); + const imageDetailsLabel = isPromptOpen + ? localize('com_ui_hide_image_details') + : localize('com_ui_show_image_details'); + return ( onOpenChange(false)} variant="ghost" className="h-10 w-10 p-0 hover:bg-surface-hover" + aria-label={localize('com_ui_close')} > @@ -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/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 450ed0fc22..8b1395ee88 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useMemo } from 'react'; import { Skeleton } from '@librechat/client'; import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { apiBaseUrl } from 'librechat-data-provider'; import { cn, scaleImage } from '~/utils'; import DialogImage from './DialogImage'; @@ -36,6 +37,24 @@ const Image = ({ const handleImageLoad = () => setIsLoaded(true); + // Fix image path to include base path for subdirectory deployments + const absoluteImageUrl = useMemo(() => { + if (!imagePath) return imagePath; + + // If it's already an absolute URL or doesn't start with /images/, return as is + if ( + imagePath.startsWith('http') || + imagePath.startsWith('data:') || + !imagePath.startsWith('/images/') + ) { + return imagePath; + } + + // Get the base URL and prepend it to the image path + const baseURL = apiBaseUrl(); + return `${baseURL}${imagePath}`; + }, [imagePath]); + const { width: scaledWidth, height: scaledHeight } = useMemo( () => scaleImage({ @@ -48,7 +67,7 @@ const Image = ({ const downloadImage = async () => { try { - const response = await fetch(imagePath); + const response = await fetch(absoluteImageUrl); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.status}`); } @@ -67,7 +86,7 @@ const Image = ({ } catch (error) { console.error('Download failed:', error); const link = document.createElement('a'); - link.href = imagePath; + link.href = absoluteImageUrl; link.download = altText || 'image.png'; document.body.appendChild(link); link.click(); @@ -97,7 +116,7 @@ const Image = ({ 'opacity-100 transition-opacity duration-100', isLoaded ? 'opacity-100' : 'opacity-0', )} - src={imagePath} + src={absoluteImageUrl} style={{ width: `${scaledWidth}`, height: 'auto', @@ -117,7 +136,7 @@ const Image = ({ diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 7ade775647..42136384d4 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { langSubset, preprocessLaTeX } from '~/utils'; import { unicodeCitation } from '~/components/Web'; -import { code, a, p } from './MarkdownComponents'; +import { code, a, p, img } from './MarkdownComponents'; import store from '~/store'; type TContentProps = { @@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { code, a, p, + img, artifact: Artifact, citation: Citation, 'highlighted-text': HighlightedText, diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index ed69c677b2..fa94cbac82 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo, useRef, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { useToastContext } from '@librechat/client'; -import { PermissionTypes, Permissions, dataService } from 'librechat-data-provider'; +import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import useHasAccess from '~/hooks/Roles/useHasAccess'; import { useFileDownload } from '~/data-provider'; @@ -135,7 +135,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { props.onClick = handleDownload; props.target = '_blank'; - const domainServerBaseUrl = dataService.getDomainServerBaseUrl(); + const domainServerBaseUrl = `${apiBaseUrl()}/api`; return ( { return

{children}

; }); + +type TImageProps = { + src?: string; + alt?: string; + title?: string; + className?: string; + style?: React.CSSProperties; +}; + +export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => { + // Get the base URL from the API endpoints + const baseURL = apiBaseUrl(); + + // If src starts with /images/, prepend the base URL + const fixedSrc = useMemo(() => { + if (!src) return src; + + // If it's already an absolute URL or doesn't start with /images/, return as is + if (src.startsWith('http') || src.startsWith('data:') || !src.startsWith('/images/')) { + return src; + } + + // Prepend base URL to the image path + return `${baseURL}${src}`; + }, [src, baseURL]); + + return {alt}; +}); diff --git a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx index d553e6b708..65efe2f256 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx @@ -6,7 +6,7 @@ import supersub from 'remark-supersub'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import type { PluggableList } from 'unified'; -import { code, codeNoExecution, a, p } from './MarkdownComponents'; +import { code, codeNoExecution, a, p, img } from './MarkdownComponents'; import { CodeBlockProvider, ArtifactProvider } from '~/Providers'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { langSubset } from '~/utils'; @@ -44,6 +44,7 @@ const MarkdownLite = memo( code: codeExecution ? code : codeNoExecution, a, p, + img, } as { [nodeType: string]: React.ElementType; } diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index f512f2dcbd..29f2e7187a 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -69,7 +69,7 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => { return (
-
+
-
+
- {isCopied ? : } + {isCopied ? ( + + ) : ( + + )}
); 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/Endpoints/MessageEndpointIcon.tsx b/client/src/components/Endpoints/MessageEndpointIcon.tsx index 0a9782ce99..9b71396d12 100644 --- a/client/src/components/Endpoints/MessageEndpointIcon.tsx +++ b/client/src/components/Endpoints/MessageEndpointIcon.tsx @@ -25,7 +25,7 @@ type EndpointIcon = { function getOpenAIColor(_model: string | null | undefined) { const model = _model?.toLowerCase() ?? ''; - if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) { + if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9](?:\.\d+)?\b/i.test(model))) { return '#000000'; } return model.includes('gpt-4') ? '#AB68FF' : '#19C37D'; 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', })}

- ; -}) { - 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/components/SidePanel/ArtifactsPanel.tsx b/client/src/components/SidePanel/ArtifactsPanel.tsx index 1ea644e9b9..cbdb8d2832 100644 --- a/client/src/components/SidePanel/ArtifactsPanel.tsx +++ b/client/src/components/SidePanel/ArtifactsPanel.tsx @@ -2,8 +2,6 @@ import { useRef, useEffect, memo } from 'react'; import { ResizableHandleAlt, ResizablePanel } from '@librechat/client'; import type { ImperativePanelHandle } from 'react-resizable-panels'; -const ANIMATION_DURATION = 500; - interface ArtifactsPanelProps { artifacts: React.ReactNode | null; currentLayout: number[]; @@ -24,14 +22,9 @@ const ArtifactsPanel = memo(function ArtifactsPanel({ onRenderChange, }: ArtifactsPanelProps) { const artifactsPanelRef = useRef(null); - const timeoutRef = useRef(null); useEffect(() => { if (artifacts != null) { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } onRenderChange(true); requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -39,17 +32,8 @@ const ArtifactsPanel = memo(function ArtifactsPanel({ }); }); } else if (shouldRender) { - artifactsPanelRef.current?.collapse(); - timeoutRef.current = setTimeout(() => { - onRenderChange(false); - }, ANIMATION_DURATION); + onRenderChange(false); } - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; }, [artifacts, shouldRender, onRenderChange]); if (!shouldRender) { 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 (