Compare commits
39 Commits
v0.8.1-rc2
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f548bec17 | ||
|
|
4f4e0937f7 | ||
|
|
7958401979 | ||
|
|
ab706ecf70 | ||
|
|
f490f1a87f | ||
|
|
ff67edc75c | ||
|
|
3cb21de1df | ||
|
|
342656156a | ||
|
|
1968cf55eb | ||
|
|
334b5f8853 | ||
|
|
c0d371a24c | ||
|
|
c5a0bc6298 | ||
|
|
1b2006af12 | ||
|
|
ccb378c903 | ||
|
|
e7b209ee09 | ||
|
|
7eff895121 | ||
|
|
2872058dcf | ||
|
|
caadc4e85d | ||
|
|
9a4e657fcd | ||
|
|
d2299b86ec | ||
|
|
cd85162076 | ||
|
|
ccad6db7c5 | ||
|
|
ee91891e20 | ||
|
|
0ebe96f47e | ||
|
|
2f532ea8d3 | ||
|
|
1c612ba364 | ||
|
|
df16406401 | ||
|
|
2a9295ba0c | ||
|
|
6e47b8800f | ||
|
|
0396dd7e78 | ||
|
|
7a5996871c | ||
|
|
ee00dcdb60 | ||
|
|
507bfb5989 | ||
|
|
c2e0ed8ad6 | ||
|
|
61daedc9df | ||
|
|
87f31c1dbd | ||
|
|
ab74ce262e | ||
|
|
0cd45d24fc | ||
|
|
e32bd14c89 |
@@ -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
|
||||
|
||||
21
.github/workflows/cache-integration-tests.yml
vendored
21
.github/workflows/cache-integration-tests.yml
vendored
@@ -61,23 +61,30 @@ jobs:
|
||||
npm run build:data-schemas
|
||||
npm run build:api
|
||||
|
||||
- name: Run all cache integration tests (Single Redis Node)
|
||||
- name: Run cache integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
USE_REDIS_CLUSTER: false
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration
|
||||
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration:core
|
||||
|
||||
- name: Run all cache integration tests (Redis Cluster)
|
||||
- name: Run cluster integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
USE_REDIS_CLUSTER: true
|
||||
REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:cluster
|
||||
|
||||
- name: Run mcp integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:mcp
|
||||
|
||||
- name: Stop Redis Cluster
|
||||
if: always()
|
||||
|
||||
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
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
|
||||
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
|
||||
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
|
||||
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
|
||||
- OpenRouter, Perplexity, ShuttleAI, Deepseek, Qwen, and more
|
||||
|
||||
- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
|
||||
- Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran
|
||||
|
||||
@@ -305,9 +305,11 @@ class AnthropicClient extends BaseClient {
|
||||
}
|
||||
|
||||
async addImageURLs(message, attachments) {
|
||||
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
});
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
EModelEndpoint.anthropic,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -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,7 @@ 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,
|
||||
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
@@ -1230,10 +1228,7 @@ class BaseClient {
|
||||
const videoResult = await encodeAndFormatVideos(
|
||||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||
},
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.videos =
|
||||
@@ -1245,10 +1240,7 @@ class BaseClient {
|
||||
const audioResult = await encodeAndFormatAudios(
|
||||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||
},
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.audios =
|
||||
|
||||
@@ -305,9 +305,7 @@ class GoogleClient extends BaseClient {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
endpoint: EModelEndpoint.google,
|
||||
},
|
||||
EModelEndpoint.google,
|
||||
mode,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
|
||||
@@ -354,9 +354,11 @@ class OpenAIClient extends BaseClient {
|
||||
* @returns {Promise<MongoFile[]>}
|
||||
*/
|
||||
async addImageURLs(message, attachments) {
|
||||
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
|
||||
endpoint: this.options.endpoint,
|
||||
});
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.endpoint,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,14 +65,14 @@ function buildPromptPrefix({ result, message, functionsAgent }) {
|
||||
const preliminaryAnswer =
|
||||
result.output?.length > 0 ? `Preliminary Answer: "${result.output.trim()}"` : '';
|
||||
const prefix = preliminaryAnswer
|
||||
? 'review and improve the answer you generated using plugins in response to the User Message below. The user hasn\'t seen your answer or thoughts yet.'
|
||||
? "review and improve the answer you generated using plugins in response to the User Message below. The user hasn't seen your answer or thoughts yet."
|
||||
: 'respond to the User Message below based on your preliminary thoughts & actions.';
|
||||
|
||||
return `As a helpful AI Assistant, ${prefix}${errorMessage}\n${internalActions}
|
||||
${preliminaryAnswer}
|
||||
Reply conversationally to the User based on your ${
|
||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
||||
${
|
||||
preliminaryAnswer
|
||||
? ''
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module.exports = {
|
||||
instructions:
|
||||
'Remember, all your responses MUST be in the format described. Do not respond unless it\'s in the format described, using the structure of Action, Action Input, etc.',
|
||||
"Remember, all your responses MUST be in the format described. Do not respond unless it's in the format described, using the structure of Action, Action Input, etc.",
|
||||
errorInstructions:
|
||||
'\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn\'t mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:',
|
||||
"\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn't mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:",
|
||||
imageInstructions:
|
||||
'You must include the exact image paths from above, formatted in Markdown syntax: ',
|
||||
completionInstructions:
|
||||
|
||||
@@ -18,17 +18,17 @@ function generateShadcnPrompt(options) {
|
||||
Here are the components that are available, along with how to import them, and how to use them:
|
||||
|
||||
${Object.values(components)
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
<component>
|
||||
<name>${component.componentName}</name>
|
||||
<import-instructions>${component.importDocs}</import-instructions>
|
||||
<usage-instructions>${component.usageDocs}</usage-instructions>
|
||||
</component>
|
||||
`;
|
||||
} else {
|
||||
return dedent`
|
||||
} else {
|
||||
return dedent`
|
||||
# ${component.componentName}
|
||||
|
||||
## Import Instructions
|
||||
@@ -37,9 +37,9 @@ function generateShadcnPrompt(options) {
|
||||
## Usage Instructions
|
||||
${component.usageDocs}
|
||||
`;
|
||||
}
|
||||
})
|
||||
.join('\n\n')}
|
||||
}
|
||||
})
|
||||
.join('\n\n')}
|
||||
`;
|
||||
|
||||
return systemPrompt;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -232,7 +232,7 @@ class OpenWeather extends Tool {
|
||||
|
||||
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
|
||||
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
|
||||
return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').';
|
||||
return "Error: lat and lon are required and must be numbers for this action (or specify 'city').";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ class OpenWeather extends Tool {
|
||||
let dt;
|
||||
if (action === 'timestamp') {
|
||||
if (!date) {
|
||||
return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.';
|
||||
return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required.";
|
||||
}
|
||||
dt = this.convertDateToUnix(date);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,6 @@ const traverse = require('traverse');
|
||||
const SPLAT_SYMBOL = Symbol.for('splat');
|
||||
const MESSAGE_SYMBOL = Symbol.for('message');
|
||||
const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255;
|
||||
const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150;
|
||||
|
||||
const sensitiveKeys = [
|
||||
/^(sk-)[^\s]+/, // OpenAI API key pattern
|
||||
@@ -119,7 +118,7 @@ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...met
|
||||
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
|
||||
}
|
||||
|
||||
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`;
|
||||
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`;
|
||||
try {
|
||||
if (level !== 'debug') {
|
||||
return msg;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
|
||||
return await Conversation.findOne({ user, conversationId }).lean();
|
||||
} catch (error) {
|
||||
logger.error('[getConvo] Error getting single conversation', error);
|
||||
return { message: 'Error getting single conversation' };
|
||||
throw new Error('Error getting single conversation');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,13 +151,21 @@ module.exports = {
|
||||
const result = await Conversation.bulkWrite(bulkOps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[saveBulkConversations] Error saving conversations in bulk', error);
|
||||
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||
throw new Error('Failed to save conversations in bulk.');
|
||||
}
|
||||
},
|
||||
getConvosByCursor: async (
|
||||
user,
|
||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||
{
|
||||
cursor,
|
||||
limit = 25,
|
||||
isArchived = false,
|
||||
tags,
|
||||
search,
|
||||
sortBy = 'createdAt',
|
||||
sortDirection = 'desc',
|
||||
} = {},
|
||||
) => {
|
||||
const filters = [{ user }];
|
||||
if (isArchived) {
|
||||
@@ -184,35 +192,77 @@ module.exports = {
|
||||
filters.push({ conversationId: { $in: matchingIds } });
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||
return { message: 'Error during meiliSearch' };
|
||||
throw new Error('Error during meiliSearch');
|
||||
}
|
||||
}
|
||||
|
||||
const validSortFields = ['title', 'createdAt', 'updatedAt'];
|
||||
if (!validSortFields.includes(sortBy)) {
|
||||
throw new Error(
|
||||
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const finalSortBy = sortBy;
|
||||
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
let cursorFilter = null;
|
||||
if (cursor) {
|
||||
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
const { primary, secondary } = decoded;
|
||||
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||
const secondaryValue = new Date(secondary);
|
||||
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
|
||||
|
||||
cursorFilter = {
|
||||
$or: [
|
||||
{ [finalSortBy]: { [op]: primaryValue } },
|
||||
{
|
||||
[finalSortBy]: primaryValue,
|
||||
updatedAt: { [op]: secondaryValue },
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||
}
|
||||
if (cursorFilter) {
|
||||
filters.push(cursorFilter);
|
||||
}
|
||||
}
|
||||
|
||||
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||
|
||||
try {
|
||||
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
|
||||
const sortObj = { [finalSortBy]: sortOrder };
|
||||
|
||||
if (finalSortBy !== 'updatedAt') {
|
||||
sortObj.updatedAt = sortOrder;
|
||||
}
|
||||
|
||||
const convos = await Conversation.find(query)
|
||||
.select(
|
||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||
)
|
||||
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
||||
.sort(sortObj)
|
||||
.limit(limit + 1)
|
||||
.lean();
|
||||
|
||||
let nextCursor = null;
|
||||
if (convos.length > limit) {
|
||||
const lastConvo = convos.pop();
|
||||
nextCursor = lastConvo.updatedAt.toISOString();
|
||||
const primaryValue = lastConvo[finalSortBy];
|
||||
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
|
||||
const secondaryStr = lastConvo.updatedAt.toISOString();
|
||||
const composite = { primary: primaryStr, secondary: secondaryStr };
|
||||
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||
}
|
||||
|
||||
return { conversations: convos, nextCursor };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||
return { message: 'Error getting conversations' };
|
||||
throw new Error('Error getting conversations');
|
||||
}
|
||||
},
|
||||
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||
@@ -252,7 +302,7 @@ module.exports = {
|
||||
return { conversations: limited, nextCursor, convoMap };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||
return { message: 'Error fetching conversations' };
|
||||
throw new Error('Error fetching conversations');
|
||||
}
|
||||
},
|
||||
getConvo,
|
||||
@@ -269,7 +319,7 @@ module.exports = {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||
return { message: 'Error getting conversation title' };
|
||||
throw new Error('Error getting conversation title');
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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'",
|
||||
@@ -43,15 +43,15 @@
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@googleapis/youtube": "^20.0.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.79",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@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.5",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.12.1",
|
||||
@@ -76,7 +76,7 @@
|
||||
"handlebars": "^4.7.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"keyv": "^5.3.2",
|
||||
@@ -117,7 +117,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"mongodb-memory-server": "^10.1.4",
|
||||
"nodemon": "^3.0.3",
|
||||
"supertest": "^7.1.0"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -379,8 +376,6 @@ function disposeClient(client) {
|
||||
client.options = null;
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
} finally {
|
||||
logger.debug('[disposeClient] Client disposed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { nanoid } = require('nanoid');
|
||||
const { sendEvent } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
|
||||
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
|
||||
const {
|
||||
EnvVar,
|
||||
Providers,
|
||||
@@ -27,64 +27,31 @@ class ModelEndHandler {
|
||||
this.collectedUsage = collectedUsage;
|
||||
}
|
||||
|
||||
finalize(errorMessage) {
|
||||
if (!errorMessage) {
|
||||
return;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} event
|
||||
* @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;
|
||||
}
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let errorMessage;
|
||||
try {
|
||||
const agentContext = graph.getAgentContext(metadata);
|
||||
const isGoogle = agentContext.provider === Providers.GOOGLE;
|
||||
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
|
||||
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
|
||||
const info = { ...data.output.additional_kwargs };
|
||||
errorMessage = JSON.stringify({
|
||||
type: ErrorTypes.REFUSAL,
|
||||
info,
|
||||
});
|
||||
logger.debug(`[ModelEndHandler] Model refused to respond`, {
|
||||
...info,
|
||||
userId: metadata.user_id,
|
||||
messageId: metadata.run_id,
|
||||
conversationId: metadata.thread_id,
|
||||
});
|
||||
}
|
||||
|
||||
const toolCalls = data?.output?.tool_calls;
|
||||
let hasUnprocessedToolCalls = false;
|
||||
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
|
||||
try {
|
||||
hasUnprocessedToolCalls = toolCalls.some(
|
||||
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
|
||||
);
|
||||
} catch {
|
||||
hasUnprocessedToolCalls = false;
|
||||
}
|
||||
}
|
||||
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
|
||||
await handleToolCalls(toolCalls, metadata, graph);
|
||||
if (
|
||||
agentContext.provider === Providers.GOOGLE ||
|
||||
agentContext.clientOptions?.disableStreaming
|
||||
) {
|
||||
handleToolCalls(data?.output?.tool_calls, metadata, graph);
|
||||
}
|
||||
|
||||
const usage = data?.output?.usage_metadata;
|
||||
if (!usage) {
|
||||
return this.finalize(errorMessage);
|
||||
return;
|
||||
}
|
||||
const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
|
||||
if (modelName) {
|
||||
@@ -92,16 +59,17 @@ class ModelEndHandler {
|
||||
}
|
||||
|
||||
this.collectedUsage.push(usage);
|
||||
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
|
||||
if (!streamingDisabled) {
|
||||
return this.finalize(errorMessage);
|
||||
return;
|
||||
}
|
||||
if (!data.output.content) {
|
||||
return this.finalize(errorMessage);
|
||||
return;
|
||||
}
|
||||
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 +79,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,13 +88,12 @@ class ModelEndHandler {
|
||||
],
|
||||
});
|
||||
} else if (content.every((c) => c.type?.startsWith('text'))) {
|
||||
await graph.dispatchMessageDelta(stepId, {
|
||||
graph.dispatchMessageDelta(stepId, {
|
||||
content,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling model end event:', error);
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +129,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,19 +9,16 @@ const {
|
||||
logAxiosError,
|
||||
sanitizeTitle,
|
||||
resolveHeaders,
|
||||
createSafeUser,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
getTransactionsConfig,
|
||||
createMemoryProcessor,
|
||||
filterMalformedContentParts,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Callback,
|
||||
Providers,
|
||||
TitleMethod,
|
||||
formatMessage,
|
||||
labelContentByAgent,
|
||||
formatAgentMessages,
|
||||
getTokenCountForMessage,
|
||||
createMetadataAggregator,
|
||||
@@ -94,61 +91,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 +140,6 @@ class AgentClient extends BaseClient {
|
||||
this.indexTokenCountMap = {};
|
||||
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
||||
this.processMemory;
|
||||
/** @type {Record<number, string> | null} */
|
||||
this.agentIdMap = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,10 +210,7 @@ class AgentClient extends BaseClient {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent.provider,
|
||||
endpoint: this.options.endpoint,
|
||||
},
|
||||
this.options.agent.provider,
|
||||
VisionModes.agents,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
@@ -292,12 +229,6 @@ class AgentClient extends BaseClient {
|
||||
summary: this.shouldSummarize,
|
||||
});
|
||||
|
||||
orderedMessages = applyAgentLabelsToHistory(
|
||||
orderedMessages,
|
||||
this.options.agent,
|
||||
this.agentConfigs,
|
||||
);
|
||||
|
||||
let payload;
|
||||
/** @type {number | undefined} */
|
||||
let promptTokens;
|
||||
@@ -410,7 +341,7 @@ class AgentClient extends BaseClient {
|
||||
|
||||
if (mcpServers.length > 0) {
|
||||
try {
|
||||
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
|
||||
const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers);
|
||||
if (mcpInstructions) {
|
||||
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
|
||||
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
|
||||
@@ -677,11 +608,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 this.contentParts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,14 +761,12 @@ class AgentClient extends BaseClient {
|
||||
let run;
|
||||
/** @type {Promise<(TAttachment | null)[] | undefined>} */
|
||||
let memoryPromise;
|
||||
const appConfig = this.options.req.config;
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
try {
|
||||
if (!abortController) {
|
||||
abortController = new AbortController();
|
||||
}
|
||||
|
||||
const appConfig = this.options.req.config;
|
||||
/** @type {AppConfig['endpoints']['agents']} */
|
||||
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
|
||||
|
||||
@@ -857,7 +782,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 +858,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()),
|
||||
});
|
||||
|
||||
@@ -974,23 +898,29 @@ 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,
|
||||
});
|
||||
}
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AgentClient] Error capturing agent ID map:', error);
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||
err,
|
||||
@@ -1005,27 +935,6 @@ class AgentClient extends BaseClient {
|
||||
[ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
|
||||
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,
|
||||
|
||||
@@ -14,14 +14,6 @@ jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
}));
|
||||
|
||||
// Mock getMCPManager
|
||||
const mockFormatInstructions = jest.fn();
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(() => ({
|
||||
formatInstructionsForContext: mockFormatInstructions,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
@@ -989,7 +981,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic that handles GPT-5+ models
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1009,7 +1001,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
const paramName =
|
||||
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
@@ -1034,7 +1026,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1055,7 +1047,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1068,9 +1060,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
|
||||
it('should handle various GPT-5+ model formats', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5.1', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-codex', shouldTransform: true },
|
||||
{ model: 'gpt-5', shouldTransform: true },
|
||||
{ model: 'gpt-5-turbo', shouldTransform: true },
|
||||
{ model: 'gpt-6', shouldTransform: true },
|
||||
@@ -1090,10 +1079,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (
|
||||
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
|
||||
clientOptions.maxTokens != null
|
||||
) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1111,9 +1097,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
|
||||
it('should not swap max token param for older models when using useResponsesApi', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5.1', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-codex', shouldTransform: true },
|
||||
{ model: 'gpt-5', shouldTransform: true },
|
||||
{ model: 'gpt-5-turbo', shouldTransform: true },
|
||||
{ model: 'gpt-6', shouldTransform: true },
|
||||
@@ -1133,10 +1116,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
if (
|
||||
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
|
||||
clientOptions.maxTokens != null
|
||||
) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
const paramName =
|
||||
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
@@ -1169,10 +1149,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (
|
||||
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
|
||||
clientOptions.maxTokens != null
|
||||
) {
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1191,200 +1168,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessages with MCP server instructions', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
let mockAgent;
|
||||
let mockOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset the mock to default behavior
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# MCP Server Instructions\n\nTest MCP instructions here',
|
||||
);
|
||||
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
|
||||
// Create mock MCP tools with the delimiter pattern
|
||||
const mockMCPTool1 = new DynamicStructuredTool({
|
||||
name: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Test MCP tool 1',
|
||||
schema: {},
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const mockMCPTool2 = new DynamicStructuredTool({
|
||||
name: `tool2${Constants.mcp_delimiter}server2`,
|
||||
description: 'Test MCP tool 2',
|
||||
schema: {},
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
mockAgent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
instructions: 'Base agent instructions',
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
tools: [mockMCPTool1, mockMCPTool2],
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
},
|
||||
body: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
|
||||
mockRes = {};
|
||||
|
||||
mockOptions = {
|
||||
req: mockReq,
|
||||
res: mockRes,
|
||||
agent: mockAgent,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
};
|
||||
|
||||
client = new AgentClient(mockOptions);
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.shouldSummarize = false;
|
||||
client.maxContextTokens = 4096;
|
||||
});
|
||||
|
||||
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
|
||||
// Set specific return value for this test
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# MCP Server Instructions\n\nUse these tools carefully',
|
||||
);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify formatInstructionsForContext was called with correct server names
|
||||
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
|
||||
|
||||
// Verify the instructions do NOT contain [object Promise]
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
|
||||
// Verify the instructions DO contain the MCP instructions
|
||||
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
|
||||
expect(client.options.agent.instructions).toContain('Use these tools carefully');
|
||||
|
||||
// Verify the base instructions are also included
|
||||
expect(client.options.agent.instructions).toContain('Base instructions');
|
||||
});
|
||||
|
||||
it('should handle MCP instructions with ephemeral agent', async () => {
|
||||
// Set specific return value for this test
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
|
||||
);
|
||||
|
||||
// Set up ephemeral agent with MCP servers
|
||||
mockReq.body.ephemeralAgent = {
|
||||
mcp: ['ephemeral-server1', 'ephemeral-server2'],
|
||||
};
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Test ephemeral',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Ephemeral instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify formatInstructionsForContext was called with ephemeral server names
|
||||
expect(mockFormatInstructions).toHaveBeenCalledWith([
|
||||
'ephemeral-server1',
|
||||
'ephemeral-server2',
|
||||
]);
|
||||
|
||||
// Verify no [object Promise] in instructions
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
|
||||
// Verify ephemeral MCP instructions are included
|
||||
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
|
||||
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
|
||||
});
|
||||
|
||||
it('should handle empty MCP instructions gracefully', async () => {
|
||||
// Set empty return value for this test
|
||||
mockFormatInstructions.mockResolvedValue('');
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions only',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify the instructions still work without MCP content
|
||||
expect(client.options.agent.instructions).toBe('Base instructions only');
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
});
|
||||
|
||||
it('should handle MCP instructions error gracefully', async () => {
|
||||
// Set error return for this test
|
||||
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Should still have base instructions without MCP content
|
||||
expect(client.options.agent.instructions).toContain('Base instructions');
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMemory method', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -185,8 +185,8 @@ process.on('uncaughtException', (err) => {
|
||||
logger.error('There was an uncaught error:', err);
|
||||
}
|
||||
|
||||
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
|
||||
logger.warn('There was an uncatchable abort error.');
|
||||
if (err.message.includes('abort')) {
|
||||
logger.warn('There was an uncatchable AbortController error.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -43,7 +43,6 @@ afterEach(() => {
|
||||
|
||||
//TODO: This works/passes locally but http request tests fail with 404 in CI. Need to figure out why.
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('GET /', () => {
|
||||
it('should return 200 and the correct body', async () => {
|
||||
process.env.APP_TITLE = 'Test Title';
|
||||
|
||||
@@ -290,7 +290,6 @@ describe('MCP Routes', () => {
|
||||
it('should handle OAuth callback successfully', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -383,7 +382,6 @@ describe('MCP Routes', () => {
|
||||
it('should handle system-level OAuth completion', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -419,7 +417,6 @@ describe('MCP Routes', () => {
|
||||
it('should handle reconnection failure after OAuth', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -501,108 +498,6 @@ describe('MCP Routes', () => {
|
||||
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
|
||||
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use original flow state credentials when storing tokens', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const clientInfo = {
|
||||
client_id: 'client123',
|
||||
client_secret: 'client_secret',
|
||||
};
|
||||
const flowState = {
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
|
||||
clientInfo: clientInfo,
|
||||
codeVerifier: 'test-verifier',
|
||||
status: 'PENDING',
|
||||
};
|
||||
const mockTokens = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
};
|
||||
|
||||
// First call checks idempotency (status PENDING = not completed)
|
||||
// Second call retrieves flow state for processing
|
||||
mockFlowManager.getFlowState
|
||||
.mockResolvedValueOnce({ status: 'PENDING' })
|
||||
.mockResolvedValueOnce(flowState);
|
||||
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
|
||||
clearReconnection: jest.fn(),
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
||||
|
||||
// Verify storeTokens was called with ORIGINAL flow state credentials
|
||||
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'test-user-id',
|
||||
serverName: 'test-server',
|
||||
tokens: mockTokens,
|
||||
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
|
||||
metadata: flowState.metadata,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent duplicate token exchange with idempotency check', async () => {
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
};
|
||||
|
||||
// Flow is already completed
|
||||
mockFlowManager.getFlowState.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
});
|
||||
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
});
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
||||
|
||||
// Verify completeOAuthFlow was NOT called (prevented duplicate)
|
||||
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
|
||||
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /oauth/tokens/:flowId', () => {
|
||||
@@ -1347,9 +1242,7 @@ describe('MCP Routes', () => {
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ const {
|
||||
PermissionTypes,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
validateActionDomain,
|
||||
validateAndParseOpenAPISpec,
|
||||
} = require('librechat-data-provider');
|
||||
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
|
||||
const { findAccessibleResources } = require('~/server/services/PermissionService');
|
||||
@@ -85,32 +83,6 @@ router.post(
|
||||
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const appConfig = req.config;
|
||||
|
||||
// SECURITY: Validate the OpenAPI spec and extract the server URL
|
||||
if (metadata.raw_spec) {
|
||||
const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
|
||||
if (!validationResult.status || !validationResult.serverUrl) {
|
||||
return res.status(400).json({
|
||||
message: validationResult.message || 'Invalid OpenAPI specification',
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Validate the client-provided domain matches the spec's server URL domain
|
||||
// This prevents SSRF attacks where an attacker provides a whitelisted domain
|
||||
// but uses a different (potentially internal) URL in the raw_spec
|
||||
const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
|
||||
userId: req.user.id,
|
||||
agent_id,
|
||||
});
|
||||
return res.status(400).json({
|
||||
message:
|
||||
'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isDomainAllowed = await isActionDomainAllowed(
|
||||
metadata.domain,
|
||||
appConfig?.actions?.allowedDomains,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -31,7 +31,8 @@ router.get('/', async (req, res) => {
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
||||
const order = req.query.order || 'desc';
|
||||
const sortBy = req.query.sortBy || 'createdAt';
|
||||
const sortDirection = req.query.sortDirection || 'desc';
|
||||
|
||||
let tags;
|
||||
if (req.query.tags) {
|
||||
@@ -45,7 +46,8 @@ router.get('/', async (req, res) => {
|
||||
isArchived,
|
||||
tags,
|
||||
search,
|
||||
order,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,8 +10,8 @@ const {
|
||||
ResourceType,
|
||||
EModelEndpoint,
|
||||
PermissionBits,
|
||||
isAgentsEndpoint,
|
||||
checkOpenAIStorage,
|
||||
isAssistantsEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
filterFile,
|
||||
@@ -376,11 +376,11 @@ router.post('/', async (req, res) => {
|
||||
metadata.temp_file_id = metadata.file_id;
|
||||
metadata.file_id = req.file_id;
|
||||
|
||||
if (isAssistantsEndpoint(metadata.endpoint)) {
|
||||
return await processFileUpload({ req, res, metadata });
|
||||
if (isAgentsEndpoint(metadata.endpoint)) {
|
||||
return await processAgentFileUpload({ req, res, metadata });
|
||||
}
|
||||
|
||||
return await processAgentFileUpload({ req, res, metadata });
|
||||
await processFileUpload({ req, res, metadata });
|
||||
} catch (error) {
|
||||
let message = 'Error processing file';
|
||||
logger.error('[/files] Error processing file:', error);
|
||||
|
||||
@@ -3,11 +3,7 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { sanitizeFilename } = require('@librechat/api');
|
||||
const {
|
||||
mergeFileConfig,
|
||||
getEndpointFileConfig,
|
||||
fileConfig: defaultFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
@@ -57,14 +53,12 @@ const createFileFilter = (customFileConfig) => {
|
||||
}
|
||||
|
||||
const endpoint = req.body.endpoint;
|
||||
const endpointType = req.body.endpointType;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig: customFileConfig,
|
||||
endpoint,
|
||||
endpointType,
|
||||
});
|
||||
const supportedTypes =
|
||||
customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ??
|
||||
customFileConfig?.endpoints?.default.supportedMimeTypes ??
|
||||
defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes;
|
||||
|
||||
if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
|
||||
if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) {
|
||||
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,16 +134,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
hasCodeVerifier: !!flowState.codeVerifier,
|
||||
});
|
||||
|
||||
/** Check if this flow has already been completed (idempotency protection) */
|
||||
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||
if (currentFlowState?.status === 'COMPLETED') {
|
||||
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
|
||||
flowId,
|
||||
serverName,
|
||||
});
|
||||
return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const express = require('express');
|
||||
const { unescapeLaTeX } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
@@ -135,32 +134,17 @@ router.post('/artifact/:messageId', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Artifact index out of bounds' });
|
||||
}
|
||||
|
||||
// Unescape LaTeX preprocessing done by the frontend
|
||||
// The frontend escapes $ signs for display, but the database has unescaped versions
|
||||
const unescapedOriginal = unescapeLaTeX(original);
|
||||
const unescapedUpdated = unescapeLaTeX(updated);
|
||||
|
||||
const targetArtifact = artifacts[index];
|
||||
let updatedText = null;
|
||||
|
||||
if (targetArtifact.source === 'content') {
|
||||
const part = message.content[targetArtifact.partIndex];
|
||||
updatedText = replaceArtifactContent(
|
||||
part.text,
|
||||
targetArtifact,
|
||||
unescapedOriginal,
|
||||
unescapedUpdated,
|
||||
);
|
||||
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
|
||||
if (updatedText) {
|
||||
part.text = updatedText;
|
||||
}
|
||||
} else {
|
||||
updatedText = replaceArtifactContent(
|
||||
message.text,
|
||||
targetArtifact,
|
||||
unescapedOriginal,
|
||||
unescapedUpdated,
|
||||
);
|
||||
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
|
||||
if (updatedText) {
|
||||
message.text = updatedText;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -109,7 +109,7 @@ async function getEndpointsConfig(req) {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const checkCapability = async (req, capability) => {
|
||||
const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint);
|
||||
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
const capabilities =
|
||||
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
const { isUserProvided } = require('@librechat/api');
|
||||
const {
|
||||
EModelEndpoint,
|
||||
extractEnvVariable,
|
||||
normalizeEndpointName,
|
||||
} = require('librechat-data-provider');
|
||||
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
|
||||
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
|
||||
const { fetchModels } = require('~/server/services/ModelService');
|
||||
const { getAppConfig } = require('./app');
|
||||
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -3,14 +3,12 @@ const {
|
||||
primeResources,
|
||||
getModelMaxTokens,
|
||||
extractLibreChatParams,
|
||||
filterFilesByEndpointConfig,
|
||||
optionalChainWithEmptyCheck,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
ErrorTypes,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
paramEndpoints,
|
||||
isAgentsEndpoint,
|
||||
replaceSpecialVars,
|
||||
providerEndpointMap,
|
||||
@@ -73,9 +71,6 @@ const initializeAgent = async ({
|
||||
|
||||
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
|
||||
|
||||
const provider = agent.provider;
|
||||
agent.endpoint = provider;
|
||||
|
||||
if (isInitialAgent && conversationId != null && resendFiles) {
|
||||
const fileIds = (await getConvoFiles(conversationId)) ?? [];
|
||||
/** @type {Set<EToolResources>} */
|
||||
@@ -93,19 +88,6 @@ const initializeAgent = async ({
|
||||
currentFiles = await processFiles(requestFiles);
|
||||
}
|
||||
|
||||
if (currentFiles && currentFiles.length) {
|
||||
let endpointType;
|
||||
if (!paramEndpoints.has(agent.endpoint)) {
|
||||
endpointType = EModelEndpoint.custom;
|
||||
}
|
||||
|
||||
currentFiles = filterFilesByEndpointConfig(req, {
|
||||
files: currentFiles,
|
||||
endpoint: agent.endpoint,
|
||||
endpointType,
|
||||
});
|
||||
}
|
||||
|
||||
const { attachments, tool_resources } = await primeResources({
|
||||
req,
|
||||
getFiles,
|
||||
@@ -116,6 +98,7 @@ const initializeAgent = async ({
|
||||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||
});
|
||||
|
||||
const provider = agent.provider;
|
||||
const {
|
||||
tools: structuredTools,
|
||||
toolContextMap,
|
||||
@@ -130,6 +113,7 @@ const initializeAgent = async ({
|
||||
tool_resources,
|
||||
})) ?? {};
|
||||
|
||||
agent.endpoint = provider;
|
||||
const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
|
||||
if (overrideProvider !== agent.provider) {
|
||||
agent.provider = overrideProvider;
|
||||
|
||||
@@ -3,7 +3,6 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
|
||||
const buildOptions = async (endpoint, parsedBody) => {
|
||||
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
|
||||
parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
const axios = require('axios');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logAxiosError, validateImage } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
VisionModes,
|
||||
ImageDetail,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
|
||||
@@ -86,15 +84,11 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
|
||||
* Encodes and formats the given files.
|
||||
* @param {ServerRequest} req - The request object.
|
||||
* @param {Array<MongoFile>} files - The array of files to encode and format.
|
||||
* @param {object} params - Object containing provider/endpoint information
|
||||
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
|
||||
* @param {string} [params.endpoint] - Optional: The endpoint for the image
|
||||
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
|
||||
* @param {string} [mode] - Optional: The endpoint mode for the image.
|
||||
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
||||
*/
|
||||
async function encodeAndFormat(req, files, params, mode) {
|
||||
const { provider, endpoint } = params;
|
||||
const effectiveEndpoint = endpoint ?? provider;
|
||||
async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
const promises = [];
|
||||
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
|
||||
const encodingMethods = {};
|
||||
@@ -140,7 +134,7 @@ async function encodeAndFormat(req, files, params, mode) {
|
||||
} catch (error) {
|
||||
logger.error('Error processing image from blob storage:', error);
|
||||
}
|
||||
} else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
|
||||
} else if (source !== FileSources.local && base64Only.has(endpoint)) {
|
||||
const [_file, imageURL] = await preparePayload(req, file);
|
||||
promises.push([_file, await fetchImageToBase64(imageURL)]);
|
||||
continue;
|
||||
@@ -154,17 +148,6 @@ async function encodeAndFormat(req, files, params, mode) {
|
||||
const formattedImages = await Promise.all(promises);
|
||||
promises.length = 0;
|
||||
|
||||
/** Extract configured file size limit from fileConfig for this endpoint */
|
||||
let configuredFileSizeLimit;
|
||||
if (req.config?.fileConfig) {
|
||||
const fileConfig = mergeFileConfig(req.config.fileConfig);
|
||||
const endpointConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint: effectiveEndpoint,
|
||||
});
|
||||
configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
|
||||
}
|
||||
|
||||
for (const [file, imageContent] of formattedImages) {
|
||||
const fileMetadata = {
|
||||
type: file.type,
|
||||
@@ -185,26 +168,6 @@ async function encodeAndFormat(req, files, params, mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** Validate image buffer against size limits */
|
||||
if (file.height && file.width) {
|
||||
const imageBuffer = imageContent.startsWith('http')
|
||||
? null
|
||||
: Buffer.from(imageContent, 'base64');
|
||||
|
||||
if (imageBuffer) {
|
||||
const validation = await validateImage(
|
||||
imageBuffer,
|
||||
imageBuffer.length,
|
||||
effectiveEndpoint,
|
||||
configuredFileSizeLimit,
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagePart = {
|
||||
type: ContentTypes.IMAGE_URL,
|
||||
image_url: {
|
||||
@@ -221,19 +184,15 @@ async function encodeAndFormat(req, files, params, mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveEndpoint &&
|
||||
effectiveEndpoint === EModelEndpoint.google &&
|
||||
mode === VisionModes.generative
|
||||
) {
|
||||
if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) {
|
||||
delete imagePart.image_url;
|
||||
imagePart.inlineData = {
|
||||
mimeType: file.type,
|
||||
data: imageContent,
|
||||
};
|
||||
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
|
||||
} else if (endpoint && endpoint === EModelEndpoint.google) {
|
||||
imagePart.image_url = imagePart.image_url.url;
|
||||
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
|
||||
} else if (endpoint && endpoint === EModelEndpoint.anthropic) {
|
||||
imagePart.type = 'image';
|
||||
imagePart.source = {
|
||||
type: 'base64',
|
||||
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
checkOpenAIStorage,
|
||||
removeNullishValues,
|
||||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
@@ -995,7 +994,7 @@ async function saveBase64Image(
|
||||
*/
|
||||
function filterFile({ req, image, isAvatar }) {
|
||||
const { file } = req;
|
||||
const { endpoint, endpointType, file_id, width, height } = req.body;
|
||||
const { endpoint, file_id, width, height } = req.body;
|
||||
|
||||
if (!file_id && !isAvatar) {
|
||||
throw new Error('No file_id provided');
|
||||
@@ -1017,13 +1016,9 @@ function filterFile({ req, image, isAvatar }) {
|
||||
const appConfig = req.config;
|
||||
const fileConfig = mergeFileConfig(appConfig.fileConfig);
|
||||
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
endpoint,
|
||||
fileConfig,
|
||||
endpointType,
|
||||
});
|
||||
const fileSizeLimit =
|
||||
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
|
||||
const { fileSizeLimit: sizeLimit, supportedMimeTypes } =
|
||||
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
|
||||
const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit;
|
||||
|
||||
if (file.size > fileSizeLimit) {
|
||||
throw new Error(
|
||||
@@ -1033,10 +1028,7 @@ function filterFile({ req, image, isAvatar }) {
|
||||
);
|
||||
}
|
||||
|
||||
const isSupportedMimeType = fileConfig.checkType(
|
||||
file.mimetype,
|
||||
endpointFileConfig.supportedMimeTypes,
|
||||
);
|
||||
const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes);
|
||||
|
||||
if (!isSupportedMimeType) {
|
||||
throw new Error('Unsupported file type');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('processMessages', () => {
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
|
||||
"The text you have uploaded is from the book \"Harry Potter and the Philosopher's Stone\" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -424,7 +424,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
|
||||
"The text you have uploaded is from the book \"Harry Potter and the Philosopher's Stone\" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -582,7 +582,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation【11:2†source】.',
|
||||
"This is a test ^1^ with pre-existing citation-like text. Here's a real citation【11:2†source】.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -610,7 +610,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
});
|
||||
|
||||
const expectedText =
|
||||
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation^1^.\n\n^1.^ test.txt';
|
||||
"This is a test ^1^ with pre-existing citation-like text. Here's a real citation^1^.\n\n^1.^ test.txt";
|
||||
|
||||
expect(result.text).toBe(expectedText);
|
||||
expect(result.edited).toBe(true);
|
||||
|
||||
@@ -18,7 +18,6 @@ const {
|
||||
ImageVisionTool,
|
||||
openapiToFunction,
|
||||
AgentCapabilities,
|
||||
validateActionDomain,
|
||||
defaultAgentCapabilities,
|
||||
validateAndParseOpenAPISpec,
|
||||
} = require('librechat-data-provider');
|
||||
@@ -237,26 +236,12 @@ async function processRequiredActions(client, requiredActions) {
|
||||
|
||||
// Validate and parse OpenAPI spec
|
||||
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
||||
if (!validationResult.spec || !validationResult.serverUrl) {
|
||||
if (!validationResult.spec) {
|
||||
throw new Error(
|
||||
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Validate the domain from the spec matches the stored domain
|
||||
// This is defense-in-depth to prevent any stored malicious actions
|
||||
const domainValidation = validateActionDomain(
|
||||
action.metadata.domain,
|
||||
validationResult.serverUrl,
|
||||
);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
||||
userId: client.req.user.id,
|
||||
action_id: action.action_id,
|
||||
});
|
||||
continue; // Skip this action rather than failing the entire request
|
||||
}
|
||||
|
||||
// Process the OpenAPI spec
|
||||
const { requestBuilders } = openapiToFunction(validationResult.spec);
|
||||
|
||||
@@ -540,25 +525,10 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||
|
||||
// Validate and parse OpenAPI spec once per action set
|
||||
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
||||
if (!validationResult.spec || !validationResult.serverUrl) {
|
||||
if (!validationResult.spec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// SECURITY: Validate the domain from the spec matches the stored domain
|
||||
// This is defense-in-depth to prevent any stored malicious actions
|
||||
const domainValidation = validateActionDomain(
|
||||
action.metadata.domain,
|
||||
validationResult.serverUrl,
|
||||
);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
||||
userId: req.user.id,
|
||||
agent_id: agent.id,
|
||||
action_id: action.action_id,
|
||||
});
|
||||
continue; // Skip this action rather than failing the entire request
|
||||
}
|
||||
|
||||
const encrypted = {
|
||||
oauth_client_id: action.metadata.oauth_client_id,
|
||||
oauth_client_secret: action.metadata.oauth_client_secret,
|
||||
|
||||
@@ -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": {
|
||||
@@ -93,7 +93,7 @@
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
@@ -135,10 +135,10 @@
|
||||
"babel-plugin-root-import": "^6.6.0",
|
||||
"babel-plugin-transform-import-meta": "^2.3.2",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"eslint-plugin-jest": "^29.1.0",
|
||||
"eslint-plugin-jest": "^28.11.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-file-loader": "^1.0.3",
|
||||
@@ -147,6 +147,7 @@
|
||||
"postcss-loader": "^7.1.0",
|
||||
"postcss-preset-env": "^8.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 50 KiB |
12
client/src/@types/i18next.d.ts
vendored
12
client/src/@types/i18next.d.ts
vendored
@@ -1,9 +1,9 @@
|
||||
import { defaultNS, resources } from '~/locales/i18n';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true
|
||||
}
|
||||
}
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext } from './ChatContext';
|
||||
import { getLatestText } from '~/utils';
|
||||
|
||||
export interface ArtifactsContextValue {
|
||||
interface ArtifactsContextValue {
|
||||
isSubmitting: boolean;
|
||||
latestMessageId: string | null;
|
||||
latestMessageText: string;
|
||||
@@ -12,15 +12,10 @@ export interface ArtifactsContextValue {
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
children: React.ReactNode;
|
||||
value?: Partial<ArtifactsContextValue>;
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
|
||||
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
||||
|
||||
const chatLatestMessageText = useMemo(() => {
|
||||
const latestMessageText = useMemo(() => {
|
||||
return getLatestText({
|
||||
messageId: latestMessage?.messageId ?? null,
|
||||
text: latestMessage?.text ?? null,
|
||||
@@ -28,20 +23,15 @@ export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
|
||||
} as TMessage);
|
||||
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
||||
|
||||
const defaultContextValue = useMemo<ArtifactsContextValue>(
|
||||
/** Context value only created when relevant values change */
|
||||
const contextValue = useMemo<ArtifactsContextValue>(
|
||||
() => ({
|
||||
isSubmitting,
|
||||
latestMessageText: chatLatestMessageText,
|
||||
latestMessageText,
|
||||
latestMessageId: latestMessage?.messageId ?? null,
|
||||
conversationId: conversation?.conversationId ?? null,
|
||||
}),
|
||||
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
|
||||
);
|
||||
|
||||
/** Context value only created when relevant values change */
|
||||
const contextValue = useMemo<ArtifactsContextValue>(
|
||||
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
|
||||
[defaultContextValue, value],
|
||||
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
|
||||
);
|
||||
|
||||
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField } from '~/utils/endpoints';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface DragDropContextValue {
|
||||
|
||||
@@ -1,76 +1,29 @@
|
||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Mutation state context - for components that need to know about save/edit status
|
||||
* Separated from code state to prevent unnecessary re-renders
|
||||
*/
|
||||
interface MutationContextType {
|
||||
interface EditorContextType {
|
||||
isMutating: boolean;
|
||||
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code state context - for components that need the current code content
|
||||
* Changes frequently (on every keystroke), so only subscribe if needed
|
||||
*/
|
||||
interface CodeContextType {
|
||||
currentCode?: string;
|
||||
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
|
||||
const MutationContext = createContext<MutationContextType | undefined>(undefined);
|
||||
const CodeContext = createContext<CodeContextType | undefined>(undefined);
|
||||
const EditorContext = createContext<EditorContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provides editor state management for artifact code editing
|
||||
* Split into two contexts to prevent unnecessary re-renders:
|
||||
* - MutationContext: for save/edit status (changes rarely)
|
||||
* - CodeContext: for code content (changes on every keystroke)
|
||||
*/
|
||||
export function EditorProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const [currentCode, setCurrentCode] = useState<string | undefined>();
|
||||
|
||||
const mutationValue = useMemo(() => ({ isMutating, setIsMutating }), [isMutating]);
|
||||
const codeValue = useMemo(() => ({ currentCode, setCurrentCode }), [currentCode]);
|
||||
|
||||
return (
|
||||
<MutationContext.Provider value={mutationValue}>
|
||||
<CodeContext.Provider value={codeValue}>{children}</CodeContext.Provider>
|
||||
</MutationContext.Provider>
|
||||
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
|
||||
{children}
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access mutation state only
|
||||
* Use this when you only need to know about save/edit status
|
||||
*/
|
||||
export function useMutationState() {
|
||||
const context = useContext(MutationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useMutationState must be used within an EditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access code state only
|
||||
* Use this when you need the current code content
|
||||
*/
|
||||
export function useCodeState() {
|
||||
const context = useContext(CodeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCodeState must be used within an EditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use useMutationState() and/or useCodeState() instead
|
||||
* This hook causes components to re-render on every keystroke
|
||||
*/
|
||||
export function useEditorContext() {
|
||||
const mutation = useMutationState();
|
||||
const code = useCodeState();
|
||||
return { ...mutation, ...code };
|
||||
const context = useContext(EditorContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useEditorContext must be used within an EditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
|
||||
import type { Pluggable } from 'unified';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||
import { logger, extractContent, isArtifactRoute } from '~/utils';
|
||||
import { artifactsState } from '~/store/artifacts';
|
||||
import { logger, extractContent } from '~/utils';
|
||||
import ArtifactButton from './ArtifactButton';
|
||||
|
||||
export const artifactPlugin: Pluggable = () => {
|
||||
@@ -88,7 +88,7 @@ export function Artifact({
|
||||
lastUpdateTime: now,
|
||||
};
|
||||
|
||||
if (!isArtifactRoute(location.pathname)) {
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return setArtifact(currentArtifact);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
|
||||
import { getFileType, logger } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -13,9 +13,8 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
const location = useLocation();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
||||
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
|
||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const isSelected = artifact?.id === currentArtifactId;
|
||||
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
||||
|
||||
const debouncedSetVisibleRef = useRef(
|
||||
@@ -37,7 +36,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isArtifactRoute(location.pathname)) {
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,52 +54,35 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
||||
{(() => {
|
||||
const handleClick = () => {
|
||||
if (isSelected) {
|
||||
resetCurrentArtifactId();
|
||||
setVisible(false);
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetCurrentArtifactId();
|
||||
setVisible(true);
|
||||
|
||||
if (artifacts?.[artifact.id] == null) {
|
||||
setArtifacts(visibleArtifacts);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentArtifactId(artifact.id);
|
||||
}, 15);
|
||||
};
|
||||
|
||||
const buttonClass = cn(
|
||||
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
|
||||
{
|
||||
'border-border-medium bg-surface-hover shadow-lg': isSelected,
|
||||
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
|
||||
},
|
||||
);
|
||||
|
||||
const actionLabel = isSelected
|
||||
? localize('com_ui_click_to_close')
|
||||
: localize('com_ui_artifact_click');
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleClick} className={buttonClass}>
|
||||
<div className="w-fit p-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden text-left">
|
||||
<div className="truncate font-medium">{artifact.title}</div>
|
||||
<div className="truncate text-text-secondary">{actionLabel}</div>
|
||||
</div>
|
||||
}}
|
||||
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
|
||||
>
|
||||
<div className="w-fit bg-surface-tertiary p-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden text-left">
|
||||
<div className="truncate font-medium">{artifact.title}</div>
|
||||
<div className="truncate text-text-secondary">
|
||||
{localize('com_ui_artifact_click')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { KeyBinding } from '@codemirror/view';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
useSandpack,
|
||||
SandpackCodeEditor,
|
||||
@@ -12,143 +10,116 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { ArtifactFiles, Artifact } from '~/common';
|
||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
|
||||
const CodeEditor = memo(
|
||||
({
|
||||
const createDebouncedMutation = (
|
||||
callback: (params: {
|
||||
index: number;
|
||||
messageId: string;
|
||||
original: string;
|
||||
updated: string;
|
||||
}) => void,
|
||||
) => debounce(callback, 500);
|
||||
|
||||
const CodeEditor = ({
|
||||
fileKey,
|
||||
readOnly,
|
||||
artifact,
|
||||
editorRef,
|
||||
}: {
|
||||
fileKey: string;
|
||||
readOnly?: boolean;
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) => {
|
||||
const { sandpack } = useSandpack();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
},
|
||||
});
|
||||
|
||||
const mutationCallback = useCallback(
|
||||
(params: { index: number; messageId: string; original: string; updated: string }) => {
|
||||
editArtifact.mutate(params);
|
||||
},
|
||||
[editArtifact],
|
||||
);
|
||||
|
||||
const debouncedMutation = useMemo(
|
||||
() => createDebouncedMutation(mutationCallback),
|
||||
[mutationCallback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
if (isMutating) {
|
||||
return;
|
||||
}
|
||||
if (artifact.index == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
const isNotOriginal =
|
||||
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdate == null
|
||||
? true
|
||||
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
|
||||
|
||||
if (artifact.content && isNotOriginal && isNotRepeated) {
|
||||
setCurrentCode(currentCode);
|
||||
debouncedMutation({
|
||||
index: artifact.index,
|
||||
messageId: artifact.messageId ?? '',
|
||||
original: artifact.content,
|
||||
updated: currentCode,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedMutation.cancel();
|
||||
};
|
||||
}, [
|
||||
fileKey,
|
||||
artifact.index,
|
||||
artifact.content,
|
||||
artifact.messageId,
|
||||
readOnly,
|
||||
artifact,
|
||||
editorRef,
|
||||
}: {
|
||||
fileKey: string;
|
||||
readOnly?: boolean;
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) => {
|
||||
const { sandpack } = useSandpack();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating } = useMutationState();
|
||||
const { setCurrentCode } = useCodeState();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
},
|
||||
});
|
||||
isMutating,
|
||||
currentUpdate,
|
||||
setIsMutating,
|
||||
sandpack.files,
|
||||
setCurrentCode,
|
||||
debouncedMutation,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Create stable debounced mutation that doesn't depend on changing callbacks
|
||||
* Use refs to always access the latest values without recreating the debounce
|
||||
*/
|
||||
const artifactRef = useRef(artifact);
|
||||
const isMutatingRef = useRef(isMutating);
|
||||
const currentUpdateRef = useRef(currentUpdate);
|
||||
const editArtifactRef = useRef(editArtifact);
|
||||
const setCurrentCodeRef = useRef(setCurrentCode);
|
||||
|
||||
useEffect(() => {
|
||||
artifactRef.current = artifact;
|
||||
}, [artifact]);
|
||||
|
||||
useEffect(() => {
|
||||
isMutatingRef.current = isMutating;
|
||||
}, [isMutating]);
|
||||
|
||||
useEffect(() => {
|
||||
currentUpdateRef.current = currentUpdate;
|
||||
}, [currentUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
editArtifactRef.current = editArtifact;
|
||||
}, [editArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentCodeRef.current = setCurrentCode;
|
||||
}, [setCurrentCode]);
|
||||
|
||||
/**
|
||||
* Create debounced mutation once - never recreate it
|
||||
* All values are accessed via refs so they're always current
|
||||
*/
|
||||
const debouncedMutation = useMemo(
|
||||
() =>
|
||||
debounce((code: string) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
if (isMutatingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (artifactRef.current.index == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = artifactRef.current;
|
||||
const artifactIndex = artifact.index;
|
||||
const isNotOriginal =
|
||||
code && artifact.content != null && code.trim() !== artifact.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdateRef.current == null
|
||||
? true
|
||||
: code != null && code.trim() !== currentUpdateRef.current.trim();
|
||||
|
||||
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
|
||||
setCurrentCodeRef.current(code);
|
||||
editArtifactRef.current.mutate({
|
||||
index: artifactIndex,
|
||||
messageId: artifact.messageId ?? '',
|
||||
original: artifact.content,
|
||||
updated: code,
|
||||
});
|
||||
}
|
||||
}, 500),
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
/**
|
||||
* Listen to Sandpack file changes and trigger debounced mutation
|
||||
*/
|
||||
useEffect(() => {
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
if (currentCode) {
|
||||
debouncedMutation(currentCode);
|
||||
}
|
||||
}, [sandpack.files, fileKey, debouncedMutation]);
|
||||
|
||||
/**
|
||||
* Cleanup: cancel pending mutations when component unmounts or artifact changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedMutation.cancel();
|
||||
};
|
||||
}, [artifact.id, debouncedMutation]);
|
||||
|
||||
return (
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
extensions={[autocompletion()]}
|
||||
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
return (
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ArtifactCodeEditor = function ({
|
||||
files,
|
||||
@@ -157,7 +128,6 @@ export const ArtifactCodeEditor = function ({
|
||||
artifact,
|
||||
editorRef,
|
||||
sharedProps,
|
||||
readOnly: externalReadOnly,
|
||||
}: {
|
||||
fileKey: string;
|
||||
artifact: Artifact;
|
||||
@@ -165,7 +135,6 @@ export const ArtifactCodeEditor = function ({
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
@@ -179,11 +148,10 @@ export const ArtifactCodeEditor = function ({
|
||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||
};
|
||||
}, [config, template, fileKey]);
|
||||
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
|
||||
const [readOnly, setReadOnly] = useState(initialReadOnly);
|
||||
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
||||
useEffect(() => {
|
||||
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
|
||||
}, [isSubmitting, externalReadOnly]);
|
||||
setReadOnly(isSubmitting ?? false);
|
||||
}, [isSubmitting]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { memo, useMemo, type MutableRefObject } from 'react';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type {
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
SandpackPreview,
|
||||
SandpackProvider,
|
||||
SandpackProviderProps,
|
||||
SandpackPreviewRef,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type { ArtifactFiles } from '~/common';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
@@ -21,7 +22,7 @@ export const ArtifactPreview = memo(function ({
|
||||
fileKey: string;
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
previewRef: MutableRefObject<SandpackPreviewRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
currentCode?: string;
|
||||
startupConfig?: TStartupConfig;
|
||||
}) {
|
||||
@@ -35,7 +36,9 @@ export const ArtifactPreview = memo(function ({
|
||||
}
|
||||
return {
|
||||
...files,
|
||||
[fileKey]: { code },
|
||||
[fileKey]: {
|
||||
code,
|
||||
},
|
||||
};
|
||||
}, [currentCode, files, fileKey]);
|
||||
|
||||
@@ -43,10 +46,12 @@ export const ArtifactPreview = memo(function ({
|
||||
if (!startupConfig) {
|
||||
return sharedOptions;
|
||||
}
|
||||
return {
|
||||
const _options: typeof sharedOptions = {
|
||||
...sharedOptions,
|
||||
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
|
||||
};
|
||||
|
||||
return _options;
|
||||
}, [startupConfig, template]);
|
||||
|
||||
if (Object.keys(artifactFiles).length === 0) {
|
||||
@@ -55,7 +60,10 @@ export const ArtifactPreview = memo(function ({
|
||||
|
||||
return (
|
||||
<SandpackProvider
|
||||
files={{ ...artifactFiles, ...sharedFiles }}
|
||||
files={{
|
||||
...artifactFiles,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={options}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
artifact,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isSharedConvo,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
isSharedConvo?: boolean;
|
||||
}) {
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const { currentCode, setCurrentCode } = useCodeState();
|
||||
const { currentCode, setCurrentCode } = useEditorContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.id !== lastIdRef.current) {
|
||||
setCurrentCode(undefined);
|
||||
@@ -37,16 +33,14 @@ export default function ArtifactTabs({
|
||||
const content = artifact.content ?? '';
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||
|
||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<>
|
||||
<Tabs.Content
|
||||
ref={contentRef}
|
||||
value="code"
|
||||
id="artifacts-code"
|
||||
className="h-full w-full flex-grow overflow-auto"
|
||||
className={cn('flex-grow overflow-auto')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArtifactCodeEditor
|
||||
@@ -56,11 +50,9 @@ export default function ArtifactTabs({
|
||||
artifact={artifact}
|
||||
editorRef={editorRef}
|
||||
sharedProps={sharedProps}
|
||||
readOnly={isSharedConvo}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
@@ -71,6 +63,6 @@ export default function ArtifactTabs({
|
||||
startupConfig={startupConfig}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MenuButton } from '@ariakit/react';
|
||||
import { History, Check } from 'lucide-react';
|
||||
import { DropdownPopup, TooltipAnchor, Button, useMediaQuery } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ArtifactVersionProps {
|
||||
currentIndex: number;
|
||||
totalVersions: number;
|
||||
onVersionChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function ArtifactVersion({
|
||||
currentIndex,
|
||||
totalVersions,
|
||||
onVersionChange,
|
||||
}: ArtifactVersionProps) {
|
||||
const localize = useLocalize();
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const menuId = 'version-dropdown-menu';
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
const index = parseInt(value, 10);
|
||||
onVersionChange(index);
|
||||
setIsPopoverActive(false);
|
||||
};
|
||||
|
||||
if (totalVersions <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = Array.from({ length: totalVersions }, (_, index) => ({
|
||||
value: index.toString(),
|
||||
label: localize('com_ui_version_var', { 0: String(index + 1) }),
|
||||
}));
|
||||
|
||||
const dropdownItems = options.map((option) => {
|
||||
const isSelected = option.value === String(currentIndex);
|
||||
return {
|
||||
label: option.label,
|
||||
onClick: () => handleValueChange(option.value),
|
||||
value: option.value,
|
||||
icon: isSelected ? (
|
||||
<Check size={16} className="text-text-primary" aria-hidden="true" />
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownPopup
|
||||
menuId={menuId}
|
||||
portal
|
||||
focusLoop
|
||||
unmountOnHide
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_change_version')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
asChild
|
||||
aria-label={localize('com_ui_change_version')}
|
||||
>
|
||||
<MenuButton>
|
||||
<History
|
||||
size={18}
|
||||
className="text-text-secondary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
</MenuButton>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +1,147 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Code, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import { useShareContext, useMutationState } from '~/Providers';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
import ArtifactVersion from './ArtifactVersion';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { CopyCodeButton } from './Code';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const MAX_BLUR_AMOUNT = 32;
|
||||
const MAX_BACKDROP_OPACITY = 0.3;
|
||||
|
||||
export default function Artifacts() {
|
||||
const localize = useLocalize();
|
||||
const { isMutating } = useMutationState();
|
||||
const { isSharedConvo } = useShareContext();
|
||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||
const { isMutating } = useEditorContext();
|
||||
const editorRef = useRef<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [height, setHeight] = useState(90);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [blurAmount, setBlurAmount] = useState(0);
|
||||
const dragStartY = useRef(0);
|
||||
const dragStartHeight = useRef(90);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
value: 'code',
|
||||
label: localize('com_ui_code'),
|
||||
icon: <Code className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: 'preview',
|
||||
label: localize('com_ui_preview'),
|
||||
icon: <Play className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const delay = isMobile ? 50 : 30;
|
||||
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
setIsMounted(false);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setBlurAmount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const minHeightForBlur = 50;
|
||||
const maxHeightForBlur = 100;
|
||||
|
||||
if (height <= minHeightForBlur) {
|
||||
setBlurAmount(0);
|
||||
} else if (height >= maxHeightForBlur) {
|
||||
setBlurAmount(MAX_BLUR_AMOUNT);
|
||||
} else {
|
||||
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
|
||||
setBlurAmount(Math.round(progress * MAX_BLUR_AMOUNT));
|
||||
}
|
||||
}, [height, isMobile]);
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
} = useArtifacts();
|
||||
|
||||
const handleDragStart = (e: React.PointerEvent) => {
|
||||
setIsDragging(true);
|
||||
dragStartY.current = e.clientY;
|
||||
dragStartHeight.current = height;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handleDragMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaY = dragStartY.current - e.clientY;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const deltaPercentage = (deltaY / viewportHeight) * 100;
|
||||
const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage));
|
||||
|
||||
setHeight(newHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: React.PointerEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
// Snap to positions based on final height
|
||||
if (height < 30) {
|
||||
closeArtifacts();
|
||||
} else if (height > 95) {
|
||||
setHeight(100);
|
||||
} else if (height < 60) {
|
||||
setHeight(50);
|
||||
} else {
|
||||
setHeight(90);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentArtifact || !isMounted) {
|
||||
if (currentArtifact === null || currentArtifact === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
const client = previewRef.current?.getClient();
|
||||
if (client) {
|
||||
if (client != null) {
|
||||
client.dispatch({ type: 'refresh' });
|
||||
}
|
||||
setTimeout(() => setIsRefreshing(false), 750);
|
||||
};
|
||||
|
||||
const closeArtifacts = () => {
|
||||
if (isMobile) {
|
||||
setIsClosing(true);
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setArtifactsVisible(false);
|
||||
setIsClosing(false);
|
||||
setHeight(90);
|
||||
}, 250);
|
||||
} else {
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
}
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
};
|
||||
|
||||
const backdropOpacity =
|
||||
blurAmount > 0
|
||||
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Mobile backdrop with dynamic blur */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
|
||||
isVisible && !isClosing
|
||||
? 'transition-all duration-300'
|
||||
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
|
||||
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
|
||||
)}
|
||||
style={{
|
||||
opacity: isVisible && !isClosing ? backdropOpacity : 0,
|
||||
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
}}
|
||||
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* Main Container */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
|
||||
isMobile
|
||||
? cn(
|
||||
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
|
||||
isVisible && !isClosing
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'duration-250 translate-y-full opacity-0 transition-all',
|
||||
isDragging ? '' : 'transition-all duration-300',
|
||||
)
|
||||
: cn(
|
||||
'h-full shadow-2xl',
|
||||
isVisible && !isClosing
|
||||
? 'duration-350 translate-x-0 opacity-100 transition-all'
|
||||
: 'translate-x-5 opacity-0 transition-all duration-300',
|
||||
),
|
||||
`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out`,
|
||||
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm',
|
||||
)}
|
||||
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
|
||||
>
|
||||
{isMobile && (
|
||||
<div
|
||||
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
>
|
||||
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
|
||||
isMobile ? 'justify-center' : 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center transition-all duration-500',
|
||||
isVisible && !isClosing
|
||||
? 'translate-x-0 opacity-100'
|
||||
: '-translate-x-2 opacity-0',
|
||||
)}
|
||||
>
|
||||
<Radio
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 transition-all duration-500',
|
||||
isMobile ? 'min-w-max' : '',
|
||||
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
|
||||
<div className="flex items-center">
|
||||
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Refresh button */}
|
||||
{activeTab === 'preview' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
<button
|
||||
className={cn(
|
||||
'mr-2 text-text-secondary transition-transform duration-500 ease-in-out',
|
||||
isRefreshing ? 'rotate-180' : '',
|
||||
)}
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={localize('com_ui_refresh')}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} className="transition-transform duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={cn('transform', isRefreshing ? 'animate-spin' : '')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<RefreshCw size={16} className="animate-spin text-text-secondary" />
|
||||
)}
|
||||
{orderedArtifactIds.length > 1 && (
|
||||
<ArtifactVersion
|
||||
currentIndex={currentIndex}
|
||||
totalVersions={orderedArtifactIds.length}
|
||||
onVersionChange={(index) => {
|
||||
const target = orderedArtifactIds[index];
|
||||
if (target) {
|
||||
setCurrentArtifactId(target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
|
||||
<Tabs.Trigger
|
||||
value="preview"
|
||||
disabled={isMutating}
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
{localize('com_ui_preview')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="code"
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
{localize('com_ui_code')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<button className="text-text-secondary" onClick={closeArtifacts}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
/>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
|
||||
<div className="flex items-center">
|
||||
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs">{`${currentIndex + 1} / ${
|
||||
orderedArtifactIds.length
|
||||
}`}</span>
|
||||
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
{/* Download Button */}
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={closeArtifacts}
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
{/* Publish button */}
|
||||
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
|
||||
Publish
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
isSharedConvo={isSharedConvo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
|
||||
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!isRefreshing}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isRefreshing ? 'scale-100' : 'scale-95',
|
||||
)}
|
||||
>
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
|
||||
<Radio
|
||||
fullWidth
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
@@ -2,9 +2,8 @@ import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Button } from '@librechat/client';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { Copy, CircleCheckBig } from 'lucide-react';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import { handleDoubleClick, langSubset } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -108,13 +107,12 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
<button
|
||||
className="mr-2 text-text-secondary"
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
>
|
||||
{isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />}
|
||||
</Button>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download, CircleCheckBig } from 'lucide-react';
|
||||
import { Download } from 'lucide-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import { Button } from '@librechat/client';
|
||||
import { CheckMark } from '@librechat/client';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
|
||||
const DownloadArtifact = ({
|
||||
artifact,
|
||||
className = '',
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
className?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { currentCode } = useCodeState();
|
||||
const { currentCode } = useEditorContext();
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
const { fileKey: fileName } = useArtifactProps({ artifact });
|
||||
|
||||
@@ -35,14 +41,13 @@ const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
<button
|
||||
className={`mr-2 text-text-secondary ${className}`}
|
||||
onClick={handleDownload}
|
||||
aria-label={localize('com_ui_download_artifact')}
|
||||
>
|
||||
{isDownloaded ? <CircleCheckBig size={16} /> : <Download size={16} />}
|
||||
</Button>
|
||||
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -156,7 +156,6 @@ test('renders registration form', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('calls registerUser.mutate on registration', async () => {
|
||||
// const mutate = jest.fn();
|
||||
// const { getByTestId, getByRole, history } = setup({
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user