mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-09 20:18:50 +01:00
Merge branch 'main' into re-add-download-audio
This commit is contained in:
commit
c27e8566fb
152 changed files with 5856 additions and 1334 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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) 🌤️
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
268
api/models/ConversationTag.js
Normal file
268
api/models/ConversationTag.js
Normal 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' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
31
api/models/schema/conversationTagSchema.js
Normal file
31
api/models/schema/conversationTagSchema.js
Normal 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);
|
||||
|
|
@ -42,6 +42,11 @@ const convoSchema = mongoose.Schema(
|
|||
invocationId: {
|
||||
type: Number,
|
||||
},
|
||||
tags: {
|
||||
type: [String],
|
||||
default: [],
|
||||
meiliIndex: true,
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ const conversationPreset = {
|
|||
spec: {
|
||||
type: String,
|
||||
},
|
||||
tags: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
tools: { type: [{ type: String }], default: undefined },
|
||||
maxContextTokens: {
|
||||
type: Number,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
55
api/server/routes/__tests__/ldap.spec.js
Normal file
55
api/server/routes/__tests__/ldap.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
44
api/server/routes/tags.js
Normal 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;
|
||||
24
api/server/services/Config/ldap.js
Normal file
24
api/server/services/Config/ldap.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
9
client/src/Providers/BookmarkContext.tsx
Normal file
9
client/src/Providers/BookmarkContext.tsx
Normal 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);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ const mockStartupConfig: TStartupConfig = {
|
|||
passwordResetEnabled: true,
|
||||
serverDomain: 'mock-server',
|
||||
appTitle: '',
|
||||
ldapLoginEnabled: false,
|
||||
ldap: {
|
||||
enabled: false,
|
||||
},
|
||||
emailEnabled: false,
|
||||
checkBalance: false,
|
||||
showBirthdayIcon: false,
|
||||
|
|
|
|||
69
client/src/components/Bookmarks/BookmarkEditDialog.tsx
Normal file
69
client/src/components/Bookmarks/BookmarkEditDialog.tsx
Normal 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;
|
||||
195
client/src/components/Bookmarks/BookmarkForm.tsx
Normal file
195
client/src/components/Bookmarks/BookmarkForm.tsx
Normal 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;
|
||||
74
client/src/components/Bookmarks/BookmarkItem.tsx
Normal file
74
client/src/components/Bookmarks/BookmarkItem.tsx
Normal 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;
|
||||
30
client/src/components/Bookmarks/BookmarkItems.tsx
Normal file
30
client/src/components/Bookmarks/BookmarkItems.tsx
Normal 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;
|
||||
58
client/src/components/Bookmarks/DeleteBookmarkButton.tsx
Normal file
58
client/src/components/Bookmarks/DeleteBookmarkButton.tsx
Normal 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;
|
||||
42
client/src/components/Bookmarks/EditBookmarkButton.tsx
Normal file
42
client/src/components/Bookmarks/EditBookmarkButton.tsx
Normal 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;
|
||||
6
client/src/components/Bookmarks/index.ts
Normal file
6
client/src/components/Bookmarks/index.ts
Normal 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';
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
134
client/src/components/Chat/Menus/BookmarkMenu.tsx
Normal file
134
client/src/components/Chat/Menus/BookmarkMenu.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -33,6 +33,7 @@ export default function MessagesView({
|
|||
<div
|
||||
onScroll={debouncedHandleScroll}
|
||||
ref={scrollableRef}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
99
client/src/components/Nav/Bookmarks/BookmarkNav.tsx
Normal file
99
client/src/components/Nav/Bookmarks/BookmarkNav.tsx
Normal 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;
|
||||
58
client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx
Normal file
58
client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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), []);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx
Normal file
46
client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx
Normal 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;
|
||||
97
client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Normal file
97
client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue