mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
a7cf1ae27b
241 changed files with 25653 additions and 3303 deletions
|
|
@ -785,3 +785,7 @@ OPENWEATHER_API_KEY=
|
||||||
|
|
||||||
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
||||||
# MCP_CONNECTION_CHECK_TTL=60000
|
# 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
|
||||||
|
|
|
||||||
14
.github/workflows/client.yml
vendored
14
.github/workflows/client.yml
vendored
|
|
@ -13,9 +13,14 @@ on:
|
||||||
required: false
|
required: false
|
||||||
default: 'Manual publish requested'
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-publish:
|
build-and-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: publish # Must match npm trusted publisher config
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -23,6 +28,10 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
run: cd packages/client && npm ci
|
run: cd packages/client && npm ci
|
||||||
|
|
@ -30,9 +39,6 @@ jobs:
|
||||||
- name: Build client
|
- name: Build client
|
||||||
run: cd packages/client && npm run build
|
run: cd packages/client && npm run build
|
||||||
|
|
||||||
- name: Set up npm authentication
|
|
||||||
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
|
|
||||||
|
|
||||||
- name: Check version change
|
- name: Check version change
|
||||||
id: check
|
id: check
|
||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
|
|
@ -55,4 +61,4 @@ jobs:
|
||||||
- name: Publish
|
- name: Publish
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
run: npm publish *.tgz --access public
|
run: npm publish *.tgz --access public --provenance
|
||||||
|
|
|
||||||
13
.github/workflows/data-provider.yml
vendored
13
.github/workflows/data-provider.yml
vendored
|
|
@ -13,6 +13,10 @@ on:
|
||||||
required: false
|
required: false
|
||||||
default: 'Manual publish requested'
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -27,14 +31,17 @@ jobs:
|
||||||
publish-npm:
|
publish-npm:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: publish # Must match npm trusted publisher config
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
|
||||||
|
|
||||||
- run: cd packages/data-provider && npm ci
|
- run: cd packages/data-provider && npm ci
|
||||||
- run: cd packages/data-provider && npm run build
|
- run: cd packages/data-provider && npm run build
|
||||||
- run: cd packages/data-provider && npm publish
|
- run: cd packages/data-provider && npm publish --provenance
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
||||||
|
|
|
||||||
14
.github/workflows/data-schemas.yml
vendored
14
.github/workflows/data-schemas.yml
vendored
|
|
@ -13,9 +13,14 @@ on:
|
||||||
required: false
|
required: false
|
||||||
default: 'Manual publish requested'
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-publish:
|
build-and-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: publish # Must match npm trusted publisher config
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -23,6 +28,10 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd packages/data-schemas && npm ci
|
run: cd packages/data-schemas && npm ci
|
||||||
|
|
@ -30,9 +39,6 @@ jobs:
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cd packages/data-schemas && npm run build
|
run: cd packages/data-schemas && npm run build
|
||||||
|
|
||||||
- name: Set up npm authentication
|
|
||||||
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
|
|
||||||
|
|
||||||
- name: Check version change
|
- name: Check version change
|
||||||
id: check
|
id: check
|
||||||
working-directory: packages/data-schemas
|
working-directory: packages/data-schemas
|
||||||
|
|
@ -55,4 +61,4 @@ jobs:
|
||||||
- name: Publish
|
- name: Publish
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
working-directory: packages/data-schemas
|
working-directory: packages/data-schemas
|
||||||
run: npm publish *.tgz --access public
|
run: npm publish *.tgz --access public --provenance
|
||||||
|
|
|
||||||
66
.github/workflows/dev-staging-images.yml
vendored
Normal file
66
.github/workflows/dev-staging-images.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
name: Docker Dev Staging Images Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: api-build
|
||||||
|
file: Dockerfile.multi
|
||||||
|
image_name: lc-dev-staging-api
|
||||||
|
- target: node
|
||||||
|
file: Dockerfile
|
||||||
|
image_name: lc-dev-staging
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Check out the repository
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up QEMU
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
# Set up Docker Buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Log in to GitHub Container Registry
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Login to Docker Hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Prepare the environment
|
||||||
|
- name: Prepare environment
|
||||||
|
run: |
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Build and push Docker images for each target
|
||||||
|
- name: Build and push Docker images
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||||
|
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
|
||||||
16
.github/workflows/eslint-ci.yml
vendored
16
.github/workflows/eslint-ci.yml
vendored
|
|
@ -35,8 +35,6 @@ jobs:
|
||||||
|
|
||||||
# Run ESLint on changed files within the api/ and client/ directories.
|
# Run ESLint on changed files within the api/ and client/ directories.
|
||||||
- name: Run ESLint on changed files
|
- name: Run ESLint on changed files
|
||||||
env:
|
|
||||||
SARIF_ESLINT_IGNORE_SUPPRESSED: "true"
|
|
||||||
run: |
|
run: |
|
||||||
# Extract the base commit SHA from the pull_request event payload.
|
# Extract the base commit SHA from the pull_request event payload.
|
||||||
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
||||||
|
|
@ -52,22 +50,10 @@ jobs:
|
||||||
# Ensure there are files to lint before running ESLint
|
# Ensure there are files to lint before running ESLint
|
||||||
if [[ -z "$CHANGED_FILES" ]]; then
|
if [[ -z "$CHANGED_FILES" ]]; then
|
||||||
echo "No matching files changed. Skipping ESLint."
|
echo "No matching files changed. Skipping ESLint."
|
||||||
echo "UPLOAD_SARIF=false" >> $GITHUB_ENV
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set variable to allow SARIF upload
|
|
||||||
echo "UPLOAD_SARIF=true" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Run ESLint
|
# Run ESLint
|
||||||
npx eslint --no-error-on-unmatched-pattern \
|
npx eslint --no-error-on-unmatched-pattern \
|
||||||
--config eslint.config.mjs \
|
--config eslint.config.mjs \
|
||||||
--format @microsoft/eslint-formatter-sarif \
|
$CHANGED_FILES
|
||||||
--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,3 +138,34 @@ helm/**/.values.yaml
|
||||||
/.tabnine/
|
/.tabnine/
|
||||||
/.codeium
|
/.codeium
|
||||||
*.local.md
|
*.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-rc1
|
# v0.8.1
|
||||||
|
|
||||||
# Base node image
|
# Base node image
|
||||||
FROM node:20-alpine AS node
|
FROM node:20-alpine AS node
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Dockerfile.multi
|
# Dockerfile.multi
|
||||||
# v0.8.1-rc1
|
# v0.8.1
|
||||||
|
|
||||||
# Base for all builds
|
# Base for all builds
|
||||||
FROM node:20-alpine AS base-min
|
FROM node:20-alpine AS base-min
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const crypto = require('crypto');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
|
countTokens,
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
extractFileContext,
|
extractFileContext,
|
||||||
encodeAndFormatAudios,
|
encodeAndFormatAudios,
|
||||||
|
|
@ -23,7 +24,6 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { checkBalance } = require('~/models/balanceMethods');
|
const { checkBalance } = require('~/models/balanceMethods');
|
||||||
const { truncateToolCallOutputs } = require('./prompts');
|
const { truncateToolCallOutputs } = require('./prompts');
|
||||||
const countTokens = require('~/server/utils/countTokens');
|
|
||||||
const { getFiles } = require('~/models/File');
|
const { getFiles } = require('~/models/File');
|
||||||
const TextStream = require('./TextStream');
|
const TextStream = require('./TextStream');
|
||||||
|
|
||||||
|
|
@ -81,6 +81,7 @@ class BaseClient {
|
||||||
throw new Error("Method 'getCompletion' must be implemented.");
|
throw new Error("Method 'getCompletion' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {sendCompletion} */
|
||||||
async sendCompletion() {
|
async sendCompletion() {
|
||||||
throw new Error("Method 'sendCompletion' must be implemented.");
|
throw new Error("Method 'sendCompletion' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
@ -689,8 +690,7 @@ class BaseClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {string|string[]|undefined} */
|
const { completion, metadata } = await this.sendCompletion(payload, opts);
|
||||||
const completion = await this.sendCompletion(payload, opts);
|
|
||||||
if (this.abortController) {
|
if (this.abortController) {
|
||||||
this.abortController.requestCompleted = true;
|
this.abortController.requestCompleted = true;
|
||||||
}
|
}
|
||||||
|
|
@ -708,6 +708,7 @@ class BaseClient {
|
||||||
iconURL: this.options.iconURL,
|
iconURL: this.options.iconURL,
|
||||||
endpoint: this.options.endpoint,
|
endpoint: this.options.endpoint,
|
||||||
...(this.metadata ?? {}),
|
...(this.metadata ?? {}),
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof completion === 'string') {
|
if (typeof completion === 'string') {
|
||||||
|
|
@ -1212,8 +1213,8 @@ class BaseClient {
|
||||||
this.options.req,
|
this.options.req,
|
||||||
attachments,
|
attachments,
|
||||||
{
|
{
|
||||||
provider: this.options.agent?.provider,
|
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||||
endpoint: this.options.agent?.endpoint,
|
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||||
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
|
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
|
||||||
},
|
},
|
||||||
getStrategyFunctions,
|
getStrategyFunctions,
|
||||||
|
|
@ -1230,8 +1231,8 @@ class BaseClient {
|
||||||
this.options.req,
|
this.options.req,
|
||||||
attachments,
|
attachments,
|
||||||
{
|
{
|
||||||
provider: this.options.agent?.provider,
|
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||||
endpoint: this.options.agent?.endpoint,
|
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||||
},
|
},
|
||||||
getStrategyFunctions,
|
getStrategyFunctions,
|
||||||
);
|
);
|
||||||
|
|
@ -1245,8 +1246,8 @@ class BaseClient {
|
||||||
this.options.req,
|
this.options.req,
|
||||||
attachments,
|
attachments,
|
||||||
{
|
{
|
||||||
provider: this.options.agent?.provider,
|
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||||
endpoint: this.options.agent?.endpoint,
|
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||||
},
|
},
|
||||||
getStrategyFunctions,
|
getStrategyFunctions,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const { getBasePath } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
|
||||||
// Correct any erroneous URLs in the responseMessage.text first
|
// Correct any erroneous URLs in the responseMessage.text first
|
||||||
intermediateSteps.forEach((step) => {
|
intermediateSteps.forEach((step) => {
|
||||||
const { observation } = step;
|
const { observation } = step;
|
||||||
|
|
@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const essentialImagePath = match[0];
|
const essentialImagePath = match[0];
|
||||||
|
const fullImagePath = `${basePath}${essentialImagePath}`;
|
||||||
|
|
||||||
const regex = /!\[.*?\]\((.*?)\)/g;
|
const regex = /!\[.*?\]\((.*?)\)/g;
|
||||||
let matchErroneous;
|
let matchErroneous;
|
||||||
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
|
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
|
||||||
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) {
|
if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
|
||||||
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath);
|
// Replace with the full path including base path
|
||||||
|
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
|
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
|
||||||
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
|
if (observedImagePath) {
|
||||||
responseMessage.text += '\n' + observedImagePath[0];
|
// Fix the image path to include base path if it doesn't already
|
||||||
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ describe('addImages', () => {
|
||||||
|
|
||||||
it('should append correctly from a real scenario', () => {
|
it('should append correctly from a real scenario', () => {
|
||||||
responseMessage.text =
|
responseMessage.text =
|
||||||
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?';
|
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
|
||||||
const originalText = responseMessage.text;
|
const originalText = responseMessage.text;
|
||||||
const imageMarkdown = '';
|
const imageMarkdown = '';
|
||||||
intermediateSteps.push({ observation: imageMarkdown });
|
intermediateSteps.push({ observation: imageMarkdown });
|
||||||
|
|
@ -139,4 +139,108 @@ describe('addImages', () => {
|
||||||
addImages(intermediateSteps, responseMessage);
|
addImages(intermediateSteps, responseMessage);
|
||||||
expect(responseMessage.text).toBe('\n');
|
expect(responseMessage.text).toBe('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('basePath functionality', () => {
|
||||||
|
let originalDomainClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalDomainClient = process.env.DOMAIN_CLIENT;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.DOMAIN_CLIENT = originalDomainClient;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not prepend base path when image URL already has base path', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correct erroneous URLs with base path', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
responseMessage.text = '';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty base path (root deployment)', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing DOMAIN_CLIENT', () => {
|
||||||
|
delete process.env.DOMAIN_CLIENT;
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle observation without image path match', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested subdirectories in base path', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple observations with mixed base path scenarios', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe(
|
||||||
|
'\n\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex markdown with base path', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
const complexMarkdown = `
|
||||||
|
# Document Title
|
||||||
|

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

|
||||||
|
`;
|
||||||
|
intermediateSteps.push({ observation: complexMarkdown });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs that are already absolute', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
intermediateSteps.push({ observation: '' });
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle data URLs', () => {
|
||||||
|
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||||
|
intermediateSteps.push({
|
||||||
|
observation:
|
||||||
|
'',
|
||||||
|
});
|
||||||
|
addImages(intermediateSteps, responseMessage);
|
||||||
|
expect(responseMessage.text).toBe(
|
||||||
|
'\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: ContentTypes.TEXT,
|
type: ContentTypes.TEXT,
|
||||||
[ContentTypes.TEXT]: 'I\'ll search for that information.',
|
[ContentTypes.TEXT]: "I'll search for that information.",
|
||||||
tool_call_ids: ['search_1'],
|
tool_call_ids: ['search_1'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: ContentTypes.TEXT,
|
type: ContentTypes.TEXT,
|
||||||
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
|
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
|
||||||
tool_call_ids: ['convert_1'],
|
tool_call_ids: ['convert_1'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
|
||||||
output: '23.89°C',
|
output: '23.89°C',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' },
|
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
|
||||||
expect(result[4]).toBeInstanceOf(AIMessage);
|
expect(result[4]).toBeInstanceOf(AIMessage);
|
||||||
|
|
||||||
// Check first AIMessage
|
// Check first AIMessage
|
||||||
expect(result[0].content).toBe('I\'ll search for that information.');
|
expect(result[0].content).toBe("I'll search for that information.");
|
||||||
expect(result[0].tool_calls).toHaveLength(1);
|
expect(result[0].tool_calls).toHaveLength(1);
|
||||||
expect(result[0].tool_calls[0]).toEqual({
|
expect(result[0].tool_calls[0]).toEqual({
|
||||||
id: 'search_1',
|
id: 'search_1',
|
||||||
|
|
@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check second AIMessage
|
// Check second AIMessage
|
||||||
expect(result[2].content).toBe('Now, I\'ll convert the temperature.');
|
expect(result[2].content).toBe("Now, I'll convert the temperature.");
|
||||||
expect(result[2].tool_calls).toHaveLength(1);
|
expect(result[2].tool_calls).toHaveLength(1);
|
||||||
expect(result[2].tool_calls[0]).toEqual({
|
expect(result[2].tool_calls[0]).toEqual({
|
||||||
id: 'convert_1',
|
id: 'convert_1',
|
||||||
|
|
@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
|
||||||
|
|
||||||
// Check final AIMessage
|
// Check final AIMessage
|
||||||
expect(result[4].content).toStrictEqual([
|
expect(result[4].content).toStrictEqual([
|
||||||
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
|
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
|
||||||
},
|
},
|
||||||
{ role: 'user', content: 'What\'s the weather?' },
|
{ role: 'user', content: "What's the weather?" },
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [
|
||||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' },
|
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
|
||||||
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
expect(result[2].content).toStrictEqual([
|
expect(result[2].content).toStrictEqual([
|
||||||
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
expect(result[3].content).toBe('Let me check that for you.');
|
expect(result[3].content).toBe('Let me check that for you.');
|
||||||
expect(result[4].content).toBe('Sunny, 75°F');
|
expect(result[4].content).toBe('Sunny, 75°F');
|
||||||
expect(result[5].content).toStrictEqual([
|
expect(result[5].content).toStrictEqual([
|
||||||
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check that there are no consecutive AIMessages
|
// Check that there are no consecutive AIMessages
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
TestClient.sendCompletion = jest.fn(async () => {
|
TestClient.sendCompletion = jest.fn(async () => {
|
||||||
return 'Mock response text';
|
return {
|
||||||
|
completion: 'Mock response text',
|
||||||
|
metadata: undefined,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
|
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
|
||||||
const { Tool } = require('@langchain/core/tools');
|
const { Tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
||||||
|
const { getBasePath } = require('@librechat/api');
|
||||||
const paths = require('~/config/paths');
|
const paths = require('~/config/paths');
|
||||||
|
|
||||||
const displayMessage =
|
const displayMessage =
|
||||||
|
|
@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool {
|
||||||
this.description_for_model = `// Generate images and visuals using text.
|
this.description_for_model = `// Generate images and visuals using text.
|
||||||
// Guidelines:
|
// Guidelines:
|
||||||
// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
|
// - 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: 
|
// - ALWAYS include the markdown url in your final response to show the user: }/images/id.png)
|
||||||
// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
|
// - 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.
|
// - 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:
|
// - Here's an example for generating a realistic portrait photo of a man:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
|
const { ProxyAgent, fetch } = require('undici');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { getApiKey } = require('./credentials');
|
const { getApiKey } = require('./credentials');
|
||||||
|
|
||||||
|
|
@ -19,13 +20,19 @@ function createTavilySearchTool(fields = {}) {
|
||||||
...kwargs,
|
...kwargs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('https://api.tavily.com/search', {
|
const fetchOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.tavily.com/search', fetchOptions);
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
|
const { ProxyAgent, fetch } = require('undici');
|
||||||
const { Tool } = require('@langchain/core/tools');
|
const { Tool } = require('@langchain/core/tools');
|
||||||
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
||||||
|
|
||||||
|
|
@ -102,13 +103,19 @@ class TavilySearchResults extends Tool {
|
||||||
...this.kwargs,
|
...this.kwargs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('https://api.tavily.com/search', {
|
const fetchOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.tavily.com/search', fetchOptions);
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
const { fetch, ProxyAgent } = require('undici');
|
||||||
const TavilySearchResults = require('../TavilySearchResults');
|
const TavilySearchResults = require('../TavilySearchResults');
|
||||||
|
|
||||||
jest.mock('node-fetch');
|
jest.mock('undici');
|
||||||
jest.mock('@langchain/core/utils/env');
|
jest.mock('@langchain/core/utils/env');
|
||||||
|
|
||||||
describe('TavilySearchResults', () => {
|
describe('TavilySearchResults', () => {
|
||||||
|
|
@ -13,6 +14,7 @@ describe('TavilySearchResults', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
process.env = {
|
process.env = {
|
||||||
...originalEnv,
|
...originalEnv,
|
||||||
TAVILY_API_KEY: mockApiKey,
|
TAVILY_API_KEY: mockApiKey,
|
||||||
|
|
@ -20,7 +22,6 @@ describe('TavilySearchResults', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -35,4 +36,49 @@ describe('TavilySearchResults', () => {
|
||||||
});
|
});
|
||||||
expect(instance.apiKey).toBe(mockApiKey);
|
expect(instance.apiKey).toBe(mockApiKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('proxy support', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: jest.fn().mockResolvedValue({ results: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetch.mockResolvedValue(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use ProxyAgent when PROXY env var is set', async () => {
|
||||||
|
const proxyUrl = 'http://proxy.example.com:8080';
|
||||||
|
process.env.PROXY = proxyUrl;
|
||||||
|
|
||||||
|
const mockProxyAgent = { type: 'proxy-agent' };
|
||||||
|
ProxyAgent.mockImplementation(() => mockProxyAgent);
|
||||||
|
|
||||||
|
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
|
||||||
|
await instance._call({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.tavily.com/search',
|
||||||
|
expect.objectContaining({
|
||||||
|
dispatcher: mockProxyAgent,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use ProxyAgent when PROXY env var is not set', async () => {
|
||||||
|
delete process.env.PROXY;
|
||||||
|
|
||||||
|
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
|
||||||
|
await instance._call({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(ProxyAgent).not.toHaveBeenCalled();
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.tavily.com/search',
|
||||||
|
expect.not.objectContaining({
|
||||||
|
dispatcher: expect.anything(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,15 +78,14 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
return tool(
|
return tool(
|
||||||
async ({ query }) => {
|
async ({ query }) => {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return 'No files to search. Instruct the user to add files for the search.';
|
return ['No files to search. Instruct the user to add files for the search.', undefined];
|
||||||
}
|
}
|
||||||
const jwtToken = generateShortLivedToken(userId);
|
const jwtToken = generateShortLivedToken(userId);
|
||||||
if (!jwtToken) {
|
if (!jwtToken) {
|
||||||
return 'There was an error authenticating the file search request.';
|
return ['There was an error authenticating the file search request.', undefined];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* @param {import('librechat-data-provider').TFile} file
|
* @param {import('librechat-data-provider').TFile} file
|
||||||
* @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
|
* @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
|
||||||
*/
|
*/
|
||||||
|
|
@ -122,7 +121,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
const validResults = results.filter((result) => result !== null);
|
const validResults = results.filter((result) => result !== null);
|
||||||
|
|
||||||
if (validResults.length === 0) {
|
if (validResults.length === 0) {
|
||||||
return 'No results found or errors occurred while searching the files.';
|
return ['No results found or errors occurred while searching the files.', undefined];
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedResults = validResults
|
const formattedResults = validResults
|
||||||
|
|
@ -135,11 +134,16 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
page: docInfo.metadata.page || null,
|
page: docInfo.metadata.page || null,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
// TODO: results should be sorted by relevance, not distance
|
|
||||||
.sort((a, b) => a.distance - b.distance)
|
.sort((a, b) => a.distance - b.distance)
|
||||||
// TODO: make this configurable
|
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (formattedResults.length === 0) {
|
||||||
|
return [
|
||||||
|
'No content found in the files. The files may not have been processed correctly or you may need to refine your query.',
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const formattedString = formattedResults
|
const formattedString = formattedResults
|
||||||
.map(
|
.map(
|
||||||
(result, index) =>
|
(result, index) =>
|
||||||
|
|
@ -169,11 +173,12 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
? `
|
? `
|
||||||
|
|
||||||
**CITE FILE SEARCH RESULTS:**
|
**CITE FILE SEARCH RESULTS:**
|
||||||
Use anchor markers immediately after statements derived from file content. Reference the filename in your text:
|
Use the EXACT anchor markers shown below (copy them verbatim) immediately after statements derived from file content. Reference the filename in your text:
|
||||||
- File citation: "The document.pdf states that... \\ue202turn0file0"
|
- File citation: "The document.pdf states that... \\ue202turn0file0"
|
||||||
- Page reference: "According to report.docx... \\ue202turn0file1"
|
- Page reference: "According to report.docx... \\ue202turn0file1"
|
||||||
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
|
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
|
||||||
|
|
||||||
|
**CRITICAL:** Output these escape sequences EXACTLY as shown (e.g., \\ue202turn0file0). Do NOT substitute with other characters like † or similar symbols.
|
||||||
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
|
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
|
||||||
: ''
|
: ''
|
||||||
}`,
|
}`,
|
||||||
|
|
|
||||||
|
|
@ -317,14 +317,22 @@ const loadTools = async ({
|
||||||
requestedTools[tool] = async () => {
|
requestedTools[tool] = async () => {
|
||||||
toolContextMap[tool] = `# \`${tool}\`:
|
toolContextMap[tool] = `# \`${tool}\`:
|
||||||
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
1. **Execute immediately without preface** when using \`${tool}\`.
|
|
||||||
2. **After the search, begin with a brief summary** that directly addresses the query without headers or explaining your process.
|
**Execute immediately without preface.** After search, provide a brief summary addressing the query directly, then structure your response with clear Markdown formatting (## headers, lists, tables). Cite sources properly, tailor tone to query type, and provide comprehensive details.
|
||||||
3. **Structure your response clearly** using Markdown formatting (Level 2 headers for sections, lists for multiple points, tables for comparisons).
|
|
||||||
4. **Cite sources properly** according to the citation anchor format, utilizing group anchors when appropriate.
|
**CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
|
||||||
5. **Tailor your approach to the query type** (academic, news, coding, etc.) while maintaining an expert, journalistic, unbiased tone.
|
Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
|
||||||
6. **Provide comprehensive information** with specific details, examples, and as much relevant context as possible from search results.
|
|
||||||
7. **Avoid moralizing language.**
|
Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|news|image|ref, index=0,1,2...
|
||||||
`.trim();
|
|
||||||
|
**Examples (copy these exactly):**
|
||||||
|
- Single: "Statement.\\ue202turn0search0"
|
||||||
|
- Multiple: "Statement.\\ue202turn0search0\\ue202turn0news1"
|
||||||
|
- Group: "Statement. \\ue200\\ue202turn0search0\\ue202turn0news1\\ue201"
|
||||||
|
- Highlight: "\\ue203Cited text.\\ue204\\ue202turn0search0"
|
||||||
|
- Image: "See photo\\ue202turn0image0."
|
||||||
|
|
||||||
|
**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with † or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
|
||||||
return createSearchTool({
|
return createSearchTool({
|
||||||
...result.authResult,
|
...result.authResult,
|
||||||
onSearchResults,
|
onSearchResults,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ const {
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||||
const { getMCPServerTools } = require('~/server/services/Config');
|
const { getMCPServerTools } = require('~/server/services/Config');
|
||||||
|
const { Agent, AclEntry } = require('~/db/models');
|
||||||
const { getActions } = require('./Action');
|
const { getActions } = require('./Action');
|
||||||
const { Agent } = require('~/db/models');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an agent with the provided data.
|
* Create an agent with the provided data.
|
||||||
|
|
@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => {
|
||||||
return agent;
|
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.
|
* Get agents by accessible IDs with optional cursor-based pagination.
|
||||||
* @param {Object} params - The parameters for getting accessible agents.
|
* @param {Object} params - The parameters for getting accessible agents.
|
||||||
|
|
@ -856,6 +887,7 @@ module.exports = {
|
||||||
createAgent,
|
createAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
|
deleteUserAgents,
|
||||||
getListAgents,
|
getListAgents,
|
||||||
revertAgentVersion,
|
revertAgentVersion,
|
||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
|
|
|
||||||
|
|
@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
|
||||||
*
|
*
|
||||||
* @async
|
* @async
|
||||||
* @function deleteMessages
|
* @function deleteMessages
|
||||||
* @param {Object} filter - The filter criteria to find messages to delete.
|
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
|
||||||
* @returns {Promise<Object>} The metadata with count of deleted messages.
|
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
|
||||||
* @throws {Error} If there is an error in deleting messages.
|
* @throws {Error} If there is an error in deleting messages.
|
||||||
*/
|
*/
|
||||||
async function deleteMessages(filter) {
|
async function deleteMessages(filter) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { ObjectId } = require('mongodb');
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { escapeRegExp } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -13,8 +14,7 @@ const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||||
const { PromptGroup, Prompt } = require('~/db/models');
|
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
||||||
const { escapeRegExp } = require('~/server/utils');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a pipeline for the aggregation to get prompt groups
|
* Create a pipeline for the aggregation to get prompt groups
|
||||||
|
|
@ -591,6 +591,36 @@ module.exports = {
|
||||||
return { prompt: 'Prompt deleted successfully' };
|
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
|
* Update prompt group
|
||||||
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
|
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,12 @@ const tokenValues = Object.assign(
|
||||||
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
|
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
|
||||||
'claude-haiku-4-5': { prompt: 1, completion: 5 },
|
'claude-haiku-4-5': { prompt: 1, completion: 5 },
|
||||||
'claude-opus-4': { prompt: 15, completion: 75 },
|
'claude-opus-4': { prompt: 15, completion: 75 },
|
||||||
|
'claude-opus-4-5': { prompt: 5, completion: 25 },
|
||||||
'claude-sonnet-4': { prompt: 3, completion: 15 },
|
'claude-sonnet-4': { prompt: 3, completion: 15 },
|
||||||
'command-r': { prompt: 0.5, completion: 1.5 },
|
'command-r': { prompt: 0.5, completion: 1.5 },
|
||||||
'command-r-plus': { prompt: 3, completion: 15 },
|
'command-r-plus': { prompt: 3, completion: 15 },
|
||||||
'command-text': { prompt: 1.5, completion: 2.0 },
|
'command-text': { prompt: 1.5, completion: 2.0 },
|
||||||
|
'deepseek-chat': { prompt: 0.28, completion: 0.42 },
|
||||||
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
|
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
|
||||||
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
|
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
|
||||||
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
|
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
|
||||||
|
|
@ -156,6 +158,7 @@ const tokenValues = Object.assign(
|
||||||
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
||||||
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
|
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
|
||||||
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||||
|
'gemini-3': { prompt: 2, completion: 12 },
|
||||||
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
|
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
|
||||||
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
|
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
|
||||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
||||||
|
|
@ -171,6 +174,9 @@ const tokenValues = Object.assign(
|
||||||
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
||||||
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
||||||
'grok-4': { prompt: 3.0, completion: 15.0 },
|
'grok-4': { prompt: 3.0, completion: 15.0 },
|
||||||
|
'grok-4-fast': { prompt: 0.2, completion: 0.5 },
|
||||||
|
'grok-4-1-fast': { prompt: 0.2, completion: 0.5 }, // covers reasoning & non-reasoning variants
|
||||||
|
'grok-code-fast': { prompt: 0.2, completion: 1.5 },
|
||||||
codestral: { prompt: 0.3, completion: 0.9 },
|
codestral: { prompt: 0.3, completion: 0.9 },
|
||||||
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
||||||
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
||||||
|
|
@ -237,8 +243,14 @@ const cacheTokenValues = {
|
||||||
'claude-3.5-haiku': { write: 1, read: 0.08 },
|
'claude-3.5-haiku': { write: 1, read: 0.08 },
|
||||||
'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-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-sonnet-4': { write: 3.75, read: 0.3 },
|
||||||
'claude-opus-4': { write: 18.75, read: 1.5 },
|
'claude-opus-4': { write: 18.75, read: 1.5 },
|
||||||
|
'claude-opus-4-5': { write: 6.25, read: 0.5 },
|
||||||
|
// DeepSeek models - cache hit: $0.028/1M, cache miss: $0.28/1M
|
||||||
|
deepseek: { write: 0.28, read: 0.028 },
|
||||||
|
'deepseek-chat': { write: 0.28, read: 0.028 },
|
||||||
|
'deepseek-reasoner': { write: 0.28, read: 0.028 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -766,6 +766,78 @@ describe('Deepseek Model Tests', () => {
|
||||||
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
|
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return correct pricing for deepseek-chat', () => {
|
||||||
|
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['deepseek-chat'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['deepseek-chat'].completion,
|
||||||
|
);
|
||||||
|
expect(tokenValues['deepseek-chat'].prompt).toBe(0.28);
|
||||||
|
expect(tokenValues['deepseek-chat'].completion).toBe(0.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct pricing for deepseek-reasoner', () => {
|
||||||
|
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['deepseek-reasoner'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['deepseek-reasoner'].completion,
|
||||||
|
);
|
||||||
|
expect(tokenValues['deepseek-reasoner'].prompt).toBe(0.28);
|
||||||
|
expect(tokenValues['deepseek-reasoner'].completion).toBe(0.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DeepSeek model name variations with provider prefixes', () => {
|
||||||
|
const modelVariations = [
|
||||||
|
'deepseek/deepseek-chat',
|
||||||
|
'openrouter/deepseek-chat',
|
||||||
|
'deepseek/deepseek-reasoner',
|
||||||
|
];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
||||||
|
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
|
||||||
|
expect(promptMultiplier).toBe(0.28);
|
||||||
|
expect(completionMultiplier).toBe(0.42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct cache multipliers for DeepSeek models', () => {
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'write' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-chat'].write,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'read' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-chat'].read,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'write' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-reasoner'].write,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'read' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-reasoner'].read,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct cache pricing values for DeepSeek models', () => {
|
||||||
|
expect(cacheTokenValues['deepseek-chat'].write).toBe(0.28);
|
||||||
|
expect(cacheTokenValues['deepseek-chat'].read).toBe(0.028);
|
||||||
|
expect(cacheTokenValues['deepseek-reasoner'].write).toBe(0.28);
|
||||||
|
expect(cacheTokenValues['deepseek-reasoner'].read).toBe(0.028);
|
||||||
|
expect(cacheTokenValues['deepseek'].write).toBe(0.28);
|
||||||
|
expect(cacheTokenValues['deepseek'].read).toBe(0.028);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DeepSeek cache multipliers with model variations', () => {
|
||||||
|
const modelVariations = ['deepseek/deepseek-chat', 'openrouter/deepseek-reasoner'];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
||||||
|
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
||||||
|
expect(writeMultiplier).toBe(0.28);
|
||||||
|
expect(readMultiplier).toBe(0.028);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Qwen3 Model Tests', () => {
|
describe('Qwen3 Model Tests', () => {
|
||||||
|
|
@ -1040,6 +1112,7 @@ describe('getCacheMultiplier', () => {
|
||||||
|
|
||||||
describe('Google Model Tests', () => {
|
describe('Google Model Tests', () => {
|
||||||
const googleModels = [
|
const googleModels = [
|
||||||
|
'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',
|
||||||
|
|
@ -1083,6 +1156,7 @@ describe('Google Model Tests', () => {
|
||||||
|
|
||||||
it('should map to the correct model keys', () => {
|
it('should map to the correct model keys', () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
|
'gemini-3': 'gemini-3',
|
||||||
'gemini-2.5-pro': 'gemini-2.5-pro',
|
'gemini-2.5-pro': 'gemini-2.5-pro',
|
||||||
'gemini-2.5-flash': 'gemini-2.5-flash',
|
'gemini-2.5-flash': 'gemini-2.5-flash',
|
||||||
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
|
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
|
||||||
|
|
@ -1203,6 +1277,39 @@ describe('Grok Model Tests - Pricing', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4 Fast model', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4.1 Fast models', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok Code Fast model', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
||||||
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
||||||
tokenValues['grok-3'].prompt,
|
tokenValues['grok-3'].prompt,
|
||||||
|
|
@ -1238,6 +1345,39 @@ describe('Grok Model Tests - Pricing', () => {
|
||||||
tokenValues['grok-4'].completion,
|
tokenValues['grok-4'].completion,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4 Fast model with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4.1 Fast models with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'completion' }),
|
||||||
|
).toBe(tokenValues['grok-4-1-fast'].completion);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok Code Fast model with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1370,6 +1510,15 @@ describe('Claude Model Tests', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return correct prompt and completion rates for Claude Opus 4.5', () => {
|
||||||
|
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['claude-opus-4-5'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['claude-opus-4-5'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle Claude Haiku 4.5 model name variations', () => {
|
it('should handle Claude Haiku 4.5 model name variations', () => {
|
||||||
const modelVariations = [
|
const modelVariations = [
|
||||||
'claude-haiku-4-5',
|
'claude-haiku-4-5',
|
||||||
|
|
@ -1392,6 +1541,28 @@ describe('Claude Model Tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle Claude Opus 4.5 model name variations', () => {
|
||||||
|
const modelVariations = [
|
||||||
|
'claude-opus-4-5',
|
||||||
|
'claude-opus-4-5-20250420',
|
||||||
|
'claude-opus-4-5-latest',
|
||||||
|
'anthropic/claude-opus-4-5',
|
||||||
|
'claude-opus-4-5/anthropic',
|
||||||
|
'claude-opus-4-5-preview',
|
||||||
|
];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
const valueKey = getValueKey(model);
|
||||||
|
expect(valueKey).toBe('claude-opus-4-5');
|
||||||
|
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['claude-opus-4-5'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['claude-opus-4-5'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
||||||
const modelVariations = [
|
const modelVariations = [
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
|
|
@ -1438,6 +1609,15 @@ describe('Claude Model Tests', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return correct cache rates for Claude Opus 4.5', () => {
|
||||||
|
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'write' })).toBe(
|
||||||
|
cacheTokenValues['claude-opus-4-5'].write,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'read' })).toBe(
|
||||||
|
cacheTokenValues['claude-opus-4-5'].read,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => {
|
it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => {
|
||||||
const modelVariations = [
|
const modelVariations = [
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/backend",
|
"name": "@librechat/backend",
|
||||||
"version": "v0.8.1-rc1",
|
"version": "v0.8.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "echo 'please run this from the root directory'",
|
"start": "echo 'please run this from the root directory'",
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
"@langchain/google-genai": "^0.2.13",
|
"@langchain/google-genai": "^0.2.13",
|
||||||
"@langchain/google-vertexai": "^0.2.13",
|
"@langchain/google-vertexai": "^0.2.13",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^3.0.17",
|
"@librechat/agents": "^3.0.50",
|
||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
|
|
@ -92,7 +92,7 @@
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^7.0.11",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "5.8.2",
|
"openai": "5.8.2",
|
||||||
"openid-client": "^6.5.0",
|
"openid-client": "^6.5.0",
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,9 @@ function disposeClient(client) {
|
||||||
if (client.agentConfigs) {
|
if (client.agentConfigs) {
|
||||||
client.agentConfigs = null;
|
client.agentConfigs = null;
|
||||||
}
|
}
|
||||||
|
if (client.agentIdMap) {
|
||||||
|
client.agentIdMap = null;
|
||||||
|
}
|
||||||
if (client.artifactPromises) {
|
if (client.artifactPromises) {
|
||||||
client.artifactPromises = null;
|
client.artifactPromises = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,15 @@ const refreshController = async (req, res) => {
|
||||||
if (error || !user) {
|
if (error || !user) {
|
||||||
return res.status(401).redirect('/login');
|
return res.status(401).redirect('/login');
|
||||||
}
|
}
|
||||||
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
|
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
|
||||||
|
|
||||||
|
user.federatedTokens = {
|
||||||
|
access_token: tokenset.access_token,
|
||||||
|
id_token: tokenset.id_token,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_at: claims.exp,
|
||||||
|
};
|
||||||
|
|
||||||
return res.status(200).send({ token, user });
|
return res.status(200).send({ token, user });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[refreshController] OpenID token refresh error', error);
|
logger.error('[refreshController] OpenID token refresh error', error);
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
|
||||||
const {
|
const {
|
||||||
MCPOAuthHandler,
|
MCPOAuthHandler,
|
||||||
MCPTokenStorage,
|
MCPTokenStorage,
|
||||||
|
mcpServersRegistry,
|
||||||
normalizeHttpError,
|
normalizeHttpError,
|
||||||
extractWebSearchEnvVars,
|
extractWebSearchEnvVars,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
getFiles,
|
|
||||||
findToken,
|
|
||||||
updateUser,
|
|
||||||
deleteFiles,
|
|
||||||
deleteConvos,
|
|
||||||
deletePresets,
|
|
||||||
deleteMessages,
|
|
||||||
deleteUserById,
|
|
||||||
deleteAllSharedLinks,
|
|
||||||
deleteAllUserSessions,
|
deleteAllUserSessions,
|
||||||
|
deleteAllSharedLinks,
|
||||||
|
deleteUserById,
|
||||||
|
deleteMessages,
|
||||||
|
deletePresets,
|
||||||
|
deleteConvos,
|
||||||
|
deleteFiles,
|
||||||
|
updateUser,
|
||||||
|
findToken,
|
||||||
|
getFiles,
|
||||||
} = require('~/models');
|
} = require('~/models');
|
||||||
|
const {
|
||||||
|
ConversationTag,
|
||||||
|
Transaction,
|
||||||
|
MemoryEntry,
|
||||||
|
Assistant,
|
||||||
|
AclEntry,
|
||||||
|
Balance,
|
||||||
|
Action,
|
||||||
|
Group,
|
||||||
|
Token,
|
||||||
|
User,
|
||||||
|
} = require('~/db/models');
|
||||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
||||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
const { Transaction, Balance, User, Token } = require('~/db/models');
|
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { getAppConfig } = require('~/server/services/Config');
|
const { getAppConfig } = require('~/server/services/Config');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
|
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||||
|
const { deleteUserAgents } = require('~/models/Agent');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { mcpServersRegistry } = require('@librechat/api');
|
|
||||||
|
|
||||||
const getUserController = async (req, res) => {
|
const getUserController = async (req, res) => {
|
||||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||||
|
|
@ -237,7 +250,6 @@ const deleteUserController = async (req, res) => {
|
||||||
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
|
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
|
||||||
await Balance.deleteMany({ user: user._id }); // delete user balances
|
await Balance.deleteMany({ user: user._id }); // delete user balances
|
||||||
await deletePresets(user.id); // delete user presets
|
await deletePresets(user.id); // delete user presets
|
||||||
/* TODO: Delete Assistant Threads */
|
|
||||||
try {
|
try {
|
||||||
await deleteConvos(user.id); // delete user convos
|
await deleteConvos(user.id); // delete user convos
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => {
|
||||||
await deleteUserFiles(req); // delete user files
|
await deleteUserFiles(req); // delete user files
|
||||||
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
|
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 deleteToolCalls(user.id); // delete user tool calls
|
||||||
/* TODO: queue job for cleaning actions and assistants of non-existant users */
|
await deleteUserAgents(user.id); // delete user agents
|
||||||
|
await Assistant.deleteMany({ user: user.id }); // delete user assistants
|
||||||
|
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
|
||||||
|
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
|
||||||
|
await deleteUserPrompts(req, user.id); // delete user prompts
|
||||||
|
await Action.deleteMany({ user: user.id }); // delete user actions
|
||||||
|
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
|
||||||
|
await Group.updateMany(
|
||||||
|
// remove user from all groups
|
||||||
|
{ memberIds: user.id },
|
||||||
|
{ $pull: { memberIds: user.id } },
|
||||||
|
);
|
||||||
|
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
|
||||||
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
|
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
|
||||||
res.status(200).send({ message: 'User deleted' });
|
res.status(200).send({ message: 'User deleted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ class ModelEndHandler {
|
||||||
* @param {ModelEndData | undefined} data
|
* @param {ModelEndData | undefined} data
|
||||||
* @param {Record<string, unknown> | undefined} metadata
|
* @param {Record<string, unknown> | undefined} metadata
|
||||||
* @param {StandardGraph} graph
|
* @param {StandardGraph} graph
|
||||||
* @returns
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
handle(event, data, metadata, graph) {
|
async handle(event, data, metadata, graph) {
|
||||||
if (!graph || !metadata) {
|
if (!graph || !metadata) {
|
||||||
console.warn(`Graph or metadata not found in ${event} event`);
|
console.warn(`Graph or metadata not found in ${event} event`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -79,7 +79,7 @@ class ModelEndHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
|
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
|
||||||
handleToolCalls(toolCalls, metadata, graph);
|
await handleToolCalls(toolCalls, metadata, graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = data?.output?.usage_metadata;
|
const usage = data?.output?.usage_metadata;
|
||||||
|
|
@ -101,7 +101,7 @@ class ModelEndHandler {
|
||||||
const stepKey = graph.getStepKey(metadata);
|
const stepKey = graph.getStepKey(metadata);
|
||||||
const message_id = getMessageId(stepKey, graph) ?? '';
|
const message_id = getMessageId(stepKey, graph) ?? '';
|
||||||
if (message_id) {
|
if (message_id) {
|
||||||
graph.dispatchRunStep(stepKey, {
|
await graph.dispatchRunStep(stepKey, {
|
||||||
type: StepTypes.MESSAGE_CREATION,
|
type: StepTypes.MESSAGE_CREATION,
|
||||||
message_creation: {
|
message_creation: {
|
||||||
message_id,
|
message_id,
|
||||||
|
|
@ -111,7 +111,7 @@ class ModelEndHandler {
|
||||||
const stepId = graph.getStepIdByKey(stepKey);
|
const stepId = graph.getStepIdByKey(stepKey);
|
||||||
const content = data.output.content;
|
const content = data.output.content;
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
graph.dispatchMessageDelta(stepId, {
|
await graph.dispatchMessageDelta(stepId, {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|
@ -120,7 +120,7 @@ class ModelEndHandler {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} else if (content.every((c) => c.type?.startsWith('text'))) {
|
} else if (content.every((c) => c.type?.startsWith('text'))) {
|
||||||
graph.dispatchMessageDelta(stepId, {
|
await graph.dispatchMessageDelta(stepId, {
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +162,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||||
}
|
}
|
||||||
const handlers = {
|
const handlers = {
|
||||||
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
|
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
|
||||||
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback),
|
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
|
||||||
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
|
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
|
||||||
[GraphEvents.ON_RUN_STEP]: {
|
[GraphEvents.ON_RUN_STEP]: {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const {
|
||||||
logAxiosError,
|
logAxiosError,
|
||||||
sanitizeTitle,
|
sanitizeTitle,
|
||||||
resolveHeaders,
|
resolveHeaders,
|
||||||
|
createSafeUser,
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
memoryInstructions,
|
memoryInstructions,
|
||||||
getTransactionsConfig,
|
getTransactionsConfig,
|
||||||
|
|
@ -20,6 +21,7 @@ const {
|
||||||
Providers,
|
Providers,
|
||||||
TitleMethod,
|
TitleMethod,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
|
labelContentByAgent,
|
||||||
formatAgentMessages,
|
formatAgentMessages,
|
||||||
getTokenCountForMessage,
|
getTokenCountForMessage,
|
||||||
createMetadataAggregator,
|
createMetadataAggregator,
|
||||||
|
|
@ -92,6 +94,61 @@ function logToolError(graph, error, toolId) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies agent labeling to conversation history when multi-agent patterns are detected.
|
||||||
|
* Labels content parts by their originating agent to prevent identity confusion.
|
||||||
|
*
|
||||||
|
* @param {TMessage[]} orderedMessages - The ordered conversation messages
|
||||||
|
* @param {Agent} primaryAgent - The primary agent configuration
|
||||||
|
* @param {Map<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 {
|
class AgentClient extends BaseClient {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
super(null, options);
|
super(null, options);
|
||||||
|
|
@ -141,6 +198,8 @@ class AgentClient extends BaseClient {
|
||||||
this.indexTokenCountMap = {};
|
this.indexTokenCountMap = {};
|
||||||
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
||||||
this.processMemory;
|
this.processMemory;
|
||||||
|
/** @type {Record<number, string> | null} */
|
||||||
|
this.agentIdMap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -233,6 +292,12 @@ class AgentClient extends BaseClient {
|
||||||
summary: this.shouldSummarize,
|
summary: this.shouldSummarize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
orderedMessages = applyAgentLabelsToHistory(
|
||||||
|
orderedMessages,
|
||||||
|
this.options.agent,
|
||||||
|
this.agentConfigs,
|
||||||
|
);
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
/** @type {number | undefined} */
|
/** @type {number | undefined} */
|
||||||
let promptTokens;
|
let promptTokens;
|
||||||
|
|
@ -612,7 +677,11 @@ class AgentClient extends BaseClient {
|
||||||
userMCPAuthMap: opts.userMCPAuthMap,
|
userMCPAuthMap: opts.userMCPAuthMap,
|
||||||
abortController: opts.abortController,
|
abortController: opts.abortController,
|
||||||
});
|
});
|
||||||
return filterMalformedContentParts(this.contentParts);
|
|
||||||
|
const completion = filterMalformedContentParts(this.contentParts);
|
||||||
|
const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
|
||||||
|
|
||||||
|
return { completion, metadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -788,7 +857,7 @@ class AgentClient extends BaseClient {
|
||||||
conversationId: this.conversationId,
|
conversationId: this.conversationId,
|
||||||
parentMessageId: this.parentMessageId,
|
parentMessageId: this.parentMessageId,
|
||||||
},
|
},
|
||||||
user: this.options.req.user,
|
user: createSafeUser(this.options.req.user),
|
||||||
},
|
},
|
||||||
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
|
@ -864,6 +933,7 @@ class AgentClient extends BaseClient {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
customHandlers: this.options.eventHandlers,
|
customHandlers: this.options.eventHandlers,
|
||||||
requestBody: config.configurable.requestBody,
|
requestBody: config.configurable.requestBody,
|
||||||
|
user: createSafeUser(this.options.req?.user),
|
||||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -902,6 +972,24 @@ class AgentClient extends BaseClient {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** Capture agent ID map if we have edges or multiple agents */
|
||||||
|
const shouldStoreAgentMap =
|
||||||
|
(this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0;
|
||||||
|
if (shouldStoreAgentMap && run?.Graph) {
|
||||||
|
const contentPartAgentMap = run.Graph.getContentPartAgentMap();
|
||||||
|
if (contentPartAgentMap && contentPartAgentMap.size > 0) {
|
||||||
|
this.agentIdMap = Object.fromEntries(contentPartAgentMap);
|
||||||
|
logger.debug('[AgentClient] Captured agent ID map:', {
|
||||||
|
totalParts: this.contentParts.length,
|
||||||
|
mappedParts: Object.keys(this.agentIdMap).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[AgentClient] Error capturing agent ID map:', error);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||||
|
|
@ -935,6 +1023,9 @@ class AgentClient extends BaseClient {
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
run = null;
|
||||||
|
config = null;
|
||||||
|
memoryPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1063,6 +1154,7 @@ class AgentClient extends BaseClient {
|
||||||
if (clientOptions?.configuration?.defaultHeaders != null) {
|
if (clientOptions?.configuration?.defaultHeaders != null) {
|
||||||
clientOptions.configuration.defaultHeaders = resolveHeaders({
|
clientOptions.configuration.defaultHeaders = resolveHeaders({
|
||||||
headers: clientOptions.configuration.defaultHeaders,
|
headers: clientOptions.configuration.defaultHeaders,
|
||||||
|
user: createSafeUser(this.options.req?.user),
|
||||||
body: {
|
body: {
|
||||||
messageId: this.responseMessageId,
|
messageId: this.responseMessageId,
|
||||||
conversationId: this.conversationId,
|
conversationId: this.conversationId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
const { sendEvent } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
const {
|
||||||
|
sendEvent,
|
||||||
|
sanitizeFileForTransmit,
|
||||||
|
sanitizeMessageForTransmit,
|
||||||
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
handleAbortError,
|
handleAbortError,
|
||||||
createAbortController,
|
createAbortController,
|
||||||
|
|
@ -224,13 +228,13 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
// Process files if needed
|
// Process files if needed (sanitize to remove large text fields before transmission)
|
||||||
if (req.body.files && client.options?.attachments) {
|
if (req.body.files && client.options?.attachments) {
|
||||||
userMessage.files = [];
|
userMessage.files = [];
|
||||||
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
|
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
|
||||||
for (let attachment of client.options.attachments) {
|
for (const attachment of client.options.attachments) {
|
||||||
if (messageFiles.has(attachment.file_id)) {
|
if (messageFiles.has(attachment.file_id)) {
|
||||||
userMessage.files.push({ ...attachment });
|
userMessage.files.push(sanitizeFileForTransmit(attachment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete userMessage.image_urls;
|
delete userMessage.image_urls;
|
||||||
|
|
@ -245,7 +249,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
responseMessage: finalResponse,
|
responseMessage: finalResponse,
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
|
|
@ -273,7 +277,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
responseMessage: finalResponse,
|
responseMessage: finalResponse,
|
||||||
error: { message: 'Request was aborted during completion' },
|
error: { message: 'Request was aborted during completion' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ const {
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
Constants,
|
Constants,
|
||||||
SystemRoles,
|
|
||||||
FileSources,
|
FileSources,
|
||||||
ResourceType,
|
ResourceType,
|
||||||
AccessRoleIds,
|
AccessRoleIds,
|
||||||
|
|
@ -20,6 +19,8 @@ const {
|
||||||
PermissionBits,
|
PermissionBits,
|
||||||
actionDelimiter,
|
actionDelimiter,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
|
CacheKeys,
|
||||||
|
Time,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
getListAgentsByAccess,
|
getListAgentsByAccess,
|
||||||
|
|
@ -45,6 +46,7 @@ const { updateAction, getActions } = require('~/models/Action');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const { deleteFileByFilter } = require('~/models/File');
|
const { deleteFileByFilter } = require('~/models/File');
|
||||||
const { getCategoriesWithCounts } = require('~/models');
|
const { getCategoriesWithCounts } = require('~/models');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
const systemTools = {
|
const systemTools = {
|
||||||
[Tools.execute_code]: true,
|
[Tools.execute_code]: true,
|
||||||
|
|
@ -52,6 +54,49 @@ const systemTools = {
|
||||||
[Tools.web_search]: true,
|
[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.
|
* Creates an Agent.
|
||||||
* @route POST /Agents
|
* @route POST /Agents
|
||||||
|
|
@ -142,10 +187,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
|
||||||
agent.version = agent.versions ? agent.versions.length : 0;
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
|
||||||
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||||
const originalUrl = agent.avatar.filepath;
|
try {
|
||||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
agent.avatar = {
|
||||||
if (originalUrl !== agent.avatar.filepath) {
|
...agent.avatar,
|
||||||
await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id });
|
filepath: await refreshS3Url(agent.avatar),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +257,12 @@ const updateAgentHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const validatedData = agentUpdateSchema.parse(req.body);
|
const validatedData = agentUpdateSchema.parse(req.body);
|
||||||
const { _id, ...updateData } = removeNullishValues(validatedData);
|
// Preserve explicit null for avatar to allow resetting the avatar
|
||||||
|
const { avatar: avatarField, _id, ...rest } = validatedData;
|
||||||
|
const updateData = removeNullishValues(rest);
|
||||||
|
if (avatarField === null) {
|
||||||
|
updateData.avatar = avatarField;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert OCR to context in incoming updateData
|
// Convert OCR to context in incoming updateData
|
||||||
convertOcrToContextInPlace(updateData);
|
convertOcrToContextInPlace(updateData);
|
||||||
|
|
@ -342,21 +395,21 @@ const duplicateAgentHandler = async (req, res) => {
|
||||||
const [domain] = action.action_id.split(actionDelimiter);
|
const [domain] = action.action_id.split(actionDelimiter);
|
||||||
const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
|
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(
|
const newAction = await updateAction(
|
||||||
{ action_id: newActionId },
|
{ action_id: newActionId },
|
||||||
{
|
{
|
||||||
metadata: action.metadata,
|
metadata: filteredMetadata,
|
||||||
agent_id: newAgentId,
|
agent_id: newAgentId,
|
||||||
user: userId,
|
user: userId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredMetadata = { ...newAction.metadata };
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
delete filteredMetadata[field];
|
|
||||||
}
|
|
||||||
|
|
||||||
newAction.metadata = filteredMetadata;
|
|
||||||
newActionsList.push(newAction);
|
newActionsList.push(newAction);
|
||||||
return fullActionId;
|
return fullActionId;
|
||||||
};
|
};
|
||||||
|
|
@ -463,13 +516,13 @@ const getListAgentsHandler = async (req, res) => {
|
||||||
filter.is_promoted = { $ne: true };
|
filter.is_promoted = { $ne: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle search filter
|
// Handle search filter (escape regex and cap length)
|
||||||
if (search && search.trim() !== '') {
|
if (search && search.trim() !== '') {
|
||||||
filter.$or = [
|
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
|
||||||
{ name: { $regex: search.trim(), $options: 'i' } },
|
const regex = new RegExp(safeSearch, 'i');
|
||||||
{ description: { $regex: search.trim(), $options: 'i' } },
|
filter.$or = [{ name: regex }, { description: regex }];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get agent IDs the user has VIEW access to via ACL
|
// Get agent IDs the user has VIEW access to via ACL
|
||||||
const accessibleIds = await findAccessibleResources({
|
const accessibleIds = await findAccessibleResources({
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -477,10 +530,12 @@ const getListAgentsHandler = async (req, res) => {
|
||||||
resourceType: ResourceType.AGENT,
|
resourceType: ResourceType.AGENT,
|
||||||
requiredPermissions: requiredPermission,
|
requiredPermissions: requiredPermission,
|
||||||
});
|
});
|
||||||
|
|
||||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||||
resourceType: ResourceType.AGENT,
|
resourceType: ResourceType.AGENT,
|
||||||
requiredPermissions: PermissionBits.VIEW,
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the new ACL-aware function
|
// Use the new ACL-aware function
|
||||||
const data = await getListAgentsByAccess({
|
const data = await getListAgentsByAccess({
|
||||||
accessibleIds,
|
accessibleIds,
|
||||||
|
|
@ -488,13 +543,31 @@ const getListAgentsHandler = async (req, res) => {
|
||||||
limit,
|
limit,
|
||||||
after: cursor,
|
after: cursor,
|
||||||
});
|
});
|
||||||
if (data?.data?.length) {
|
|
||||||
data.data = data.data.map((agent) => {
|
const agents = data?.data ?? [];
|
||||||
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
|
if (!agents.length) {
|
||||||
|
return res.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
|
||||||
|
|
||||||
|
data.data = agents.map((agent) => {
|
||||||
|
try {
|
||||||
|
if (agent?._id && publicSet.has(agent._id.toString())) {
|
||||||
agent.isPublic = true;
|
agent.isPublic = true;
|
||||||
}
|
}
|
||||||
return agent;
|
} catch (e) {
|
||||||
});
|
// Silently ignore mapping errors
|
||||||
|
void e;
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opportunistically refresh S3 avatar URLs for list results with caching
|
||||||
|
try {
|
||||||
|
await refreshListAvatars(data.data, req.user.id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('[/Agents] Skipping avatar refresh for list', err);
|
||||||
}
|
}
|
||||||
return res.json(data);
|
return res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -517,28 +590,21 @@ const getListAgentsHandler = async (req, res) => {
|
||||||
const uploadAgentAvatarHandler = async (req, res) => {
|
const uploadAgentAvatarHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const appConfig = req.config;
|
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 });
|
filterFile({ req, file: req.file, image: true, isAvatar: true });
|
||||||
const { agent_id } = req.params;
|
const { agent_id } = req.params;
|
||||||
if (!agent_id) {
|
if (!agent_id) {
|
||||||
return res.status(400).json({ message: 'Agent ID is required' });
|
return res.status(400).json({ message: 'Agent ID is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
|
||||||
const existingAgent = await getAgent({ id: agent_id });
|
const existingAgent = await getAgent({ id: agent_id });
|
||||||
|
|
||||||
if (!existingAgent) {
|
if (!existingAgent) {
|
||||||
return res.status(404).json({ error: 'Agent not found' });
|
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 buffer = await fs.readFile(req.file.path);
|
||||||
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
|
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
|
||||||
const resizedBuffer = await resizeAvatar({
|
const resizedBuffer = await resizeAvatar({
|
||||||
|
|
@ -571,8 +637,6 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises = [];
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
avatar: {
|
avatar: {
|
||||||
filepath: image.filepath,
|
filepath: image.filepath,
|
||||||
|
|
@ -580,17 +644,16 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
promises.push(
|
const updatedAgent = await updateAgent({ id: agent_id }, data, {
|
||||||
await updateAgent({ id: agent_id }, data, {
|
updatingUserId: req.user.id,
|
||||||
updatingUserId: req.user.id,
|
});
|
||||||
}),
|
res.status(201).json(updatedAgent);
|
||||||
);
|
|
||||||
|
|
||||||
const resolved = await Promise.all(promises);
|
|
||||||
res.status(201).json(resolved[0]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = 'An error occurred while updating the Agent Avatar';
|
const message = 'An error occurred while updating the Agent Avatar';
|
||||||
logger.error(message, error);
|
logger.error(
|
||||||
|
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
res.status(500).json({ message });
|
res.status(500).json({ message });
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|
@ -629,21 +692,13 @@ const revertAgentVersionHandler = async (req, res) => {
|
||||||
return res.status(400).json({ error: 'version_index is required' });
|
return res.status(400).json({ error: 'version_index is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
|
||||||
const existingAgent = await getAgent({ id });
|
const existingAgent = await getAgent({ id });
|
||||||
|
|
||||||
if (!existingAgent) {
|
if (!existingAgent) {
|
||||||
return res.status(404).json({ error: 'Agent not found' });
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthor = existingAgent.author.toString() === req.user.id.toString();
|
// Permissions are enforced via route middleware (ACL EDIT)
|
||||||
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);
|
const updatedAgent = await revertAgentVersion({ id }, version_index);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ jest.mock('~/server/services/PermissionService', () => ({
|
||||||
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
|
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
|
||||||
grantPermission: jest.fn(),
|
grantPermission: jest.fn(),
|
||||||
hasPublicPermission: jest.fn().mockResolvedValue(false),
|
hasPublicPermission: jest.fn().mockResolvedValue(false),
|
||||||
|
checkPermission: jest.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/models', () => ({
|
jest.mock('~/models', () => ({
|
||||||
|
|
@ -573,6 +574,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
||||||
expect(updatedAgent.version).toBe(agentInDb.versions.length);
|
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 () => {
|
test('should handle validation errors properly', async () => {
|
||||||
mockReq.user.id = existingAgentAuthorId.toString();
|
mockReq.user.id = existingAgentAuthorId.toString();
|
||||||
mockReq.params.id = existingAgentId;
|
mockReq.params.id = existingAgentId;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const { v4 } = require('uuid');
|
const { v4 } = require('uuid');
|
||||||
const { sleep } = require('@librechat/agents');
|
const { sleep } = require('@librechat/agents');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Time,
|
Time,
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -33,7 +33,6 @@ const { getTransactions } = require('~/models/Transaction');
|
||||||
const { checkBalance } = require('~/models/balanceMethods');
|
const { checkBalance } = require('~/models/balanceMethods');
|
||||||
const { getConvo } = require('~/models/Conversation');
|
const { getConvo } = require('~/models/Conversation');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { countTokens } = require('~/server/utils');
|
|
||||||
const { getOpenAIClient } = require('./helpers');
|
const { getOpenAIClient } = require('./helpers');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const { v4 } = require('uuid');
|
const { v4 } = require('uuid');
|
||||||
const { sleep } = require('@librechat/agents');
|
const { sleep } = require('@librechat/agents');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Time,
|
Time,
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -30,7 +30,6 @@ const { getTransactions } = require('~/models/Transaction');
|
||||||
const { checkBalance } = require('~/models/balanceMethods');
|
const { checkBalance } = require('~/models/balanceMethods');
|
||||||
const { getConvo } = require('~/models/Conversation');
|
const { getConvo } = require('~/models/Conversation');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { countTokens } = require('~/server/utils');
|
|
||||||
const { getOpenAIClient } = require('./helpers');
|
const { getOpenAIClient } = require('./helpers');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,13 @@ const getMCPTools = async (req, res) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
|
let serverTools;
|
||||||
|
try {
|
||||||
|
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!serverTools) {
|
if (!serverTools) {
|
||||||
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
|
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
417
api/server/experimental.js
Normal file
417
api/server/experimental.js
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
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,
|
||||||
|
handleJsonParseError,
|
||||||
|
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(handleJsonParseError);
|
||||||
|
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/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);
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,7 @@ const {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
ErrorController,
|
ErrorController,
|
||||||
performStartupChecks,
|
performStartupChecks,
|
||||||
|
handleJsonParseError,
|
||||||
initializeFileStorage,
|
initializeFileStorage,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const { connectDb, indexSync } = require('~/db');
|
const { connectDb, indexSync } = require('~/db');
|
||||||
|
|
@ -81,6 +82,7 @@ const startServer = async () => {
|
||||||
app.use(noIndex);
|
app.use(noIndex);
|
||||||
app.use(express.json({ limit: '3mb' }));
|
app.use(express.json({ limit: '3mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||||
|
app.use(handleJsonParseError);
|
||||||
app.use(mongoSanitize());
|
app.use(mongoSanitize());
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
@ -126,7 +128,6 @@ const startServer = async () => {
|
||||||
app.use('/api/presets', routes.presets);
|
app.use('/api/presets', routes.presets);
|
||||||
app.use('/api/prompts', routes.prompts);
|
app.use('/api/prompts', routes.prompts);
|
||||||
app.use('/api/categories', routes.categories);
|
app.use('/api/categories', routes.categories);
|
||||||
app.use('/api/tokenizer', routes.tokenizer);
|
|
||||||
app.use('/api/endpoints', routes.endpoints);
|
app.use('/api/endpoints', routes.endpoints);
|
||||||
app.use('/api/balance', routes.balance);
|
app.use('/api/balance', routes.balance);
|
||||||
app.use('/api/models', routes.models);
|
app.use('/api/models', routes.models);
|
||||||
|
|
@ -213,6 +214,17 @@ process.on('uncaughtException', (err) => {
|
||||||
return;
|
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);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
|
const { countTokens, isEnabled, sendEvent, sanitizeMessageForTransmit } = require('@librechat/api');
|
||||||
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
|
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
|
||||||
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
||||||
const clearPendingReq = require('~/cache/clearPendingReq');
|
const clearPendingReq = require('~/cache/clearPendingReq');
|
||||||
|
|
@ -290,7 +290,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
||||||
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
|
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
requestMessage: userMessage,
|
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
responseMessage: responseMessage,
|
responseMessage: responseMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -61,18 +61,24 @@ async function buildEndpointOption(req, res, next) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentModelSpec.preset.spec = spec;
|
currentModelSpec.preset.spec = spec;
|
||||||
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
|
|
||||||
currentModelSpec.preset.iconURL = currentModelSpec.iconURL;
|
|
||||||
}
|
|
||||||
parsedBody = parseCompactConvo({
|
parsedBody = parseCompactConvo({
|
||||||
endpoint,
|
endpoint,
|
||||||
endpointType,
|
endpointType,
|
||||||
conversation: currentModelSpec.preset,
|
conversation: currentModelSpec.preset,
|
||||||
});
|
});
|
||||||
|
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
|
||||||
|
parsedBody.iconURL = currentModelSpec.iconURL;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
|
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
|
||||||
return handleError(res, { text: 'Error parsing model spec' });
|
return handleError(res, { text: 'Error parsing model spec' });
|
||||||
}
|
}
|
||||||
|
} else if (parsedBody.spec && appConfig.modelSpecs?.list) {
|
||||||
|
// Non-enforced mode: if spec is selected, derive iconURL from model spec
|
||||||
|
const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec);
|
||||||
|
if (modelSpec?.iconURL) {
|
||||||
|
parsedBody.iconURL = modelSpec.iconURL;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { parseConvo } = require('librechat-data-provider');
|
const { parseConvo } = require('librechat-data-provider');
|
||||||
const { sendEvent, handleError } = require('@librechat/api');
|
const { sendEvent, handleError, sanitizeMessageForTransmit } = require('@librechat/api');
|
||||||
const { saveMessage, getMessages } = require('~/models/Message');
|
const { saveMessage, getMessages } = require('~/models/Message');
|
||||||
const { getConvo } = require('~/models/Conversation');
|
const { getConvo } = require('~/models/Conversation');
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ const sendError = async (req, res, options, callback) => {
|
||||||
|
|
||||||
return sendEvent(res, {
|
return sendEvent(res, {
|
||||||
final: true,
|
final: true,
|
||||||
requestMessage: query?.[0] ? query[0] : requestMessage,
|
requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
|
||||||
responseMessage: errorMessage,
|
responseMessage: errorMessage,
|
||||||
conversation: convo,
|
conversation: convo,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { isEnabled } = require('@librechat/api');
|
|
||||||
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||||
|
|
||||||
|
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
|
||||||
jest.mock('@librechat/api', () => ({
|
jest.mock('@librechat/api', () => ({
|
||||||
|
...jest.requireActual('@librechat/api'),
|
||||||
isEnabled: jest.fn(),
|
isEnabled: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
|
|
||||||
describe('validateImageRequest middleware', () => {
|
describe('validateImageRequest middleware', () => {
|
||||||
let req, res, next, validateImageRequest;
|
let req, res, next, validateImageRequest;
|
||||||
const validObjectId = '65cfb246f7ecadb8b1e8036b';
|
const validObjectId = '65cfb246f7ecadb8b1e8036b';
|
||||||
|
|
@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => {
|
||||||
next = jest.fn();
|
next = jest.fn();
|
||||||
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
||||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||||
|
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
|
||||||
|
|
||||||
// Default: OpenID token reuse disabled
|
// Default: OpenID token reuse disabled
|
||||||
isEnabled.mockReturnValue(false);
|
isEnabled.mockReturnValue(false);
|
||||||
|
|
@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => {
|
||||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
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 cookies = require('cookie');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { isEnabled } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { isEnabled, getBasePath } = require('@librechat/api');
|
||||||
|
|
||||||
const OBJECT_ID_LENGTH = 24;
|
const OBJECT_ID_LENGTH = 24;
|
||||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||||
|
|
@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) {
|
||||||
return res.status(403).send('Access Denied');
|
return res.status(403).send('Access Denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
|
const basePath = getBasePath();
|
||||||
|
const imagesPath = `${basePath}/images`;
|
||||||
|
|
||||||
|
const agentAvatarPattern = new RegExp(
|
||||||
|
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
|
||||||
|
);
|
||||||
if (agentAvatarPattern.test(fullPath)) {
|
if (agentAvatarPattern.test(fullPath)) {
|
||||||
logger.debug('[validateImageRequest] Image request validated');
|
logger.debug('[validateImageRequest] Image request validated');
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
|
const pathPattern = new RegExp(
|
||||||
|
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
|
||||||
|
);
|
||||||
|
|
||||||
if (pathPattern.test(fullPath)) {
|
if (pathPattern.test(fullPath)) {
|
||||||
logger.debug('[validateImageRequest] Image request validated');
|
logger.debug('[validateImageRequest] Image request validated');
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,15 @@ router.delete(
|
||||||
* @param {number} req.body.version_index - Index of the version to revert to.
|
* @param {number} req.body.version_index - Index of the version to revert to.
|
||||||
* @returns {Agent} 200 - success response - application/json
|
* @returns {Agent} 200 - success response - application/json
|
||||||
*/
|
*/
|
||||||
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
|
router.post(
|
||||||
|
'/:id/revert',
|
||||||
|
checkGlobalAgentShare,
|
||||||
|
canAccessAgentResource({
|
||||||
|
requiredPermission: PermissionBits.EDIT,
|
||||||
|
resourceIdParam: 'id',
|
||||||
|
}),
|
||||||
|
v1.revertAgentVersion,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of agents.
|
* Returns a list of agents.
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,46 @@ const publicSharedLinksEnabled =
|
||||||
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
||||||
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
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) {
|
router.get('/', async function (req, res) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
|
||||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||||
if (cachedStartupConfig) {
|
if (cachedStartupConfig) {
|
||||||
|
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||||
|
await getMCPServers(cachedStartupConfig, appConfig);
|
||||||
res.send(cachedStartupConfig);
|
res.send(cachedStartupConfig);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -126,35 +161,6 @@ router.get('/', async function (req, res) {
|
||||||
payload.minPasswordLength = minPasswordLength;
|
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;
|
const webSearchConfig = appConfig?.webSearch;
|
||||||
if (
|
if (
|
||||||
webSearchConfig != null &&
|
webSearchConfig != null &&
|
||||||
|
|
@ -184,6 +190,7 @@ router.get('/', async function (req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
||||||
|
await getMCPServers(payload, appConfig);
|
||||||
return res.status(200).send(payload);
|
return res.status(200).send(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error in startup config', err);
|
logger.error('Error in startup config', err);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
const accessPermissions = require('./accessPermissions');
|
const accessPermissions = require('./accessPermissions');
|
||||||
const assistants = require('./assistants');
|
const assistants = require('./assistants');
|
||||||
const categories = require('./categories');
|
const categories = require('./categories');
|
||||||
const tokenizer = require('./tokenizer');
|
|
||||||
const endpoints = require('./endpoints');
|
const endpoints = require('./endpoints');
|
||||||
const staticRoute = require('./static');
|
const staticRoute = require('./static');
|
||||||
const messages = require('./messages');
|
const messages = require('./messages');
|
||||||
|
|
@ -53,7 +52,6 @@ module.exports = {
|
||||||
messages,
|
messages,
|
||||||
memories,
|
memories,
|
||||||
endpoints,
|
endpoints,
|
||||||
tokenizer,
|
|
||||||
assistants,
|
assistants,
|
||||||
categories,
|
categories,
|
||||||
staticRoute,
|
staticRoute,
|
||||||
|
|
|
||||||
|
|
@ -429,13 +429,23 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
|
||||||
const connectionStatus = {};
|
const connectionStatus = {};
|
||||||
|
|
||||||
for (const [serverName] of Object.entries(mcpConfig)) {
|
for (const [serverName] of Object.entries(mcpConfig)) {
|
||||||
connectionStatus[serverName] = await getServerConnectionStatus(
|
try {
|
||||||
user.id,
|
connectionStatus[serverName] = await getServerConnectionStatus(
|
||||||
serverName,
|
user.id,
|
||||||
appConnections,
|
serverName,
|
||||||
userConnections,
|
appConnections,
|
||||||
oauthServers,
|
userConnections,
|
||||||
);
|
oauthServers,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = `Failed to get status for server "${serverName}"`;
|
||||||
|
logger.error(`[MCP Connection Status] ${message},`, error);
|
||||||
|
connectionStatus[serverName] = {
|
||||||
|
connectionState: 'error',
|
||||||
|
requiresOAuth: oauthServers.has(serverName),
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { unescapeLaTeX } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ContentTypes } = require('librechat-data-provider');
|
const { ContentTypes } = require('librechat-data-provider');
|
||||||
|
const { unescapeLaTeX, countTokens } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
saveConvo,
|
saveConvo,
|
||||||
getMessage,
|
getMessage,
|
||||||
|
|
@ -14,7 +14,6 @@ const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/
|
||||||
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
|
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
|
||||||
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
||||||
const { getConvosQueried } = require('~/models/Conversation');
|
const { getConvosQueried } = require('~/models/Conversation');
|
||||||
const { countTokens } = require('~/server/utils');
|
|
||||||
const { Message } = require('~/db/models');
|
const { Message } = require('~/db/models');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const {
|
||||||
markPublicPromptGroups,
|
markPublicPromptGroups,
|
||||||
buildPromptGroupFilter,
|
buildPromptGroupFilter,
|
||||||
formatPromptGroupsResponse,
|
formatPromptGroupsResponse,
|
||||||
|
safeValidatePromptGroupUpdate,
|
||||||
createEmptyPromptGroupsResponse,
|
createEmptyPromptGroupsResponse,
|
||||||
filterAccessibleIdsBySharedLogic,
|
filterAccessibleIdsBySharedLogic,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
|
|
@ -344,7 +345,16 @@ const patchPromptGroup = async (req, res) => {
|
||||||
if (req.user.role === SystemRoles.ADMIN) {
|
if (req.user.role === SystemRoles.ADMIN) {
|
||||||
delete filter.author;
|
delete filter.author;
|
||||||
}
|
}
|
||||||
const promptGroup = await updatePromptGroup(filter, req.body);
|
|
||||||
|
const validationResult = safeValidatePromptGroupUpdate(req.body);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Invalid request body',
|
||||||
|
details: validationResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptGroup = await updatePromptGroup(filter, validationResult.data);
|
||||||
res.status(200).send(promptGroup);
|
res.status(200).send(promptGroup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,169 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
|
||||||
|
let testGroup;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a prompt group
|
||||||
|
testGroup = await PromptGroup.create({
|
||||||
|
name: 'Security Test Group',
|
||||||
|
category: 'security-test',
|
||||||
|
author: testUsers.owner._id,
|
||||||
|
authorName: testUsers.owner.name,
|
||||||
|
productionId: new ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant owner permissions
|
||||||
|
await grantPermission({
|
||||||
|
principalType: PrincipalType.USER,
|
||||||
|
principalId: testUsers.owner._id,
|
||||||
|
resourceType: ResourceType.PROMPTGROUP,
|
||||||
|
resourceId: testGroup._id,
|
||||||
|
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
|
||||||
|
grantedBy: testUsers.owner._id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await PromptGroup.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow updating allowed fields (name, category, oneliner)', async () => {
|
||||||
|
const updateData = {
|
||||||
|
name: 'Updated Group Name',
|
||||||
|
category: 'updated-category',
|
||||||
|
oneliner: 'Updated description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(updateData)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.name).toBe(updateData.name);
|
||||||
|
expect(response.body.category).toBe(updateData.category);
|
||||||
|
expect(response.body.oneliner).toBe(updateData.oneliner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with author field (400 Bad Request)', async () => {
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
author: testUsers.noAccess._id.toString(), // Try to change ownership
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
expect(response.body.details).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with authorName field (400 Bad Request)', async () => {
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
authorName: 'Malicious Author Name',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with _id field (400 Bad Request)', async () => {
|
||||||
|
const newId = new ObjectId();
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
_id: newId.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with productionId field (400 Bad Request)', async () => {
|
||||||
|
const newProductionId = new ObjectId();
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
productionId: newProductionId.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with createdAt field (400 Bad Request)', async () => {
|
||||||
|
const maliciousDate = new Date('2020-01-01');
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
createdAt: maliciousDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with __v field (400 Bad Request)', async () => {
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
__v: 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject request with multiple sensitive fields (400 Bad Request)', async () => {
|
||||||
|
const maliciousUpdate = {
|
||||||
|
name: 'Legit Update',
|
||||||
|
author: testUsers.noAccess._id.toString(),
|
||||||
|
authorName: 'Hacker',
|
||||||
|
_id: new ObjectId().toString(),
|
||||||
|
productionId: new ObjectId().toString(),
|
||||||
|
createdAt: new Date('2020-01-01').toISOString(),
|
||||||
|
__v: 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||||
|
.send(maliciousUpdate)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// Verify the request was rejected with validation errors
|
||||||
|
expect(response.body.error).toBe('Invalid request body');
|
||||||
|
expect(response.body.details).toBeDefined();
|
||||||
|
expect(Array.isArray(response.body.details)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Pagination', () => {
|
describe('Pagination', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Create multiple prompt groups for pagination testing
|
// Create multiple prompt groups for pagination testing
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
const express = require('express');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
|
||||||
const { countTokens } = require('~/server/utils');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post('/', requireJwtAuth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { arg } = req.body;
|
|
||||||
const count = await countTokens(arg?.text ?? arg);
|
|
||||||
res.send({ count });
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[/tokenizer] Error counting tokens', e);
|
|
||||||
res.status(500).json('Error counting tokens');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -8,7 +8,12 @@ const {
|
||||||
deleteUserController,
|
deleteUserController,
|
||||||
getUserController,
|
getUserController,
|
||||||
} = require('~/server/controllers/UserController');
|
} = require('~/server/controllers/UserController');
|
||||||
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
|
const {
|
||||||
|
verifyEmailLimiter,
|
||||||
|
configMiddleware,
|
||||||
|
canDeleteAccount,
|
||||||
|
requireJwtAuth,
|
||||||
|
} = require('~/server/middleware');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController);
|
||||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||||
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
|
router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
|
||||||
router.post('/verify', verifyEmailController);
|
router.post('/verify', verifyEmailController);
|
||||||
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
|
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => {
|
||||||
return { status: 404, message: errorMessage };
|
return { status: 404, message: errorMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password, name, username } = user;
|
const { email, password, name, username, provider } = user;
|
||||||
|
|
||||||
let newUserId;
|
let newUserId;
|
||||||
try {
|
try {
|
||||||
|
|
@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => {
|
||||||
|
|
||||||
const salt = bcrypt.genSaltSync(10);
|
const salt = bcrypt.genSaltSync(10);
|
||||||
const newUserData = {
|
const newUserData = {
|
||||||
provider: 'local',
|
provider: provider ?? 'local',
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
name,
|
name,
|
||||||
|
|
@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
|
||||||
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
||||||
* @returns {String} - access token
|
* @returns {String} - access token
|
||||||
*/
|
*/
|
||||||
const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
|
||||||
try {
|
try {
|
||||||
if (!tokenset) {
|
if (!tokenset) {
|
||||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||||
|
|
@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!tokenset.access_token || !tokenset.refresh_token) {
|
if (!tokenset.access_token) {
|
||||||
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
|
logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.cookie('refreshToken', tokenset.refresh_token, {
|
|
||||||
|
const refreshToken = tokenset.refresh_token || existingRefreshToken;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
logger.error('[setOpenIDAuthTokens] No refresh token available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
expires: expirationDate,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
res.cookie('openid_access_token', tokenset.access_token, {
|
||||||
expires: expirationDate,
|
expires: expirationDate,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isProduction,
|
secure: isProduction,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||||
const serverTools = {};
|
const serverTools = {};
|
||||||
const mcpDelimiter = Constants.mcp_delimiter;
|
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) {
|
for (const tool of tools) {
|
||||||
const name = `${tool.name}${mcpDelimiter}${serverName}`;
|
const name = `${tool.name}${mcpDelimiter}${serverName}`;
|
||||||
serverTools[name] = {
|
serverTools[name] = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
const { ProxyAgent } = require('undici');
|
const { ProxyAgent } = require('undici');
|
||||||
|
const { isUserProvided } = require('@librechat/api');
|
||||||
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
|
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
getUserKeyValues,
|
getUserKeyValues,
|
||||||
|
|
@ -7,7 +8,6 @@ const {
|
||||||
checkUserKeyExpiry,
|
checkUserKeyExpiry,
|
||||||
} = require('~/server/services/UserService');
|
} = require('~/server/services/UserService');
|
||||||
const OAIClient = require('~/app/clients/OpenAIClient');
|
const OAIClient = require('~/app/clients/OpenAIClient');
|
||||||
const { isUserProvided } = require('~/server/utils');
|
|
||||||
|
|
||||||
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
|
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
|
||||||
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;
|
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
const {
|
const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
|
||||||
resolveHeaders,
|
|
||||||
isUserProvided,
|
|
||||||
getOpenAIConfig,
|
|
||||||
getCustomEndpointConfig,
|
|
||||||
} = require('@librechat/api');
|
|
||||||
const {
|
const {
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
ErrorTypes,
|
ErrorTypes,
|
||||||
|
|
@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
||||||
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
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)) {
|
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
||||||
throw new Error(`Missing API Key for ${endpoint}.`);
|
throw new Error(`Missing API Key for ${endpoint}.`);
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||||
}
|
}
|
||||||
|
|
||||||
const customOptions = {
|
const customOptions = {
|
||||||
headers: resolvedHeaders,
|
headers: endpointConfig.headers,
|
||||||
addParams: endpointConfig.addParams,
|
addParams: endpointConfig.addParams,
|
||||||
dropParams: endpointConfig.dropParams,
|
dropParams: endpointConfig.dropParams,
|
||||||
customParams: endpointConfig.customParams,
|
customParams: endpointConfig.customParams,
|
||||||
|
|
|
||||||
|
|
@ -69,17 +69,21 @@ describe('custom/initializeClient', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
|
it('stores original template headers for deferred resolution', async () => {
|
||||||
const { resolveHeaders } = require('@librechat/api');
|
/**
|
||||||
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
|
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
||||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
* in the OpenAIClient or AgentClient, not during initialization.
|
||||||
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
* This test verifies that the initialize function completes successfully with optionsOnly flag,
|
||||||
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
|
* and that headers are passed through to be resolved later during the actual LLM request.
|
||||||
/**
|
*/
|
||||||
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
const result = await initializeClient({
|
||||||
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
|
req: mockRequest,
|
||||||
*/
|
res: mockResponse,
|
||||||
|
optionsOnly: true,
|
||||||
});
|
});
|
||||||
|
// Verify that options are returned for later use
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result).toHaveProperty('useLegacyContent', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if endpoint config is missing', async () => {
|
it('throws if endpoint config is missing', async () => {
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,13 @@ const initGoogle = require('~/server/services/Endpoints/google/initialize');
|
||||||
* @returns {boolean} - True if the provider is a known custom provider, false otherwise
|
* @returns {boolean} - True if the provider is a known custom provider, false otherwise
|
||||||
*/
|
*/
|
||||||
function isKnownCustomProvider(provider) {
|
function isKnownCustomProvider(provider) {
|
||||||
return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
|
return [Providers.XAI, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
|
||||||
provider?.toLowerCase() || '',
|
provider?.toLowerCase() || '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerConfigMap = {
|
const providerConfigMap = {
|
||||||
[Providers.XAI]: initCustom,
|
[Providers.XAI]: initCustom,
|
||||||
[Providers.OLLAMA]: initCustom,
|
|
||||||
[Providers.DEEPSEEK]: initCustom,
|
[Providers.DEEPSEEK]: initCustom,
|
||||||
[Providers.OPENROUTER]: initCustom,
|
[Providers.OPENROUTER]: initCustom,
|
||||||
[EModelEndpoint.openAI]: initOpenAI,
|
[EModelEndpoint.openAI]: initOpenAI,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ const fs = require('fs').promises;
|
||||||
const FormData = require('form-data');
|
const FormData = require('form-data');
|
||||||
const { Readable } = require('stream');
|
const { Readable } = require('stream');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { genAzureEndpoint } = require('@librechat/api');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
|
const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
|
||||||
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
|
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
|
||||||
const { getAppConfig } = require('~/server/services/Config');
|
const { getAppConfig } = require('~/server/services/Config');
|
||||||
|
|
||||||
|
|
@ -34,6 +35,34 @@ const MIME_TO_EXTENSION_MAP = {
|
||||||
'audio/x-flac': 'flac',
|
'audio/x-flac': 'flac',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and extracts ISO-639-1 language code from a locale string.
|
||||||
|
* @param {string} language - The language/locale string (e.g., "en-US", "en", "zh-CN")
|
||||||
|
* @returns {string|null} The ISO-639-1 language code (e.g., "en") or null if invalid
|
||||||
|
*/
|
||||||
|
function getValidatedLanguageCode(language) {
|
||||||
|
try {
|
||||||
|
if (!language) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLanguage = language.toLowerCase();
|
||||||
|
const isValidLocaleCode = /^[a-z]{2}(-[a-z]{2})?$/.test(normalizedLanguage);
|
||||||
|
|
||||||
|
if (isValidLocaleCode) {
|
||||||
|
return normalizedLanguage.split('-')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[STT] Invalid language format "${language}". Expected ISO-639-1 locale code like "en-US" or "en". Skipping language parameter.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[STT] Error validating language code "${language}":`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the file extension from the MIME type.
|
* Gets the file extension from the MIME type.
|
||||||
* @param {string} mimeType - The MIME type.
|
* @param {string} mimeType - The MIME type.
|
||||||
|
|
@ -172,10 +201,9 @@ class STTService {
|
||||||
model: sttSchema.model,
|
model: sttSchema.model,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (language) {
|
const validLanguage = getValidatedLanguageCode(language);
|
||||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
if (validLanguage) {
|
||||||
const isoLanguage = language.split('-')[0];
|
data.language = validLanguage;
|
||||||
data.language = isoLanguage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
|
|
@ -220,10 +248,9 @@ class STTService {
|
||||||
contentType: audioFile.mimetype,
|
contentType: audioFile.mimetype,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (language) {
|
const validLanguage = getValidatedLanguageCode(language);
|
||||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
if (validLanguage) {
|
||||||
const isoLanguage = language.split('-')[0];
|
formData.append('language', validLanguage);
|
||||||
formData.append('language', isoLanguage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
|
|
@ -266,8 +293,14 @@ class STTService {
|
||||||
language,
|
language,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const options = { headers };
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(url, data, { headers });
|
const response = await axios.post(url, data, options);
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
throw new Error('Invalid response from the STT API');
|
throw new Error('Invalid response from the STT API');
|
||||||
|
|
@ -279,7 +312,7 @@ class STTService {
|
||||||
|
|
||||||
return response.data.text.trim();
|
return response.data.text.trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`STT request failed for provider ${provider}:`, error);
|
logAxiosError({ message: `STT request failed for provider ${provider}:`, error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +342,7 @@ class STTService {
|
||||||
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
|
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
|
||||||
res.json({ text });
|
res.json({ text });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('An error occurred while processing the audio:', error);
|
logAxiosError({ message: 'An error occurred while processing the audio:', error });
|
||||||
res.sendStatus(500);
|
res.sendStatus(500);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { genAzureEndpoint } = require('@librechat/api');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
|
const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
|
||||||
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
|
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
|
||||||
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
|
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
|
||||||
const { getAppConfig } = require('~/server/services/Config');
|
const { getAppConfig } = require('~/server/services/Config');
|
||||||
|
|
@ -266,10 +267,14 @@ class TTSService {
|
||||||
|
|
||||||
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
|
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await axios.post(url, data, options);
|
return await axios.post(url, data, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`TTS request failed for provider ${provider}:`, error);
|
logAxiosError({ message: `TTS request failed for provider ${provider}:`, error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +330,10 @@ class TTSService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
logger.error('Error processing manual update:', chunk, innerError);
|
logAxiosError({
|
||||||
|
message: `[TTS] Error processing manual update for chunk: ${chunk?.text?.substring(0, 50)}...`,
|
||||||
|
error: innerError,
|
||||||
|
});
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).end();
|
return res.status(500).end();
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +345,7 @@ class TTSService {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating the audio stream:', error);
|
logAxiosError({ message: '[TTS] Error creating the audio stream:', error });
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).send('An error occurred');
|
return res.status(500).send('An error occurred');
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +415,10 @@ class TTSService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
logger.error('Error processing audio stream update:', update, innerError);
|
logAxiosError({
|
||||||
|
message: `[TTS] Error processing audio stream update: ${update?.text?.substring(0, 50)}...`,
|
||||||
|
error: innerError,
|
||||||
|
});
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).end();
|
return res.status(500).end();
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +435,7 @@ class TTSService {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch audio:', error);
|
logAxiosError({ message: '[TTS] Failed to fetch audio:', error });
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).end();
|
res.status(500).end();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { v4 } = require('uuid');
|
const { v4 } = require('uuid');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { logAxiosError } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { getCodeBaseURL } = require('@librechat/agents');
|
const { getCodeBaseURL } = require('@librechat/agents');
|
||||||
|
const { logAxiosError, getBasePath } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
FileContext,
|
FileContext,
|
||||||
|
|
@ -41,11 +41,12 @@ const processCodeOutput = async ({
|
||||||
const appConfig = req.config;
|
const appConfig = req.config;
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const baseURL = getCodeBaseURL();
|
const baseURL = getCodeBaseURL();
|
||||||
|
const basePath = getBasePath();
|
||||||
const fileExt = path.extname(name);
|
const fileExt = path.extname(name);
|
||||||
if (!fileExt || !imageExtRegex.test(name)) {
|
if (!fileExt || !imageExtRegex.test(name)) {
|
||||||
return {
|
return {
|
||||||
filename: name,
|
filename: name,
|
||||||
filepath: `/api/files/code/download/${session_id}/${id}`,
|
filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
|
||||||
/** Note: expires 24 hours after creation */
|
/** Note: expires 24 hours after creation */
|
||||||
expiresAt: currentDate.getTime() + 86400000,
|
expiresAt: currentDate.getTime() + 86400000,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
|
||||||
|
|
@ -169,14 +169,24 @@ function extractFirebaseFilePath(urlString) {
|
||||||
const deleteFirebaseFile = async (req, file) => {
|
const deleteFirebaseFile = async (req, file) => {
|
||||||
if (file.embedded && process.env.RAG_API_URL) {
|
if (file.embedded && process.env.RAG_API_URL) {
|
||||||
const jwtToken = req.headers.authorization.split(' ')[1];
|
const jwtToken = req.headers.authorization.split(' ')[1];
|
||||||
axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
try {
|
||||||
headers: {
|
await axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||||
Authorization: `Bearer ${jwtToken}`,
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
Authorization: `Bearer ${jwtToken}`,
|
||||||
accept: 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
accept: 'application/json',
|
||||||
data: [file.file_id],
|
},
|
||||||
});
|
data: [file.file_id],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
logger.warn(
|
||||||
|
`[deleteFirebaseFile] Document ${file.file_id} not found in RAG API, may have been deleted already`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error('[deleteFirebaseFile] Error deleting document from RAG API:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = extractFirebaseFilePath(file.filepath);
|
const fileName = extractFirebaseFilePath(file.filepath);
|
||||||
|
|
|
||||||
|
|
@ -210,14 +210,24 @@ const deleteLocalFile = async (req, file) => {
|
||||||
|
|
||||||
if (file.embedded && process.env.RAG_API_URL) {
|
if (file.embedded && process.env.RAG_API_URL) {
|
||||||
const jwtToken = generateShortLivedToken(req.user.id);
|
const jwtToken = generateShortLivedToken(req.user.id);
|
||||||
axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
try {
|
||||||
headers: {
|
await axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||||
Authorization: `Bearer ${jwtToken}`,
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
Authorization: `Bearer ${jwtToken}`,
|
||||||
accept: 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
accept: 'application/json',
|
||||||
data: [file.file_id],
|
},
|
||||||
});
|
data: [file.file_id],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
logger.warn(
|
||||||
|
`[deleteLocalFile] Document ${file.file_id} not found in RAG API, may have been deleted already`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error('[deleteLocalFile] Error deleting document from RAG API:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanFilepath.startsWith(`/uploads/${req.user.id}`)) {
|
if (cleanFilepath.startsWith(`/uploads/${req.user.id}`)) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { Providers } = require('@librechat/agents');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
|
const { logAxiosError, inputSchema, processModelData, isUserProvided } = require('@librechat/api');
|
||||||
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
|
const {
|
||||||
|
CacheKeys,
|
||||||
|
defaultModels,
|
||||||
|
KnownEndpoints,
|
||||||
|
EModelEndpoint,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
const { OllamaClient } = require('~/app/clients/OllamaClient');
|
const { OllamaClient } = require('~/app/clients/OllamaClient');
|
||||||
const { isUserProvided } = require('~/server/utils');
|
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { extractBaseURL } = require('~/utils');
|
const { extractBaseURL } = require('~/utils');
|
||||||
|
|
||||||
|
|
@ -68,7 +71,7 @@ const fetchModels = async ({
|
||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
|
if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) {
|
||||||
try {
|
try {
|
||||||
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
|
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
|
||||||
} catch (ollamaError) {
|
} catch (ollamaError) {
|
||||||
|
|
@ -80,7 +83,9 @@ const fetchModels = async ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options = {
|
const options = {
|
||||||
headers: {},
|
headers: {
|
||||||
|
...(headers ?? {}),
|
||||||
|
},
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -101,7 +106,7 @@ const fetchModels = async ({
|
||||||
options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
|
options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(`${baseURL}${azure ? '' : '/models'}`);
|
const url = new URL(`${baseURL.replace(/\/+$/, '')}${azure ? '' : '/models'}`);
|
||||||
if (user && userIdQuery) {
|
if (user && userIdQuery) {
|
||||||
url.searchParams.append('user', user);
|
url.searchParams.append('user', user);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,70 @@ describe('fetchModels', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass custom headers to the API request', async () => {
|
||||||
|
const customHeaders = {
|
||||||
|
'X-Custom-Header': 'custom-value',
|
||||||
|
'X-API-Version': 'v2',
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com',
|
||||||
|
name: 'TestAPI',
|
||||||
|
headers: customHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('https://api.test.com/models'),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'X-Custom-Header': 'custom-value',
|
||||||
|
'X-API-Version': 'v2',
|
||||||
|
Authorization: 'Bearer testApiKey',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null headers gracefully', async () => {
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com',
|
||||||
|
name: 'TestAPI',
|
||||||
|
headers: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('https://api.test.com/models'),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer testApiKey',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined headers gracefully', async () => {
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com',
|
||||||
|
name: 'TestAPI',
|
||||||
|
headers: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('https://api.test.com/models'),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer testApiKey',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
@ -372,6 +436,68 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchModels URL construction with trailing slashes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
data: [{ id: 'model-1' }, { id: 'model-2' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create double slashes when baseURL has a trailing slash', async () => {
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com/v1/',
|
||||||
|
name: 'TestAPI',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle baseURL without trailing slash normally', async () => {
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com/v1',
|
||||||
|
name: 'TestAPI',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle baseURL with multiple trailing slashes', async () => {
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com/v1///',
|
||||||
|
name: 'TestAPI',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly append query params after stripping trailing slashes', async () => {
|
||||||
|
await fetchModels({
|
||||||
|
user: 'user123',
|
||||||
|
apiKey: 'testApiKey',
|
||||||
|
baseURL: 'https://api.test.com/v1/',
|
||||||
|
name: 'TestAPI',
|
||||||
|
userIdQuery: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
|
'https://api.test.com/v1/models?user=user123',
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('splitAndTrim', () => {
|
describe('splitAndTrim', () => {
|
||||||
it('should split a string by commas and trim each value', () => {
|
it('should split a string by commas and trim each value', () => {
|
||||||
const input = ' model1, model2 , model3,model4 ';
|
const input = ' model1, model2 , model3,model4 ';
|
||||||
|
|
@ -410,6 +536,64 @@ describe('getAnthropicModels', () => {
|
||||||
const models = await getAnthropicModels();
|
const models = await getAnthropicModels();
|
||||||
expect(models).toEqual(['claude-1', 'claude-2']);
|
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', () => {
|
describe('getGoogleModels', () => {
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ const ensurePrincipalExists = async function (principal) {
|
||||||
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
existingUser = await findUser({ email: principal.email.toLowerCase() });
|
existingUser = await findUser({ email: principal.email });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { v4 } = require('uuid');
|
const { v4 } = require('uuid');
|
||||||
|
const { countTokens, escapeRegExp } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
|
|
@ -8,7 +9,6 @@ const {
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
|
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
|
||||||
const { recordMessage, getMessages } = require('~/models/Message');
|
const { recordMessage, getMessages } = require('~/models/Message');
|
||||||
const { countTokens, escapeRegExp } = require('~/server/utils');
|
|
||||||
const { spendTokens } = require('~/models/spendTokens');
|
const { spendTokens } = require('~/models/spendTokens');
|
||||||
const { saveConvo } = require('~/models/Conversation');
|
const { saveConvo } = require('~/models/Conversation');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
const { Tiktoken } = require('tiktoken/lite');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const p50k_base = require('tiktoken/encoders/p50k_base.json');
|
|
||||||
const cl100k_base = require('tiktoken/encoders/cl100k_base.json');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts the number of tokens in a given text using a specified encoding model.
|
|
||||||
*
|
|
||||||
* This function utilizes the 'Tiktoken' library to encode text based on the selected model.
|
|
||||||
* It supports two models, 'text-davinci-003' and 'gpt-3.5-turbo', each with its own encoding strategy.
|
|
||||||
* For 'text-davinci-003', the 'p50k_base' encoder is used, whereas for other models, the 'cl100k_base' encoder is applied.
|
|
||||||
* In case of an error during encoding, the error is logged, and the function returns 0.
|
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @param {string} text - The text to be tokenized. Defaults to an empty string if not provided.
|
|
||||||
* @param {string} modelName - The name of the model used for tokenizing. Defaults to 'gpt-3.5-turbo'.
|
|
||||||
* @returns {Promise<number>} The number of tokens in the provided text. Returns 0 if an error occurs.
|
|
||||||
* @throws Logs the error to a logger and rethrows if any error occurs during tokenization.
|
|
||||||
*/
|
|
||||||
const countTokens = async (text = '', modelName = 'gpt-3.5-turbo') => {
|
|
||||||
let encoder = null;
|
|
||||||
try {
|
|
||||||
const model = modelName.includes('text-davinci-003') ? p50k_base : cl100k_base;
|
|
||||||
encoder = new Tiktoken(model.bpe_ranks, model.special_tokens, model.pat_str);
|
|
||||||
const tokens = encoder.encode(text);
|
|
||||||
encoder.free();
|
|
||||||
return tokens.length;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[countTokens]', e);
|
|
||||||
if (encoder) {
|
|
||||||
encoder.free();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = countTokens;
|
|
||||||
|
|
@ -10,14 +10,6 @@ const {
|
||||||
const { sendEvent } = require('@librechat/api');
|
const { sendEvent } = require('@librechat/api');
|
||||||
const partialRight = require('lodash/partialRight');
|
const partialRight = require('lodash/partialRight');
|
||||||
|
|
||||||
/** Helper function to escape special characters in regex
|
|
||||||
* @param {string} string - The string to escape.
|
|
||||||
* @returns {string} The escaped string.
|
|
||||||
*/
|
|
||||||
function escapeRegExp(string) {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text);
|
const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text);
|
||||||
|
|
||||||
const base = { message: true, initial: true };
|
const base = { message: true, initial: true };
|
||||||
|
|
@ -181,7 +173,6 @@ function generateConfig(key, baseURL, endpoint) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleText,
|
handleText,
|
||||||
formatSteps,
|
formatSteps,
|
||||||
escapeRegExp,
|
|
||||||
formatAction,
|
formatAction,
|
||||||
isUserProvided,
|
isUserProvided,
|
||||||
generateConfig,
|
generateConfig,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
const removePorts = require('./removePorts');
|
const removePorts = require('./removePorts');
|
||||||
const countTokens = require('./countTokens');
|
|
||||||
const handleText = require('./handleText');
|
const handleText = require('./handleText');
|
||||||
const sendEmail = require('./sendEmail');
|
const sendEmail = require('./sendEmail');
|
||||||
const queue = require('./queue');
|
const queue = require('./queue');
|
||||||
|
|
@ -7,7 +6,6 @@ const files = require('./files');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...handleText,
|
...handleText,
|
||||||
countTokens,
|
|
||||||
removePorts,
|
removePorts,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
...files,
|
...files,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const cookies = require('cookie');
|
||||||
const jwksRsa = require('jwks-rsa');
|
const jwksRsa = require('jwks-rsa');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
|
|
@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
|
||||||
{
|
{
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
|
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
|
||||||
|
passReqToCallback: true,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
* @param {import('@librechat/api').ServerRequest} req
|
||||||
* @param {import('openid-client').IDToken} payload
|
* @param {import('openid-client').IDToken} payload
|
||||||
* @param {import('passport-jwt').VerifyCallback} done
|
* @param {import('passport-jwt').VerifyCallback} done
|
||||||
*/
|
*/
|
||||||
async (payload, done) => {
|
async (req, payload, done) => {
|
||||||
try {
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const rawToken = authHeader?.replace('Bearer ', '');
|
||||||
|
|
||||||
const { user, error, migration } = await findOpenIDUser({
|
const { user, error, migration } = await findOpenIDUser({
|
||||||
findUser,
|
findUser,
|
||||||
email: payload?.email,
|
email: payload?.email,
|
||||||
|
|
@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => {
|
||||||
await updateUser(user.id, updateData);
|
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);
|
done(null, user);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,15 @@ async function setupOpenId() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
done(null, { ...user, tokenset });
|
done(null, {
|
||||||
|
...user,
|
||||||
|
tokenset,
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: tokenset.access_token,
|
||||||
|
refresh_token: tokenset.refresh_token,
|
||||||
|
expires_at: tokenset.expires_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[openidStrategy] login failed', err);
|
logger.error('[openidStrategy] login failed', err);
|
||||||
done(err);
|
done(err);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({
|
||||||
jest.mock('@librechat/api', () => ({
|
jest.mock('@librechat/api', () => ({
|
||||||
...jest.requireActual('@librechat/api'),
|
...jest.requireActual('@librechat/api'),
|
||||||
isEnabled: jest.fn(() => false),
|
isEnabled: jest.fn(() => false),
|
||||||
|
isEmailDomainAllowed: jest.fn(() => true),
|
||||||
|
findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser,
|
||||||
getBalanceConfig: jest.fn(() => ({
|
getBalanceConfig: jest.fn(() => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
})),
|
})),
|
||||||
|
|
@ -446,6 +448,46 @@ describe('setupOpenId', () => {
|
||||||
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
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 () => {
|
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
|
||||||
// Act
|
// Act
|
||||||
const { user } = await validate(tokenset);
|
const { user } = await validate(tokenset);
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ describe('socialLogin', () => {
|
||||||
|
|
||||||
/** Verify both searches happened */
|
/** Verify both searches happened */
|
||||||
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||||
|
/** Email passed as-is; findUser implementation handles case normalization */
|
||||||
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
||||||
expect(findUser).toHaveBeenCalledTimes(2);
|
expect(findUser).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ module.exports = {
|
||||||
clientId: 'fake_client_id',
|
clientId: 'fake_client_id',
|
||||||
clientSecret: 'fake_client_secret',
|
clientSecret: 'fake_client_secret',
|
||||||
issuer: 'https://fake-issuer.com',
|
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(() => ({
|
Client: jest.fn().mockImplementation(() => ({
|
||||||
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
|
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
|
||||||
callback: jest.fn().mockResolvedValue({
|
callback: jest.fn().mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch');
|
const axios = require('axios');
|
||||||
|
|
||||||
// Mock dependencies
|
jest.mock('axios');
|
||||||
jest.mock('../../../../../models', () => ({
|
jest.mock('@librechat/api', () => ({
|
||||||
Files: {
|
generateShortLivedToken: jest.fn(),
|
||||||
find: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
queryVectors: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../../../../config', () => ({
|
|
||||||
logger: {
|
logger: {
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
|
|
@ -19,68 +13,220 @@ jest.mock('../../../../../config', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { queryVectors } = require('../../../../../server/services/Files/VectorDB/crud');
|
jest.mock('~/models/File', () => ({
|
||||||
|
getFiles: jest.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('fileSearch.js - test only new file_id and page additions', () => {
|
jest.mock('~/server/services/Files/permissions', () => ({
|
||||||
|
filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createFileSearchTool } = require('~/app/clients/tools/util/fileSearch');
|
||||||
|
const { generateShortLivedToken } = require('@librechat/api');
|
||||||
|
|
||||||
|
describe('fileSearch.js - tuple return validation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
process.env.RAG_API_URL = 'http://localhost:8000';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test only the specific changes: file_id and page metadata additions
|
describe('error cases should return tuple with undefined as second value', () => {
|
||||||
it('should add file_id and page to search result format', async () => {
|
it('should return tuple when no files provided', async () => {
|
||||||
const mockFiles = [{ file_id: 'test-file-123' }];
|
const fileSearchTool = await createFileSearchTool({
|
||||||
const mockResults = [
|
userId: 'user1',
|
||||||
{
|
files: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fileSearchTool.func({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toBe('No files to search. Instruct the user to add files for the search.');
|
||||||
|
expect(result[1]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tuple when JWT token generation fails', async () => {
|
||||||
|
generateShortLivedToken.mockReturnValue(null);
|
||||||
|
|
||||||
|
const fileSearchTool = await createFileSearchTool({
|
||||||
|
userId: 'user1',
|
||||||
|
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fileSearchTool.func({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toBe('There was an error authenticating the file search request.');
|
||||||
|
expect(result[1]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tuple when no valid results found', async () => {
|
||||||
|
generateShortLivedToken.mockReturnValue('mock-jwt-token');
|
||||||
|
axios.post.mockRejectedValue(new Error('API Error'));
|
||||||
|
|
||||||
|
const fileSearchTool = await createFileSearchTool({
|
||||||
|
userId: 'user1',
|
||||||
|
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fileSearchTool.func({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toBe('No results found or errors occurred while searching the files.');
|
||||||
|
expect(result[1]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('success cases should return tuple with artifact object', () => {
|
||||||
|
it('should return tuple with formatted results and sources artifact', async () => {
|
||||||
|
generateShortLivedToken.mockReturnValue('mock-jwt-token');
|
||||||
|
|
||||||
|
const mockApiResponse = {
|
||||||
data: [
|
data: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
page_content: 'test content',
|
page_content: 'This is test content from the document',
|
||||||
metadata: { source: 'test.pdf', page: 1 },
|
metadata: { source: '/path/to/test.pdf', page: 1 },
|
||||||
},
|
},
|
||||||
0.3,
|
0.2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
page_content: 'Additional relevant content',
|
||||||
|
metadata: { source: '/path/to/test.pdf', page: 2 },
|
||||||
|
},
|
||||||
|
0.35,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
];
|
|
||||||
|
|
||||||
queryVectors.mockResolvedValue(mockResults);
|
axios.post.mockResolvedValue(mockApiResponse);
|
||||||
|
|
||||||
const fileSearchTool = await createFileSearchTool({
|
const fileSearchTool = await createFileSearchTool({
|
||||||
userId: 'user1',
|
userId: 'user1',
|
||||||
files: mockFiles,
|
files: [{ file_id: 'file-123', filename: 'test.pdf' }],
|
||||||
entity_id: 'agent-123',
|
entity_id: 'agent-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fileSearchTool.func({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
|
||||||
|
const [formattedString, artifact] = result;
|
||||||
|
|
||||||
|
expect(typeof formattedString).toBe('string');
|
||||||
|
expect(formattedString).toContain('File: test.pdf');
|
||||||
|
expect(formattedString).toContain('Relevance:');
|
||||||
|
expect(formattedString).toContain('This is test content from the document');
|
||||||
|
expect(formattedString).toContain('Additional relevant content');
|
||||||
|
|
||||||
|
expect(artifact).toBeDefined();
|
||||||
|
expect(artifact).toHaveProperty('file_search');
|
||||||
|
expect(artifact.file_search).toHaveProperty('sources');
|
||||||
|
expect(artifact.file_search).toHaveProperty('fileCitations', false);
|
||||||
|
expect(Array.isArray(artifact.file_search.sources)).toBe(true);
|
||||||
|
expect(artifact.file_search.sources.length).toBe(2);
|
||||||
|
|
||||||
|
const source = artifact.file_search.sources[0];
|
||||||
|
expect(source).toMatchObject({
|
||||||
|
type: 'file',
|
||||||
|
fileId: 'file-123',
|
||||||
|
fileName: 'test.pdf',
|
||||||
|
content: expect.any(String),
|
||||||
|
relevance: expect.any(Number),
|
||||||
|
pages: [1],
|
||||||
|
pageRelevance: { 1: expect.any(Number) },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the tool's function to return the formatted result
|
it('should include file citations in description when enabled', async () => {
|
||||||
fileSearchTool.func = jest.fn().mockImplementation(async () => {
|
generateShortLivedToken.mockReturnValue('mock-jwt-token');
|
||||||
// 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
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// NEW: Internal data section for processAgentResponse
|
const mockApiResponse = {
|
||||||
const internalData = formattedResults
|
data: [
|
||||||
.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`,
|
page_content: 'Content with citations',
|
||||||
)
|
metadata: { source: '/path/to/doc.pdf', page: 3 },
|
||||||
.join('\n---\n');
|
},
|
||||||
|
0.15,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
return `File: test.pdf\nRelevance: 0.7000\nContent: test content\n\n<!-- INTERNAL_DATA_START -->\n${internalData}\n<!-- INTERNAL_DATA_END -->`;
|
axios.post.mockResolvedValue(mockApiResponse);
|
||||||
|
|
||||||
|
const fileSearchTool = await createFileSearchTool({
|
||||||
|
userId: 'user1',
|
||||||
|
files: [{ file_id: 'file-789', filename: 'doc.pdf' }],
|
||||||
|
fileCitations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fileSearchTool.func({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
|
||||||
|
const [formattedString, artifact] = result;
|
||||||
|
|
||||||
|
expect(formattedString).toContain('Anchor:');
|
||||||
|
expect(formattedString).toContain('\\ue202turn0file0');
|
||||||
|
expect(artifact.file_search.fileCitations).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await fileSearchTool.func('test');
|
it('should handle multiple files correctly', async () => {
|
||||||
|
generateShortLivedToken.mockReturnValue('mock-jwt-token');
|
||||||
|
|
||||||
// Verify the new additions
|
const mockResponse1 = {
|
||||||
expect(result).toContain('File_ID: test-file-123');
|
data: [
|
||||||
expect(result).toContain('Page: 1');
|
[
|
||||||
expect(result).toContain('<!-- INTERNAL_DATA_START -->');
|
{
|
||||||
expect(result).toContain('<!-- INTERNAL_DATA_END -->');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1828,7 +1828,7 @@
|
||||||
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
|
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
|
||||||
* @param {AbortController} opts.abortController - AbortController instance
|
* @param {AbortController} opts.abortController - AbortController instance
|
||||||
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
|
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
|
||||||
* @memberof typedefs
|
* @memberof typedefs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,9 @@ describe('getModelMaxTokens', () => {
|
||||||
expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe(
|
expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe(
|
||||||
maxTokensMap[EModelEndpoint.google]['gemini-1.5'],
|
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(
|
expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe(
|
||||||
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
|
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
|
||||||
);
|
);
|
||||||
|
|
@ -662,7 +665,7 @@ describe('Meta Models Tests', () => {
|
||||||
|
|
||||||
test('should match Deepseek model variations', () => {
|
test('should match Deepseek model variations', () => {
|
||||||
expect(getModelMaxTokens('deepseek-chat')).toBe(
|
expect(getModelMaxTokens('deepseek-chat')).toBe(
|
||||||
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
|
maxTokensMap[EModelEndpoint.openAI]['deepseek-chat'],
|
||||||
);
|
);
|
||||||
expect(getModelMaxTokens('deepseek-coder')).toBe(
|
expect(getModelMaxTokens('deepseek-coder')).toBe(
|
||||||
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
|
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
|
||||||
|
|
@ -674,6 +677,20 @@ describe('Meta Models Tests', () => {
|
||||||
maxTokensMap[EModelEndpoint.openAI]['deepseek.r1'],
|
maxTokensMap[EModelEndpoint.openAI]['deepseek.r1'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return 128000 context tokens for all DeepSeek models', () => {
|
||||||
|
expect(getModelMaxTokens('deepseek-chat')).toBe(128000);
|
||||||
|
expect(getModelMaxTokens('deepseek-reasoner')).toBe(128000);
|
||||||
|
expect(getModelMaxTokens('deepseek-r1')).toBe(128000);
|
||||||
|
expect(getModelMaxTokens('deepseek-v3')).toBe(128000);
|
||||||
|
expect(getModelMaxTokens('deepseek.r1')).toBe(128000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle DeepSeek models with provider prefixes', () => {
|
||||||
|
expect(getModelMaxTokens('deepseek/deepseek-chat')).toBe(128000);
|
||||||
|
expect(getModelMaxTokens('openrouter/deepseek-reasoner')).toBe(128000);
|
||||||
|
expect(getModelMaxTokens('openai/deepseek-v3')).toBe(128000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('matchModelName', () => {
|
describe('matchModelName', () => {
|
||||||
|
|
@ -702,11 +719,42 @@ describe('Meta Models Tests', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should match Deepseek model variations', () => {
|
test('should match Deepseek model variations', () => {
|
||||||
expect(matchModelName('deepseek-chat')).toBe('deepseek');
|
expect(matchModelName('deepseek-chat')).toBe('deepseek-chat');
|
||||||
expect(matchModelName('deepseek-coder')).toBe('deepseek');
|
expect(matchModelName('deepseek-coder')).toBe('deepseek');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek Max Output Tokens', () => {
|
||||||
|
const { getModelMaxOutputTokens } = require('@librechat/api');
|
||||||
|
|
||||||
|
test('should return correct max output tokens for deepseek-chat', () => {
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-chat')).toBe(8000);
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-chat', EModelEndpoint.openAI)).toBe(8000);
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-chat', EModelEndpoint.custom)).toBe(8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct max output tokens for deepseek-reasoner', () => {
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-reasoner')).toBe(64000);
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-reasoner', EModelEndpoint.openAI)).toBe(64000);
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-reasoner', EModelEndpoint.custom)).toBe(64000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct max output tokens for deepseek-r1', () => {
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-r1')).toBe(64000);
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-r1', EModelEndpoint.openAI)).toBe(64000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct max output tokens for deepseek base pattern', () => {
|
||||||
|
expect(getModelMaxOutputTokens('deepseek')).toBe(8000);
|
||||||
|
expect(getModelMaxOutputTokens('deepseek-v3')).toBe(8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle DeepSeek models with provider prefixes for max output tokens', () => {
|
||||||
|
expect(getModelMaxOutputTokens('deepseek/deepseek-chat')).toBe(8000);
|
||||||
|
expect(getModelMaxOutputTokens('openrouter/deepseek-reasoner')).toBe(64000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('processModelData with Meta models', () => {
|
describe('processModelData with Meta models', () => {
|
||||||
test('should process Meta model data correctly', () => {
|
test('should process Meta model data correctly', () => {
|
||||||
const input = {
|
const input = {
|
||||||
|
|
@ -775,6 +823,16 @@ describe('Grok Model Tests - Tokens', () => {
|
||||||
expect(getModelMaxTokens('grok-4-0709')).toBe(256000);
|
expect(getModelMaxTokens('grok-4-0709')).toBe(256000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct tokens for Grok 4 Fast and Grok 4.1 Fast models', () => {
|
||||||
|
expect(getModelMaxTokens('grok-4-fast')).toBe(2000000);
|
||||||
|
expect(getModelMaxTokens('grok-4-1-fast-reasoning')).toBe(2000000);
|
||||||
|
expect(getModelMaxTokens('grok-4-1-fast-non-reasoning')).toBe(2000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct tokens for Grok Code Fast model', () => {
|
||||||
|
expect(getModelMaxTokens('grok-code-fast-1')).toBe(256000);
|
||||||
|
});
|
||||||
|
|
||||||
test('should handle partial matches for Grok models with prefixes', () => {
|
test('should handle partial matches for Grok models with prefixes', () => {
|
||||||
// Vision models should match before general models
|
// Vision models should match before general models
|
||||||
expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768);
|
expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768);
|
||||||
|
|
@ -794,6 +852,12 @@ describe('Grok Model Tests - Tokens', () => {
|
||||||
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
|
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
|
||||||
// Grok 4 model
|
// Grok 4 model
|
||||||
expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000);
|
expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000);
|
||||||
|
// Grok 4 Fast and 4.1 Fast models
|
||||||
|
expect(getModelMaxTokens('xai/grok-4-fast')).toBe(2000000);
|
||||||
|
expect(getModelMaxTokens('xai/grok-4-1-fast-reasoning')).toBe(2000000);
|
||||||
|
expect(getModelMaxTokens('xai/grok-4-1-fast-non-reasoning')).toBe(2000000);
|
||||||
|
// Grok Code Fast model
|
||||||
|
expect(getModelMaxTokens('xai/grok-code-fast-1')).toBe(256000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -817,6 +881,12 @@ describe('Grok Model Tests - Tokens', () => {
|
||||||
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||||
// Grok 4 model
|
// Grok 4 model
|
||||||
expect(matchModelName('grok-4-0709')).toBe('grok-4');
|
expect(matchModelName('grok-4-0709')).toBe('grok-4');
|
||||||
|
// Grok 4 Fast and 4.1 Fast models
|
||||||
|
expect(matchModelName('grok-4-fast')).toBe('grok-4-fast');
|
||||||
|
expect(matchModelName('grok-4-1-fast-reasoning')).toBe('grok-4-1-fast');
|
||||||
|
expect(matchModelName('grok-4-1-fast-non-reasoning')).toBe('grok-4-1-fast');
|
||||||
|
// Grok Code Fast model
|
||||||
|
expect(matchModelName('grok-code-fast-1')).toBe('grok-code-fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should match Grok model variations with prefixes', () => {
|
test('should match Grok model variations with prefixes', () => {
|
||||||
|
|
@ -838,6 +908,12 @@ describe('Grok Model Tests - Tokens', () => {
|
||||||
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||||
// Grok 4 model
|
// Grok 4 model
|
||||||
expect(matchModelName('xai/grok-4-0709')).toBe('grok-4');
|
expect(matchModelName('xai/grok-4-0709')).toBe('grok-4');
|
||||||
|
// Grok 4 Fast and 4.1 Fast models
|
||||||
|
expect(matchModelName('xai/grok-4-fast')).toBe('grok-4-fast');
|
||||||
|
expect(matchModelName('xai/grok-4-1-fast-reasoning')).toBe('grok-4-1-fast');
|
||||||
|
expect(matchModelName('xai/grok-4-1-fast-non-reasoning')).toBe('grok-4-1-fast');
|
||||||
|
// Grok Code Fast model
|
||||||
|
expect(matchModelName('xai/grok-code-fast-1')).toBe('grok-code-fast');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -861,6 +937,15 @@ describe('Claude Model Tests', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return correct context length for Claude Opus 4.5', () => {
|
||||||
|
expect(getModelMaxTokens('claude-opus-4-5', EModelEndpoint.anthropic)).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
|
||||||
|
);
|
||||||
|
expect(getModelMaxTokens('claude-opus-4-5')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle Claude Haiku 4.5 model name variations', () => {
|
it('should handle Claude Haiku 4.5 model name variations', () => {
|
||||||
const modelVariations = [
|
const modelVariations = [
|
||||||
'claude-haiku-4-5',
|
'claude-haiku-4-5',
|
||||||
|
|
@ -880,6 +965,25 @@ describe('Claude Model Tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle Claude Opus 4.5 model name variations', () => {
|
||||||
|
const modelVariations = [
|
||||||
|
'claude-opus-4-5',
|
||||||
|
'claude-opus-4-5-20250420',
|
||||||
|
'claude-opus-4-5-latest',
|
||||||
|
'anthropic/claude-opus-4-5',
|
||||||
|
'claude-opus-4-5/anthropic',
|
||||||
|
'claude-opus-4-5-preview',
|
||||||
|
];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
|
||||||
|
expect(modelKey).toBe('claude-opus-4-5');
|
||||||
|
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should match model names correctly for Claude Haiku 4.5', () => {
|
it('should match model names correctly for Claude Haiku 4.5', () => {
|
||||||
const modelVariations = [
|
const modelVariations = [
|
||||||
'claude-haiku-4-5',
|
'claude-haiku-4-5',
|
||||||
|
|
@ -895,6 +999,21 @@ describe('Claude Model Tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should match model names correctly for Claude Opus 4.5', () => {
|
||||||
|
const modelVariations = [
|
||||||
|
'claude-opus-4-5',
|
||||||
|
'claude-opus-4-5-20250420',
|
||||||
|
'claude-opus-4-5-latest',
|
||||||
|
'anthropic/claude-opus-4-5',
|
||||||
|
'claude-opus-4-5/anthropic',
|
||||||
|
'claude-opus-4-5-preview',
|
||||||
|
];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
||||||
const modelVariations = [
|
const modelVariations = [
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
||||||
/** v0.8.1-rc1 */
|
/** v0.8.1 */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
roots: ['<rootDir>/src'],
|
roots: ['<rootDir>/src'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
|
|
@ -41,7 +41,6 @@ module.exports = {
|
||||||
'jest-file-loader',
|
'jest-file-loader',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
|
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
|
||||||
preset: 'ts-jest',
|
|
||||||
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
|
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/frontend",
|
"name": "@librechat/frontend",
|
||||||
"version": "v0.8.1-rc1",
|
"version": "v0.8.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
|
"dompurify": "^3.3.0",
|
||||||
"downloadjs": "^1.4.7",
|
"downloadjs": "^1.4.7",
|
||||||
"export-from-json": "^1.7.2",
|
"export-from-json": "^1.7.2",
|
||||||
"filenamify": "^6.0.0",
|
"filenamify": "^6.0.0",
|
||||||
|
|
@ -147,7 +148,6 @@
|
||||||
"postcss-loader": "^7.1.0",
|
"postcss-loader": "^7.1.0",
|
||||||
"postcss-preset-env": "^8.2.0",
|
"postcss-preset-env": "^8.2.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"ts-jest": "^29.4.5",
|
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^6.4.1",
|
"vite": "^6.4.1",
|
||||||
"vite-plugin-compression2": "^2.2.1",
|
"vite-plugin-compression2": "^2.2.1",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||||
|
import WakeLockManager from '~/components/System/WakeLockManager';
|
||||||
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
||||||
import { initializeFontSize } from '~/store/fontSize';
|
import { initializeFontSize } from '~/store/fontSize';
|
||||||
import { LiveAnnouncer } from '~/a11y';
|
import { LiveAnnouncer } from '~/a11y';
|
||||||
|
|
@ -51,6 +52,7 @@ const App = () => {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<WakeLockManager />
|
||||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||||
<Toast />
|
<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" />
|
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,8 @@ export type AgentForm = {
|
||||||
recursion_limit?: number;
|
recursion_limit?: number;
|
||||||
support_contact?: SupportContact;
|
support_contact?: SupportContact;
|
||||||
category: string;
|
category: string;
|
||||||
|
// Avatar management fields
|
||||||
|
avatar_file?: File | null;
|
||||||
|
avatar_preview?: string | null;
|
||||||
|
avatar_action?: 'upload' | 'reset' | null;
|
||||||
} & TAgentCapabilities;
|
} & TAgentCapabilities;
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,12 @@ export default function ArtifactVersion({
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={localize('com_ui_change_version')}
|
description={localize('com_ui_change_version')}
|
||||||
render={
|
render={
|
||||||
<Button size="icon" variant="ghost" asChild>
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
asChild
|
||||||
|
aria-label={localize('com_ui_change_version')}
|
||||||
|
>
|
||||||
<MenuButton>
|
<MenuButton>
|
||||||
<History
|
<History
|
||||||
size={18}
|
size={18}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ function AuthLayout({
|
||||||
|
|
||||||
<div className="flex flex-grow items-center justify-center">
|
<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">
|
<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 && (
|
{!hasStartupConfigError && !isFetching && header && (
|
||||||
<h1
|
<h1
|
||||||
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
|
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
|
||||||
style={{ userSelect: 'none' }}
|
style={{ userSelect: 'none' }}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ErrorTypes } from 'librechat-data-provider';
|
import { ErrorTypes, registerPage } from 'librechat-data-provider';
|
||||||
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
||||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||||
import type { TLoginLayoutContext } from '~/common';
|
import type { TLoginLayoutContext } from '~/common';
|
||||||
|
|
@ -104,7 +104,7 @@ function Login() {
|
||||||
{' '}
|
{' '}
|
||||||
{localize('com_auth_no_account')}{' '}
|
{localize('com_auth_no_account')}{' '}
|
||||||
<a
|
<a
|
||||||
href="/register"
|
href={registerPage()}
|
||||||
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"
|
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')}
|
{localize('com_auth_sign_up')}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
|
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
|
||||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||||
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
||||||
|
import { loginPage } from 'librechat-data-provider';
|
||||||
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
||||||
import type { TLoginLayoutContext } from '~/common';
|
import type { TLoginLayoutContext } from '~/common';
|
||||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||||
|
|
@ -213,7 +214,7 @@ const Registration: React.FC = () => {
|
||||||
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
|
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
|
||||||
{localize('com_auth_already_have_account')}{' '}
|
{localize('com_auth_already_have_account')}{' '}
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href={loginPage()}
|
||||||
aria-label="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"
|
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,6 +3,7 @@ import { useState, ReactNode } from 'react';
|
||||||
import { Spinner, Button } from '@librechat/client';
|
import { Spinner, Button } from '@librechat/client';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
|
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
|
||||||
|
import { loginPage } from 'librechat-data-provider';
|
||||||
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
|
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
|
||||||
import type { TLoginLayoutContext } from '~/common';
|
import type { TLoginLayoutContext } from '~/common';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {
|
||||||
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
|
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
|
||||||
<a
|
<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"
|
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="/login"
|
href={loginPage()}
|
||||||
>
|
>
|
||||||
{localize('com_auth_back_to_login')}
|
{localize('com_auth_back_to_login')}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -134,7 +135,7 @@ function RequestPasswordReset() {
|
||||||
{isLoading ? <Spinner /> : localize('com_auth_continue')}
|
{isLoading ? <Spinner /> : localize('com_auth_continue')}
|
||||||
</Button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href={loginPage()}
|
||||||
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"
|
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')}
|
{localize('com_auth_back_to_login')}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ const BookmarkForm = ({
|
||||||
}
|
}
|
||||||
const allTags =
|
const allTags =
|
||||||
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
|
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
|
||||||
if (allTags.some((tag) => tag.tag === data.tag)) {
|
if (allTags.some((tag) => tag.tag === data.tag && tag.tag !== bookmark?.tag)) {
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_ui_bookmarks_create_exists'),
|
message: localize('com_ui_bookmarks_create_exists'),
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
|
|
|
||||||
499
client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx
Normal file
499
client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx
Normal file
|
|
@ -0,0 +1,499 @@
|
||||||
|
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,6 +11,7 @@ import BookmarkMenu from './Menus/BookmarkMenu';
|
||||||
import { TemporaryChat } from './TemporaryChat';
|
import { TemporaryChat } from './TemporaryChat';
|
||||||
import AddMultiConvo from './AddMultiConvo';
|
import AddMultiConvo from './AddMultiConvo';
|
||||||
import { useHasAccess } from '~/hooks';
|
import { useHasAccess } from '~/hooks';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
const defaultInterface = getConfigDefaults().interface;
|
const defaultInterface = getConfigDefaults().interface;
|
||||||
|
|
||||||
|
|
@ -38,24 +39,24 @@ export default function Header() {
|
||||||
return (
|
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="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="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||||
<div className="mx-1 flex items-center gap-2">
|
<div className="mx-1 flex items-center">
|
||||||
<div
|
<AnimatePresence initial={false}>
|
||||||
className={`flex items-center gap-2 ${
|
{!navVisible && (
|
||||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
|
<motion.div
|
||||||
} ${
|
className={`flex items-center gap-2`}
|
||||||
!navVisible
|
initial={{ width: 0, opacity: 0 }}
|
||||||
? 'translate-x-0 opacity-100'
|
animate={{ width: 'auto', opacity: 1 }}
|
||||||
: 'pointer-events-none translate-x-[-100px] opacity-0'
|
exit={{ width: 0, opacity: 0 }}
|
||||||
}`}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
key="header-buttons"
|
||||||
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
|
>
|
||||||
<HeaderNewChat />
|
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
|
||||||
</div>
|
<HeaderNewChat />
|
||||||
<div
|
</motion.div>
|
||||||
className={`flex items-center gap-2 ${
|
)}
|
||||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
|
</AnimatePresence>
|
||||||
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
|
|
||||||
>
|
<div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
|
||||||
<ModelSelector startupConfig={startupConfig} />
|
<ModelSelector startupConfig={startupConfig} />
|
||||||
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
|
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
|
||||||
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
||||||
|
|
|
||||||
|
|
@ -260,37 +260,50 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
<FileFormChat conversation={conversation} />
|
<FileFormChat conversation={conversation} />
|
||||||
{endpoint && (
|
{endpoint && (
|
||||||
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
||||||
<TextareaAutosize
|
<div className="relative flex-1">
|
||||||
{...registerProps}
|
<TextareaAutosize
|
||||||
ref={(e) => {
|
{...registerProps}
|
||||||
ref(e);
|
ref={(e) => {
|
||||||
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
|
ref(e);
|
||||||
}}
|
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current =
|
||||||
disabled={disableInputs || isNotAppendable}
|
e;
|
||||||
onPaste={handlePaste}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
disabled={disableInputs || isNotAppendable}
|
||||||
onKeyUp={handleKeyUp}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onKeyDown={handleKeyDown}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onKeyUp={handleKeyUp}
|
||||||
id={mainTextareaId}
|
onCompositionStart={handleCompositionStart}
|
||||||
tabIndex={0}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
data-testid="text-input"
|
id={mainTextareaId}
|
||||||
rows={1}
|
tabIndex={0}
|
||||||
onFocus={() => {
|
data-testid="text-input"
|
||||||
handleFocusOrClick();
|
rows={1}
|
||||||
setIsTextAreaFocused(true);
|
onFocus={() => {
|
||||||
}}
|
handleFocusOrClick();
|
||||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
setIsTextAreaFocused(true);
|
||||||
aria-label={localize('com_ui_message_input')}
|
}}
|
||||||
onClick={handleFocusOrClick}
|
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||||
style={{ height: 44, overflowY: 'auto' }}
|
aria-label={localize('com_ui_message_input')}
|
||||||
className={cn(
|
onClick={handleFocusOrClick}
|
||||||
baseClasses,
|
style={{ height: 44, overflowY: 'auto' }}
|
||||||
removeFocusRings,
|
className={cn(
|
||||||
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
|
baseClasses,
|
||||||
|
removeFocusRings,
|
||||||
|
'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isCollapsed && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
|
||||||
|
maskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
<div className="flex flex-col items-start justify-start pt-1.5">
|
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
|
||||||
<CollapseChat
|
<CollapseChat
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isScrollable={isMoreThanThreeRows}
|
isScrollable={isMoreThanThreeRows}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||||
import {
|
import {
|
||||||
|
inferMimeType,
|
||||||
EToolResources,
|
EToolResources,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
defaultAgentCapabilities,
|
defaultAgentCapabilities,
|
||||||
|
|
@ -56,18 +57,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||||
const _options: FileOption[] = [];
|
const _options: FileOption[] = [];
|
||||||
const currentProvider = provider || endpoint;
|
const currentProvider = provider || endpoint;
|
||||||
|
|
||||||
|
/** Helper to get inferred MIME type for a file */
|
||||||
|
const getFileType = (file: File) => inferMimeType(file.name, file.type);
|
||||||
|
|
||||||
// Check if provider supports document upload
|
// Check if provider supports document upload
|
||||||
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
|
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
|
||||||
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
||||||
const validFileTypes = isGoogleProvider
|
const validFileTypes = isGoogleProvider
|
||||||
? files.every(
|
? files.every((file) => {
|
||||||
(file) =>
|
const type = getFileType(file);
|
||||||
file.type?.startsWith('image/') ||
|
return (
|
||||||
file.type?.startsWith('video/') ||
|
type?.startsWith('image/') ||
|
||||||
file.type?.startsWith('audio/') ||
|
type?.startsWith('video/') ||
|
||||||
file.type === 'application/pdf',
|
type?.startsWith('audio/') ||
|
||||||
)
|
type === 'application/pdf'
|
||||||
: files.every((file) => file.type?.startsWith('image/') || file.type === 'application/pdf');
|
);
|
||||||
|
})
|
||||||
|
: files.every((file) => {
|
||||||
|
const type = getFileType(file);
|
||||||
|
return type?.startsWith('image/') || type === 'application/pdf';
|
||||||
|
});
|
||||||
|
|
||||||
_options.push({
|
_options.push({
|
||||||
label: localize('com_ui_upload_provider'),
|
label: localize('com_ui_upload_provider'),
|
||||||
|
|
@ -81,7 +90,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||||
label: localize('com_ui_upload_image_input'),
|
label: localize('com_ui_upload_image_input'),
|
||||||
value: undefined,
|
value: undefined,
|
||||||
icon: <ImageUpIcon className="icon-md" />,
|
icon: <ImageUpIcon className="icon-md" />,
|
||||||
condition: files.every((file) => file.type?.startsWith('image/')),
|
condition: files.every((file) => getFileType(file)?.startsWith('image/')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
|
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider';
|
import {
|
||||||
|
EModelEndpoint,
|
||||||
|
isDocumentSupportedProvider,
|
||||||
|
inferMimeType,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
|
|
||||||
describe('DragDropModal - Provider Detection', () => {
|
describe('DragDropModal - Provider Detection', () => {
|
||||||
describe('endpointType priority over currentProvider', () => {
|
describe('endpointType priority over currentProvider', () => {
|
||||||
|
|
@ -118,4 +122,59 @@ describe('DragDropModal - Provider Detection', () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('HEIC/HEIF file type inference', () => {
|
||||||
|
it('should infer image/heic for .heic files when browser returns empty type', () => {
|
||||||
|
const fileName = 'photo.heic';
|
||||||
|
const browserType = '';
|
||||||
|
|
||||||
|
const inferredType = inferMimeType(fileName, browserType);
|
||||||
|
expect(inferredType).toBe('image/heic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should infer image/heif for .heif files when browser returns empty type', () => {
|
||||||
|
const fileName = 'photo.heif';
|
||||||
|
const browserType = '';
|
||||||
|
|
||||||
|
const inferredType = inferMimeType(fileName, browserType);
|
||||||
|
expect(inferredType).toBe('image/heif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle uppercase .HEIC extension', () => {
|
||||||
|
const fileName = 'IMG_1234.HEIC';
|
||||||
|
const browserType = '';
|
||||||
|
|
||||||
|
const inferredType = inferMimeType(fileName, browserType);
|
||||||
|
expect(inferredType).toBe('image/heic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve browser-provided type when available', () => {
|
||||||
|
const fileName = 'photo.jpg';
|
||||||
|
const browserType = 'image/jpeg';
|
||||||
|
|
||||||
|
const inferredType = inferMimeType(fileName, browserType);
|
||||||
|
expect(inferredType).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not override browser type even if extension differs', () => {
|
||||||
|
const fileName = 'renamed.heic';
|
||||||
|
const browserType = 'image/png';
|
||||||
|
|
||||||
|
const inferredType = inferMimeType(fileName, browserType);
|
||||||
|
expect(inferredType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify HEIC as image type for upload options', () => {
|
||||||
|
const heicType = inferMimeType('photo.heic', '');
|
||||||
|
expect(heicType.startsWith('image/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for unknown extension with no browser type', () => {
|
||||||
|
const fileName = 'file.xyz';
|
||||||
|
const browserType = '';
|
||||||
|
|
||||||
|
const inferredType = inferMimeType(fileName, browserType);
|
||||||
|
expect(inferredType).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
className="absolute bottom-[27px] right-2"
|
className="absolute bottom-[27px] right-2"
|
||||||
description={localize('com_ui_happy_birthday')}
|
description={localize('com_ui_happy_birthday')}
|
||||||
|
aria-label={localize('com_ui_happy_birthday')}
|
||||||
>
|
>
|
||||||
<BirthdayIcon />
|
<BirthdayIcon />
|
||||||
</TooltipAnchor>
|
</TooltipAnchor>
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
||||||
}
|
}
|
||||||
}, [isPromptOpen, zoom]);
|
}, [isPromptOpen, zoom]);
|
||||||
|
|
||||||
|
const imageDetailsLabel = isPromptOpen
|
||||||
|
? localize('com_ui_hide_image_details')
|
||||||
|
: localize('com_ui_show_image_details');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<OGDialogContent
|
<OGDialogContent
|
||||||
|
|
@ -198,6 +202,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
||||||
|
aria-label={localize('com_ui_close')}
|
||||||
>
|
>
|
||||||
<X className="size-7 sm:size-6" />
|
<X className="size-7 sm:size-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -208,7 +213,12 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={localize('com_ui_reset_zoom')}
|
description={localize('com_ui_reset_zoom')}
|
||||||
render={
|
render={
|
||||||
<Button onClick={resetZoom} variant="ghost" className="h-10 w-10 p-0">
|
<Button
|
||||||
|
onClick={resetZoom}
|
||||||
|
variant="ghost"
|
||||||
|
className="h-10 w-10 p-0"
|
||||||
|
aria-label={localize('com_ui_reset_zoom')}
|
||||||
|
>
|
||||||
<RotateCcw className="size-6" />
|
<RotateCcw className="size-6" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|
@ -217,22 +227,24 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={localize('com_ui_download')}
|
description={localize('com_ui_download')}
|
||||||
render={
|
render={
|
||||||
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
|
<Button
|
||||||
|
onClick={() => downloadImage()}
|
||||||
|
variant="ghost"
|
||||||
|
className="h-10 w-10 p-0"
|
||||||
|
aria-label={localize('com_ui_download')}
|
||||||
|
>
|
||||||
<ArrowDownToLine className="size-6" />
|
<ArrowDownToLine className="size-6" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={
|
description={imageDetailsLabel}
|
||||||
isPromptOpen
|
|
||||||
? localize('com_ui_hide_image_details')
|
|
||||||
: localize('com_ui_show_image_details')
|
|
||||||
}
|
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsPromptOpen(!isPromptOpen)}
|
onClick={() => setIsPromptOpen(!isPromptOpen)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-10 w-10 p-0"
|
className="h-10 w-10 p-0"
|
||||||
|
aria-label={imageDetailsLabel}
|
||||||
>
|
>
|
||||||
{isPromptOpen ? (
|
{isPromptOpen ? (
|
||||||
<PanelLeftOpen className="size-7 sm:size-6" />
|
<PanelLeftOpen className="size-7 sm:size-6" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo } from 'react';
|
||||||
import { Skeleton } from '@librechat/client';
|
import { Skeleton } from '@librechat/client';
|
||||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||||
|
import { apiBaseUrl } from 'librechat-data-provider';
|
||||||
import { cn, scaleImage } from '~/utils';
|
import { cn, scaleImage } from '~/utils';
|
||||||
import DialogImage from './DialogImage';
|
import DialogImage from './DialogImage';
|
||||||
|
|
||||||
|
|
@ -36,6 +37,24 @@ const Image = ({
|
||||||
|
|
||||||
const handleImageLoad = () => setIsLoaded(true);
|
const handleImageLoad = () => setIsLoaded(true);
|
||||||
|
|
||||||
|
// Fix image path to include base path for subdirectory deployments
|
||||||
|
const absoluteImageUrl = useMemo(() => {
|
||||||
|
if (!imagePath) return imagePath;
|
||||||
|
|
||||||
|
// If it's already an absolute URL or doesn't start with /images/, return as is
|
||||||
|
if (
|
||||||
|
imagePath.startsWith('http') ||
|
||||||
|
imagePath.startsWith('data:') ||
|
||||||
|
!imagePath.startsWith('/images/')
|
||||||
|
) {
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the base URL and prepend it to the image path
|
||||||
|
const baseURL = apiBaseUrl();
|
||||||
|
return `${baseURL}${imagePath}`;
|
||||||
|
}, [imagePath]);
|
||||||
|
|
||||||
const { width: scaledWidth, height: scaledHeight } = useMemo(
|
const { width: scaledWidth, height: scaledHeight } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
scaleImage({
|
scaleImage({
|
||||||
|
|
@ -48,7 +67,7 @@ const Image = ({
|
||||||
|
|
||||||
const downloadImage = async () => {
|
const downloadImage = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(imagePath);
|
const response = await fetch(absoluteImageUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +86,7 @@ const Image = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Download failed:', error);
|
console.error('Download failed:', error);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = imagePath;
|
link.href = absoluteImageUrl;
|
||||||
link.download = altText || 'image.png';
|
link.download = altText || 'image.png';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
@ -97,7 +116,7 @@ const Image = ({
|
||||||
'opacity-100 transition-opacity duration-100',
|
'opacity-100 transition-opacity duration-100',
|
||||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||||
)}
|
)}
|
||||||
src={imagePath}
|
src={absoluteImageUrl}
|
||||||
style={{
|
style={{
|
||||||
width: `${scaledWidth}`,
|
width: `${scaledWidth}`,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
|
|
@ -117,7 +136,7 @@ const Image = ({
|
||||||
<DialogImage
|
<DialogImage
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
src={imagePath}
|
src={absoluteImageUrl}
|
||||||
downloadImage={downloadImage}
|
downloadImage={downloadImage}
|
||||||
args={args}
|
args={args}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
|
||||||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||||
import { langSubset, preprocessLaTeX } from '~/utils';
|
import { langSubset, preprocessLaTeX } from '~/utils';
|
||||||
import { unicodeCitation } from '~/components/Web';
|
import { unicodeCitation } from '~/components/Web';
|
||||||
import { code, a, p } from './MarkdownComponents';
|
import { code, a, p, img } from './MarkdownComponents';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
type TContentProps = {
|
type TContentProps = {
|
||||||
|
|
@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||||
code,
|
code,
|
||||||
a,
|
a,
|
||||||
p,
|
p,
|
||||||
|
img,
|
||||||
artifact: Artifact,
|
artifact: Artifact,
|
||||||
citation: Citation,
|
citation: Citation,
|
||||||
'highlighted-text': HighlightedText,
|
'highlighted-text': HighlightedText,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue