Compare commits

..

1 Commits

Author SHA1 Message Date
Dustin Healy
4fb27dbaff fix: format error return for file_search tool in two-tuple as expected 2025-11-17 09:20:32 -08:00
148 changed files with 2604 additions and 7406 deletions

View File

@@ -785,7 +785,3 @@ 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

View File

@@ -35,6 +35,8 @@ 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")
@@ -50,10 +52,22 @@ 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 \
$CHANGED_FILES
--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

31
.gitignore vendored
View File

@@ -138,34 +138,3 @@ 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

View File

@@ -1,4 +1,4 @@
# v0.8.1-rc2
# v0.8.1-rc1
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.1-rc2
# v0.8.1-rc1
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -81,7 +81,6 @@ class BaseClient {
throw new Error("Method 'getCompletion' must be implemented.");
}
/** @type {sendCompletion} */
async sendCompletion() {
throw new Error("Method 'sendCompletion' must be implemented.");
}
@@ -690,7 +689,8 @@ class BaseClient {
});
}
const { completion, metadata } = await this.sendCompletion(payload, opts);
/** @type {string|string[]|undefined} */
const completion = await this.sendCompletion(payload, opts);
if (this.abortController) {
this.abortController.requestCompleted = true;
}
@@ -708,7 +708,6 @@ class BaseClient {
iconURL: this.options.iconURL,
endpoint: this.options.endpoint,
...(this.metadata ?? {}),
metadata,
};
if (typeof completion === 'string') {
@@ -1213,8 +1212,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
},
getStrategyFunctions,
@@ -1231,8 +1230,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
},
getStrategyFunctions,
);
@@ -1246,8 +1245,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
},
getStrategyFunctions,
);

View File

@@ -1,4 +1,3 @@
const { getBasePath } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
/**
@@ -33,8 +32,6 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const basePath = getBasePath();
// Correct any erroneous URLs in the responseMessage.text first
intermediateSteps.forEach((step) => {
const { observation } = step;
@@ -47,14 +44,12 @@ 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(`${basePath}/images/`)) {
// Replace with the full path including base path
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) {
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath);
}
}
});
@@ -66,23 +61,9 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
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);
}
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
responseMessage.text += '\n' + observedImagePath[0];
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
}
});
}

View File

@@ -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,108 +139,4 @@ 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==)',
);
});
});
});

View File

@@ -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

View File

@@ -82,10 +82,7 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
});
TestClient.sendCompletion = jest.fn(async () => {
return {
completion: 'Mock response text',
metadata: undefined,
};
return 'Mock response text';
});
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {

View File

@@ -8,7 +8,6 @@ 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 =
@@ -37,7 +36,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](${getBasePath()}/images/id.png)
// - ALWAYS include the markdown url in your final response to show the user: ![caption](/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:

View File

@@ -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.', undefined];
return ['No files to search. Instruct the user to add files for the search.', {}];
}
const jwtToken = generateShortLivedToken(userId);
if (!jwtToken) {
return ['There was an error authenticating the file search request.', undefined];
return ['There was an error authenticating the file search request.', {}];
}
/**
@@ -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.', undefined];
return ['No results found or errors occurred while searching the files.', {}];
}
const formattedResults = validResults

View File

@@ -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,37 +539,6 @@ 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<void>} 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.
@@ -887,7 +856,6 @@ module.exports = {
createAgent,
updateAgent,
deleteAgent,
deleteUserAgents,
getListAgents,
revertAgentVersion,
updateAgentProjects,

View File

@@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
*
* @async
* @function deleteMessages
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
* @param {Object} filter - The filter criteria to find messages to delete.
* @returns {Promise<Object>} The metadata with count of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {

View File

@@ -13,7 +13,7 @@ const {
getProjectByName,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
const { PromptGroup, Prompt } = require('~/db/models');
const { escapeRegExp } = require('~/server/utils');
/**
@@ -591,36 +591,6 @@ 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<MongoPromptGroup>} filter - Filter to find prompt group

View File

@@ -136,7 +136,6 @@ 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 },
@@ -157,7 +156,6 @@ 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 },
@@ -239,10 +237,8 @@ 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 },
};
/**

View File

@@ -1040,7 +1040,6 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => {
const googleModels = [
'gemini-3',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
@@ -1084,7 +1083,6 @@ 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',
@@ -1372,15 +1370,6 @@ 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',
@@ -1403,28 +1392,6 @@ 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',
@@ -1471,15 +1438,6 @@ 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',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.1-rc2",
"version": "v0.8.1-rc1",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -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.32",
"@librechat/agents": "^3.0.17",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View File

@@ -350,9 +350,6 @@ function disposeClient(client) {
if (client.agentConfigs) {
client.agentConfigs = null;
}
if (client.agentIdMap) {
client.agentIdMap = null;
}
if (client.artifactPromises) {
client.artifactPromises = null;
}

View File

@@ -82,15 +82,7 @@ const refreshController = async (req, res) => {
if (error || !user) {
return res.status(401).redirect('/login');
}
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,
};
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
return res.status(200).send({ token, user });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);

View File

@@ -3,45 +3,32 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
const {
MCPOAuthHandler,
MCPTokenStorage,
mcpServersRegistry,
normalizeHttpError,
extractWebSearchEnvVars,
} = require('@librechat/api');
const {
deleteAllUserSessions,
deleteAllSharedLinks,
deleteUserById,
deleteMessages,
deletePresets,
deleteConvos,
deleteFiles,
updateUser,
findToken,
getFiles,
findToken,
updateUser,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
deleteAllSharedLinks,
deleteAllUserSessions,
} = 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 });
@@ -250,6 +237,7 @@ 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) {
@@ -261,19 +249,7 @@ 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
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
/* TODO: queue job for cleaning actions and assistants of non-existant users */
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {

View File

@@ -39,9 +39,9 @@ class ModelEndHandler {
* @param {ModelEndData | undefined} data
* @param {Record<string, unknown> | undefined} metadata
* @param {StandardGraph} graph
* @returns {Promise<void>}
* @returns
*/
async handle(event, data, metadata, graph) {
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) {
await handleToolCalls(toolCalls, metadata, graph);
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) {
await graph.dispatchRunStep(stepKey, {
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') {
await graph.dispatchMessageDelta(stepId, {
graph.dispatchMessageDelta(stepId, {
content: [
{
type: 'text',
@@ -120,7 +120,7 @@ class ModelEndHandler {
],
});
} else if (content.every((c) => c.type?.startsWith('text'))) {
await graph.dispatchMessageDelta(stepId, {
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, logger),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback),
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
[GraphEvents.ON_RUN_STEP]: {
/**

View File

@@ -9,7 +9,6 @@ const {
logAxiosError,
sanitizeTitle,
resolveHeaders,
createSafeUser,
getBalanceConfig,
memoryInstructions,
getTransactionsConfig,
@@ -21,7 +20,6 @@ const {
Providers,
TitleMethod,
formatMessage,
labelContentByAgent,
formatAgentMessages,
getTokenCountForMessage,
createMetadataAggregator,
@@ -94,61 +92,6 @@ 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<string, Agent>} 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<string, string>} */
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);
@@ -198,8 +141,6 @@ class AgentClient extends BaseClient {
this.indexTokenCountMap = {};
/** @type {(messages: BaseMessage[]) => Promise<void>} */
this.processMemory;
/** @type {Record<number, string> | null} */
this.agentIdMap = null;
}
/**
@@ -292,12 +233,6 @@ class AgentClient extends BaseClient {
summary: this.shouldSummarize,
});
orderedMessages = applyAgentLabelsToHistory(
orderedMessages,
this.options.agent,
this.agentConfigs,
);
let payload;
/** @type {number | undefined} */
let promptTokens;
@@ -677,11 +612,7 @@ class AgentClient extends BaseClient {
userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController,
});
const completion = filterMalformedContentParts(this.contentParts);
const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
return { completion, metadata };
return filterMalformedContentParts(this.contentParts);
}
/**
@@ -857,7 +788,7 @@ class AgentClient extends BaseClient {
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
user: createSafeUser(this.options.req.user),
user: this.options.req.user,
},
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
signal: abortController.signal,
@@ -933,7 +864,6 @@ 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()),
});
@@ -972,24 +902,6 @@ 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',
@@ -1023,9 +935,6 @@ class AgentClient extends BaseClient {
err,
);
}
run = null;
config = null;
memoryPromise = null;
}
}
@@ -1154,7 +1063,6 @@ 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,

View File

@@ -11,6 +11,7 @@ const {
const {
Tools,
Constants,
SystemRoles,
FileSources,
ResourceType,
AccessRoleIds,
@@ -19,8 +20,6 @@ const {
PermissionBits,
actionDelimiter,
removeNullishValues,
CacheKeys,
Time,
} = require('librechat-data-provider');
const {
getListAgentsByAccess,
@@ -46,7 +45,6 @@ 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,
@@ -54,49 +52,6 @@ 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
@@ -187,13 +142,10 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
try {
agent.avatar = {
...agent.avatar,
filepath: await refreshS3Url(agent.avatar),
};
} catch (e) {
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
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 });
}
}
@@ -257,12 +209,7 @@ const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const validatedData = agentUpdateSchema.parse(req.body);
// 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;
}
const { _id, ...updateData } = removeNullishValues(validatedData);
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
@@ -395,21 +342,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: filteredMetadata,
metadata: action.metadata,
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;
};
@@ -516,13 +463,13 @@ const getListAgentsHandler = async (req, res) => {
filter.is_promoted = { $ne: true };
}
// Handle search filter (escape regex and cap length)
// Handle search filter
if (search && search.trim() !== '') {
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
const regex = new RegExp(safeSearch, 'i');
filter.$or = [{ name: regex }, { description: regex }];
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
@@ -530,12 +477,10 @@ 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,
@@ -543,31 +488,13 @@ const getListAgentsHandler = async (req, res) => {
limit,
after: cursor,
});
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())) {
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
agent.isPublic = true;
}
} 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 agent;
});
}
return res.json(data);
} catch (error) {
@@ -590,21 +517,28 @@ 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({
@@ -637,6 +571,8 @@ const uploadAgentAvatarHandler = async (req, res) => {
}
}
const promises = [];
const data = {
avatar: {
filepath: image.filepath,
@@ -644,16 +580,17 @@ const uploadAgentAvatarHandler = async (req, res) => {
},
};
const updatedAgent = await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
res.status(201).json(updatedAgent);
promises.push(
await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
}),
);
const resolved = await Promise.all(promises);
res.status(201).json(resolved[0]);
} catch (error) {
const message = 'An error occurred while updating the Agent Avatar';
logger.error(
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
error,
);
logger.error(message, error);
res.status(500).json({ message });
} finally {
try {
@@ -692,13 +629,21 @@ 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' });
}
// Permissions are enforced via route middleware (ACL EDIT)
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 updatedAgent = await revertAgentVersion({ id }, version_index);

View File

@@ -47,7 +47,6 @@ 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', () => ({
@@ -574,68 +573,6 @@ 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;

View File

@@ -44,13 +44,7 @@ const getMCPTools = async (req, res) => {
continue;
}
let serverTools;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
}
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;

View File

@@ -1,416 +0,0 @@
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, '&quot;');
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);
});

View File

@@ -213,17 +213,6 @@ 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);
});

View File

@@ -1,14 +1,11 @@
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';
@@ -26,7 +23,6 @@ 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);
@@ -300,175 +296,4 @@ 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();
});
});
});

View File

@@ -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,21 +124,14 @@ function createValidateImageRequest(secureImageLinks) {
return res.status(403).send('Access Denied');
}
const basePath = getBasePath();
const imagesPath = `${basePath}/images`;
const agentAvatarPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
);
const agentAvatarPattern = /^\/images\/[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(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
);
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');

View File

@@ -146,15 +146,7 @@ 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,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.revertAgentVersion,
);
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
/**
* Returns a list of agents.

View File

@@ -30,46 +30,11 @@ 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;
}
@@ -161,6 +126,35 @@ 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 &&
@@ -190,7 +184,6 @@ 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);

View File

@@ -8,12 +8,7 @@ const {
deleteUserController,
getUserController,
} = require('~/server/controllers/UserController');
const {
verifyEmailLimiter,
configMiddleware,
canDeleteAccount,
requireJwtAuth,
} = require('~/server/middleware');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
const router = express.Router();
@@ -21,7 +16,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, configMiddleware, deleteUserController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
router.post('/verify', verifyEmailController);
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);

View File

@@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 404, message: errorMessage };
}
const { email, password, name, username, provider } = user;
const { email, password, name, username } = user;
let newUserId;
try {
@@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => {
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: provider ?? 'local',
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, existingRefreshToken) => {
const setOpenIDAuthTokens = (tokenset, res, userId) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
@@ -427,25 +427,11 @@ const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
if (!tokenset.access_token) {
logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
if (!tokenset.access_token || !tokenset.refresh_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
return;
}
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, {
res.cookie('refreshToken', tokenset.refresh_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,

View File

@@ -16,11 +16,6 @@ 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] = {

View File

@@ -1,4 +1,9 @@
const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
const {
resolveHeaders,
isUserProvided,
getOpenAIConfig,
getCustomEndpointConfig,
} = require('@librechat/api');
const {
CacheKeys,
ErrorTypes,
@@ -29,6 +34,14 @@ 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}.`);
}
@@ -95,7 +108,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
}
const customOptions = {
headers: endpointConfig.headers,
headers: resolvedHeaders,
addParams: endpointConfig.addParams,
dropParams: endpointConfig.dropParams,
customParams: endpointConfig.customParams,

View File

@@ -69,21 +69,17 @@ describe('custom/initializeClient', () => {
});
});
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,
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
*/
});
// Verify that options are returned for later use
expect(result).toBeDefined();
expect(result).toHaveProperty('useLegacyContent', true);
});
it('throws if endpoint config is missing', async () => {

View File

@@ -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,12 +41,11 @@ 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: `${basePath}/api/files/code/download/${session_id}/${id}`,
filepath: `/api/files/code/download/${session_id}/${id}`,
/** Note: expires 24 hours after creation */
expiresAt: currentDate.getTime() + 86400000,
conversationId,

View File

@@ -80,9 +80,7 @@ const fetchModels = async ({
try {
const options = {
headers: {
...(headers ?? {}),
},
headers: {},
timeout: 5000,
};

View File

@@ -81,70 +81,6 @@ 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();
});
@@ -474,64 +410,6 @@ 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', () => {

View File

@@ -1,4 +1,3 @@
const cookies = require('cookie');
const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
@@ -41,18 +40,13 @@ 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 (req, payload, done) => {
async (payload, done) => {
try {
const authHeader = req.headers.authorization;
const rawToken = authHeader?.replace('Bearer ', '');
const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload?.email,
@@ -83,18 +77,6 @@ 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(

View File

@@ -543,15 +543,7 @@ async function setupOpenId() {
},
);
done(null, {
...user,
tokenset,
federatedTokens: {
access_token: tokenset.access_token,
refresh_token: tokenset.refresh_token,
expires_at: tokenset.expires_at,
},
});
done(null, { ...user, tokenset });
} catch (err) {
logger.error('[openidStrategy] login failed', err);
done(err);

View File

@@ -18,8 +18,6 @@ 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,
})),
@@ -448,46 +446,6 @@ 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);

View File

@@ -40,10 +40,6 @@ 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({

View File

@@ -1,11 +1,17 @@
const axios = require('axios');
const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch');
jest.mock('axios');
jest.mock('@librechat/api', () => ({
generateShortLivedToken: jest.fn(),
// Mock dependencies
jest.mock('../../../../../models', () => ({
Files: {
find: jest.fn(),
},
}));
jest.mock('@librechat/data-schemas', () => ({
jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({
queryVectors: jest.fn(),
}));
jest.mock('../../../../../config', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
@@ -13,220 +19,68 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
jest.mock('~/models/File', () => ({
getFiles: jest.fn().mockResolvedValue([]),
}));
const { queryVectors } = require('../../../../../server/services/Files/VectorDB/crud');
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', () => {
describe('fileSearch.js - test only new file_id and page additions', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.RAG_API_URL = 'http://localhost:8000';
});
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 = {
// 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 = [
{
data: [
[
{
page_content: 'This is test content from the document',
metadata: { source: '/path/to/test.pdf', page: 1 },
page_content: 'test content',
metadata: { source: 'test.pdf', page: 1 },
},
0.2,
],
[
{
page_content: 'Additional relevant content',
metadata: { source: '/path/to/test.pdf', page: 2 },
},
0.35,
0.3,
],
],
};
},
];
axios.post.mockResolvedValue(mockApiResponse);
queryVectors.mockResolvedValue(mockResults);
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) },
});
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: mockFiles,
entity_id: 'agent-123',
});
it('should include file citations in description when enabled', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
// 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
},
];
const mockApiResponse = {
data: [
[
{
page_content: 'Content with citations',
metadata: { source: '/path/to/doc.pdf', page: 3 },
},
0.15,
],
],
};
// 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');
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);
return `File: test.pdf\nRelevance: 0.7000\nContent: test content\n\n<!-- INTERNAL_DATA_START -->\n${internalData}\n<!-- INTERNAL_DATA_END -->`;
});
it('should handle multiple files correctly', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
const result = await fileSearchTool.func('test');
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');
});
// Verify the new additions
expect(result).toContain('File_ID: test-file-123');
expect(result).toContain('Page: 1');
expect(result).toContain('<!-- INTERNAL_DATA_START -->');
expect(result).toContain('<!-- INTERNAL_DATA_END -->');
});
});

View File

@@ -1828,7 +1828,7 @@
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
* @param {AbortController} opts.abortController - AbortController instance
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
* @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
* @returns {Promise<string>}
* @memberof typedefs
*/

View File

@@ -275,9 +275,6 @@ 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'],
);
@@ -864,15 +861,6 @@ 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',
@@ -892,25 +880,6 @@ 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',
@@ -926,21 +895,6 @@ 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',

View File

@@ -1,4 +1,4 @@
/** v0.8.1-rc2 */
/** v0.8.1-rc1 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
@@ -41,6 +41,7 @@ module.exports = {
'jest-file-loader',
},
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
preset: 'ts-jest',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.1-rc2",
"version": "v0.8.1-rc1",
"description": "",
"type": "module",
"scripts": {
@@ -147,6 +147,7 @@
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.2.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.5",
"typescript": "^5.3.3",
"vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1",

View File

@@ -8,7 +8,6 @@ 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';
@@ -52,7 +51,6 @@ const App = () => {
<ToastProvider>
<DndProvider backend={HTML5Backend}>
<RouterProvider router={router} />
<WakeLockManager />
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
<Toast />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />

View File

@@ -41,8 +41,4 @@ 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;

View File

@@ -59,12 +59,7 @@ export default function ArtifactVersion({
<TooltipAnchor
description={localize('com_ui_change_version')}
render={
<Button
size="icon"
variant="ghost"
asChild
aria-label={localize('com_ui_change_version')}
>
<Button size="icon" variant="ghost" asChild>
<MenuButton>
<History
size={18}

View File

@@ -75,7 +75,7 @@ function AuthLayout({
<div className="flex flex-grow items-center justify-center">
<div className="w-authPageWidth overflow-hidden bg-white px-6 py-4 dark:bg-gray-900 sm:max-w-md sm:rounded-lg">
{!hasStartupConfigError && !isFetching && header && (
{!hasStartupConfigError && !isFetching && (
<h1
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
style={{ userSelect: 'none' }}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { ErrorTypes, registerPage } from 'librechat-data-provider';
import { ErrorTypes } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
@@ -104,7 +104,7 @@ function Login() {
{' '}
{localize('com_auth_no_account')}{' '}
<a
href={registerPage()}
href="/register"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_sign_up')}

View File

@@ -4,7 +4,6 @@ import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { useLocalize, TranslationKeys } from '~/hooks';
@@ -214,7 +213,7 @@ const Registration: React.FC = () => {
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{localize('com_auth_already_have_account')}{' '}
<a
href={loginPage()}
href="/login"
aria-label="Login"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>

View File

@@ -3,7 +3,6 @@ import { useState, ReactNode } from 'react';
import { Spinner, Button } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import type { FC } from 'react';
@@ -27,7 +26,7 @@ const ResetPasswordBodyText = () => {
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
<a
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
href={loginPage()}
href="/login"
>
{localize('com_auth_back_to_login')}
</a>
@@ -135,7 +134,7 @@ function RequestPasswordReset() {
{isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button>
<a
href={loginPage()}
href="/login"
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_back_to_login')}

View File

@@ -72,7 +72,7 @@ const BookmarkForm = ({
}
const allTags =
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
if (allTags.some((tag) => tag.tag === data.tag && tag.tag !== bookmark?.tag)) {
if (allTags.some((tag) => tag.tag === data.tag)) {
showToast({
message: localize('com_ui_bookmarks_create_exists'),
status: 'warning',

View File

@@ -1,499 +0,0 @@
import React, { createRef } from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import BookmarkForm from '../BookmarkForm';
import type { TConversationTag } from 'librechat-data-provider';
const mockMutate = jest.fn();
const mockShowToast = jest.fn();
const mockGetQueryData = jest.fn();
const mockSetOpen = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
com_ui_bookmarks_title: 'Title',
com_ui_bookmarks_description: 'Description',
com_ui_bookmarks_edit: 'Edit Bookmark',
com_ui_bookmarks_new: 'New Bookmark',
com_ui_bookmarks_create_exists: 'This bookmark already exists',
com_ui_bookmarks_add_to_conversation: 'Add to current conversation',
com_ui_bookmarks_tag_exists: 'A bookmark with this title already exists',
com_ui_field_required: 'This field is required',
com_ui_field_max_length: `${params?.field || 'Field'} must be less than ${params?.length || 0} characters`,
};
return translations[key] || key;
},
}));
jest.mock('@librechat/client', () => {
const ActualReact = jest.requireActual<typeof import('react')>('react');
return {
Checkbox: ({
checked,
onCheckedChange,
value,
...props
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
value: string;
}) =>
ActualReact.createElement('input', {
type: 'checkbox',
checked,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange(e.target.checked),
value,
...props,
}),
Label: ({ children, ...props }: { children: React.ReactNode }) =>
ActualReact.createElement('label', props, children),
TextareaAutosize: ActualReact.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>((props, ref) => ActualReact.createElement('textarea', { ref, ...props })),
Input: ActualReact.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => ActualReact.createElement('input', { ref, ...props }),
),
useToastContext: () => ({
showToast: mockShowToast,
}),
};
});
jest.mock('~/Providers/BookmarkContext', () => ({
useBookmarkContext: () => ({
bookmarks: [],
}),
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
getQueryData: mockGetQueryData,
}),
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | null | boolean)[]) => classes.filter(Boolean).join(' '),
logger: {
log: jest.fn(),
},
}));
const createMockBookmark = (overrides?: Partial<TConversationTag>): TConversationTag => ({
_id: 'bookmark-1',
user: 'user-1',
tag: 'Test Bookmark',
description: 'Test description',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
count: 1,
position: 0,
...overrides,
});
const createMockMutation = (isLoading = false) => ({
mutate: mockMutate,
isLoading,
isError: false,
isSuccess: false,
data: undefined,
error: null,
reset: jest.fn(),
mutateAsync: jest.fn(),
status: 'idle' as const,
variables: undefined,
context: undefined,
failureCount: 0,
failureReason: null,
isPaused: false,
isIdle: true,
submittedAt: 0,
});
describe('BookmarkForm - Bookmark Editing', () => {
const formRef = createRef<HTMLFormElement>();
beforeEach(() => {
jest.clearAllMocks();
mockGetQueryData.mockReturnValue([]);
});
describe('Editing only the description (tag unchanged)', () => {
it('should allow submitting when only the description is changed', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Original description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'My Bookmark',
description: 'Updated description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should not submit when both tag and description are unchanged', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Same description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).not.toHaveBeenCalled();
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
describe('Renaming a tag to an existing tag (should show error)', () => {
it('should show error toast when renaming to an existing tag name (via allTags)', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
const otherBookmark = createMockBookmark({
_id: 'bookmark-2',
tag: 'Existing Tag',
description: 'Other description',
});
mockGetQueryData.mockReturnValue([existingBookmark, otherBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
it('should show error toast when renaming to an existing tag name (via tags prop)', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
tags={['Existing Tag', 'Another Tag']}
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
describe('Renaming a tag to a new tag (should succeed)', () => {
it('should allow renaming to a completely new tag name', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'Brand New Tag',
description: 'Description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should allow keeping the same tag name when editing (not trigger duplicate error)', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Original description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'New description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'My Bookmark',
description: 'New description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
});
});
describe('Validation interaction between different data sources', () => {
it('should check both tags prop and allTags query data for duplicates', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
const queryDataBookmark = createMockBookmark({
_id: 'bookmark-query',
tag: 'Query Data Tag',
});
mockGetQueryData.mockReturnValue([existingBookmark, queryDataBookmark]);
render(
<BookmarkForm
tags={['Props Tag']}
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Props Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
});
it('should not trigger mutation when mutation is loading', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation(true) as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).not.toHaveBeenCalled();
});
});
it('should handle empty allTags gracefully', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Description',
});
mockGetQueryData.mockReturnValue(null);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'New Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'New Tag',
}),
);
});
});
});
});

View File

@@ -11,7 +11,6 @@ import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
import { AnimatePresence, motion } from 'framer-motion';
const defaultInterface = getConfigDefaults().interface;
@@ -39,24 +38,24 @@ export default function Header() {
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center">
<AnimatePresence initial={false}>
{!navVisible && (
<motion.div
className={`flex items-center gap-2`}
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</motion.div>
)}
</AnimatePresence>
<div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
<div className="mx-1 flex items-center gap-2">
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${
!navVisible
? 'translate-x-0 opacity-100'
: 'pointer-events-none translate-x-[-100px] opacity-0'
}`}
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</div>
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
>
<ModelSelector startupConfig={startupConfig} />
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}

View File

@@ -260,50 +260,37 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<FileFormChat conversation={conversation} />
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<div className="relative flex-1">
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current =
e;
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
/>
{isCollapsed && (
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 transition-all duration-200"
style={{
backdropFilter: 'blur(2px)',
WebkitMaskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
maskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
}}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
</div>
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
/>
<div className="flex flex-col items-start justify-start pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isMoreThanThreeRows}

View File

@@ -166,7 +166,6 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<TooltipAnchor
className="absolute bottom-[27px] right-2"
description={localize('com_ui_happy_birthday')}
aria-label={localize('com_ui_happy_birthday')}
>
<BirthdayIcon />
</TooltipAnchor>

View File

@@ -180,10 +180,6 @@ 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 (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
@@ -202,7 +198,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover"
aria-label={localize('com_ui_close')}
>
<X className="size-7 sm:size-6" />
</Button>
@@ -213,12 +208,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<TooltipAnchor
description={localize('com_ui_reset_zoom')}
render={
<Button
onClick={resetZoom}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={localize('com_ui_reset_zoom')}
>
<Button onClick={resetZoom} variant="ghost" className="h-10 w-10 p-0">
<RotateCcw className="size-6" />
</Button>
}
@@ -227,24 +217,22 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<TooltipAnchor
description={localize('com_ui_download')}
render={
<Button
onClick={() => downloadImage()}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={localize('com_ui_download')}
>
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
<ArrowDownToLine className="size-6" />
</Button>
}
/>
<TooltipAnchor
description={imageDetailsLabel}
description={
isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details')
}
render={
<Button
onClick={() => setIsPromptOpen(!isPromptOpen)}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={imageDetailsLabel}
>
{isPromptOpen ? (
<PanelLeftOpen className="size-7 sm:size-6" />

View File

@@ -1,7 +1,6 @@
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';
@@ -37,24 +36,6 @@ 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({
@@ -67,7 +48,7 @@ const Image = ({
const downloadImage = async () => {
try {
const response = await fetch(absoluteImageUrl);
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
@@ -86,7 +67,7 @@ const Image = ({
} catch (error) {
console.error('Download failed:', error);
const link = document.createElement('a');
link.href = absoluteImageUrl;
link.href = imagePath;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
@@ -116,7 +97,7 @@ const Image = ({
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={absoluteImageUrl}
src={imagePath}
style={{
width: `${scaledWidth}`,
height: 'auto',
@@ -136,7 +117,7 @@ const Image = ({
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={absoluteImageUrl}
src={imagePath}
downloadImage={downloadImage}
args={args}
/>

View File

@@ -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, img } from './MarkdownComponents';
import { code, a, p } from './MarkdownComponents';
import store from '~/store';
type TContentProps = {
@@ -81,7 +81,6 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
code,
a,
p,
img,
artifact: Artifact,
citation: Citation,
'highlighted-text': HighlightedText,

View File

@@ -1,7 +1,7 @@
import React, { memo, useMemo, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
import { PermissionTypes, Permissions, dataService } 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 = `${apiBaseUrl()}/api`;
const domainServerBaseUrl = dataService.getDomainServerBaseUrl();
return (
<a
@@ -158,31 +158,3 @@ type TParagraphProps = {
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
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 <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
});

View File

@@ -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, img } from './MarkdownComponents';
import { code, codeNoExecution, a, p } from './MarkdownComponents';
import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { langSubset } from '~/utils';
@@ -44,7 +44,6 @@ const MarkdownLite = memo(
code: codeExecution ? code : codeNoExecution,
a,
p,
img,
} as {
[nodeType: string]: React.ElementType;
}

View File

@@ -2,7 +2,7 @@ import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Menu from '@ariakit/react/menu';
import { useParams, useNavigate } from 'react-router-dom';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
import type { MouseEvent } from 'react';
import {
useDuplicateConversationMutation,
@@ -151,7 +151,7 @@ function ConvoOptions({
icon: isDuplicateLoading ? (
<Spinner className="size-4" />
) : (
<CopyPlus className="icon-sm mr-2 text-text-primary" />
<Copy className="icon-sm mr-2 text-text-primary" />
),
},
{

View File

@@ -55,11 +55,6 @@ export function DeleteConversationDialog({
}
setMenuOpen?.(false);
retainView();
showToast({
message: localize('com_ui_convo_delete_success'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: () => {
showToast({

View File

@@ -113,8 +113,6 @@ export default function SharedLinkButton({
}
};
const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr');
return (
<>
<div className="flex gap-2">
@@ -132,7 +130,6 @@ export default function SharedLinkButton({
<Button
{...props}
onClick={() => updateSharedLink()}
aria-label={localize('com_ui_refresh_link')}
variant="outline"
disabled={isUpdateLoading}
>
@@ -146,14 +143,9 @@ export default function SharedLinkButton({
/>
<TooltipAnchor
description={qrCodeLabel}
description={showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr')}
render={(props) => (
<Button
{...props}
onClick={() => setShowQR(!showQR)}
variant="outline"
aria-label={qrCodeLabel}
>
<Button {...props} onClick={() => setShowQR(!showQR)} variant="outline">
<QrCode className="size-4" />
</Button>
)}
@@ -162,12 +154,7 @@ export default function SharedLinkButton({
<TooltipAnchor
description={localize('com_ui_delete')}
render={(props) => (
<Button
{...props}
onClick={() => setShowDeleteDialog(true)}
variant="destructive"
aria-label={localize('com_ui_delete')}
>
<Button {...props} onClick={() => setShowDeleteDialog(true)} variant="destructive">
<Trash2 className="size-4" />
</Button>
)}

View File

@@ -9,10 +9,8 @@ import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function MobileNav({
navVisible,
setNavVisible,
}: {
navVisible: boolean;
setNavVisible: Dispatch<SetStateAction<boolean>>;
}) {
const localize = useLocalize();
@@ -27,7 +25,7 @@ export default function MobileNav({
type="button"
data-testid="mobile-header-new-chat-button"
aria-label={localize('com_nav_open_sidebar')}
className={`m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover ${navVisible ? 'invisible' : ''}`}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { AnimatePresence, motion } from 'framer-motion';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
@@ -191,21 +190,22 @@ const Nav = memo(
return (
<>
<AnimatePresence initial={false}>
{navVisible && (
<motion.div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'md:max-w-[260px]',
)}
initial={{ width: 0 }}
animate={{ width: navWidth }}
exit={{ width: 0 }}
transition={{ duration: 0.2 }}
key="nav"
>
<div className="h-full w-[320px] md:w-[260px]">
<div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 transform overflow-x-hidden bg-surface-primary-alt transition-all duration-200 ease-in-out',
'md:max-w-[260px]',
)}
style={{
width: navVisible ? navWidth : '0px',
transform: navVisible ? 'translateX(0)' : 'translateX(-100%)',
}}
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<div
className={`flex h-full flex-col transition-opacity duration-200 ease-in-out ${navVisible ? 'opacity-100' : 'opacity-0'}`}
>
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
@@ -235,9 +235,9 @@ const Nav = memo(
</nav>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
);

View File

@@ -134,13 +134,8 @@ function Avatar() {
e.preventDefault();
}, []);
const openFileDialog = useCallback(() => {
const openFileDialog = () => {
fileInputRef.current?.click();
}, []);
const handleSelectFileClick = (event: React.MouseEvent) => {
event.stopPropagation();
openFileDialog();
};
const resetImage = useCallback(() => {
@@ -346,7 +341,7 @@ function Avatar() {
: '2MB',
})}
</p>
<Button type="button" variant="secondary" onClick={handleSelectFileClick}>
<Button type="button" variant="secondary" onClick={openFileDialog}>
{localize('com_ui_select_file')}
</Button>
<input

View File

@@ -93,11 +93,6 @@ export default function ArchivedChatsTable({
onSuccess: async () => {
setIsDeleteOpen(false);
await refetch();
showToast({
message: localize('com_ui_convo_delete_success'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: (error: unknown) => {
showToast({

View File

@@ -29,13 +29,6 @@ const toggleSwitchConfigs = [
hoverCardText: undefined,
key: 'hideSidePanel',
},
{
stateAtom: store.keepScreenAwake,
localizationKey: 'com_nav_keep_screen_awake',
switchId: 'keepScreenAwake',
hoverCardText: undefined,
key: 'keepScreenAwake',
},
];
export const ThemeSelector = ({

View File

@@ -1,101 +1,202 @@
import { useEffect, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import * as Popover from '@radix-ui/react-popover';
import { useToastContext } from '@librechat/client';
import { useFormContext, useWatch } from 'react-hook-form';
import { mergeFileConfig, fileConfig as defaultFileConfig } from 'librechat-data-provider';
import type { AgentAvatar } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
mergeFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type {
Agent,
AgentAvatar,
AgentCreateParams,
AgentListResponse,
} from 'librechat-data-provider';
import {
useUploadAgentAvatarMutation,
useGetFileConfig,
allAgentViewAndEditQueryKeys,
invalidateAgentMarketplaceQueries,
} from '~/data-provider';
import { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
import { useGetFileConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { formatBytes } from '~/utils';
function Avatar({ avatar }: { avatar: AgentAvatar | null }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { control, setValue } = useFormContext<AgentForm>();
const avatarPreview = useWatch({ control, name: 'avatar_preview' }) ?? '';
const avatarAction = useWatch({ control, name: 'avatar_action' });
function Avatar({
agent_id = '',
avatar,
createMutation,
}: {
agent_id: string | null;
avatar: null | AgentAvatar;
createMutation: UseMutationResult<Agent, Error, AgentCreateParams>;
}) {
const queryClient = useQueryClient();
const [menuOpen, setMenuOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [progress, setProgress] = useState<number>(1);
const [input, setInput] = useState<File | null>(null);
const lastSeenCreatedId = useRef<string | null>(null);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
// Derive whether agent has a remote avatar from the avatar prop
const hasRemoteAvatar = Boolean(avatar?.filepath);
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<AgentListResponse>([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<AgentListResponse>([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);
},
});
useEffect(() => {
if (avatarAction) {
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) {
return;
}
if (avatar?.filepath && avatarPreview !== avatar.filepath) {
setValue('avatar_preview', avatar.filepath);
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,
});
}
}, [createMutation.data, createMutation.isSuccess, input, previewUrl, uploadAvatar]);
if (!avatar?.filepath && avatarPreview !== '') {
setValue('avatar_preview', '');
}
}, [avatar?.filepath, avatarAction, avatarPreview, setValue]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
const sizeLimit = fileConfig.avatarSizeLimit ?? 0;
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const sizeLimit = fileConfig.avatarSizeLimit ?? 0;
if (sizeLimit && file && file.size <= sizeLimit) {
setInput(file);
setMenuOpen(false);
if (!file) {
const currentId = agent_id ?? '';
if (!currentId) {
return;
}
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;
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));
}
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],
);
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 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;
setMenuOpen(false);
};
return (
<>
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<div className="flex w-full items-center justify-center gap-4">
<AvatarMenu
trigger={
<button
type="button"
className="f h-20 w-20 outline-none ring-offset-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_ui_upload_agent_avatar_label')}
>
{avatarPreview ? <AgentAvatarRender url={avatarPreview} /> : <NoImage />}
</button>
}
handleFileChange={handleFileChange}
onReset={handleReset}
canReset={canReset}
/>
<Popover.Trigger asChild>
<button
type="button"
className="f h-20 w-20 focus:rounded-full focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_upload_agent_avatar_label')}
>
{previewUrl ? <AgentAvatarRender url={previewUrl} progress={progress} /> : <NoImage />}
</button>
</Popover.Trigger>
</div>
</>
{<AvatarMenu handleFileChange={handleFileChange} />}
</Popover.Root>
);
}

View File

@@ -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, IconComponentTypes } from '~/common';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import {
removeFocusOutlines,
processAgentOption,
@@ -37,7 +37,7 @@ const inputClass = cn(
removeFocusOutlines,
);
export default function AgentConfig() {
export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'createMutation'>) {
const localize = useLocalize();
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
@@ -183,7 +183,11 @@ export default function AgentConfig() {
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
{/* Avatar & Name */}
<div className="mb-4">
<AgentAvatar avatar={agent?.['avatar'] ?? null} />
<AgentAvatar
agent_id={agent_id}
createMutation={createMutation}
avatar={agent?.['avatar'] ?? null}
/>
<label className={labelClass} htmlFor="name">
{localize('com_ui_name')}
<span className="text-red-500">*</span>

View File

@@ -24,13 +24,11 @@ export default function AgentFooter({
updateMutation,
setActivePanel,
setCurrentAgentId,
isAvatarUploading = false,
}: Pick<
AgentPanelProps,
'setCurrentAgentId' | 'createMutation' | 'activePanel' | 'setActivePanel'
> & {
updateMutation: ReturnType<typeof useUpdateAgentMutation>;
isAvatarUploading?: boolean;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
@@ -51,9 +49,8 @@ export default function AgentFooter({
const canShareThisAgent = hasPermission(PermissionBits.SHARE);
const canDeleteThisAgent = hasPermission(PermissionBits.DELETE);
const isSaving = createMutation.isLoading || updateMutation.isLoading || isAvatarUploading;
const renderSaveButton = () => {
if (isSaving) {
if (createMutation.isLoading || updateMutation.isLoading) {
return <Spinner className="icon-md" aria-hidden="true" />;
}
@@ -96,8 +93,8 @@ export default function AgentFooter({
<button
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
type="submit"
disabled={isSaving}
aria-busy={isSaving}
disabled={createMutation.isLoading || updateMutation.isLoading}
aria-busy={createMutation.isLoading || updateMutation.isLoading}
>
{renderSaveButton()}
</button>

View File

@@ -1,7 +1,7 @@
import { Plus } from 'lucide-react';
import React, { useMemo, useCallback, useRef, useState } from 'react';
import React, { useMemo, useCallback, useRef } from 'react';
import { Button, useToastContext } from '@librechat/client';
import { useWatch, useForm, FormProvider, type FieldNamesMarkedBoolean } from 'react-hook-form';
import { useWatch, useForm, FormProvider } from 'react-hook-form';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import {
Tools,
@@ -12,13 +12,11 @@ import {
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { AgentForm, StringOption } from '~/common';
import type { Agent } from 'librechat-data-provider';
import {
useCreateAgentMutation,
useUpdateAgentMutation,
useGetAgentByIdQuery,
useGetExpandedAgentByIdQuery,
useUploadAgentAvatarMutation,
} from '~/data-provider';
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
import { useResourcePermissions } from '~/hooks/useResourcePermissions';
@@ -32,176 +30,6 @@ import AgentSelect from './AgentSelect';
import AgentFooter from './AgentFooter';
import ModelPanel from './ModelPanel';
/* Helpers */
function getUpdateToastMessage(
noVersionChange: boolean,
avatarActionState: AgentForm['avatar_action'],
name: string | undefined,
localize: (key: string, vars?: Record<string, unknown> | Array<string | number>) => string,
): string | null {
// If only avatar upload is pending (separate endpoint), suppress the no-changes toast.
if (noVersionChange && avatarActionState === 'upload') {
return null;
}
if (noVersionChange) {
return localize('com_ui_no_changes');
}
return `${localize('com_assistants_update_success')} ${name ?? localize('com_ui_agent')}`;
}
/**
* Normalizes the payload sent to the agent update/create endpoints.
* Handles avatar reset requests for persistent agents independently of avatar uploads.
* @param {AgentForm} data - Form data from the agent configuration form.
* @param {string | null} [agent_id] - Agent identifier, if the agent already exists.
* @returns {{ payload: Partial<AgentForm>; provider: string; model: string }} Payload metadata.
*/
export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | null) {
const {
name,
artifacts,
description,
instructions,
model: _model,
model_parameters,
provider: _provider,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
avatar_action: avatarActionState,
} = data;
const shouldResetAvatar =
avatarActionState === 'reset' && Boolean(agent_id) && !isEphemeralAgent(agent_id);
const model = _model ?? '';
const provider =
(typeof _provider === 'string' ? _provider : (_provider as StringOption).value) ?? '';
return {
payload: {
name,
artifacts,
description,
instructions,
model,
provider,
model_parameters,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
...(shouldResetAvatar ? { avatar: null } : {}),
},
provider,
model,
} as const;
}
type UploadAvatarFn = (variables: { agent_id: string; formData: FormData }) => Promise<Agent>;
export interface PersistAvatarChangesParams {
agentId?: string | null;
avatarActionState: AgentForm['avatar_action'];
avatarFile?: File | null;
uploadAvatar: UploadAvatarFn;
}
/**
* Uploads a new avatar when the form indicates an avatar upload is pending.
* The helper ensures we only attempt uploads for persisted agents and when
* the avatar action is explicitly set to "upload".
* @returns {Promise<boolean>} Resolves true if an upload occurred, false otherwise.
*/
export async function persistAvatarChanges({
agentId,
avatarActionState,
avatarFile,
uploadAvatar,
}: PersistAvatarChangesParams): Promise<boolean> {
if (!agentId || isEphemeralAgent(agentId)) {
return false;
}
if (avatarActionState !== 'upload' || !avatarFile) {
return false;
}
const formData = new FormData();
formData.append('file', avatarFile, avatarFile.name);
await uploadAvatar({
agent_id: agentId,
formData,
});
return true;
}
const AVATAR_ONLY_DIRTY_FIELDS = new Set(['avatar_action', 'avatar_file', 'avatar_preview']);
const IGNORED_DIRTY_FIELDS = new Set(['agent']);
const isNestedDirtyField = (
value: FieldNamesMarkedBoolean<AgentForm>[keyof AgentForm],
): value is FieldNamesMarkedBoolean<AgentForm> => typeof value === 'object' && value !== null;
const evaluateDirtyFields = (
fields: FieldNamesMarkedBoolean<AgentForm>,
): { sawDirty: boolean; onlyAvatarDirty: boolean } => {
let sawDirty = false;
for (const [key, value] of Object.entries(fields)) {
if (!value) {
continue;
}
if (IGNORED_DIRTY_FIELDS.has(key)) {
continue;
}
if (isNestedDirtyField(value)) {
const nested = evaluateDirtyFields(value);
if (!nested.onlyAvatarDirty) {
return { sawDirty: true, onlyAvatarDirty: false };
}
sawDirty = sawDirty || nested.sawDirty;
continue;
}
sawDirty = true;
if (AVATAR_ONLY_DIRTY_FIELDS.has(key)) {
continue;
}
return { sawDirty: true, onlyAvatarDirty: false };
}
return { sawDirty, onlyAvatarDirty: true };
};
/**
* Determines whether the dirty form state only contains avatar uploads/resets.
* This enables short-circuiting the general agent update flow when only the avatar
* needs to be uploaded.
*/
export const isAvatarUploadOnlyDirty = (
dirtyFields?: FieldNamesMarkedBoolean<AgentForm>,
): boolean => {
if (!dirtyFields) {
return false;
}
const result = evaluateDirtyFields(dirtyFields);
return result.sawDirty && result.onlyAvatarDirty;
};
export default function AgentPanel() {
const localize = useLocalize();
const { user } = useAuthContext();
@@ -239,58 +67,7 @@ export default function AgentPanel() {
mode: 'onChange',
});
const {
control,
handleSubmit,
reset,
getValues,
setValue,
formState: { dirtyFields },
} = methods;
const [isAvatarUploadInFlight, setIsAvatarUploadInFlight] = useState(false);
const uploadAvatarMutation = useUploadAgentAvatarMutation({
onSuccess: (updatedAgent) => {
showToast({ message: localize('com_ui_upload_agent_avatar') });
setValue('avatar_preview', updatedAgent.avatar?.filepath ?? '', { shouldDirty: false });
setValue('avatar_file', null, { shouldDirty: false });
setValue('avatar_action', null, { shouldDirty: false });
const agentOption = getValues('agent');
if (agentOption && typeof agentOption !== 'string') {
setValue('agent', { ...agentOption, ...updatedAgent }, { shouldDirty: false });
}
},
onError: () => {
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
},
});
const handleAvatarUpload = useCallback(
async (agentId?: string | null) => {
const avatarActionState = getValues('avatar_action');
const avatarFile = getValues('avatar_file');
if (!agentId || isEphemeralAgent(agentId) || avatarActionState !== 'upload' || !avatarFile) {
return false;
}
setIsAvatarUploadInFlight(true);
try {
return await persistAvatarChanges({
agentId,
avatarActionState,
avatarFile,
uploadAvatar: uploadAvatarMutation.mutateAsync,
});
} catch (error) {
console.error('[AgentPanel] Avatar upload failed', error);
throw error;
} finally {
setIsAvatarUploadInFlight(false);
}
},
[getValues, uploadAvatarMutation],
);
const { control, handleSubmit, reset } = methods;
const agent_id = useWatch({ control, name: 'id' });
const previousVersionRef = useRef<number | undefined>();
@@ -320,41 +97,20 @@ export default function AgentPanel() {
// Store the current version before mutation
previousVersionRef.current = agentQuery.data?.version;
},
onSuccess: async (data) => {
const avatarActionState = getValues('avatar_action');
const noVersionChange =
previousVersionRef.current !== undefined && data.version === previousVersionRef.current;
const toastMessage = getUpdateToastMessage(
noVersionChange,
avatarActionState,
data.name,
localize,
);
if (toastMessage) {
showToast({ message: toastMessage, status: noVersionChange ? 'info' : undefined });
}
const agentOption = getValues('agent');
if (agentOption && typeof agentOption !== 'string') {
setValue('agent', { ...agentOption, ...data }, { shouldDirty: false });
}
try {
await handleAvatarUpload(data.id ?? agent_id);
} catch (error) {
console.error('[AgentPanel] Avatar upload failed after update', error);
onSuccess: (data) => {
// Check if agent version is the same (no changes were made)
if (previousVersionRef.current !== undefined && data.version === previousVersionRef.current) {
showToast({
message: localize('com_agents_avatar_upload_error'),
status: 'error',
message: localize('com_ui_no_changes'),
status: 'info',
});
} else {
showToast({
message: `${localize('com_assistants_update_success')} ${
data.name ?? localize('com_ui_agent')
}`,
});
}
if (avatarActionState === 'reset') {
setValue('avatar_action', null, { shouldDirty: false });
setValue('avatar_file', null, { shouldDirty: false });
setValue('avatar_preview', '', { shouldDirty: false });
}
// Clear the ref after use
previousVersionRef.current = undefined;
},
@@ -370,23 +126,13 @@ export default function AgentPanel() {
});
const create = useCreateAgentMutation({
onSuccess: async (data) => {
onSuccess: (data) => {
setCurrentAgentId(data.id);
showToast({
message: `${localize('com_assistants_create_success')} ${
data.name ?? localize('com_ui_agent')
}`,
});
try {
await handleAvatarUpload(data.id);
} catch (error) {
console.error('[AgentPanel] Avatar upload failed after create', error);
showToast({
message: localize('com_agents_avatar_upload_error'),
status: 'error',
});
}
},
onError: (err) => {
const error = err as Error;
@@ -400,7 +146,7 @@ export default function AgentPanel() {
});
const onSubmit = useCallback(
async (data: AgentForm) => {
(data: AgentForm) => {
const tools = data.tools ?? [];
if (data.execute_code === true) {
@@ -413,28 +159,48 @@ export default function AgentPanel() {
tools.push(Tools.web_search);
}
const { payload: basePayload, provider, model } = composeAgentUpdatePayload(data, agent_id);
const {
name,
artifacts,
description,
instructions,
model: _model,
model_parameters,
provider: _provider,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
} = data;
const model = _model ?? '';
const provider =
(typeof _provider === 'string' ? _provider : (_provider as StringOption).value) ?? '';
if (agent_id) {
if (data.avatar_action === 'upload' && isAvatarUploadOnlyDirty(dirtyFields)) {
try {
const uploaded = await handleAvatarUpload(agent_id);
if (!uploaded) {
showToast({
message: localize('com_agents_avatar_upload_error'),
status: 'error',
});
}
} catch (error) {
console.error('[AgentPanel] Avatar upload failed for avatar-only submission', error);
showToast({
message: localize('com_agents_avatar_upload_error'),
status: 'error',
});
}
return;
}
update.mutate({ agent_id, data: { ...basePayload, tools } });
update.mutate({
agent_id,
data: {
name,
artifacts,
description,
instructions,
model,
tools,
provider,
model_parameters,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
},
});
return;
}
@@ -444,16 +210,32 @@ export default function AgentPanel() {
status: 'error',
});
}
if (!data.name) {
if (!name) {
return showToast({
message: localize('com_agents_missing_name'),
status: 'error',
});
}
create.mutate({ ...basePayload, model, tools, provider });
create.mutate({
name,
artifacts,
description,
instructions,
model,
tools,
provider,
model_parameters,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
});
},
[agent_id, create, dirtyFields, handleAvatarUpload, update, showToast, localize],
[agent_id, create, update, showToast, localize],
);
const handleSelectAgent = useCallback(() => {
@@ -548,7 +330,7 @@ export default function AgentPanel() {
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
<AgentConfig />
<AgentConfig createMutation={create} />
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (
<AdvancedPanel />
@@ -557,7 +339,6 @@ export default function AgentPanel() {
<AgentFooter
createMutation={create}
updateMutation={update}
isAvatarUploading={isAvatarUploadInFlight || uploadAvatarMutation.isPending}
activePanel={activePanel}
setActivePanel={setActivePanel}
setCurrentAgentId={setCurrentAgentId}

View File

@@ -81,9 +81,6 @@ export default function AgentSelect({
category: fullAgent.category || 'general',
// Make sure support_contact is properly loaded
support_contact: fullAgent.support_contact,
avatar_file: null,
avatar_preview: fullAgent.avatar?.filepath ?? '',
avatar_action: null,
};
Object.entries(fullAgent).forEach(([name, value]) => {

View File

@@ -1,4 +1,4 @@
import { CopyPlus } from 'lucide-react';
import { CopyIcon } from 'lucide-react';
import { useToastContext, Button } from '@librechat/client';
import { useDuplicateAgentMutation } from '~/data-provider';
import { isEphemeralAgent } from '~/common';
@@ -41,7 +41,7 @@ export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
onClick={handleDuplicate}
>
<div className="flex w-full items-center justify-center gap-2 text-primary">
<CopyPlus className="size-4" />
<CopyIcon className="size-4" />
</div>
</Button>
);

View File

@@ -1,7 +1,5 @@
import { useRef, useState, useEffect, type ReactElement } from 'react';
import * as Ariakit from '@ariakit/react';
import { DropdownPopup, Skeleton } from '@librechat/client';
import type { MenuItemProps } from '~/common/menus';
import { useRef } from 'react';
import * as Popover from '@radix-ui/react-popover';
import { useLocalize } from '~/hooks';
export function NoImage() {
@@ -26,11 +24,21 @@ export function NoImage() {
);
}
export const AgentAvatarRender = ({ url }: { url?: string }) => {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(false);
}, [url]);
export const AgentAvatarRender = ({
url,
progress = 1,
}: {
url?: string;
progress: number; // between 0 and 1
}) => {
const radius = 55; // Radius of the SVG circle
const circumference = 2 * Math.PI * radius;
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear',
};
return (
<div>
@@ -38,38 +46,50 @@ export const AgentAvatarRender = ({ url }: { url?: string }) => {
<img
src={url}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
alt="Agent avatar"
alt="GPT"
width="80"
height="80"
loading="lazy"
style={{ opacity: progress < 1 ? 0.4 : 1 }}
key={url || 'default-key'}
onLoad={() => setIsLoaded(true)}
onError={() => setIsLoaded(false)}
style={{
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.2s ease-in-out',
}}
/>
{!isLoaded && <Skeleton className="absolute inset-0 rounded-full" aria-hidden="true" />}
{progress < 1 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/5 text-white">
<svg width="120" height="120" viewBox="0 0 120 120" className="h-6 w-6">
<circle
className="origin-[50%_50%] -rotate-90 stroke-gray-400"
strokeWidth="10"
fill="transparent"
r="55"
cx="60"
cy="60"
/>
<circle
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
stroke="currentColor"
strokeWidth="10"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="transparent"
r="55"
cx="60"
cy="60"
style={circleCSSProperties}
/>
</svg>
</div>
)}
</div>
</div>
);
};
export function AvatarMenu({
trigger,
handleFileChange,
onReset,
canReset,
}: {
trigger: ReactElement;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onReset: () => void;
canReset: boolean;
}) {
const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isOpen, setIsOpen] = useState(false);
const onItemClick = () => {
if (fileInputRef.current) {
@@ -78,61 +98,40 @@ 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 (
<>
<DropdownPopup
trigger={<Ariakit.MenuButton render={trigger} />}
items={items}
isOpen={isOpen}
setIsOpen={setIsOpen}
menuId="agent-avatar-menu"
placement="bottom"
gutter={8}
portal
mountByState
/>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={(event) => {
handleFileChange(event);
if (fileInputRef.current) {
fileInputRef.current.value = '';
} else {
event.currentTarget.value = '';
}
}}
ref={fileInputRef}
tabIndex={-1}
/>
</>
<Popover.Portal>
<Popover.Content
className="flex min-w-[100px] max-w-xs flex-col rounded-xl border border-gray-400 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-850 dark:text-white"
sideOffset={5}
>
<button
type="button"
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
tabIndex={0}
data-orientation="vertical"
onClick={onItemClick}
>
{localize('com_ui_upload_image')}
</button>
{/* <Popover.Close
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
tabIndex={-1}
data-orientation="vertical"
>
Use DALL·E
</Popover.Close> */}
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
ref={fileInputRef}
tabIndex={-1}
/>
</Popover.Content>
</Popover.Portal>
);
}

View File

@@ -1,95 +0,0 @@
/**
* @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: () => <div data-testid="avatar-render" />,
NoImage: () => <div data-testid="no-avatar" />,
AvatarMenu: ({ onReset }: { onReset: () => void }) => (
<button type="button" data-testid="reset-avatar" onClick={onReset}>
Reset
</button>
),
}));
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<AgentForm>;
const Wrapper = () => {
methodsRef = useForm<AgentForm>({
defaultValues: {
...defaultFormValues,
avatar_preview: 'data:image/png;base64,abc',
avatar_file: new File(['avatar'], 'avatar.png', { type: 'image/png' }),
avatar_action: 'upload',
},
});
return (
<FormProvider {...methodsRef}>
<AgentAvatar
avatar={{
filepath: 'https://example.com/current.png',
source: 's3',
}}
/>
</FormProvider>
);
};
const { getByTestId } = render(<Wrapper />);
fireEvent.click(getByTestId('reset-avatar'));
expect(methodsRef.getValues('avatar_preview')).toBe('');
expect(methodsRef.getValues('avatar_file')).toBeNull();
expect(methodsRef.getValues('avatar_action')).toBe('reset');
});
});

View File

@@ -157,7 +157,7 @@ jest.mock('../DuplicateAgent', () => ({
),
}));
jest.mock('@librechat/client', () => ({
jest.mock('~/components', () => ({
Spinner: () => <div data-testid="spinner" />,
}));
@@ -225,7 +225,6 @@ describe('AgentFooter', () => {
updateMutation: mockUpdateMutation,
setActivePanel: mockSetActivePanel,
setCurrentAgentId: mockSetCurrentAgentId,
isAvatarUploading: false,
};
beforeEach(() => {
@@ -276,14 +275,14 @@ describe('AgentFooter', () => {
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
expect(screen.getByTestId('duplicate-button')).toBeInTheDocument();
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
expect(document.querySelector('.spinner')).not.toBeInTheDocument();
});
test('handles loading states for createMutation', () => {
const { unmount } = render(
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
expect(document.querySelector('.spinner')).toBeInTheDocument();
expect(screen.queryByText('Save')).not.toBeInTheDocument();
// Find the submit button (the one with aria-busy attribute)
const buttons = screen.getAllByRole('button');
@@ -295,18 +294,9 @@ describe('AgentFooter', () => {
test('handles loading states for updateMutation', () => {
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
expect(document.querySelector('.spinner')).toBeInTheDocument();
expect(screen.queryByText('Save')).not.toBeInTheDocument();
});
test('handles loading state when avatar upload is in progress', () => {
render(<AgentFooter {...defaultProps} isAvatarUploading={true} />);
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', () => {

View File

@@ -1,141 +0,0 @@
/**
* @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<AgentForm>;
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<AgentForm>;
expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(true);
});
it('returns false when other fields are dirty', () => {
const dirtyFields = {
name: true,
} as FieldNamesMarkedBoolean<AgentForm>;
expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(false);
});
});

View File

@@ -59,10 +59,6 @@ const AssistantConversationStarters: React.FC<AssistantConversationStartersProps
const hasReachedMax = field.value.length >= Constants.MAX_CONVO_STARTERS;
const addConversationStarterLabel = hasReachedMax
? localize('com_assistants_max_starters_reached')
: localize('com_ui_add');
return (
<div className="relative">
<label className={labelClass} htmlFor="conversation_starters">
@@ -112,8 +108,11 @@ const AssistantConversationStarters: React.FC<AssistantConversationStartersProps
>
<TooltipAnchor
side="top"
description={addConversationStarterLabel}
aria-label={addConversationStarterLabel}
description={
hasReachedMax
? localize('com_assistants_max_starters_reached')
: localize('com_ui_add')
}
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onClick={handleAddStarter}
disabled={hasReachedMax}
@@ -141,7 +140,6 @@ const AssistantConversationStarters: React.FC<AssistantConversationStartersProps
<TooltipAnchor
side="top"
description={localize('com_ui_delete')}
aria-label={localize('com_ui_delete')}
className="absolute right-1 top-1 flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onClick={() => handleDeleteStarter(index)}
>

View File

@@ -147,13 +147,11 @@ const SidePanelGroup = memo(
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={handleClosePanel}
/>
)}
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={handleClosePanel}
/>
</>
);
},

View File

@@ -1,31 +0,0 @@
import { useRecoilValue } from 'recoil';
import useWakeLock from '~/hooks/useWakeLock';
import store from '~/store';
/**
* WakeLockManager Component
*
* Manages the Screen Wake Lock during AI response generation to prevent
* device screens from sleeping or dimming during long-running operations.
*
* The wake lock is only active when:
* 1. Any conversation is currently generating a response (anySubmittingSelector)
* 2. User has not disabled the feature in settings (keepScreenAwake preference)
*
* This component is rendered at the root level of the application
* to ensure wake lock state persists across all conversations and routes.
*
* @see useWakeLock - The hook that manages the actual wake lock implementation
* @see anySubmittingSelector - Recoil selector tracking if any conversation is generating
*/
const WakeLockManager = () => {
const isSubmitting = useRecoilValue(store.anySubmittingSelector);
const keepScreenAwake = useRecoilValue(store.keepScreenAwake);
const shouldPreventSleep = isSubmitting && keepScreenAwake;
useWakeLock(shouldPreventSleep);
return null;
};
export default WakeLockManager;

View File

@@ -188,41 +188,9 @@ export const useUploadAgentAvatarMutation = (
t.AgentAvatarVariables, // request
unknown // context
> => {
const queryClient = useQueryClient();
return useMutation<t.Agent, unknown, t.AgentAvatarVariables>({
mutationKey: [MutationKeys.agentAvatarUpload],
return useMutation([MutationKeys.agentAvatarUpload], {
mutationFn: (variables: t.AgentAvatarVariables) => dataService.uploadAgentAvatar(variables),
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updatedAgent, variables, context) => {
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (!listRes) {
return;
}
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
}
return agent;
}),
});
});
})(allAgentViewAndEditQueryKeys);
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
queryClient.setQueryData<t.Agent>(
[QueryKeys.agent, variables.agent_id, 'expanded'],
updatedAgent,
);
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.(updatedAgent, variables, context);
},
...(options || {}),
});
};

View File

@@ -84,6 +84,32 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
/** Filter disconnected servers when values change, but only after initial load
This prevents clearing selections on page refresh when servers haven't connected yet
*/
const hasInitialLoadCompleted = useRef(false);
useEffect(() => {
if (!connectionStatus || Object.keys(connectionStatus).length === 0) {
return;
}
if (!hasInitialLoadCompleted.current) {
hasInitialLoadCompleted.current = true;
return;
}
if (!mcpValues?.length) return;
const connectedSelected = mcpValues.filter(
(serverName) => connectionStatus[serverName]?.connectionState === 'connected',
);
if (connectedSelected.length !== mcpValues.length) {
setMCPValues(connectedSelected);
}
}, [connectionStatus, mcpValues, setMCPValues]);
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
setServerStates((prev) => {
const newStates = { ...prev };

View File

@@ -1,211 +0,0 @@
import { useEffect, useRef } from 'react';
/**
* Extended Navigator type that includes the Screen Wake Lock API
* @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
*/
type WakeLockCapableNavigator = Navigator & {
wakeLock?: {
request: (type: WakeLockType) => Promise<WakeLockSentinel>;
};
};
/**
* Checks if we're in a client-side environment (browser)
* Prevents SSR issues by verifying window, navigator, and document exist
*/
const isClientEnvironment =
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
typeof document !== 'undefined';
const getNavigator = () => navigator as WakeLockCapableNavigator;
/**
* Determines if the browser supports the Screen Wake Lock API
* Checking outside component scope for better performance
*/
const supportsWakeLock = isClientEnvironment && 'wakeLock' in navigator;
/**
* Enable/disable debug logging for wake lock operations
* Set to true during development to see wake lock lifecycle events
*/
const DEBUG_WAKE_LOCK = false;
/**
* Custom hook to prevent screen from sleeping during critical operations
* Uses the Screen Wake Lock API to keep the device screen active
*
* @param shouldHold - Boolean flag indicating whether to acquire/hold the wake lock
* @returns void - This hook manages wake lock state internally
*
* @example
* ```tsx
* const isGeneratingResponse = useRecoilValue(anySubmittingSelector);
* useWakeLock(isGeneratingResponse);
* ```
*
* @remarks
* - Automatically handles page visibility changes (reacquires lock when tab becomes visible)
* - Properly cleans up lock on unmount or when shouldHold becomes false
* - Gracefully degrades on browsers without Wake Lock API support
* - Wake locks are automatically released when user switches tabs
*/
export const useWakeLock = (shouldHold: boolean) => {
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
if (!supportsWakeLock) {
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] API not supported in this browser');
}
return;
}
/**
* Flag to prevent operations after effect cleanup
* Essential for avoiding race conditions when:
* - Component unmounts while lock is being acquired
* - shouldHold changes while async operations are in flight
* - Multiple visibility change events fire in quick succession
*/
let cancelled = false;
const { wakeLock } = getNavigator();
if (!wakeLock) {
return;
}
/**
* Releases the currently held wake lock
* Called when: shouldHold becomes false, component unmounts, or before acquiring new lock
*/
const releaseLock = async () => {
if (!wakeLockRef.current) {
return;
}
try {
await wakeLockRef.current.release();
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Lock released successfully');
}
} catch (error) {
console.warn('[WakeLock] release failed', error);
} finally {
wakeLockRef.current = null;
}
};
/**
* Requests a new wake lock from the browser
* Checks multiple conditions before requesting to avoid unnecessary API calls:
* - shouldHold must be true (user wants lock)
* - cancelled must be false (effect still active)
* - document must be visible (API requirement - locks only work in visible tabs)
* - no existing lock (prevent duplicate locks)
*/
const requestLock = async () => {
if (
!shouldHold ||
cancelled ||
document.visibilityState !== 'visible' ||
wakeLockRef.current
) {
return;
}
try {
const sentinel = await wakeLock.request('screen');
wakeLockRef.current = sentinel;
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Lock acquired successfully');
}
/**
* CRITICAL: Recursive re-acquire logic for automatic lock restoration
*
* The browser automatically releases wake locks when:
* - User switches to a different tab
* - User minimizes the browser window
* - Device goes to sleep
* - User navigates to a different page
*
* This handler automatically re-acquires the lock when:
* 1. The lock is released by the browser
* 2. The effect is still active (not cancelled)
* 3. The component still wants to hold the lock (shouldHold is true)
* 4. The tab is visible again (document.visibilityState === 'visible')
*
* This ensures users don't need to manually restart their work after
* switching tabs during long-running operations like AI response generation.
*/
const handleRelease = () => {
wakeLockRef.current = null;
sentinel.removeEventListener('release', handleRelease);
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Lock released, checking if re-acquire needed');
}
if (!cancelled && shouldHold && document.visibilityState === 'visible') {
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Re-acquiring lock');
}
void requestLock();
}
};
sentinel.addEventListener('release', handleRelease);
} catch (error) {
console.warn('[WakeLock] request failed', error);
}
};
/**
* Handles browser tab visibility changes
* When user returns to the tab, re-acquire the lock if it's still needed
* This is necessary because wake locks are automatically released when tab becomes hidden
*/
const handleVisibilityChange = () => {
if (cancelled) {
return;
}
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Visibility changed:', document.visibilityState);
}
if (document.visibilityState === 'visible' && shouldHold) {
void requestLock();
}
};
if (shouldHold) {
void requestLock();
document.addEventListener('visibilitychange', handleVisibilityChange);
} else {
void releaseLock();
}
/**
* Cleanup function runs when:
* - Component unmounts
* - shouldHold changes
* - Effect dependencies change
*
* Sets cancelled flag first to prevent any in-flight operations from completing
* Removes event listeners to prevent memory leaks
* Releases any held wake lock
*/
return () => {
cancelled = true;
document.removeEventListener('visibilitychange', handleVisibilityChange);
void releaseLock();
};
}, [shouldHold]);
};
export default useWakeLock;

View File

@@ -493,7 +493,6 @@
"com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.",
"com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface",
"com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.",
"com_nav_keep_screen_awake": "Keep screen awake during response generation",
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Հայերեն",
"com_nav_lang_auto": "Auto detect",
@@ -799,7 +798,6 @@
"com_ui_continue_oauth": "Continue with OAuth",
"com_ui_controls": "Controls",
"com_ui_convo_delete_error": "Failed to delete conversation",
"com_ui_convo_delete_success": "Conversation successfully deleted",
"com_ui_copied": "Copied!",
"com_ui_copied_to_clipboard": "Copied to clipboard",
"com_ui_copy_code": "Copy code",

View File

@@ -75,7 +75,7 @@ export default function Root() {
<div className="relative z-0 flex h-full w-full overflow-hidden">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
<MobileNav navVisible={navVisible} setNavVisible={setNavVisible} />
<MobileNav setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
</div>
</div>

View File

@@ -1,202 +0,0 @@
/* eslint-disable i18next/no-literal-string */
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import useAuthRedirect from '../useAuthRedirect';
import { useAuthContext } from '~/hooks';
// Polyfill Request for React Router in test environment
if (typeof Request === 'undefined') {
global.Request = class Request {
constructor(
public url: string,
public init?: RequestInit,
) {}
} as any;
}
jest.mock('~/hooks', () => ({
useAuthContext: jest.fn(),
}));
/**
* TestComponent that uses the useAuthRedirect hook and exposes its return value
*/
function TestComponent() {
const result = useAuthRedirect();
// Expose result for assertions
(window as any).__testResult = result;
return <div data-testid="test-component">Test Component</div>;
}
/**
* Creates a test router with optional basename to verify navigation works correctly
* with subdirectory deployments (e.g., /librechat)
*/
const createTestRouter = (basename = '/') => {
// When using basename, initialEntries must include the basename
const initialEntry = basename === '/' ? '/' : `${basename}/`;
return createMemoryRouter(
[
{
path: '/',
element: <TestComponent />,
},
{
path: '/login',
element: <div data-testid="login-page">Login Page</div>,
},
],
{
basename,
initialEntries: [initialEntry],
},
);
};
describe('useAuthRedirect', () => {
beforeEach(() => {
(window as any).__testResult = undefined;
});
afterEach(() => {
jest.clearAllMocks();
(window as any).__testResult = undefined;
});
it('should not redirect when user is authenticated', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: { id: '123', email: 'test@example.com' },
isAuthenticated: true,
});
const router = createTestRouter();
const { getByTestId } = render(<RouterProvider router={router} />);
expect(router.state.location.pathname).toBe('/');
expect(getByTestId('test-component')).toBeInTheDocument();
// Wait for the timeout (300ms) plus a buffer
await new Promise((resolve) => setTimeout(resolve, 400));
// Should still be on home page, not redirected
expect(router.state.location.pathname).toBe('/');
expect(getByTestId('test-component')).toBeInTheDocument();
});
it('should redirect to /login when user is not authenticated', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter();
const { getByTestId, queryByTestId } = render(<RouterProvider router={router} />);
expect(router.state.location.pathname).toBe('/');
expect(getByTestId('test-component')).toBeInTheDocument();
// Wait for the redirect to happen (300ms timeout + navigation)
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/login');
expect(getByTestId('login-page')).toBeInTheDocument();
expect(queryByTestId('test-component')).not.toBeInTheDocument();
},
{ timeout: 1000 },
);
// Verify navigation used replace (history has only 1 entry)
// This prevents users from hitting back to return to protected pages
expect(router.state.historyAction).toBe('REPLACE');
});
it('should respect router basename when redirecting (subdirectory deployment)', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
// Test with basename="/librechat" (simulates subdirectory deployment)
const router = createTestRouter('/librechat');
const { getByTestId } = render(<RouterProvider router={router} />);
// Full pathname includes basename
expect(router.state.location.pathname).toBe('/librechat/');
// Wait for the redirect - router handles basename internally
await waitFor(
() => {
// Router state pathname includes the full path with basename
expect(router.state.location.pathname).toBe('/librechat/login');
expect(getByTestId('login-page')).toBeInTheDocument();
},
{ timeout: 1000 },
);
// The key point: navigate('/login', { replace: true }) works correctly with basename
// The router automatically prepends the basename to create the full URL
expect(router.state.historyAction).toBe('REPLACE');
});
it('should use React Router navigate (not window.location) for SPA experience', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter('/librechat');
const { getByTestId } = render(<RouterProvider router={router} />);
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/librechat/login');
expect(getByTestId('login-page')).toBeInTheDocument();
},
{ timeout: 1000 },
);
// The fact that navigation worked within the router proves we're using
// navigate() and not window.location.href (which would cause a full reload
// and break the test entirely). This maintains the SPA experience.
expect(router.state.location.pathname).toBe('/librechat/login');
});
it('should clear timeout on unmount', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter();
const { unmount } = render(<RouterProvider router={router} />);
// Unmount immediately before timeout fires
unmount();
// Wait past the timeout period
await new Promise((resolve) => setTimeout(resolve, 400));
// Should still be at home, not redirected (timeout was cleared)
expect(router.state.location.pathname).toBe('/');
});
it('should return user and isAuthenticated values', async () => {
const mockUser = { id: '123', email: 'test@example.com' };
(useAuthContext as jest.Mock).mockReturnValue({
user: mockUser,
isAuthenticated: true,
});
const router = createTestRouter();
render(<RouterProvider router={router} />);
await waitFor(() => {
const testResult = (window as any).__testResult;
expect(testResult).toBeDefined();
expect(testResult.user).toEqual(mockUser);
expect(testResult.isAuthenticated).toBe(true);
});
});
});

View File

@@ -190,14 +190,6 @@ const isSubmittingFamily = atomFamily({
],
});
const anySubmittingSelector = selector<boolean>({
key: 'anySubmittingSelector',
get: ({ get }) => {
const keys = get(conversationKeysAtom);
return keys.some((key) => get(isSubmittingFamily(key)) === true);
},
});
const optionSettingsFamily = atomFamily<TOptionSettings, string | number>({
key: 'optionSettingsByIndex',
default: {},
@@ -407,7 +399,6 @@ export default {
showPopoverFamily,
latestMessageFamily,
messagesSiblingIdxFamily,
anySubmittingSelector,
allConversationsSelector,
conversationByKeySelector,
useClearConvoState,

View File

@@ -25,7 +25,6 @@ const localStorageAtoms = {
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
true,
),
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
// Chat settings
enterToSend: atomWithLocalStorage('enterToSend', true),

View File

@@ -1487,26 +1487,6 @@ button {
background-color: transparent;
}
/* Show scrollbar only on hover */
.scrollbar-hover {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scrollbar-hover:hover {
scrollbar-color: var(--border-medium) transparent;
}
.scrollbar-hover::-webkit-scrollbar-thumb {
background-color: transparent;
transition: background-color 0.3s ease 0.5s;
}
.scrollbar-hover:hover::-webkit-scrollbar-thumb {
background-color: var(--border-medium);
transition-delay: 0s;
}
body,
html {
height: 100%;

View File

@@ -52,9 +52,6 @@ export const getDefaultAgentFormValues = () => ({
...defaultAgentFormValues,
model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '',
provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''),
avatar_file: null,
avatar_preview: '',
avatar_action: null,
});
export const processAgentOption = ({

View File

@@ -23,32 +23,32 @@ const connect = require('./connect');
console.purple('--------------------------');
}
let email = '';
let password = '';
let name = '';
let username = '';
let emailVerified = true;
// Parse command line arguments
let email, password, name, username, emailVerified, provider;
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i].startsWith('--email-verified=')) {
emailVerified = process.argv[i].split('=')[1].toLowerCase() !== 'false';
continue;
}
if (process.argv[i].startsWith('--provider=')) {
provider = process.argv[i].split('=')[1];
continue;
}
if (email === undefined) {
if (!email) {
email = process.argv[i];
} else if (name === undefined) {
} else if (!name) {
name = process.argv[i];
} else if (username === undefined) {
} else if (!username) {
username = process.argv[i];
} else if (password === undefined) {
} else if (!password) {
console.red('Warning: password passed in as argument, this is not secure!');
password = process.argv[i];
}
}
if (email === undefined) {
if (!email) {
email = await askQuestion('Email:');
}
if (!email.includes('@')) {
@@ -57,19 +57,19 @@ const connect = require('./connect');
}
const defaultName = email.split('@')[0];
if (name === undefined) {
if (!name) {
name = await askQuestion('Name: (default is: ' + defaultName + ')');
if (!name) {
name = defaultName;
}
}
if (username === undefined) {
if (!username) {
username = await askQuestion('Username: (default is: ' + defaultName + ')');
if (!username) {
username = defaultName;
}
}
if (password === undefined) {
if (!password) {
password = await askQuestion('Password: (leave blank, to generate one)');
if (!password) {
password = Math.random().toString(36).slice(-18);
@@ -78,7 +78,7 @@ const connect = require('./connect');
}
// Only prompt for emailVerified if it wasn't set via CLI
if (emailVerified === undefined){
if (!process.argv.some((arg) => arg.startsWith('--email-verified='))) {
const emailVerifiedInput = await askQuestion(`Email verified? (Y/n, default is Y):
If \`y\`, the user's email will be considered verified.
@@ -99,7 +99,7 @@ or the user will need to attempt logging in to have a verification link sent to
silentExit(1);
}
const user = { email, password, name, username, confirm_password: password, provider };
const user = { email, password, name, username, confirm_password: password };
let result;
try {
result = await registerUser(user, { emailVerified });

View File

@@ -1,31 +1,26 @@
#!/usr/bin/env node
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
const path = require('path');
const mongoose = require('mongoose');
const {
Key,
User,
File,
Agent,
Token,
Group,
Action,
Preset,
Prompt,
Balance,
Message,
Session,
AclEntry,
ToolCall,
Assistant,
SharedLink,
PluginAuth,
MemoryEntry,
PromptGroup,
Balance,
Transaction,
Conversation,
ConversationTag,
Conversation,
Message,
File,
Key,
MemoryEntry,
PluginAuth,
Prompt,
PromptGroup,
Preset,
Session,
SharedLink,
ToolCall,
Token,
} = require('@librechat/data-schemas').createModels(mongoose);
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { askQuestion, silentExit } = require('./helpers');
@@ -77,7 +72,6 @@ async function gracefulExit(code = 0) {
// 5) Build and run deletion tasks
const tasks = [
Action.deleteMany({ user: uid }),
Agent.deleteMany({ author: uid }),
Assistant.deleteMany({ user: uid }),
Balance.deleteMany({ user: uid }),
@@ -95,7 +89,6 @@ async function gracefulExit(code = 0) {
SharedLink.deleteMany({ user: uid }),
ToolCall.deleteMany({ user: uid }),
Token.deleteMany({ userId: uid }),
AclEntry.deleteMany({ principalId: user._id }),
];
if (deleteTx) {
@@ -104,10 +97,7 @@ async function gracefulExit(code = 0) {
await Promise.all(tasks);
// 6) Remove user from all groups
await Group.updateMany({ memberIds: user._id }, { $pull: { memberIds: user._id } });
// 7) Finally delete the user document itself
// 6) Finally delete the user document itself
await User.deleteOne({ _id: uid });
console.green(`✔ Successfully deleted user ${email} and all associated data.`);

View File

@@ -158,22 +158,12 @@ async function flushRedisCache(dryRun = false, verbose = false) {
if (dryRun) {
console.log('🔍 [DRY RUN] Would flush Redis cache');
try {
let allKeys = [];
if (useCluster) {
const nodes = redis.nodes('master');
console.log(` Cluster detected: ${nodes.length} master nodes`);
for (const node of nodes) {
const keys = await node.keys('*');
allKeys = allKeys.concat(keys);
}
} else {
allKeys = await redis.keys('*');
}
console.log(` Would delete ${allKeys.length} keys`);
if (verbose && allKeys.length > 0) {
const keys = await redis.keys('*');
console.log(` Would delete ${keys.length} keys`);
if (verbose && keys.length > 0) {
console.log(
' Sample keys:',
allKeys.slice(0, 10).join(', ') + (allKeys.length > 10 ? '...' : ''),
keys.slice(0, 10).join(', ') + (keys.length > 10 ? '...' : ''),
);
}
} catch (error) {
@@ -186,29 +176,15 @@ async function flushRedisCache(dryRun = false, verbose = false) {
// Get key count before flushing
let keyCount = 0;
try {
if (useCluster) {
const nodes = redis.nodes('master');
for (const node of nodes) {
const keys = await node.keys('*');
keyCount += keys.length;
}
} else {
const keys = await redis.keys('*');
keyCount = keys.length;
}
const keys = await redis.keys('*');
keyCount = keys.length;
} catch (_error) {
// Continue with flush even if we can't count keys
}
// Flush the Redis cache
if (useCluster) {
const nodes = redis.nodes('master');
await Promise.all(nodes.map((node) => node.flushdb()));
console.log(`✅ Redis cluster cache flushed successfully (${nodes.length} master nodes)`);
} else {
await redis.flushdb();
console.log('✅ Redis cache flushed successfully');
}
await redis.flushdb();
console.log('✅ Redis cache flushed successfully');
if (keyCount > 0) {
console.log(` Deleted ${keyCount} keys`);

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