Compare commits
1 Commits
v0.8.1-rc2
...
fix/file-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb27dbaff |
@@ -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
|
||||
|
||||
16
.github/workflows/eslint-ci.yml
vendored
16
.github/workflows/eslint-ci.yml
vendored
@@ -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
31
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.1-rc2
|
||||
# v0.8.1-rc1
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
intermediateSteps.push({ observation: imageMarkdown });
|
||||
@@ -139,108 +139,4 @@ describe('addImages', () => {
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
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: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should not prepend base path when image URL already has base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should correct erroneous URLs with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
responseMessage.text = '';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty base path (root deployment)', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle missing DOMAIN_CLIENT', () => {
|
||||
delete process.env.DOMAIN_CLIENT;
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle observation without image path match', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle nested subdirectories in base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle multiple observations with mixed base path scenarios', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex markdown with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const complexMarkdown = `
|
||||
# Document Title
|
||||

|
||||
Some text between images
|
||||

|
||||
`;
|
||||
intermediateSteps.push({ observation: complexMarkdown });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle URLs that are already absolute', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle data URLs', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({
|
||||
observation:
|
||||
'',
|
||||
});
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: }/images/id.png)
|
||||
// - ALWAYS include the markdown url in your final response to show the user: 
|
||||
// - 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]: {
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '"');
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,9 +80,7 @@ const fetchModels = async ({
|
||||
|
||||
try {
|
||||
const options = {
|
||||
headers: {
|
||||
...(headers ?? {}),
|
||||
},
|
||||
headers: {},
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 -->');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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 || {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -25,7 +25,6 @@ const localStorageAtoms = {
|
||||
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
|
||||
true,
|
||||
),
|
||||
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
|
||||
|
||||
// Chat settings
|
||||
enterToSend: atomWithLocalStorage('enterToSend', true),
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user