Compare commits

..

8 Commits

Author SHA1 Message Date
Marco Beretta
c27e8566fb Merge branch 'main' into re-add-download-audio 2024-07-30 05:36:43 -04:00
Marco Beretta
511f1336da Merge branch 'main' into re-add-download-audio 2024-07-17 18:38:28 +02:00
Marco Beretta
25ea3b8e98 revert lint 2 2024-07-16 17:18:15 +02:00
Marco Beretta
b1ec67ea42 revert lint 2024-07-16 17:17:43 +02:00
Marco Beretta
f1bb5fa4c5 revert back translation changes 2024-07-15 18:54:44 +02:00
Marco Beretta
44d8596872 fix: removed title button 2024-07-15 18:48:21 +02:00
Marco Beretta
32d84c85ea Merge branch 'main' into re-add-download-audio 2024-07-15 18:37:04 +02:00
Marco Beretta
d839e4661c feat: download audio file support 2024-06-18 16:24:37 +02:00
247 changed files with 4635 additions and 8990 deletions

View File

@@ -87,6 +87,7 @@ ANTHROPIC_API_KEY=user_provided
# Azure #
#============#
# Note: these variables are DEPRECATED
# Use the `librechat.yaml` configuration for `azureOpenAI` instead
# You may also continue to use them if you opt out of using the `librechat.yaml` configuration
@@ -116,7 +117,7 @@ BINGAI_TOKEN=user_provided
GOOGLE_KEY=user_provided
# GOOGLE_REVERSE_PROXY=
# Gemini API (AI Studio)
# Gemini API
# GOOGLE_MODELS=gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
# Vertex AI
@@ -124,24 +125,20 @@ GOOGLE_KEY=user_provided
# GOOGLE_TITLE_MODEL=gemini-pro
# Google Safety Settings
# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
# Google Gemini Safety Settings
# NOTE (Vertex AI): You do not have access to the BLOCK_NONE setting by default.
# To use this restricted HarmBlockThreshold setting, you will need to either:
#
# For Vertex AI:
# To use the BLOCK_NONE setting, you need either:
# (a) Access through an allowlist via your Google account team, or
# (b) Switch to monthly invoiced billing: https://cloud.google.com/billing/docs/how-to/invoiced-billing
#
# For Gemini API (AI Studio):
# BLOCK_NONE is available by default, no special account requirements.
#
# Available options: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE
# (a) Get access through an allowlist via your Google account team
# (b) Switch your account type to monthly invoiced billing following this instruction:
# https://cloud.google.com/billing/docs/how-to/invoiced-billing
#
# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
#============#
# OpenAI #
#============#
@@ -264,6 +261,7 @@ MEILI_NO_ANALYTICS=true
MEILI_HOST=http://0.0.0.0:7700
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
#==================================================#
# Speech to Text & Text to Speech #
#==================================================#
@@ -271,16 +269,6 @@ MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
STT_API_KEY=
TTS_API_KEY=
#==================================================#
# RAG #
#==================================================#
# More info: https://www.librechat.ai/docs/configuration/rag_api
# RAG_OPENAI_BASEURL=
# RAG_OPENAI_API_KEY=
# EMBEDDINGS_PROVIDER=openai
# EMBEDDINGS_MODEL=text-embedding-3-small
#===================================================#
# User System #
#===================================================#
@@ -425,18 +413,6 @@ FIREBASE_APP_ID=
ALLOW_SHARED_LINKS=true
ALLOW_SHARED_LINKS_PUBLIC=true
#==============================#
# Static File Cache Control #
#==============================#
# Leave commented out to use default of 1 month for max-age and 1 week for s-maxage
# NODE_ENV must be set to production for these to take effect
# STATIC_CACHE_MAX_AGE=604800
# STATIC_CACHE_S_MAX_AGE=259200
# If you have another service in front of your LibreChat doing compression, disable express based compression here
# DISABLE_COMPRESSION=true
#===================================================#
# UI #
#===================================================#

View File

@@ -12,7 +12,6 @@ module.exports = {
'plugin:react-hooks/recommended',
'plugin:jest/recommended',
'prettier',
'plugin:jsx-a11y/recommended',
],
ignorePatterns: [
'client/dist/**/*',
@@ -33,7 +32,7 @@ module.exports = {
jsx: true,
},
},
plugins: ['react', 'react-hooks', '@typescript-eslint', 'import', 'jsx-a11y'],
plugins: ['react', 'react-hooks', '@typescript-eslint', 'import'],
rules: {
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow' }],
@@ -66,7 +65,6 @@ module.exports = {
'no-restricted-syntax': 'off',
'react/prop-types': ['off'],
'react/display-name': ['off'],
'no-nested-ternary': 'error',
'no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
quotes: ['error', 'single'],
},
@@ -120,8 +118,6 @@ module.exports = {
],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-condition': 'warn',
'@typescript-eslint/strict-boolean-expressions': 'warn',
},
},
{

View File

@@ -1,17 +0,0 @@
name: Lint for accessibility issues
on:
pull_request:
paths:
- 'client/src/**'
jobs:
axe-linter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dequelabs/axe-linter-action@v1
with:
api_key: ${{ secrets.AXE_LINTER_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,41 +0,0 @@
name: Update Test Server
on:
workflow_run:
workflows: ["Docker Dev Images Build"]
types:
- completed
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
if: |
github.repository == 'danny-avila/LibreChat' &&
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install SSH Key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
known_hosts: ${{ secrets.DO_KNOWN_HOSTS }}
- name: Run update script on DigitalOcean Droplet
env:
DO_HOST: ${{ secrets.DO_HOST }}
DO_USER: ${{ secrets.DO_USER }}
run: |
ssh -o StrictHostKeyChecking=no ${DO_USER}@${DO_HOST} << EOF
sudo -i -u danny bash << EEOF
cd ~/LibreChat && \
git fetch origin main && \
npm run update:deployed && \
git checkout do-deploy && \
git rebase main && \
npm run start:deployed && \
echo "Update completed. Application should be running now."
EEOF
EOF

View File

@@ -1,35 +0,0 @@
name: Build Helm Charts on Tag
# The workflow is triggered when a tag is pushed
on:
push:
tags:
- "*"
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: helmchart
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -1,4 +1,4 @@
# v0.7.4
# v0.7.3
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,4 +1,4 @@
# v0.7.4
# v0.7.3
# Build API, Client and Data Provider
FROM node:20-alpine AS base
@@ -38,6 +38,6 @@ ENV HOST=0.0.0.0
CMD ["node", "server/index.js"]
# Nginx setup
FROM nginx:1.27.0-alpine AS prod-stage
FROM nginx:1.21.1-alpine AS prod-stage
COPY ./client/nginx.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -677,6 +677,7 @@ class GoogleClient extends BaseClient {
const modelName = clientOptions.modelName ?? clientOptions.model ?? '';
if (modelName?.includes('1.5') && !this.project_id) {
/** @type {GenerativeModel} */
const client = model;
const requestOptions = {
contents: _payload,
@@ -692,7 +693,8 @@ class GoogleClient extends BaseClient {
};
}
requestOptions.safetySettings = _payload.safetySettings;
const safetySettings = _payload.safetySettings;
requestOptions.safetySettings = safetySettings;
const delay = modelName.includes('flash') ? 8 : 14;
const result = await client.generateContentStream(requestOptions);
@@ -707,10 +709,11 @@ class GoogleClient extends BaseClient {
return reply;
}
const safetySettings = _payload.safetySettings;
const stream = await model.stream(messages, {
signal: abortController.signal,
timeout: 7000,
safetySettings: _payload.safetySettings,
safetySettings: safetySettings,
});
let delay = this.options.streamRate || 8;
@@ -868,36 +871,38 @@ class GoogleClient extends BaseClient {
}
async sendCompletion(payload, opts = {}) {
payload.safetySettings = this.getSafetySettings();
const modelName = payload.parameters?.model;
if (modelName && modelName.toLowerCase().includes('gemini')) {
const safetySettings = [
{
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold:
process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HATE_SPEECH',
threshold: process.env.GOOGLE_SAFETY_HATE_SPEECH || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HARASSMENT',
threshold: process.env.GOOGLE_SAFETY_HARASSMENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold:
process.env.GOOGLE_SAFETY_DANGEROUS_CONTENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
];
payload.safetySettings = safetySettings;
}
let reply = '';
reply = await this.getCompletion(payload, opts);
return reply.trim();
}
getSafetySettings() {
return [
{
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold:
process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HATE_SPEECH',
threshold: process.env.GOOGLE_SAFETY_HATE_SPEECH || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HARASSMENT',
threshold: process.env.GOOGLE_SAFETY_HARASSMENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold:
process.env.GOOGLE_SAFETY_DANGEROUS_CONTENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
];
}
/* TO-DO: Handle tokens with Google tokenization NOTE: these are required */
static getTokenizer(encoding, isModelName = false, extendSpecialTokens = {}) {
if (tokenizersCache[encoding]) {

View File

@@ -60,10 +60,10 @@ function addImages(intermediateSteps, responseMessage) {
if (!observation || !observation.includes('![')) {
return;
}
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
const observedImagePath = observation.match(/!\[.*\]\([^)]*\)/g);
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
responseMessage.text += '\n' + observedImagePath[0];
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
responseMessage.text += '\n' + observation;
logger.debug('[addImages] added image from intermediateSteps:', observation);
}
});
}

View File

@@ -81,62 +81,4 @@ describe('addImages', () => {
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(`${originalText}\n${imageMarkdown}`);
});
it('should extract only image markdowns when there is text between them', () => {
const markdownWithTextBetweenImages = `
![image1](/images/image1.png)
Some text between images that should not be included.
![image2](/images/image2.png)
More text that should be ignored.
![image3](/images/image3.png)
`;
intermediateSteps.push({ observation: markdownWithTextBetweenImages });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
it('should only return the first image when multiple images are present', () => {
const markdownWithMultipleImages = `
![image1](/images/image1.png)
![image2](/images/image2.png)
![image3](/images/image3.png)
`;
intermediateSteps.push({ observation: markdownWithMultipleImages });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
it('should not include any text or metadata surrounding the image markdown', () => {
const markdownWithMetadata = `
Title: Test Document
Author: John Doe
![image1](/images/image1.png)
Some content after the image.
Vector values: [0.1, 0.2, 0.3]
`;
intermediateSteps.push({ observation: markdownWithMetadata });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
it('should handle complex markdown with multiple images and only return the first one', () => {
const complexMarkdown = `
# Document Title
## Section 1
Here's some text with an embedded image:
![image1](/images/image1.png)
## Section 2
More text here...
![image2](/images/image2.png)
### Subsection
Even more content
![image3](/images/image3.png)
`;
intermediateSteps.push({ observation: complexMarkdown });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
});

View File

@@ -38,12 +38,7 @@ const run = async () => {
"On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains."
`;
const model = 'gpt-3.5-turbo';
let maxContextTokens = 4095;
if (model === 'gpt-4') {
maxContextTokens = 8191;
} else if (model === 'gpt-4-32k') {
maxContextTokens = 32767;
}
const maxContextTokens = model === 'gpt-4' ? 8191 : model === 'gpt-4-32k' ? 32767 : 4095; // 1 less than maximum
const clientOptions = {
reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null,
maxContextTokens,

View File

@@ -12,15 +12,9 @@ class GoogleSearchResults extends Tool {
this.envVarApiKey = 'GOOGLE_SEARCH_API_KEY';
this.envVarSearchEngineId = 'GOOGLE_CSE_ID';
this.override = fields.override ?? false;
this.apiKey = fields[this.envVarApiKey] ?? getEnvironmentVariable(this.envVarApiKey);
this.apiKey = fields.apiKey ?? getEnvironmentVariable(this.envVarApiKey);
this.searchEngineId =
fields[this.envVarSearchEngineId] ?? getEnvironmentVariable(this.envVarSearchEngineId);
if (!this.override && (!this.apiKey || !this.searchEngineId)) {
throw new Error(
`Missing ${this.envVarApiKey} or ${this.envVarSearchEngineId} environment variable.`,
);
}
fields.searchEngineId ?? getEnvironmentVariable(this.envVarSearchEngineId);
this.kwargs = fields?.kwargs ?? {};
this.name = 'google';

View File

@@ -12,7 +12,7 @@ class TavilySearchResults extends Tool {
this.envVar = 'TAVILY_API_KEY';
/* Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false;
this.apiKey = fields[this.envVar] ?? this.getApiKey();
this.apiKey = fields.apiKey ?? this.getApiKey();
this.kwargs = fields?.kwargs ?? {};
this.name = 'tavily_search_results_json';
@@ -82,9 +82,7 @@ class TavilySearchResults extends Tool {
const json = await response.json();
if (!response.ok) {
throw new Error(
`Request failed with status ${response.status}: ${json?.detail?.error || json?.error}`,
);
throw new Error(`Request failed with status ${response.status}: ${json.error}`);
}
return JSON.stringify(json);

View File

@@ -1,50 +0,0 @@
const GoogleSearch = require('../GoogleSearch');
jest.mock('node-fetch');
jest.mock('@langchain/core/utils/env');
describe('GoogleSearch', () => {
let originalEnv;
const mockApiKey = 'mock_api';
const mockSearchEngineId = 'mock_search_engine_id';
beforeAll(() => {
originalEnv = { ...process.env };
});
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
GOOGLE_SEARCH_API_KEY: mockApiKey,
GOOGLE_CSE_ID: mockSearchEngineId,
};
});
afterEach(() => {
jest.clearAllMocks();
process.env = originalEnv;
});
it('should use mockApiKey and mockSearchEngineId when environment variables are not set', () => {
const instance = new GoogleSearch({
GOOGLE_SEARCH_API_KEY: mockApiKey,
GOOGLE_CSE_ID: mockSearchEngineId,
});
expect(instance.apiKey).toBe(mockApiKey);
expect(instance.searchEngineId).toBe(mockSearchEngineId);
});
it('should throw an error if GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID is missing', () => {
delete process.env.GOOGLE_SEARCH_API_KEY;
expect(() => new GoogleSearch()).toThrow(
'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
);
process.env.GOOGLE_SEARCH_API_KEY = mockApiKey;
delete process.env.GOOGLE_CSE_ID;
expect(() => new GoogleSearch()).toThrow(
'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
);
});
});

View File

@@ -1,38 +0,0 @@
const TavilySearchResults = require('../TavilySearchResults');
jest.mock('node-fetch');
jest.mock('@langchain/core/utils/env');
describe('TavilySearchResults', () => {
let originalEnv;
const mockApiKey = 'mock_api_key';
beforeAll(() => {
originalEnv = { ...process.env };
});
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
TAVILY_API_KEY: mockApiKey,
};
});
afterEach(() => {
jest.clearAllMocks();
process.env = originalEnv;
});
it('should throw an error if TAVILY_API_KEY is missing', () => {
delete process.env.TAVILY_API_KEY;
expect(() => new TavilySearchResults()).toThrow('Missing TAVILY_API_KEY environment variable.');
});
it('should use mockApiKey when TAVILY_API_KEY is not set in the environment', () => {
const instance = new TavilySearchResults({
TAVILY_API_KEY: mockApiKey,
});
expect(instance.apiKey).toBe(mockApiKey);
});
});

View File

@@ -35,11 +35,11 @@ const messages = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis, ttl: Time.FIVE_MINUTES })
: new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.FIVE_MINUTES });
const tokenConfig = isEnabled(USE_REDIS)
const tokenConfig = isEnabled(USE_REDIS) // ttl: 30 minutes
? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES })
: new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES });
const genTitle = isEnabled(USE_REDIS)
const genTitle = isEnabled(USE_REDIS) // ttl: 2 minutes
? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES })
: new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: Time.TWO_MINUTES });
@@ -69,7 +69,6 @@ const namespaces = {
registrations: createViolationInstance('registrations'),
[ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT),
[ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT),
[ViolationTypes.CONVO_ACCESS]: createViolationInstance(ViolationTypes.CONVO_ACCESS),
[ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT),
[ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT),
[ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance(

View File

@@ -2,20 +2,6 @@ const Conversation = require('./schema/convoSchema');
const { getMessages, deleteMessages } = require('./Message');
const logger = require('~/config/winston');
/**
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
* @param {string} conversationId - The conversation's ID.
* @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found.
*/
const searchConversation = async (conversationId) => {
try {
return await Conversation.findOne({ conversationId }, 'conversationId user').lean();
} catch (error) {
logger.error('[searchConversation] Error searching conversation', error);
throw new Error('Error searching conversation');
}
};
/**
* Retrieves a single conversation for a given user and conversation ID.
* @param {string} user - The user's ID.
@@ -33,7 +19,6 @@ const getConvo = async (user, conversationId) => {
module.exports = {
Conversation,
searchConversation,
/**
* Saves a conversation to the database.
* @param {Object} req - The request object.

View File

@@ -1,242 +1,268 @@
const ConversationTag = require('./schema/conversationTagSchema');
const Conversation = require('./schema/convoSchema');
//const crypto = require('crypto');
const logger = require('~/config/winston');
const Conversation = require('./schema/convoSchema');
const ConversationTag = require('./schema/conversationTagSchema');
/**
* Retrieves all conversation tags for a user.
* @param {string} user - The user ID.
* @returns {Promise<Array>} An array of conversation tags.
*/
const getConversationTags = async (user) => {
try {
return await ConversationTag.find({ user }).sort({ position: 1 }).lean();
} catch (error) {
logger.error('[getConversationTags] Error getting conversation tags', error);
throw new Error('Error getting conversation tags');
}
};
const SAVED_TAG = 'Saved';
/**
* Creates a new conversation tag.
* @param {string} user - The user ID.
* @param {Object} data - The tag data.
* @param {string} data.tag - The tag name.
* @param {string} [data.description] - The tag description.
* @param {boolean} [data.addToConversation] - Whether to add the tag to a conversation.
* @param {string} [data.conversationId] - The conversation ID to add the tag to.
* @returns {Promise<Object>} The created tag.
*/
const createConversationTag = async (user, data) => {
try {
const { tag, description, addToConversation, conversationId } = data;
const existingTag = await ConversationTag.findOne({ user, tag }).lean();
if (existingTag) {
return existingTag;
}
const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean();
const position = (maxPosition?.position || 0) + 1;
const newTag = await ConversationTag.findOneAndUpdate(
{ tag, user },
{
tag,
user,
count: addToConversation ? 1 : 0,
position,
description,
$setOnInsert: { createdAt: new Date() },
},
{
new: true,
upsert: true,
lean: true,
},
);
if (addToConversation && conversationId) {
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $addToSet: { tags: tag } },
{ new: true },
);
}
return newTag;
} catch (error) {
logger.error('[createConversationTag] Error creating conversation tag', error);
throw new Error('Error creating conversation tag');
}
};
/**
* Updates an existing conversation tag.
* @param {string} user - The user ID.
* @param {string} oldTag - The current tag name.
* @param {Object} data - The updated tag data.
* @param {string} [data.tag] - The new tag name.
* @param {string} [data.description] - The updated description.
* @param {number} [data.position] - The new position.
* @returns {Promise<Object>} The updated tag.
*/
const updateConversationTag = async (user, oldTag, data) => {
try {
const { tag: newTag, description, position } = data;
const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
if (!existingTag) {
return null;
}
if (newTag && newTag !== oldTag) {
const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean();
if (tagAlreadyExists) {
throw new Error('Tag already exists');
}
await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } });
}
const updateData = {};
if (newTag) {
updateData.tag = newTag;
}
if (description !== undefined) {
updateData.description = description;
}
if (position !== undefined) {
await adjustPositions(user, existingTag.position, position);
updateData.position = position;
}
return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, {
new: true,
lean: true,
});
} catch (error) {
logger.error('[updateConversationTag] Error updating conversation tag', error);
throw new Error('Error updating conversation tag');
}
};
/**
* Adjusts positions of tags when a tag's position is changed.
* @param {string} user - The user ID.
* @param {number} oldPosition - The old position of the tag.
* @param {number} newPosition - The new position of the tag.
* @returns {Promise<void>}
*/
const adjustPositions = async (user, oldPosition, newPosition) => {
if (oldPosition === newPosition) {
return;
}
const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
await ConversationTag.updateMany(
{
user,
position: {
$gt: Math.min(oldPosition, newPosition),
$lte: Math.max(oldPosition, newPosition),
},
},
update,
);
};
/**
* Deletes a conversation tag.
* @param {string} user - The user ID.
* @param {string} tag - The tag to delete.
* @returns {Promise<Object>} The deleted tag.
*/
const deleteConversationTag = async (user, tag) => {
try {
const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
if (!deletedTag) {
return null;
}
await Conversation.updateMany({ user, tags: tag }, { $pull: { tags: tag } });
await ConversationTag.updateMany(
{ user, position: { $gt: deletedTag.position } },
{ $inc: { position: -1 } },
);
return deletedTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
throw new Error('Error deleting conversation tag');
}
};
/**
* Updates tags for a specific conversation.
* @param {string} user - The user ID.
* @param {string} conversationId - The conversation ID.
* @param {string[]} tags - The new set of tags for the conversation.
* @returns {Promise<string[]>} The updated list of tags for the conversation.
*/
const updateTagsForConversation = async (user, conversationId, tags) => {
try {
const conversation = await Conversation.findOne({ user, conversationId }).lean();
const conversation = await Conversation.findOne({ user, conversationId });
if (!conversation) {
throw new Error('Conversation not found');
return { message: 'Conversation not found' };
}
const oldTags = new Set(conversation.tags);
const newTags = new Set(tags);
const addedTags = [...newTags].filter((tag) => !oldTags.has(tag));
const removedTags = [...oldTags].filter((tag) => !newTags.has(tag));
const bulkOps = [];
const addedTags = tags.tags.filter((tag) => !conversation.tags.includes(tag));
const removedTags = conversation.tags.filter((tag) => !tags.tags.includes(tag));
for (const tag of addedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: 1 } },
upsert: true,
},
});
await ConversationTag.updateOne({ tag, user }, { $inc: { count: 1 } }, { upsert: true });
}
for (const tag of removedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: -1 } },
},
});
await ConversationTag.updateOne({ tag, user }, { $inc: { count: -1 } });
}
if (bulkOps.length > 0) {
await ConversationTag.bulkWrite(bulkOps);
}
const updatedConversation = (
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $set: { tags: [...newTags] } },
{ new: true },
)
).toObject();
return updatedConversation.tags;
conversation.tags = tags.tags;
await conversation.save({ timestamps: { updatedAt: false } });
return conversation.tags;
} catch (error) {
logger.error('[updateTagsForConversation] Error updating tags', error);
throw new Error('Error updating tags for conversation');
logger.error('[updateTagsToConversation] Error updating tags', error);
return { message: 'Error updating tags' };
}
};
module.exports = {
getConversationTags,
createConversationTag,
updateConversationTag,
deleteConversationTag,
updateTagsForConversation,
const createConversationTag = async (user, data) => {
try {
const cTag = await ConversationTag.findOne({ user, tag: data.tag });
if (cTag) {
return cTag;
}
const addToConversation = data.addToConversation && data.conversationId;
const newTag = await ConversationTag.create({
user,
tag: data.tag,
count: 0,
description: data.description,
position: 1,
});
await ConversationTag.updateMany(
{ user, position: { $gte: 1 }, _id: { $ne: newTag._id } },
{ $inc: { position: 1 } },
);
if (addToConversation) {
const conversation = await Conversation.findOne({
user,
conversationId: data.conversationId,
});
if (conversation) {
const tags = [...(conversation.tags || []), data.tag];
await updateTagsForConversation(user, data.conversationId, { tags });
} else {
logger.warn('[updateTagsForConversation] Conversation not found', data.conversationId);
}
}
return await ConversationTag.findOne({ user, tag: data.tag });
} catch (error) {
logger.error('[createConversationTag] Error updating conversation tag', error);
return { message: 'Error updating conversation tag' };
}
};
const replaceOrRemoveTagInConversations = async (user, oldtag, newtag) => {
try {
const conversations = await Conversation.find({ user, tags: { $in: [oldtag] } });
for (const conversation of conversations) {
if (newtag && newtag !== '') {
conversation.tags = conversation.tags.map((tag) => (tag === oldtag ? newtag : tag));
} else {
conversation.tags = conversation.tags.filter((tag) => tag !== oldtag);
}
await conversation.save({ timestamps: { updatedAt: false } });
}
} catch (error) {
logger.error('[replaceOrRemoveTagInConversations] Error updating conversation tags', error);
return { message: 'Error updating conversation tags' };
}
};
const updateTagPosition = async (user, tag, newPosition) => {
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return { message: 'Tag not found' };
}
const oldPosition = cTag.position;
if (newPosition === oldPosition) {
return cTag;
}
const updateOperations = [];
if (newPosition > oldPosition) {
// Move other tags up
updateOperations.push({
updateMany: {
filter: {
user,
position: { $gt: oldPosition, $lte: newPosition },
tag: { $ne: SAVED_TAG },
},
update: { $inc: { position: -1 } },
},
});
} else {
// Move other tags down
updateOperations.push({
updateMany: {
filter: {
user,
position: { $gte: newPosition, $lt: oldPosition },
tag: { $ne: SAVED_TAG },
},
update: { $inc: { position: 1 } },
},
});
}
// Update the target tag's position
updateOperations.push({
updateOne: {
filter: { _id: cTag._id },
update: { $set: { position: newPosition } },
},
});
await ConversationTag.bulkWrite(updateOperations);
return await ConversationTag.findById(cTag._id);
} catch (error) {
logger.error('[updateTagPosition] Error updating tag position', error);
return { message: 'Error updating tag position' };
}
};
module.exports = {
SAVED_TAG,
ConversationTag,
getConversationTags: async (user) => {
try {
const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean();
cTags.sort((a, b) => (a.tag === SAVED_TAG ? -1 : b.tag === SAVED_TAG ? 1 : 0));
return cTags;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
return { message: 'Error getting share link' };
}
},
createConversationTag,
updateConversationTag: async (user, tag, data) => {
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return createConversationTag(user, data);
}
if (cTag.tag !== data.tag || cTag.description !== data.description) {
cTag.tag = data.tag;
cTag.description = data.description === undefined ? cTag.description : data.description;
await cTag.save();
}
if (data.position !== undefined && cTag.position !== data.position) {
await updateTagPosition(user, tag, data.position);
}
// update conversation tags properties
replaceOrRemoveTagInConversations(user, tag, data.tag);
return await ConversationTag.findOne({ user, tag: data.tag });
} catch (error) {
logger.error('[updateConversationTag] Error updating conversation tag', error);
return { message: 'Error updating conversation tag' };
}
},
deleteConversationTag: async (user, tag) => {
try {
const currentTag = await ConversationTag.findOne({ user, tag });
if (!currentTag) {
return;
}
await currentTag.deleteOne({ user, tag });
await replaceOrRemoveTagInConversations(user, tag, null);
return currentTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
return { message: 'Error deleting conversation tag' };
}
},
updateTagsForConversation,
rebuildConversationTags: async (user) => {
try {
const conversations = await Conversation.find({ user }).select('tags');
const tagCountMap = {};
// Count the occurrences of each tag
conversations.forEach((conversation) => {
conversation.tags.forEach((tag) => {
if (tagCountMap[tag]) {
tagCountMap[tag]++;
} else {
tagCountMap[tag] = 1;
}
});
});
const tags = await ConversationTag.find({ user }).sort({ position: -1 });
// Update existing tags and add new tags
for (const [tag, count] of Object.entries(tagCountMap)) {
const existingTag = tags.find((t) => t.tag === tag);
if (existingTag) {
existingTag.count = count;
await existingTag.save();
} else {
const newTag = new ConversationTag({ user, tag, count });
tags.push(newTag);
await newTag.save();
}
}
// Set count to 0 for tags that are not in the grouped tags
for (const tag of tags) {
if (!tagCountMap[tag.tag]) {
tag.count = 0;
await tag.save();
}
}
// Sort tags by position in descending order
tags.sort((a, b) => a.position - b.position);
// Move the tag with name "saved" to the first position
const savedTagIndex = tags.findIndex((tag) => tag.tag === SAVED_TAG);
if (savedTagIndex !== -1) {
const [savedTag] = tags.splice(savedTagIndex, 1);
tags.unshift(savedTag);
}
// Reassign positions starting from 0
tags.forEach((tag, index) => {
tag.position = index;
tag.save();
});
return tags;
} catch (error) {
logger.error('[rearrangeTags] Error rearranging tags', error);
return { message: 'Error rearranging tags' };
}
},
};

View File

@@ -317,7 +317,7 @@ async function getMessages(filter, select) {
* @async
* @function deleteMessages
* @param {Object} filter - The filter criteria to find messages to delete.
* @returns {Promise<Object>} The metadata with count of deleted messages.
* @returns {Promise<Number>} The number of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {

View File

@@ -1,6 +1,6 @@
const crypto = require('crypto');
const mongoose = require('mongoose');
const signPayload = require('~/server/services/signPayload');
const { hashToken } = require('~/server/utils/crypto');
const { logger } = require('~/config');
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
@@ -39,7 +39,8 @@ sessionSchema.methods.generateRefreshToken = async function () {
expirationTime: Math.floor((expiresIn - Date.now()) / 1000),
});
this.refreshTokenHash = await hashToken(refreshToken);
const hash = crypto.createHash('sha256');
this.refreshTokenHash = hash.update(refreshToken).digest('hex');
await this.save();

View File

@@ -1,252 +1,117 @@
const { nanoid } = require('nanoid');
const { Constants } = require('librechat-data-provider');
const SharedLink = require('./schema/shareSchema');
const crypto = require('crypto');
const { getMessages } = require('./Message');
const SharedLink = require('./schema/shareSchema');
const logger = require('~/config/winston');
/**
* Anonymizes a conversation ID
* @returns {string} The anonymized conversation ID
*/
function anonymizeConvoId() {
return `convo_${nanoid()}`;
}
module.exports = {
SharedLink,
getSharedMessages: async (shareId) => {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
/**
* Anonymizes an assistant ID
* @returns {string} The anonymized assistant ID
*/
function anonymizeAssistantId() {
return `a_${nanoid()}`;
}
if (!share || !share.conversationId || !share.isPublic) {
return null;
}
/**
* Anonymizes a message ID
* @param {string} id - The original message ID
* @returns {string} The anonymized message ID
*/
function anonymizeMessageId(id) {
return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`;
}
/**
* Anonymizes a conversation object
* @param {object} conversation - The conversation object
* @returns {object} The anonymized conversation object
*/
function anonymizeConvo(conversation) {
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId();
}
return newConvo;
}
/**
* Anonymizes messages in a conversation
* @param {TMessage[]} messages - The original messages
* @param {string} newConvoId - The new conversation ID
* @returns {TMessage[]} The anonymized messages
*/
function anonymizeMessages(messages, newConvoId) {
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedMessage = Object.assign(message, {
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
});
if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) {
anonymizedMessage.model = anonymizeAssistantId();
return share;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
throw new Error('Error getting share link');
}
},
return anonymizedMessage;
});
}
/**
* Retrieves shared messages for a given share ID
* @param {string} shareId - The share ID
* @returns {Promise<object|null>} The shared conversation data or null if not found
*/
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share || !share.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId();
return Object.assign(share, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
});
} catch (error) {
logger.error('[getShare] Error getting share link', error);
throw new Error('Error getting share link');
}
}
/**
* Retrieves shared links for a user
* @param {string} user - The user ID
* @param {number} [pageNumber=1] - The page number
* @param {number} [pageSize=25] - The page size
* @param {boolean} [isPublic=true] - Whether to retrieve public links only
* @returns {Promise<object>} The shared links and pagination data
*/
async function getSharedLinks(user, pageNumber = 1, pageSize = 25, isPublic = true) {
const query = { user, isPublic };
try {
const [totalConvos, sharedLinks] = await Promise.all([
SharedLink.countDocuments(query),
SharedLink.find(query)
getSharedLinks: async (user, pageNumber = 1, pageSize = 25, isPublic = true) => {
const query = { user, isPublic };
try {
const totalConvos = (await SharedLink.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const shares = await SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean(),
]);
.lean();
const totalPages = Math.ceil((totalConvos || 1) / pageSize);
return { sharedLinks: shares, pages: totalPages, pageNumber, pageSize };
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
},
return {
sharedLinks,
pages: totalPages,
pageNumber,
pageSize,
};
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
}
createSharedLink: async (user, { conversationId, ...shareData }) => {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
return share;
}
/**
* Creates a new shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The created shared link
*/
async function createSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(share);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
const shareId = crypto.randomUUID();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: true,
});
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
}
},
const shareId = nanoid();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
const newShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: true,
}).lean();
updateSharedLink: async (user, { conversationId, ...shareData }) => {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(newShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(newShare.messages, newConvoId),
});
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
}
}
/**
* Updates an existing shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data to update
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The updated shared link
*/
async function updateSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
// update messages to the latest
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: false,
});
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', error);
throw new Error('Error updating shared link');
}
},
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: false,
}).lean();
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(updatedShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(updatedShare.messages, newConvoId),
});
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', error);
throw new Error('Error updating shared link');
}
}
/**
* Deletes a shared link
* @param {string} user - The user ID
* @param {object} params - The deletion parameters
* @param {string} params.shareId - The share ID to delete
* @returns {Promise<object>} The result of the deletion
*/
async function deleteSharedLink(user, { shareId }) {
try {
const result = await SharedLink.findOneAndDelete({ shareId, user });
return result ? { message: 'Share deleted successfully' } : { message: 'Share not found' };
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
}
}
/**
* Deletes all shared links for a specific user
* @param {string} user - The user ID
* @returns {Promise<object>} The result of the deletion
*/
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
throw new Error('Error deleting shared links');
}
}
module.exports = {
SharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedMessages,
deleteAllSharedLinks,
deleteSharedLink: async (user, { shareId }) => {
try {
const share = await SharedLink.findOne({ shareId, user });
if (!share) {
return { message: 'Share not found' };
}
return await SharedLink.findOneAndDelete({ shareId, user });
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
}
},
/**
* Deletes all shared links for a specific user.
* @param {string} user - The user ID.
* @returns {Promise<{ message: string, deletedCount?: number }>} A result object indicating success or error message.
*/
deleteAllSharedLinks: async (user) => {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
throw new Error('Error deleting shared links');
}
},
};

View File

@@ -61,7 +61,6 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
}
convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1 }, { unique: true });
const Conversation = mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);

View File

@@ -131,7 +131,6 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
messageSchema.index({ createdAt: 1 });
messageSchema.index({ messageId: 1, user: 1 }, { unique: true });
/** @type {mongoose.Model<TMessage>} */
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
module.exports = Message;

View File

@@ -1,5 +1,4 @@
const mongoose = require('mongoose');
const { SystemRoles } = require('librechat-data-provider');
/**
* @typedef {Object} MongoSession
@@ -79,7 +78,7 @@ const userSchema = mongoose.Schema(
},
role: {
type: String,
default: SystemRoles.USER,
default: 'USER',
},
googleId: {
type: String,

View File

@@ -1,74 +1,38 @@
const { matchModelName } = require('../utils');
const defaultRate = 6;
/** AWS Bedrock pricing */
const bedrockValues = {
'anthropic.claude-3-haiku-20240307-v1:0': { prompt: 0.25, completion: 1.25 },
'anthropic.claude-3-sonnet-20240229-v1:0': { prompt: 3.0, completion: 15.0 },
'anthropic.claude-3-opus-20240229-v1:0': { prompt: 15.0, completion: 75.0 },
'anthropic.claude-3-5-sonnet-20240620-v1:0': { prompt: 3.0, completion: 15.0 },
'anthropic.claude-v2:1': { prompt: 8.0, completion: 24.0 },
'anthropic.claude-instant-v1': { prompt: 0.8, completion: 2.4 },
'meta.llama2-13b-chat-v1': { prompt: 0.75, completion: 1.0 },
'meta.llama2-70b-chat-v1': { prompt: 1.95, completion: 2.56 },
'meta.llama3-8b-instruct-v1:0': { prompt: 0.3, completion: 0.6 },
'meta.llama3-70b-instruct-v1:0': { prompt: 2.65, completion: 3.5 },
'meta.llama3-1-8b-instruct-v1:0': { prompt: 0.3, completion: 0.6 },
'meta.llama3-1-70b-instruct-v1:0': { prompt: 2.65, completion: 3.5 },
'meta.llama3-1-405b-instruct-v1:0': { prompt: 5.32, completion: 16.0 },
'mistral.mistral-7b-instruct-v0:2': { prompt: 0.15, completion: 0.2 },
'mistral.mistral-small-2402-v1:0': { prompt: 0.15, completion: 0.2 },
'mistral.mixtral-8x7b-instruct-v0:1': { prompt: 0.45, completion: 0.7 },
'mistral.mistral-large-2402-v1:0': { prompt: 4.0, completion: 12.0 },
'mistral.mistral-large-2407-v1:0': { prompt: 3.0, completion: 9.0 },
'cohere.command-text-v14': { prompt: 1.5, completion: 2.0 },
'cohere.command-light-text-v14': { prompt: 0.3, completion: 0.6 },
'cohere.command-r-v1:0': { prompt: 0.5, completion: 1.5 },
'cohere.command-r-plus-v1:0': { prompt: 3.0, completion: 15.0 },
'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 },
'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 },
'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 },
'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 },
};
for (const [key, value] of Object.entries(bedrockValues)) {
bedrockValues[`bedrock/${key}`] = value;
}
/**
* Mapping of model token sizes to their respective multipliers for prompt and completion.
* The rates are 1 USD per 1M tokens.
* @type {Object.<string, {prompt: number, completion: number}>}
*/
const tokenValues = Object.assign(
{
'8k': { prompt: 30, completion: 60 },
'32k': { prompt: 60, completion: 120 },
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'gpt-4o-2024-08-06': { prompt: 2.5, completion: 10 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-4o': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-2': { prompt: 8, completion: 24 },
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
/* cohere doesn't have rates for the older command models,
const tokenValues = {
'8k': { prompt: 30, completion: 60 },
'32k': { prompt: 60, completion: 120 },
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-4o': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-2': { prompt: 8, completion: 24 },
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
/* cohere doesn't have rates for the older command models,
so this was from https://artificialanalysis.ai/models/command-light/providers */
command: { prompt: 0.38, completion: 0.38 },
'gemini-1.5': { prompt: 7, completion: 21 }, // May 2nd, 2024 pricing
gemini: { prompt: 0.5, completion: 1.5 }, // May 2nd, 2024 pricing
},
bedrockValues,
);
command: { prompt: 0.38, completion: 0.38 },
// 'gemini-1.5': { prompt: 7, completion: 21 }, // May 2nd, 2024 pricing
// 'gemini': { prompt: 0.5, completion: 1.5 }, // May 2nd, 2024 pricing
'gemini-1.5': { prompt: 0, completion: 0 }, // currently free
gemini: { prompt: 0, completion: 0 }, // currently free
};
/**
* Retrieves the key associated with a given model name.
@@ -91,8 +55,6 @@ const getValueKey = (model, endpoint) => {
return 'gpt-3.5-turbo-1106';
} else if (modelName.includes('gpt-3.5')) {
return '4k';
} else if (modelName.includes('gpt-4o-2024-08-06')) {
return 'gpt-4o-2024-08-06';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {

View File

@@ -53,14 +53,6 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4o-mini-2024-07-18')).toBe('gpt-4o-mini');
expect(getValueKey('openai/gpt-4o-mini')).toBe('gpt-4o-mini');
expect(getValueKey('gpt-4o-mini-0718')).toBe('gpt-4o-mini');
expect(getValueKey('gpt-4o-2024-08-06-0718')).not.toBe('gpt-4o');
});
it('should return "gpt-4o-2024-08-06" for model type of "gpt-4o-2024-08-06"', () => {
expect(getValueKey('gpt-4o-2024-08-06-2024-07-18')).toBe('gpt-4o-2024-08-06');
expect(getValueKey('openai/gpt-4o-2024-08-06')).toBe('gpt-4o-2024-08-06');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o-2024-08-06');
expect(getValueKey('gpt-4o-2024-08-06-0718')).not.toBe('gpt-4o');
});
it('should return "claude-3-5-sonnet" for model type of "claude-3-5-sonnet-"', () => {
@@ -160,68 +152,3 @@ describe('getMultiplier', () => {
);
});
});
describe('AWS Bedrock Model Tests', () => {
const awsModels = [
'anthropic.claude-3-haiku-20240307-v1:0',
'anthropic.claude-3-sonnet-20240229-v1:0',
'anthropic.claude-3-opus-20240229-v1:0',
'anthropic.claude-3-5-sonnet-20240620-v1:0',
'anthropic.claude-v2:1',
'anthropic.claude-instant-v1',
'meta.llama2-13b-chat-v1',
'meta.llama2-70b-chat-v1',
'meta.llama3-8b-instruct-v1:0',
'meta.llama3-70b-instruct-v1:0',
'meta.llama3-1-8b-instruct-v1:0',
'meta.llama3-1-70b-instruct-v1:0',
'meta.llama3-1-405b-instruct-v1:0',
'mistral.mistral-7b-instruct-v0:2',
'mistral.mistral-small-2402-v1:0',
'mistral.mixtral-8x7b-instruct-v0:1',
'mistral.mistral-large-2402-v1:0',
'mistral.mistral-large-2407-v1:0',
'cohere.command-text-v14',
'cohere.command-light-text-v14',
'cohere.command-r-v1:0',
'cohere.command-r-plus-v1:0',
'ai21.j2-mid-v1',
'ai21.j2-ultra-v1',
'amazon.titan-text-lite-v1',
'amazon.titan-text-express-v1',
];
it('should return the correct prompt multipliers for all models', () => {
const results = awsModels.map((model) => {
const multiplier = getMultiplier({ valueKey: model, tokenType: 'prompt' });
return multiplier === tokenValues[model].prompt;
});
expect(results.every(Boolean)).toBe(true);
});
it('should return the correct completion multipliers for all models', () => {
const results = awsModels.map((model) => {
const multiplier = getMultiplier({ valueKey: model, tokenType: 'completion' });
return multiplier === tokenValues[model].completion;
});
expect(results.every(Boolean)).toBe(true);
});
it('should return the correct prompt multipliers for all models with Bedrock prefix', () => {
const results = awsModels.map((model) => {
const modelName = `bedrock/${model}`;
const multiplier = getMultiplier({ valueKey: modelName, tokenType: 'prompt' });
return multiplier === tokenValues[model].prompt;
});
expect(results.every(Boolean)).toBe(true);
});
it('should return the correct completion multipliers for all models with Bedrock prefix', () => {
const results = awsModels.map((model) => {
const modelName = `bedrock/${model}`;
const multiplier = getMultiplier({ valueKey: modelName, tokenType: 'completion' });
return multiplier === tokenValues[model].completion;
});
expect(results.every(Boolean)).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "0.7.4",
"version": "0.7.4-rc1",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -45,7 +45,6 @@
"bcryptjs": "^2.4.3",
"cheerio": "^1.0.0-rc.12",
"cohere-ai": "^7.9.1",
"compression": "^1.7.4",
"connect-redis": "^7.1.0",
"cookie": "^0.5.0",
"cors": "^2.8.5",
@@ -73,7 +72,6 @@
"module-alias": "^2.2.3",
"mongoose": "^7.1.1",
"multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7",
"nodejs-gpt": "^1.37.4",
"nodemailer": "^6.9.4",
"ollama": "^0.5.0",

View File

@@ -1,3 +1,4 @@
const crypto = require('crypto');
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const {
@@ -6,7 +7,6 @@ const {
setAuthTokens,
requestPasswordReset,
} = require('~/server/services/AuthService');
const { hashToken } = require('~/server/utils/crypto');
const { Session, getUserById } = require('~/models');
const { logger } = require('~/config');
@@ -74,7 +74,8 @@ const refreshController = async (req, res) => {
}
// Hash the refresh token
const hashedToken = await hashToken(refreshToken);
const hash = crypto.createHash('sha256');
const hashedToken = hash.update(refreshToken).digest('hex');
// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });

View File

@@ -4,7 +4,6 @@ require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const compression = require('compression');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
const { jwtLogin, passportLogin } = require('~/strategies');
@@ -18,9 +17,8 @@ const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
const noIndex = require('./middleware/noIndex');
const routes = require('./routes');
const staticCache = require('./utils/staticCache');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
const { PORT, HOST, ALLOW_SOCIAL_LOGIN } = process.env ?? {};
const port = Number(PORT) || 3080;
const host = HOST || 'localhost';
@@ -45,16 +43,12 @@ const startServer = async () => {
app.use(express.json({ limit: '3mb' }));
app.use(mongoSanitize());
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(staticCache(app.locals.paths.dist));
app.use(staticCache(app.locals.paths.fonts));
app.use(staticCache(app.locals.paths.assets));
app.use(express.static(app.locals.paths.dist));
app.use(express.static(app.locals.paths.fonts));
app.use(express.static(app.locals.paths.assets));
app.set('trust proxy', 1); // trust first proxy
app.use(cors());
if (DISABLE_COMPRESSION !== 'true') {
app.use(compression());
}
if (!ALLOW_SOCIAL_LOGIN) {
console.warn(
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',

View File

@@ -1,7 +1,5 @@
const { Time } = require('librechat-data-provider');
const clearPendingReq = require('~/cache/clearPendingReq');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const clearPendingReq = require('../../cache/clearPendingReq');
const { logViolation, getLogStores } = require('../../cache');
const denyRequest = require('./denyRequest');
const {
@@ -9,6 +7,7 @@ const {
CONCURRENT_MESSAGE_MAX = 1,
CONCURRENT_VIOLATION_SCORE: score,
} = process.env ?? {};
const ttl = 1000 * 60 * 1;
/**
* Middleware to limit concurrent requests for a user.
@@ -39,7 +38,7 @@ const concurrentLimiter = async (req, res, next) => {
const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1);
const type = 'concurrent';
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`;
const key = `${USE_REDIS ? namespace : ''}:${userId}`;
const pendingRequests = +((await cache.get(key)) ?? 0);
if (pendingRequests >= limit) {
@@ -52,7 +51,7 @@ const concurrentLimiter = async (req, res, next) => {
await logViolation(req, res, type, errorMessage, score);
return await denyRequest(req, res, errorMessage);
} else {
await cache.set(key, pendingRequests + 1, Time.ONE_MINUTE);
await cache.set(key, pendingRequests + 1, ttl);
}
// Ensure the requests are removed from the store once the request is done

View File

@@ -14,7 +14,6 @@ const requireJwtAuth = require('./requireJwtAuth');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
const setHeaders = require('./setHeaders');
const validate = require('./validate');
const limiters = require('./limiters');
const uaParser = require('./uaParser');
const checkBan = require('./checkBan');
@@ -23,7 +22,6 @@ const roles = require('./roles');
module.exports = {
...abortMiddleware,
...validate,
...limiters,
...roles,
noIndex,

View File

@@ -1,112 +0,0 @@
const jwt = require('jsonwebtoken');
const validateImageRequest = require('~/server/middleware/validateImageRequest');
describe('validateImageRequest middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
app: { locals: { secureImageLinks: true } },
headers: {},
originalUrl: '',
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
});
afterEach(() => {
jest.clearAllMocks();
});
test('should call next() if secureImageLinks is false', () => {
req.app.locals.secureImageLinks = false;
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 401 if refresh token is not provided', () => {
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
test('should return 403 if refresh token is invalid', () => {
req.headers.cookie = 'refreshToken=invalid-token';
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 if refresh token is expired', () => {
const expiredToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${expiredToken}`;
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should call next() for valid image path', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/example.jpg';
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid image path', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/456/example.jpg';
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
// File traversal tests
test('should prevent file traversal attempts', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
const traversalAttempts = [
'/images/123/../../../etc/passwd',
'/images/123/..%2F..%2F..%2Fetc%2Fpasswd',
'/images/123/image.jpg/../../../etc/passwd',
'/images/123/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd',
];
traversalAttempts.forEach((attempt) => {
req.originalUrl = attempt;
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks();
});
});
test('should handle URL encoded characters in valid paths', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/image%20with%20spaces.jpg';
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -1,73 +0,0 @@
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const { searchConversation } = require('~/models/Conversation');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
/**
* Middleware to validate user's authorization for a conversation.
*
* This middleware checks if a user has the right to access a specific conversation.
* If the user doesn't have access, an error is returned. If the conversation doesn't exist,
* a not found error is returned. If the access is valid, the middleware allows the request to proceed.
* If the `cache` store is not available, the middleware will skip its logic.
*
* @function
* @param {Express.Request} req - Express request object containing user information.
* @param {Express.Response} res - Express response object.
* @param {function} next - Express next middleware function.
* @throws {Error} Throws an error if the user doesn't have access to the conversation.
*/
const validateConvoAccess = async (req, res, next) => {
const namespace = ViolationTypes.CONVO_ACCESS;
const cache = getLogStores(namespace);
const conversationId = req.body.conversationId;
if (!conversationId || conversationId === Constants.NEW_CONVO) {
return next();
}
const userId = req.user?.id ?? req.user?._id ?? '';
const type = ViolationTypes.CONVO_ACCESS;
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}:${conversationId}`;
try {
if (cache) {
const cachedAccess = await cache.get(key);
if (cachedAccess === 'authorized') {
return next();
}
}
const conversation = await searchConversation(conversationId);
if (!conversation) {
return next();
}
if (conversation.user !== userId) {
const errorMessage = {
type,
error: 'User not authorized for this conversation',
};
if (cache) {
await logViolation(req, res, type, errorMessage, score);
}
return await denyRequest(req, res, errorMessage);
}
if (cache) {
await cache.set(key, 'authorized', Time.TEN_MINUTES);
}
next();
} catch (error) {
console.error('Error validating conversation access:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
module.exports = validateConvoAccess;

View File

@@ -1,4 +0,0 @@
const validateConvoAccess = require('./convoAccess');
module.exports = {
validateConvoAccess,
};

View File

@@ -12,10 +12,9 @@ const {
uaParser,
checkBan,
requireJwtAuth,
messageIpLimiter,
concurrentLimiter,
messageIpLimiter,
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@@ -38,8 +37,6 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use(validateConvoAccess);
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
router.use(`/${EModelEndpoint.chatGPTBrowser}`, askChatGPTBrowser);
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);

View File

@@ -42,7 +42,7 @@ router.post('/:assistant_id', async (req, res) => {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(_metadata);
let metadata = encryptMetadata(_metadata);
let { domain } = metadata;
domain = await domainParser(req, domain, true);

View File

@@ -8,7 +8,6 @@ const {
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
const validateAssistant = require('~/server/middleware/assistants/validate');
const chatController = require('~/server/controllers/assistants/chatV1');
@@ -22,14 +21,6 @@ router.post('/abort', handleAbort());
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post(
'/',
validateModel,
buildEndpointOption,
validateAssistant,
validateConvoAccess,
setHeaders,
chatController,
);
router.post('/', validateModel, buildEndpointOption, validateAssistant, setHeaders, chatController);
module.exports = router;

View File

@@ -8,7 +8,6 @@ const {
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
const validateAssistant = require('~/server/middleware/assistants/validate');
const chatController = require('~/server/controllers/assistants/chatV2');
@@ -22,14 +21,6 @@ router.post('/abort', handleAbort());
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post(
'/',
validateModel,
buildEndpointOption,
validateAssistant,
validateConvoAccess,
setHeaders,
chatController,
);
router.post('/', validateModel, buildEndpointOption, validateAssistant, setHeaders, chatController);
module.exports = router;

View File

@@ -1,6 +1,13 @@
const express = require('express');
const router = express.Router();
const { uaParser, checkBan, requireJwtAuth } = require('~/server/middleware');
const {
uaParser,
checkBan,
requireJwtAuth,
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
} = require('~/server/middleware');
const v1 = require('./v1');
const chatV1 = require('./chatV1');

View File

@@ -31,12 +31,11 @@ router.get('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid page size' });
}
const isArchived = req.query.isArchived === 'true';
let tags;
if (req.query.tags) {
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
} else {
tags = undefined;
}
const tags = req.query.tags
? Array.isArray(req.query.tags)
? req.query.tags
: [req.query.tags]
: undefined;
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
});
@@ -175,17 +174,8 @@ router.post('/fork', async (req, res) => {
});
router.put('/tags/:conversationId', async (req, res) => {
try {
const conversationTags = await updateTagsForConversation(
req.user.id,
req.params.conversationId,
req.body.tags,
);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);
res.status(500).send('Error updating conversation tags');
}
const tag = await updateTagsForConversation(req.user.id, req.params.conversationId, req.body);
res.status(200).json(tag);
});
module.exports = router;

View File

@@ -13,7 +13,6 @@ const {
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@@ -36,8 +35,6 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use(validateConvoAccess);
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);
router.use(`/${EModelEndpoint.anthropic}`, anthropic);

View File

@@ -1,8 +1,7 @@
const express = require('express');
const staticCache = require('../utils/staticCache');
const paths = require('~/config/paths');
const router = express.Router();
router.use(staticCache(paths.imageOutput));
router.use(express.static(paths.imageOutput));
module.exports = router;

View File

@@ -1,88 +1,44 @@
const express = require('express');
const {
getConversationTags,
updateConversationTag,
createConversationTag,
deleteConversationTag,
rebuildConversationTags,
} = require('~/models/ConversationTag');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router();
router.use(requireJwtAuth);
/**
* GET /
* Retrieves all conversation tags for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.get('/', async (req, res) => {
try {
const tags = await getConversationTags(req.user.id);
if (tags) {
res.status(200).json(tags);
} else {
res.status(404).end();
}
} catch (error) {
console.error('Error getting conversation tags:', error);
res.status(500).json({ error: 'Internal server error' });
const tags = await getConversationTags(req.user.id);
if (tags) {
res.status(200).json(tags);
} else {
res.status(404).end();
}
});
/**
* POST /
* Creates a new conversation tag for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.post('/', async (req, res) => {
try {
const tag = await createConversationTag(req.user.id, req.body);
res.status(200).json(tag);
} catch (error) {
console.error('Error creating conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
const tag = await createConversationTag(req.user.id, req.body);
res.status(200).json(tag);
});
router.post('/rebuild', async (req, res) => {
const tag = await rebuildConversationTags(req.user.id);
res.status(200).json(tag);
});
/**
* PUT /:tag
* Updates an existing conversation tag for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/:tag', async (req, res) => {
try {
const tag = await updateConversationTag(req.user.id, req.params.tag, req.body);
if (tag) {
res.status(200).json(tag);
} else {
res.status(404).json({ error: 'Tag not found' });
}
} catch (error) {
console.error('Error updating conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
const tag = await updateConversationTag(req.user.id, req.params.tag, req.body);
res.status(200).json(tag);
});
/**
* DELETE /:tag
* Deletes a conversation tag for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.delete('/:tag', async (req, res) => {
try {
const tag = await deleteConversationTag(req.user.id, req.params.tag);
if (tag) {
res.status(200).json(tag);
} else {
res.status(404).json({ error: 'Tag not found' });
}
} catch (error) {
console.error('Error deleting conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
const tag = await deleteConversationTag(req.user.id, req.params.tag);
res.status(200).json(tag);
});
module.exports = router;

View File

@@ -116,8 +116,8 @@ async function loadActionSets(searchParams) {
* @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call.
* @returns { { _call: (toolInput: Object) => unknown} } An object with `_call` method to execute the tool input.
*/
async function createActionTool({ action, requestBuilder }) {
action.metadata = await decryptMetadata(action.metadata);
function createActionTool({ action, requestBuilder }) {
action.metadata = decryptMetadata(action.metadata);
const _call = async (toolInput) => {
try {
requestBuilder.setParams(toolInput);
@@ -153,23 +153,23 @@ async function createActionTool({ action, requestBuilder }) {
* @param {ActionMetadata} metadata - The action metadata to encrypt.
* @returns {ActionMetadata} The updated action metadata with encrypted values.
*/
async function encryptMetadata(metadata) {
function encryptMetadata(metadata) {
const encryptedMetadata = { ...metadata };
// ServiceHttp
if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
if (metadata.api_key) {
encryptedMetadata.api_key = await encryptV2(metadata.api_key);
encryptedMetadata.api_key = encryptV2(metadata.api_key);
}
}
// OAuth
else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
if (metadata.oauth_client_id) {
encryptedMetadata.oauth_client_id = await encryptV2(metadata.oauth_client_id);
encryptedMetadata.oauth_client_id = encryptV2(metadata.oauth_client_id);
}
if (metadata.oauth_client_secret) {
encryptedMetadata.oauth_client_secret = await encryptV2(metadata.oauth_client_secret);
encryptedMetadata.oauth_client_secret = encryptV2(metadata.oauth_client_secret);
}
}
@@ -182,23 +182,23 @@ async function encryptMetadata(metadata) {
* @param {ActionMetadata} metadata - The action metadata to decrypt.
* @returns {ActionMetadata} The updated action metadata with decrypted values.
*/
async function decryptMetadata(metadata) {
function decryptMetadata(metadata) {
const decryptedMetadata = { ...metadata };
// ServiceHttp
if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
if (metadata.api_key) {
decryptedMetadata.api_key = await decryptV2(metadata.api_key);
decryptedMetadata.api_key = decryptV2(metadata.api_key);
}
}
// OAuth
else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
if (metadata.oauth_client_id) {
decryptedMetadata.oauth_client_id = await decryptV2(metadata.oauth_client_id);
decryptedMetadata.oauth_client_id = decryptV2(metadata.oauth_client_id);
}
if (metadata.oauth_client_secret) {
decryptedMetadata.oauth_client_secret = await decryptV2(metadata.oauth_client_secret);
decryptedMetadata.oauth_client_secret = decryptV2(metadata.oauth_client_secret);
}
}

View File

@@ -1,5 +1,5 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { webcrypto } = require('node:crypto');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
@@ -12,7 +12,6 @@ const {
} = require('~/models/userMethods');
const { sendEmail, checkEmailConfig } = require('~/server/utils');
const { registerSchema } = require('~/strategies/validators');
const { hashToken } = require('~/server/utils/crypto');
const isDomainAllowed = require('./isDomainAllowed');
const Token = require('~/models/schema/tokenSchema');
const Session = require('~/models/Session');
@@ -35,7 +34,7 @@ const genericVerificationMessage = 'Please check your email to verify your email
*/
const logoutUser = async (userId, refreshToken) => {
try {
const hash = await hashToken(refreshToken);
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
// Find the session with the matching user and refreshTokenHash
const session = await Session.findOne({ user: userId, refreshTokenHash: hash });
@@ -54,23 +53,14 @@ const logoutUser = async (userId, refreshToken) => {
}
};
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = () => {
const token = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user) => {
const [verifyToken, hash] = createTokenHash();
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
const verificationLink = `${
domains.client
@@ -236,7 +226,8 @@ const requestPasswordReset = async (req) => {
await token.deleteOne();
}
const [resetToken, hash] = createTokenHash();
let resetToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(resetToken, 10);
await new Token({
userId: user._id,
@@ -374,7 +365,8 @@ const resendVerificationEmail = async (req) => {
return { status: 200, message: genericVerificationMessage };
}
const [verifyToken, hash] = createTokenHash();
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
const verificationLink = `${
domains.client

View File

@@ -15,7 +15,7 @@ const getLdapConfig = () => {
if (ldapLoginUsesUsername) {
ldap.username = true;
}
return ldap;
};

View File

@@ -1,248 +0,0 @@
const { Readable } = require('stream');
const axios = require('axios');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { genAzureEndpoint } = require('~/utils');
const { logger } = require('~/config');
/**
* Service class for handling Speech-to-Text (STT) operations.
* @class
*/
class STTService {
/**
* Creates an instance of STTService.
* @param {Object} customConfig - The custom configuration object.
*/
constructor(customConfig) {
this.customConfig = customConfig;
this.providerStrategies = {
[STTProviders.OPENAI]: this.openAIProvider,
[STTProviders.AZURE_OPENAI]: this.azureOpenAIProvider,
};
}
/**
* Creates a singleton instance of STTService.
* @static
* @async
* @returns {Promise<STTService>} The STTService instance.
* @throws {Error} If the custom config is not found.
*/
static async getInstance() {
const customConfig = await getCustomConfig();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new STTService(customConfig);
}
/**
* Retrieves the configured STT provider and its schema.
* @returns {Promise<[string, Object]>} A promise that resolves to an array containing the provider name and its schema.
* @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set.
*/
async getProviderSchema() {
const sttSchema = this.customConfig.speech.stt;
if (!sttSchema) {
throw new Error(
'No STT schema is set. Did you configure STT in the custom config (librechat.yaml)?',
);
}
const providers = Object.entries(sttSchema).filter(
([, value]) => Object.keys(value).length > 0,
);
if (providers.length !== 1) {
throw new Error(
providers.length > 1
? 'Multiple providers are set. Please set only one provider.'
: 'No provider is set. Please set a provider.',
);
}
const [provider, schema] = providers[0];
return [provider, schema];
}
/**
* Recursively removes undefined properties from an object.
* @param {Object} obj - The object to clean.
* @returns {void}
*/
removeUndefined(obj) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
this.removeUndefined(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
} else if (obj[key] === undefined) {
delete obj[key];
}
});
}
/**
* Prepares the request for the OpenAI STT provider.
* @param {Object} sttSchema - The STT schema for OpenAI.
* @param {Stream} audioReadStream - The audio data to be transcribed.
* @returns {Array} An array containing the URL, data, and headers for the request.
*/
openAIProvider(sttSchema, audioReadStream) {
const url = sttSchema?.url || 'https://api.openai.com/v1/audio/transcriptions';
const apiKey = extractEnvVariable(sttSchema.apiKey) || '';
const data = {
file: audioReadStream,
model: sttSchema.model,
};
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
};
[headers].forEach(this.removeUndefined);
return [url, data, headers];
}
/**
* Prepares the request for the Azure OpenAI STT provider.
* @param {Object} sttSchema - The STT schema for Azure OpenAI.
* @param {Buffer} audioBuffer - The audio data to be transcribed.
* @param {Object} audioFile - The audio file object containing originalname, mimetype, and size.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the audio file size exceeds 25MB or the audio file format is not accepted.
*/
azureOpenAIProvider(sttSchema, audioBuffer, audioFile) {
const url = `${genAzureEndpoint({
azureOpenAIApiInstanceName: sttSchema?.instanceName,
azureOpenAIApiDeploymentName: sttSchema?.deploymentName,
})}/audio/transcriptions?api-version=${sttSchema?.apiVersion}`;
const apiKey = sttSchema.apiKey ? extractEnvVariable(sttSchema.apiKey) : '';
if (audioBuffer.byteLength > 25 * 1024 * 1024) {
throw new Error('The audio file size exceeds the limit of 25MB');
}
const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm'];
const fileFormat = audioFile.mimetype.split('/')[1];
if (!acceptedFormats.includes(fileFormat)) {
throw new Error(`The audio file format ${fileFormat} is not accepted`);
}
const formData = new FormData();
const audioBlob = new Blob([audioBuffer], { type: audioFile.mimetype });
formData.append('file', audioBlob, audioFile.originalname);
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { 'api-key': apiKey }),
};
[headers].forEach(this.removeUndefined);
return [url, formData, headers];
}
/**
* Sends an STT request to the specified provider.
* @async
* @param {string} provider - The STT provider to use.
* @param {Object} sttSchema - The STT schema for the provider.
* @param {Object} requestData - The data required for the STT request.
* @param {Buffer} requestData.audioBuffer - The audio data to be transcribed.
* @param {Object} requestData.audioFile - The audio file object containing originalname, mimetype, and size.
* @returns {Promise<string>} A promise that resolves to the transcribed text.
* @throws {Error} If the provider is invalid, the response status is not 200, or the response data is missing.
*/
async sttRequest(provider, sttSchema, { audioBuffer, audioFile }) {
const strategy = this.providerStrategies[provider];
if (!strategy) {
throw new Error('Invalid provider');
}
const audioReadStream = Readable.from(audioBuffer);
audioReadStream.path = 'audio.wav';
const [url, data, headers] = strategy.call(this, sttSchema, audioReadStream, audioFile);
if (!Readable.from && data instanceof FormData) {
const audioBlob = new Blob([audioBuffer], { type: audioFile.mimetype });
data.set('file', audioBlob, audioFile.originalname);
}
try {
const response = await axios.post(url, data, { headers });
if (response.status !== 200) {
throw new Error('Invalid response from the STT API');
}
if (!response.data || !response.data.text) {
throw new Error('Missing data in response from the STT API');
}
return response.data.text.trim();
} catch (error) {
logger.error(`STT request failed for provider ${provider}:`, error);
throw error;
}
}
/**
* Processes a speech-to-text request.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async processTextToSpeech(req, res) {
if (!req.file || !req.file.buffer) {
return res.status(400).json({ message: 'No audio file provided in the FormData' });
}
const audioBuffer = req.file.buffer;
const audioFile = {
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
};
try {
const [provider, sttSchema] = await this.getProviderSchema();
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
res.json({ text });
} catch (error) {
logger.error('An error occurred while processing the audio:', error);
res.sendStatus(500);
}
}
}
/**
* Factory function to create an STTService instance.
* @async
* @returns {Promise<STTService>} A promise that resolves to an STTService instance.
*/
async function createSTTService() {
return STTService.getInstance();
}
/**
* Wrapper function for speech-to-text processing.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async function speechToText(req, res) {
const sttService = await createSTTService();
await sttService.processTextToSpeech(req, res);
}
module.exports = { speechToText };

View File

@@ -1,477 +0,0 @@
const axios = require('axios');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { logger } = require('~/config');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { genAzureEndpoint } = require('~/utils');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
/**
* Service class for handling Text-to-Speech (TTS) operations.
* @class
*/
class TTSService {
/**
* Creates an instance of TTSService.
* @param {Object} customConfig - The custom configuration object.
*/
constructor(customConfig) {
this.customConfig = customConfig;
this.providerStrategies = {
[TTSProviders.OPENAI]: this.openAIProvider.bind(this),
[TTSProviders.AZURE_OPENAI]: this.azureOpenAIProvider.bind(this),
[TTSProviders.ELEVENLABS]: this.elevenLabsProvider.bind(this),
[TTSProviders.LOCALAI]: this.localAIProvider.bind(this),
};
}
/**
* Creates a singleton instance of TTSService.
* @static
* @async
* @returns {Promise<TTSService>} The TTSService instance.
* @throws {Error} If the custom config is not found.
*/
static async getInstance() {
const customConfig = await getCustomConfig();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new TTSService(customConfig);
}
/**
* Retrieves the configured TTS provider.
* @returns {string} The name of the configured provider.
* @throws {Error} If no provider is set or multiple providers are set.
*/
getProvider() {
const ttsSchema = this.customConfig.speech.tts;
if (!ttsSchema) {
throw new Error(
'No TTS schema is set. Did you configure TTS in the custom config (librechat.yaml)?',
);
}
const providers = Object.entries(ttsSchema).filter(
([, value]) => Object.keys(value).length > 0,
);
if (providers.length !== 1) {
throw new Error(
providers.length > 1
? 'Multiple providers are set. Please set only one provider.'
: 'No provider is set. Please set a provider.',
);
}
return providers[0][0];
}
/**
* Selects a voice for TTS based on provider schema and request.
* @async
* @param {Object} providerSchema - The schema for the selected provider.
* @param {string} requestVoice - The requested voice.
* @returns {Promise<string>} The selected voice.
*/
async getVoice(providerSchema, requestVoice) {
const voices = providerSchema.voices.filter((voice) => voice && voice.toUpperCase() !== 'ALL');
let voice = requestVoice;
if (!voice || !voices.includes(voice) || (voice.toUpperCase() === 'ALL' && voices.length > 1)) {
voice = getRandomVoiceId(voices);
}
return voice;
}
/**
* Recursively removes undefined properties from an object.
* @param {Object} obj - The object to clean.
*/
removeUndefined(obj) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
this.removeUndefined(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
} else if (obj[key] === undefined) {
delete obj[key];
}
});
}
/**
* Prepares the request for OpenAI TTS provider.
* @param {Object} ttsSchema - The TTS schema for OpenAI.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
openAIProvider(ttsSchema, input, voice) {
const url = ttsSchema?.url || 'https://api.openai.com/v1/audio/speech';
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
input,
model: ttsSchema?.model,
voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
backend: ttsSchema?.backend,
};
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${extractEnvVariable(ttsSchema?.apiKey)}`,
};
return [url, data, headers];
}
/**
* Prepares the request for Azure OpenAI TTS provider.
* @param {Object} ttsSchema - The TTS schema for Azure OpenAI.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
azureOpenAIProvider(ttsSchema, input, voice) {
const url = `${genAzureEndpoint({
azureOpenAIApiInstanceName: ttsSchema?.instanceName,
azureOpenAIApiDeploymentName: ttsSchema?.deploymentName,
})}/audio/speech?api-version=${ttsSchema?.apiVersion}`;
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
model: ttsSchema?.model,
input,
voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
};
const headers = {
'Content-Type': 'application/json',
'api-key': ttsSchema.apiKey ? extractEnvVariable(ttsSchema.apiKey) : '',
};
return [url, data, headers];
}
/**
* Prepares the request for ElevenLabs TTS provider.
* @param {Object} ttsSchema - The TTS schema for ElevenLabs.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @param {boolean} stream - Whether to use streaming.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
elevenLabsProvider(ttsSchema, input, voice, stream) {
let url =
ttsSchema?.url ||
`https://api.elevenlabs.io/v1/text-to-speech/${voice}${stream ? '/stream' : ''}`;
if (!ttsSchema?.voices.includes(voice) && !ttsSchema?.voices.includes('ALL')) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
model_id: ttsSchema?.model,
text: input,
voice_settings: {
similarity_boost: ttsSchema?.voice_settings?.similarity_boost,
stability: ttsSchema?.voice_settings?.stability,
style: ttsSchema?.voice_settings?.style,
use_speaker_boost: ttsSchema?.voice_settings?.use_speaker_boost,
},
pronunciation_dictionary_locators: ttsSchema?.pronunciation_dictionary_locators,
};
const headers = {
'Content-Type': 'application/json',
'xi-api-key': extractEnvVariable(ttsSchema?.apiKey),
Accept: 'audio/mpeg',
};
return [url, data, headers];
}
/**
* Prepares the request for LocalAI TTS provider.
* @param {Object} ttsSchema - The TTS schema for LocalAI.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
localAIProvider(ttsSchema, input, voice) {
const url = ttsSchema?.url;
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
input,
model: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
backend: ttsSchema?.backend,
};
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${extractEnvVariable(ttsSchema?.apiKey)}`,
};
if (extractEnvVariable(ttsSchema.apiKey) === '') {
delete headers.Authorization;
}
return [url, data, headers];
}
/**
* Sends a TTS request to the specified provider.
* @async
* @param {string} provider - The TTS provider to use.
* @param {Object} ttsSchema - The TTS schema for the provider.
* @param {Object} options - The options for the TTS request.
* @param {string} options.input - The input text.
* @param {string} options.voice - The voice to use.
* @param {boolean} [options.stream=true] - Whether to use streaming.
* @returns {Promise<Object>} The axios response object.
* @throws {Error} If the provider is invalid or the request fails.
*/
async ttsRequest(provider, ttsSchema, { input, voice, stream = true }) {
const strategy = this.providerStrategies[provider];
if (!strategy) {
throw new Error('Invalid provider');
}
const [url, data, headers] = strategy.call(this, ttsSchema, input, voice, stream);
[data, headers].forEach(this.removeUndefined.bind(this));
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
try {
return await axios.post(url, data, options);
} catch (error) {
logger.error(`TTS request failed for provider ${provider}:`, error);
throw error;
}
}
/**
* Processes a text-to-speech request.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async processTextToSpeech(req, res) {
const { input, voice: requestVoice } = req.body;
if (!input) {
return res.status(400).send('Missing text in request body');
}
try {
res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider();
const ttsSchema = this.customConfig.speech.tts[provider];
const voice = await this.getVoice(ttsSchema, requestVoice);
if (input.length < 4096) {
const response = await this.ttsRequest(provider, ttsSchema, { input, voice });
response.data.pipe(res);
return;
}
const textChunks = splitTextIntoChunks(input, 1000);
for (const chunk of textChunks) {
try {
const response = await this.ttsRequest(provider, ttsSchema, {
voice,
input: chunk.text,
stream: true,
});
logger.debug(`[textToSpeech] user: ${req?.user?.id} | writing audio stream`);
await new Promise((resolve) => {
response.data.pipe(res, { end: chunk.isFinished });
response.data.on('end', resolve);
});
if (chunk.isFinished) {
break;
}
} catch (innerError) {
logger.error('Error processing manual update:', chunk, innerError);
if (!res.headersSent) {
return res.status(500).end();
}
return;
}
}
if (!res.headersSent) {
res.end();
}
} catch (error) {
logger.error('Error creating the audio stream:', error);
if (!res.headersSent) {
return res.status(500).send('An error occurred');
}
}
}
/**
* Streams audio data from the TTS provider.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async streamAudio(req, res) {
res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider();
const ttsSchema = this.customConfig.speech.tts[provider];
const voice = await this.getVoice(ttsSchema, req.body.voice);
let shouldContinue = true;
req.on('close', () => {
logger.warn('[streamAudio] Audio Stream Request closed by client');
shouldContinue = false;
});
const processChunks = createChunkProcessor(req.body.messageId);
try {
while (shouldContinue) {
const updates = await processChunks();
if (typeof updates === 'string') {
logger.error(`Error processing audio stream updates: ${updates}`);
return res.status(500).end();
}
if (updates.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1250));
continue;
}
for (const update of updates) {
try {
const response = await this.ttsRequest(provider, ttsSchema, {
voice,
input: update.text,
stream: true,
});
if (!shouldContinue) {
break;
}
logger.debug(`[streamAudio] user: ${req?.user?.id} | writing audio stream`);
await new Promise((resolve) => {
response.data.pipe(res, { end: update.isFinished });
response.data.on('end', resolve);
});
if (update.isFinished) {
shouldContinue = false;
break;
}
} catch (innerError) {
logger.error('Error processing audio stream update:', update, innerError);
if (!res.headersSent) {
return res.status(500).end();
}
return;
}
}
if (!shouldContinue) {
break;
}
}
if (!res.headersSent) {
res.end();
}
} catch (error) {
logger.error('Failed to fetch audio:', error);
if (!res.headersSent) {
res.status(500).end();
}
}
}
}
/**
* Factory function to create a TTSService instance.
* @async
* @returns {Promise<TTSService>} A promise that resolves to a TTSService instance.
*/
async function createTTSService() {
return TTSService.getInstance();
}
/**
* Wrapper function for text-to-speech processing.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async function textToSpeech(req, res) {
const ttsService = await createTTSService();
await ttsService.processTextToSpeech(req, res);
}
/**
* Wrapper function for audio streaming.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async function streamAudio(req, res) {
const ttsService = await createTTSService();
await ttsService.streamAudio(req, res);
}
/**
* Wrapper function to get the configured TTS provider.
* @async
* @returns {Promise<string>} A promise that resolves to the name of the configured provider.
*/
async function getProvider() {
const ttsService = await createTTSService();
return ttsService.getProvider();
}
module.exports = {
textToSpeech,
streamAudio,
getProvider,
};

View File

@@ -1,6 +1,6 @@
const { TTSProviders } = require('librechat-data-provider');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { getProvider } = require('./TTSService');
const { getProvider } = require('./textToSpeech');
/**
* This function retrieves the available voices for the current TTS provider
@@ -21,7 +21,7 @@ async function getVoices(req, res) {
}
const ttsSchema = customConfig?.speech?.tts;
const provider = await getProvider(ttsSchema);
const provider = getProvider(ttsSchema);
let voices;
switch (provider) {

View File

@@ -1,11 +1,11 @@
const getVoices = require('./getVoices');
const getCustomConfigSpeech = require('./getCustomConfigSpeech');
const TTSService = require('./TTSService');
const STTService = require('./STTService');
const textToSpeech = require('./textToSpeech');
const speechToText = require('./speechToText');
module.exports = {
getVoices,
getCustomConfigSpeech,
...STTService,
...TTSService,
speechToText,
...textToSpeech,
};

View File

@@ -0,0 +1,225 @@
const { Readable } = require('stream');
const axios = require('axios');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { genAzureEndpoint } = require('~/utils');
const { logger } = require('~/config');
/**
* Handle the response from the STT API
* @param {Object} response - The response from the STT API
*
* @returns {string} The text from the response data
*
* @throws Will throw an error if the response status is not 200 or the response data is missing
*/
async function handleResponse(response) {
if (response.status !== 200) {
throw new Error('Invalid response from the STT API');
}
if (!response.data || !response.data.text) {
throw new Error('Missing data in response from the STT API');
}
return response.data.text.trim();
}
/**
* getProviderSchema function
* This function takes the customConfig object and returns the name of the provider and its schema
* If more than one provider is set or no provider is set, it throws an error
*
* @param {Object} customConfig - The custom configuration containing the STT schema
* @returns {Promise<[string, Object]>} The name of the provider and its schema
* @throws {Error} Throws an error if multiple providers are set or no provider is set
*/
async function getProviderSchema(customConfig) {
const sttSchema = customConfig.speech.stt;
if (!sttSchema) {
throw new Error(`No STT schema is set. Did you configure STT in the custom config (librechat.yaml)?
https://www.librechat.ai/docs/configuration/stt_tts#stt`);
}
const providers = Object.entries(sttSchema).filter(([, value]) => Object.keys(value).length > 0);
if (providers.length > 1) {
throw new Error('Multiple providers are set. Please set only one provider.');
} else if (providers.length === 0) {
throw new Error('No provider is set. Please set a provider.');
} else {
const provider = providers[0][0];
return [provider, sttSchema[provider]];
}
}
function removeUndefined(obj) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
removeUndefined(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
} else if (obj[key] === undefined) {
delete obj[key];
}
});
}
/**
* This function prepares the necessary data and headers for making a request to the OpenAI API
* It uses the provided speech-to-text schema and audio stream to create the request
*
* @param {Object} sttSchema - The speech-to-text schema containing the OpenAI configuration
* @param {Stream} audioReadStream - The audio data to be transcribed
*
* @returns {Array} An array containing the URL for the API request, the data to be sent, and the headers for the request
* If an error occurs, it returns an array with three null values and logs the error with logger
*/
function openAIProvider(sttSchema, audioReadStream) {
try {
const url = sttSchema.openai?.url || 'https://api.openai.com/v1/audio/transcriptions';
const apiKey = sttSchema.openai.apiKey ? extractEnvVariable(sttSchema.openai.apiKey) : '';
let data = {
file: audioReadStream,
model: sttSchema.openai.model,
};
let headers = {
'Content-Type': 'multipart/form-data',
};
[headers].forEach(removeUndefined);
if (apiKey) {
headers.Authorization = 'Bearer ' + apiKey;
}
return [url, data, headers];
} catch (error) {
logger.error('An error occurred while preparing the OpenAI API STT request: ', error);
return [null, null, null];
}
}
/**
* Prepares the necessary data and headers for making a request to the Azure API.
* It uses the provided Speech-to-Text (STT) schema and audio file to create the request.
*
* @param {Object} sttSchema - The STT schema object, which should contain instanceName, deploymentName, apiVersion, and apiKey.
* @param {Buffer} audioBuffer - The audio data to be transcribed
* @param {Object} audioFile - The audio file object, which should contain originalname, mimetype, and size.
*
* @returns {Array} An array containing the URL for the API request, the data to be sent, and the headers for the request.
* If an error occurs, it logs the error with logger and returns an array with three null values.
*/
function azureOpenAIProvider(sttSchema, audioBuffer, audioFile) {
try {
const instanceName = sttSchema?.instanceName;
const deploymentName = sttSchema?.deploymentName;
const apiVersion = sttSchema?.apiVersion;
const url =
genAzureEndpoint({
azureOpenAIApiInstanceName: instanceName,
azureOpenAIApiDeploymentName: deploymentName,
}) +
'/audio/transcriptions?api-version=' +
apiVersion;
const apiKey = sttSchema.apiKey ? extractEnvVariable(sttSchema.apiKey) : '';
if (audioBuffer.byteLength > 25 * 1024 * 1024) {
throw new Error('The audio file size exceeds the limit of 25MB');
}
const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm'];
const fileFormat = audioFile.mimetype.split('/')[1];
if (!acceptedFormats.includes(fileFormat)) {
throw new Error(`The audio file format ${fileFormat} is not accepted`);
}
const formData = new FormData();
const audioBlob = new Blob([audioBuffer], { type: audioFile.mimetype });
formData.append('file', audioBlob, audioFile.originalname);
let data = formData;
let headers = {
'Content-Type': 'multipart/form-data',
};
[headers].forEach(removeUndefined);
if (apiKey) {
headers['api-key'] = apiKey;
}
return [url, data, headers];
} catch (error) {
logger.error('An error occurred while preparing the Azure OpenAI API STT request: ', error);
throw error;
}
}
/**
* Convert speech to text
* @param {Object} req - The request object
* @param {Object} res - The response object
*
* @returns {Object} The response object with the text from the STT API
*
* @throws Will throw an error if an error occurs while processing the audio
*/
async function speechToText(req, res) {
const customConfig = await getCustomConfig();
if (!customConfig) {
return res.status(500).send('Custom config not found');
}
if (!req.file || !req.file.buffer) {
return res.status(400).json({ message: 'No audio file provided in the FormData' });
}
const audioBuffer = req.file.buffer;
const audioReadStream = Readable.from(audioBuffer);
audioReadStream.path = 'audio.wav';
const [provider, sttSchema] = await getProviderSchema(customConfig);
let [url, data, headers] = [];
switch (provider) {
case STTProviders.OPENAI:
[url, data, headers] = openAIProvider(sttSchema, audioReadStream);
break;
case STTProviders.AZURE_OPENAI:
[url, data, headers] = azureOpenAIProvider(sttSchema, audioBuffer, req.file);
break;
default:
throw new Error('Invalid provider');
}
if (!Readable.from) {
const audioBlob = new Blob([audioBuffer], { type: req.file.mimetype });
delete data['file'];
data['file'] = audioBlob;
}
try {
const response = await axios.post(url, data, { headers: headers });
const text = await handleResponse(response);
res.json({ text });
} catch (error) {
logger.error('An error occurred while processing the audio:', error);
res.sendStatus(500);
}
}
module.exports = speechToText;

View File

@@ -0,0 +1,473 @@
const axios = require('axios');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { logger } = require('~/config');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { genAzureEndpoint } = require('~/utils');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
/**
* getProvider function
* This function takes the ttsSchema object and returns the name of the provider
* If more than one provider is set or no provider is set, it throws an error
*
* @param {Object} ttsSchema - The TTS schema containing the provider configuration
* @returns {string} The name of the provider
* @throws {Error} Throws an error if multiple providers are set or no provider is set
*/
function getProvider(ttsSchema) {
if (!ttsSchema) {
throw new Error(`No TTS schema is set. Did you configure TTS in the custom config (librechat.yaml)?
https://www.librechat.ai/docs/configuration/stt_tts#tts`);
}
const providers = Object.entries(ttsSchema).filter(([, value]) => Object.keys(value).length > 0);
if (providers.length > 1) {
throw new Error('Multiple providers are set. Please set only one provider.');
} else if (providers.length === 0) {
throw new Error('No provider is set. Please set a provider.');
} else {
return providers[0][0];
}
}
/**
* removeUndefined function
* This function takes an object and removes all keys with undefined values
* It also removes keys with empty objects as values
*
* @param {Object} obj - The object to be cleaned
* @returns {void} This function does not return a value. It modifies the input object directly
*/
function removeUndefined(obj) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
removeUndefined(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
} else if (obj[key] === undefined) {
delete obj[key];
}
});
}
/**
* This function prepares the necessary data and headers for making a request to the OpenAI TTS
* It uses the provided TTS schema, input text, and voice to create the request
*
* @param {TCustomConfig['tts']['openai']} ttsSchema - The TTS schema containing the OpenAI configuration
* @param {string} input - The text to be converted to speech
* @param {string} voice - The voice to be used for the speech
*
* @returns {Array} An array containing the URL for the API request, the data to be sent, and the headers for the request
* If an error occurs, it throws an error with a message indicating that the selected voice is not available
*/
function openAIProvider(ttsSchema, input, voice) {
const url = ttsSchema?.url || 'https://api.openai.com/v1/audio/speech';
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
let data = {
input,
model: ttsSchema?.model,
voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
backend: ttsSchema?.backend,
};
let headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + extractEnvVariable(ttsSchema?.apiKey),
};
[data, headers].forEach(removeUndefined);
return [url, data, headers];
}
/**
* Generates the necessary parameters for making a request to Azure's OpenAI Text-to-Speech API.
*
* @param {TCustomConfig['tts']['azureOpenAI']} ttsSchema - The TTS schema containing the AzureOpenAI configuration
* @param {string} input - The text to be converted to speech
* @param {string} voice - The voice to be used for the speech
*
* @returns {Array} An array containing the URL for the API request, the data to be sent, and the headers for the request
* If an error occurs, it throws an error with a message indicating that the selected voice is not available
*/
function azureOpenAIProvider(ttsSchema, input, voice) {
const instanceName = ttsSchema?.instanceName;
const deploymentName = ttsSchema?.deploymentName;
const apiVersion = ttsSchema?.apiVersion;
const url =
genAzureEndpoint({
azureOpenAIApiInstanceName: instanceName,
azureOpenAIApiDeploymentName: deploymentName,
}) +
'/audio/speech?api-version=' +
apiVersion;
const apiKey = ttsSchema.apiKey ? extractEnvVariable(ttsSchema.apiKey) : '';
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
let data = {
model: ttsSchema?.model,
input,
voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
};
let headers = {
'Content-Type': 'application/json',
};
[data, headers].forEach(removeUndefined);
if (apiKey) {
headers['api-key'] = apiKey;
}
return [url, data, headers];
}
/**
* elevenLabsProvider function
* This function prepares the necessary data and headers for making a request to the Eleven Labs TTS
* It uses the provided TTS schema, input text, and voice to create the request
*
* @param {TCustomConfig['tts']['elevenLabs']} ttsSchema - The TTS schema containing the Eleven Labs configuration
* @param {string} input - The text to be converted to speech
* @param {string} voice - The voice to be used for the speech
* @param {boolean} stream - Whether to stream the audio or not
*
* @returns {Array} An array containing the URL for the API request, the data to be sent, and the headers for the request
* @throws {Error} Throws an error if the selected voice is not available
*/
function elevenLabsProvider(ttsSchema, input, voice, stream) {
let url =
ttsSchema?.url ||
`https://api.elevenlabs.io/v1/text-to-speech/{voice_id}${stream ? '/stream' : ''}`;
if (!ttsSchema?.voices.includes(voice) && !ttsSchema?.voices.includes('ALL')) {
throw new Error(`Voice ${voice} is not available.`);
}
url = url.replace('{voice_id}', voice);
let data = {
model_id: ttsSchema?.model,
text: input,
// voice_id: voice,
voice_settings: {
similarity_boost: ttsSchema?.voice_settings?.similarity_boost,
stability: ttsSchema?.voice_settings?.stability,
style: ttsSchema?.voice_settings?.style,
use_speaker_boost: ttsSchema?.voice_settings?.use_speaker_boost || undefined,
},
pronunciation_dictionary_locators: ttsSchema?.pronunciation_dictionary_locators,
};
let headers = {
'Content-Type': 'application/json',
'xi-api-key': extractEnvVariable(ttsSchema?.apiKey),
Accept: 'audio/mpeg',
};
[data, headers].forEach(removeUndefined);
return [url, data, headers];
}
/**
* localAIProvider function
* This function prepares the necessary data and headers for making a request to the LocalAI TTS
* It uses the provided TTS schema, input text, and voice to create the request
*
* @param {TCustomConfig['tts']['localai']} ttsSchema - The TTS schema containing the LocalAI configuration
* @param {string} input - The text to be converted to speech
* @param {string} voice - The voice to be used for the speech
*
* @returns {Array} An array containing the URL for the API request, the data to be sent, and the headers for the request
* @throws {Error} Throws an error if the selected voice is not available
*/
function localAIProvider(ttsSchema, input, voice) {
let url = ttsSchema?.url;
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
let data = {
input,
model: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
backend: ttsSchema?.backend,
};
let headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + extractEnvVariable(ttsSchema?.apiKey),
};
[data, headers].forEach(removeUndefined);
if (extractEnvVariable(ttsSchema.apiKey) === '') {
delete headers.Authorization;
}
return [url, data, headers];
}
/**
*
* Returns provider and its schema for use with TTS requests
* @param {TCustomConfig} customConfig
* @param {string} _voice
* @returns {Promise<[string, TProviderSchema]>}
*/
async function getProviderSchema(customConfig) {
const provider = getProvider(customConfig.speech.tts);
return [provider, customConfig.speech.tts[provider]];
}
/**
*
* Returns a tuple of the TTS schema as well as the voice for the TTS request
* @param {TProviderSchema} providerSchema
* @param {string} requestVoice
* @returns {Promise<string>}
*/
async function getVoice(providerSchema, requestVoice) {
const voices = providerSchema.voices.filter((voice) => voice && voice.toUpperCase() !== 'ALL');
let voice = requestVoice;
if (!voice || !voices.includes(voice) || (voice.toUpperCase() === 'ALL' && voices.length > 1)) {
voice = getRandomVoiceId(voices);
}
return voice;
}
/**
*
* @param {string} provider
* @param {TProviderSchema} ttsSchema
* @param {object} params
* @param {string} params.voice
* @param {string} params.input
* @param {boolean} [params.stream]
* @returns {Promise<ArrayBuffer>}
*/
async function ttsRequest(provider, ttsSchema, { input, voice, stream = true } = { stream: true }) {
let [url, data, headers] = [];
switch (provider) {
case TTSProviders.OPENAI:
[url, data, headers] = openAIProvider(ttsSchema, input, voice);
break;
case TTSProviders.AZURE_OPENAI:
[url, data, headers] = azureOpenAIProvider(ttsSchema, input, voice);
break;
case TTSProviders.ELEVENLABS:
[url, data, headers] = elevenLabsProvider(ttsSchema, input, voice, stream);
break;
case TTSProviders.LOCALAI:
[url, data, headers] = localAIProvider(ttsSchema, input, voice);
break;
default:
throw new Error('Invalid provider');
}
if (stream) {
return await axios.post(url, data, { headers, responseType: 'stream' });
}
return await axios.post(url, data, { headers, responseType: 'arraybuffer' });
}
/**
* Handles a text-to-speech request. Extracts input and voice from the request, retrieves the TTS configuration,
* and sends a request to the appropriate provider. The resulting audio data is sent in the response
*
* @param {Object} req - The request object, which should contain the input text and voice in its body
* @param {Object} res - The response object, used to send the audio data or an error message
*
* @returns {Promise<void>} This function does not return a value. It sends the audio data or an error message in the response
*
* @throws {Error} Throws an error if the provider is invalid
*/
async function textToSpeech(req, res) {
const { input } = req.body;
if (!input) {
return res.status(400).send('Missing text in request body');
}
const customConfig = await getCustomConfig();
if (!customConfig) {
res.status(500).send('Custom config not found');
}
try {
res.setHeader('Content-Type', 'audio/mpeg');
const [provider, ttsSchema] = await getProviderSchema(customConfig);
const voice = await getVoice(ttsSchema, req.body.voice);
if (input.length < 4096) {
const response = await ttsRequest(provider, ttsSchema, { input, voice });
response.data.pipe(res);
return;
}
const textChunks = splitTextIntoChunks(input, 1000);
for (const chunk of textChunks) {
try {
const response = await ttsRequest(provider, ttsSchema, {
voice,
input: chunk.text,
stream: true,
});
logger.debug(`[textToSpeech] user: ${req?.user?.id} | writing audio stream`);
await new Promise((resolve) => {
response.data.pipe(res, { end: chunk.isFinished });
response.data.on('end', () => {
resolve();
});
});
if (chunk.isFinished) {
break;
}
} catch (innerError) {
logger.error('Error processing manual update:', chunk, innerError);
if (!res.headersSent) {
res.status(500).end();
}
return;
}
}
if (!res.headersSent) {
res.end();
}
} catch (error) {
logger.error(
'Error creating the audio stream. Suggestion: check your provider quota. Error:',
error,
);
res.status(500).send('An error occurred');
}
}
async function streamAudio(req, res) {
res.setHeader('Content-Type', 'audio/mpeg');
const customConfig = await getCustomConfig();
if (!customConfig) {
return res.status(500).send('Custom config not found');
}
const [provider, ttsSchema] = await getProviderSchema(customConfig);
const voice = await getVoice(ttsSchema, req.body.voice);
try {
let shouldContinue = true;
req.on('close', () => {
logger.warn('[streamAudio] Audio Stream Request closed by client');
shouldContinue = false;
});
const processChunks = createChunkProcessor(req.body.messageId);
while (shouldContinue) {
// example updates
// const updates = [
// { text: 'This is a test.', isFinished: false },
// { text: 'This is only a test.', isFinished: false },
// { text: 'Your voice is like a combination of Fergie and Jesus!', isFinished: true },
// ];
const updates = await processChunks();
if (typeof updates === 'string') {
logger.error(`Error processing audio stream updates: ${JSON.stringify(updates)}`);
res.status(500).end();
return;
}
if (updates.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1250));
continue;
}
for (const update of updates) {
try {
const response = await ttsRequest(provider, ttsSchema, {
voice,
input: update.text,
stream: true,
});
if (!shouldContinue) {
break;
}
logger.debug(`[streamAudio] user: ${req?.user?.id} | writing audio stream`);
await new Promise((resolve) => {
response.data.pipe(res, { end: update.isFinished });
response.data.on('end', () => {
resolve();
});
});
if (update.isFinished) {
shouldContinue = false;
break;
}
} catch (innerError) {
logger.error('Error processing audio stream update:', update, innerError);
if (!res.headersSent) {
res.status(500).end();
}
return;
}
}
if (!shouldContinue) {
break;
}
}
if (!res.headersSent) {
res.end();
}
} catch (error) {
logger.error('Failed to fetch audio:', error);
if (!res.headersSent) {
res.status(500).end();
}
}
}
module.exports = {
textToSpeech,
getProvider,
streamAudio,
};

View File

@@ -29,7 +29,7 @@ const getUserPluginAuthValue = async (userId, authField) => {
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
}
const decryptedValue = await decrypt(pluginAuth.value);
const decryptedValue = decrypt(pluginAuth.value);
return decryptedValue;
} catch (err) {
logger.error('[getUserPluginAuthValue]', err);
@@ -64,7 +64,7 @@ const getUserPluginAuthValue = async (userId, authField) => {
const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
try {
const encryptedValue = await encrypt(value);
const encryptedValue = encrypt(value);
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
if (pluginAuth) {
const pluginAuth = await PluginAuth.updateOne(

View File

@@ -11,6 +11,7 @@ const { recordMessage, getMessages } = require('~/models/Message');
const { saveConvo } = require('~/models/Conversation');
const spendTokens = require('~/models/spendTokens');
const { countTokens } = require('~/server/utils');
const { logger } = require('~/config');
/**
* Initializes a new thread or adds messages to an existing thread.
@@ -515,34 +516,80 @@ const recordUsage = async ({
);
};
const uniqueCitationStart = '^====||===';
const uniqueCitationEnd = '==|||||^';
/** Helper function to escape special characters in regex
* @param {string} string - The string to escape.
* @returns {string} The escaped string.
/**
* Creates a replaceAnnotation function with internal state for tracking the index offset.
*
* @returns {function} The replaceAnnotation function with closure for index offset.
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function createReplaceAnnotation() {
let indexOffset = 0;
/**
* Safely replaces the annotated text within the specified range denoted by start_index and end_index,
* after verifying that the text within that range matches the given annotation text.
* Proceeds with the replacement even if a mismatch is found, but logs a warning.
*
* @param {object} params The original text content.
* @param {string} params.currentText The current text content, with/without replacements.
* @param {number} params.start_index The starting index where replacement should begin.
* @param {number} params.end_index The ending index where replacement should end.
* @param {string} params.expectedText The text expected to be found in the specified range.
* @param {string} params.replacementText The text to insert in place of the existing content.
* @returns {string} The text with the replacement applied, regardless of text match.
*/
function replaceAnnotation({
currentText,
start_index,
end_index,
expectedText,
replacementText,
}) {
const adjustedStartIndex = start_index + indexOffset;
const adjustedEndIndex = end_index + indexOffset;
if (
adjustedStartIndex < 0 ||
adjustedEndIndex > currentText.length ||
adjustedStartIndex > adjustedEndIndex
) {
logger.warn(`Invalid range specified for annotation replacement.
Attempting replacement with \`replace\` method instead...
length: ${currentText.length}
start_index: ${adjustedStartIndex}
end_index: ${adjustedEndIndex}`);
return currentText.replace(expectedText, replacementText);
}
if (currentText.substring(adjustedStartIndex, adjustedEndIndex) !== expectedText) {
return currentText.replace(expectedText, replacementText);
}
indexOffset += replacementText.length - (adjustedEndIndex - adjustedStartIndex);
return (
currentText.slice(0, adjustedStartIndex) +
replacementText +
currentText.slice(adjustedEndIndex)
);
}
return replaceAnnotation;
}
/**
* Sorts, processes, and flattens messages to a single string.
*
* @param {object} params - The parameters for processing messages.
* @param {object} params - The OpenAI client instance.
* @param {OpenAIClient} params.openai - The OpenAI client instance.
* @param {RunClient} params.client - The LibreChat client that manages the run: either refers to `OpenAI` or `StreamRunManager`.
* @param {ThreadMessage[]} params.messages - An array of messages.
* @returns {Promise<{messages: ThreadMessage[], text: string, edited: boolean}>} The sorted messages, the flattened text, and whether it was edited.
* @returns {Promise<{messages: ThreadMessage[], text: string}>} The sorted messages and the flattened text.
*/
async function processMessages({ openai, client, messages = [] }) {
const sorted = messages.sort((a, b) => a.created_at - b.created_at);
let text = '';
let edited = false;
const sources = new Map();
const fileRetrievalPromises = [];
const sources = [];
for (const message of sorted) {
message.files = [];
for (const content of message.content) {
@@ -551,21 +598,15 @@ async function processMessages({ openai, client, messages = [] }) {
const currentFileId = contentType?.file_id;
if (type === ContentTypes.IMAGE_FILE && !client.processedFileIds.has(currentFileId)) {
fileRetrievalPromises.push(
retrieveAndProcessFile({
openai,
client,
file_id: currentFileId,
basename: `${currentFileId}.png`,
})
.then((file) => {
client.processedFileIds.add(currentFileId);
message.files.push(file);
})
.catch((error) => {
console.error(`Failed to retrieve file: ${error.message}`);
}),
);
const file = await retrieveAndProcessFile({
openai,
client,
file_id: currentFileId,
basename: `${currentFileId}.png`,
});
client.processedFileIds.add(currentFileId);
message.files.push(file);
continue;
}
@@ -574,110 +615,78 @@ async function processMessages({ openai, client, messages = [] }) {
/** @type {{ annotations: Annotation[] }} */
const { annotations } = contentType ?? {};
// Process annotations if they exist
if (!annotations?.length) {
text += currentText;
text += currentText + ' ';
continue;
}
const replacements = [];
const annotationPromises = annotations.map(async (annotation) => {
const originalText = currentText;
text += originalText;
const replaceAnnotation = createReplaceAnnotation();
logger.debug('[processMessages] Processing annotations:', annotations);
for (const annotation of annotations) {
let file;
const type = annotation.type;
const annotationType = annotation[type];
const file_id = annotationType?.file_id;
const alreadyProcessed = client.processedFileIds.has(file_id);
let file;
let replacementText = '';
const replaceCurrentAnnotation = (replacementText = '') => {
const { start_index, end_index, text: expectedText } = annotation;
currentText = replaceAnnotation({
originalText,
currentText,
start_index,
end_index,
expectedText,
replacementText,
});
edited = true;
};
try {
if (alreadyProcessed) {
file = await retrieveAndProcessFile({ openai, client, file_id, unknownType: true });
} else if (type === AnnotationTypes.FILE_PATH) {
const basename = path.basename(annotation.text);
file = await retrieveAndProcessFile({
openai,
client,
file_id,
basename,
});
replacementText = file.filepath;
} else if (type === AnnotationTypes.FILE_CITATION && file_id) {
file = await retrieveAndProcessFile({
openai,
client,
file_id,
unknownType: true,
});
if (file && file.filename) {
if (!sources.has(file.filename)) {
sources.set(file.filename, sources.size + 1);
}
replacementText = `${uniqueCitationStart}${sources.get(
file.filename,
)}${uniqueCitationEnd}`;
}
}
if (file && replacementText) {
replacements.push({
start: annotation.start_index,
end: annotation.end_index,
text: replacementText,
});
edited = true;
if (!alreadyProcessed) {
client.processedFileIds.add(file_id);
message.files.push(file);
}
}
} catch (error) {
console.error(`Failed to process annotation: ${error.message}`);
if (alreadyProcessed) {
const { file_id } = annotationType || {};
file = await retrieveAndProcessFile({ openai, client, file_id, unknownType: true });
} else if (type === AnnotationTypes.FILE_PATH) {
const basename = path.basename(annotation.text);
file = await retrieveAndProcessFile({
openai,
client,
file_id,
basename,
});
replaceCurrentAnnotation(file.filepath);
} else if (type === AnnotationTypes.FILE_CITATION) {
file = await retrieveAndProcessFile({
openai,
client,
file_id,
unknownType: true,
});
sources.push(file.filename);
replaceCurrentAnnotation(`^${sources.length}^`);
}
});
await Promise.all(annotationPromises);
text = currentText;
// Apply replacements in reverse order
replacements.sort((a, b) => b.start - a.start);
for (const { start, end, text: replacementText } of replacements) {
currentText = currentText.slice(0, start) + replacementText + currentText.slice(end);
if (!file) {
continue;
}
client.processedFileIds.add(file_id);
message.files.push(file);
}
text += currentText;
}
}
await Promise.all(fileRetrievalPromises);
// Handle adjacent identical citations with the unique format
const adjacentCitationRegex = new RegExp(
`${escapeRegExp(uniqueCitationStart)}(\\d+)${escapeRegExp(
uniqueCitationEnd,
)}(\\s*)${escapeRegExp(uniqueCitationStart)}(\\d+)${escapeRegExp(uniqueCitationEnd)}`,
'g',
);
text = text.replace(adjacentCitationRegex, (match, num1, space, num2) => {
return num1 === num2
? `${uniqueCitationStart}${num1}${uniqueCitationEnd}`
: `${uniqueCitationStart}${num1}${uniqueCitationEnd}${space}${uniqueCitationStart}${num2}${uniqueCitationEnd}`;
});
// Remove any remaining adjacent identical citations
const remainingAdjacentRegex = new RegExp(
`(${escapeRegExp(uniqueCitationStart)}(\\d+)${escapeRegExp(uniqueCitationEnd)})\\s*\\1+`,
'g',
);
text = text.replace(remainingAdjacentRegex, '$1');
// Replace the unique citation format with the final format
text = text.replace(new RegExp(escapeRegExp(uniqueCitationStart), 'g'), '^');
text = text.replace(new RegExp(escapeRegExp(uniqueCitationEnd), 'g'), '^');
if (sources.size) {
if (sources.length) {
text += '\n\n';
Array.from(sources.entries()).forEach(([source, index], arrayIndex) => {
text += `^${index}.^ ${source}${arrayIndex === sources.size - 1 ? '' : '\n'}`;
});
for (let i = 0; i < sources.length; i++) {
text += `^${i + 1}.^ ${sources[i]}${i === sources.length - 1 ? '' : '\n'}`;
}
}
return { messages: sorted, text, edited };

View File

@@ -1,983 +0,0 @@
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processMessages } = require('./manage');
jest.mock('~/server/services/Files/process', () => ({
retrieveAndProcessFile: jest.fn(),
}));
describe('processMessages', () => {
let openai, client;
beforeEach(() => {
openai = {};
client = {
processedFileIds: new Set(),
};
jest.clearAllMocks();
retrieveAndProcessFile.mockReset();
});
test('handles normal case with single source', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This is a test ^1^ and another^1^',
annotations: [
{
type: 'file_citation',
start_index: 15,
end_index: 18,
file_citation: { file_id: 'file1' },
},
{
type: 'file_citation',
start_index: 30,
end_index: 33,
file_citation: { file_id: 'file1' },
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockResolvedValue({ filename: 'test.txt' });
const result = await processMessages({ openai, client, messages });
expect(result.text).toBe('This is a test ^1^ and another^1^\n\n^1.^ test.txt');
expect(result.edited).toBe(true);
});
test('handles multiple different sources', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This is a test ^1^ and another^2^',
annotations: [
{
type: 'file_citation',
start_index: 15,
end_index: 18,
file_citation: { file_id: 'file1' },
},
{
type: 'file_citation',
start_index: 30,
end_index: 33,
file_citation: { file_id: 'file2' },
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile
.mockResolvedValueOnce({ filename: 'test1.txt' })
.mockResolvedValueOnce({ filename: 'test2.txt' });
const result = await processMessages({ openai, client, messages });
expect(result.text).toBe('This is a test ^1^ and another^2^\n\n^1.^ test1.txt\n^2.^ test2.txt');
expect(result.edited).toBe(true);
});
test('handles file retrieval failure', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This is a test ^1^',
annotations: [
{
type: 'file_citation',
start_index: 15,
end_index: 18,
file_citation: { file_id: 'file1' },
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockRejectedValue(new Error('File not found'));
const result = await processMessages({ openai, client, messages });
expect(result.text).toBe('This is a test ^1^');
expect(result.edited).toBe(false);
});
test('handles citations without file ids', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This is a test ^1^',
annotations: [{ type: 'file_citation', start_index: 15, end_index: 18 }],
},
},
],
created_at: 1,
},
];
const result = await processMessages({ openai, client, messages });
expect(result.text).toBe('This is a test ^1^');
expect(result.edited).toBe(false);
});
test('handles mixed valid and invalid citations', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This is a test ^1^ and ^2^ and ^3^',
annotations: [
{
type: 'file_citation',
start_index: 15,
end_index: 18,
file_citation: { file_id: 'file1' },
},
{ type: 'file_citation', start_index: 23, end_index: 26 },
{
type: 'file_citation',
start_index: 31,
end_index: 34,
file_citation: { file_id: 'file3' },
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile
.mockResolvedValueOnce({ filename: 'test1.txt' })
.mockResolvedValueOnce({ filename: 'test3.txt' });
const result = await processMessages({ openai, client, messages });
expect(result.text).toBe(
'This is a test ^1^ and ^2^ and ^2^\n\n^1.^ test1.txt\n^2.^ test3.txt',
);
expect(result.edited).toBe(true);
});
test('handles adjacent identical citations', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This is a test ^1^^1^ and ^1^ ^1^',
annotations: [
{
type: 'file_citation',
start_index: 15,
end_index: 18,
file_citation: { file_id: 'file1' },
},
{
type: 'file_citation',
start_index: 18,
end_index: 21,
file_citation: { file_id: 'file1' },
},
{
type: 'file_citation',
start_index: 26,
end_index: 29,
file_citation: { file_id: 'file1' },
},
{
type: 'file_citation',
start_index: 30,
end_index: 33,
file_citation: { file_id: 'file1' },
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockResolvedValue({ filename: 'test.txt' });
const result = await processMessages({ openai, client, messages });
expect(result.text).toBe('This is a test ^1^ and ^1^\n\n^1.^ test.txt');
expect(result.edited).toBe(true);
});
test('handles real data with multiple adjacent citations', async () => {
const messages = [
{
id: 'msg_XXXXXXXXXXXXXXXXXXXX',
object: 'thread.message',
created_at: 1722980324,
assistant_id: 'asst_XXXXXXXXXXXXXXXXXXXX',
thread_id: 'thread_XXXXXXXXXXXXXXXXXXXX',
run_id: 'run_XXXXXXXXXXXXXXXXXXXX',
status: 'completed',
incomplete_details: null,
incomplete_at: null,
completed_at: 1722980331,
role: 'assistant',
content: [
{
type: 'text',
text: {
value:
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
annotations: [
{
type: 'file_citation',
text: '【11:2†source】',
start_index: 420,
end_index: 433,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:4†source】',
start_index: 433,
end_index: 446,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:9†source】',
start_index: 578,
end_index: 591,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:14†source】',
start_index: 591,
end_index: 605,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:12†source】',
start_index: 767,
end_index: 781,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:18†source】',
start_index: 781,
end_index: 795,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:16†source】',
start_index: 935,
end_index: 949,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:1†source】',
start_index: 1114,
end_index: 1127,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:10†source】',
start_index: 1127,
end_index: 1141,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:7†source】',
start_index: 1141,
end_index: 1154,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
],
},
},
],
attachments: [],
metadata: {},
files: [
{
object: 'file',
id: 'file-XXXXXXXXXXXXXXXXXXXX',
purpose: 'assistants',
filename: 'hp1.txt',
bytes: 439742,
created_at: 1722962139,
status: 'processed',
status_details: null,
type: 'text/plain',
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-XXXXXXXXXXXXXXXXXXXX/hp1.txt',
usage: 1,
user: 'XXXXXXXXXXXXXXXXXXXX',
context: 'assistants',
source: 'openai',
model: 'gpt-4o',
},
],
},
];
retrieveAndProcessFile.mockResolvedValue({ filename: 'hp1.txt' });
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText = `The text you have uploaded is from the book "Harry Potter and the Philosopher's Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:
1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry^1^.
2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's^1^.
3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background^1^.
4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy^1^.
5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort^1^.
These points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.
^1.^ hp1.txt`;
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
test('handles real data with multiple adjacent citations with multiple sources', async () => {
const messages = [
{
id: 'msg_XXXXXXXXXXXXXXXXXXXX',
object: 'thread.message',
created_at: 1722980324,
assistant_id: 'asst_XXXXXXXXXXXXXXXXXXXX',
thread_id: 'thread_XXXXXXXXXXXXXXXXXXXX',
run_id: 'run_XXXXXXXXXXXXXXXXXXXX',
status: 'completed',
incomplete_details: null,
incomplete_at: null,
completed_at: 1722980331,
role: 'assistant',
content: [
{
type: 'text',
text: {
value:
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
annotations: [
{
type: 'file_citation',
text: '【11:2†source】',
start_index: 420,
end_index: 433,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:4†source】',
start_index: 433,
end_index: 446,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:9†source】',
start_index: 578,
end_index: 591,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:14†source】',
start_index: 591,
end_index: 605,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:12†source】',
start_index: 767,
end_index: 781,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:18†source】',
start_index: 781,
end_index: 795,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:16†source】',
start_index: 935,
end_index: 949,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:1†source】',
start_index: 1114,
end_index: 1127,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:10†source】',
start_index: 1127,
end_index: 1141,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_citation',
text: '【11:7†source】',
start_index: 1141,
end_index: 1154,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
],
},
},
],
attachments: [],
metadata: {},
files: [
{
object: 'file',
id: 'file-XXXXXXXXXXXXXXXXXXXX',
purpose: 'assistants',
filename: 'hp1.txt',
bytes: 439742,
created_at: 1722962139,
status: 'processed',
status_details: null,
type: 'text/plain',
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-XXXXXXXXXXXXXXXXXXXX/hp1.txt',
usage: 1,
user: 'XXXXXXXXXXXXXXXXXXXX',
context: 'assistants',
source: 'openai',
model: 'gpt-4o',
},
],
},
];
retrieveAndProcessFile.mockResolvedValue({ filename: 'hp1.txt' });
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText = `The text you have uploaded is from the book "Harry Potter and the Philosopher's Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:
1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry^1^.
2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's^1^.
3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background^1^.
4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy^1^.
5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort^1^.
These points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.
^1.^ hp1.txt`;
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
test('handles edge case with pre-existing citation-like text', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value:
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation【11:2†source】.',
annotations: [
{
type: 'file_citation',
text: '【11:2†source】',
start_index: 79,
end_index: 92,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockResolvedValue({ filename: 'test.txt' });
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText =
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation^1^.\n\n^1.^ test.txt';
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
test('handles FILE_PATH annotation type', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'Here is a file path: [file_path]',
annotations: [
{
type: 'file_path',
text: '[file_path]',
start_index: 21,
end_index: 32,
file_path: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockResolvedValue({
filename: 'test.txt',
filepath: '/path/to/test.txt',
});
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText = 'Here is a file path: /path/to/test.txt';
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
test('handles FILE_CITATION annotation type', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'Here is a citation: [citation]',
annotations: [
{
type: 'file_citation',
text: '[citation]',
start_index: 20,
end_index: 30,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockResolvedValue({ filename: 'test.txt' });
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText = 'Here is a citation: ^1^\n\n^1.^ test.txt';
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
test('handles multiple annotation types in a single message', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value:
'File path: [file_path]. Citation: [citation1]. Another citation: [citation2].',
annotations: [
{
type: 'file_path',
text: '[file_path]',
start_index: 11,
end_index: 22,
file_path: {
file_id: 'file-XXXXXXXXXXXXXXXX1',
},
},
{
type: 'file_citation',
text: '[citation1]',
start_index: 34,
end_index: 45,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXX2',
},
},
{
type: 'file_citation',
text: '[citation2]',
start_index: 65,
end_index: 76,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXX3',
},
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockResolvedValueOnce({
filename: 'file1.txt',
filepath: '/path/to/file1.txt',
});
retrieveAndProcessFile.mockResolvedValueOnce({ filename: 'file2.txt' });
retrieveAndProcessFile.mockResolvedValueOnce({ filename: 'file3.txt' });
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText =
'File path: /path/to/file1.txt. Citation: ^1^. Another citation: ^2^.\n\n^1.^ file2.txt\n^2.^ file3.txt';
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
test('handles annotation processing failure', async () => {
const messages = [
{
content: [
{
type: 'text',
text: {
value: 'This citation will fail: [citation]',
annotations: [
{
type: 'file_citation',
text: '[citation]',
start_index: 25,
end_index: 35,
file_citation: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
],
},
},
],
created_at: 1,
},
];
retrieveAndProcessFile.mockRejectedValue(new Error('File not found'));
const result = await processMessages({
openai: {},
client: { processedFileIds: new Set() },
messages,
});
const expectedText = 'This citation will fail: [citation]';
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(false);
});
test('handles multiple FILE_PATH annotations with sandbox links', async () => {
const messages = [
{
id: 'msg_XXXXXXXXXXXXXXXXXXXX',
object: 'thread.message',
created_at: 1722983745,
assistant_id: 'asst_XXXXXXXXXXXXXXXXXXXX',
thread_id: 'thread_XXXXXXXXXXXXXXXXXXXX',
run_id: 'run_XXXXXXXXXXXXXXXXXXXX',
status: 'completed',
incomplete_details: null,
incomplete_at: null,
completed_at: 1722983747,
role: 'assistant',
content: [
{
type: 'text',
text: {
value:
'I have generated three dummy CSV files for you. You can download them using the links below:\n\n1. [Download Dummy Data 1](sandbox:/mnt/data/dummy_data1.csv)\n2. [Download Dummy Data 2](sandbox:/mnt/data/dummy_data2.csv)\n3. [Download Dummy Data 3](sandbox:/mnt/data/dummy_data3.csv)',
annotations: [
{
type: 'file_path',
text: 'sandbox:/mnt/data/dummy_data1.csv',
start_index: 121,
end_index: 154,
file_path: {
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
},
},
{
type: 'file_path',
text: 'sandbox:/mnt/data/dummy_data2.csv',
start_index: 183,
end_index: 216,
file_path: {
file_id: 'file-YYYYYYYYYYYYYYYYYYYY',
},
},
{
type: 'file_path',
text: 'sandbox:/mnt/data/dummy_data3.csv',
start_index: 245,
end_index: 278,
file_path: {
file_id: 'file-ZZZZZZZZZZZZZZZZZZZZ',
},
},
],
},
},
],
attachments: [
{
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
tools: [
{
type: 'code_interpreter',
},
],
},
{
file_id: 'file-YYYYYYYYYYYYYYYYYYYY',
tools: [
{
type: 'code_interpreter',
},
],
},
{
file_id: 'file-ZZZZZZZZZZZZZZZZZZZZ',
tools: [
{
type: 'code_interpreter',
},
],
},
],
metadata: {},
files: [
{
object: 'file',
id: 'file-XXXXXXXXXXXXXXXXXXXX',
purpose: 'assistants_output',
filename: 'dummy_data1.csv',
bytes: 1925,
created_at: 1722983746,
status: 'processed',
status_details: null,
type: 'text/csv',
file_id: 'file-XXXXXXXXXXXXXXXXXXXX',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-XXXXXXXXXXXXXXXXXXXX/dummy_data1.csv',
usage: 1,
user: 'XXXXXXXXXXXXXXXXXXXX',
context: 'assistants_output',
source: 'openai',
model: 'gpt-4o-mini',
},
{
object: 'file',
id: 'file-YYYYYYYYYYYYYYYYYYYY',
purpose: 'assistants_output',
filename: 'dummy_data2.csv',
bytes: 4221,
created_at: 1722983746,
status: 'processed',
status_details: null,
type: 'text/csv',
file_id: 'file-YYYYYYYYYYYYYYYYYYYY',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-YYYYYYYYYYYYYYYYYYYY/dummy_data2.csv',
usage: 1,
user: 'XXXXXXXXXXXXXXXXXXXX',
context: 'assistants_output',
source: 'openai',
model: 'gpt-4o-mini',
},
{
object: 'file',
id: 'file-ZZZZZZZZZZZZZZZZZZZZ',
purpose: 'assistants_output',
filename: 'dummy_data3.csv',
bytes: 3534,
created_at: 1722983747,
status: 'processed',
status_details: null,
type: 'text/csv',
file_id: 'file-ZZZZZZZZZZZZZZZZZZZZ',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-ZZZZZZZZZZZZZZZZZZZZ/dummy_data3.csv',
usage: 1,
user: 'XXXXXXXXXXXXXXXXXXXX',
context: 'assistants_output',
source: 'openai',
model: 'gpt-4o-mini',
},
],
},
];
const mockClient = {
processedFileIds: new Set(),
};
// Mock the retrieveAndProcessFile function for each file
retrieveAndProcessFile.mockImplementation(({ file_id }) => {
const fileMap = {
'file-XXXXXXXXXXXXXXXXXXXX': {
filename: 'dummy_data1.csv',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-XXXXXXXXXXXXXXXXXXXX/dummy_data1.csv',
},
'file-YYYYYYYYYYYYYYYYYYYY': {
filename: 'dummy_data2.csv',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-YYYYYYYYYYYYYYYYYYYY/dummy_data2.csv',
},
'file-ZZZZZZZZZZZZZZZZZZZZ': {
filename: 'dummy_data3.csv',
filepath:
'https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-ZZZZZZZZZZZZZZZZZZZZ/dummy_data3.csv',
},
};
return Promise.resolve(fileMap[file_id]);
});
const result = await processMessages({ openai: {}, client: mockClient, messages });
const expectedText =
'I have generated three dummy CSV files for you. You can download them using the links below:\n\n1. [Download Dummy Data 1](https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-XXXXXXXXXXXXXXXXXXXX/dummy_data1.csv)\n2. [Download Dummy Data 2](https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-YYYYYYYYYYYYYYYYYYYY/dummy_data2.csv)\n3. [Download Dummy Data 3](https://api.openai.com/v1/files/XXXXXXXXXXXXXXXXXXXX/file-ZZZZZZZZZZZZZZZZZZZZ/dummy_data3.csv)';
expect(result.text).toBe(expectedText);
expect(result.edited).toBe(true);
});
});

View File

@@ -335,7 +335,7 @@ async function processRequiredActions(client, requiredActions) {
continue;
}
tool = await createActionTool({ action: actionSet, requestBuilder });
tool = createActionTool({ action: actionSet, requestBuilder });
isActionTool = !!tool;
ActionToolMap[currentAction.tool] = tool;
}

View File

@@ -50,7 +50,7 @@ const getUserKey = async ({ userId, name }) => {
}),
);
}
return await decrypt(keyValue.value);
return decrypt(keyValue.value);
};
/**
@@ -109,7 +109,7 @@ const getUserKeyExpiry = async ({ userId, name }) => {
* after encrypting the provided value. It sets the provided expiry date for the key.
*/
const updateUserKey = async ({ userId, name, value, expiresAt = null }) => {
const encryptedValue = await encrypt(value);
const encryptedValue = encrypt(value);
let updateObject = {
userId,
name,

View File

@@ -1,74 +1,34 @@
require('dotenv').config();
const { webcrypto } = require('node:crypto');
const crypto = require('crypto');
const key = Buffer.from(process.env.CREDS_KEY, 'hex');
const iv = Buffer.from(process.env.CREDS_IV, 'hex');
const algorithm = 'AES-CBC';
const algorithm = 'aes-256-cbc';
async function encrypt(value) {
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'encrypt',
]);
const encoder = new TextEncoder();
const data = encoder.encode(value);
const encryptedBuffer = await webcrypto.subtle.encrypt(
{
name: algorithm,
iv: iv,
},
cryptoKey,
data,
);
return Buffer.from(encryptedBuffer).toString('hex');
function encrypt(value) {
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
}
async function decrypt(encryptedValue) {
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'decrypt',
]);
const encryptedBuffer = Buffer.from(encryptedValue, 'hex');
const decryptedBuffer = await webcrypto.subtle.decrypt(
{
name: algorithm,
iv: iv,
},
cryptoKey,
encryptedBuffer,
);
const decoder = new TextDecoder();
return decoder.decode(decryptedBuffer);
function decrypt(encryptedValue) {
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let decrypted = decipher.update(encryptedValue, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Programmatically generate iv
async function encryptV2(value) {
const gen_iv = webcrypto.getRandomValues(new Uint8Array(16));
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'encrypt',
]);
const encoder = new TextEncoder();
const data = encoder.encode(value);
const encryptedBuffer = await webcrypto.subtle.encrypt(
{
name: algorithm,
iv: gen_iv,
},
cryptoKey,
data,
);
return Buffer.from(gen_iv).toString('hex') + ':' + Buffer.from(encryptedBuffer).toString('hex');
// Programatically generate iv
function encryptV2(value) {
const gen_iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, gen_iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
return gen_iv.toString('hex') + ':' + encrypted;
}
async function decryptV2(encryptedValue) {
function decryptV2(encryptedValue) {
const parts = encryptedValue.split(':');
// Already decrypted from an earlier invocation
if (parts.length === 1) {
@@ -76,30 +36,10 @@ async function decryptV2(encryptedValue) {
}
const gen_iv = Buffer.from(parts.shift(), 'hex');
const encrypted = parts.join(':');
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'decrypt',
]);
const encryptedBuffer = Buffer.from(encrypted, 'hex');
const decryptedBuffer = await webcrypto.subtle.decrypt(
{
name: algorithm,
iv: gen_iv,
},
cryptoKey,
encryptedBuffer,
);
const decoder = new TextDecoder();
return decoder.decode(decryptedBuffer);
const decipher = crypto.createDecipheriv(algorithm, key, gen_iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
async function hashToken(str) {
const data = new TextEncoder().encode(str);
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
return Buffer.from(hashBuffer).toString('hex');
}
module.exports = { encrypt, decrypt, encryptV2, decryptV2, hashToken };
module.exports = { encrypt, decrypt, encryptV2, decryptV2 };

View File

@@ -1,19 +0,0 @@
const express = require('express');
const oneWeekInSeconds = 24 * 60 * 60 * 7;
const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneWeekInSeconds;
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneWeekInSeconds * 4;
const staticCache = (staticPath) =>
express.static(staticPath, {
setHeaders: (res) => {
if (process.env.NODE_ENV.toLowerCase() !== 'production') {
return;
}
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
},
});
module.exports = staticCache;

View File

@@ -5,7 +5,6 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { hashToken } = require('~/server/utils/crypto');
const { logger } = require('~/config');
let crypto;
@@ -185,7 +184,9 @@ async function setupOpenId() {
let fileName;
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
const hash = crypto.createHash('sha256');
hash.update(userinfo.sub);
fileName = hash.digest('hex') + '.png';
} else {
fileName = userinfo.sub + '.png';
}

View File

@@ -11,7 +11,6 @@ const openAIModels = {
'gpt-4-0125': 127990, // -10 from max
'gpt-4o': 127990, // -10 from max
'gpt-4o-mini': 127990, // -10 from max
'gpt-4o-2024-08-06': 127990, // -10 from max
'gpt-4-turbo': 127990, // -10 from max
'gpt-4-vision': 127990, // -10 from max
'gpt-3.5-turbo': 16375, // -10 from max

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en-US">
<html>
<head>
<meta charset="utf-8" />
<meta name="theme-color" content="#171717">

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "0.7.4",
"version": "0.7.4-rc1",
"description": "",
"type": "module",
"scripts": {
@@ -68,7 +68,6 @@
"lodash": "^4.17.21",
"lucide-react": "^0.394.0",
"match-sorter": "^6.3.4",
"msedge-tts": "^1.3.4",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.2",

View File

@@ -26,11 +26,6 @@ import type {
import type { UseMutationResult } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
export enum PromptsEditorMode {
SIMPLE = 'simple',
ADVANCED = 'advanced',
}
export type AudioChunk = {
audio: string;
isFinal: boolean;

View File

@@ -9,9 +9,9 @@ import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { useConversationTagMutation } from '~/data-provider';
import { Checkbox, Label, TextareaAutosize } from '~/components/ui/';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
type TBookmarkFormProps = {
bookmark?: TConversationTag;
@@ -31,11 +31,10 @@ const BookmarkForm = ({
tags,
setTags,
}: TBookmarkFormProps) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { bookmarks } = useBookmarkContext();
const localize = useLocalize();
const mutation = useConversationTagMutation(bookmark?.tag);
const onSuccess = useBookmarkSuccess(conversation?.conversationId || '');
const { bookmarks } = useBookmarkContext();
const {
register,
@@ -83,7 +82,6 @@ const BookmarkForm = ({
(tag) => tag !== undefined,
) as string[];
setTags(newTags);
onSuccess(newTags);
}
},
onError: () => {
@@ -174,9 +172,9 @@ const BookmarkForm = ({
/>
)}
/>
<button
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="addToConversation"
onClick={() =>
setValue('addToConversation', !getValues('addToConversation'), {
shouldDirty: true,
@@ -186,7 +184,7 @@ const BookmarkForm = ({
<div className="flex select-none items-center">
{localize('com_ui_bookmarks_add_to_conversation')}
</div>
</button>
</label>
</div>
)}
</div>

View File

@@ -5,9 +5,8 @@ import { Spinner } from '~/components/svg';
import { cn } from '~/utils';
type MenuItemProps = {
tag: string | React.ReactNode;
tag: string;
selected: boolean;
ctx: 'header' | 'nav';
count?: number;
handleSubmit: (tag: string) => Promise<void>;
icon?: React.ReactNode;
@@ -16,7 +15,6 @@ type MenuItemProps = {
const BookmarkItem: FC<MenuItemProps> = ({
tag,
ctx,
selected,
count,
handleSubmit,
@@ -27,39 +25,16 @@ const BookmarkItem: FC<MenuItemProps> = ({
const [isLoading, setIsLoading] = useState(false);
const clickHandler = async () => {
setIsLoading(true);
await handleSubmit(tag as string);
await handleSubmit(tag);
setIsLoading(false);
};
const breakWordStyle: React.CSSProperties = {
wordBreak: 'break-word',
overflowWrap: 'anywhere',
};
const renderIcon = () => {
if (icon) {
return icon;
}
if (isLoading) {
return <Spinner className="size-4" />;
}
if (selected) {
return <BookmarkFilledIcon className="size-4" />;
}
return <BookmarkIcon className="size-4" />;
};
const ariaLabel =
ctx === 'header' ? `${selected ? 'Remove' : 'Add'} bookmark for ${tag}` : (tag as string);
return (
<button
aria-label={ariaLabel}
<div
role="menuitem"
className={cn(
'group m-1.5 flex w-[225px] cursor-pointer gap-2 rounded bg-transparent px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50',
highlightSelected && selected ? 'bg-surface-secondary' : '',
ctx === 'header' ? 'hover:bg-header-hover' : 'hover:bg-surface-hover',
'group m-1.5 flex cursor-pointer gap-2 rounded px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50',
'hover:bg-black/5 dark:hover:bg-white/5',
highlightSelected && selected && 'bg-black/5 dark:bg-white/5',
)}
tabIndex={-1}
{...rest}
@@ -67,14 +42,25 @@ const BookmarkItem: FC<MenuItemProps> = ({
>
<div className="flex grow items-center justify-between gap-2">
<div className="flex items-center gap-2">
{renderIcon()}
<div style={breakWordStyle}>{tag}</div>
{icon ? (
icon
) : isLoading ? (
<Spinner className="size-4" />
) : selected ? (
<BookmarkFilledIcon className="size-4" />
) : (
<BookmarkIcon className="size-4" />
)}
<div className="break-all">{tag}</div>
</div>
{count !== undefined && (
<div className="flex items-center justify-end">
<span
className="ml-auto w-7 min-w-max whitespace-nowrap rounded-md bg-surface-secondary px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-text-secondary"
className={cn(
'ml-auto w-9 min-w-max whitespace-nowrap rounded-md bg-white px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-gray-600',
'dark:bg-gray-800 dark:text-white',
)}
aria-hidden="true"
>
{count}
@@ -82,8 +68,7 @@ const BookmarkItem: FC<MenuItemProps> = ({
</div>
)}
</div>
</button>
</div>
);
};
export default BookmarkItem;

View File

@@ -1,40 +1,30 @@
import type { FC } from 'react';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import BookmarkItem from './BookmarkItem';
interface BookmarkItemsProps {
ctx: 'header' | 'nav';
const BookmarkItems: FC<{
tags: string[];
handleSubmit: (tag: string) => Promise<void>;
header: React.ReactNode;
highlightSelected?: boolean;
}
const BookmarkItems: FC<BookmarkItemsProps> = ({
ctx,
tags,
handleSubmit,
header,
highlightSelected,
}) => {
}> = ({ tags, handleSubmit, header, highlightSelected }) => {
const { bookmarks } = useBookmarkContext();
return (
<>
{header}
<div className="my-1.5 h-px" role="none" />
{bookmarks.map((bookmark) => (
<BookmarkItem
ctx={ctx}
key={bookmark.tag}
tag={bookmark.tag}
selected={tags.includes(bookmark.tag)}
count={bookmark.count}
handleSubmit={handleSubmit}
highlightSelected={highlightSelected}
/>
))}
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
{bookmarks.length > 0 &&
bookmarks.map((bookmark) => (
<BookmarkItem
key={bookmark.tag}
tag={bookmark.tag}
selected={tags.includes(bookmark.tag)}
count={bookmark.count}
handleSubmit={handleSubmit}
highlightSelected={highlightSelected}
/>
))}
</>
);
};
export default BookmarkItems;

View File

@@ -33,10 +33,9 @@ function AddMultiConvo({ className = '' }: { className?: string }) {
return (
<button
aria-label="Add multi-conversation"
onClick={clickHandler}
className={cn(
'group m-1.5 flex w-fit cursor-pointer items-center rounded text-sm hover:bg-border-medium focus-visible:bg-border-medium focus-visible:outline-offset-2',
'group m-1.5 flex w-fit cursor-pointer items-center rounded text-sm hover:bg-border-medium focus-visible:bg-border-medium focus-visible:outline-0',
className,
)}
>

View File

@@ -41,25 +41,20 @@ function ChatView({ index = 0 }: { index?: number }) {
defaultValues: { text: '' },
});
let content: JSX.Element | null | undefined;
if (isLoading && conversationId !== 'new') {
content = (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
);
} else if (messagesTree && messagesTree.length !== 0) {
content = <MessagesView messagesTree={messagesTree} Header={<Header />} />;
} else {
content = <Landing Header={<Header />} />;
}
return (
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation useSidePanel={true}>
{content}
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />

View File

@@ -78,8 +78,6 @@ export default function AudioRecorder({
<Tooltip>
<TooltipTrigger asChild>
<button
id="audio-recorder"
aria-label={localize('com_ui_use_micrphone')}
onClick={isListening ? handleStopRecording : handleStartRecording}
disabled={disabled}
className={cn(

View File

@@ -38,8 +38,8 @@ const ChatForm = ({ index = 0 }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const SpeechToText = useRecoilState<boolean>(store.speechToText);
const TextToSpeech = useRecoilState<boolean>(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
@@ -106,7 +106,7 @@ const ChatForm = ({ index = 0 }) => {
() =>
isAssistantsEndpoint(conversation?.endpoint) &&
(!conversation?.assistant_id ||
!assistantMap[conversation.endpoint ?? ''][conversation.assistant_id ?? '']),
!assistantMap?.[conversation?.endpoint ?? '']?.[conversation?.assistant_id ?? '']),
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
);
const disableInputs = useMemo(

View File

@@ -45,7 +45,8 @@ const AttachFile = ({
<button
disabled={!!disabled}
type="button"
className="btn relative text-black focus:outline-none focus:ring-2 focus:ring-border-xheavy focus:ring-opacity-50 dark:text-white"
tabIndex={1}
className="btn relative p-0 text-black dark:text-white"
aria-label="Attach files"
style={{ padding: 0 }}
>

View File

@@ -14,14 +14,14 @@ const FileContainer = ({
const fileType = getFileType(file.type);
return (
<div className="group relative inline-block text-sm text-text-primary">
<div className="relative overflow-hidden rounded-xl border border-border-medium">
<div className="w-60 bg-surface-active p-2">
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
<div className="w-60 p-2 dark:bg-gray-600">
<div className="flex flex-row items-center gap-2">
<FilePreview file={file} fileType={fileType} className="relative" />
<div className="overflow-hidden">
<div className="truncate font-medium">{file.filename}</div>
<div className="truncate text-text-secondary">{fileType.title}</div>
<div className="truncate text-gray-300">{fileType.title}</div>
</div>
</div>
</div>

View File

@@ -91,7 +91,6 @@ export default function HeaderOptions({
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
)}
onClick={triggerAdvancedMode}
aria-label="Settings/parameters"
>
<Settings2 className="w-4 text-gray-600 dark:text-white" />
</Button>
@@ -101,7 +100,7 @@ export default function HeaderOptions({
<OptionsPopover
visible={showPopover}
saveAsPreset={saveAsPreset}
presetsDisabled={!interfaceConfig.presets}
presetsDisabled={!interfaceConfig?.presets}
PopoverButtons={<PopoverButtons />}
closePopover={() => setShowPopover(false)}
>

View File

@@ -103,18 +103,15 @@ export default function Mention({
};
}, []);
const type = commandChar !== '@' ? 'add-convo' : 'mention';
useEffect(() => {
const currentActiveItem = document.getElementById(`${type}-item-${activeIndex}`);
const currentActiveItem = document.getElementById(`mention-item-${activeIndex}`);
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [type, activeIndex]);
}, [activeIndex]);
return (
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize(placeholder)}
@@ -158,7 +155,6 @@ export default function Mention({
<div className="max-h-40 overflow-y-auto">
{(matches as MentionOption[]).map((mention, index) => (
<MentionItem
type={type}
index={index}
key={`${mention.value}-${index}`}
onClick={() => {

View File

@@ -9,37 +9,37 @@ export default function MentionItem({
icon,
isActive,
description,
type = 'mention',
}: {
name: string;
onClick: () => void;
index: number;
type?: 'prompt' | 'mention' | 'add-convo';
icon?: React.ReactNode;
isActive?: boolean;
description?: string;
}) {
return (
<button tabIndex={index} onClick={onClick} id={`${type}-item-${index}`} className="w-full">
<div tabIndex={index} onClick={onClick} id={`mention-item-${index}`} className="cursor-pointer">
<div
className={cn(
'text-token-text-primary bg-token-main-surface-secondary group flex h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium hover:bg-surface-secondary',
isActive ? 'bg-surface-active' : 'bg-transparent',
'text-token-text-primary bg-token-main-surface-secondary group flex h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-600',
isActive ? 'bg-gray-100 dark:bg-gray-600' : '',
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
<div className="flex min-w-0 flex-grow items-center justify-between">
<div className="truncate">
<span className="font-medium">{name}</span>
{icon ? icon : null}
<div className="flex h-fit grow flex-row justify-between space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
<div className="flex flex-row space-x-2">
<span className="shrink-0 truncate">{name}</span>
{description ? (
<span className="text-token-text-tertiary ml-2 text-sm font-light">
<span className="text-token-text-tertiary flex-grow truncate text-sm font-light sm:max-w-xs lg:max-w-md">
{description}
</span>
) : null}
</div>
<Clock4 size={16} className="ml-2 flex-shrink-0" />
<span className="shrink-0 self-center">
<Clock4 size={16} className="icon-sm" />
</span>
</div>
</div>
</button>
</div>
);
}

View File

@@ -66,7 +66,7 @@ export default function OptionsPopover({
{presetsDisabled ? null : (
<Button
type="button"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-ring-primary dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />
@@ -77,7 +77,7 @@ export default function OptionsPopover({
<Button
type="button"
className={cn(
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
removeFocusOutlines,
)}
onClick={closePopover}

View File

@@ -60,7 +60,7 @@ function PromptsCommand({
label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${
group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? ''
}`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
icon: <CategoryIcon category={group.category ?? ''} />,
}));
const promptsMap = mapPromptGroups(data);
@@ -108,7 +108,7 @@ function PromptsCommand({
}
const group = promptsMap[mention.id];
const hasVariables = detectVariables(group.productionPrompt?.prompt ?? '');
const hasVariables = detectVariables(group?.productionPrompt?.prompt ?? '');
if (group && hasVariables) {
if (e && e.key === 'Tab') {
e.preventDefault();
@@ -154,8 +154,6 @@ function PromptsCommand({
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}
@@ -206,7 +204,6 @@ function PromptsCommand({
return (matches as PromptOption[]).map((mention, index) => (
<MentionItem
index={index}
type="prompt"
key={`${mention.value}-${index}`}
onClick={() => {
if (timeoutRef.current) {

View File

@@ -22,11 +22,9 @@ const SubmitButton = React.memo(
<TooltipTrigger asChild>
<button
ref={ref}
aria-label={localize('com_nav_send_message')}
id="send-button"
disabled={props.disabled}
className={cn(
'absolute rounded-lg border border-black p-0.5 text-white outline-offset-4 transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white',
'absolute rounded-lg border border-black p-0.5 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white',
props.isRTL
? 'bottom-1.5 left-2 md:bottom-3 md:left-3'
: 'bottom-1.5 right-2 md:bottom-3 md:right-3',
@@ -52,7 +50,7 @@ const SubmitButton = React.memo(
const SendButton = React.memo(
forwardRef((props: SendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const data = useWatch({ control: props.control });
return <SubmitButton ref={ref} disabled={props.disabled || !data.text} isRTL={props.isRTL} />;
return <SubmitButton ref={ref} disabled={props.disabled || !data?.text} isRTL={props.isRTL} />;
}),
);

View File

@@ -31,10 +31,10 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const isAssistant = isAssistantsEndpoint(endpoint);
const assistant = isAssistant ? assistantMap?.[endpoint][assistant_id ?? ''] : undefined;
const assistantName = assistant && assistant.name;
const assistantDesc = assistant && assistant.description;
const avatar = assistant && (assistant.metadata?.avatar as string);
const assistant = isAssistant && assistantMap?.[endpoint]?.[assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const assistantDesc = (assistant && assistant?.description) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
@@ -79,11 +79,11 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
</div> */}
</div>
) : (
<h2 className="mb-5 max-w-[75vh] px-12 text-center text-lg font-medium dark:text-white md:px-0 md:text-2xl">
<div className="mb-5 max-w-[75vh] px-12 text-center text-lg font-medium dark:text-white md:px-0 md:text-2xl">
{isAssistant
? conversation?.greeting ?? localize('com_nav_welcome_assistant')
: conversation?.greeting ?? localize('com_nav_welcome_message')}
</h2>
</div>
)}
</div>
</div>

View File

@@ -1,35 +1,62 @@
import { useState, type FC } from 'react';
import { useEffect, useState, type FC } from 'react';
import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { Spinner } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const SAVED_TAG = 'Saved';
const BookmarkMenu: FC = () => {
const localize = useLocalize();
const location = useLocation();
const conversation = useRecoilValue(store.conversationByIndex(0));
const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const [tags, setTags] = useState<string[]>(conversation?.tags || []);
const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [tags, setTags] = useState<string[]>();
const [open, setIsOpen] = useState(false);
const [conversation, setConversation] = useState<TConversation>();
const { mutateAsync, isLoading } = useTagConversationMutation(conversationId);
let thisConversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
thisConversation = globalConvo;
} else {
thisConversation = activeConvo;
}
const { mutateAsync, isLoading } = useTagConversationMutation(
thisConversation?.conversationId ?? '',
);
const { data } = useConversationTagsQuery();
useEffect(() => {
if (
(!conversation && thisConversation) ||
(conversation &&
thisConversation &&
conversation.conversationId !== thisConversation.conversationId)
) {
setConversation(thisConversation);
setTags(thisConversation.tags ?? []);
}
if (tags === undefined && conversation) {
setTags(conversation.tags ?? []);
}
}, [thisConversation, conversation, tags]);
const isActiveConvo =
conversation &&
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search';
thisConversation &&
thisConversation.conversationId &&
thisConversation.conversationId !== 'new' &&
thisConversation.conversationId !== 'search';
if (!isActiveConvo) {
return <></>;
@@ -43,59 +70,59 @@ const BookmarkMenu: FC = () => {
if (open && tags && tags.length > 0) {
setIsOpen(open);
} else {
if (conversation && conversationId) {
await mutateAsync(
{
tags: [Constants.SAVED_TAG as 'Saved'],
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
},
onError: () => {
console.error('Error adding bookmark');
},
},
);
if (thisConversation && thisConversation.conversationId) {
await mutateAsync({
conversationId: thisConversation.conversationId,
tags: [SAVED_TAG],
});
setTags([SAVED_TAG]);
setConversation({ ...thisConversation, tags: [SAVED_TAG] });
}
}
};
const renderButtonContent = () => {
if (isLoading) {
return <Spinner />;
}
if (tags && tags.length > 0) {
return <BookmarkFilledIcon className="icon-sm" />;
}
return <BookmarkIcon className="icon-sm" />;
};
return (
<Root open={open} onOpenChange={onOpenChange}>
<Trigger asChild>
<button
id="header-bookmarks-menu"
className={cn(
'pointer-cursor relative flex flex-col rounded-md border border-border-light bg-transparent text-left focus:outline-none focus:ring-0 sm:text-sm',
'hover:bg-header-button-hover radix-state-open:bg-header-button-hover',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:outline-offset-2 focus:ring-0 focus-visible:ring-2 focus-visible:ring-ring-primary ',
'pointer-cursor relative flex flex-col rounded-md border border-gray-100 bg-white text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
)}
title={localize('com_ui_bookmarks')}
>
{renderButtonContent()}
{isLoading ? (
<Spinner />
) : tags && tags.length > 0 ? (
<BookmarkFilledIcon className="icon-sm" />
) : (
<BookmarkIcon className="icon-sm" />
)}
</button>
</Trigger>
<Portal>
<Content
className="mt-2 grid max-h-[500px] w-full min-w-[240px] overflow-y-auto rounded-lg border border-border-medium bg-header-primary text-text-primary shadow-lg"
className={cn(
'grid w-full',
'mt-2 min-w-[240px] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white',
'max-h-[500px]',
)}
side="bottom"
align="start"
>
{data && conversation && (
// Display all bookmarks registered by the user and highlight the tags of the currently selected conversation
<BookmarkContext.Provider value={{ bookmarks: data }}>
<BookmarkMenuItems conversation={conversation} tags={tags ?? []} setTags={setTags} />
<BookmarkMenuItems
// Currently selected conversation
conversation={conversation}
setConversation={setConversation}
// Tags in the conversation
tags={tags ?? []}
// Update tags in the conversation
setTags={setTags}
/>
</BookmarkContext.Provider>
)}
</Content>

View File

@@ -1,37 +1,36 @@
import React, { useCallback } from 'react';
import { useCallback } from 'react';
import { BookmarkPlusIcon } from 'lucide-react';
import type { FC } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { BookmarkItems, BookmarkEditDialog } from '~/components/Bookmarks';
import { useTagConversationMutation } from '~/data-provider';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
export const BookmarkMenuItems: FC<{
conversation: TConversation;
tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>;
}> = ({ conversation, tags, setTags }) => {
setTags: (tags: string[]) => void;
setConversation: (conversation: TConversation) => void;
}> = ({ conversation, tags, setTags, setConversation }) => {
const { showToast } = useToastContext();
const localize = useLocalize();
const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const { mutateAsync } = useTagConversationMutation(conversationId);
const { mutateAsync } = useTagConversationMutation(conversation?.conversationId ?? '');
const handleSubmit = useCallback(
async (tag: string): Promise<void> => {
if (tags !== undefined && conversationId) {
if (tags !== undefined && conversation?.conversationId) {
const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag];
await mutateAsync(
{
conversationId: conversation.conversationId,
tags: newTags,
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
setConversation({ ...conversation, tags: newTags });
},
onError: () => {
showToast({
@@ -43,12 +42,11 @@ export const BookmarkMenuItems: FC<{
);
}
},
[tags, conversationId, mutateAsync, setTags, onSuccess, showToast],
[tags, conversation],
);
return (
<BookmarkItems
ctx="header"
tags={tags}
handleSubmit={handleSubmit}
header={
@@ -60,7 +58,7 @@ export const BookmarkMenuItems: FC<{
trigger={
<div
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-header-hover focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
tabIndex={-1}
>
<div className="flex grow items-center justify-between gap-2">

View File

@@ -97,13 +97,10 @@ const MenuItem: FC<MenuItemProps> = ({
<div
role="menuitem"
className={cn(
'group m-1.5 flex max-h-[40px] cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100',
'hover:bg-black/5 dark:hover:bg-gray-600',
'radix-disabled:pointer-events-none radix-disabled:opacity-50',
'focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2',
'dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
'group m-1.5 flex max-h-[40px] cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600',
'focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
)}
tabIndex={0}
tabIndex={1}
{...rest}
onClick={() => onSelectEndpoint(endpoint)}
onKeyDown={(e) => {

View File

@@ -22,15 +22,14 @@ export default function MenuButton({
const localize = useLocalize();
return (
<Trigger asChild>
<button
<div
className={cn(
'group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
className,
)}
type="button"
aria-label={`Select ${primaryText}`}
// type="button"
>
{selected && selected.showIconInHeader === true && (
{selected && selected.showIconInHeader && (
<SpecIcon currentSpec={selected} endpointsConfig={endpointsConfig} />
)}
<div className={textClassName}>
@@ -52,7 +51,7 @@ export default function MenuButton({
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</Trigger>
);
}

View File

@@ -27,14 +27,13 @@ const PresetsMenu: FC = () => {
<Trigger asChild>
<button
className={cn(
'pointer-cursor relative flex flex-col rounded-md border border-gray-100 bg-white text-left focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm',
'pointer-cursor relative flex flex-col rounded-md border border-gray-100 bg-white text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
)}
id="presets-button"
data-testid="presets-button"
title={localize('com_endpoint_examples')}
aria-label={localize('com_endpoint_examples')}
>
<BookCopy className="icon-sm" id="presets-button" />
</button>

View File

@@ -33,28 +33,18 @@ const MenuItem: FC<MenuItemProps> = ({
return (
<div
role="menuitem"
aria-label={`menu item for ${title} ${description}`}
data-testid="chat-menu-item"
className={cn(
'group m-1.5 flex cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 md:min-w-[240px]',
className || '',
className ?? '',
)}
tabIndex={0} // Change to 0 to make it focusable
tabIndex={-1}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (onClick) {
onClick();
}
}
}}
{...rest}
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className={cn('flex items-center gap-1 ')}>
{icon != null ? icon : null}
{icon && icon}
<div className={cn('truncate', textClassName)}>
{title}
<div className="text-token-text-tertiary">{description}</div>

View File

@@ -3,10 +3,9 @@ import { Trigger } from '@radix-ui/react-popover';
export default function TitleButton({ primaryText = '', secondaryText = '' }) {
return (
<Trigger asChild>
<button
<div
className="group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700"
type="button"
aria-label={`Select ${primaryText}`}
// type="button"
>
<div>
{primaryText}{' '}
@@ -27,7 +26,7 @@ export default function TitleButton({ primaryText = '', secondaryText = '' }) {
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</Trigger>
);
}

View File

@@ -54,11 +54,11 @@ export default function CodeAnalyze({
onClick={() => setShowCode((prev) => !prev)}
inProgressText="Analyzing"
finishedText="Finished analyzing"
hasInput={!!code.length}
hasInput={!!code?.length}
/>
</div>
{showCode && (
<div className="code-analyze-block mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
<div className="mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
<MarkdownLite content={code ? `\`\`\`python\n${code}\n\`\`\`` : ''} />
{logs && (
<div className="bg-gray-700 p-4 text-xs">

View File

@@ -67,8 +67,8 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
<Container message={message}>
<div
className={cn(
showCursor && !!text.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words',
showCursor && !!text?.length ? 'result-streaming' : '',
'markdown prose dark:prose-invert light w-full break-words',
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-100',
)}
>

View File

@@ -23,8 +23,8 @@ const DisplayMessage = ({ text, isCreatedByUser = false, message, showCursor }:
return (
<div
className={cn(
showCursor && !!text.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words',
showCursor && !!text?.length ? 'result-streaming' : '',
'markdown prose dark:prose-invert light w-full break-words',
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
)}
>
@@ -58,12 +58,14 @@ export default function Part({
// Access the value property
return (
<Container message={message}>
<DisplayMessage
text={part[ContentTypes.TEXT].value}
isCreatedByUser={message.isCreatedByUser}
message={message}
showCursor={showCursor}
/>
<div className="markdown prose dark:prose-invert light dark:text-gray-70 my-1 w-full break-words">
<DisplayMessage
text={part[ContentTypes.TEXT].value}
isCreatedByUser={message.isCreatedByUser}
message={message}
showCursor={showCursor}
/>
</div>
</Container>
);
} else if (
@@ -105,12 +107,14 @@ export default function Part({
if (isSubmitting && showCursor) {
return (
<Container message={message}>
<DisplayMessage
text={''}
isCreatedByUser={message.isCreatedByUser}
message={message}
showCursor={showCursor}
/>
<div className="markdown prose dark:prose-invert light dark:text-gray-70 my-1 w-full break-words">
<DisplayMessage
text={''}
isCreatedByUser={message.isCreatedByUser}
message={message}
showCursor={showCursor}
/>
</div>
</Container>
);
}

View File

@@ -1,16 +1,13 @@
import * as Popover from '@radix-ui/react-popover';
import { cn } from '~/utils';
const wrapperClass =
'progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5';
const Wrapper = ({ popover, children }: { popover: boolean; children: React.ReactNode }) => {
if (popover) {
return (
<div className={wrapperClass}>
<div className="text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5">
<Popover.Trigger asChild>
<div
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
className="absolute left-0 top-0 line-clamp-1 overflow-visible"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="78"
>
@@ -22,9 +19,9 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
}
return (
<div className={wrapperClass}>
<div className="text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5">
<div
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
className="absolute left-0 top-0 line-clamp-1 overflow-visible"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="78"
>

View File

@@ -72,12 +72,12 @@ export default function HoverButtons({
};
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
{TextToSpeech && <MessageAudio index={index} message={message} isLast={isLast} />}
{isEditableEndpoint && (
<button
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active text-gray-700 dark:text-gray-200' : '',
@@ -93,7 +93,7 @@ export default function HoverButtons({
)}
<button
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
@@ -108,7 +108,7 @@ export default function HoverButtons({
{regenerateEnabled ? (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
'hover-button active rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
@@ -131,7 +131,7 @@ export default function HoverButtons({
{continueSupported ? (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
'hover-button active rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={handleContinue}

View File

@@ -40,7 +40,7 @@ export default function Message(props: TMessageProps) {
<>
<MessageContainer handleScroll={handleScroll}>
{showSibling ? (
<div className="m-auto my-2 flex justify-center p-4 py-2 md:gap-6">
<div className="m-auto my-2 flex justify-center p-4 py-2 text-base md:gap-6">
<div className="flex w-full flex-row flex-wrap justify-between gap-1 md:max-w-5xl md:flex-nowrap md:gap-2 lg:max-w-5xl xl:max-w-6xl">
<MessageRender
{...props}
@@ -58,7 +58,7 @@ export default function Message(props: TMessageProps) {
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
<MessageRender {...props} />
</div>
)}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '~/components/ui';
import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components/svg';
import { useLocalize, useTextToSpeech } from '~/hooks';
import store from '~/store';
@@ -14,32 +15,77 @@ type THoverButtons = {
export default function MessageAudio({ index, message, isLast }: THoverButtons) {
const localize = useLocalize();
const playbackRate = useRecoilValue(store.playbackRate);
const [audioText, setAudioText] = useState<string>(localize('com_ui_info_read_aloud'));
const [tooltipOpen, setTooltipOpen] = useState(false);
const [wasLongPress, setWasLongPress] = useState(false);
const { toggleSpeech, isSpeaking, isLoading, audioRef } = useTextToSpeech(message, isLast, index);
const isMouseDownRef = useRef(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const counterRef = useRef(0);
const renderIcon = (size: string) => {
if (isLoading) {
return <Spinner size={size} />;
}
return isSpeaking ? <VolumeMuteIcon size={size} /> : <VolumeIcon size={size} />;
};
if (isSpeaking) {
return <VolumeMuteIcon size={size} />;
const handleMouseDown = () => {
setWasLongPress(false);
setTooltipOpen(true);
if (isMouseDownRef.current) {
return;
}
isMouseDownRef.current = true;
counterRef.current = 2;
setAudioText(localize('com_ui_hold_mouse_download', counterRef.current.toString()));
timerRef.current = setInterval(() => {
counterRef.current--;
if (counterRef.current >= 0) {
setAudioText(localize('com_ui_hold_mouse_download', counterRef.current.toString()));
}
if (isMouseDownRef.current && counterRef.current === 0) {
setAudioText(localize('com_ui_downloading'));
toggleSpeech(true);
}
if (counterRef.current < 0 && timerRef.current) {
clearInterval(timerRef.current);
}
}, 1000);
window.addEventListener('mouseup', handleMouseUp);
};
const handleMouseUp = () => {
if (counterRef.current > 0) {
toggleSpeech(false);
}
return <VolumeIcon size={size} />;
if (counterRef.current === 0) {
setWasLongPress(true);
}
setTooltipOpen(false);
isMouseDownRef.current = false;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
setAudioText(localize('com_ui_info_read_aloud'));
}
window.removeEventListener('mouseup', handleMouseUp);
};
useEffect(() => {
const messageAudio = document.getElementById(
`audio-${message.messageId}`,
) as HTMLAudioElement | null;
if (!messageAudio) {
return;
}
if (
playbackRate &&
playbackRate > 0 &&
messageAudio &&
playbackRate !== null &&
playbackRate > 0 &&
messageAudio.playbackRate !== playbackRate
) {
messageAudio.playbackRate = playbackRate;
@@ -47,48 +93,60 @@ export default function MessageAudio({ index, message, isLast }: THoverButtons)
}, [audioRef, isSpeaking, playbackRate, message.messageId]);
return (
<>
<button
className="hover-button rounded-md p-1 pl-0 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
// onMouseDownCapture={() => {
// if (audioRef.current) {
// audioRef.current.muted = false;
// }
// handleMouseDown();
// }}
// onMouseUpCapture={() => {
// if (audioRef.current) {
// audioRef.current.muted = false;
// }
// handleMouseUp();
// }}
onClickCapture={() => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
}}
type="button"
title={isSpeaking ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
>
{renderIcon('19')}
</button>
<audio
ref={audioRef}
controls
controlsList="nodownload nofullscreen noremoteplayback"
style={{
position: 'absolute',
overflow: 'hidden',
display: 'none',
height: '0px',
width: '0px',
}}
src={audioRef.current?.src || undefined}
id={`audio-${message.messageId}`}
muted
autoPlay
/>
</>
<TooltipProvider>
<>
<Tooltip open={tooltipOpen}>
<TooltipTrigger asChild>
<button
className="hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
onMouseDownCapture={handleMouseDown}
onMouseUpCapture={handleMouseUp}
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
onClickCapture={() => {
if (!wasLongPress && audioRef.current) {
audioRef.current.muted = false;
toggleSpeech(false);
}
}}
type="button"
>
{renderIcon('19')}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={0}>
<div className="space-y-2">
{isSpeaking ? (
<p className="text-center text-sm text-gray-600 dark:text-gray-300">
{localize('com_ui_stop')}
</p>
) : (
<p className="text-center text-sm text-gray-600 dark:text-gray-300">
{localize('com_ui_read_aloud')}
<br />
{audioText}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
<audio
ref={audioRef}
controls
controlsList="nodownload nofullscreen noremoteplayback"
style={{
position: 'absolute',
overflow: 'hidden',
display: 'none',
height: '0px',
width: '0px',
}}
src={audioRef.current?.src || undefined}
id={`audio-${message.messageId}`}
muted
autoPlay
/>
</>
</TooltipProvider>
);
}

View File

@@ -8,7 +8,7 @@ import Icon from '~/components/Endpoints/Icon';
function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
assistant?: Assistant;
assistant?: false | Assistant;
},
) {
const { data: endpointsConfig } = useGetEndpointsQuery();
@@ -21,19 +21,19 @@ function MessageIcon(
() => ({
...(conversation ?? {}),
...({
...(message ?? {}),
...message,
iconURL: message?.iconURL ?? '',
} as TMessage),
}),
[conversation, message],
);
const iconURL = messageSettings.iconURL;
let endpoint = messageSettings.endpoint;
const iconURL = messageSettings?.iconURL;
let endpoint = messageSettings?.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
if (message?.isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) {
if (!message?.isCreatedByUser && iconURL && iconURL.includes('http')) {
return (
<ConvoIconURL
preset={messageSettings as typeof messageSettings & TPreset}

View File

@@ -1,18 +1,15 @@
import { useRecoilValue } from 'recoil';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { useMessageHelpers, useLocalize } from '~/hooks';
import ContentParts from './Content/ContentParts';
import SiblingSwitch from './SiblingSwitch';
import { useMessageHelpers } from '~/hooks';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message(props: TMessageProps) {
const localize = useLocalize();
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
@@ -31,7 +28,7 @@ export default function Message(props: TMessageProps) {
copyToClipboard,
regenerateMessage,
} = useMessageHelpers(props);
const fontSize = useRecoilValue(store.fontSize);
const { content, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
if (!message) {
@@ -45,8 +42,8 @@ export default function Message(props: TMessageProps) {
onWheel={handleScroll}
onTouchMove={handleScroll}
>
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
<div className="group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
@@ -57,15 +54,10 @@ export default function Message(props: TMessageProps) {
</div>
</div>
<div
className={cn(
'relative flex w-full flex-col',
isCreatedByUser === true ? '' : 'agent-turn',
)}
className={cn('relative flex w-full flex-col', isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>
{isCreatedByUser === true
? localize('com_user_message')
: (assistant && assistant.name) ?? localize('com_ui_assistant')}
<div className="select-none font-semibold">
{isCreatedByUser ? 'You' : (assistant && assistant?.name) ?? 'Assistant'}
</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
@@ -77,7 +69,7 @@ export default function Message(props: TMessageProps) {
message={message}
messageId={messageId}
enterEdit={enterEdit}
error={!!(error ?? false)}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}

View File

@@ -1,13 +1,10 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group';
import type { ReactNode } from 'react';
import type { TMessage } from 'librechat-data-provider';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { useScreenshot, useMessageScrolling } from '~/hooks';
import { CSSTransition } from 'react-transition-group';
import MultiMessage from './MultiMessage';
import { cn } from '~/utils';
import store from '~/store';
export default function MessagesView({
messagesTree: _messagesTree,
@@ -16,7 +13,6 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
Header?: ReactNode;
}) {
const fontSize = useRecoilValue(store.fontSize);
const { screenshotTargetRef } = useScreenshot();
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
@@ -33,24 +29,20 @@ export default function MessagesView({
return (
<div className="flex-1 overflow-hidden overflow-y-auto">
<div className="relative h-full">
<div className="dark:gpt-dark-gray relative h-full">
<div
onScroll={debouncedHandleScroll}
ref={scrollableRef}
tabIndex={0}
style={{
height: '100%',
overflowY: 'auto',
width: '100%',
}}
>
<div className="flex flex-col pb-9 dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(
'flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300',
fontSize,
)}
>
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
{(_messagesTree && _messagesTree?.length == 0) || _messagesTree === null ? (
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
Nothing found
</div>
) : (
@@ -68,8 +60,7 @@ export default function MessagesView({
</>
)}
<div
id="messages-end"
className="group h-0 w-full flex-shrink-0"
className="dark:gpt-dark-gray group h-0 w-full flex-shrink-0 dark:border-gray-800/50"
ref={messagesEndRef}
/>
</div>

View File

@@ -11,7 +11,6 @@ import store from '~/store';
export default function Message({ message }: Pick<TMessageProps, 'message'>) {
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const fontSize = useRecoilValue(store.fontSize);
const { user } = useAuthContext();
const localize = useLocalize();
@@ -19,13 +18,11 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
return null;
}
const { isCreatedByUser } = message;
const { isCreatedByUser } = message ?? {};
let messageLabel = '';
if (isCreatedByUser) {
messageLabel = UsernameDisplay
? (user?.name ?? '') || user?.username
: localize('com_user_message');
messageLabel = UsernameDisplay ? user?.name || user?.username : localize('com_user_message');
} else {
messageLabel = message.sender;
}
@@ -33,8 +30,8 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
return (
<>
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
@@ -47,7 +44,7 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
<div
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<div className="select-none font-semibold">{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
<SearchContent message={message} />

View File

@@ -1,5 +1,5 @@
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo, memo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useMessageActions } from '~/hooks';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
@@ -9,9 +9,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
import { cn } from '~/utils';
type MessageRenderProps = {
message?: TMessage;
@@ -23,7 +21,7 @@ type MessageRenderProps = {
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
const MessageRender = memo(
const MessageRender = React.memo(
({
isCard,
siblingIdx,
@@ -56,7 +54,6 @@ const MessageRender = memo(
setCurrentEditId,
});
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const { isCreatedByUser, error, unfinished } = msg ?? {};
const isLast = useMemo(
@@ -68,40 +65,28 @@ const MessageRender = memo(
return null;
}
const isLatestMessage = msg.messageId === latestMessage?.messageId;
const showCardRender = isLast && !(isSubmittingFamily === true) && isCard === true;
const isLatestCard = isCard === true && !(isSubmittingFamily === true) && isLatestMessage;
const isLatestCard =
isCard && !isSubmittingFamily && msg.messageId === latestMessage?.messageId;
const clickHandler =
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg.messageId} as latest message`);
logger.dir(msg);
setLatestMessage(msg);
}
isLast && isCard && !isSubmittingFamily && msg.messageId !== latestMessage?.messageId
? () => setLatestMessage(msg)
: undefined;
return (
<div
aria-label={`message-${msg.depth}-${msg.messageId}`}
className={cn(
'final-completion group mx-auto flex flex-1 gap-3',
isCard === true
'final-completion group mx-auto flex flex-1 gap-3 text-base',
isCard
? 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4'
: 'md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5',
isLatestCard === true ? 'bg-surface-secondary' : '',
showCardRender ? 'cursor-pointer transition-colors duration-300' : '',
'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
isLatestCard ? 'bg-surface-secondary' : '',
isLast && !isSubmittingFamily && isCard
? 'cursor-pointer transition-colors duration-300'
: '',
)}
onClick={clickHandler}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
clickHandler();
}
}}
role={showCardRender ? 'button' : undefined}
tabIndex={showCardRender ? 0 : undefined}
>
{isLatestCard === true && (
{isLatestCard && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary"></div>
)}
<div className="relative flex flex-shrink-0 flex-col items-end">
@@ -114,23 +99,20 @@ const MessageRender = memo(
</div>
</div>
<div
className={cn(
'relative flex w-11/12 flex-col',
msg.isCreatedByUser === true ? '' : 'agent-turn',
)}
className={cn('relative flex w-11/12 flex-col', msg?.isCreatedByUser ? '' : 'agent-turn')}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<div className="select-none font-semibold">{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
{msg.plugin && <Plugin plugin={msg.plugin} />}
{msg?.plugin && <Plugin plugin={msg?.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
text={msg.text ?? ''}
message={msg}
enterEdit={enterEdit}
error={!!(error ?? false)}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
@@ -139,7 +121,7 @@ const MessageRender = memo(
/>
</div>
</div>
{!msg.children?.length && (isSubmittingFamily === true || isSubmitting) ? (
{!msg?.children?.length && (isSubmittingFamily || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">

View File

@@ -62,24 +62,24 @@ export default function Presentation({
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
return resizableLayout ? JSON.parse(resizableLayout) : undefined;
}, []);
const defaultCollapsed = useMemo(() => {
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
return collapsedPanels ? JSON.parse(collapsedPanels) : undefined;
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
const layout = () => (
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
<div className="flex h-full flex-col" role="presentation">
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
{children}
{isActive && <DragDropOverlay />}
</div>
</div>
);
if (useSidePanel && !hideSidePanel && interfaceConfig.sidePanel === true) {
if (useSidePanel && !hideSidePanel && interfaceConfig.sidePanel) {
return (
<div
ref={drop}
@@ -90,10 +90,10 @@ export default function Presentation({
defaultCollapsed={defaultCollapsed}
fullPanelCollapse={fullCollapse}
>
<main className="flex h-full flex-col" role="main">
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
{children}
{isActive && <DragDropOverlay />}
</main>
</div>
</SidePanel>
</div>
);

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