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

This commit is contained in:
Marco Beretta 2024-07-30 05:36:43 -04:00 committed by GitHub
commit c27e8566fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 5856 additions and 1334 deletions

View file

@ -144,7 +144,7 @@ GOOGLE_KEY=user_provided
#============#
OPENAI_API_KEY=user_provided
# OPENAI_MODELS=gpt-4o,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
# OPENAI_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
DEBUG_OPENAI=false
@ -166,7 +166,7 @@ DEBUG_OPENAI=false
ASSISTANTS_API_KEY=user_provided
# ASSISTANTS_BASE_URL=
# ASSISTANTS_MODELS=gpt-4o,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
# ASSISTANTS_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
#==========================#
# Azure Assistants API #
@ -188,7 +188,7 @@ ASSISTANTS_API_KEY=user_provided
# Plugins #
#============#
# PLUGIN_MODELS=gpt-4o,gpt-4,gpt-4-turbo-preview,gpt-4-0125-preview,gpt-4-1106-preview,gpt-4-0613,gpt-3.5-turbo,gpt-3.5-turbo-0125,gpt-3.5-turbo-1106,gpt-3.5-turbo-0613
# PLUGIN_MODELS=gpt-4o,gpt-4o-mini,gpt-4,gpt-4-turbo-preview,gpt-4-0125-preview,gpt-4-1106-preview,gpt-4-0613,gpt-3.5-turbo,gpt-3.5-turbo-0125,gpt-3.5-turbo-1106,gpt-3.5-turbo-0613
DEBUG_PLUGINS=true
@ -374,6 +374,8 @@ LDAP_BIND_CREDENTIALS=
LDAP_USER_SEARCH_BASE=
LDAP_SEARCH_FILTER=mail={{username}}
LDAP_CA_CERT_PATH=
# LDAP_TLS_REJECT_UNAUTHORIZED=
# LDAP_LOGIN_USES_USERNAME=true
# LDAP_ID=
# LDAP_USERNAME=
# LDAP_FULL_NAME=

View file

@ -50,7 +50,7 @@
- 🔄 Edit, Resubmit, and Continue Messages with Conversation branching
- 🌿 Fork Messages & Conversations for Advanced Context control
- 💬 Multimodal Chat:
- Upload and analyze images with Claude 3, GPT-4 (including `gpt-4o`), and Gemini Vision 📸
- Upload and analyze images with Claude 3, GPT-4 (including `gpt-4o` and `gpt-4o-mini`), and Gemini Vision 📸
- Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, & Google. 🗃️
- Advanced Agents with Files, Code Interpreter, Tools, and API Actions 🔦
- Available through the [OpenAI Assistants API](https://platform.openai.com/docs/assistants/overview) 🌤️

View file

@ -4,6 +4,7 @@ const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = requ
const {
Constants,
EModelEndpoint,
anthropicSettings,
getResponseSender,
validateVisionModel,
} = require('librechat-data-provider');
@ -31,6 +32,8 @@ function delayBeforeRetry(attempts, baseDelay = 1000) {
return new Promise((resolve) => setTimeout(resolve, baseDelay * attempts));
}
const { legacy } = anthropicSettings;
class AnthropicClient extends BaseClient {
constructor(apiKey, options = {}) {
super(apiKey, options);
@ -63,15 +66,20 @@ class AnthropicClient extends BaseClient {
const modelOptions = this.options.modelOptions || {};
this.modelOptions = {
...modelOptions,
// set some good defaults (check for undefined in some cases because they may be 0)
model: modelOptions.model || 'claude-1',
temperature: typeof modelOptions.temperature === 'undefined' ? 1 : modelOptions.temperature, // 0 - 1, 1 is default
topP: typeof modelOptions.topP === 'undefined' ? 0.7 : modelOptions.topP, // 0 - 1, default: 0.7
topK: typeof modelOptions.topK === 'undefined' ? 40 : modelOptions.topK, // 1-40, default: 40
stop: modelOptions.stop, // no stop method for now
model: modelOptions.model || anthropicSettings.model.default,
};
this.isClaude3 = this.modelOptions.model.includes('claude-3');
this.isLegacyOutput = !this.modelOptions.model.includes('claude-3-5-sonnet');
if (
this.isLegacyOutput &&
this.modelOptions.maxOutputTokens &&
this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default
) {
this.modelOptions.maxOutputTokens = legacy.maxOutputTokens.default;
}
this.useMessages = this.isClaude3 || !!this.options.attachments;
this.defaultVisionModel = this.options.visionModel ?? 'claude-3-sonnet-20240229';
@ -121,10 +129,11 @@ class AnthropicClient extends BaseClient {
/**
* Get the initialized Anthropic client.
* @param {Partial<Anthropic.ClientOptions>} requestOptions - The options for the client.
* @returns {Anthropic} The Anthropic client instance.
*/
getClient() {
/** @type {Anthropic.default.RequestOptions} */
getClient(requestOptions) {
/** @type {Anthropic.ClientOptions} */
const options = {
fetch: this.fetch,
apiKey: this.apiKey,
@ -138,6 +147,12 @@ class AnthropicClient extends BaseClient {
options.baseURL = this.options.reverseProxyUrl;
}
if (requestOptions?.model && requestOptions.model.includes('claude-3-5-sonnet')) {
options.defaultHeaders = {
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
};
}
return new Anthropic(options);
}
@ -558,8 +573,6 @@ class AnthropicClient extends BaseClient {
}
logger.debug('modelOptions', { modelOptions });
const client = this.getClient();
const metadata = {
user_id: this.user,
};
@ -587,7 +600,7 @@ class AnthropicClient extends BaseClient {
if (this.useMessages) {
requestOptions.messages = payload;
requestOptions.max_tokens = maxOutputTokens || 1500;
requestOptions.max_tokens = maxOutputTokens || legacy.maxOutputTokens.default;
} else {
requestOptions.prompt = payload;
requestOptions.max_tokens_to_sample = maxOutputTokens || 1500;
@ -614,6 +627,7 @@ class AnthropicClient extends BaseClient {
while (attempts < maxRetries) {
let response;
try {
const client = this.getClient(requestOptions);
response = await this.createResponse(client, requestOptions);
signal.addEventListener('abort', () => {
@ -742,7 +756,11 @@ class AnthropicClient extends BaseClient {
};
try {
const response = await this.createResponse(this.getClient(), requestOptions, true);
const response = await this.createResponse(
this.getClient(requestOptions),
requestOptions,
true,
);
let promptTokens = response?.usage?.input_tokens;
let completionTokens = response?.usage?.output_tokens;
if (!promptTokens) {

View file

@ -540,6 +540,9 @@ class BaseClient {
const completionTokens = this.getTokenCount(completion);
await this.recordTokenUsage({ promptTokens, completionTokens });
}
if (this.userMessagePromise) {
await this.userMessagePromise;
}
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
const messageCache = getLogStores(CacheKeys.MESSAGES);
messageCache.set(
@ -612,22 +615,31 @@ class BaseClient {
throw new Error('User mismatch.');
}
const savedMessage = await saveMessage(this.options.req, {
...message,
endpoint: this.options.endpoint,
unfinished: false,
user,
});
const savedMessage = await saveMessage(
this.options.req,
{
...message,
endpoint: this.options.endpoint,
unfinished: false,
user,
},
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
);
if (this.skipSaveConvo) {
return { message: savedMessage };
}
const conversation = await saveConvo(user, {
conversationId: message.conversationId,
endpoint: this.options.endpoint,
endpointType: this.options.endpointType,
...endpointOptions,
});
const conversation = await saveConvo(
this.options.req,
{
conversationId: message.conversationId,
endpoint: this.options.endpoint,
endpointType: this.options.endpointType,
...endpointOptions,
},
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo' },
);
return { message: savedMessage, conversation };
}

View file

@ -1,4 +1,6 @@
const AnthropicClient = require('../AnthropicClient');
const { anthropicSettings } = require('librechat-data-provider');
const AnthropicClient = require('~/app/clients/AnthropicClient');
const HUMAN_PROMPT = '\n\nHuman:';
const AI_PROMPT = '\n\nAssistant:';
@ -22,7 +24,7 @@ describe('AnthropicClient', () => {
const options = {
modelOptions: {
model,
temperature: 0.7,
temperature: anthropicSettings.temperature.default,
},
};
client = new AnthropicClient('test-api-key');
@ -33,7 +35,42 @@ describe('AnthropicClient', () => {
it('should set the options correctly', () => {
expect(client.apiKey).toBe('test-api-key');
expect(client.modelOptions.model).toBe(model);
expect(client.modelOptions.temperature).toBe(0.7);
expect(client.modelOptions.temperature).toBe(anthropicSettings.temperature.default);
});
it('should set legacy maxOutputTokens for non-Claude-3 models', () => {
const client = new AnthropicClient('test-api-key');
client.setOptions({
modelOptions: {
model: 'claude-2',
maxOutputTokens: anthropicSettings.maxOutputTokens.default,
},
});
expect(client.modelOptions.maxOutputTokens).toBe(
anthropicSettings.legacy.maxOutputTokens.default,
);
});
it('should not set maxOutputTokens if not provided', () => {
const client = new AnthropicClient('test-api-key');
client.setOptions({
modelOptions: {
model: 'claude-3',
},
});
expect(client.modelOptions.maxOutputTokens).toBeUndefined();
});
it('should not set legacy maxOutputTokens for Claude-3 models', () => {
const client = new AnthropicClient('test-api-key');
client.setOptions({
modelOptions: {
model: 'claude-3-opus-20240229',
maxOutputTokens: anthropicSettings.legacy.maxOutputTokens.default,
},
});
expect(client.modelOptions.maxOutputTokens).toBe(
anthropicSettings.legacy.maxOutputTokens.default,
);
});
});
@ -136,4 +173,57 @@ describe('AnthropicClient', () => {
expect(prompt).toContain('You are Claude-2');
});
});
describe('getClient', () => {
it('should set legacy maxOutputTokens for non-Claude-3 models', () => {
const client = new AnthropicClient('test-api-key');
client.setOptions({
modelOptions: {
model: 'claude-2',
maxOutputTokens: anthropicSettings.legacy.maxOutputTokens.default,
},
});
expect(client.modelOptions.maxOutputTokens).toBe(
anthropicSettings.legacy.maxOutputTokens.default,
);
});
it('should not set legacy maxOutputTokens for Claude-3 models', () => {
const client = new AnthropicClient('test-api-key');
client.setOptions({
modelOptions: {
model: 'claude-3-opus-20240229',
maxOutputTokens: anthropicSettings.legacy.maxOutputTokens.default,
},
});
expect(client.modelOptions.maxOutputTokens).toBe(
anthropicSettings.legacy.maxOutputTokens.default,
);
});
it('should add beta header for claude-3-5-sonnet model', () => {
const client = new AnthropicClient('test-api-key');
const modelOptions = {
model: 'claude-3-5-sonnet-20240307',
};
client.setOptions({ modelOptions });
const anthropicClient = client.getClient(modelOptions);
expect(anthropicClient._options.defaultHeaders).toBeDefined();
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
'max-tokens-3-5-sonnet-2024-07-15',
);
});
it('should not add beta header for other models', () => {
const client = new AnthropicClient('test-api-key');
client.setOptions({
modelOptions: {
model: 'claude-2',
},
});
const anthropicClient = client.getClient();
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
});
});
});

View file

@ -1,7 +1,7 @@
const { Constants } = require('librechat-data-provider');
const { initializeFakeClient } = require('./FakeClient');
jest.mock('../../../lib/db/connectDb');
jest.mock('~/lib/db/connectDb');
jest.mock('~/models', () => ({
User: jest.fn(),
Key: jest.fn(),
@ -631,5 +631,32 @@ describe('BaseClient', () => {
}),
);
});
test('userMessagePromise is awaited before saving response message', async () => {
// Mock the saveMessageToDatabase method
TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {
return new Promise((resolve) => setTimeout(resolve, 100)); // Simulate a delay
});
// Send a message
const messagePromise = TestClient.sendMessage('Hello, world!');
// Wait a short time to ensure the user message save has started
await new Promise((resolve) => setTimeout(resolve, 50));
// Check that saveMessageToDatabase has been called once (for the user message)
expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(1);
// Wait for the message to be fully processed
await messagePromise;
// Check that saveMessageToDatabase has been called twice (once for user message, once for response)
expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(2);
// Check the order of calls
const calls = TestClient.saveMessageToDatabase.mock.calls;
expect(calls[0][0].isCreatedByUser).toBe(true); // First call should be for user message
expect(calls[1][0].isCreatedByUser).toBe(false); // Second call should be for response message
});
});
});

View file

@ -109,6 +109,14 @@ const condenseArray = (item) => {
* @returns {string} - The formatted log message.
*/
const debugTraverse = winston.format.printf(({ level, message, timestamp, ...metadata }) => {
if (!message) {
return `${timestamp} ${level}`;
}
if (!message?.trim || typeof message !== 'string') {
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
}
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`;
try {
if (level !== 'debug') {

View file

@ -19,22 +19,39 @@ const getConvo = async (user, conversationId) => {
module.exports = {
Conversation,
saveConvo: async (user, { conversationId, newConversationId, ...convo }) => {
/**
* Saves a conversation to the database.
* @param {Object} req - The request object.
* @param {string} conversationId - The conversation's ID.
* @param {Object} metadata - Additional metadata to log for operation.
* @returns {Promise<TConversation>} The conversation object.
*/
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
try {
if (metadata && metadata?.context) {
logger.debug(`[saveConvo] ${metadata.context}`);
}
const messages = await getMessages({ conversationId }, '_id');
const update = { ...convo, messages, user };
const update = { ...convo, messages, user: req.user.id };
if (newConversationId) {
update.conversationId = newConversationId;
}
const conversation = await Conversation.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: true,
});
const conversation = await Conversation.findOneAndUpdate(
{ conversationId, user: req.user.id },
update,
{
new: true,
upsert: true,
},
);
return conversation.toObject();
} catch (error) {
logger.error('[saveConvo] Error saving conversation', error);
if (metadata && metadata?.context) {
logger.info(`[saveConvo] ${metadata.context}`);
}
return { message: 'Error saving conversation' };
}
},
@ -56,13 +73,16 @@ module.exports = {
throw new Error('Failed to save conversations in bulk.');
}
},
getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false) => {
getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false, tags) => {
const query = { user };
if (isArchived) {
query.isArchived = true;
} else {
query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }];
}
if (Array.isArray(tags) && tags.length > 0) {
query.tags = { $in: tags };
}
try {
const totalConvos = (await Conversation.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);

View file

@ -0,0 +1,268 @@
//const crypto = require('crypto');
const logger = require('~/config/winston');
const Conversation = require('./schema/convoSchema');
const ConversationTag = require('./schema/conversationTagSchema');
const SAVED_TAG = 'Saved';
const updateTagsForConversation = async (user, conversationId, tags) => {
try {
const conversation = await Conversation.findOne({ user, conversationId });
if (!conversation) {
return { message: 'Conversation not found' };
}
const addedTags = tags.tags.filter((tag) => !conversation.tags.includes(tag));
const removedTags = conversation.tags.filter((tag) => !tags.tags.includes(tag));
for (const tag of addedTags) {
await ConversationTag.updateOne({ tag, user }, { $inc: { count: 1 } }, { upsert: true });
}
for (const tag of removedTags) {
await ConversationTag.updateOne({ tag, user }, { $inc: { count: -1 } });
}
conversation.tags = tags.tags;
await conversation.save({ timestamps: { updatedAt: false } });
return conversation.tags;
} catch (error) {
logger.error('[updateTagsToConversation] Error updating tags', error);
return { message: 'Error updating tags' };
}
};
const createConversationTag = async (user, data) => {
try {
const cTag = await ConversationTag.findOne({ user, tag: data.tag });
if (cTag) {
return cTag;
}
const addToConversation = data.addToConversation && data.conversationId;
const newTag = await ConversationTag.create({
user,
tag: data.tag,
count: 0,
description: data.description,
position: 1,
});
await ConversationTag.updateMany(
{ user, position: { $gte: 1 }, _id: { $ne: newTag._id } },
{ $inc: { position: 1 } },
);
if (addToConversation) {
const conversation = await Conversation.findOne({
user,
conversationId: data.conversationId,
});
if (conversation) {
const tags = [...(conversation.tags || []), data.tag];
await updateTagsForConversation(user, data.conversationId, { tags });
} else {
logger.warn('[updateTagsForConversation] Conversation not found', data.conversationId);
}
}
return await ConversationTag.findOne({ user, tag: data.tag });
} catch (error) {
logger.error('[createConversationTag] Error updating conversation tag', error);
return { message: 'Error updating conversation tag' };
}
};
const replaceOrRemoveTagInConversations = async (user, oldtag, newtag) => {
try {
const conversations = await Conversation.find({ user, tags: { $in: [oldtag] } });
for (const conversation of conversations) {
if (newtag && newtag !== '') {
conversation.tags = conversation.tags.map((tag) => (tag === oldtag ? newtag : tag));
} else {
conversation.tags = conversation.tags.filter((tag) => tag !== oldtag);
}
await conversation.save({ timestamps: { updatedAt: false } });
}
} catch (error) {
logger.error('[replaceOrRemoveTagInConversations] Error updating conversation tags', error);
return { message: 'Error updating conversation tags' };
}
};
const updateTagPosition = async (user, tag, newPosition) => {
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return { message: 'Tag not found' };
}
const oldPosition = cTag.position;
if (newPosition === oldPosition) {
return cTag;
}
const updateOperations = [];
if (newPosition > oldPosition) {
// Move other tags up
updateOperations.push({
updateMany: {
filter: {
user,
position: { $gt: oldPosition, $lte: newPosition },
tag: { $ne: SAVED_TAG },
},
update: { $inc: { position: -1 } },
},
});
} else {
// Move other tags down
updateOperations.push({
updateMany: {
filter: {
user,
position: { $gte: newPosition, $lt: oldPosition },
tag: { $ne: SAVED_TAG },
},
update: { $inc: { position: 1 } },
},
});
}
// Update the target tag's position
updateOperations.push({
updateOne: {
filter: { _id: cTag._id },
update: { $set: { position: newPosition } },
},
});
await ConversationTag.bulkWrite(updateOperations);
return await ConversationTag.findById(cTag._id);
} catch (error) {
logger.error('[updateTagPosition] Error updating tag position', error);
return { message: 'Error updating tag position' };
}
};
module.exports = {
SAVED_TAG,
ConversationTag,
getConversationTags: async (user) => {
try {
const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean();
cTags.sort((a, b) => (a.tag === SAVED_TAG ? -1 : b.tag === SAVED_TAG ? 1 : 0));
return cTags;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
return { message: 'Error getting share link' };
}
},
createConversationTag,
updateConversationTag: async (user, tag, data) => {
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return createConversationTag(user, data);
}
if (cTag.tag !== data.tag || cTag.description !== data.description) {
cTag.tag = data.tag;
cTag.description = data.description === undefined ? cTag.description : data.description;
await cTag.save();
}
if (data.position !== undefined && cTag.position !== data.position) {
await updateTagPosition(user, tag, data.position);
}
// update conversation tags properties
replaceOrRemoveTagInConversations(user, tag, data.tag);
return await ConversationTag.findOne({ user, tag: data.tag });
} catch (error) {
logger.error('[updateConversationTag] Error updating conversation tag', error);
return { message: 'Error updating conversation tag' };
}
},
deleteConversationTag: async (user, tag) => {
try {
const currentTag = await ConversationTag.findOne({ user, tag });
if (!currentTag) {
return;
}
await currentTag.deleteOne({ user, tag });
await replaceOrRemoveTagInConversations(user, tag, null);
return currentTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
return { message: 'Error deleting conversation tag' };
}
},
updateTagsForConversation,
rebuildConversationTags: async (user) => {
try {
const conversations = await Conversation.find({ user }).select('tags');
const tagCountMap = {};
// Count the occurrences of each tag
conversations.forEach((conversation) => {
conversation.tags.forEach((tag) => {
if (tagCountMap[tag]) {
tagCountMap[tag]++;
} else {
tagCountMap[tag] = 1;
}
});
});
const tags = await ConversationTag.find({ user }).sort({ position: -1 });
// Update existing tags and add new tags
for (const [tag, count] of Object.entries(tagCountMap)) {
const existingTag = tags.find((t) => t.tag === tag);
if (existingTag) {
existingTag.count = count;
await existingTag.save();
} else {
const newTag = new ConversationTag({ user, tag, count });
tags.push(newTag);
await newTag.save();
}
}
// Set count to 0 for tags that are not in the grouped tags
for (const tag of tags) {
if (!tagCountMap[tag.tag]) {
tag.count = 0;
await tag.save();
}
}
// Sort tags by position in descending order
tags.sort((a, b) => a.position - b.position);
// Move the tag with name "saved" to the first position
const savedTagIndex = tags.findIndex((tag) => tag.tag === SAVED_TAG);
if (savedTagIndex !== -1) {
const [savedTag] = tags.splice(savedTagIndex, 1);
tags.unshift(savedTag);
}
// Reassign positions starting from 0
tags.forEach((tag, index) => {
tag.position = index;
tag.save();
});
return tags;
} catch (error) {
logger.error('[rearrangeTags] Error rearranging tags', error);
return { message: 'Error rearranging tags' };
}
},
};

View file

@ -1,6 +1,6 @@
const { z } = require('zod');
const Message = require('./schema/messageSchema');
const logger = require('~/config/winston');
const { logger } = require('~/config');
const idSchema = z.string().uuid();
@ -27,42 +27,53 @@ const idSchema = z.string().uuid();
* @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.plugins] - An array of plugins associated with the message.
* @param {string} [params.model] - The model used to generate the message.
* @param {Object} [metadata] - Additional metadata for this operation
* @param {string} [metadata.context] - The context of the operation
* @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,
newMessageId,
conversationId,
parentMessageId,
sender,
text,
isCreatedByUser,
error,
unfinished,
files,
isEdited,
finish_reason,
tokenCount,
plugin,
plugins,
model,
},
) {
async function saveMessage(req, params, metadata) {
try {
if (!req || !req.user || !req.user.id) {
throw new Error('User not authenticated');
}
const {
text,
error,
model,
files,
plugin,
sender,
plugins,
iconURL,
endpoint,
isEdited,
messageId,
unfinished,
tokenCount,
newMessageId,
finish_reason,
conversationId,
parentMessageId,
isCreatedByUser,
} = params;
const validConvoId = idSchema.safeParse(conversationId);
if (!validConvoId.success) {
throw new Error('Invalid conversation ID');
logger.warn(`Invalid conversation ID: ${conversationId}`);
if (metadata && metadata?.context) {
logger.info(`---\`saveMessage\` context: ${metadata.context}`);
}
logger.info(`---Invalid conversation ID Params:
${JSON.stringify(params, null, 2)}
`);
return;
}
const update = {
@ -97,6 +108,9 @@ async function saveMessage(
return message.toObject();
} catch (err) {
logger.error('Error saving message:', err);
if (metadata && metadata?.context) {
logger.info(`---\`saveMessage\` context: ${metadata.context}`);
}
throw err;
}
}
@ -167,7 +181,7 @@ async function recordMessage({
new: true,
});
} catch (err) {
logger.error('Error saving message:', err);
logger.error('Error recording message:', err);
throw err;
}
}
@ -206,10 +220,12 @@ async function updateMessageText(req, { messageId, text }) {
* @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.
* @param {Object} [metadata] - The operation metadata
* @param {string} [metadata.context] - The operation metadata
* @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) {
async function updateMessage(req, message, metadata) {
try {
const { messageId, ...update } = message;
update.isEdited = true;
@ -237,6 +253,9 @@ async function updateMessage(req, message) {
};
} catch (err) {
logger.error('Error updating message:', err);
if (metadata && metadata?.context) {
logger.info(`---\`updateMessage\` context: ${metadata.context}`);
}
throw err;
}
}

View file

@ -78,7 +78,7 @@ describe('Message Operations', () => {
it('should throw an error for invalid conversation ID', async () => {
mockMessage.conversationId = 'invalid-id';
await expect(saveMessage(mockReq, mockMessage)).rejects.toThrow('Invalid conversation ID');
await expect(saveMessage(mockReq, mockMessage)).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,31 @@
const mongoose = require('mongoose');
const conversationTagSchema = mongoose.Schema(
{
tag: {
type: String,
index: true,
},
user: {
type: String,
index: true,
},
description: {
type: String,
index: true,
},
count: {
type: Number,
default: 0,
},
position: {
type: Number,
default: 0,
},
},
{ timestamps: true },
);
conversationTagSchema.index({ tag: 1, user: 1 }, { unique: true });
module.exports = mongoose.model('ConversationTag', conversationTagSchema);

View file

@ -42,6 +42,11 @@ const convoSchema = mongoose.Schema(
invocationId: {
type: Number,
},
tags: {
type: [String],
default: [],
meiliIndex: true,
},
},
{ timestamps: true },
);

View file

@ -103,6 +103,10 @@ const conversationPreset = {
spec: {
type: String,
},
tags: {
type: [String],
default: [],
},
tools: { type: [{ type: String }], default: undefined },
maxContextTokens: {
type: Number,

View file

@ -129,6 +129,7 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
}
messageSchema.index({ createdAt: 1 });
messageSchema.index({ messageId: 1, user: 1 }, { unique: true });
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);

View file

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

View file

@ -12,6 +12,7 @@ const tokenValues = {
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-4o': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
@ -54,6 +55,8 @@ const getValueKey = (model, endpoint) => {
return 'gpt-3.5-turbo-1106';
} else if (modelName.includes('gpt-3.5')) {
return '4k';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {
return 'gpt-4o';
} else if (modelName.includes('gpt-4-vision')) {

View file

@ -49,6 +49,12 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4o-0125')).toBe('gpt-4o');
});
it('should return "gpt-4o-mini" for model type of "gpt-4o-mini"', () => {
expect(getValueKey('gpt-4o-mini-2024-07-18')).toBe('gpt-4o-mini');
expect(getValueKey('openai/gpt-4o-mini')).toBe('gpt-4o-mini');
expect(getValueKey('gpt-4o-mini-0718')).toBe('gpt-4o-mini');
});
it('should return "claude-3-5-sonnet" for model type of "claude-3-5-sonnet-"', () => {
expect(getValueKey('claude-3-5-sonnet-20240620')).toBe('claude-3-5-sonnet');
expect(getValueKey('anthropic/claude-3-5-sonnet')).toBe('claude-3-5-sonnet');
@ -109,6 +115,19 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for gpt-4o-mini', () => {
const valueKey = getValueKey('gpt-4o-mini-2024-07-18');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
tokenValues['gpt-4o-mini'].prompt,
);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-4o-mini'].completion,
);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe(
tokenValues['gpt-4-1106'].completion,
);
});
it('should derive the valueKey from the model if not provided for new models', () => {
expect(
getMultiplier({ tokenType: 'prompt', model: 'gpt-3.5-turbo-1106-some-other-info' }),

View file

@ -150,11 +150,17 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
});
res.end();
await saveMessage(req, { ...response, user });
await saveMessage(
req,
{ ...response, user },
{ context: 'api/server/controllers/AskController.js - response end' },
);
}
if (!client.skipSaveUserMessage) {
await saveMessage(req, userMessage);
await saveMessage(req, userMessage, {
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
});
}
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {

View file

@ -145,7 +145,11 @@ const EditController = async (req, res, next, initializeClient) => {
});
res.end();
await saveMessage(req, { ...response, user });
await saveMessage(
req,
{ ...response, user },
{ context: 'api/server/controllers/EditController.js - response end' },
);
}
} catch (error) {
const partialText = getPartialText();

View file

@ -383,6 +383,9 @@ const chatV1 = async (req, res) => {
return files;
};
/** @type {Promise<Run>|undefined} */
let userMessagePromise;
const initializeThread = async () => {
/** @type {[ undefined | MongoFile[]]}*/
const [processedFiles] = await Promise.all([addVisionPrompt(), getRequestFileIds()]);
@ -439,7 +442,7 @@ const chatV1 = async (req, res) => {
previousMessages.push(requestMessage);
/* asynchronous */
saveUserMessage({ ...requestMessage, model });
userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
conversation = {
conversationId,
@ -583,7 +586,10 @@ const chatV1 = async (req, res) => {
});
res.end();
await saveAssistantMessage({ ...responseMessage, model });
if (userMessagePromise) {
await userMessagePromise;
}
await saveAssistantMessage(req, { ...responseMessage, model });
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
addTitle(req, {

View file

@ -246,6 +246,9 @@ const chatV2 = async (req, res) => {
}
};
/** @type {Promise<Run>|undefined} */
let userMessagePromise;
const initializeThread = async () => {
await getRequestFileIds();
@ -288,7 +291,7 @@ const chatV2 = async (req, res) => {
previousMessages.push(requestMessage);
/* asynchronous */
saveUserMessage({ ...requestMessage, model });
userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
conversation = {
conversationId,
@ -449,7 +452,10 @@ const chatV2 = async (req, res) => {
});
res.end();
await saveAssistantMessage({ ...responseMessage, model });
if (userMessagePromise) {
await userMessagePromise;
}
await saveAssistantMessage(req, { ...responseMessage, model });
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
addTitle(req, {

View file

@ -94,6 +94,7 @@ const startServer = async () => {
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/tags', routes.tags);
app.use((req, res) => {
res.sendFile(path.join(app.locals.paths.dist, 'index.html'));
});

View file

@ -119,7 +119,11 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
{ promptTokens, completionTokens },
);
saveMessage(req, { ...responseMessage, user });
saveMessage(
req,
{ ...responseMessage, user },
{ context: 'api/server/middleware/abortMiddleware.js' },
);
let conversation;
if (userMessagePromise) {

View file

@ -19,7 +19,8 @@ const validateAssistant = async (req, res, next) => {
}
const { supportedIds, excludedIds } = assistantsConfig;
const error = { message: 'Assistant not supported' };
const error = { message: 'validateAssistant: Assistant not supported' };
if (supportedIds?.length && !supportedIds.includes(assistant_id)) {
return await handleAbortError(res, req, error, {
sender: 'System',

View file

@ -41,7 +41,11 @@ const denyRequest = async (req, res, errorMessage) => {
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
if (shouldSaveMessage) {
await saveMessage(req, { ...userMessage, user: req.user.id });
await saveMessage(
req,
{ ...userMessage, user: req.user.id },
{ context: `api/server/middleware/denyRequest.js - ${responseText}` },
);
}
return await sendError(req, res, {

View file

@ -76,7 +76,9 @@ describe.skip('GET /', () => {
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
ldapLoginEnabled: true,
ldap: {
enabled: true,
},
serverDomain: 'http://test-server.com',
emailLoginEnabled: 'true',
registrationEnabled: 'true',

View file

@ -0,0 +1,55 @@
const request = require('supertest');
const express = require('express');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { isEnabled } = require('~/server/utils');
jest.mock('~/server/services/Config/ldap');
jest.mock('~/server/utils');
const app = express();
// Mock the route handler
app.get('/api/config', (req, res) => {
const ldapConfig = getLdapConfig();
res.json({ ldap: ldapConfig });
});
describe('LDAP Config Tests', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should return LDAP config with username property when LDAP_LOGIN_USES_USERNAME is enabled', async () => {
getLdapConfig.mockReturnValue({ enabled: true, username: true });
isEnabled.mockReturnValue(true);
const response = await request(app).get('/api/config');
expect(response.statusCode).toBe(200);
expect(response.body.ldap).toEqual({
enabled: true,
username: true,
});
});
it('should return LDAP config without username property when LDAP_LOGIN_USES_USERNAME is not enabled', async () => {
getLdapConfig.mockReturnValue({ enabled: true });
isEnabled.mockReturnValue(false);
const response = await request(app).get('/api/config');
expect(response.statusCode).toBe(200);
expect(response.body.ldap).toEqual({
enabled: true,
});
});
it('should not return LDAP config when LDAP is not enabled', async () => {
getLdapConfig.mockReturnValue(undefined);
const response = await request(app).get('/api/config');
expect(response.statusCode).toBe(200);
expect(response.body.ldap).toBeUndefined();
});
});

View file

@ -52,7 +52,7 @@ router.post('/', setHeaders, async (req, res) => {
if (!overrideParentMessageId) {
await saveMessage(req, { ...userMessage, user: req.user.id });
await saveConvo(req.user.id, {
await saveConvo(req, {
...userMessage,
...endpointOption,
conversationId,
@ -183,7 +183,7 @@ const ask = async ({
}
}
await saveConvo(user, conversationUpdate);
await saveConvo(req, conversationUpdate);
conversationId = newConversationId;
// STEP3 update the user message
@ -213,7 +213,7 @@ const ask = async ({
if (userParentMessageId == Constants.NO_PARENT) {
// const title = await titleConvo({ endpoint: endpointOption?.endpoint, text, response: responseMessage });
const title = await response.details.title;
await saveConvo(user, {
await saveConvo(req, {
conversationId: conversationId,
title,
});

View file

@ -71,7 +71,7 @@ router.post('/', setHeaders, async (req, res) => {
if (!overrideParentMessageId) {
await saveMessage(req, { ...userMessage, user: req.user.id });
await saveConvo(req.user.id, {
await saveConvo(req, {
...userMessage,
...endpointOption,
conversationId,
@ -216,7 +216,7 @@ const ask = async ({
conversationUpdate.invocationId = response.invocationId;
}
await saveConvo(user, conversationUpdate);
await saveConvo(req, conversationUpdate);
userMessage.messageId = newUserMessageId;
// If response has parentMessageId, the fake userMessage.messageId should be updated to the real one.
@ -245,7 +245,7 @@ const ask = async ({
response: responseMessage,
});
await saveConvo(user, {
await saveConvo(req, {
conversationId: conversationId,
title,
});

View file

@ -4,7 +4,7 @@ const { getResponseSender, Constants, CacheKeys, Time } = require('librechat-dat
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 { saveMessage, updateMessage } = require('~/models');
const { getLogStores } = require('~/cache');
const {
handleAbort,
@ -73,7 +73,14 @@ router.post(
};
const messageCache = getLogStores(CacheKeys.MESSAGES);
const throttledSetMessage = throttle(messageCache.set, 3000, { trailing: false });
const throttledCacheSet = throttle(
(text) => {
messageCache.set(responseMessageId, text, Time.FIVE_MINUTES);
},
3000,
{ trailing: false },
);
let streaming = null;
let timer = null;
@ -87,21 +94,7 @@ router.post(
clearTimeout(timer);
}
/*
{
messageId: responseMessageId,
sender,
conversationId,
parentMessageId: overrideParentMessageId || userMessageId,
text: partialText,
model: endpointOption.modelOptions.model,
unfinished: true,
error: false,
plugins,
user,
}
*/
throttledSetMessage(responseMessageId, partialText, Time.FIVE_MINUTES);
throttledCacheSet(partialText);
streaming = new Promise((resolve) => {
timer = setTimeout(() => {
@ -175,7 +168,11 @@ router.post(
const onChainEnd = () => {
if (!client.skipSaveUserMessage) {
saveMessage(req, { ...userMessage, user });
saveMessage(
req,
{ ...userMessage, user },
{ context: 'api/server/routes/ask/gptPlugins.js - onChainEnd' },
);
}
sendIntermediateMessage(res, {
plugins,
@ -212,9 +209,6 @@ router.post(
logger.debug('[/ask/gptPlugins]', response);
response.plugins = plugins.map((p) => ({ ...p, loading: false }));
await saveMessage(req, { ...response, user });
const { conversation = {} } = await client.responsePromise;
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
@ -235,6 +229,15 @@ router.post(
client,
});
}
response.plugins = plugins.map((p) => ({ ...p, loading: false }));
if (response.plugins?.length > 0) {
await updateMessage(
req,
{ ...response, user },
{ context: 'api/server/routes/ask/gptPlugins.js - save plugins used' },
);
}
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {

View file

@ -1,5 +1,6 @@
const express = require('express');
const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getProjectByName } = require('~/models/Project');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
@ -33,7 +34,8 @@ router.get('/', async function (req, res) {
const instanceProject = await getProjectByName('instance', '_id');
const ldapLoginEnabled = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
const ldap = getLdapConfig();
try {
/** @type {TStartupConfig} */
const payload = {
@ -51,10 +53,9 @@ router.get('/', async function (req, res) {
!!process.env.OPENID_SESSION_SECRET,
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
openidImageUrl: process.env.OPENID_IMAGE_URL,
ldapLoginEnabled,
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
emailLoginEnabled,
registrationEnabled: !ldapLoginEnabled && isEnabled(process.env.ALLOW_REGISTRATION),
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
emailEnabled:
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
@ -76,6 +77,10 @@ router.get('/', async function (req, res) {
instanceProjectId: instanceProject._id.toString(),
};
if (ldap) {
payload.ldap = ldap;
}
if (typeof process.env.CUSTOM_FOOTER === 'string') {
payload.customFooter = process.env.CUSTOM_FOOTER;
}

View file

@ -8,6 +8,7 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { forkConversation } = require('~/server/utils/import/fork');
const { importConversations } = require('~/server/utils/import');
const { createImportLimiters } = require('~/server/middleware');
const { updateTagsForConversation } = require('~/models/ConversationTag');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
@ -30,8 +31,13 @@ router.get('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid page size' });
}
const isArchived = req.query.isArchived === 'true';
const tags = req.query.tags
? Array.isArray(req.query.tags)
? req.query.tags
: [req.query.tags]
: undefined;
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived));
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
});
router.get('/:conversationId', async (req, res) => {
@ -104,7 +110,7 @@ router.post('/update', async (req, res) => {
const update = req.body.arg;
try {
const dbResponse = await saveConvo(req.user.id, update);
const dbResponse = await saveConvo(req, update, { context: 'POST /api/convos/update' });
res.status(201).json(dbResponse);
} catch (error) {
logger.error('Error updating conversation', error);
@ -167,4 +173,9 @@ router.post('/fork', async (req, res) => {
}
});
router.put('/tags/:conversationId', async (req, res) => {
const tag = await updateTagsForConversation(req.user.id, req.params.conversationId, req.body);
res.status(200).json(tag);
});
module.exports = router;

View file

@ -13,7 +13,7 @@ const {
} = require('~/server/middleware');
const { sendMessage, createOnProgress, formatSteps, formatAction } = require('~/server/utils');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { saveMessage } = require('~/models');
const { saveMessage, updateMessage } = require('~/models');
const { getLogStores } = require('~/cache');
const { validateTools } = require('~/app');
const { logger } = require('~/config');
@ -81,7 +81,14 @@ router.post(
};
const messageCache = getLogStores(CacheKeys.MESSAGES);
const throttledSetMessage = throttle(messageCache.set, 3000, { trailing: false });
const throttledCacheSet = throttle(
(text) => {
messageCache.set(responseMessageId, text, Time.FIVE_MINUTES);
},
3000,
{ trailing: false },
);
const {
onProgress: progressCallback,
sendIntermediateMessage,
@ -92,22 +99,7 @@ router.post(
if (plugin.loading === true) {
plugin.loading = false;
}
/*
{
messageId: responseMessageId,
sender,
conversationId,
parentMessageId: overrideParentMessageId || userMessageId,
text: partialText,
model: endpointOption.modelOptions.model,
unfinished: true,
isEdited: true,
error: false,
user,
}
*/
throttledSetMessage(responseMessageId, partialText, Time.FIVE_MINUTES);
throttledCacheSet(partialText);
},
});
@ -115,7 +107,11 @@ router.post(
let { intermediateSteps: steps } = data;
plugin.outputs = steps && steps[0].action ? formatSteps(steps) : 'An error occurred.';
plugin.loading = false;
saveMessage(req, { ...userMessage, user });
saveMessage(
req,
{ ...userMessage, user },
{ context: 'api/server/routes/ask/gptPlugins.js - onChainEnd' },
);
sendIntermediateMessage(res, {
plugin,
parentMessageId: userMessage.messageId,
@ -146,7 +142,11 @@ router.post(
plugin.inputs.push(formattedAction);
plugin.latest = formattedAction.plugin;
if (!start && !client.skipSaveUserMessage) {
saveMessage(req, { ...userMessage, user });
saveMessage(
req,
{ ...userMessage, user },
{ context: 'api/server/routes/ask/gptPlugins.js - onAgentAction' },
);
}
sendIntermediateMessage(res, {
plugin,
@ -184,8 +184,6 @@ router.post(
}
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
response.plugin = { ...plugin, loading: false };
await saveMessage(req, { ...response, user });
const { conversation = {} } = await client.responsePromise;
conversation.title =
@ -199,6 +197,13 @@ router.post(
responseMessage: response,
});
res.end();
response.plugin = { ...plugin, loading: false };
await updateMessage(
req,
{ ...response, user },
{ context: 'api/server/routes/edit/gptPlugins.js' },
);
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {

View file

@ -21,6 +21,7 @@ const staticRoute = require('./static');
const share = require('./share');
const categories = require('./categories');
const roles = require('./roles');
const tags = require('./tags');
module.exports = {
search,
@ -46,4 +47,5 @@ module.exports = {
share,
categories,
roles,
tags,
};

View file

@ -1,45 +1,79 @@
const express = require('express');
const router = express.Router();
const { saveConvo, saveMessage, getMessages, updateMessage, deleteMessages } = require('~/models');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { countTokens } = require('~/server/utils');
const { logger } = require('~/config');
const router = express.Router();
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'));
try {
const { conversationId } = req.params;
const messages = await getMessages({ conversationId }, '-_id -__v -user');
res.status(200).json(messages);
} catch (error) {
logger.error('Error fetching messages:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// CREATE
router.post('/:conversationId', validateMessageReq, async (req, res) => {
const message = req.body;
const savedMessage = await saveMessage(req, { ...message, user: req.user.id });
await saveConvo(req.user.id, savedMessage);
res.status(201).send(savedMessage);
try {
const message = req.body;
const savedMessage = await saveMessage(
req,
{ ...message, user: req.user.id },
{ context: 'POST /api/messages/:conversationId' },
);
if (!savedMessage) {
return res.status(400).json({ error: 'Message not saved' });
}
await saveConvo(req, savedMessage, { context: 'POST /api/messages/:conversationId' });
res.status(201).json(savedMessage);
} catch (error) {
logger.error('Error saving message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// READ
router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
const { conversationId, messageId } = req.params;
res.status(200).send(await getMessages({ conversationId, messageId }, '-_id -__v -user'));
try {
const { conversationId, messageId } = req.params;
const message = await getMessages({ conversationId, messageId }, '-_id -__v -user');
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
res.status(200).json(message);
} catch (error) {
logger.error('Error fetching message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// UPDATE
router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
const { messageId, model } = req.params;
const { text } = req.body;
const tokenCount = await countTokens(text, model);
const result = await updateMessage(req, { messageId, text, tokenCount });
res.status(201).json(result);
try {
const { messageId, model } = req.params;
const { text } = req.body;
const tokenCount = await countTokens(text, model);
const result = await updateMessage(req, { messageId, text, tokenCount });
res.status(200).json(result);
} catch (error) {
logger.error('Error updating message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
const { messageId } = req.params;
await deleteMessages({ messageId });
res.status(204).send();
try {
const { messageId } = req.params;
await deleteMessages({ messageId });
res.status(204).send();
} catch (error) {
logger.error('Error deleting message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

44
api/server/routes/tags.js Normal file
View file

@ -0,0 +1,44 @@
const express = require('express');
const {
getConversationTags,
updateConversationTag,
createConversationTag,
deleteConversationTag,
rebuildConversationTags,
} = require('~/models/ConversationTag');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router();
router.use(requireJwtAuth);
router.get('/', async (req, res) => {
const tags = await getConversationTags(req.user.id);
if (tags) {
res.status(200).json(tags);
} else {
res.status(404).end();
}
});
router.post('/', async (req, res) => {
const tag = await createConversationTag(req.user.id, req.body);
res.status(200).json(tag);
});
router.post('/rebuild', async (req, res) => {
const tag = await rebuildConversationTags(req.user.id);
res.status(200).json(tag);
});
router.put('/:tag', async (req, res) => {
const tag = await updateConversationTag(req.user.id, req.params.tag, req.body);
res.status(200).json(tag);
});
router.delete('/:tag', async (req, res) => {
const tag = await deleteConversationTag(req.user.id, req.params.tag);
res.status(200).json(tag);
});
module.exports = router;

View file

@ -0,0 +1,24 @@
const { isEnabled } = require('~/server/utils');
/** @returns {TStartupConfig['ldap'] | undefined} */
const getLdapConfig = () => {
const ldapLoginEnabled = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
const ldap = {
enabled: ldapLoginEnabled,
};
const ldapLoginUsesUsername = isEnabled(process.env.LDAP_LOGIN_USES_USERNAME);
if (!ldapLoginEnabled) {
return ldap;
}
if (ldapLoginUsesUsername) {
ldap.username = true;
}
return ldap;
};
module.exports = {
getLdapConfig,
};

View file

@ -23,10 +23,14 @@ const addTitle = async (req, { text, response, client }) => {
const title = await client.titleConvo({ text, responseText: response?.text });
await titleCache.set(key, title, 120000);
await saveConvo(req.user.id, {
conversationId: response.conversationId,
title,
});
await saveConvo(
req,
{
conversationId: response.conversationId,
title,
},
{ context: 'api/server/services/Endpoints/anthropic/addTitle.js' },
);
};
module.exports = addTitle;

View file

@ -19,10 +19,14 @@ const addTitle = async (req, { text, responseText, conversationId, client }) =>
const title = await client.titleConvo({ text, conversationId, responseText });
await titleCache.set(key, title, 120000);
await saveConvo(req.user.id, {
conversationId,
title,
});
await saveConvo(
req,
{
conversationId,
title,
},
{ context: 'api/server/services/Endpoints/assistants/addTitle.js' },
);
};
module.exports = addTitle;

View file

@ -49,10 +49,14 @@ const addTitle = async (req, { text, response, client }) => {
const title = await titleClient.titleConvo({ text, responseText: response?.text });
await titleCache.set(key, title, 120000);
await saveConvo(req.user.id, {
conversationId: response.conversationId,
title,
});
await saveConvo(
req,
{
conversationId: response.conversationId,
title,
},
{ context: 'api/server/services/Endpoints/google/addTitle.js' },
);
};
module.exports = addTitle;

View file

@ -23,10 +23,14 @@ const addTitle = async (req, { text, response, client }) => {
const title = await client.titleConvo({ text, responseText: response?.text });
await titleCache.set(key, title, 120000);
await saveConvo(req.user.id, {
conversationId: response.conversationId,
title,
});
await saveConvo(
req,
{
conversationId: response.conversationId,
title,
},
{ context: 'api/server/services/Endpoints/openAI/addTitle.js' },
);
};
module.exports = addTitle;

View file

@ -442,7 +442,7 @@ async function streamAudio(req, res) {
break;
}
} catch (innerError) {
logger.error('Error processing update:', update, innerError);
logger.error('Error processing audio stream update:', update, innerError);
if (!res.headersSent) {
res.status(500).end();
}

View file

@ -41,6 +41,7 @@ async function initThread({ openai, body, thread_id: _thread_id }) {
/**
* Saves a user message to the DB in the Assistants endpoint format.
*
* @param {Object} req - The request object.
* @param {Object} params - The parameters of the user message
* @param {string} params.user - The user's ID.
* @param {string} params.text - The user's prompt.
@ -59,7 +60,7 @@ async function initThread({ openai, body, thread_id: _thread_id }) {
* @param {string[]} [params.file_ids] - Optional. List of File IDs attached to the userMessage.
* @return {Promise<Run>} A promise that resolves to the created run object.
*/
async function saveUserMessage(params) {
async function saveUserMessage(req, params) {
const tokenCount = await countTokens(params.text);
// todo: do this on the frontend
@ -110,14 +111,16 @@ async function saveUserMessage(params) {
}
const message = await recordMessage(userMessage);
await saveConvo(params.user, convo);
await saveConvo(req, convo, {
context: 'api/server/services/Threads/manage.js #saveUserMessage',
});
return message;
}
/**
* Saves an Assistant message to the DB in the Assistants endpoint format.
*
* @param {Object} req - The request object.
* @param {Object} params - The parameters of the Assistant message
* @param {string} params.user - The user's ID.
* @param {string} params.messageId - The message Id.
@ -134,7 +137,7 @@ async function saveUserMessage(params) {
* @param {string} [params.promptPrefix] - Optional: from preset for `additional_instructions` field.
* @return {Promise<Run>} A promise that resolves to the created run object.
*/
async function saveAssistantMessage(params) {
async function saveAssistantMessage(req, params) {
// const tokenCount = // TODO: need to count each content part
const message = await recordMessage({
@ -154,14 +157,18 @@ async function saveAssistantMessage(params) {
// tokenCount,
});
await saveConvo(params.user, {
endpoint: params.endpoint,
conversationId: params.conversationId,
promptPrefix: params.promptPrefix,
instructions: params.instructions,
assistant_id: params.assistant_id,
model: params.model,
});
await saveConvo(
req,
{
endpoint: params.endpoint,
conversationId: params.conversationId,
promptPrefix: params.promptPrefix,
instructions: params.instructions,
assistant_id: params.assistant_id,
model: params.model,
},
{ context: 'api/server/services/Threads/manage.js #saveAssistantMessage' },
);
return message;
}
@ -338,10 +345,14 @@ async function syncMessages({
await Promise.all(modifyPromises);
await Promise.all(recordPromises);
await saveConvo(openai.req.user.id, {
conversationId,
file_ids: attached_file_ids,
});
await saveConvo(
openai.req,
{
conversationId,
file_ids: attached_file_ids,
},
{ context: 'api/server/services/Threads/manage.js #syncMessages' },
);
return result;
}

View file

@ -1,6 +1,7 @@
const fs = require('fs');
const LdapStrategy = require('passport-ldapauth');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { isEnabled } = require('~/server/utils');
const logger = require('~/utils/logger');
const {
@ -13,6 +14,7 @@ const {
LDAP_FULL_NAME,
LDAP_ID,
LDAP_USERNAME,
LDAP_TLS_REJECT_UNAUTHORIZED,
} = process.env;
// Check required environment variables
@ -41,6 +43,7 @@ if (LDAP_ID) {
if (LDAP_USERNAME) {
searchAttributes.push(LDAP_USERNAME);
}
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED);
const ldapOptions = {
server: {
@ -52,6 +55,7 @@ const ldapOptions = {
searchAttributes: [...new Set(searchAttributes)],
...(LDAP_CA_CERT_PATH && {
tlsOptions: {
rejectUnauthorized,
ca: (() => {
try {
return [fs.readFileSync(LDAP_CA_CERT_PATH)];

View file

@ -1,45 +1,6 @@
const z = require('zod');
const { EModelEndpoint } = require('librechat-data-provider');
const models = [
'text-davinci-003',
'text-davinci-002',
'text-davinci-001',
'text-curie-001',
'text-babbage-001',
'text-ada-001',
'davinci',
'curie',
'babbage',
'ada',
'code-davinci-002',
'code-davinci-001',
'code-cushman-002',
'code-cushman-001',
'davinci-codex',
'cushman-codex',
'text-davinci-edit-001',
'code-davinci-edit-001',
'text-embedding-ada-002',
'text-similarity-davinci-001',
'text-similarity-curie-001',
'text-similarity-babbage-001',
'text-similarity-ada-001',
'text-search-davinci-doc-001',
'text-search-curie-doc-001',
'text-search-babbage-doc-001',
'text-search-ada-doc-001',
'code-search-babbage-code-001',
'code-search-ada-code-001',
'gpt2',
'gpt-4',
'gpt-4-0314',
'gpt-4-32k',
'gpt-4-32k-0314',
'gpt-3.5-turbo',
'gpt-3.5-turbo-0301',
];
const openAIModels = {
'gpt-4': 8187, // -5 from max
'gpt-4-0613': 8187, // -5 from max
@ -49,6 +10,7 @@ const openAIModels = {
'gpt-4-1106': 127990, // -10 from max
'gpt-4-0125': 127990, // -10 from max
'gpt-4o': 127990, // -10 from max
'gpt-4o-mini': 127990, // -10 from max
'gpt-4-turbo': 127990, // -10 from max
'gpt-4-vision': 127990, // -10 from max
'gpt-3.5-turbo': 16375, // -10 from max
@ -101,7 +63,6 @@ const anthropicModels = {
const aggregateModels = { ...openAIModels, ...googleModels, ...anthropicModels, ...cohereModels };
// Order is important here: by model series and context size (gpt-4 then gpt-3, ascending)
const maxTokensMap = {
[EModelEndpoint.azureOpenAI]: openAIModels,
[EModelEndpoint.openAI]: aggregateModels,
@ -110,6 +71,24 @@ const maxTokensMap = {
[EModelEndpoint.anthropic]: anthropicModels,
};
/**
* Finds the first matching pattern in the tokens map.
* @param {string} modelName
* @param {Record<string, number>} tokensMap
* @returns {string|null}
*/
function findMatchingPattern(modelName, tokensMap) {
const keys = Object.keys(tokensMap);
for (let i = keys.length - 1; i >= 0; i--) {
const modelKey = keys[i];
if (modelName.includes(modelKey)) {
return modelKey;
}
}
return null;
}
/**
* Retrieves the maximum tokens for a given model name. If the exact model name isn't found,
* it searches for partial matches within the model name, checking keys in reverse order.
@ -143,12 +122,11 @@ function getModelMaxTokens(modelName, endpoint = EModelEndpoint.openAI, endpoint
return tokensMap[modelName];
}
const keys = Object.keys(tokensMap);
for (let i = keys.length - 1; i >= 0; i--) {
if (modelName.includes(keys[i])) {
const result = tokensMap[keys[i]];
return result?.context ?? result;
}
const matchedPattern = findMatchingPattern(modelName, tokensMap);
if (matchedPattern) {
const result = tokensMap[matchedPattern];
return result?.context ?? result;
}
return undefined;
@ -181,15 +159,8 @@ function matchModelName(modelName, endpoint = EModelEndpoint.openAI) {
return modelName;
}
const keys = Object.keys(tokensMap);
for (let i = keys.length - 1; i >= 0; i--) {
const modelKey = keys[i];
if (modelName.includes(modelKey)) {
return modelKey;
}
}
return modelName;
const matchedPattern = findMatchingPattern(modelName, tokensMap);
return matchedPattern || modelName;
}
const modelSchema = z.object({
@ -241,8 +212,47 @@ function processModelData(input) {
return tokenConfig;
}
const tiktokenModels = new Set([
'text-davinci-003',
'text-davinci-002',
'text-davinci-001',
'text-curie-001',
'text-babbage-001',
'text-ada-001',
'davinci',
'curie',
'babbage',
'ada',
'code-davinci-002',
'code-davinci-001',
'code-cushman-002',
'code-cushman-001',
'davinci-codex',
'cushman-codex',
'text-davinci-edit-001',
'code-davinci-edit-001',
'text-embedding-ada-002',
'text-similarity-davinci-001',
'text-similarity-curie-001',
'text-similarity-babbage-001',
'text-similarity-ada-001',
'text-search-davinci-doc-001',
'text-search-curie-doc-001',
'text-search-babbage-doc-001',
'text-search-ada-doc-001',
'code-search-babbage-code-001',
'code-search-ada-code-001',
'gpt2',
'gpt-4',
'gpt-4-0314',
'gpt-4-32k',
'gpt-4-32k-0314',
'gpt-3.5-turbo',
'gpt-3.5-turbo-0301',
]);
module.exports = {
tiktokenModels: new Set(models),
tiktokenModels,
maxTokensMap,
inputSchema,
modelSchema,

View file

@ -0,0 +1,9 @@
import { createContext, useContext } from 'react';
import type { TConversationTag } from 'librechat-data-provider';
type TBookmarkContext = { bookmarks: TConversationTag[] };
export const BookmarkContext = createContext<TBookmarkContext>({
bookmarks: [],
} as TBookmarkContext);
export const useBookmarkContext = () => useContext(BookmarkContext);

View file

@ -7,6 +7,7 @@ export * from './SearchContext';
export * from './FileMapContext';
export * from './AddedChatContext';
export * from './ChatFormContext';
export * from './BookmarkContext';
export * from './DashboardContext';
export * from './AssistantsContext';
export * from './AssistantsMapContext';

View file

@ -83,7 +83,7 @@ export type NavLink = {
label?: string;
icon: LucideIcon | React.FC;
Component?: React.ComponentType;
onClick?: () => void;
onClick?: (e?: React.MouseEvent) => void;
variant?: 'default' | 'ghost';
id: string;
};

View file

@ -1,5 +1,6 @@
import { useForm } from 'react-hook-form';
import React, { useState, useEffect } from 'react';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail } from '~/data-provider';
@ -22,6 +23,9 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const { data: config } = useGetStartupConfig();
const useUsernameLogin = config?.ldap?.username;
useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
setShowResendLink(true);
@ -82,12 +86,15 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
<input
type="text"
id="email"
autoComplete="email"
autoComplete={useUsernameLogin ? 'username' : 'email'}
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
pattern: { value: /\S+@\S+\.\S+/, message: localize('com_auth_email_pattern') },
pattern: {
value: useUsernameLogin ? /\S+/ : /\S+@\S+\.\S+/,
message: localize('com_auth_email_pattern'),
},
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
@ -97,7 +104,9 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
htmlFor="email"
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_email_address')}
{useUsernameLogin
? localize('com_auth_username').replace(/ \(.*$/, '')
: localize('com_auth_email_address')}
</label>
</div>
{renderError('email')}

View file

@ -21,7 +21,9 @@ const mockStartupConfig = {
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
ldapLoginEnabled: false,
ldap: {
enabled: false,
},
registrationEnabled: true,
emailLoginEnabled: true,
socialLoginEnabled: true,

View file

@ -23,7 +23,9 @@ const mockStartupConfig: TStartupConfig = {
passwordResetEnabled: true,
serverDomain: 'mock-server',
appTitle: '',
ldapLoginEnabled: false,
ldap: {
enabled: false,
},
emailEnabled: false,
checkBalance: false,
showBirthdayIcon: false,

View file

@ -0,0 +1,69 @@
import React, { useRef, useState } from 'react';
import { TConversationTag, TConversation } from 'librechat-data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { OGDialog, OGDialogTrigger, OGDialogClose } from '~/components/ui/';
import BookmarkForm from './BookmarkForm';
import { useLocalize } from '~/hooks';
import { Spinner } from '../svg';
type BookmarkEditDialogProps = {
bookmark?: TConversationTag;
conversation?: TConversation;
tags?: string[];
setTags?: (tags: string[]) => void;
trigger: React.ReactNode;
};
const BookmarkEditDialog = ({
bookmark,
conversation,
tags,
setTags,
trigger,
}: BookmarkEditDialogProps) => {
const localize = useLocalize();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const handleSubmitForm = () => {
if (formRef.current) {
formRef.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>{trigger}</OGDialogTrigger>
<OGDialogTemplate
title="Bookmark"
className="w-11/12 sm:w-1/4"
showCloseButton={false}
main={
<BookmarkForm
conversation={conversation}
onOpenChange={setOpen}
setIsLoading={setIsLoading}
bookmark={bookmark}
formRef={formRef}
setTags={setTags}
tags={tags}
/>
}
buttons={
<OGDialogClose asChild>
<button
type="submit"
disabled={isLoading}
onClick={handleSubmitForm}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{isLoading ? <Spinner /> : localize('com_ui_save')}
</button>
</OGDialogClose>
}
/>
</OGDialog>
);
};
export default BookmarkEditDialog;

View file

@ -0,0 +1,195 @@
import React, { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import type {
TConversationTag,
TConversation,
TConversationTagRequest,
} from 'librechat-data-provider';
import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { useConversationTagMutation } from '~/data-provider';
import { Checkbox, Label, TextareaAutosize } from '~/components/ui/';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
type TBookmarkFormProps = {
bookmark?: TConversationTag;
conversation?: TConversation;
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
formRef: React.RefObject<HTMLFormElement>;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
tags?: string[];
setTags?: (tags: string[]) => void;
};
const BookmarkForm = ({
bookmark,
conversation,
onOpenChange,
formRef,
setIsLoading,
tags,
setTags,
}: TBookmarkFormProps) => {
const { showToast } = useToastContext();
const localize = useLocalize();
const mutation = useConversationTagMutation(bookmark?.tag);
const { bookmarks } = useBookmarkContext();
const {
register,
handleSubmit,
setValue,
getValues,
control,
formState: { errors },
} = useForm<TConversationTagRequest>({
defaultValues: {
tag: bookmark?.tag || '',
description: bookmark?.description || '',
conversationId: conversation?.conversationId || '',
addToConversation: conversation ? true : false,
},
});
useEffect(() => {
if (bookmark) {
setValue('tag', bookmark.tag || '');
setValue('description', bookmark.description || '');
}
}, [bookmark, setValue]);
const onSubmit = (data: TConversationTagRequest) => {
if (mutation.isLoading) {
return;
}
if (data.tag === bookmark?.tag && data.description === bookmark?.description) {
return;
}
setIsLoading(true);
mutation.mutate(data, {
onSuccess: () => {
showToast({
message: bookmark
? localize('com_ui_bookmarks_update_success')
: localize('com_ui_bookmarks_create_success'),
});
setIsLoading(false);
onOpenChange(false);
if (setTags && data.addToConversation) {
const newTags = [...(tags || []), data.tag].filter(
(tag) => tag !== undefined,
) as string[];
setTags(newTags);
}
},
onError: () => {
showToast({
message: bookmark
? localize('com_ui_bookmarks_update_error')
: localize('com_ui_bookmarks_create_error'),
severity: NotificationSeverity.ERROR,
});
setIsLoading(false);
},
});
};
return (
<form
ref={formRef}
className="mt-6"
aria-label="Bookmark form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
{localize('com_ui_bookmarks_title')}
</Label>
<input
type="text"
id="bookmark-tag"
aria-label="Bookmark"
{...register('tag', {
required: 'tag is required',
maxLength: {
value: 128,
message: localize('com_auth_password_max_length'),
},
validate: (value) => {
return (
value === bookmark?.tag ||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
'tag must be unique'
);
},
})}
aria-invalid={!!errors.tag}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
placeholder=" "
/>
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
{localize('com_ui_bookmarks_description')}
</Label>
<TextareaAutosize
{...register('description', {
maxLength: {
value: 1048,
message: 'Maximum 1048 characters',
},
})}
id="bookmark-description"
disabled={false}
className={cn(
defaultTextProps,
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
)}
/>
</div>
{conversation && (
<div className="mt-2 flex w-full items-center">
<Controller
name="addToConversation"
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="addToConversation"
onClick={() =>
setValue('addToConversation', !getValues('addToConversation'), {
shouldDirty: true,
})
}
>
<div className="flex select-none items-center">
{localize('com_ui_bookmarks_add_to_conversation')}
</div>
</label>
</div>
)}
</div>
</form>
);
};
export default BookmarkForm;

View file

@ -0,0 +1,74 @@
import { useState } from 'react';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import type { FC } from 'react';
import { Spinner } from '~/components/svg';
import { cn } from '~/utils';
type MenuItemProps = {
tag: string;
selected: boolean;
count?: number;
handleSubmit: (tag: string) => Promise<void>;
icon?: React.ReactNode;
highlightSelected?: boolean;
};
const BookmarkItem: FC<MenuItemProps> = ({
tag,
selected,
count,
handleSubmit,
icon,
highlightSelected,
...rest
}) => {
const [isLoading, setIsLoading] = useState(false);
const clickHandler = async () => {
setIsLoading(true);
await handleSubmit(tag);
setIsLoading(false);
};
return (
<div
role="menuitem"
className={cn(
'group m-1.5 flex cursor-pointer gap-2 rounded px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50',
'hover:bg-black/5 dark:hover:bg-white/5',
highlightSelected && selected && 'bg-black/5 dark:bg-white/5',
)}
tabIndex={-1}
{...rest}
onClick={clickHandler}
>
<div className="flex grow items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon ? (
icon
) : isLoading ? (
<Spinner className="size-4" />
) : selected ? (
<BookmarkFilledIcon className="size-4" />
) : (
<BookmarkIcon className="size-4" />
)}
<div className="break-all">{tag}</div>
</div>
{count !== undefined && (
<div className="flex items-center justify-end">
<span
className={cn(
'ml-auto w-9 min-w-max whitespace-nowrap rounded-md bg-white px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-gray-600',
'dark:bg-gray-800 dark:text-white',
)}
aria-hidden="true"
>
{count}
</span>
</div>
)}
</div>
</div>
);
};
export default BookmarkItem;

View file

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

View file

@ -0,0 +1,58 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { useDeleteConversationTagMutation } from '~/data-provider';
import TooltipIcon from '~/components/ui/TooltipIcon';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import { Label } from '~/components/ui';
import { useLocalize } from '~/hooks';
const DeleteBookmarkButton: FC<{
bookmark: string;
tabIndex?: number;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ bookmark, tabIndex = 0, onFocus, onBlur }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const deleteBookmarkMutation = useDeleteConversationTagMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_delete_success'),
});
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const confirmDelete = useCallback(async () => {
await deleteBookmarkMutation.mutateAsync(bookmark);
}, [bookmark, deleteBookmarkMutation]);
return (
<TooltipIcon
disabled={false}
appendLabel={false}
title="Delete Bookmark"
confirmMessage={
<Label htmlFor="bookmark" className="text-left text-sm font-medium">
{localize('com_ui_bookmark_delete_confirm')} {bookmark}
</Label>
}
confirm={confirmDelete}
className="transition-color flex h-7 w-7 min-w-7 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
icon={<TrashIcon className="size-4" />}
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
/>
);
};
export default DeleteBookmarkButton;

View file

@ -0,0 +1,42 @@
import type { FC } from 'react';
import type { TConversationTag } from 'librechat-data-provider';
import BookmarkEditDialog from './BookmarkEditDialog';
import { EditIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '~/components/ui';
const EditBookmarkButton: FC<{
bookmark: TConversationTag;
tabIndex?: number;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ bookmark, tabIndex = 0, onFocus, onBlur }) => {
const localize = useLocalize();
return (
<BookmarkEditDialog
bookmark={bookmark}
trigger={
<button
type="button"
className="transition-color flex h-7 w-7 min-w-7 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<EditIcon />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
{localize('com_ui_edit')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
}
/>
);
};
export default EditBookmarkButton;

View file

@ -0,0 +1,6 @@
export { default as DeleteBookmarkButton } from './DeleteBookmarkButton';
export { default as EditBookmarkButton } from './EditBookmarkButton';
export { default as BookmarkEditDialog } from './BookmarkEditDialog';
export { default as BookmarkItems } from './BookmarkItems';
export { default as BookmarkItem } from './BookmarkItem';
export { default as BookmarkForm } from './BookmarkForm';

View file

@ -6,6 +6,7 @@ import type { ContextType } from '~/common';
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import ExportAndShareMenu from './ExportAndShareMenu';
import HeaderOptions from './Input/HeaderOptions';
import BookmarkMenu from './Menus/BookmarkMenu';
import AddMultiConvo from './AddMultiConvo';
import { useMediaQuery } from '~/hooks';
@ -37,6 +38,7 @@ export default function Header() {
className="pl-0"
/>
)}
<BookmarkMenu />
<AddMultiConvo />
</div>
{!isSmallScreen && (

View file

@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
return (
<button
type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-white bg-gray-500 p-0.5 text-white transition-colors hover:bg-black hover:opacity-100 group-hover:opacity-100 md:opacity-0"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-gray-500 bg-gray-500 p-0.5 text-white transition-colors hover:bg-gray-700 hover:opacity-100 group-hover:opacity-100 md:opacity-0"
onClick={onRemove}
>
<span>
@ -15,6 +15,8 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
strokeLinejoin="round"
className="icon-sm"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />

View file

@ -32,7 +32,7 @@ import {
DropdownMenuTrigger,
} from '~/components/ui';
import { useDeleteFilesFromTable } from '~/hooks/Files';
import { NewTrashIcon, Spinner } from '~/components/svg';
import { TrashIcon, Spinner } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
interface DataTableProps<TData, TValue> {
@ -102,7 +102,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{isDeleting ? (
<Spinner className="h-4 w-4" />
) : (
<NewTrashIcon className="h-4 w-4 text-red-400" />
<TrashIcon className="h-4 w-4 text-red-400" />
)}
{localize('com_ui_delete')}
</Button>

View file

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

View file

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

View file

@ -33,6 +33,7 @@ export default function MessagesView({
<div
onScroll={debouncedHandleScroll}
ref={scrollableRef}
tabIndex={0}
style={{
height: '100%',
overflowY: 'auto',

View file

@ -1,6 +1,7 @@
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { useState, useRef, useMemo } from 'react';
import { Constants } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import { useUpdateConversationMutation } from '~/data-provider';
@ -9,14 +10,14 @@ import { useConversations, useNavigateToConvo } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { ArchiveIcon } from '~/components/svg';
import { useToastContext } from '~/Providers';
import DropDownMenu from './DropDownMenu';
import ArchiveButton from './ArchiveButton';
import DropDownMenu from './DropDownMenu';
import DeleteButton from './DeleteButton';
import RenameButton from './RenameButton';
import HoverToggle from './HoverToggle';
import ShareButton from './ShareButton';
import { cn } from '~/utils';
import store from '~/store';
import ShareButton from './ShareButton';
type KeyEvent = KeyboardEvent<HTMLInputElement>;
@ -51,7 +52,8 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
// set document title
document.title = title;
navigateWithLastTools(conversation);
/* Note: Latest Message should not be reset if existing convo */
navigateWithLastTools(conversation, !conversationId || conversationId === Constants.NEW_CONVO);
};
const renameHandler = (e: MouseEvent<HTMLButtonElement>) => {

View file

@ -1,5 +1,5 @@
import React from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { anthropicSettings } from 'librechat-data-provider';
import type { TModelSelectProps, OnInputNumberChange } from '~/common';
import {
Input,
@ -41,15 +41,31 @@ export default function Settings({ conversation, setOption, models, readonly }:
return null;
}
const setModel = setOption('model');
const setModelLabel = setOption('modelLabel');
const setPromptPrefix = setOption('promptPrefix');
const setTemperature = setOption('temperature');
const setTopP = setOption('topP');
const setTopK = setOption('topK');
const setMaxOutputTokens = setOption('maxOutputTokens');
const setResendFiles = setOption('resendFiles');
const setModel = (newModel: string) => {
const modelSetter = setOption('model');
const maxOutputSetter = setOption('maxOutputTokens');
if (maxOutputTokens) {
maxOutputSetter(anthropicSettings.maxOutputTokens.set(maxOutputTokens, newModel));
}
modelSetter(newModel);
};
const setMaxOutputTokens = (value: number) => {
const setter = setOption('maxOutputTokens');
if (model) {
setter(anthropicSettings.maxOutputTokens.set(value, model));
} else {
setter(value);
}
};
return (
<div className="grid grid-cols-5 gap-6">
<div className="col-span-5 flex flex-col items-center justify-start gap-6 sm:col-span-3">
@ -139,14 +155,16 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small>
<small className="opacity-40">
({localize('com_endpoint_default')}: {anthropicSettings.temperature.default})
</small>
</Label>
<InputNumber
id="temp-int"
disabled={readonly}
value={temperature}
onChange={(value) => setTemperature(Number(value))}
max={1}
max={anthropicSettings.temperature.max}
min={0}
step={0.01}
controls={false}
@ -161,10 +179,10 @@ export default function Settings({ conversation, setOption, models, readonly }:
</div>
<Slider
disabled={readonly}
value={[temperature ?? 1]}
value={[temperature ?? anthropicSettings.temperature.default]}
onValueChange={(value) => setTemperature(value[0])}
doubleClickHandler={() => setTemperature(1)}
max={1}
doubleClickHandler={() => setTemperature(anthropicSettings.temperature.default)}
max={anthropicSettings.temperature.max}
min={0}
step={0.01}
className="flex h-4 w-full"
@ -178,7 +196,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', '0.7')})
({localize('com_endpoint_default_with_num', anthropicSettings.topP.default + '')})
</small>
</Label>
<InputNumber
@ -186,7 +204,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
disabled={readonly}
value={topP}
onChange={(value) => setTopP(Number(value))}
max={1}
max={anthropicSettings.topP.max}
min={0}
step={0.01}
controls={false}
@ -203,8 +221,8 @@ export default function Settings({ conversation, setOption, models, readonly }:
disabled={readonly}
value={[topP ?? 0.7]}
onValueChange={(value) => setTopP(value[0])}
doubleClickHandler={() => setTopP(1)}
max={1}
doubleClickHandler={() => setTopP(anthropicSettings.topP.default)}
max={anthropicSettings.topP.max}
min={0}
step={0.01}
className="flex h-4 w-full"
@ -219,7 +237,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
<Label htmlFor="top-k-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_k')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', '5')})
({localize('com_endpoint_default_with_num', anthropicSettings.topK.default + '')})
</small>
</Label>
<InputNumber
@ -227,7 +245,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
disabled={readonly}
value={topK}
onChange={(value) => setTopK(Number(value))}
max={40}
max={anthropicSettings.topK.max}
min={1}
step={0.01}
controls={false}
@ -244,8 +262,8 @@ export default function Settings({ conversation, setOption, models, readonly }:
disabled={readonly}
value={[topK ?? 5]}
onValueChange={(value) => setTopK(value[0])}
doubleClickHandler={() => setTopK(0)}
max={40}
doubleClickHandler={() => setTopK(anthropicSettings.topK.default)}
max={anthropicSettings.topK.max}
min={1}
step={0.01}
className="flex h-4 w-full"
@ -258,16 +276,14 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="flex justify-between">
<Label htmlFor="max-tokens-int" className="text-left text-sm font-medium">
{localize('com_endpoint_max_output_tokens')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', '4000')})
</small>
<small className="opacity-40">({anthropicSettings.maxOutputTokens.default})</small>
</Label>
<InputNumber
id="max-tokens-int"
disabled={readonly}
value={maxOutputTokens}
onChange={(value) => setMaxOutputTokens(Number(value))}
max={4000}
max={anthropicSettings.maxOutputTokens.max}
min={1}
step={1}
controls={false}
@ -282,10 +298,12 @@ export default function Settings({ conversation, setOption, models, readonly }:
</div>
<Slider
disabled={readonly}
value={[maxOutputTokens ?? 4000]}
value={[maxOutputTokens ?? anthropicSettings.maxOutputTokens.default]}
onValueChange={(value) => setMaxOutputTokens(value[0])}
doubleClickHandler={() => setMaxOutputTokens(0)}
max={4000}
doubleClickHandler={() =>
setMaxOutputTokens(anthropicSettings.maxOutputTokens.default)
}
max={anthropicSettings.maxOutputTokens.max}
min={1}
step={1}
className="flex h-4 w-full"

View file

@ -1,5 +1,5 @@
import React from 'react';
import { CrossIcon, NewTrashIcon } from '~/components/svg';
import { CrossIcon, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type DeleteIconButtonProps = {
@ -10,7 +10,7 @@ export default function DeleteIconButton({ onClick }: DeleteIconButtonProps) {
return (
<div className="w-fit">
<Button className="bg-red-400 p-3" onClick={onClick}>
<NewTrashIcon />
<TrashIcon />
</Button>
</div>
);

View file

@ -32,7 +32,7 @@ import {
DropdownMenuTrigger,
} from '~/components/ui';
import { useDeleteFilesFromTable } from '~/hooks/Files';
import { NewTrashIcon, Spinner } from '~/components/svg';
import { TrashIcon, Spinner } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import ActionButton from '../ActionButton';
import UploadFileButton from './UploadFileButton';
@ -112,7 +112,7 @@ export default function DataTableFile<TData, TValue>({
{isDeleting ? (
<Spinner className="h-4 w-4" />
) : (
<NewTrashIcon className="h-4 w-4 text-red-400" />
<TrashIcon className="h-4 w-4 text-red-400" />
)}
{localize('com_ui_delete')}
</Button>

View file

@ -1,6 +1,6 @@
import type { TFile } from 'librechat-data-provider';
import React from 'react';
import { NewTrashIcon } from '~/components/svg';
import { TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type FileListItemProps = {
@ -25,7 +25,7 @@ export default function FileListItem({ file, deleteFile, width = '400px' }: File
className="my-0 ml-3 bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => deleteFile(file._id)}
>
<NewTrashIcon className="m-0 p-0" />
<TrashIcon className="m-0 p-0" />
</Button>
</div>
</div>

View file

@ -2,7 +2,7 @@ import type { TFile } from 'librechat-data-provider';
import { FileIcon, PlusIcon } from 'lucide-react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { DotsIcon, NewTrashIcon } from '~/components/svg';
import { DotsIcon, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type FileListItemProps = {
@ -68,7 +68,7 @@ export default function FileListItem2({
className="w-min bg-transparent text-[#666666] hover:bg-slate-200"
onClick={() => deleteFile(file._id)}
>
<NewTrashIcon className="" />
<TrashIcon className="" />
</Button>
</div>
</div>

View file

@ -1,7 +1,7 @@
import { TFile } from 'librechat-data-provider/dist/types';
import React, { useState } from 'react';
import { TThread, TVectorStore } from '~/common';
import { CheckMark, NewTrashIcon } from '~/components/svg';
import { CheckMark, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
import DeleteIconButton from '../DeleteIconButton';
import VectorStoreButton from '../VectorStore/VectorStoreButton';
@ -140,7 +140,7 @@ export default function FilePreview() {
}}
variant={'ghost'}
>
<NewTrashIcon className="m-0 p-0" />
<TrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
@ -167,7 +167,7 @@ export default function FilePreview() {
console.log('Remove from thread');
}}
>
<NewTrashIcon className="m-0 p-0" />
<TrashIcon className="m-0 p-0" />
</Button>
</div>
</div>

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { TVectorStore } from '~/common';
import { DotsIcon, NewTrashIcon, TrashIcon } from '~/components/svg';
import { DotsIcon, TrashIcon, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type VectorStoreListItemProps = {
@ -39,7 +39,7 @@ export default function VectorStoreListItem({
className="m-0 w-full bg-transparent p-0 text-[#666666] hover:bg-slate-200 sm:w-fit"
onClick={() => deleteVectorStore(vectorStore._id)}
>
<NewTrashIcon className="m-0 p-0" />
<TrashIcon className="m-0 p-0" />
</Button>
</div>
</div>

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import DeleteIconButton from '../DeleteIconButton';
import { Button } from '~/components/ui';
import { NewTrashIcon } from '~/components/svg';
import { TrashIcon } from '~/components/svg';
import { TFile } from 'librechat-data-provider/dist/types';
import UploadFileButton from '../FileList/UploadFileButton';
import UploadFileModal from '../FileList/UploadFileModal';
@ -204,7 +204,7 @@ export default function VectorStorePreview() {
className="my-0 ml-3 h-min bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => console.log('click')}
>
<NewTrashIcon className="m-0 p-0" />
<TrashIcon className="m-0 p-0" />
</Button>
</div>
</div>

View file

@ -0,0 +1,99 @@
import { useState, type FC } from 'react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useGetConversationTags } from 'librechat-data-provider/react-query';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
type BookmarkNavProps = {
tags: string[];
setTags: (tags: string[]) => void;
};
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps) => {
const localize = useLocalize();
const location = useLocation();
const { data } = useGetConversationTags();
const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [open, setIsOpen] = useState(false);
let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}
// Hide the button if there are no tags
if (!data || !data.some((tag) => tag.count > 0)) {
return null;
}
return (
<Root open={open} onOpenChange={setIsOpen}>
<Trigger asChild>
<button
className={cn(
'group-ui-open:bg-gray-100 dark:group-ui-open:bg-gray-700 duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800',
open ? 'bg-gray-100 dark:bg-gray-800' : '',
)}
id="presets-button"
data-testid="presets-button"
title={localize('com_endpoint_examples')}
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
<div className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 dark:text-white">
{tags.length > 0 ? (
<BookmarkFilledIcon className="h-6 w-6" />
) : (
<BookmarkIcon className="h-6 w-6" />
)}
</div>
</div>
</div>
<div
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-black dark:text-gray-100"
style={{ marginTop: '0', marginLeft: '0' }}
>
{tags.length > 0 ? tags.join(',') : localize('com_ui_bookmarks')}
</div>
</button>
</Trigger>
<Portal>
<div className="fixed left-0 top-0 z-auto translate-x-[268px] translate-y-[50px]">
<Content
side="bottom"
align="start"
className="mt-2 max-h-96 min-w-[240px] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-96"
>
{data && conversation && data.some((tag) => tag.count > 0) && (
// Display bookmarks and highlight the selected tag
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems
// Currently selected conversation
conversation={conversation}
// List of selected tags(string)
tags={tags}
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
setTags={setTags}
/>
</BookmarkContext.Provider>
)}
</Content>
</div>
</Portal>
</Root>
);
};
export default BookmarkNav;

View file

@ -0,0 +1,58 @@
import { useEffect, useState, type FC } from 'react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import type { TConversation } from 'librechat-data-provider';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
const BookmarkNavItems: FC<{
conversation: TConversation;
tags: string[];
setTags: (tags: string[]) => void;
}> = ({ conversation, tags, setTags }) => {
const [currentConversation, setCurrentConversation] = useState<TConversation>();
useEffect(() => {
if (!currentConversation) {
setCurrentConversation(conversation);
}
}, [conversation, currentConversation]);
const getUpdatedSelected = (tag: string) => {
if (tags.some((selectedTag) => selectedTag === tag)) {
return tags.filter((selectedTag) => selectedTag !== tag);
} else {
return [...(tags || []), tag];
}
};
const handleSubmit = (tag: string) => {
const updatedSelected = getUpdatedSelected(tag);
setTags(updatedSelected);
return Promise.resolve();
};
const clear = () => {
setTags([]);
return Promise.resolve();
};
return (
<>
<BookmarkItems
tags={tags}
handleSubmit={handleSubmit}
highlightSelected={true}
header={
<BookmarkItem
tag="Clear all"
data-testid="bookmark-item-clear"
handleSubmit={clear}
selected={false}
icon={<CrossCircledIcon className="h-4 w-4" />}
/>
}
/>
</>
);
};
export default BookmarkNavItems;

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import type { ConversationListResponse } from 'librechat-data-provider';
import {
useMediaQuery,
useAuthContext,
@ -12,6 +13,7 @@ import {
import { useConversationsInfiniteQuery } from '~/data-provider';
import { TooltipProvider, Tooltip } from '~/components/ui';
import { Conversations } from '~/components/Conversations';
import BookmarkNav from './Bookmarks/BookmarkNav';
import { useSearchContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import SearchBar from './SearchBar';
@ -19,7 +21,6 @@ import NavToggle from './NavToggle';
import NavLinks from './NavLinks';
import NewChat from './NewChat';
import { cn } from '~/utils';
import { ConversationListResponse } from 'librechat-data-provider';
import store from '~/store';
const Nav = ({ navVisible, setNavVisible }) => {
@ -58,12 +59,21 @@ const Nav = ({ navVisible, setNavVisible }) => {
const { refreshConversations } = useConversations();
const { pageNumber, searchQuery, setPageNumber, searchQueryRes } = useSearchContext();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery(
{ pageNumber: pageNumber.toString(), isArchived: false },
{ enabled: isAuthenticated },
);
const [tags, setTags] = useState<string[]>([]);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
useConversationsInfiniteQuery(
{
pageNumber: pageNumber.toString(),
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{ enabled: isAuthenticated },
);
useEffect(() => {
// When a tag is selected, refetch the list of conversations related to that tag
refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
hasNextPage: searchQuery ? searchQueryRes.hasNextPage : hasNextPage,
@ -154,6 +164,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
/>
)}
</div>
<BookmarkNav tags={tags} setTags={setTags} />
<NavLinks />
</nav>
</div>
@ -168,7 +179,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
navVisible={navVisible}
className="fixed left-0 top-1/2 z-40 hidden md:flex"
/>
<div className={`nav-mask${navVisible ? ' active' : ''}`} onClick={toggleNavVisible} />
<div className={`nav-mask${navVisible ? 'active' : ''}`} onClick={toggleNavVisible} />
</Tooltip>
</TooltipProvider>
);

View file

@ -1,12 +1,9 @@
import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { Lightbulb, Cog } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { Lightbulb, Cog } from 'lucide-react';
import { useOnClickOutside, useMediaQuery } from '~/hooks';
import store from '~/store';
import { cn } from '~/utils';
import ConversationModeSwitch from './ConversationModeSwitch';
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
import {
CloudBrowserVoicesSwitch,
AutomaticPlaybackSwitch,
@ -24,7 +21,10 @@ import {
EngineSTTDropdown,
DecibelSelector,
} from './STT';
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
import ConversationModeSwitch from './ConversationModeSwitch';
import { useOnClickOutside, useMediaQuery } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
function Speech() {
const [confirmClear, setConfirmClear] = useState(false);
@ -75,7 +75,11 @@ function Speech() {
playbackRate: { value: playbackRate, setFunc: setPlaybackRate },
};
if (settings[key].value !== newValue || settings[key].value === newValue || !settings[key]) {
if (
(settings[key].value !== newValue || settings[key].value === newValue || !settings[key]) &&
settings[key].value === 'sttExternal' &&
settings[key].value === 'ttsExternal'
) {
return;
}
@ -131,8 +135,7 @@ function Speech() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
console.log(sttExternal);
console.log(ttsExternal);
logger.log({ sttExternal, ttsExternal });
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);

View file

@ -18,7 +18,7 @@ import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { RenameButton } from '~/components/Conversations';
import { useLocalize, useAuthContext } from '~/hooks';
import { NewTrashIcon } from '~/components/svg';
import { TrashIcon } from '~/components/svg';
import { cn } from '~/utils/';
export default function DashGroupItem({
@ -169,7 +169,7 @@ export default function DashGroupItem({
e.stopPropagation();
}}
>
<NewTrashIcon className="icon-md text-gray-600 dark:text-gray-300" />
<TrashIcon className="icon-md text-gray-600 dark:text-gray-300" />
</Button>
</DialogTrigger>
<DialogTemplate

View file

@ -3,7 +3,7 @@ import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
import type { TPromptGroup } from 'librechat-data-provider';
import { extractVariableInfo, wrapVariable, replaceSpecialVars } from '~/utils';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { Input } from '~/components/ui';
import { Textarea } from '~/components/ui';
type FormValues = {
fields: { variable: string; value: string }[];
@ -103,11 +103,18 @@ export default function VariableForm({
name={`fields.${index}.value`}
control={control}
render={({ field }) => (
<Input
<Textarea
{...field}
id={`fields.${index}.value`}
className="input text-grey-darker rounded border px-3 py-2 focus:bg-white dark:border-gray-500 dark:focus:bg-gray-700"
className="input text-grey-darker h-10 rounded border px-3 py-2 focus:bg-white dark:border-gray-500 dark:focus:bg-gray-700"
placeholder={uniqueVariables[index]}
onKeyDown={(e) => {
// Submit the form on enter like you would with an Input component
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit((data) => onSubmit(data))();
}
}}
/>
)}
/>

View file

@ -70,7 +70,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
onBlur={() => setIsEditing(false)}
/>
) : (
<span className="block break-words px-2 py-1 dark:text-gray-200">{field.value}</span>
<pre className="block break-words px-2 py-1 dark:text-gray-200">{field.value}</pre>
)
}
/>

View file

@ -1,20 +1,31 @@
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import { useGetSharedMessages, useGetStartupConfig } from 'librechat-data-provider/react-query';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { ShareContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import MessagesView from './MessagesView';
import { useLocalize } from '~/hooks';
import { buildTree } from '~/utils';
import Footer from '../Chat/Footer';
function SharedView() {
const localize = useLocalize();
const { data: config } = useGetStartupConfig();
const { shareId } = useParams();
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : dataTree ?? null;
// configure document title
let docTitle = '';
if (config?.appTitle && data?.title) {
docTitle = `${data?.title} | ${config.appTitle}`;
} else {
docTitle = data?.title || config?.appTitle || document.title;
}
useDocumentTitle(docTitle);
return (
<ShareContext.Provider value={{ isSharedConvo: true }}>
<div

View file

@ -0,0 +1,46 @@
import { BookmarkPlusIcon } from 'lucide-react';
import { useConversationTagsQuery, useRebuildConversationTagsMutation } from '~/data-provider';
import { Button } from '~/components/ui';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import BookmarkTable from './BookmarkTable';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
const BookmarkPanel = () => {
const localize = useLocalize();
const { mutate, isLoading } = useRebuildConversationTagsMutation();
const { data } = useConversationTagsQuery();
const rebuildTags = () => {
mutate({});
};
return (
<div className="h-auto max-w-full overflow-x-hidden">
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
<BookmarkTable />
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={rebuildTags} className="w-50 text-sm">
{isLoading ? (
<Spinner />
) : (
<div className="flex gap-2">
{localize('com_ui_bookmarks_rebuild')}
<HoverCardSettings side="top" text="com_nav_info_bookmarks_rebuild" />
</div>
)}
</Button>
<BookmarkEditDialog
trigger={
<Button variant="outline" onClick={rebuildTags} className="w-full text-sm">
<BookmarkPlusIcon className="mr-1 size-4" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</Button>
}
/>
</div>
</BookmarkContext.Provider>
</div>
);
};
export default BookmarkPanel;

View file

@ -0,0 +1,97 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider';
import { Table, TableHeader, TableBody, TableRow, TableCell, Input, Button } from '~/components/ui';
import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext';
import BookmarkTableRow from './BookmarkTableRow';
import { useLocalize } from '~/hooks';
const BookmarkTable = () => {
const localize = useLocalize();
const [rows, setRows] = useState<ConversationTagsResponse>([]);
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const pageSize = 10;
const { bookmarks } = useBookmarkContext();
useEffect(() => {
setRows(bookmarks?.map((item) => ({ id: item.tag, ...item })) || []);
}, [bookmarks]);
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
setRows((prevTags: TConversationTag[]) => {
const updatedRows = [...prevTags];
const [movedRow] = updatedRows.splice(dragIndex, 1);
updatedRows.splice(hoverIndex, 0, movedRow);
return updatedRows;
});
}, []);
const renderRow = useCallback((row: TConversationTag, position: number) => {
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={position} />;
}, []);
const filteredRows = rows.filter((row) =>
row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
);
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
return (
<BookmarkContext.Provider value={{ bookmarks }}>
<div className="flex items-center gap-4 py-4">
<Input
placeholder={localize('com_ui_bookmarks_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full dark:border-gray-700"
/>
</div>
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
<Table className="table-fixed border-separate border-spacing-0">
<TableHeader>
<TableRow>
<TableCell className="w-full px-3 py-3.5 pl-6 dark:bg-gray-700">
<div>{localize('com_ui_bookmarks_title')}</div>
</TableCell>
<TableCell className="w-full px-3 py-3.5 dark:bg-gray-700 sm:pl-6">
<div>{localize('com_ui_bookmarks_count')}</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{currentRows.map((row, i) => renderRow(row, i))}</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4">
<div className="pl-1 text-gray-400">
{localize('com_ui_showing')} {pageIndex * pageSize + 1} -{' '}
{Math.min((pageIndex + 1) * pageSize, filteredRows.length)} {localize('com_ui_of')}{' '}
{filteredRows.length}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
>
{localize('com_ui_prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
</BookmarkContext.Provider>
);
};
export default BookmarkTable;

View file

@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import type { TConversationTag } from 'librechat-data-provider';
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
import { TableRow, TableCell } from '~/components/ui';
interface BookmarkTableRowProps {
row: TConversationTag;
moveRow: (dragIndex: number, hoverIndex: number) => void;
position: number;
}
const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => {
const [isHovered, setIsHovered] = useState(false);
const ref = React.useRef<HTMLTableRowElement>(null);
const [, drop] = useDrop({
accept: 'bookmark',
hover(item: { index: number }) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = position;
if (dragIndex === hoverIndex) {
return;
}
moveRow(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: 'bookmark',
item: { index: position },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<TableRow
ref={ref}
className="cursor-move hover:bg-gray-100 dark:hover:bg-gray-800"
style={{ opacity: isDragging ? 0.5 : 1 }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<TableCell className="w-full px-3 py-3.5 pl-6">
<div className="truncate">{row.tag}</div>
</TableCell>
<TableCell className="w-full px-3 py-3.5 sm:pl-6">
<div className="flex items-center justify-between py-1">
<div>{row.count}</div>
<div
className="flex items-center gap-2"
style={{
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.1s ease-in-out',
}}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
>
<EditBookmarkButton
bookmark={row}
tabIndex={0}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
/>
<DeleteBookmarkButton
bookmark={row.tag}
tabIndex={0}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
/>
</div>
</div>
</TableCell>
</TableRow>
);
};
export default BookmarkTableRow;

View file

@ -7,9 +7,10 @@ import {
} from 'librechat-data-provider';
import type { AssistantPanelProps, ActionAuthForm } from '~/common';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { Dialog, DialogTrigger } from '~/components/ui';
import { Dialog, DialogTrigger, OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useDeleteAction } from '~/data-provider';
import { NewTrashIcon } from '~/components/svg';
import { TrashIcon } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import ActionsInput from './ActionsInput';
import ActionsAuth from './ActionsAuth';
@ -119,33 +120,52 @@ export default function ActionsPanel({
</div>
</button>
</div>
{!!action && (
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!assistant_id || !action.action_id}
className="btn relative bg-transparent text-red-500 hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => {
if (!assistant_id) {
return prompt('No assistant_id found, is the assistant created?');
}
const confirmed = confirm('Are you sure you want to delete this action?');
if (confirmed) {
<OGDialog>
<OGDialogTrigger asChild>
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!assistant_id || !action.action_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
</button>
</div>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_action')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_action_confirm')}
</Label>
}
selection={{
selectHandler: () => {
if (!assistant_id) {
return showToast({
message: 'No assistant_id found, is the assistant created?',
status: 'error',
});
}
deleteAction.mutate({
model: assistantMap[endpoint][assistant_id].model,
action_id: action.action_id,
assistant_id,
endpoint,
});
}
},
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
>
<div className="flex w-full items-center justify-center gap-2">
<NewTrashIcon className="icon-md text-red-500" />
</div>
</button>
</div>
/>
</OGDialog>
)}
<div className="text-xl font-medium">{(action ? 'Edit' : 'Add') + ' ' + 'actions'}</div>
<div className="text-token-text-tertiary text-sm">
{localize('com_assistants_actions_info')}

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import type { Action } from 'librechat-data-provider';
import GearIcon from '~/components/svg/GearIcon';
@ -8,11 +9,15 @@ export default function AssistantAction({
action: Action;
onClick: () => void;
}) {
const [isHovering, setIsHovering] = useState(false);
return (
<div>
<div
onClick={onClick}
className="border-token-border-medium flex w-full rounded-lg border text-sm hover:cursor-pointer"
className="flex w-full rounded-lg text-sm hover:cursor-pointer"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div
className="h-9 grow whitespace-nowrap px-3 py-2"
@ -20,13 +25,14 @@ export default function AssistantAction({
>
{action.metadata.domain}
</div>
<div className="w-px bg-gray-300 dark:bg-gray-600" />
<button
type="button"
className="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg rounded-l-none"
>
<GearIcon className="icon-sm" />
</button>
{isHovering && (
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<GearIcon className="icon-sm" />
</button>
)}
</div>
</div>
);

View file

@ -13,6 +13,7 @@ import {
import type { FunctionTool, TConfig, TPlugin } from 'librechat-data-provider';
import type { AssistantForm, AssistantPanelProps } from '~/common';
import { useCreateAssistantMutation, useUpdateAssistantMutation } from '~/data-provider';
import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { useSelectAssistant, useLocalize } from '~/hooks';
import { ToolSelectDialog } from '~/components/Tools';
@ -24,13 +25,15 @@ import AssistantAction from './AssistantAction';
import ContextButton from './ContextButton';
import AssistantTool from './AssistantTool';
import { Spinner } from '~/components/svg';
import { cn, cardStyle } from '~/utils/';
import Knowledge from './Knowledge';
import { Panel } from '~/common';
const labelClass = 'mb-2 block text-xs font-bold text-gray-700 dark:text-gray-400';
const inputClass =
'focus:shadow-outline w-full appearance-none rounded-md border px-3 py-2 text-sm leading-tight text-gray-700 dark:text-white shadow focus:border-green-500 focus:outline-none focus:ring-0 dark:bg-gray-800 dark:border-gray-700/80';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
const inputClass = cn(
defaultTextProps,
'flex w-full px-3 py-2 dark:border-gray-800 dark:bg-gray-800',
removeFocusOutlines,
);
export default function AssistantPanel({
// index = 0,
@ -297,7 +300,7 @@ export default function AssistantPanel({
{...field}
value={field.value ?? ''}
{...{ max: 32768 }}
className="focus:shadow-outline min-h-[150px] w-full resize-none resize-y appearance-none rounded-md border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-700/80 dark:bg-gray-800 dark:text-white"
className={cn(inputClass, 'min-h-[100px] resize-none resize-y')}
id="instructions"
placeholder={localize('com_assistants_instructions_placeholder')}
rows={3}
@ -357,7 +360,7 @@ export default function AssistantPanel({
${toolsEnabled && actionsEnabled ? ' + ' : ''}
${actionsEnabled ? localize('com_assistants_actions') : ''}`}
</label>
<div className="space-y-1">
<div className="space-y-2">
{functions.map((func, i) => (
<AssistantTool
key={`${func}-${i}-${assistant_id}`}
@ -373,37 +376,39 @@ export default function AssistantPanel({
<AssistantAction key={i} action={action} onClick={() => setAction(action)} />
);
})}
{toolsEnabled && (
<button
type="button"
onClick={() => setShowToolDialog(true)}
className="btn border-token-border-light relative mx-1 mt-2 h-8 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_tools')}
</div>
</button>
)}
{actionsEnabled && (
<button
type="button"
disabled={!assistant_id}
onClick={() => {
if (!assistant_id) {
return showToast({
message: localize('com_assistants_actions_disabled'),
status: 'warning',
});
}
setActivePanel(Panel.actions);
}}
className="btn border-token-border-light relative mt-2 h-8 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_actions')}
</div>
</button>
)}
<div className="flex space-x-2">
{toolsEnabled && (
<button
type="button"
onClick={() => setShowToolDialog(true)}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_tools')}
</div>
</button>
)}
{actionsEnabled && (
<button
type="button"
disabled={!assistant_id}
onClick={() => {
if (!assistant_id) {
return showToast({
message: localize('com_assistants_actions_disabled'),
status: 'warning',
});
}
setActivePanel(Panel.actions);
}}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_actions')}
</div>
</button>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
@ -415,23 +420,9 @@ export default function AssistantPanel({
createMutation={create}
endpoint={endpoint}
/>
{/* Secondary Select Button */}
{assistant_id && (
<button
className="btn btn-secondary"
type="button"
disabled={!assistant_id}
onClick={(e) => {
e.preventDefault();
onSelectAssistant(assistant_id);
}}
>
{localize('com_ui_select')}
</button>
)}
{/* Submit Button */}
<button
className="btn btn-primary focus:shadow-outline flex w-[90px] items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
type="submit"
>
{create.isLoading || update.isLoading ? (

View file

@ -1,5 +1,12 @@
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import type { TPlugin } from 'librechat-data-provider';
import GearIcon from '~/components/svg/GearIcon';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function AssistantTool({
@ -11,42 +18,89 @@ export default function AssistantTool({
allTools: TPlugin[];
assistant_id?: string;
}) {
const [isHovering, setIsHovering] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext();
const currentTool = allTools.find((t) => t.pluginKey === tool);
const removeTool = (tool: string) => {
if (tool) {
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isAssistantTool: true },
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const fns = getValues('functions').filter((fn) => fn !== tool);
setValue('functions', fns);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
);
}
};
if (!currentTool) {
return null;
}
return (
<div>
<OGDialog>
<div
className={cn(
'border-token-border-medium flex w-full rounded-lg border text-sm hover:cursor-pointer',
'flex w-full items-center rounded-lg text-sm',
!assistant_id ? 'opacity-40' : '',
)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{currentTool.icon && (
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
/>
<div className="flex grow items-center">
{currentTool.icon && (
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
/>
</div>
)}
<div
className="h-9 grow px-3 py-2"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.name}
</div>
)}
<div
className="h-9 grow px-3 py-2"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.name}
</div>
<div className="w-px bg-gray-300 dark:bg-gray-600" />
<button
type="button"
className="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg rounded-l-none"
>
<GearIcon className="icon-sm" />
</button>
{isHovering && (
<OGDialogTrigger asChild>
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<TrashIcon />
</button>
</OGDialogTrigger>
)}
</div>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(currentTool.pluginKey),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
}

View file

@ -1,9 +1,12 @@
import { useMemo } from 'react';
import { Capabilities } from 'librechat-data-provider';
import { useFormContext, useWatch } from 'react-hook-form';
import type { TConfig, AssistantsEndpoint } from 'librechat-data-provider';
import type { AssistantForm } from '~/common';
import ImageVision from './ImageVision';
import { useLocalize } from '~/hooks';
import Retrieval from './Retrieval';
import CodeFiles from './CodeFiles';
import Code from './Code';
export default function CapabilitiesForm({
@ -21,6 +24,17 @@ export default function CapabilitiesForm({
}) {
const localize = useLocalize();
const methods = useFormContext<AssistantForm>();
const { control } = methods;
const assistant = useWatch({ control, name: 'assistant' });
const assistant_id = useWatch({ control, name: 'id' });
const files = useMemo(() => {
if (typeof assistant === 'string') {
return [];
}
return assistant.code_files;
}, [assistant]);
const retrievalModels = useMemo(
() => new Set(assistantsConfig?.retrievalModels ?? []),
[assistantsConfig],
@ -31,7 +45,7 @@ export default function CapabilitiesForm({
);
return (
<div className="mb-6">
<div className="mb-4">
<div className="mb-1.5 flex items-center">
<span>
<label className="text-token-text-primary block font-medium">
@ -40,11 +54,19 @@ export default function CapabilitiesForm({
</span>
</div>
<div className="flex flex-col items-start gap-2">
{codeEnabled && <Code endpoint={endpoint} version={version} />}
{imageVisionEnabled && version == 1 && <ImageVision />}
{codeEnabled && <Code version={version} />}
{retrievalEnabled && (
<Retrieval endpoint={endpoint} version={version} retrievalModels={retrievalModels} />
)}
{imageVisionEnabled && version == 1 && <ImageVision />}
{codeEnabled && version && (
<CodeFiles
assistant_id={assistant_id}
version={version}
endpoint={endpoint}
files={files}
/>
)}
</div>
</div>
);

View file

@ -1,70 +1,66 @@
import { useMemo } from 'react';
import { Capabilities } from 'librechat-data-provider';
import { useFormContext, Controller, useWatch } from 'react-hook-form';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { AssistantForm } from '~/common';
import { Checkbox, QuestionMark } from '~/components/ui';
import {
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
} from '~/components/ui';
import { CircleHelpIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import CodeFiles from './CodeFiles';
import { ESide } from '~/common';
export default function Code({
version,
endpoint,
}: {
version: number | string;
endpoint: AssistantsEndpoint;
}) {
export default function Code({ version }: { version: number | string }) {
const localize = useLocalize();
const methods = useFormContext<AssistantForm>();
const { control, setValue, getValues } = methods;
const assistant = useWatch({ control, name: 'assistant' });
const assistant_id = useWatch({ control, name: 'id' });
const files = useMemo(() => {
if (typeof assistant === 'string') {
return [];
}
return assistant.code_files;
}, [assistant]);
return (
<>
<div className="flex items-center">
<Controller
name={Capabilities.code_interpreter}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.code_interpreter}
onClick={() =>
setValue(Capabilities.code_interpreter, !getValues(Capabilities.code_interpreter), {
shouldDirty: true,
})
}
>
<div className="flex select-none items-center">
{localize('com_assistants_code_interpreter')}
<QuestionMark />
<HoverCard openDelay={50}>
<div className="flex items-center">
<Controller
name={Capabilities.code_interpreter}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<div className="flex items-center space-x-2">
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.code_interpreter}
onClick={() =>
setValue(Capabilities.code_interpreter, !getValues(Capabilities.code_interpreter), {
shouldDirty: true,
})
}
>
{localize('com_assistants_code_interpreter')}
</label>
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-gray-500" />
</HoverCardTrigger>
</div>
</label>
</div>
{version == 2 && (
<CodeFiles
assistant_id={assistant_id}
version={version}
endpoint={endpoint}
files={files}
/>
)}
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">
{version == 2 && localize('com_assistants_code_interpreter_info')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</div>
</HoverCard>
</>
);
}

View file

@ -58,7 +58,7 @@ export default function CodeFiles({
};
return (
<div className={'mb-2'}>
<div className="mb-2 w-full">
<div className="flex flex-col gap-4">
<div className="text-token-text-tertiary rounded-lg text-xs">
{localize('com_assistants_code_interpreter_files')}
@ -75,7 +75,7 @@ export default function CodeFiles({
<button
type="button"
disabled={!assistant_id}
className="btn btn-neutral border-token-border-light relative h-8 rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
onClick={handleButtonClick}
>
<div className="flex w-full items-center justify-center gap-2">

View file

@ -1,4 +1,3 @@
import * as Popover from '@radix-ui/react-popover';
import type { Assistant, AssistantCreateParams, AssistantsEndpoint } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import { Dialog, DialogTrigger, Label } from '~/components/ui';
@ -7,7 +6,7 @@ import { useDeleteAssistantMutation } from '~/data-provider';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { useLocalize, useSetIndexOptions } from '~/hooks';
import { cn, removeFocusOutlines } from '~/utils/';
import { NewTrashIcon } from '~/components/svg';
import { TrashIcon } from '~/components/svg';
export default function ContextButton({
activeModel,
@ -78,88 +77,40 @@ export default function ContextButton({
return (
<Dialog>
<Popover.Root>
<Popover.Trigger asChild>
<button
className={cn(
'btn border-token-border-light relative h-9 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800',
removeFocusOutlines,
)}
type="button"
>
<div className="flex w-full items-center justify-center gap-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 12C3 10.8954 3.89543 10 5 10C6.10457 10 7 10.8954 7 12C7 13.1046 6.10457 14 5 14C3.89543 14 3 13.1046 3 12ZM10 12C10 10.8954 10.8954 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8954 14 10 13.1046 10 12ZM17 12C17 10.8954 17.8954 10 19 10C20.1046 10 21 10.8954 21 12C21 13.1046 20.1046 14 19 14C17.8954 14 17 13.1046 17 12Z"
fill="currentColor"
/>
</svg>
</div>
</button>
</Popover.Trigger>
<div
style={{
position: 'fixed',
left: ' 0px',
top: ' 0px',
transform: 'translate(1772.8px, 49.6px)',
minWidth: 'max-content',
zIndex: 'auto',
}}
dir="ltr"
<DialogTrigger asChild>
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
type="button"
>
<Popover.Content
side="top"
role="menu"
className="bg-token-surface-primary min-w-[180px] max-w-xs rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-900 dark:bg-gray-850"
style={{ outline: 'none', pointerEvents: 'auto' }}
sideOffset={8}
tabIndex={-1}
align="end"
>
<DialogTrigger asChild>
<Popover.Close
role="menuitem"
className="group m-1.5 flex w-full cursor-pointer gap-2 rounded p-2.5 text-sm text-red-500 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
tabIndex={-1}
>
<NewTrashIcon />
{localize('com_ui_delete') + ' ' + localize('com_ui_assistant')}
</Popover.Close>
</DialogTrigger>
</Popover.Content>
</div>
<DialogTemplate
title={localize('com_ui_delete') + ' ' + localize('com_ui_assistant')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="delete-assistant" className="text-left text-sm font-medium">
{localize('com_ui_delete_assistant_confirm')}
</Label>
</div>
<div className="flex w-full items-center justify-center gap-2 text-red-500">
<TrashIcon />
</div>
</button>
</DialogTrigger>
<DialogTemplate
title={localize('com_ui_delete') + ' ' + localize('com_ui_assistant')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="delete-assistant" className="text-left text-sm font-medium">
{localize('com_ui_delete_assistant_confirm')}
</Label>
</div>
</>
}
selection={{
selectHandler: () =>
deleteAssistant.mutate({ assistant_id, model: activeModel, endpoint }),
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</Popover.Root>
</div>
</>
}
selection={{
selectHandler: () =>
deleteAssistant.mutate({ assistant_id, model: activeModel, endpoint }),
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</Dialog>
);
}

View file

@ -33,10 +33,7 @@ export default function ImageVision() {
})
}
>
<div className="flex items-center">
{localize('com_assistants_image_vision')}
<QuestionMark />
</div>
<div className="flex items-center">{localize('com_assistants_image_vision')}</div>
</label>
</div>
);

View file

@ -1,10 +1,17 @@
import { useEffect, useMemo } from 'react';
import { useFormContext, Controller, useWatch } from 'react-hook-form';
import { Capabilities } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import type { AssistantForm } from '~/common';
import { useFormContext, Controller, useWatch } from 'react-hook-form';
import {
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
} from '~/components/ui';
import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
import { Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui';
import { CircleHelpIcon } from '~/components/svg';
import type { AssistantForm } from '~/common';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
import { cn } from '~/utils/';
@ -40,23 +47,23 @@ export default function Retrieval({
return (
<>
<div className="flex items-center">
<Controller
name={Capabilities.retrieval}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
disabled={isDisabled}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<HoverCard openDelay={50}>
<div className="flex items-center">
<Controller
name={Capabilities.retrieval}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
disabled={isDisabled}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<div className="flex items-center space-x-2">
<label
className={cn(
'form-check-label text-token-text-primary w-full select-none',
@ -74,21 +81,29 @@ export default function Retrieval({
? localize('com_assistants_retrieval')
: localize('com_assistants_file_search')}
</label>
</HoverCardTrigger>
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-gray-500" />
</HoverCardTrigger>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} disabled={isDisabled} className="ml-16 w-80">
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">
{version == 2 && localize('com_assistants_file_search_info')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
<OptionHover
side={ESide.Top}
disabled={!isDisabled}
description="com_assistants_non_retrieval_model"
langCode={true}
sideOffset={20}
className="ml-16"
/>
</HoverCard>
</div>
{version == 2 && (
<div className="text-token-text-tertiary rounded-lg text-xs">
{localize('com_assistants_file_search_info')}
</div>
)}
</HoverCard>
</>
);
}

View file

@ -72,12 +72,12 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
return (
<>
<div className="flex items-center gap-4 px-2 py-4">
<div className="flex items-center gap-4 py-4">
<Input
placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="max-w-xs dark:border-gray-700"
className="w-full dark:border-gray-700"
/>
</div>
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
@ -90,7 +90,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
<TableHead
key={header.id}
style={{ width: index === 0 ? '75%' : '25%' }}
className="sticky top-0 h-auto border-b border-black/10 bg-white py-1 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-800 dark:text-gray-100"
className="sticky top-0 h-auto bg-white py-1 text-left font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-100"
>
{header.isPlaceholder
? null
@ -133,17 +133,19 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
</TableBody>
</Table>
</div>
<div className="flex items-center justify-around space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setShowFiles(true)}
className="flex gap-2"
>
<LucideArrowUpLeft className="icon-sm" />
{localize('com_sidepanel_manage_files')}
</Button>
<div className="flex gap-2">
<div className="flex items-center justify-between py-4">
<div className="flex items-center">
<Button
variant="outline"
size="sm"
onClick={() => setShowFiles(true)}
className="flex gap-2"
>
<LucideArrowUpLeft className="icon-sm" />
{localize('com_sidepanel_manage_files')}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"

View file

@ -41,9 +41,9 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
? 'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
: '',
)}
onClick={() => {
onClick={(e) => {
if (link.onClick) {
link.onClick();
link.onClick(e);
setActive('');
return;
}
@ -87,9 +87,9 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
'hover:bg-gray-50 data-[state=open]:bg-gray-50 data-[state=open]:text-black dark:hover:bg-gray-700 dark:data-[state=open]:bg-gray-700 dark:data-[state=open]:text-white',
'w-full justify-start rounded-md border dark:border-gray-700',
)}
onClick={() => {
onClick={(e) => {
if (link.onClick) {
link.onClick();
link.onClick(e);
setActive('');
}
}}

View file

@ -9,6 +9,7 @@ type TOptionHoverProps = {
sideOffset?: number;
disabled?: boolean;
side: ESide;
className?: string;
};
function OptionHover({
@ -17,6 +18,7 @@ function OptionHover({
disabled,
langCode,
sideOffset = 30,
className,
}: TOptionHoverProps) {
const localize = useLocalize();
if (disabled) {
@ -25,11 +27,7 @@ function OptionHover({
const text = langCode ? localize(description) : description;
return (
<HoverCardPortal>
<HoverCardContent
side={side}
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={sideOffset}
>
<HoverCardContent side={side} className={`z-[999] w-80 ${className}`} sideOffset={sideOffset}>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">{text}</p>
</div>

View file

@ -12,6 +12,7 @@ import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/compo
import { TooltipProvider, Tooltip } from '~/components/ui/Tooltip';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useMediaQuery, useLocalStorage } from '~/hooks';
import BookmarkPanel from './Bookmarks/BookmarkPanel';
import NavToggle from '~/components/Nav/NavToggle';
import { useChatContext } from '~/Providers';
import Switcher from './Switcher';
@ -79,8 +80,20 @@ const SidePanel = ({
localStorage.setItem('fullPanelCollapse', 'true');
panelRef.current?.collapse();
}, []);
const [showBookmarks, setShowBookmarks] = useState(false);
const manageBookmarks = useCallback((e) => {
e.preventDefault();
setShowBookmarks((prev) => !prev);
}, []);
const Links = useSideNavLinks({ hidePanel, assistants, keyProvided, endpoint, interfaceConfig });
const Links = useSideNavLinks({
hidePanel,
assistants,
keyProvided,
endpoint,
interfaceConfig,
manageBookmarks,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledSaveLayout = useCallback(
@ -128,6 +141,7 @@ const SidePanel = ({
return (
<>
{showBookmarks && <BookmarkPanel open={showBookmarks} onOpenChange={setShowBookmarks} />}
<TooltipProvider delayDuration={0}>
<ResizablePanelGroup
direction="horizontal"
@ -216,7 +230,7 @@ const SidePanel = ({
</ResizablePanelGroup>
</TooltipProvider>
<div
className={`nav-mask${!isCollapsed ? ' active' : ''}`}
className={`nav-mask${!isCollapsed ? 'active' : ''}`}
onClick={() => {
setIsCollapsed(() => {
localStorage.setItem('fullPanelCollapse', 'true');

View file

@ -1,19 +0,0 @@
export default function NewTrashIcon({ className = 'icon-md' }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.5555 4C10.099 4 9.70052 4.30906 9.58693 4.75114L9.29382 5.8919H14.715L14.4219 4.75114C14.3083 4.30906 13.9098 4 13.4533 4H10.5555ZM16.7799 5.8919L16.3589 4.25342C16.0182 2.92719 14.8226 2 13.4533 2H10.5555C9.18616 2 7.99062 2.92719 7.64985 4.25342L7.22886 5.8919H4C3.44772 5.8919 3 6.33961 3 6.8919C3 7.44418 3.44772 7.8919 4 7.8919H4.10069L5.31544 19.3172C5.47763 20.8427 6.76455 22 8.29863 22H15.7014C17.2354 22 18.5224 20.8427 18.6846 19.3172L19.8993 7.8919H20C20.5523 7.8919 21 7.44418 21 6.8919C21 6.33961 20.5523 5.8919 20 5.8919H16.7799ZM17.888 7.8919H6.11196L7.30423 19.1057C7.3583 19.6142 7.78727 20 8.29863 20H15.7014C16.2127 20 16.6417 19.6142 16.6958 19.1057L17.888 7.8919ZM10 10C10.5523 10 11 10.4477 11 11V16C11 16.5523 10.5523 17 10 17C9.44772 17 9 16.5523 9 16V11C9 10.4477 9.44772 10 10 10ZM14 10C14.5523 10 15 10.4477 15 11V16C15 16.5523 14.5523 17 14 17C13.4477 17 13 16.5523 13 16V11C13 10.4477 13.4477 10 14 10Z"
fill="currentColor"
/>
</svg>
);
}

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