Merge branch 'main' into re-add-download-audio

This commit is contained in:
Marco Beretta 2024-07-17 18:38:28 +02:00 committed by GitHub
commit 511f1336da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 2193 additions and 1416 deletions

View file

@ -2,8 +2,9 @@ const Anthropic = require('@anthropic-ai/sdk');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const {
getResponseSender,
Constants,
EModelEndpoint,
getResponseSender,
validateVisionModel,
} = require('librechat-data-provider');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
@ -16,6 +17,7 @@ const {
} = require('./prompts');
const spendTokens = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
const { sleep } = require('~/server/utils');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
@ -605,6 +607,7 @@ class AnthropicClient extends BaseClient {
};
const maxRetries = 3;
const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
async function processResponse() {
let attempts = 0;
@ -627,6 +630,8 @@ class AnthropicClient extends BaseClient {
} else if (completion.completion) {
handleChunk(completion.completion);
}
await sleep(streamRate);
}
// Successful processing, exit loop

View file

@ -1,10 +1,11 @@
const crypto = require('crypto');
const fetch = require('node-fetch');
const { supportsBalanceCheck, Constants } = require('librechat-data-provider');
const { supportsBalanceCheck, Constants, CacheKeys, Time } = require('librechat-data-provider');
const { getMessages, saveMessage, updateMessage, saveConvo } = require('~/models');
const { addSpaceIfNeeded, isEnabled } = require('~/server/utils');
const checkBalance = require('~/models/checkBalance');
const { getFiles } = require('~/models/File');
const { getLogStores } = require('~/cache');
const TextStream = require('./TextStream');
const { logger } = require('~/config');
@ -540,6 +541,15 @@ class BaseClient {
await this.recordTokenUsage({ promptTokens, completionTokens });
}
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
const messageCache = getLogStores(CacheKeys.MESSAGES);
messageCache.set(
responseMessageId,
{
text: responseMessage.text,
complete: true,
},
Time.FIVE_MINUTES,
);
delete responseMessage.tokenCount;
return responseMessage;
}
@ -598,7 +608,11 @@ class BaseClient {
* @param {string | null} user
*/
async saveMessageToDatabase(message, endpointOptions, user = null) {
const savedMessage = await saveMessage({
if (this.user && user !== this.user) {
throw new Error('User mismatch.');
}
const savedMessage = await saveMessage(this.options.req, {
...message,
endpoint: this.options.endpoint,
unfinished: false,
@ -619,7 +633,7 @@ class BaseClient {
}
async updateMessageInDatabase(message) {
await updateMessage(message);
await updateMessage(this.options.req, message);
}
/**

View file

@ -13,10 +13,12 @@ const {
endpointSettings,
EModelEndpoint,
VisionModes,
Constants,
AuthKeys,
} = require('librechat-data-provider');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { getModelMaxTokens } = require('~/utils');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const {
formatMessage,
@ -620,8 +622,9 @@ class GoogleClient extends BaseClient {
}
async getCompletion(_payload, options = {}) {
const { onProgress, abortController } = options;
const { parameters, instances } = _payload;
const { onProgress, abortController } = options;
const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
const { messages: _messages, context, examples: _examples } = instances?.[0] ?? {};
let examples;
@ -701,6 +704,7 @@ class GoogleClient extends BaseClient {
delay,
});
reply += chunkText;
await sleep(streamRate);
}
return reply;
}
@ -712,10 +716,17 @@ class GoogleClient extends BaseClient {
safetySettings: safetySettings,
});
let delay = this.isGenerativeModel ? 12 : 8;
if (modelName.includes('flash')) {
delay = 5;
let delay = this.options.streamRate || 8;
if (!this.options.streamRate) {
if (this.isGenerativeModel) {
delay = 12;
}
if (modelName.includes('flash')) {
delay = 5;
}
}
for await (const chunk of stream) {
const chunkText = chunk?.content ?? chunk;
await this.generateTextStream(chunkText, onProgress, {

View file

@ -1,7 +1,9 @@
const { z } = require('zod');
const axios = require('axios');
const { Ollama } = require('ollama');
const { Constants } = require('librechat-data-provider');
const { deriveBaseURL } = require('~/utils');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const ollamaPayloadSchema = z.object({
@ -40,6 +42,7 @@ const getValidBase64 = (imageUrl) => {
class OllamaClient {
constructor(options = {}) {
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
/** @type {Ollama} */
this.client = new Ollama({ host });
}
@ -136,6 +139,8 @@ class OllamaClient {
stream.controller.abort();
break;
}
await sleep(this.streamRate);
}
}
// TODO: regular completion

View file

@ -1182,8 +1182,10 @@ ${convo}
});
}
const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
if (this.message_file_map && this.isOllama) {
const ollamaClient = new OllamaClient({ baseURL });
const ollamaClient = new OllamaClient({ baseURL, streamRate });
return await ollamaClient.chatCompletion({
payload: modelOptions,
onProgress,
@ -1221,8 +1223,6 @@ ${convo}
}
});
const azureDelay = this.modelOptions.model?.includes('gpt-4') ? 30 : 17;
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content || '';
intermediateReply += token;
@ -1232,9 +1232,7 @@ ${convo}
break;
}
if (this.azure) {
await sleep(azureDelay);
}
await sleep(streamRate);
}
if (!UnexpectedRoleError) {

View file

@ -1,5 +1,6 @@
const OpenAIClient = require('./OpenAIClient');
const { CallbackManager } = require('langchain/callbacks');
const { CacheKeys, Time } = require('librechat-data-provider');
const { BufferMemory, ChatMessageHistory } = require('langchain/memory');
const { initializeCustomAgent, initializeFunctionsAgent } = require('./agents');
const { addImages, buildErrorInput, buildPromptPrefix } = require('./output_parsers');
@ -11,6 +12,7 @@ const { SelfReflectionTool } = require('./tools');
const { isEnabled } = require('~/server/utils');
const { extractBaseURL } = require('~/utils');
const { loadTools } = require('./tools/util');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
class PluginsClient extends OpenAIClient {
@ -220,6 +222,13 @@ class PluginsClient extends OpenAIClient {
}
}
/**
*
* @param {TMessage} responseMessage
* @param {Partial<TMessage>} saveOptions
* @param {string} user
* @returns
*/
async handleResponseMessage(responseMessage, saveOptions, user) {
const { output, errorMessage, ...result } = this.result;
logger.debug('[PluginsClient][handleResponseMessage] Output:', {
@ -239,6 +248,15 @@ class PluginsClient extends OpenAIClient {
}
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
const messageCache = getLogStores(CacheKeys.MESSAGES);
messageCache.set(
responseMessage.messageId,
{
text: responseMessage.text,
complete: true,
},
Time.FIVE_MINUTES,
);
delete responseMessage.tokenCount;
return { ...responseMessage, ...result };
}

View file

@ -1,13 +1,11 @@
const Keyv = require('keyv');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { math, isEnabled } = require('~/server/utils');
const keyvRedis = require('./keyvRedis');
const keyvMongo = require('./keyvMongo');
const { BAN_DURATION, USE_REDIS } = process.env ?? {};
const THIRTY_MINUTES = 1800000;
const TEN_MINUTES = 600000;
const duration = math(BAN_DURATION, 7200000);
@ -29,17 +27,21 @@ const roles = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ROLES });
const audioRuns = isEnabled(USE_REDIS) // ttl: 30 minutes
? new Keyv({ store: keyvRedis, ttl: TEN_MINUTES })
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: TEN_MINUTES });
const audioRuns = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
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) // ttl: 30 minutes
? new Keyv({ store: keyvRedis, ttl: THIRTY_MINUTES })
: new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: THIRTY_MINUTES });
? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES })
: new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES });
const genTitle = isEnabled(USE_REDIS) // ttl: 2 minutes
? new Keyv({ store: keyvRedis, ttl: 120000 })
: new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: 120000 });
? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES })
: new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: Time.TWO_MINUTES });
const modelQueries = isEnabled(process.env.USE_REDIS)
? new Keyv({ store: keyvRedis })
@ -47,7 +49,7 @@ const modelQueries = isEnabled(process.env.USE_REDIS)
const abortKeys = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: 600000 });
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES });
const namespaces = {
[CacheKeys.ROLES]: roles,
@ -81,6 +83,7 @@ const namespaces = {
[CacheKeys.GEN_TITLE]: genTitle,
[CacheKeys.MODEL_QUERIES]: modelQueries,
[CacheKeys.AUDIO_RUNS]: audioRuns,
[CacheKeys.MESSAGES]: messages,
};
/**

View file

@ -4,11 +4,37 @@ const logger = require('~/config/winston');
const idSchema = z.string().uuid();
module.exports = {
Message,
async saveMessage({
user,
/**
* Saves a message in the database.
*
* @async
* @function saveMessage
* @param {Express.Request} req - The request object containing user information.
* @param {Object} params - The message data object.
* @param {string} params.endpoint - The endpoint where the message originated.
* @param {string} params.iconURL - The URL of the sender's icon.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.newMessageId - The new unique identifier for the message (if applicable).
* @param {string} params.conversationId - The identifier of the conversation.
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
* @param {string} params.sender - The identifier of the sender.
* @param {string} params.text - The text content of the message.
* @param {boolean} params.isCreatedByUser - Indicates if the message was created by the user.
* @param {string} [params.error] - Any error associated with the message.
* @param {boolean} [params.unfinished] - Indicates if the message is unfinished.
* @param {Object[]} [params.files] - An array of files associated with the message.
* @param {boolean} [params.isEdited] - Indicates if the message was edited.
* @param {string} [params.finish_reason] - Reason for finishing the message.
* @param {number} [params.tokenCount] - The number of tokens in the message.
* @param {string} [params.plugin] - Plugin associated with the message.
* @param {Object[]} [params.plugins] - An array of plugins associated with the message.
* @param {string} [params.model] - The model used to generate the message.
* @returns {Promise<TMessage>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message.
*/
async function saveMessage(
req,
{
endpoint,
iconURL,
messageId,
@ -27,178 +53,271 @@ module.exports = {
plugin,
plugins,
model,
}) {
try {
const validConvoId = idSchema.safeParse(conversationId);
if (!validConvoId.success) {
return;
}
},
) {
try {
if (!req || !req.user || !req.user.id) {
throw new Error('User not authenticated');
}
const update = {
user,
iconURL,
endpoint,
messageId: newMessageId || messageId,
conversationId,
parentMessageId,
sender,
text,
isCreatedByUser,
isEdited,
finish_reason,
error,
unfinished,
tokenCount,
plugin,
plugins,
model,
};
const validConvoId = idSchema.safeParse(conversationId);
if (!validConvoId.success) {
throw new Error('Invalid conversation ID');
}
if (files) {
update.files = files;
}
const update = {
user: req.user.id,
iconURL,
endpoint,
messageId: newMessageId || messageId,
conversationId,
parentMessageId,
sender,
text,
isCreatedByUser,
isEdited,
finish_reason,
error,
unfinished,
tokenCount,
plugin,
plugins,
model,
};
const message = await Message.findOneAndUpdate({ messageId }, update, {
if (files) {
update.files = files;
}
const message = await Message.findOneAndUpdate({ messageId, user: req.user.id }, update, {
upsert: true,
new: true,
});
return message.toObject();
} catch (err) {
logger.error('Error saving message:', err);
throw err;
}
}
/**
* Saves multiple messages in the database in bulk.
*
* @async
* @function bulkSaveMessages
* @param {Object[]} messages - An array of message objects to save.
* @returns {Promise<Object>} The result of the bulk write operation.
* @throws {Error} If there is an error in saving messages in bulk.
*/
async function bulkSaveMessages(messages) {
try {
const bulkOps = messages.map((message) => ({
updateOne: {
filter: { messageId: message.messageId },
update: message,
upsert: true,
},
}));
const result = await Message.bulkWrite(bulkOps);
return result;
} catch (err) {
logger.error('Error saving messages in bulk:', err);
throw err;
}
}
/**
* Records a message in the database.
*
* @async
* @function recordMessage
* @param {Object} params - The message data object.
* @param {string} params.user - The identifier of the user.
* @param {string} params.endpoint - The endpoint where the message originated.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.conversationId - The identifier of the conversation.
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
* @param {Partial<TMessage>} rest - Any additional properties from the TMessage typedef not explicitly listed.
* @returns {Promise<Object>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message.
*/
async function recordMessage({
user,
endpoint,
messageId,
conversationId,
parentMessageId,
...rest
}) {
try {
// No parsing of convoId as may use threadId
const message = {
user,
endpoint,
messageId,
conversationId,
parentMessageId,
...rest,
};
return await Message.findOneAndUpdate({ user, messageId }, message, {
upsert: true,
new: true,
});
} catch (err) {
logger.error('Error saving message:', err);
throw err;
}
}
/**
* Updates the text of a message.
*
* @async
* @function updateMessageText
* @param {Object} params - The update data object.
* @param {Object} req - The request object.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.text - The new text content of the message.
* @returns {Promise<void>}
* @throws {Error} If there is an error in updating the message text.
*/
async function updateMessageText(req, { messageId, text }) {
try {
await Message.updateOne({ messageId, user: req.user.id }, { text });
} catch (err) {
logger.error('Error updating message text:', err);
throw err;
}
}
/**
* Updates a message.
*
* @async
* @function updateMessage
* @param {Object} message - The message object containing update data.
* @param {Object} req - The request object.
* @param {string} message.messageId - The unique identifier for the message.
* @param {string} [message.text] - The new text content of the message.
* @param {Object[]} [message.files] - The files associated with the message.
* @param {boolean} [message.isCreatedByUser] - Indicates if the message was created by the user.
* @param {string} [message.sender] - The identifier of the sender.
* @param {number} [message.tokenCount] - The number of tokens in the message.
* @returns {Promise<TMessage>} The updated message document.
* @throws {Error} If there is an error in updating the message or if the message is not found.
*/
async function updateMessage(req, message) {
try {
const { messageId, ...update } = message;
update.isEdited = true;
const updatedMessage = await Message.findOneAndUpdate(
{ messageId, user: req.user.id },
update,
{
new: true,
},
);
if (!updatedMessage) {
throw new Error('Message not found or user not authorized.');
}
return {
messageId: updatedMessage.messageId,
conversationId: updatedMessage.conversationId,
parentMessageId: updatedMessage.parentMessageId,
sender: updatedMessage.sender,
text: updatedMessage.text,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
isEdited: true,
};
} catch (err) {
logger.error('Error updating message:', err);
throw err;
}
}
/**
* Deletes messages in a conversation since a specific message.
*
* @async
* @function deleteMessagesSince
* @param {Object} params - The parameters object.
* @param {Object} req - The request object.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.conversationId - The identifier of the conversation.
* @returns {Promise<Number>} The number of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessagesSince(req, { messageId, conversationId }) {
try {
const message = await Message.findOne({ messageId, user: req.user.id }).lean();
if (message) {
const query = Message.find({ conversationId, user: req.user.id });
return await query.deleteMany({
createdAt: { $gt: message.createdAt },
});
return message.toObject();
} catch (err) {
logger.error('Error saving message:', err);
throw new Error('Failed to save message.');
}
},
return undefined;
} catch (err) {
logger.error('Error deleting messages:', err);
throw err;
}
}
async bulkSaveMessages(messages) {
try {
const bulkOps = messages.map((message) => ({
updateOne: {
filter: { messageId: message.messageId },
update: message,
upsert: true,
},
}));
const result = await Message.bulkWrite(bulkOps);
return result;
} catch (err) {
logger.error('Error saving messages in bulk:', err);
throw new Error('Failed to save messages in bulk.');
/**
* Retrieves messages from the database.
* @async
* @function getMessages
* @param {Record<string, unknown>} filter - The filter criteria.
* @param {string | undefined} [select] - The fields to select.
* @returns {Promise<TMessage[]>} The messages that match the filter criteria.
* @throws {Error} If there is an error in retrieving messages.
*/
async function getMessages(filter, select) {
try {
if (select) {
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
}
},
/**
* Records a message in the database.
*
* @async
* @function recordMessage
* @param {Object} params - The message data object.
* @param {string} params.user - The identifier of the user.
* @param {string} params.endpoint - The endpoint where the message originated.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.conversationId - The identifier of the conversation.
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
* @param {Partial<TMessage>} rest - Any additional properties from the TMessage typedef not explicitly listed.
* @returns {Promise<Object>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message.
*/
async recordMessage({ user, endpoint, messageId, conversationId, parentMessageId, ...rest }) {
try {
// No parsing of convoId as may use threadId
const message = {
user,
endpoint,
messageId,
conversationId,
parentMessageId,
...rest,
};
return await Message.find(filter).sort({ createdAt: 1 }).lean();
} catch (err) {
logger.error('Error getting messages:', err);
throw err;
}
}
return await Message.findOneAndUpdate({ user, messageId }, message, {
upsert: true,
new: true,
});
} catch (err) {
logger.error('Error saving message:', err);
throw new Error('Failed to save message.');
}
},
async updateMessageText({ messageId, text }) {
try {
await Message.updateOne({ messageId }, { text });
} catch (err) {
logger.error('Error updating message text:', err);
throw new Error('Failed to update message text.');
}
},
async updateMessage(message) {
try {
const { messageId, ...update } = message;
update.isEdited = true;
const updatedMessage = await Message.findOneAndUpdate({ messageId }, update, {
new: true,
});
/**
* Deletes messages from the database.
*
* @async
* @function deleteMessages
* @param {Object} filter - The filter criteria to find messages to delete.
* @returns {Promise<Number>} The number of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {
try {
return await Message.deleteMany(filter);
} catch (err) {
logger.error('Error deleting messages:', err);
throw err;
}
}
if (!updatedMessage) {
throw new Error('Message not found.');
}
return {
messageId: updatedMessage.messageId,
conversationId: updatedMessage.conversationId,
parentMessageId: updatedMessage.parentMessageId,
sender: updatedMessage.sender,
text: updatedMessage.text,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
isEdited: true,
};
} catch (err) {
logger.error('Error updating message:', err);
throw new Error('Failed to update message.');
}
},
async deleteMessagesSince({ messageId, conversationId }) {
try {
const message = await Message.findOne({ messageId }).lean();
if (message) {
return await Message.find({ conversationId }).deleteMany({
createdAt: { $gt: message.createdAt },
});
}
} catch (err) {
logger.error('Error deleting messages:', err);
throw new Error('Failed to delete messages.');
}
},
/**
* Retrieves messages from the database.
* @param {Record<string, unknown>} filter
* @param {string | undefined} [select]
* @returns
*/
async getMessages(filter, select) {
try {
if (select) {
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
}
return await Message.find(filter).sort({ createdAt: 1 }).lean();
} catch (err) {
logger.error('Error getting messages:', err);
throw new Error('Failed to get messages.');
}
},
async deleteMessages(filter) {
try {
return await Message.deleteMany(filter);
} catch (err) {
logger.error('Error deleting messages:', err);
throw new Error('Failed to delete messages.');
}
},
module.exports = {
Message,
saveMessage,
bulkSaveMessages,
recordMessage,
updateMessageText,
updateMessage,
deleteMessagesSince,
getMessages,
deleteMessages,
};

239
api/models/Message.spec.js Normal file
View file

@ -0,0 +1,239 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
jest.mock('mongoose');
const mockFindQuery = {
select: jest.fn().mockReturnThis(),
sort: jest.fn().mockReturnThis(),
lean: jest.fn().mockReturnThis(),
deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }),
};
const mockSchema = {
findOneAndUpdate: jest.fn(),
updateOne: jest.fn(),
findOne: jest.fn(() => ({
lean: jest.fn(),
})),
find: jest.fn(() => mockFindQuery),
deleteMany: jest.fn(),
};
mongoose.model.mockReturnValue(mockSchema);
jest.mock('~/models/schema/messageSchema', () => mockSchema);
jest.mock('~/config/winston', () => ({
error: jest.fn(),
}));
const {
saveMessage,
getMessages,
updateMessage,
deleteMessages,
updateMessageText,
deleteMessagesSince,
} = require('~/models/Message');
describe('Message Operations', () => {
let mockReq;
let mockMessage;
beforeEach(() => {
jest.clearAllMocks();
mockReq = {
user: { id: 'user123' },
};
mockMessage = {
messageId: 'msg123',
conversationId: uuidv4(),
text: 'Hello, world!',
user: 'user123',
};
mockSchema.findOneAndUpdate.mockResolvedValue({
toObject: () => mockMessage,
});
});
describe('saveMessage', () => {
it('should save a message for an authenticated user', async () => {
const result = await saveMessage(mockReq, mockMessage);
expect(result).toEqual(mockMessage);
expect(mockSchema.findOneAndUpdate).toHaveBeenCalledWith(
{ messageId: 'msg123', user: 'user123' },
expect.objectContaining({ user: 'user123' }),
expect.any(Object),
);
});
it('should throw an error for unauthenticated user', async () => {
mockReq.user = null;
await expect(saveMessage(mockReq, mockMessage)).rejects.toThrow('User not authenticated');
});
it('should throw an error for invalid conversation ID', async () => {
mockMessage.conversationId = 'invalid-id';
await expect(saveMessage(mockReq, mockMessage)).rejects.toThrow('Invalid conversation ID');
});
});
describe('updateMessageText', () => {
it('should update message text for the authenticated user', async () => {
await updateMessageText(mockReq, { messageId: 'msg123', text: 'Updated text' });
expect(mockSchema.updateOne).toHaveBeenCalledWith(
{ messageId: 'msg123', user: 'user123' },
{ text: 'Updated text' },
);
});
});
describe('updateMessage', () => {
it('should update a message for the authenticated user', async () => {
mockSchema.findOneAndUpdate.mockResolvedValue(mockMessage);
const result = await updateMessage(mockReq, { messageId: 'msg123', text: 'Updated text' });
expect(result).toEqual(
expect.objectContaining({
messageId: 'msg123',
text: 'Hello, world!',
isEdited: true,
}),
);
});
it('should throw an error if message is not found', async () => {
mockSchema.findOneAndUpdate.mockResolvedValue(null);
await expect(
updateMessage(mockReq, { messageId: 'nonexistent', text: 'Test' }),
).rejects.toThrow('Message not found or user not authorized.');
});
});
describe('deleteMessagesSince', () => {
it('should delete messages only for the authenticated user', async () => {
mockSchema.findOne().lean.mockResolvedValueOnce({ createdAt: new Date() });
mockFindQuery.deleteMany.mockResolvedValueOnce({ deletedCount: 1 });
const result = await deleteMessagesSince(mockReq, {
messageId: 'msg123',
conversationId: 'convo123',
});
expect(mockSchema.findOne).toHaveBeenCalledWith({ messageId: 'msg123', user: 'user123' });
expect(mockSchema.find).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should return undefined if no message is found', async () => {
mockSchema.findOne().lean.mockResolvedValueOnce(null);
const result = await deleteMessagesSince(mockReq, {
messageId: 'nonexistent',
conversationId: 'convo123',
});
expect(result).toBeUndefined();
});
});
describe('getMessages', () => {
it('should retrieve messages with the correct filter', async () => {
const filter = { conversationId: 'convo123' };
await getMessages(filter);
expect(mockSchema.find).toHaveBeenCalledWith(filter);
expect(mockFindQuery.sort).toHaveBeenCalledWith({ createdAt: 1 });
expect(mockFindQuery.lean).toHaveBeenCalled();
});
});
describe('deleteMessages', () => {
it('should delete messages with the correct filter', async () => {
await deleteMessages({ user: 'user123' });
expect(mockSchema.deleteMany).toHaveBeenCalledWith({ user: 'user123' });
});
});
describe('Conversation Hijacking Prevention', () => {
it('should not allow editing a message in another user\'s conversation', async () => {
const attackerReq = { user: { id: 'attacker123' } };
const victimConversationId = 'victim-convo-123';
const victimMessageId = 'victim-msg-123';
mockSchema.findOneAndUpdate.mockResolvedValue(null);
await expect(
updateMessage(attackerReq, {
messageId: victimMessageId,
conversationId: victimConversationId,
text: 'Hacked message',
}),
).rejects.toThrow('Message not found or user not authorized.');
expect(mockSchema.findOneAndUpdate).toHaveBeenCalledWith(
{ messageId: victimMessageId, user: 'attacker123' },
expect.anything(),
expect.anything(),
);
});
it('should not allow deleting messages from another user\'s conversation', async () => {
const attackerReq = { user: { id: 'attacker123' } };
const victimConversationId = 'victim-convo-123';
const victimMessageId = 'victim-msg-123';
mockSchema.findOne().lean.mockResolvedValueOnce(null); // Simulating message not found for this user
const result = await deleteMessagesSince(attackerReq, {
messageId: victimMessageId,
conversationId: victimConversationId,
});
expect(result).toBeUndefined();
expect(mockSchema.findOne).toHaveBeenCalledWith({
messageId: victimMessageId,
user: 'attacker123',
});
});
it('should not allow inserting a new message into another user\'s conversation', async () => {
const attackerReq = { user: { id: 'attacker123' } };
const victimConversationId = uuidv4(); // Use a valid UUID
await expect(
saveMessage(attackerReq, {
conversationId: victimConversationId,
text: 'Inserted malicious message',
messageId: 'new-msg-123',
}),
).resolves.not.toThrow(); // It should not throw an error
// Check that the message was saved with the attacker's user ID
expect(mockSchema.findOneAndUpdate).toHaveBeenCalledWith(
{ messageId: 'new-msg-123', user: 'attacker123' },
expect.objectContaining({
user: 'attacker123',
conversationId: victimConversationId,
}),
expect.anything(),
);
});
it('should allow retrieving messages from any conversation', async () => {
const victimConversationId = 'victim-convo-123';
await getMessages({ conversationId: victimConversationId });
expect(mockSchema.find).toHaveBeenCalledWith({
conversationId: victimConversationId,
});
mockSchema.find.mockReturnValueOnce({
select: jest.fn().mockReturnThis(),
sort: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue([{ text: 'Test message' }]),
});
const result = await getMessages({ conversationId: victimConversationId });
expect(result).toEqual([{ text: 'Test message' }]);
});
});
});

View file

@ -1,7 +1,8 @@
const throttle = require('lodash/throttle');
const { getResponseSender, Constants, EModelEndpoint } = require('librechat-data-provider');
const { getResponseSender, Constants, CacheKeys, Time } = require('librechat-data-provider');
const { createAbortController, handleAbortError } = require('~/server/middleware');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
@ -51,11 +52,13 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
try {
const { client } = await initializeClient({ req, res, endpointOption });
const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true;
const messageCache = getLogStores(CacheKeys.MESSAGES);
const { onProgress: progressCallback, getPartialText } = createOnProgress({
onProgress: throttle(
({ text: partialText }) => {
saveMessage({
/*
const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true;
messageCache.set(responseMessageId, {
messageId: responseMessageId,
sender,
conversationId,
@ -65,7 +68,10 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
unfinished,
error: false,
user,
});
}, Time.FIVE_MINUTES);
*/
messageCache.set(responseMessageId, partialText, Time.FIVE_MINUTES);
},
3000,
{ trailing: false },
@ -144,11 +150,11 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
});
res.end();
await saveMessage({ ...response, user });
await saveMessage(req, { ...response, user });
}
if (!client.skipSaveUserMessage) {
await saveMessage(userMessage);
await saveMessage(req, userMessage);
}
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {

View file

@ -1,7 +1,8 @@
const throttle = require('lodash/throttle');
const { getResponseSender, EModelEndpoint } = require('librechat-data-provider');
const { getResponseSender, CacheKeys, Time } = require('librechat-data-provider');
const { createAbortController, handleAbortError } = require('~/server/middleware');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
@ -51,12 +52,14 @@ const EditController = async (req, res, next, initializeClient) => {
}
};
const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true;
const messageCache = getLogStores(CacheKeys.MESSAGES);
const { onProgress: progressCallback, getPartialText } = createOnProgress({
generation,
onProgress: throttle(
({ text: partialText }) => {
saveMessage({
/*
const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true;
{
messageId: responseMessageId,
sender,
conversationId,
@ -67,7 +70,8 @@ const EditController = async (req, res, next, initializeClient) => {
isEdited: true,
error: false,
user,
});
} */
messageCache.set(responseMessageId, partialText, Time.FIVE_MINUTES);
},
3000,
{ trailing: false },
@ -141,7 +145,7 @@ const EditController = async (req, res, next, initializeClient) => {
});
res.end();
await saveMessage({ ...response, user });
await saveMessage(req, { ...response, user });
}
} catch (error) {
const partialText = getPartialText();

View file

@ -120,21 +120,22 @@ const chatV1 = async (req, res) => {
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(res, messageData, errorMessage);
return sendResponse(req, res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
req,
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(res, messageData, error.message);
return sendResponse(req, res, messageData, error.message);
} else {
logger.error('[/assistants/chat/]', error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(res, messageData, defaultErrorMessage);
return sendResponse(req, res, messageData, defaultErrorMessage);
}
await sleep(2000);
@ -221,10 +222,10 @@ const chatV1 = async (req, res) => {
};
} catch (error) {
logger.error('[/assistants/chat/] Error finalizing error process', error);
return sendResponse(res, messageData, 'The Assistant run failed');
return sendResponse(req, res, messageData, 'The Assistant run failed');
}
return sendResponse(res, finalEvent);
return sendResponse(req, res, finalEvent);
};
try {

View file

@ -1,12 +1,12 @@
const { v4 } = require('uuid');
const {
Time,
Constants,
RunStatus,
CacheKeys,
ContentTypes,
ToolCallTypes,
EModelEndpoint,
ViolationTypes,
retrievalMimeTypes,
AssistantStreamEvents,
} = require('librechat-data-provider');
@ -14,12 +14,12 @@ const {
initThread,
recordUsage,
saveUserMessage,
checkMessageGaps,
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, isEnabled, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const { sendMessage, sleep, isEnabled, countTokens } = require('~/server/utils');
const { createErrorHandler } = require('~/server/controllers/assistants/errors');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
@ -44,7 +44,7 @@ const ten_minutes = 1000 * 60 * 10;
const chatV2 = async (req, res) => {
logger.debug('[/assistants/chat/] req.body', req.body);
/** @type {{ files: MongoFile[]}} */
/** @type {{files: MongoFile[]}} */
const {
text,
model,
@ -90,139 +90,20 @@ const chatV2 = async (req, res) => {
/** @type {Run | undefined} - The completed run, undefined if incomplete */
let completedRun;
const handleError = async (error) => {
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
thread_id,
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint,
};
const getContext = () => ({
openai,
run_id,
endpoint,
cacheKey,
thread_id,
completedRun,
assistant_id,
conversationId,
parentMessageId,
responseMessageId,
});
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug('[/assistants/chat/] Request aborted on close');
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === EModelEndpoint.azureAssistants
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(res, messageData, error.message);
} else {
logger.error('[/assistants/chat/]', error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(res, messageData, defaultErrorMessage);
}
await sleep(2000);
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug('[/assistants/chat/] Run already cancelled');
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[/assistants/chat/] Error cancelling run', error);
}
await sleep(2000);
let run;
try {
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error('[/assistants/chat/] Error fetching or processing run', error);
}
let finalEvent;
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const errorContentPart = {
text: {
value:
error?.message ?? 'There was an error processing your request. Please try again later.',
},
type: ContentTypes.ERROR,
};
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
runMessages[runMessages.length - 1].content = [errorContentPart];
} else {
const contentParts = runMessages[runMessages.length - 1].content;
for (let i = 0; i < contentParts.length; i++) {
const currentPart = contentParts[i];
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
if (
toolCall &&
toolCall?.function &&
!(toolCall?.function?.output || toolCall?.function?.output?.length)
) {
contentParts[i] = {
...currentPart,
[ContentTypes.TOOL_CALL]: {
...toolCall,
function: {
...toolCall.function,
output: 'error processing tool',
},
},
};
}
}
runMessages[runMessages.length - 1].content.push(errorContentPart);
}
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
} catch (error) {
logger.error('[/assistants/chat/] Error finalizing error process', error);
return sendResponse(res, messageData, 'The Assistant run failed');
}
return sendResponse(res, finalEvent);
};
const handleError = createErrorHandler({ req, res, getContext });
try {
res.on('close', async () => {
@ -489,6 +370,11 @@ const chatV2 = async (req, res) => {
},
};
/** @type {undefined | TAssistantEndpoint} */
const config = req.app.locals[endpoint] ?? {};
/** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all;
const streamRunManager = new StreamRunManager({
req,
res,
@ -498,6 +384,7 @@ const chatV2 = async (req, res) => {
attachedFileIds,
parentMessageId: userMessageId,
responseMessage: openai.responseMessage,
streamRate: allConfig?.streamRate ?? config.streamRate,
// streamOptions: {
// },
@ -510,6 +397,16 @@ const chatV2 = async (req, res) => {
response = streamRunManager;
response.text = streamRunManager.intermediateText;
const messageCache = getLogStores(CacheKeys.MESSAGES);
messageCache.set(
responseMessageId,
{
complete: true,
text: response.text,
},
Time.FIVE_MINUTES,
);
};
await processRun();

View file

@ -0,0 +1,193 @@
// errorHandler.js
const { sendResponse } = require('~/server/utils');
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { getConvo } = require('~/models/Conversation');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
/**
* @typedef {Object} ErrorHandlerContext
* @property {OpenAIClient} openai - The OpenAI client
* @property {string} thread_id - The thread ID
* @property {string} run_id - The run ID
* @property {boolean} completedRun - Whether the run has completed
* @property {string} assistant_id - The assistant ID
* @property {string} conversationId - The conversation ID
* @property {string} parentMessageId - The parent message ID
* @property {string} responseMessageId - The response message ID
* @property {string} endpoint - The endpoint being used
* @property {string} cacheKey - The cache key for the current request
*/
/**
* @typedef {Object} ErrorHandlerDependencies
* @property {Express.Request} req - The Express request object
* @property {Express.Response} res - The Express response object
* @property {() => ErrorHandlerContext} getContext - Function to get the current context
* @property {string} [originPath] - The origin path for the error handler
*/
/**
* Creates an error handler function with the given dependencies
* @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
* @returns {(error: Error) => Promise<void>} The error handler function
*/
const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
const cache = getLogStores(CacheKeys.ABORT_KEYS);
/**
* Handles errors that occur during the chat process
* @param {Error} error - The error that occurred
* @returns {Promise<void>}
*/
return async (error) => {
const {
openai,
run_id,
endpoint,
cacheKey,
thread_id,
completedRun,
assistant_id,
conversationId,
parentMessageId,
responseMessageId,
} = getContext();
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
thread_id,
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint,
};
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug(`[${originPath}] Request aborted on close`);
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
req,
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(req, res, messageData, error.message);
} else {
logger.error(`[${originPath}]`, error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(req, res, messageData, defaultErrorMessage);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug(`[${originPath}] Run already cancelled`);
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
} catch (error) {
logger.error(`[${originPath}] Error cancelling run`, error);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
let run;
try {
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error(`[${originPath}] Error fetching or processing run`, error);
}
let finalEvent;
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const errorContentPart = {
text: {
value:
error?.message ?? 'There was an error processing your request. Please try again later.',
},
type: ContentTypes.ERROR,
};
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
runMessages[runMessages.length - 1].content = [errorContentPart];
} else {
const contentParts = runMessages[runMessages.length - 1].content;
for (let i = 0; i < contentParts.length; i++) {
const currentPart = contentParts[i];
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
if (
toolCall &&
toolCall?.function &&
!(toolCall?.function?.output || toolCall?.function?.output?.length)
) {
contentParts[i] = {
...currentPart,
[ContentTypes.TOOL_CALL]: {
...toolCall,
function: {
...toolCall.function,
output: 'error processing tool',
},
},
};
}
}
runMessages[runMessages.length - 1].content.push(errorContentPart);
}
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
} catch (error) {
logger.error(`[${originPath}] Error finalizing error process`, error);
return sendResponse(req, res, messageData, 'The Assistant run failed');
}
return sendResponse(req, res, finalEvent);
};
};
module.exports = { createErrorHandler };

View file

@ -30,7 +30,10 @@ async function abortMessage(req, res) {
return res.status(204).send({ message: 'Request not found' });
}
const finalEvent = await abortController.abortCompletion();
logger.info('[abortMessage] Aborted request', { abortKey });
logger.debug(
`[abortMessage] ID: ${req.user.id} | ${req.user.email} | Aborted request: ` +
JSON.stringify({ abortKey }),
);
abortControllers.delete(abortKey);
if (res.headersSent && finalEvent) {
@ -116,7 +119,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
{ promptTokens, completionTokens },
);
saveMessage({ ...responseMessage, user });
saveMessage(req, { ...responseMessage, user });
let conversation;
if (userMessagePromise) {
@ -190,7 +193,7 @@ const handleAbortError = async (res, req, error, data) => {
}
};
await sendError(res, options, callback);
await sendError(req, res, options, callback);
};
if (partialText && partialText.length > 5) {

View file

@ -41,10 +41,10 @@ const denyRequest = async (req, res, errorMessage) => {
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
if (shouldSaveMessage) {
await saveMessage({ ...userMessage, user: req.user.id });
await saveMessage(req, { ...userMessage, user: req.user.id });
}
return await sendError(res, {
return await sendError(req, res, {
sender: getResponseSender(req.body),
messageId: crypto.randomUUID(),
conversationId,

View file

@ -31,10 +31,14 @@ function validateImageRequest(req, res, next) {
return res.status(403).send('Access Denied');
}
if (req.path.includes(payload.id)) {
const fullPath = decodeURIComponent(req.originalUrl);
const pathPattern = new RegExp(`^/images/${payload.id}/[^/]+$`);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
next();
} else {
logger.warn('[validateImageRequest] Invalid image path');
res.status(403).send('Access Denied');
}
}

View file

@ -51,7 +51,7 @@ router.post('/', setHeaders, async (req, res) => {
});
if (!overrideParentMessageId) {
await saveMessage({ ...userMessage, user: req.user.id });
await saveMessage(req, { ...userMessage, user: req.user.id });
await saveConvo(req.user.id, {
...userMessage,
...endpointOption,
@ -93,7 +93,7 @@ const ask = async ({
const currentTimestamp = Date.now();
if (currentTimestamp - lastSavedTimestamp > 500) {
lastSavedTimestamp = currentTimestamp;
saveMessage({
saveMessage(req, {
messageId: responseMessageId,
sender: endpointOption?.jailbreak ? 'Sydney' : 'BingAI',
conversationId,
@ -159,7 +159,7 @@ const ask = async ({
isCreatedByUser: false,
};
await saveMessage({ ...responseMessage, user });
await saveMessage(req, { ...responseMessage, user });
responseMessage.messageId = newResponseMessageId;
// STEP2 update the conversation
@ -192,7 +192,7 @@ const ask = async ({
// If response has parentMessageId, the fake userMessage.messageId should be updated to the real one.
if (!overrideParentMessageId) {
await saveMessage({
await saveMessage(req, {
...userMessage,
user,
messageId: userMessageId,
@ -229,7 +229,7 @@ const ask = async ({
isCreatedByUser: false,
text: `${getPartialMessage() ?? ''}\n\nError message: "${error.message}"`,
};
await saveMessage({ ...errorMessage, user });
await saveMessage(req, { ...errorMessage, user });
handleError(res, errorMessage);
}
};

View file

@ -70,7 +70,7 @@ router.post('/', setHeaders, async (req, res) => {
});
if (!overrideParentMessageId) {
await saveMessage({ ...userMessage, user: req.user.id });
await saveMessage(req, { ...userMessage, user: req.user.id });
await saveConvo(req.user.id, {
...userMessage,
...endpointOption,
@ -118,7 +118,7 @@ const ask = async ({
const currentTimestamp = Date.now();
if (currentTimestamp - lastSavedTimestamp > 500) {
lastSavedTimestamp = currentTimestamp;
saveMessage({
saveMessage(req, {
messageId: responseMessageId,
sender: model,
conversationId,
@ -197,7 +197,7 @@ const ask = async ({
isCreatedByUser: false,
};
await saveMessage({ ...responseMessage, user });
await saveMessage(req, { ...responseMessage, user });
responseMessage.messageId = newResponseMessageId;
let conversationUpdate = {
@ -221,7 +221,7 @@ const ask = async ({
// If response has parentMessageId, the fake userMessage.messageId should be updated to the real one.
if (!overrideParentMessageId) {
await saveMessage({
await saveMessage(req, {
...userMessage,
user,
messageId: userMessageId,
@ -266,7 +266,7 @@ const ask = async ({
isCreatedByUser: false,
};
saveMessage({ ...responseMessage, user });
saveMessage(req, { ...responseMessage, user });
return {
title: await getConvoTitle(user, conversationId),
@ -288,7 +288,7 @@ const ask = async ({
model,
isCreatedByUser: false,
};
await saveMessage({ ...errorMessage, user });
await saveMessage(req, { ...errorMessage, user });
handleError(res, errorMessage);
}
}

View file

@ -1,10 +1,11 @@
const express = require('express');
const throttle = require('lodash/throttle');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { getResponseSender, Constants, CacheKeys, Time } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { addTitle } = require('~/server/services/Endpoints/openAI');
const { saveMessage } = require('~/models');
const { getLogStores } = require('~/cache');
const {
handleAbort,
createAbortController,
@ -71,7 +72,8 @@ router.post(
}
};
const throttledSaveMessage = throttle(saveMessage, 3000, { trailing: false });
const messageCache = getLogStores(CacheKeys.MESSAGES);
const throttledSetMessage = throttle(messageCache.set, 3000, { trailing: false });
let streaming = null;
let timer = null;
@ -85,7 +87,8 @@ router.post(
clearTimeout(timer);
}
throttledSaveMessage({
/*
{
messageId: responseMessageId,
sender,
conversationId,
@ -96,7 +99,9 @@ router.post(
error: false,
plugins,
user,
});
}
*/
throttledSetMessage(responseMessageId, partialText, Time.FIVE_MINUTES);
streaming = new Promise((resolve) => {
timer = setTimeout(() => {
@ -170,7 +175,7 @@ router.post(
const onChainEnd = () => {
if (!client.skipSaveUserMessage) {
saveMessage({ ...userMessage, user });
saveMessage(req, { ...userMessage, user });
}
sendIntermediateMessage(res, {
plugins,
@ -208,7 +213,7 @@ router.post(
logger.debug('[/ask/gptPlugins]', response);
response.plugins = plugins.map((p) => ({ ...p, loading: false }));
await saveMessage({ ...response, user });
await saveMessage(req, { ...response, user });
const { conversation = {} } = await client.responsePromise;
conversation.title =

View file

@ -1,19 +1,20 @@
const express = require('express');
const throttle = require('lodash/throttle');
const { getResponseSender } = require('librechat-data-provider');
const { getResponseSender, CacheKeys, Time } = require('librechat-data-provider');
const {
handleAbort,
createAbortController,
handleAbortError,
setHeaders,
handleAbort,
moderateText,
validateModel,
handleAbortError,
validateEndpoint,
buildEndpointOption,
moderateText,
createAbortController,
} = require('~/server/middleware');
const { sendMessage, createOnProgress, formatSteps, formatAction } = require('~/server/utils');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { saveMessage } = require('~/models');
const { getLogStores } = require('~/cache');
const { validateTools } = require('~/app');
const { logger } = require('~/config');
@ -79,7 +80,8 @@ router.post(
}
};
const throttledSaveMessage = throttle(saveMessage, 3000, { trailing: false });
const messageCache = getLogStores(CacheKeys.MESSAGES);
const throttledSetMessage = throttle(messageCache.set, 3000, { trailing: false });
const {
onProgress: progressCallback,
sendIntermediateMessage,
@ -91,7 +93,8 @@ router.post(
plugin.loading = false;
}
throttledSaveMessage({
/*
{
messageId: responseMessageId,
sender,
conversationId,
@ -102,7 +105,9 @@ router.post(
isEdited: true,
error: false,
user,
});
}
*/
throttledSetMessage(responseMessageId, partialText, Time.FIVE_MINUTES);
},
});
@ -110,7 +115,7 @@ router.post(
let { intermediateSteps: steps } = data;
plugin.outputs = steps && steps[0].action ? formatSteps(steps) : 'An error occurred.';
plugin.loading = false;
saveMessage({ ...userMessage, user });
saveMessage(req, { ...userMessage, user });
sendIntermediateMessage(res, {
plugin,
parentMessageId: userMessage.messageId,
@ -141,7 +146,7 @@ router.post(
plugin.inputs.push(formattedAction);
plugin.latest = formattedAction.plugin;
if (!start && !client.skipSaveUserMessage) {
saveMessage({ ...userMessage, user });
saveMessage(req, { ...userMessage, user });
}
sendIntermediateMessage(res, {
plugin,
@ -180,7 +185,7 @@ router.post(
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
response.plugin = { ...plugin, loading: false };
await saveMessage({ ...response, user });
await saveMessage(req, { ...response, user });
const { conversation = {} } = await client.responsePromise;
conversation.title =

View file

@ -1,17 +1,12 @@
const express = require('express');
const router = express.Router();
const {
getMessages,
updateMessage,
saveConvo,
saveMessage,
deleteMessages,
} = require('../../models');
const { countTokens } = require('../utils');
const { requireJwtAuth, validateMessageReq } = require('../middleware/');
const { saveConvo, saveMessage, getMessages, updateMessage, deleteMessages } = require('~/models');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { countTokens } = require('~/server/utils');
router.use(requireJwtAuth);
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
router.get('/:conversationId', validateMessageReq, async (req, res) => {
const { conversationId } = req.params;
res.status(200).send(await getMessages({ conversationId }, '-_id -__v -user'));
@ -20,7 +15,7 @@ router.get('/:conversationId', validateMessageReq, async (req, res) => {
// CREATE
router.post('/:conversationId', validateMessageReq, async (req, res) => {
const message = req.body;
const savedMessage = await saveMessage({ ...message, user: req.user.id });
const savedMessage = await saveMessage(req, { ...message, user: req.user.id });
await saveConvo(req.user.id, savedMessage);
res.status(201).send(savedMessage);
});
@ -36,7 +31,8 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
const { messageId, model } = req.params;
const { text } = req.body;
const tokenCount = await countTokens(text, model);
res.status(201).json(await updateMessage({ messageId, text, tokenCount }));
const result = await updateMessage(req, { messageId, text, tokenCount });
res.status(201).json(result);
});
// DELETE

View file

@ -67,17 +67,18 @@ const AppService = async (app) => {
handleRateLimits(config?.rateLimits);
const endpointLocals = {};
const endpoints = config?.endpoints;
if (config?.endpoints?.[EModelEndpoint.azureOpenAI]) {
if (endpoints?.[EModelEndpoint.azureOpenAI]) {
endpointLocals[EModelEndpoint.azureOpenAI] = azureConfigSetup(config);
checkAzureVariables();
}
if (config?.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
endpointLocals[EModelEndpoint.azureAssistants] = azureAssistantsDefaults();
}
if (config?.endpoints?.[EModelEndpoint.azureAssistants]) {
if (endpoints?.[EModelEndpoint.azureAssistants]) {
endpointLocals[EModelEndpoint.azureAssistants] = assistantsConfigSetup(
config,
EModelEndpoint.azureAssistants,
@ -85,7 +86,7 @@ const AppService = async (app) => {
);
}
if (config?.endpoints?.[EModelEndpoint.assistants]) {
if (endpoints?.[EModelEndpoint.assistants]) {
endpointLocals[EModelEndpoint.assistants] = assistantsConfigSetup(
config,
EModelEndpoint.assistants,
@ -93,6 +94,19 @@ const AppService = async (app) => {
);
}
if (endpoints?.[EModelEndpoint.openAI]) {
endpointLocals[EModelEndpoint.openAI] = endpoints[EModelEndpoint.openAI];
}
if (endpoints?.[EModelEndpoint.google]) {
endpointLocals[EModelEndpoint.google] = endpoints[EModelEndpoint.google];
}
if (endpoints?.[EModelEndpoint.anthropic]) {
endpointLocals[EModelEndpoint.anthropic] = endpoints[EModelEndpoint.anthropic];
}
if (endpoints?.[EModelEndpoint.gptPlugins]) {
endpointLocals[EModelEndpoint.gptPlugins] = endpoints[EModelEndpoint.gptPlugins];
}
app.locals = {
...defaultLocals,
modelSpecs: config.modelSpecs,

View file

@ -19,11 +19,27 @@ const initializeClient = async ({ req, res, endpointOption }) => {
checkUserKeyExpiry(expiresAt, EModelEndpoint.anthropic);
}
const clientOptions = {};
/** @type {undefined | TBaseEndpoint} */
const anthropicConfig = req.app.locals[EModelEndpoint.anthropic];
if (anthropicConfig) {
clientOptions.streamRate = anthropicConfig.streamRate;
}
/** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all;
if (allConfig) {
clientOptions.streamRate = allConfig.streamRate;
}
const client = new AnthropicClient(anthropicApiKey, {
req,
res,
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
proxy: PROXY ?? null,
...clientOptions,
...endpointOption,
});

View file

@ -114,9 +114,16 @@ const initializeClient = async ({ req, res, endpointOption }) => {
contextStrategy: endpointConfig.summarize ? 'summarize' : null,
directEndpoint: endpointConfig.directEndpoint,
titleMessageRole: endpointConfig.titleMessageRole,
streamRate: endpointConfig.streamRate,
endpointTokenConfig,
};
/** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all;
if (allConfig) {
customOptions.streamRate = allConfig.streamRate;
}
const clientOptions = {
reverseProxyUrl: baseURL ?? null,
proxy: PROXY ?? null,

View file

@ -27,11 +27,27 @@ const initializeClient = async ({ req, res, endpointOption }) => {
[AuthKeys.GOOGLE_API_KEY]: GOOGLE_KEY,
};
const clientOptions = {};
/** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all;
/** @type {undefined | TBaseEndpoint} */
const googleConfig = req.app.locals[EModelEndpoint.google];
if (googleConfig) {
clientOptions.streamRate = googleConfig.streamRate;
}
if (allConfig) {
clientOptions.streamRate = allConfig.streamRate;
}
const client = new GoogleClient(credentials, {
req,
res,
reverseProxyUrl: GOOGLE_REVERSE_PROXY ?? null,
proxy: PROXY ?? null,
...clientOptions,
...endpointOption,
});

View file

@ -8,6 +8,8 @@ jest.mock('~/server/services/UserService', () => ({
getUserKey: jest.fn().mockImplementation(() => ({})),
}));
const app = { locals: {} };
describe('google/initializeClient', () => {
afterEach(() => {
jest.clearAllMocks();
@ -23,6 +25,7 @@ describe('google/initializeClient', () => {
const req = {
body: { key: expiresAt },
user: { id: '123' },
app,
};
const res = {};
const endpointOption = { modelOptions: { model: 'default-model' } };
@ -44,6 +47,7 @@ describe('google/initializeClient', () => {
const req = {
body: { key: null },
user: { id: '123' },
app,
};
const res = {};
const endpointOption = { modelOptions: { model: 'default-model' } };
@ -66,6 +70,7 @@ describe('google/initializeClient', () => {
const req = {
body: { key: expiresAt },
user: { id: '123' },
app,
};
const res = {};
const endpointOption = { modelOptions: { model: 'default-model' } };

View file

@ -86,6 +86,9 @@ const initializeClient = async ({ req, res, endpointOption }) => {
clientOptions.titleModel = azureConfig.titleModel;
clientOptions.titleMethod = azureConfig.titleMethod ?? 'completion';
const azureRate = modelName.includes('gpt-4') ? 30 : 17;
clientOptions.streamRate = azureConfig.streamRate ?? azureRate;
const groupName = modelGroupMap[modelName].group;
clientOptions.addParams = azureConfig.groupMap[groupName].addParams;
clientOptions.dropParams = azureConfig.groupMap[groupName].dropParams;
@ -98,6 +101,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
apiKey = clientOptions.azure.azureOpenAIApiKey;
}
/** @type {undefined | TBaseEndpoint} */
const pluginsConfig = req.app.locals[EModelEndpoint.gptPlugins];
if (!useAzure && pluginsConfig) {
clientOptions.streamRate = pluginsConfig.streamRate;
}
/** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all;
if (allConfig) {
clientOptions.streamRate = allConfig.streamRate;
}
if (!apiKey) {
throw new Error(`${endpoint} API key not provided. Please provide it again.`);
}

View file

@ -76,6 +76,10 @@ const initializeClient = async ({ req, res, endpointOption }) => {
clientOptions.titleConvo = azureConfig.titleConvo;
clientOptions.titleModel = azureConfig.titleModel;
const azureRate = modelName.includes('gpt-4') ? 30 : 17;
clientOptions.streamRate = azureConfig.streamRate ?? azureRate;
clientOptions.titleMethod = azureConfig.titleMethod ?? 'completion';
const groupName = modelGroupMap[modelName].group;
@ -90,6 +94,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
apiKey = clientOptions.azure.azureOpenAIApiKey;
}
/** @type {undefined | TBaseEndpoint} */
const openAIConfig = req.app.locals[EModelEndpoint.openAI];
if (!isAzureOpenAI && openAIConfig) {
clientOptions.streamRate = openAIConfig.streamRate;
}
/** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all;
if (allConfig) {
clientOptions.streamRate = allConfig.streamRate;
}
if (userProvidesKey & !apiKey) {
throw new Error(
JSON.stringify({

View file

@ -15,37 +15,43 @@ const getCustomConfig = require('~/server/services/Config/getCustomConfig');
async function getCustomConfigSpeech(req, res) {
try {
const customConfig = await getCustomConfig();
const sttExternal = !!customConfig.speech?.stt;
const ttsExternal = !!customConfig.speech?.tts;
let settings = {
sttExternal,
ttsExternal,
};
if (!customConfig || !customConfig.speech?.speechTab) {
throw new Error('Configuration or speechTab schema is missing');
return res.status(200).send(settings);
}
const ttsSchema = customConfig.speech?.speechTab;
let settings = {};
const speechTab = customConfig.speech.speechTab;
if (ttsSchema.advancedMode !== undefined) {
settings.advancedMode = ttsSchema.advancedMode;
if (speechTab.advancedMode !== undefined) {
settings.advancedMode = speechTab.advancedMode;
}
if (ttsSchema.speechToText) {
for (const key in ttsSchema.speechToText) {
if (ttsSchema.speechToText[key] !== undefined) {
settings[key] = ttsSchema.speechToText[key];
if (speechTab.speechToText) {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
}
}
}
if (ttsSchema.textToSpeech) {
for (const key in ttsSchema.textToSpeech) {
if (ttsSchema.textToSpeech[key] !== undefined) {
settings[key] = ttsSchema.textToSpeech[key];
if (speechTab.textToSpeech) {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
}
}
}
return res.status(200).send(settings);
} catch (error) {
res.status(200).send();
console.error('Failed to get custom config speech settings:', error);
res.status(500).send('Internal Server Error');
}
}

View file

@ -1,5 +1,6 @@
const WebSocket = require('ws');
const { Message } = require('~/models/Message');
const { CacheKeys } = require('librechat-data-provider');
const { getLogStores } = require('~/cache');
/**
* @param {string[]} voiceIds - Array of voice IDs
@ -104,6 +105,8 @@ function createChunkProcessor(messageId) {
throw new Error('Message ID is required');
}
const messageCache = getLogStores(CacheKeys.MESSAGES);
/**
* @returns {Promise<{ text: string, isFinished: boolean }[] | string>}
*/
@ -116,14 +119,17 @@ function createChunkProcessor(messageId) {
return `No change in message after ${MAX_NO_CHANGE_COUNT} attempts`;
}
const message = await Message.findOne({ messageId }, 'text unfinished').lean();
/** @type { string | { text: string; complete: boolean } } */
const message = await messageCache.get(messageId);
if (!message || !message.text) {
if (!message) {
notFoundCount++;
return [];
}
const { text, unfinished } = message;
const text = typeof message === 'string' ? message : message.text;
const complete = typeof message === 'string' ? false : message.complete;
if (text === processedText) {
noChangeCount++;
}
@ -131,7 +137,7 @@ function createChunkProcessor(messageId) {
const remainingText = text.slice(processedText.length);
const chunks = [];
if (unfinished && remainingText.length >= 20) {
if (!complete && remainingText.length >= 20) {
const separatorIndex = findLastSeparatorIndex(remainingText);
if (separatorIndex !== -1) {
const chunkText = remainingText.slice(0, separatorIndex + 1);
@ -141,7 +147,7 @@ function createChunkProcessor(messageId) {
chunks.push({ text: remainingText, isFinished: false });
processedText = text;
}
} else if (!unfinished && remainingText.trim().length > 0) {
} else if (complete && remainingText.trim().length > 0) {
chunks.push({ text: remainingText.trim(), isFinished: true });
processedText = text;
}

View file

@ -1,89 +1,145 @@
const { createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
const { Message } = require('~/models/Message');
jest.mock('~/models/Message', () => ({
Message: {
findOne: jest.fn().mockReturnValue({
lean: jest.fn(),
}),
},
}));
jest.mock('keyv');
const globalCache = {};
jest.mock('~/cache/getLogStores', () => {
return jest.fn().mockImplementation(() => {
const EventEmitter = require('events');
const { CacheKeys } = require('librechat-data-provider');
class KeyvMongo extends EventEmitter {
constructor(url = 'mongodb://127.0.0.1:27017', options) {
super();
this.ttlSupport = false;
url = url ?? {};
if (typeof url === 'string') {
url = { url };
}
if (url.uri) {
url = { url: url.uri, ...url };
}
this.opts = {
url,
collection: 'keyv',
...url,
...options,
};
}
get = async (key) => {
return new Promise((resolve) => {
resolve(globalCache[key] || null);
});
};
set = async (key, value) => {
return new Promise((resolve) => {
globalCache[key] = value;
resolve(true);
});
};
}
return new KeyvMongo('', {
namespace: CacheKeys.MESSAGES,
ttl: 0,
});
});
});
describe('processChunks', () => {
let processChunks;
let mockMessageCache;
beforeEach(() => {
jest.resetAllMocks();
mockMessageCache = {
get: jest.fn(),
};
require('~/cache/getLogStores').mockReturnValue(mockMessageCache);
processChunks = createChunkProcessor('message-id');
Message.findOne.mockClear();
Message.findOne().lean.mockClear();
});
it('should return an empty array when the message is not found', async () => {
Message.findOne().lean.mockResolvedValueOnce(null);
mockMessageCache.get.mockResolvedValueOnce(null);
const result = await processChunks();
expect(result).toEqual([]);
expect(Message.findOne).toHaveBeenCalledWith({ messageId: 'message-id' }, 'text unfinished');
expect(Message.findOne().lean).toHaveBeenCalled();
expect(mockMessageCache.get).toHaveBeenCalledWith('message-id');
});
it('should return an empty array when the message does not have a text property', async () => {
Message.findOne().lean.mockResolvedValueOnce({ unfinished: true });
it('should return an error message after MAX_NOT_FOUND_COUNT attempts', async () => {
mockMessageCache.get.mockResolvedValue(null);
for (let i = 0; i < 6; i++) {
await processChunks();
}
const result = await processChunks();
expect(result).toEqual([]);
expect(Message.findOne).toHaveBeenCalledWith({ messageId: 'message-id' }, 'text unfinished');
expect(Message.findOne().lean).toHaveBeenCalled();
expect(result).toBe('Message not found after 6 attempts');
});
it('should return chunks for an unfinished message with separators', async () => {
it('should return chunks for an incomplete message with separators', async () => {
const messageText = 'This is a long message. It should be split into chunks. Lol hi mom';
Message.findOne().lean.mockResolvedValueOnce({ text: messageText, unfinished: true });
mockMessageCache.get.mockResolvedValueOnce({ text: messageText, complete: false });
const result = await processChunks();
expect(result).toEqual([
{ text: 'This is a long message. It should be split into chunks.', isFinished: false },
]);
expect(Message.findOne).toHaveBeenCalledWith({ messageId: 'message-id' }, 'text unfinished');
expect(Message.findOne().lean).toHaveBeenCalled();
});
it('should return chunks for an unfinished message without separators', async () => {
it('should return chunks for an incomplete message without separators', async () => {
const messageText = 'This is a long message without separators hello there my friend';
Message.findOne().lean.mockResolvedValueOnce({ text: messageText, unfinished: true });
mockMessageCache.get.mockResolvedValueOnce({ text: messageText, complete: false });
const result = await processChunks();
expect(result).toEqual([{ text: messageText, isFinished: false }]);
expect(Message.findOne).toHaveBeenCalledWith({ messageId: 'message-id' }, 'text unfinished');
expect(Message.findOne().lean).toHaveBeenCalled();
});
it('should return the remaining text as a chunk for a finished message', async () => {
it('should return the remaining text as a chunk for a complete message', async () => {
const messageText = 'This is a finished message.';
Message.findOne().lean.mockResolvedValueOnce({ text: messageText, unfinished: false });
mockMessageCache.get.mockResolvedValueOnce({ text: messageText, complete: true });
const result = await processChunks();
expect(result).toEqual([{ text: messageText, isFinished: true }]);
expect(Message.findOne).toHaveBeenCalledWith({ messageId: 'message-id' }, 'text unfinished');
expect(Message.findOne().lean).toHaveBeenCalled();
});
it('should return an empty array for a finished message with no remaining text', async () => {
it('should return an empty array for a complete message with no remaining text', async () => {
const messageText = 'This is a finished message.';
Message.findOne().lean.mockResolvedValueOnce({ text: messageText, unfinished: false });
mockMessageCache.get.mockResolvedValueOnce({ text: messageText, complete: true });
await processChunks();
Message.findOne().lean.mockResolvedValueOnce({ text: messageText, unfinished: false });
mockMessageCache.get.mockResolvedValueOnce({ text: messageText, complete: true });
const result = await processChunks();
expect(result).toEqual([]);
expect(Message.findOne).toHaveBeenCalledWith({ messageId: 'message-id' }, 'text unfinished');
expect(Message.findOne().lean).toHaveBeenCalledTimes(2);
});
it('should return an error message after MAX_NO_CHANGE_COUNT attempts with no change', async () => {
const messageText = 'This is a message that does not change.';
mockMessageCache.get.mockResolvedValue({ text: messageText, complete: false });
for (let i = 0; i < 11; i++) {
await processChunks();
}
const result = await processChunks();
expect(result).toBe('No change in message after 10 attempts');
});
it('should handle string messages as incomplete', async () => {
const messageText = 'This is a message as a string.';
mockMessageCache.get.mockResolvedValueOnce(messageText);
const result = await processChunks();
expect(result).toEqual([{ text: messageText, isFinished: false }]);
});
});

View file

@ -1,17 +1,19 @@
const throttle = require('lodash/throttle');
const {
Time,
CacheKeys,
StepTypes,
ContentTypes,
ToolCallTypes,
// StepStatus,
MessageContentTypes,
AssistantStreamEvents,
Constants,
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService');
const { saveMessage, updateMessageText } = require('~/models/Message');
const { createOnProgress, sendMessage } = require('~/server/utils');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { processMessages } = require('~/server/services/Threads');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
/**
@ -68,8 +70,8 @@ class StreamRunManager {
this.attachedFileIds = fields.attachedFileIds;
/** @type {undefined | Promise<ChatCompletion>} */
this.visionPromise = fields.visionPromise;
/** @type {boolean} */
this.savedInitialMessage = false;
/** @type {number} */
this.streamRate = fields.streamRate ?? Constants.DEFAULT_STREAM_RATE;
/**
* @type {Object.<AssistantStreamEvents, (event: AssistantStreamEvent) => Promise<void>>}
@ -139,11 +141,11 @@ class StreamRunManager {
return this.intermediateText;
}
/** Saves the initial intermediate message
* @returns {Promise<void>}
/** Returns the current, intermediate message
* @returns {TMessage}
*/
async saveInitialMessage() {
return saveMessage({
getIntermediateMessage() {
return {
conversationId: this.finalMessage.conversationId,
messageId: this.finalMessage.messageId,
parentMessageId: this.parentMessageId,
@ -155,7 +157,7 @@ class StreamRunManager {
sender: 'Assistant',
unfinished: true,
error: false,
});
};
}
/* <------------------ Main Event Handlers ------------------> */
@ -347,6 +349,8 @@ class StreamRunManager {
type: ContentTypes.TOOL_CALL,
index,
});
await sleep(this.streamRate);
}
};
@ -444,6 +448,7 @@ class StreamRunManager {
if (content && content.type === MessageContentTypes.TEXT) {
this.intermediateText += content.text.value;
onProgress(content.text.value);
await sleep(this.streamRate);
}
}
@ -589,21 +594,14 @@ class StreamRunManager {
const index = this.getStepIndex(stepKey);
this.orderedRunSteps.set(index, message_creation);
const messageCache = getLogStores(CacheKeys.MESSAGES);
// Create the Factory Function to stream the message
const { onProgress: progressCallback } = createOnProgress({
onProgress: throttle(
() => {
if (!this.savedInitialMessage) {
this.saveInitialMessage();
this.savedInitialMessage = true;
} else {
updateMessageText({
messageId: this.finalMessage.messageId,
text: this.getText(),
});
}
messageCache.set(this.finalMessage.messageId, this.getText(), Time.FIVE_MINUTES);
},
2000,
3000,
{ trailing: false },
),
});

View file

@ -51,6 +51,7 @@ function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) {
excludedIds: parsedConfig.excludedIds,
privateAssistants: parsedConfig.privateAssistants,
timeoutMs: parsedConfig.timeoutMs,
streamRate: parsedConfig.streamRate,
};
}

View file

@ -30,7 +30,8 @@ const sendMessage = (res, message, event = 'message') => {
/**
* Processes an error with provided options, saves the error message and sends a corresponding SSE response
* @async
* @param {object} res - The server response.
* @param {object} req - The request.
* @param {object} res - The response.
* @param {object} options - The options for handling the error containing message properties.
* @param {object} options.user - The user ID.
* @param {string} options.sender - The sender of the message.
@ -41,7 +42,7 @@ const sendMessage = (res, message, event = 'message') => {
* @param {boolean} options.shouldSaveMessage - [Optional] Whether the message should be saved. Default is true.
* @param {function} callback - [Optional] The callback function to be executed.
*/
const sendError = async (res, options, callback) => {
const sendError = async (req, res, options, callback) => {
const {
user,
sender,
@ -69,7 +70,7 @@ const sendError = async (res, options, callback) => {
}
if (shouldSaveMessage) {
await saveMessage({ ...errorMessage, user });
await saveMessage(req, { ...errorMessage, user });
}
if (!errorMessage.error) {
@ -97,11 +98,12 @@ const sendError = async (res, options, callback) => {
/**
* Sends the response based on whether headers have been sent or not.
* @param {Express.Request} req - The server response.
* @param {Express.Response} res - The server response.
* @param {Object} data - The data to be sent.
* @param {string} [errorMessage] - The error message, if any.
*/
const sendResponse = (res, data, errorMessage) => {
const sendResponse = (req, res, data, errorMessage) => {
if (!res.headersSent) {
if (errorMessage) {
return res.status(500).json({ error: errorMessage });
@ -110,7 +112,7 @@ const sendResponse = (res, data, errorMessage) => {
}
if (errorMessage) {
return sendError(res, { ...data, text: errorMessage });
return sendError(req, res, { ...data, text: errorMessage });
}
return sendMessage(res, data);
};

View file

@ -465,6 +465,12 @@
* @memberof typedefs
*/
/**
* @exports TBaseEndpoint
* @typedef {import('librechat-data-provider').TBaseEndpoint} TBaseEndpoint
* @memberof typedefs
*/
/**
* @exports TEndpoint
* @typedef {import('librechat-data-provider').TEndpoint} TEndpoint

View file

@ -4,16 +4,19 @@ import { ListeningIcon, Spinner } from '~/components/svg';
import { useLocalize, useSpeechToText } from '~/hooks';
import { useChatFormContext } from '~/Providers';
import { globalAudioId } from '~/common';
import { cn } from '~/utils';
export default function AudioRecorder({
textAreaRef,
methods,
ask,
isRTL,
disabled,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
methods: ReturnType<typeof useChatFormContext>;
ask: (data: { text: string }) => void;
isRTL: boolean;
disabled: boolean;
}) {
const localize = useLocalize();
@ -77,7 +80,12 @@ export default function AudioRecorder({
<button
onClick={isListening ? handleStopRecording : handleStartRecording}
disabled={disabled}
className="absolute bottom-1.5 right-12 flex h-[30px] w-[30px] items-center justify-center rounded-lg p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 md:bottom-3 md:right-12"
className={cn(
'absolute flex h-[30px] w-[30px] items-center justify-center rounded-lg p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700',
isRTL
? 'bottom-1.5 left-4 md:bottom-3 md:left-12'
: 'bottom-1.5 right-12 md:bottom-3 md:right-12',
)}
type="button"
>
{renderIcon()}

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));
@ -48,6 +48,9 @@ const ChatForm = ({ index = 0 }) => {
store.showMentionPopoverFamily(index),
);
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const { requiresKey } = useRequiresKey();
const handleKeyUp = useHandleKeyUp({
index,
@ -149,6 +152,7 @@ const ChatForm = ({ index = 0 }) => {
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => (
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
{children}
@ -179,7 +183,7 @@ const ChatForm = ({ index = 0 }) => {
? ' pl-10 md:pl-[55px]'
: 'pl-3 md:pl-4',
'm-0 w-full resize-none border-0 bg-transparent py-[10px] placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 ',
SpeechToText ? 'pr-20 md:pr-[85px]' : 'pr-10 md:pr-12',
SpeechToText && !isRTL ? 'pr-20 md:pr-[85px]' : 'pr-10 md:pr-12',
'max-h-[65vh] md:max-h-[75vh]',
removeFocusRings,
)}
@ -188,15 +192,21 @@ const ChatForm = ({ index = 0 }) => {
<AttachFile
endpoint={_endpoint ?? ''}
endpointType={endpointType}
isRTL={isRTL}
disabled={disableInputs}
/>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
<StopButton
stop={handleStopGenerating}
setShowStopButton={setShowStopButton}
isRTL={isRTL}
/>
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
isRTL={isRTL}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
/>
)
@ -206,6 +216,7 @@ const ChatForm = ({ index = 0 }) => {
disabled={!!disableInputs}
textAreaRef={textAreaRef}
ask={submitMessage}
isRTL={isRTL}
methods={methods}
/>
)}

View file

@ -9,14 +9,17 @@ import { useGetFileConfig } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg';
import { FileUpload } from '~/components/ui';
import { useFileHandling } from '~/hooks';
import { cn } from '~/utils';
const AttachFile = ({
endpoint,
endpointType,
isRTL,
disabled = false,
}: {
endpoint: EModelEndpoint | '';
endpointType?: EModelEndpoint;
isRTL: boolean;
disabled?: boolean | null;
}) => {
const { handleFileChange } = useFileHandling();
@ -30,7 +33,14 @@ const AttachFile = ({
}
return (
<div className="absolute bottom-2 left-2 md:bottom-3 md:left-4">
<div
className={cn(
'absolute',
isRTL
? 'bottom-2 right-14 md:bottom-3.5 md:right-3'
: 'bottom-2 left-2 md:bottom-3.5 md:left-4',
)}
>
<FileUpload handleFileChange={handleFileChange} className="flex">
<button
disabled={!!disabled}

View file

@ -13,6 +13,7 @@ export default function FileRow({
assistant_id,
tool_resource,
fileFilter,
isRTL,
Wrapper,
}: {
files: Map<string, ExtendedFile>;
@ -21,6 +22,7 @@ export default function FileRow({
fileFilter?: (file: ExtendedFile) => boolean;
assistant_id?: string;
tool_resource?: EToolResources;
isRTL?: boolean;
Wrapper?: React.FC<{ children: React.ReactNode }>;
}) {
const files = Array.from(_files.values()).filter((file) =>
@ -64,8 +66,11 @@ export default function FileRow({
}
const renderFiles = () => {
// Inline style for RTL
const rowStyle = isRTL ? { display: 'flex', flexDirection: 'row-reverse' } : {};
return (
<>
<div style={rowStyle as React.CSSProperties}>
{files
.reduce(
(acc, current) => {
@ -90,10 +95,9 @@ export default function FileRow({
/>
);
}
return <FileContainer key={index} file={file} onDelete={handleDelete} />;
})}
</>
</div>
);
};

View file

@ -9,42 +9,48 @@ import { cn } from '~/utils';
type SendButtonProps = {
disabled: boolean;
control: Control<{ text: string }>;
isRTL: boolean;
};
const SubmitButton = React.memo(
forwardRef((props: { disabled: boolean }, ref: React.ForwardedRef<HTMLButtonElement>) => {
const localize = useLocalize();
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
disabled={props.disabled}
className={cn(
'absolute bottom-1.5 right-2 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 md:bottom-3 md:right-3',
)}
data-testid="send-button"
type="submit"
>
<span className="" data-state="closed">
<SendIcon size={24} />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>
{localize('com_nav_send_message')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}),
forwardRef(
(props: { disabled: boolean; isRTL: boolean }, ref: React.ForwardedRef<HTMLButtonElement>) => {
const localize = useLocalize();
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
disabled={props.disabled}
className={cn(
'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',
)}
data-testid="send-button"
type="submit"
>
<span className="" data-state="closed">
<SendIcon size={24} />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>
{localize('com_nav_send_message')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
),
);
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} />;
return <SubmitButton ref={ref} disabled={props.disabled || !data?.text} isRTL={props.isRTL} />;
}),
);

View file

@ -1,6 +1,13 @@
export default function StopButton({ stop, setShowStopButton }) {
import { cn } from '~/utils';
export default function StopButton({ stop, setShowStopButton, isRTL }) {
return (
<div className="absolute bottom-3 right-2 md:bottom-4 md:right-4">
<div
className={cn(
'absolute',
isRTL ? 'bottom-3 left-2 md:bottom-4 md:left-4' : 'bottom-3 right-2 md:bottom-4 md:right-4',
)}
>
<button
type="button"
className="border-gizmo-gray-900 rounded-full border-2 p-1 dark:border-gray-200"

View file

@ -11,7 +11,7 @@ import {
TransitionChild,
} from '@headlessui/react';
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
import { General, Messages, Speech, Beta, Data, Account } from './SettingsTabs';
import { General, Chat, Speech, Beta, Data, Account } from './SettingsTabs';
import { useMediaQuery, useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -100,7 +100,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
@ -114,21 +114,21 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.MESSAGES}
value={SettingsTabValues.CHAT}
style={{ userSelect: 'none' }}
>
<MessageSquare className="icon-sm" />
{localize('com_endpoint_messages')}
{localize('com_nav_setting_chat')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
@ -142,7 +142,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
@ -156,7 +156,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
@ -170,7 +170,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
@ -185,7 +185,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.List>
<div className="max-h-[373px] overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<General />
<Messages />
<Chat />
<Beta />
<Speech />
<Data />

View file

@ -4,15 +4,19 @@ import { SettingsTabValues } from 'librechat-data-provider';
import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
import SaveDraft from './SaveDraft';
function Messages() {
function Chat() {
return (
<Tabs.Content value={SettingsTabValues.MESSAGES} role="tabpanel" className="md: w-full">
<Tabs.Content value={SettingsTabValues.CHAT} role="tabpanel" className="md: w-full">
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ChatDirection />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ShowCodeSwitch />
</div>
@ -25,4 +29,4 @@ function Messages() {
);
}
export default memo(Messages);
export default memo(Chat);

View file

@ -0,0 +1,31 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { useLocalize } from '~/hooks';
import store from '~/store';
const ChatDirection = () => {
const [direction, setDirection] = useRecoilState(store.chatDirection);
const localize = useLocalize();
const toggleChatDirection = () => {
setDirection((prev) => (prev === 'LTR' ? 'RTL' : 'LTR'));
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<span>{localize('com_nav_chat_direction')}</span>
</div>
<label
onClick={toggleChatDirection}
data-testid="chatDirection"
className="btn btn-neutral relative"
style={{ userSelect: 'none' }}
>
{direction.toLowerCase()}
</label>
</div>
);
};
export default ChatDirection;

View file

@ -10,15 +10,15 @@ export default function ConversationModeSwitch({
}) {
const localize = useLocalize();
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
const [speechToText] = useRecoilState<boolean>(store.speechToText);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const [, setAutoSendText] = useRecoilState<boolean>(store.autoSendText);
const speechToText = useRecoilState<boolean>(store.speechToText);
const textToSpeech = useRecoilState<boolean>(store.textToSpeech);
const [, setAutoSendText] = useRecoilState(store.autoSendText);
const [, setDecibelValue] = useRecoilState(store.decibelValue);
const [, setAutoTranscribeAudio] = useRecoilState<boolean>(store.autoTranscribeAudio);
const handleCheckedChange = (value: boolean) => {
setAutoTranscribeAudio(value);
setAutoSendText(value);
setAutoSendText(3);
setDecibelValue(-45);
setConversationMode(value);
if (onCheckedChange) {

View file

@ -0,0 +1,50 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { cn, defaultTextProps, optionText } from '~/utils/';
import { Slider, InputNumber } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AutoSendTextSelector() {
const localize = useLocalize();
const speechToText = useRecoilState<boolean>(store.speechToText);
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between">
<div>{localize('com_nav_auto_send_text')}</div>
<div className="w-2" />
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small>
</div>
<div className="flex items-center justify-between">
<Slider
value={[autoSendText ?? -1]}
onValueChange={(value) => setAutoSendText(value[0])}
doubleClickHandler={() => setAutoSendText(-1)}
min={-1}
max={60}
step={1}
className="ml-4 flex h-4 w-24"
disabled={!speechToText}
/>
<div className="w-2" />
<InputNumber
value={`${autoSendText} s`}
disabled={!speechToText}
onChange={(value) => setAutoSendText(value ? value[0] : 0)}
min={-1}
max={60}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
),
)}
/>
</div>
</div>
);
}

View file

@ -1,35 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AutoSendTextSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [autoSendText, setAutoSendText] = useRecoilState<boolean>(store.autoSendText);
const [SpeechToText] = useRecoilState<boolean>(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setAutoSendText(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_auto_send_text')}</div>
<Switch
id="AutoSendText"
checked={autoSendText}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutoSendText"
disabled={!SpeechToText}
/>
</div>
);
}

View file

@ -12,7 +12,7 @@ export default function AutoTranscribeAudioSwitch({
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
store.autoTranscribeAudio,
);
const [speechToText] = useRecoilState<boolean>(store.speechToText);
const speechToText = useRecoilState<boolean>(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setAutoTranscribeAudio(value);

View file

@ -1,5 +1,5 @@
import React from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilState } from 'recoil';
import { Slider, InputNumber } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -7,7 +7,7 @@ import { cn, defaultTextProps, optionText } from '~/utils/';
export default function DecibelSelector() {
const localize = useLocalize();
const speechToText = useRecoilValue(store.speechToText);
const speechToText = useRecoilState<boolean>(store.speechToText);
const [decibelValue, setDecibelValue] = useRecoilState(store.decibelValue);
return (

View file

@ -1,15 +1,23 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Dropdown } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function EngineSTTDropdown() {
interface EngineSTTDropdownProps {
external: boolean;
}
const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
const localize = useLocalize();
const [engineSTT, setEngineSTT] = useRecoilState<string>(store.engineSTT);
const endpointOptions = [
{ value: 'browser', display: localize('com_nav_browser') },
{ value: 'external', display: localize('com_nav_external') },
];
const endpointOptions = external
? [
{ value: 'browser', display: localize('com_nav_browser') },
{ value: 'external', display: localize('com_nav_external') },
]
: [{ value: 'browser', display: localize('com_nav_browser') }];
const handleSelect = (value: string) => {
setEngineSTT(value);
@ -28,4 +36,6 @@ export default function EngineSTTDropdown() {
/>
</div>
);
}
};
export default EngineSTTDropdown;

View file

@ -1,38 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render, fireEvent } from 'test/layout-test-utils';
import AutoSendTextSwitch from '../AutoSendTextSwitch';
import { RecoilRoot } from 'recoil';
describe('AutoSendTextSwitch', () => {
/**
* Mock function to set the auto-send-text state.
*/
let mockSetAutoSendText: jest.Mock<void, [boolean]> | ((value: boolean) => void) | undefined;
beforeEach(() => {
mockSetAutoSendText = jest.fn();
});
it('renders correctly', () => {
const { getByTestId } = render(
<RecoilRoot>
<AutoSendTextSwitch />
</RecoilRoot>,
);
expect(getByTestId('AutoSendText')).toBeInTheDocument();
});
it('calls onCheckedChange when the switch is toggled', () => {
const { getByTestId } = render(
<RecoilRoot>
<AutoSendTextSwitch onCheckedChange={mockSetAutoSendText} />
</RecoilRoot>,
);
const switchElement = getByTestId('AutoSendText');
fireEvent.click(switchElement);
expect(mockSetAutoSendText).toHaveBeenCalledWith(true);
});
});

View file

@ -1,4 +1,4 @@
export { default as AutoSendTextSwitch } from './AutoSendTextSwitch';
export { default as AutoSendTextSelector } from './AutoSendTextSelector';
export { default as SpeechToTextSwitch } from './SpeechToTextSwitch';
export { default as EngineSTTDropdown } from './EngineSTTDropdown';
export { default as DecibelSelector } from './DecibelSelector';

View file

@ -20,7 +20,7 @@ import {
AutoTranscribeAudioSwitch,
LanguageSTTDropdown,
SpeechToTextSwitch,
AutoSendTextSwitch,
AutoSendTextSelector,
EngineSTTDropdown,
DecibelSelector,
} from './STT';
@ -31,6 +31,8 @@ function Speech() {
const { data } = useGetCustomConfigSpeechQuery();
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const [sttExternal, setSttExternal] = useState(false);
const [ttsExternal, setTtsExternal] = useState(false);
const [advancedMode, setAdvancedMode] = useRecoilState(store.advancedMode);
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState(store.autoTranscribeAudio);
const [conversationMode, setConversationMode] = useRecoilState(store.conversationMode);
@ -53,6 +55,8 @@ function Speech() {
const updateSetting = useCallback(
(key, newValue) => {
const settings = {
sttExternal: { value: sttExternal, setFunc: setSttExternal },
ttsExternal: { value: ttsExternal, setFunc: setTtsExternal },
conversationMode: { value: conversationMode, setFunc: setConversationMode },
advancedMode: { value: advancedMode, setFunc: setAdvancedMode },
speechToText: { value: speechToText, setFunc: setSpeechToText },
@ -79,6 +83,8 @@ function Speech() {
setting.setFunc(newValue);
},
[
sttExternal,
ttsExternal,
conversationMode,
advancedMode,
speechToText,
@ -95,6 +101,8 @@ function Speech() {
languageTTS,
automaticPlayback,
playbackRate,
setSttExternal,
setTtsExternal,
setConversationMode,
setAdvancedMode,
setSpeechToText,
@ -123,6 +131,9 @@ function Speech() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
console.log(sttExternal);
console.log(ttsExternal);
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
@ -175,21 +186,21 @@ function Speech() {
<Tabs.Content value={'simple'}>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<SpeechToTextSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineSTTDropdown />
<EngineSTTDropdown external={sttExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<TextToSpeechSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown />
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
@ -199,15 +210,15 @@ function Speech() {
<Tabs.Content value={'advanced'}>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ConversationModeSwitch />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<SpeechToTextSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineSTTDropdown />
<EngineSTTDropdown external={sttExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
@ -220,8 +231,8 @@ function Speech() {
<DecibelSelector />
</div>
)}
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<AutoSendTextSwitch />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<AutoSendTextSelector />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
@ -231,7 +242,7 @@ function Speech() {
<AutomaticPlaybackSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown />
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />

View file

@ -1,15 +1,23 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Dropdown } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function EngineTTSDropdown() {
interface EngineTTSDropdownProps {
external: boolean;
}
const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
const localize = useLocalize();
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
const endpointOptions = [
{ value: 'browser', display: localize('com_nav_browser') },
{ value: 'external', display: localize('com_nav_external') },
];
const endpointOptions = external
? [
{ value: 'browser', display: localize('com_nav_browser') },
{ value: 'external', display: localize('com_nav_external') },
]
: [{ value: 'browser', display: localize('com_nav_browser') }];
const handleSelect = (value: string) => {
setEngineTTS(value);
@ -28,4 +36,6 @@ export default function EngineTTSDropdown() {
/>
</div>
);
}
};
export default EngineTTSDropdown;

View file

@ -1,5 +1,5 @@
export { default as General } from './General/General';
export { default as Messages } from './Messages/Messages';
export { default as Chat } from './Chat/Chat';
export { ClearChatsButton } from './General/General';
export { default as Data } from './Data/Data';
export { default as Beta } from './Beta/Beta';

View file

@ -1,10 +1,14 @@
import { useRecoilValue } from 'recoil';
import { forwardRef, useLayoutEffect, useState } from 'react';
import ReactTextareaAutosize from 'react-textarea-autosize';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
import store from '~/store';
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
(props, ref) => {
const [, setIsRerendered] = useState(false);
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
useLayoutEffect(() => setIsRerendered(true), []);
return <ReactTextareaAutosize dir="auto" {...props} ref={ref} />;
return <ReactTextareaAutosize dir={chatDirection} {...props} ref={ref} />;
},
);

View file

@ -10,7 +10,7 @@ const useSpeechToTextExternal = (onTranscriptionComplete: (text: string) => void
const { externalSpeechToText } = useGetAudioSettings();
const [speechToText] = useRecoilState<boolean>(store.speechToText);
const [autoTranscribeAudio] = useRecoilState<boolean>(store.autoTranscribeAudio);
const [autoSendText] = useRecoilState<boolean>(store.autoSendText);
const [autoSendText] = useRecoilState(store.autoSendText);
const [text, setText] = useState<string>('');
const [isListening, setIsListening] = useState(false);
const [permission, setPermission] = useState(false);
@ -27,10 +27,11 @@ const useSpeechToTextExternal = (onTranscriptionComplete: (text: string) => void
const extractedText = data.text;
setText(extractedText);
setIsRequestBeingMade(false);
if (autoSendText && speechToText && extractedText.length > 0) {
if (autoSendText > -1 && speechToText && extractedText.length > 0) {
setTimeout(() => {
onTranscriptionComplete(extractedText);
}, 3000);
}, autoSendText * 1000);
}
},
onError: () => {

View file

@ -112,7 +112,7 @@ export default function useTextarea({
? getAssistantName({ name: assistantName, localize })
: getSender(conversation as TEndpointOption);
return `${localize('com_endpoint_message')} ${sender ? sender : 'ChatGPT'}`;
return `${localize('com_endpoint_message')} ${sender ? sender : 'AI'}`;
};
const placeholder = getPlaceholderText();

View file

@ -469,7 +469,6 @@ export default {
com_ui_max_tags: 'الحد الأقصى المسموح به هو {0}، باستخدام أحدث القيم.',
com_auth_back_to_login: 'العودة إلى تسجيل الدخول',
com_endpoint_message: 'رسالة',
com_endpoint_messages: 'رسائل',
com_endpoint_message_not_appendable: 'عدّل رسالتك أو أعد إنشاءها.',
com_endpoint_context_tokens: 'الحد الأقصى لرموز السياق',
com_endpoint_context_info:
@ -2294,10 +2293,6 @@ export const comparisons = {
english: 'Message',
translated: 'رسالة',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'رسائل',
},
com_endpoint_message_not_appendable: {
english: 'Edit your message or Regenerate.',
translated: 'عدّل رسالتك أو أعد إنشاءها.',

View file

@ -565,7 +565,6 @@ export default {
com_ui_min_tags:
'Es können keine weiteren Werte entfernt werden, mindestens {0} sind erforderlich.',
com_ui_max_tags: 'Die maximal erlaubte Anzahl ist {0}, die neuesten Werte werden verwendet.',
com_endpoint_messages: 'Nachrichten',
com_endpoint_context_tokens: 'Max. Kontexttoken',
com_endpoint_context_info:
'Die maximale Anzahl an Token, die für den Kontext verwendet werden kann. Verwenden Sie dies, um zu steuern, wie viele Token pro Anfrage gesendet werden. Wenn nicht angegeben, werden systemseitige Standardwerte basierend auf der bekannten Kontextgröße der Modelle verwendet. Höhere Werte können zu Fehlern und/oder höheren Tokenkosten führen.',
@ -2603,10 +2602,6 @@ export const comparisons = {
english: 'Maximum number allowed is {0}, using latest values.',
translated: 'Die maximal erlaubte Anzahl ist {0}, die neuesten Werte werden verwendet.',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'Nachrichten',
},
com_endpoint_context_tokens: {
english: 'Max Context Tokens',
translated: 'Max. Kontexttoken',

View file

@ -374,7 +374,6 @@ export default {
'WARNING: Misuse of this feature can get you BANNED from using Bing! Click on \'System Message\' for full instructions and the default message if omitted, which is the \'Sydney\' preset that is considered safe.',
com_endpoint_system_message: 'System Message',
com_endpoint_message: 'Message',
com_endpoint_messages: 'Messages',
com_endpoint_message_not_appendable: 'Edit your message or Regenerate.',
com_endpoint_default_blank: 'default: blank',
com_endpoint_default_false: 'default: false',
@ -600,6 +599,7 @@ export default {
com_nav_enter_to_send: 'Press Enter to send messages',
com_nav_user_name_display: 'Display username in messages',
com_nav_save_drafts: 'Save drafts locally',
com_nav_chat_direction: 'Chat direction',
com_nav_show_code: 'Always show code when using code interpreter',
com_nav_auto_send_prompts: 'Auto-send Prompts',
com_nav_always_make_prod: 'Always make new versions production',
@ -631,7 +631,8 @@ export default {
com_nav_delete_warning: 'WARNING: This will permanently delete your account.',
com_nav_delete_data_info: 'All your data will be deleted.',
com_nav_conversation_mode: 'Conversation Mode',
com_nav_auto_send_text: 'Auto send text (after 3 sec)',
com_nav_auto_send_text: 'Auto send text',
com_nav_auto_send_text_disabled: 'set -1 to disable',
com_nav_auto_transcribe_audio: 'Auto transcribe audio',
com_nav_db_sensitivity: 'Decibel sensitivity',
com_nav_playback_rate: 'Audio Playback Rate',
@ -662,6 +663,7 @@ export default {
com_nav_info_delete_cache_storage:
'This action will delete all cached TTS (Text-to-Speech) audio files stored on your device. Cached audio files are used to speed up playback of previously generated TTS audio, but they can consume storage space on your device.',
com_nav_setting_general: 'General',
com_nav_setting_chat: 'Chat',
com_nav_setting_beta: 'Beta features',
com_nav_setting_data: 'Data controls',
com_nav_setting_account: 'Account',

View file

@ -539,7 +539,6 @@ export default {
com_ui_import_conversation_file_type_error:
'com_ui_import_conversation_file_type_error: Tipo de archivo no compatible para importar',
com_ui_min_tags: 'No se pueden eliminar más valores, se requiere un mínimo de {0}.',
com_endpoint_messages: 'Mensajes',
com_endpoint_context_tokens: 'Máximo de tokens de contexto',
com_endpoint_stop_placeholder: 'Separe los valores presionando `Intro`',
com_error_no_base_url:
@ -2513,10 +2512,6 @@ export const comparisons = {
english: 'Cannot remove more values, a minimum of {0} are required.',
translated: 'No se pueden eliminar más valores, se requiere un mínimo de {0}.',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'Mensajes',
},
com_endpoint_context_tokens: {
english: 'Max Context Tokens',
translated: 'Máximo de tokens de contexto',

File diff suppressed because it is too large Load diff

View file

@ -571,7 +571,6 @@ export default {
com_ui_min_tags: 'Impossible de supprimer plus de valeurs, un minimum de {0} est requis.',
com_ui_max_tags: 'Le nombre maximum autorisé est {0}, en utilisant les dernières valeurs.',
com_auth_back_to_login: 'Retour à la connexion',
com_endpoint_messages: 'Messages',
com_endpoint_context_tokens: 'Jetons de contexte maximum',
com_endpoint_context_info:
'Le nombre maximum de jetons qui peuvent être utilisés pour le contexte. Utilisez ceci pour contrôler le nombre de jetons envoyés par requête. Si non spécifié, les valeurs par défaut du système seront utilisées en fonction de la taille de contexte connue des modèles. Définir des valeurs plus élevées peut entraîner des erreurs et/ou un coût en jetons plus élevé.',
@ -2733,10 +2732,6 @@ export const comparisons = {
english: 'Back to Login',
translated: 'Retour à la connexion',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'Messages',
},
com_endpoint_context_tokens: {
english: 'Max Context Tokens',
translated: 'Jetons de contexte maximum',

View file

@ -293,7 +293,6 @@ export default {
'ATTENZIONE: L\'uso improprio di questa funzione può farti BANNARE dall\'utilizzo di Bing! Clicca su "Messaggio di sistema" per le istruzioni complete e il messaggio predefinito se omesso, che è il preset "Sydney" considerato sicuro.',
com_endpoint_system_message: 'Messaggio di sistema',
com_endpoint_message: 'Messaggio',
com_endpoint_messages: 'Messaggi',
com_endpoint_message_not_appendable: 'Modifica il tuo messaggio o Rigenera.',
com_endpoint_default_blank: 'predefinito: vuoto',
com_endpoint_default_false: 'predefinito: falso',
@ -1649,10 +1648,6 @@ export const comparisons = {
english: 'Message',
translated: 'Messaggio',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'Messaggi',
},
com_endpoint_message_not_appendable: {
english: 'Edit your message or Regenerate.',
translated: 'Modifica il tuo messaggio o Rigenera.',

View file

@ -544,7 +544,6 @@ export default {
com_ui_mention:
'エンドポイント、アシスタント、またはプリセットを素早く切り替えるには、それらを言及してください。',
com_ui_import_conversation_file_type_error: 'サポートされていないインポート形式です',
com_endpoint_messages: 'メッセージ',
com_endpoint_context_tokens: 'コンテキストトークン数の最大値',
com_endpoint_context_info:
'コンテキストに使用できるトークンの最大数です。リクエストごとに送信されるトークン数を制御するために使用します。指定しない場合は、既知のモデルのコンテキストサイズに基づいてシステムのデフォルト値が使用されます。高い値を設定すると、エラーが発生したり、トークンコストが高くなる可能性があります。',
@ -2568,10 +2567,6 @@ export const comparisons = {
english: 'Unsupported import type',
translated: 'サポートされていないインポート形式です',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'メッセージ',
},
com_endpoint_context_tokens: {
english: 'Max Context Tokens',
translated: 'コンテキストトークン数の最大値',

View file

@ -455,7 +455,6 @@ export default {
com_auth_error_login_server: '내부 서버 오류가 발생했습니다. 잠시 기다렸다가 다시 시도해 주세요.',
com_auth_back_to_login: '로그인 화면으로 돌아가기',
com_endpoint_message: '메시지',
com_endpoint_messages: '메시지',
com_endpoint_message_not_appendable: '메시지를 수정하거나 다시 생성하세요.',
com_endpoint_context_tokens: '최대 컨텍스트 토큰 수',
com_endpoint_context_info:
@ -2257,10 +2256,6 @@ export const comparisons = {
english: 'Message',
translated: '메시지',
},
com_endpoint_messages: {
english: 'Messages',
translated: '메시지',
},
com_endpoint_message_not_appendable: {
english: 'Edit your message or Regenerate.',
translated: '메시지를 수정하거나 다시 생성하세요.',

View file

@ -518,7 +518,6 @@ export default {
com_ui_terms_of_service: 'Условия использования',
com_ui_min_tags: 'Нельзя удалить больше значений, требуется минимум {0}.',
com_ui_max_tags: 'Максимально допустимое количество - {0}, используются последние значения.',
com_endpoint_messages: 'Сообщения',
com_endpoint_context_tokens: 'Максимальное количество контекстных токенов',
com_endpoint_context_info:
'Максимальное количество токенов, которое может быть использовано для контекста. Используется для контроля количества токенов, отправляемых за один запрос. Если не указано, будут использованы системные значения по умолчанию, основанные на известном размере контекста моделей. Установка более высоких значений может привести к ошибкам и/или более высокой стоимости токенов.',
@ -2466,10 +2465,6 @@ export const comparisons = {
english: 'Maximum number allowed is {0}, using latest values.',
translated: 'Максимально допустимое количество - {0}, используются последние значения.',
},
com_endpoint_messages: {
english: 'Messages',
translated: 'Сообщения',
},
com_endpoint_context_tokens: {
english: 'Max Context Tokens',
translated: 'Максимальное количество контекстных токенов',

View file

@ -507,7 +507,6 @@ export default {
com_ui_import_conversation_file_type_error: '不支持的导入类型',
com_ui_min_tags: '无法再移除更多值,至少需要保留{0}个。',
com_ui_max_tags: '最多允许{0}个,使用最新值。',
com_endpoint_messages: '消息',
com_endpoint_context_tokens: '最大上下文词元数',
com_endpoint_context_info:
'可用于上下文的最大词元数。用于控制每个请求发送的词元数量。如果未指定,将根据已知模型的上下文大小使用系统默认值。设置较高的值可能会导致错误和/或更高的词元成本。',
@ -2501,10 +2500,6 @@ export const comparisons = {
english: 'Maximum number allowed is {0}, using latest values.',
translated: '最多允许{0}个,使用最新值。',
},
com_endpoint_messages: {
english: 'Messages',
translated: '消息',
},
com_endpoint_context_tokens: {
english: 'Max Context Tokens',
translated: '最大上下文词元数',

View file

@ -446,7 +446,6 @@ export default {
com_ui_max_tags: '允許的最大數量為 {0},已使用最新值。',
com_auth_back_to_login: '返回登入',
com_endpoint_message: '訊息',
com_endpoint_messages: '訊息',
com_endpoint_message_not_appendable: '無法附加訊息或重新生成。',
com_endpoint_context_tokens: '最大前後文 token 數',
com_endpoint_context_info:
@ -2264,10 +2263,6 @@ export const comparisons = {
english: 'Message',
translated: '訊息',
},
com_endpoint_messages: {
english: 'Messages',
translated: '訊息',
},
com_endpoint_message_not_appendable: {
english: 'Edit your message or Regenerate.',
translated: '無法附加訊息或重新生成。',

View file

@ -951,10 +951,6 @@ Write a prompt that is mindful of the nuances in the language with respect to it
- **english**: Message
- **translated**: Messaggio
- **com_endpoint_messages**:
- **english**: Messages
- **translated**: Messaggi
- **com_endpoint_message_not_appendable**:
- **english**: Edit your message or Regenerate.
- **translated**: Modifica il tuo messaggio o Rigenera.

View file

@ -25,6 +25,7 @@ const localStorageAtoms = {
// Messages settings
enterToSend: atomWithLocalStorage('enterToSend', true),
chatDirection: atomWithLocalStorage('chatDirection', 'LTR'),
showCode: atomWithLocalStorage('showCode', false),
saveDrafts: atomWithLocalStorage('saveDrafts', false),
forkSetting: atomWithLocalStorage('forkSetting', ''),
@ -45,7 +46,7 @@ const localStorageAtoms = {
languageSTT: atomWithLocalStorage('languageSTT', ''),
autoTranscribeAudio: atomWithLocalStorage('autoTranscribeAudio', false),
decibelValue: atomWithLocalStorage('decibelValue', -45),
autoSendText: atomWithLocalStorage('autoSendText', false),
autoSendText: atomWithLocalStorage('autoSendText', -1),
textToSpeech: atomWithLocalStorage('textToSpeech', true),
engineTTS: atomWithLocalStorage('engineTTS', 'browser'),

View file

@ -1058,6 +1058,7 @@ button {
border-color:transparent;
border-radius:.5rem;
border-width:1px;
cursor: pointer;
display:inline-flex;
font-size:.875rem;
font-weight:500;
@ -2182,4 +2183,4 @@ ol ol):not(:where([class~=not-prose] *)) {
.move-up {
animation: moveUp 4s ease-in-out infinite;
}
}

2
package-lock.json generated
View file

@ -29437,7 +29437,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.1",
"version": "0.7.2",
"license": "ISC",
"dependencies": {
"@types/js-yaml": "^4.0.9",

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.1",
"version": "0.7.2",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

View file

@ -136,71 +136,81 @@ export const defaultAssistantsVersion = {
[EModelEndpoint.azureAssistants]: 1,
};
export const assistantEndpointSchema = z.object({
/* assistants specific */
disableBuilder: z.boolean().optional(),
pollIntervalMs: z.number().optional(),
timeoutMs: z.number().optional(),
version: z.union([z.string(), z.number()]).default(2),
supportedIds: z.array(z.string()).min(1).optional(),
excludedIds: z.array(z.string()).min(1).optional(),
privateAssistants: z.boolean().optional(),
retrievalModels: z.array(z.string()).min(1).optional().default(defaultRetrievalModels),
capabilities: z
.array(z.nativeEnum(Capabilities))
.optional()
.default([
Capabilities.code_interpreter,
Capabilities.image_vision,
Capabilities.retrieval,
Capabilities.actions,
Capabilities.tools,
]),
/* general */
apiKey: z.string().optional(),
baseURL: z.string().optional(),
models: z
.object({
default: z.array(z.string()).min(1),
fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(),
})
.optional(),
titleConvo: z.boolean().optional(),
titleMethod: z.union([z.literal('completion'), z.literal('functions')]).optional(),
titleModel: z.string().optional(),
headers: z.record(z.any()).optional(),
export const baseEndpointSchema = z.object({
streamRate: z.number().optional(),
});
export type TBaseEndpoint = z.infer<typeof baseEndpointSchema>;
export const assistantEndpointSchema = baseEndpointSchema.merge(
z.object({
/* assistants specific */
disableBuilder: z.boolean().optional(),
pollIntervalMs: z.number().optional(),
timeoutMs: z.number().optional(),
version: z.union([z.string(), z.number()]).default(2),
supportedIds: z.array(z.string()).min(1).optional(),
excludedIds: z.array(z.string()).min(1).optional(),
privateAssistants: z.boolean().optional(),
retrievalModels: z.array(z.string()).min(1).optional().default(defaultRetrievalModels),
capabilities: z
.array(z.nativeEnum(Capabilities))
.optional()
.default([
Capabilities.code_interpreter,
Capabilities.image_vision,
Capabilities.retrieval,
Capabilities.actions,
Capabilities.tools,
]),
/* general */
apiKey: z.string().optional(),
baseURL: z.string().optional(),
models: z
.object({
default: z.array(z.string()).min(1),
fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(),
})
.optional(),
titleConvo: z.boolean().optional(),
titleMethod: z.union([z.literal('completion'), z.literal('functions')]).optional(),
titleModel: z.string().optional(),
headers: z.record(z.any()).optional(),
}),
);
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
export const endpointSchema = z.object({
name: z.string().refine((value) => !eModelEndpointSchema.safeParse(value).success, {
message: `Value cannot be one of the default endpoint (EModelEndpoint) values: ${Object.values(
EModelEndpoint,
).join(', ')}`,
export const endpointSchema = baseEndpointSchema.merge(
z.object({
name: z.string().refine((value) => !eModelEndpointSchema.safeParse(value).success, {
message: `Value cannot be one of the default endpoint (EModelEndpoint) values: ${Object.values(
EModelEndpoint,
).join(', ')}`,
}),
apiKey: z.string(),
baseURL: z.string(),
models: z.object({
default: z.array(z.string()).min(1),
fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(),
}),
titleConvo: z.boolean().optional(),
titleMethod: z.union([z.literal('completion'), z.literal('functions')]).optional(),
titleModel: z.string().optional(),
summarize: z.boolean().optional(),
summaryModel: z.string().optional(),
forcePrompt: z.boolean().optional(),
modelDisplayLabel: z.string().optional(),
headers: z.record(z.any()).optional(),
addParams: z.record(z.any()).optional(),
dropParams: z.array(z.string()).optional(),
customOrder: z.number().optional(),
directEndpoint: z.boolean().optional(),
titleMessageRole: z.string().optional(),
}),
apiKey: z.string(),
baseURL: z.string(),
models: z.object({
default: z.array(z.string()).min(1),
fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(),
}),
titleConvo: z.boolean().optional(),
titleMethod: z.union([z.literal('completion'), z.literal('functions')]).optional(),
titleModel: z.string().optional(),
summarize: z.boolean().optional(),
summaryModel: z.string().optional(),
forcePrompt: z.boolean().optional(),
modelDisplayLabel: z.string().optional(),
headers: z.record(z.any()).optional(),
addParams: z.record(z.any()).optional(),
dropParams: z.array(z.string()).optional(),
customOrder: z.number().optional(),
directEndpoint: z.boolean().optional(),
titleMessageRole: z.string().optional(),
});
);
export type TEndpoint = z.infer<typeof endpointSchema>;
@ -213,6 +223,7 @@ export const azureEndpointSchema = z
.and(
endpointSchema
.pick({
streamRate: true,
titleConvo: true,
titleMethod: true,
titleModel: true,
@ -304,7 +315,7 @@ const speechTab = z
languageSTT: z.string().optional(),
autoTranscribeAudio: z.boolean().optional(),
decibelValue: z.number().optional(),
autoSendText: z.boolean().optional(),
autoSendText: z.number().optional(),
}),
)
.optional(),
@ -426,10 +437,15 @@ export const configSchema = z.object({
modelSpecs: specsConfigSchema.optional(),
endpoints: z
.object({
all: baseEndpointSchema.optional(),
[EModelEndpoint.openAI]: baseEndpointSchema.optional(),
[EModelEndpoint.google]: baseEndpointSchema.optional(),
[EModelEndpoint.anthropic]: baseEndpointSchema.optional(),
[EModelEndpoint.gptPlugins]: baseEndpointSchema.optional(),
[EModelEndpoint.azureOpenAI]: azureEndpointSchema.optional(),
[EModelEndpoint.azureAssistants]: assistantEndpointSchema.optional(),
[EModelEndpoint.assistants]: assistantEndpointSchema.optional(),
custom: z.array(endpointSchema.partial()).optional(),
[EModelEndpoint.custom]: z.array(endpointSchema.partial()).optional(),
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@ -657,6 +673,16 @@ export enum InfiniteCollections {
SHARED_LINKS = 'sharedLinks',
}
/**
* Enum for time intervals
*/
export enum Time {
THIRTY_MINUTES = 1800000,
TEN_MINUTES = 600000,
FIVE_MINUTES = 300000,
TWO_MINUTES = 120000,
}
/**
* Enum for cache keys.
*/
@ -727,6 +753,10 @@ export enum CacheKeys {
* Key for the cached audio run Ids.
*/
AUDIO_RUNS = 'audioRuns',
/**
* Key for in-progress messages.
*/
MESSAGES = 'messages',
}
/**
@ -842,9 +872,9 @@ export enum SettingsTabValues {
*/
GENERAL = 'general',
/**
* Tab for Messages Settings
* Tab for Chat Settings
*/
MESSAGES = 'messages',
CHAT = 'chat',
/**
* Tab for Speech Settings
*/
@ -911,6 +941,8 @@ export enum Constants {
COMMON_DIVIDER = '__',
/** Max length for commands */
COMMANDS_MAX_LENGTH = 56,
/** Default Stream Rate (ms) */
DEFAULT_STREAM_RATE = 1,
}
export enum LocalStorageKeys {