Compare commits
8 Commits
fix/appcon
...
feat/direc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13017b7cc5 | ||
|
|
43f881eab6 | ||
|
|
e55264b22a | ||
|
|
ccb2e031dd | ||
|
|
d3bfc810ff | ||
|
|
aae47e7b3f | ||
|
|
b5aadf1302 | ||
|
|
89843262b2 |
11
.env.example
11
.env.example
@@ -40,13 +40,6 @@ NO_INDEX=true
|
||||
# Defaulted to 1.
|
||||
TRUST_PROXY=1
|
||||
|
||||
# Minimum password length for user authentication
|
||||
# Default: 8
|
||||
# Note: When using LDAP authentication, you may want to set this to 1
|
||||
# to bypass local password validation, as LDAP servers handle their own
|
||||
# password policies.
|
||||
# MIN_PASSWORD_LENGTH=8
|
||||
|
||||
#===============#
|
||||
# JSON Logging #
|
||||
#===============#
|
||||
@@ -667,10 +660,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# REDIS_URI=rediss://127.0.0.1:6380
|
||||
# REDIS_CA=/path/to/ca-cert.pem
|
||||
|
||||
# Elasticache may need to use an alternate dnsLookup for TLS connections. see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis
|
||||
# Enable alternative dnsLookup for redis
|
||||
# REDIS_USE_ALTERNATIVE_DNS_LOOKUP=true
|
||||
|
||||
# Redis authentication (if required)
|
||||
# REDIS_USERNAME=your_redis_username
|
||||
# REDIS_PASSWORD=your_redis_password
|
||||
|
||||
3
api/cache/cacheConfig.js
vendored
3
api/cache/cacheConfig.js
vendored
@@ -52,9 +52,6 @@ const cacheConfig = {
|
||||
REDIS_CONNECT_TIMEOUT: math(process.env.REDIS_CONNECT_TIMEOUT, 10000),
|
||||
/** Queue commands when disconnected */
|
||||
REDIS_ENABLE_OFFLINE_QUEUE: isEnabled(process.env.REDIS_ENABLE_OFFLINE_QUEUE ?? 'true'),
|
||||
/** flag to modify redis connection by adding dnsLookup this is required when connecting to elasticache for ioredis
|
||||
* see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis **/
|
||||
REDIS_USE_ALTERNATIVE_DNS_LOOKUP: isEnabled(process.env.REDIS_USE_ALTERNATIVE_DNS_LOOKUP),
|
||||
/** Enable redis cluster without the need of multiple URIs */
|
||||
USE_REDIS_CLUSTER: isEnabled(process.env.USE_REDIS_CLUSTER ?? 'false'),
|
||||
CI: isEnabled(process.env.CI),
|
||||
|
||||
3
api/cache/redisClients.js
vendored
3
api/cache/redisClients.js
vendored
@@ -53,9 +53,6 @@ if (cacheConfig.USE_REDIS) {
|
||||
: new IoRedis.Cluster(
|
||||
urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })),
|
||||
{
|
||||
...(cacheConfig.REDIS_USE_ALTERNATIVE_DNS_LOOKUP
|
||||
? { dnsLookup: (address, callback) => callback(null, address) }
|
||||
: {}),
|
||||
redisOptions,
|
||||
clusterRetryStrategy: (times) => {
|
||||
if (
|
||||
|
||||
@@ -211,7 +211,7 @@ describe('File Access Control', () => {
|
||||
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access when user only has VIEW permission and needs access for deletion', async () => {
|
||||
it('should deny access when user only has VIEW permission', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const agentId = uuidv4();
|
||||
@@ -263,71 +263,12 @@ describe('File Access Control', () => {
|
||||
role: SystemRoles.USER,
|
||||
fileIds,
|
||||
agentId,
|
||||
isDelete: true,
|
||||
});
|
||||
|
||||
// Should have no access to any files when only VIEW permission
|
||||
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||
});
|
||||
|
||||
it('should grant access when user has VIEW permission', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const agentId = uuidv4();
|
||||
const fileIds = [uuidv4(), uuidv4()];
|
||||
|
||||
// Create users
|
||||
await User.create({
|
||||
_id: userId,
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
provider: 'local',
|
||||
});
|
||||
|
||||
await User.create({
|
||||
_id: authorId,
|
||||
email: 'author@example.com',
|
||||
emailVerified: true,
|
||||
provider: 'local',
|
||||
});
|
||||
|
||||
// Create agent with files
|
||||
const agent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'View-Only Agent',
|
||||
author: authorId,
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: fileIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Grant only VIEW permission to user on the agent
|
||||
await grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
grantedBy: authorId,
|
||||
});
|
||||
|
||||
// Check access for files
|
||||
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||
const accessMap = await hasAccessToFilesViaAgent({
|
||||
userId: userId,
|
||||
role: SystemRoles.USER,
|
||||
fileIds,
|
||||
agentId,
|
||||
});
|
||||
|
||||
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||
expect(accessMap.get(fileIds[1])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFiles with agent access control', () => {
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { buildTree } = require('librechat-data-provider');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { getMessages, bulkSaveMessages } = require('./Message');
|
||||
const { Message } = require('~/db/models');
|
||||
|
||||
// Original version of buildTree function
|
||||
function buildTree({ messages, fileMap }) {
|
||||
if (messages === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageMap = {};
|
||||
const rootMessages = [];
|
||||
const childrenCount = {};
|
||||
|
||||
messages.forEach((message) => {
|
||||
const parentId = message.parentMessageId ?? '';
|
||||
childrenCount[parentId] = (childrenCount[parentId] || 0) + 1;
|
||||
|
||||
const extendedMessage = {
|
||||
...message,
|
||||
children: [],
|
||||
depth: 0,
|
||||
siblingIndex: childrenCount[parentId] - 1,
|
||||
};
|
||||
|
||||
if (message.files && fileMap) {
|
||||
extendedMessage.files = message.files.map((file) => fileMap[file.file_id ?? ''] ?? file);
|
||||
}
|
||||
|
||||
messageMap[message.messageId] = extendedMessage;
|
||||
|
||||
const parentMessage = messageMap[parentId];
|
||||
if (parentMessage) {
|
||||
parentMessage.children.push(extendedMessage);
|
||||
extendedMessage.depth = parentMessage.depth + 1;
|
||||
} else {
|
||||
rootMessages.push(extendedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
return rootMessages;
|
||||
}
|
||||
|
||||
let mongod;
|
||||
beforeAll(async () => {
|
||||
mongod = await MongoMemoryServer.create();
|
||||
|
||||
@@ -7,11 +7,13 @@ const {
|
||||
createRun,
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
formatContentStrings,
|
||||
createMemoryProcessor,
|
||||
encodeAndFormatAudios,
|
||||
encodeAndFormatVideos,
|
||||
encodeAndFormatDocuments,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Callback,
|
||||
@@ -34,6 +36,7 @@ const {
|
||||
AgentCapabilities,
|
||||
bedrockInputSchema,
|
||||
removeNullishValues,
|
||||
isDocumentSupportedEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
@@ -41,11 +44,13 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files');
|
||||
const { checkCapability } = require('~/server/services/Config');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { getFiles } = require('~/models');
|
||||
|
||||
const omitTitleOptions = new Set([
|
||||
'stream',
|
||||
@@ -223,6 +228,168 @@ class AgentClient extends BaseClient {
|
||||
return files;
|
||||
}
|
||||
|
||||
async addDocuments(message, attachments) {
|
||||
const documentResult = await encodeAndFormatDocuments(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.documents =
|
||||
documentResult.documents && documentResult.documents.length
|
||||
? documentResult.documents
|
||||
: undefined;
|
||||
return documentResult.files;
|
||||
}
|
||||
|
||||
async addVideos(message, attachments) {
|
||||
const videoResult = await encodeAndFormatVideos(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.videos =
|
||||
videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined;
|
||||
return videoResult.files;
|
||||
}
|
||||
|
||||
async addAudios(message, attachments) {
|
||||
const audioResult = await encodeAndFormatAudios(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.audios =
|
||||
audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined;
|
||||
return audioResult.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override addPreviousAttachments to handle all file types, not just images
|
||||
* @param {TMessage[]} _messages
|
||||
* @returns {Promise<TMessage[]>}
|
||||
*/
|
||||
async addPreviousAttachments(_messages) {
|
||||
if (!this.options.resendFiles) {
|
||||
return _messages;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const attachmentsProcessed =
|
||||
this.options.attachments && !(this.options.attachments instanceof Promise);
|
||||
if (attachmentsProcessed) {
|
||||
for (const attachment of this.options.attachments) {
|
||||
seen.add(attachment.file_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TMessage} message
|
||||
*/
|
||||
const processMessage = async (message) => {
|
||||
if (!this.message_file_map) {
|
||||
/** @type {Record<string, MongoFile[]> */
|
||||
this.message_file_map = {};
|
||||
}
|
||||
|
||||
const fileIds = [];
|
||||
for (const file of message.files) {
|
||||
if (seen.has(file.file_id)) {
|
||||
continue;
|
||||
}
|
||||
fileIds.push(file.file_id);
|
||||
seen.add(file.file_id);
|
||||
}
|
||||
|
||||
if (fileIds.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const files = await getFiles(
|
||||
{
|
||||
file_id: { $in: fileIds },
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
|
||||
await this.processAttachments(message, files);
|
||||
|
||||
this.message_file_map[message.messageId] = files;
|
||||
return message;
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const message of _messages) {
|
||||
if (!message.files) {
|
||||
promises.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
promises.push(processMessage(message));
|
||||
}
|
||||
|
||||
const messages = await Promise.all(promises);
|
||||
|
||||
this.checkVisionRequest(Object.values(this.message_file_map ?? {}).flat());
|
||||
return messages;
|
||||
}
|
||||
|
||||
async processAttachments(message, attachments) {
|
||||
const categorizedAttachments = {
|
||||
images: [],
|
||||
documents: [],
|
||||
videos: [],
|
||||
audios: [],
|
||||
};
|
||||
|
||||
for (const file of attachments) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
categorizedAttachments.images.push(file);
|
||||
} else if (file.type === 'application/pdf') {
|
||||
categorizedAttachments.documents.push(file);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
categorizedAttachments.videos.push(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
categorizedAttachments.audios.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([
|
||||
categorizedAttachments.images.length > 0
|
||||
? this.addImageURLs(message, categorizedAttachments.images)
|
||||
: Promise.resolve([]),
|
||||
categorizedAttachments.documents.length > 0
|
||||
? this.addDocuments(message, categorizedAttachments.documents)
|
||||
: Promise.resolve([]),
|
||||
categorizedAttachments.videos.length > 0
|
||||
? this.addVideos(message, categorizedAttachments.videos)
|
||||
: Promise.resolve([]),
|
||||
categorizedAttachments.audios.length > 0
|
||||
? this.addAudios(message, categorizedAttachments.audios)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles];
|
||||
const seenFileIds = new Set();
|
||||
const uniqueFiles = [];
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (file.file_id && !seenFileIds.has(file.file_id)) {
|
||||
seenFileIds.add(file.file_id);
|
||||
uniqueFiles.push(file);
|
||||
} else if (!file.file_id) {
|
||||
uniqueFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueFiles;
|
||||
}
|
||||
|
||||
async buildMessages(
|
||||
messages,
|
||||
parentMessageId,
|
||||
@@ -256,7 +423,7 @@ class AgentClient extends BaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.addImageURLs(
|
||||
const files = await this.processAttachments(
|
||||
orderedMessages[orderedMessages.length - 1],
|
||||
attachments,
|
||||
);
|
||||
@@ -279,6 +446,47 @@ class AgentClient extends BaseClient {
|
||||
assistantName: this.options?.modelLabel,
|
||||
});
|
||||
|
||||
const hasFiles =
|
||||
(message.documents && message.documents.length > 0) ||
|
||||
(message.videos && message.videos.length > 0) ||
|
||||
(message.audios && message.audios.length > 0) ||
|
||||
(message.image_urls && message.image_urls.length > 0);
|
||||
|
||||
if (
|
||||
hasFiles &&
|
||||
message.isCreatedByUser &&
|
||||
isDocumentSupportedEndpoint(this.options.agent.provider)
|
||||
) {
|
||||
const contentParts = [];
|
||||
|
||||
if (message.documents && message.documents.length > 0) {
|
||||
contentParts.push(...message.documents);
|
||||
}
|
||||
|
||||
if (message.videos && message.videos.length > 0) {
|
||||
contentParts.push(...message.videos);
|
||||
}
|
||||
|
||||
if (message.audios && message.audios.length > 0) {
|
||||
contentParts.push(...message.audios);
|
||||
}
|
||||
|
||||
if (message.image_urls && message.image_urls.length > 0) {
|
||||
contentParts.push(...message.image_urls);
|
||||
}
|
||||
|
||||
if (typeof formattedMessage.content === 'string') {
|
||||
contentParts.push({ type: 'text', text: formattedMessage.content });
|
||||
} else {
|
||||
const textPart = formattedMessage.content.find((part) => part.type === 'text');
|
||||
if (textPart) {
|
||||
contentParts.push(textPart);
|
||||
}
|
||||
}
|
||||
|
||||
formattedMessage.content = contentParts;
|
||||
}
|
||||
|
||||
if (message.ocr && i !== orderedMessages.length - 1) {
|
||||
if (typeof formattedMessage.content === 'string') {
|
||||
formattedMessage.content = message.ocr + '\n' + formattedMessage.content;
|
||||
@@ -794,6 +1002,7 @@ class AgentClient extends BaseClient {
|
||||
};
|
||||
|
||||
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
||||
|
||||
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
||||
payload,
|
||||
this.indexTokenCountMap,
|
||||
@@ -880,16 +1089,6 @@ class AgentClient extends BaseClient {
|
||||
memoryPromise = this.runMemory(messages);
|
||||
}
|
||||
|
||||
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
|
||||
* non-custom endpoints, needs consideration of varying provider header configs.
|
||||
*/
|
||||
if (agent.model_parameters?.configuration?.defaultHeaders != null) {
|
||||
agent.model_parameters.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: agent.model_parameters.configuration.defaultHeaders,
|
||||
body: config.configurable.requestBody,
|
||||
});
|
||||
}
|
||||
|
||||
run = await createRun({
|
||||
agent,
|
||||
req: this.options.req,
|
||||
@@ -1192,20 +1391,6 @@ class AgentClient extends BaseClient {
|
||||
clientOptions.json = true;
|
||||
}
|
||||
|
||||
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
|
||||
* non-custom endpoints, needs consideration of varying provider header configs.
|
||||
*/
|
||||
if (clientOptions?.configuration?.defaultHeaders != null) {
|
||||
clientOptions.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: clientOptions.configuration.defaultHeaders,
|
||||
body: {
|
||||
messageId: this.responseMessageId,
|
||||
conversationId: this.conversationId,
|
||||
parentMessageId: this.parentMessageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const titleResult = await this.run.generateTitle({
|
||||
provider,
|
||||
|
||||
@@ -117,11 +117,6 @@ router.get('/', async function (req, res) {
|
||||
openidReuseTokens,
|
||||
};
|
||||
|
||||
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
|
||||
if (minPasswordLength && !isNaN(minPasswordLength)) {
|
||||
payload.minPasswordLength = minPasswordLength;
|
||||
}
|
||||
|
||||
payload.mcpServers = {};
|
||||
const getMCPServers = () => {
|
||||
try {
|
||||
|
||||
@@ -4,13 +4,9 @@ const { sleep } = require('@librechat/agents');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
createImportLimiters,
|
||||
createForkLimiters,
|
||||
configMiddleware,
|
||||
} = require('~/server/middleware');
|
||||
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
||||
const { createImportLimiters, createForkLimiters } = require('~/server/middleware');
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
@@ -175,7 +171,6 @@ router.post(
|
||||
'/import',
|
||||
importIpLimiter,
|
||||
importUserLimiter,
|
||||
configMiddleware,
|
||||
upload.single('file'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,6 @@ const { getAssistant } = require('~/models/Assistant');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { logger } = require('~/config');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -185,7 +184,6 @@ router.delete('/', async (req, res) => {
|
||||
role: req.user.role,
|
||||
fileIds: nonOwnedFileIds,
|
||||
agentId: req.body.agent_id,
|
||||
isDelete: true,
|
||||
});
|
||||
|
||||
for (const file of nonOwnedFiles) {
|
||||
@@ -327,6 +325,11 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
|
||||
res.setHeader('X-File-Metadata', JSON.stringify(file));
|
||||
};
|
||||
|
||||
/** @type {{ body: import('stream').PassThrough } | undefined} */
|
||||
let passThrough;
|
||||
/** @type {ReadableStream | undefined} */
|
||||
let fileStream;
|
||||
|
||||
if (checkOpenAIStorage(file.source)) {
|
||||
req.body = { model: file.model };
|
||||
const endpointMap = {
|
||||
@@ -339,19 +342,12 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
|
||||
overrideEndpoint: endpointMap[file.source],
|
||||
});
|
||||
logger.debug(`Downloading file ${file_id} from OpenAI`);
|
||||
const passThrough = await getDownloadStream(file_id, openai);
|
||||
passThrough = await getDownloadStream(file_id, openai);
|
||||
setHeaders();
|
||||
logger.debug(`File ${file_id} downloaded from OpenAI`);
|
||||
|
||||
// Handle both Node.js and Web streams
|
||||
const stream =
|
||||
passThrough.body && typeof passThrough.body.getReader === 'function'
|
||||
? Readable.fromWeb(passThrough.body)
|
||||
: passThrough.body;
|
||||
|
||||
stream.pipe(res);
|
||||
passThrough.body.pipe(res);
|
||||
} else {
|
||||
const fileStream = await getDownloadStream(req, file.filepath);
|
||||
fileStream = await getDownloadStream(req, file.filepath);
|
||||
|
||||
fileStream.on('error', (streamError) => {
|
||||
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
|
||||
|
||||
@@ -36,12 +36,10 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
||||
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
||||
|
||||
/** Intentionally excludes passing `body`, i.e. `req.body`, as
|
||||
* values may not be accurate until `AgentClient` is initialized
|
||||
*/
|
||||
let resolvedHeaders = resolveHeaders({
|
||||
headers: endpointConfig.headers,
|
||||
user: req.user,
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
||||
|
||||
@@ -76,10 +76,7 @@ describe('custom/initializeClient', () => {
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
||||
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
|
||||
/**
|
||||
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
||||
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -109,11 +109,9 @@ class STTService {
|
||||
* @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set.
|
||||
*/
|
||||
async getProviderSchema(req) {
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req?.user?.role,
|
||||
}));
|
||||
const appConfig = await getAppConfig({
|
||||
role: req?.user?.role,
|
||||
});
|
||||
const sttSchema = appConfig?.speech?.stt;
|
||||
if (!sttSchema) {
|
||||
throw new Error(
|
||||
|
||||
@@ -35,12 +35,11 @@ class TTSService {
|
||||
|
||||
/**
|
||||
* Retrieves the configured TTS provider.
|
||||
* @param {AppConfig | null | undefined} [appConfig] - The app configuration object.
|
||||
* @returns {string} The name of the configured provider.
|
||||
* @throws {Error} If no provider is set or multiple providers are set.
|
||||
*/
|
||||
getProvider(appConfig) {
|
||||
const ttsSchema = appConfig?.speech?.tts;
|
||||
getProvider() {
|
||||
const ttsSchema = this.customConfig.speech.tts;
|
||||
if (!ttsSchema) {
|
||||
throw new Error(
|
||||
'No TTS schema is set. Did you configure TTS in the custom config (librechat.yaml)?',
|
||||
@@ -277,8 +276,8 @@ class TTSService {
|
||||
/**
|
||||
* Processes a text-to-speech request.
|
||||
* @async
|
||||
* @param {ServerRequest} req - The request object.
|
||||
* @param {ServerResponse} res - The response object.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {Object} res - The response object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async processTextToSpeech(req, res) {
|
||||
@@ -288,14 +287,12 @@ class TTSService {
|
||||
return res.status(400).send('Missing text in request body');
|
||||
}
|
||||
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
}));
|
||||
const appConfig = await getAppConfig({
|
||||
role: req.user?.role,
|
||||
});
|
||||
try {
|
||||
res.setHeader('Content-Type', 'audio/mpeg');
|
||||
const provider = this.getProvider(appConfig);
|
||||
const provider = this.getProvider();
|
||||
const ttsSchema = appConfig?.speech?.tts?.[provider];
|
||||
const voice = await this.getVoice(ttsSchema, requestVoice);
|
||||
|
||||
@@ -347,19 +344,14 @@ class TTSService {
|
||||
/**
|
||||
* Streams audio data from the TTS provider.
|
||||
* @async
|
||||
* @param {ServerRequest} req - The request object.
|
||||
* @param {ServerResponse} res - The response object.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {Object} res - The response object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async streamAudio(req, res) {
|
||||
res.setHeader('Content-Type', 'audio/mpeg');
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
}));
|
||||
const provider = this.getProvider(appConfig);
|
||||
const ttsSchema = appConfig?.speech?.tts?.[provider];
|
||||
const provider = this.getProvider();
|
||||
const ttsSchema = this.customConfig.speech.tts[provider];
|
||||
const voice = await this.getVoice(ttsSchema, req.body.voice);
|
||||
|
||||
let shouldContinue = true;
|
||||
@@ -444,8 +436,8 @@ async function createTTSService() {
|
||||
/**
|
||||
* Wrapper function for text-to-speech processing.
|
||||
* @async
|
||||
* @param {ServerRequest} req - The request object.
|
||||
* @param {ServerResponse} res - The response object.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {Object} res - The response object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function textToSpeech(req, res) {
|
||||
@@ -468,12 +460,11 @@ async function streamAudio(req, res) {
|
||||
/**
|
||||
* Wrapper function to get the configured TTS provider.
|
||||
* @async
|
||||
* @param {AppConfig | null | undefined} appConfig - The app configuration object.
|
||||
* @returns {Promise<string>} A promise that resolves to the name of the configured provider.
|
||||
*/
|
||||
async function getProvider(appConfig) {
|
||||
async function getProvider() {
|
||||
const ttsService = await createTTSService();
|
||||
return ttsService.getProvider(appConfig);
|
||||
return ttsService.getProvider();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -14,18 +14,16 @@ const { getProvider } = require('./TTSService');
|
||||
*/
|
||||
async function getVoices(req, res) {
|
||||
try {
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
}));
|
||||
const appConfig = await getAppConfig({
|
||||
role: req.user?.role,
|
||||
});
|
||||
|
||||
const ttsSchema = appConfig?.speech?.tts;
|
||||
if (!ttsSchema) {
|
||||
if (!appConfig || !appConfig?.speech?.tts) {
|
||||
throw new Error('Configuration or TTS schema is missing');
|
||||
}
|
||||
|
||||
const provider = await getProvider(appConfig);
|
||||
const ttsSchema = appConfig?.speech?.tts;
|
||||
const provider = await getProvider(ttsSchema);
|
||||
let voices;
|
||||
|
||||
switch (provider) {
|
||||
|
||||
@@ -4,6 +4,7 @@ const axios = require('axios');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { generateShortLivedToken } = require('@librechat/api');
|
||||
const { resizeImageBuffer } = require('~/server/services/Files/images/resize');
|
||||
const { getBufferMetadata } = require('~/server/utils');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
@@ -286,7 +287,18 @@ async function uploadLocalFile({ req, file, file_id }) {
|
||||
await fs.promises.writeFile(newPath, inputBuffer);
|
||||
const filepath = path.posix.join('/', 'uploads', req.user.id, path.basename(newPath));
|
||||
|
||||
return { filepath, bytes };
|
||||
let height, width;
|
||||
if (file.mimetype && file.mimetype.startsWith('image/')) {
|
||||
try {
|
||||
const { width: imgWidth, height: imgHeight } = await resizeImageBuffer(inputBuffer, 'high');
|
||||
height = imgHeight;
|
||||
width = imgWidth;
|
||||
} catch (error) {
|
||||
logger.warn('[uploadLocalFile] Could not get image dimensions:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { filepath, bytes, height, width };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,11 +2,13 @@ const { processCodeFile } = require('./Code/process');
|
||||
const { processFileUpload } = require('./process');
|
||||
const { uploadImageBuffer } = require('./images');
|
||||
const { hasAccessToFilesViaAgent, filterFilesByAgentAccess } = require('./permissions');
|
||||
const { getStrategyFunctions } = require('./strategies');
|
||||
|
||||
module.exports = {
|
||||
processCodeFile,
|
||||
processFileUpload,
|
||||
uploadImageBuffer,
|
||||
getStrategyFunctions,
|
||||
hasAccessToFilesViaAgent,
|
||||
filterFilesByAgentAccess,
|
||||
};
|
||||
|
||||
@@ -10,10 +10,9 @@ const { getAgent } = require('~/models/Agent');
|
||||
* @param {string} [params.role] - Optional user role to avoid DB query
|
||||
* @param {string[]} params.fileIds - Array of file IDs to check
|
||||
* @param {string} params.agentId - The agent ID that might grant access
|
||||
* @param {boolean} [params.isDelete] - Whether the operation is a delete operation
|
||||
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
|
||||
*/
|
||||
const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId, isDelete }) => {
|
||||
const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId }) => {
|
||||
const accessMap = new Map();
|
||||
|
||||
// Initialize all files as no access
|
||||
@@ -45,23 +44,22 @@ const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId, isDele
|
||||
return accessMap;
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
// Check if user has EDIT permission (which would indicate collaborative access)
|
||||
const hasEditPermission = await checkPermission({
|
||||
userId,
|
||||
role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
// Check if user has EDIT permission (which would indicate collaborative access)
|
||||
const hasEditPermission = await checkPermission({
|
||||
userId,
|
||||
role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
// If user only has VIEW permission, they can't access files
|
||||
// Only users with EDIT permission or higher can access agent files
|
||||
if (!hasEditPermission) {
|
||||
return accessMap;
|
||||
}
|
||||
// If user only has VIEW permission, they can't access files
|
||||
// Only users with EDIT permission or higher can access agent files
|
||||
if (!hasEditPermission) {
|
||||
return accessMap;
|
||||
}
|
||||
|
||||
// User has edit permissions - check which files are actually attached
|
||||
const attachedFileIds = new Set();
|
||||
if (agent.tool_resources) {
|
||||
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
|
||||
|
||||
@@ -419,11 +419,11 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
||||
const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint);
|
||||
const assistantSource =
|
||||
metadata.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
|
||||
|
||||
// Use the configured file strategy for regular file uploads (not vectordb)
|
||||
const source = isAssistantUpload ? assistantSource : appConfig.fileStrategy;
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const { file_id, temp_file_id = null } = metadata;
|
||||
|
||||
/** @type {OpenAI | undefined} */
|
||||
let openai;
|
||||
if (checkOpenAIStorage(source)) {
|
||||
@@ -605,11 +605,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
|
||||
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
|
||||
);
|
||||
const {
|
||||
text,
|
||||
bytes,
|
||||
filepath: ocrFileURL,
|
||||
} = await uploadOCR({ req, file, loadAuthValues, appConfig });
|
||||
const { text, bytes, filepath: ocrFileURL } = await uploadOCR({ req, file, loadAuthValues });
|
||||
return await createTextFile({ text, bytes, filepath: ocrFileURL });
|
||||
}
|
||||
|
||||
@@ -620,7 +616,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
|
||||
if (shouldUseSTT) {
|
||||
const sttService = await STTService.getInstance();
|
||||
const { text, bytes } = await processAudioFile({ req, file, sttService });
|
||||
const { text, bytes } = await processAudioFile({ file, sttService });
|
||||
return await createTextFile({ text, bytes });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const fs = require('fs').promises;
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getImporter } = require('./importers');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Job definition for importing a conversation.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const jwksRsa = require('jwks-rsa');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { isEnabled, findOpenIDUser } = require('@librechat/api');
|
||||
const { updateUser, findUser } = require('~/models');
|
||||
|
||||
const { logger } = require('~/config');
|
||||
const jwksRsa = require('jwks-rsa');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
/**
|
||||
* @function openIdJwtLogin
|
||||
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
|
||||
@@ -14,14 +13,6 @@ const { updateUser, findUser } = require('~/models');
|
||||
* It uses the jwks-rsa library to retrieve the signing key from a JWKS endpoint.
|
||||
* The strategy extracts the JWT from the Authorization header as a Bearer token.
|
||||
* The JWT is then verified using the signing key, and the user is retrieved from the database.
|
||||
*
|
||||
* Includes email fallback mechanism:
|
||||
* 1. Primary lookup: Search user by openidId (sub claim)
|
||||
* 2. Fallback lookup: If not found, search by email claim
|
||||
* 3. User migration: If found by email without openidId, migrate the user by adding openidId
|
||||
* 4. Provider validation: Ensures users registered with other providers cannot use OpenID
|
||||
*
|
||||
* This enables seamless migration for existing users when SharePoint integration is enabled.
|
||||
*/
|
||||
const openIdJwtLogin = (openIdConfig) => {
|
||||
let jwksRsaOptions = {
|
||||
@@ -43,41 +34,19 @@ const openIdJwtLogin = (openIdConfig) => {
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
openidId: payload?.sub,
|
||||
email: payload?.email,
|
||||
strategyName: 'openIdJwtLogin',
|
||||
findUser,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
done(null, false, { message: error });
|
||||
return;
|
||||
}
|
||||
const user = await findUser({ openidId: payload?.sub });
|
||||
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
|
||||
const updateData = {};
|
||||
if (migration) {
|
||||
updateData.provider = 'openid';
|
||||
updateData.openidId = payload?.sub;
|
||||
}
|
||||
if (!user.role) {
|
||||
user.role = SystemRoles.USER;
|
||||
updateData.role = user.role;
|
||||
await updateUser(user.id, { role: user.role });
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await updateUser(user.id, updateData);
|
||||
}
|
||||
|
||||
done(null, user);
|
||||
} else {
|
||||
logger.warn(
|
||||
'[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' +
|
||||
payload?.sub +
|
||||
(payload?.email ? ' or email: ' + payload.email : ''),
|
||||
payload?.sub,
|
||||
);
|
||||
done(null, false);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const {
|
||||
isEnabled,
|
||||
logHeaders,
|
||||
safeStringify,
|
||||
findOpenIDUser,
|
||||
getBalanceConfig,
|
||||
} = require('@librechat/api');
|
||||
const { isEnabled, logHeaders, safeStringify, getBalanceConfig } = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
@@ -339,16 +333,23 @@ async function setupOpenId() {
|
||||
async (tokenset, done) => {
|
||||
try {
|
||||
const claims = tokenset.claims();
|
||||
const result = await findOpenIDUser({
|
||||
openidId: claims.sub,
|
||||
email: claims.email,
|
||||
strategyName: 'openidStrategy',
|
||||
findUser,
|
||||
});
|
||||
let user = result.user;
|
||||
const error = result.error;
|
||||
let user = await findUser({ openidId: claims.sub });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (!user) {
|
||||
user = await findUser({ email: claims.email });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
|
||||
claims.email
|
||||
} for openidId: ${claims.sub}`,
|
||||
);
|
||||
}
|
||||
if (user != null && user.provider !== 'openid') {
|
||||
logger.info(
|
||||
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
|
||||
@@ -31,7 +31,6 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
const { z } = require('zod');
|
||||
|
||||
const MIN_PASSWORD_LENGTH = parseInt(process.env.MIN_PASSWORD_LENGTH, 10) || 8;
|
||||
|
||||
const allowedCharactersRegex = new RegExp(
|
||||
'^[' +
|
||||
'a-zA-Z0-9_.@#$%&*()' + // Basic Latin characters and symbols
|
||||
@@ -34,7 +32,7 @@ const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH)
|
||||
.min(8)
|
||||
.max(128)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: 'Password cannot be only spaces',
|
||||
@@ -52,14 +50,14 @@ const registerSchema = z
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH)
|
||||
.min(8)
|
||||
.max(128)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: 'Password cannot be only spaces',
|
||||
}),
|
||||
confirm_password: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH)
|
||||
.min(8)
|
||||
.max(128)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: 'Password cannot be only spaces',
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('Zod Schemas', () => {
|
||||
email: 'john@example.com',
|
||||
password: 'password123',
|
||||
confirm_password: 'password123',
|
||||
extraField: "I shouldn't be here",
|
||||
extraField: 'I shouldn\'t be here',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
@@ -407,7 +407,7 @@ describe('Zod Schemas', () => {
|
||||
'john{doe}', // Contains `{` and `}`
|
||||
'j', // Only one character
|
||||
'a'.repeat(81), // More than 80 characters
|
||||
"' OR '1'='1'; --", // SQL Injection
|
||||
'\' OR \'1\'=\'1\'; --', // SQL Injection
|
||||
'{$ne: null}', // MongoDB Injection
|
||||
'<script>alert("XSS")</script>', // Basic XSS
|
||||
'"><script>alert("XSS")</script>', // XSS breaking out of an attribute
|
||||
@@ -453,64 +453,4 @@ describe('Zod Schemas', () => {
|
||||
expect(result).toBe('name: String must contain at least 3 character(s)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MIN_PASSWORD_LENGTH environment variable', () => {
|
||||
// Note: These tests verify the behavior based on whatever MIN_PASSWORD_LENGTH
|
||||
// was set when the validators module was loaded
|
||||
const minLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10) || 8;
|
||||
|
||||
it('should respect the configured minimum password length for login', () => {
|
||||
// Test password exactly at minimum length
|
||||
const resultValid = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'a'.repeat(minLength),
|
||||
});
|
||||
expect(resultValid.success).toBe(true);
|
||||
|
||||
// Test password one character below minimum
|
||||
if (minLength > 1) {
|
||||
const resultInvalid = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'a'.repeat(minLength - 1),
|
||||
});
|
||||
expect(resultInvalid.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect the configured minimum password length for registration', () => {
|
||||
// Test password exactly at minimum length
|
||||
const resultValid = registerSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'a'.repeat(minLength),
|
||||
confirm_password: 'a'.repeat(minLength),
|
||||
});
|
||||
expect(resultValid.success).toBe(true);
|
||||
|
||||
// Test password one character below minimum
|
||||
if (minLength > 1) {
|
||||
const resultInvalid = registerSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'a'.repeat(minLength - 1),
|
||||
confirm_password: 'a'.repeat(minLength - 1),
|
||||
});
|
||||
expect(resultInvalid.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle edge case of very short minimum password length', () => {
|
||||
// This test is meaningful only if MIN_PASSWORD_LENGTH is set to a very low value
|
||||
if (minLength <= 3) {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'abc',
|
||||
});
|
||||
expect(result.success).toBe(minLength <= 3);
|
||||
} else {
|
||||
// Skip this test if minimum length is > 3
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useSearchApiKeyForm,
|
||||
useGetAgentsConfig,
|
||||
useCodeApiKeyForm,
|
||||
useGetMCPTools,
|
||||
useToolToggle,
|
||||
} from '~/hooks';
|
||||
import { useSearchApiKeyForm, useGetAgentsConfig, useCodeApiKeyForm, useToolToggle } from '~/hooks';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
interface BadgeRowContextType {
|
||||
conversationId?: string | null;
|
||||
mcpServerNames?: string[] | null;
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
webSearch: ReturnType<typeof useToolToggle>;
|
||||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
@@ -44,12 +37,10 @@ export default function BadgeRowProvider({
|
||||
isSubmitting,
|
||||
conversationId,
|
||||
}: BadgeRowProviderProps) {
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const hasInitializedRef = useRef(false);
|
||||
const { mcpToolDetails } = useGetMCPTools();
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key));
|
||||
|
||||
/** Initialize ephemeralAgent from localStorage on mount and when conversation changes */
|
||||
@@ -165,16 +156,11 @@ export default function BadgeRowProvider({
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
const value: BadgeRowContextType = {
|
||||
webSearch,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
mcpServerNames,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface MCPPanelContextValue {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
const MCPPanelContext = createContext<MCPPanelContextValue | undefined>(undefined);
|
||||
|
||||
export function MCPPanelProvider({ children }: { children: React.ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
/** Context value only created when conversationId changes */
|
||||
const contextValue = useMemo<MCPPanelContextValue>(
|
||||
() => ({
|
||||
conversationId: conversation?.conversationId ?? Constants.NEW_CONVO,
|
||||
}),
|
||||
[conversation?.conversationId],
|
||||
);
|
||||
|
||||
return <MCPPanelContext.Provider value={contextValue}>{children}</MCPPanelContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMCPPanelContext() {
|
||||
const context = useContext(MCPPanelContext);
|
||||
if (!context) {
|
||||
throw new Error('useMCPPanelContext must be used within MCPPanelProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export * from './SetConvoContext';
|
||||
export * from './SearchContext';
|
||||
export * from './BadgeRowContext';
|
||||
export * from './SidePanelContext';
|
||||
export * from './MCPPanelContext';
|
||||
export * from './ArtifactsContext';
|
||||
export * from './PromptGroupsContext';
|
||||
export { default as BadgeRowProvider } from './BadgeRowContext';
|
||||
|
||||
@@ -21,6 +21,7 @@ export type TAgentCapabilities = {
|
||||
[AgentCapabilities.execute_code]: boolean;
|
||||
[AgentCapabilities.end_after_tools]?: boolean;
|
||||
[AgentCapabilities.hide_sequential_outputs]?: boolean;
|
||||
[AgentCapabilities.direct_attach]?: boolean;
|
||||
};
|
||||
|
||||
export type AgentForm = {
|
||||
|
||||
@@ -8,11 +8,6 @@ import type * as t from 'librechat-data-provider';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type CodeBarProps = {
|
||||
lang: string;
|
||||
error?: boolean;
|
||||
|
||||
@@ -125,10 +125,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
aria-label={localize('com_auth_password')}
|
||||
{...register('password', {
|
||||
required: localize('com_auth_password_required'),
|
||||
minLength: {
|
||||
value: startupConfig?.minPasswordLength || 8,
|
||||
message: localize('com_auth_password_min_length'),
|
||||
},
|
||||
minLength: { value: 8, message: localize('com_auth_password_min_length') },
|
||||
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
|
||||
})}
|
||||
aria-invalid={!!errors.password}
|
||||
|
||||
@@ -165,7 +165,7 @@ const Registration: React.FC = () => {
|
||||
{renderInput('password', 'com_auth_password', 'password', {
|
||||
required: localize('com_auth_password_required'),
|
||||
minLength: {
|
||||
value: startupConfig?.minPasswordLength || 8,
|
||||
value: 8,
|
||||
message: localize('com_auth_password_min_length'),
|
||||
},
|
||||
maxLength: {
|
||||
|
||||
@@ -19,7 +19,7 @@ function ResetPassword() {
|
||||
const [params] = useSearchParams();
|
||||
const password = watch('password');
|
||||
const resetPassword = useResetPasswordMutation();
|
||||
const { setError, setHeaderText, startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
const { setError, setHeaderText } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const onSubmit = (data: TResetPassword) => {
|
||||
resetPassword.mutate(data, {
|
||||
@@ -83,7 +83,7 @@ function ResetPassword() {
|
||||
{...register('password', {
|
||||
required: localize('com_auth_password_required'),
|
||||
minLength: {
|
||||
value: startupConfig?.minPasswordLength || 8,
|
||||
value: 8,
|
||||
message: localize('com_auth_password_min_length'),
|
||||
},
|
||||
maxLength: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Constants, buildTree } from 'librechat-data-provider';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
|
||||
@@ -12,11 +12,11 @@ import ConversationStarters from './Input/ConversationStarters';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import Presentation from './Presentation';
|
||||
import { buildTree, cn } from '~/utils';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function LoadingSpinner() {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useToastContext, TooltipAnchor, ListeningIcon, Spinner } from '@librechat/client';
|
||||
import { useLocalize, useSpeechToText, useGetAudioSettings } from '~/hooks';
|
||||
import { useLocalize, useSpeechToText } from '~/hooks';
|
||||
import { useChatFormContext } from '~/Providers';
|
||||
import { globalAudioId } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external';
|
||||
export default function AudioRecorder({
|
||||
disabled,
|
||||
ask,
|
||||
@@ -19,12 +18,9 @@ export default function AudioRecorder({
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const { setValue, reset, getValues } = methods;
|
||||
const { setValue, reset } = methods;
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { speechToTextEndpoint } = useGetAudioSettings();
|
||||
|
||||
const existingTextRef = useRef<string>('');
|
||||
|
||||
const onTranscriptionComplete = useCallback(
|
||||
(text: string) => {
|
||||
@@ -41,34 +37,20 @@ export default function AudioRecorder({
|
||||
console.log('Unmuting global audio');
|
||||
globalAudio.muted = false;
|
||||
}
|
||||
/** For external STT, append existing text to the transcription */
|
||||
const finalText =
|
||||
isExternalSTT(speechToTextEndpoint) && existingTextRef.current
|
||||
? `${existingTextRef.current} ${text}`
|
||||
: text;
|
||||
ask({ text: finalText });
|
||||
ask({ text });
|
||||
reset({ text: '' });
|
||||
existingTextRef.current = '';
|
||||
}
|
||||
},
|
||||
[ask, reset, showToast, localize, isSubmitting, speechToTextEndpoint],
|
||||
[ask, reset, showToast, localize, isSubmitting],
|
||||
);
|
||||
|
||||
const setText = useCallback(
|
||||
(text: string) => {
|
||||
let newText = text;
|
||||
if (isExternalSTT(speechToTextEndpoint)) {
|
||||
/** For external STT, the text comes as a complete transcription, so append to existing */
|
||||
newText = existingTextRef.current ? `${existingTextRef.current} ${text}` : text;
|
||||
} else {
|
||||
/** For browser STT, the transcript is cumulative, so we only need to prepend the existing text once */
|
||||
newText = existingTextRef.current ? `${existingTextRef.current} ${text}` : text;
|
||||
}
|
||||
setValue('text', newText, {
|
||||
setValue('text', text, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
},
|
||||
[setValue, speechToTextEndpoint],
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const { isListening, isLoading, startRecording, stopRecording } = useSpeechToText(
|
||||
@@ -80,27 +62,18 @@ export default function AudioRecorder({
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
existingTextRef.current = getValues('text') || '';
|
||||
startRecording();
|
||||
};
|
||||
const handleStartRecording = async () => startRecording();
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
stopRecording();
|
||||
/** For browser STT, clear the reference since text was already being updated */
|
||||
if (!isExternalSTT(speechToTextEndpoint)) {
|
||||
existingTextRef.current = '';
|
||||
}
|
||||
};
|
||||
const handleStopRecording = async () => stopRecording();
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isListening === true) {
|
||||
return <ListeningIcon className="stroke-red-500" />;
|
||||
}
|
||||
if (isLoading === true) {
|
||||
return <Spinner className="stroke-text-secondary" />;
|
||||
return <Spinner className="stroke-gray-700 dark:stroke-gray-300" />;
|
||||
}
|
||||
return <ListeningIcon className="stroke-text-secondary" />;
|
||||
return <ListeningIcon className="stroke-gray-700 dark:stroke-gray-300" />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -368,7 +368,7 @@ function BadgeRow({
|
||||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<Artifacts />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
<MCPSelect />
|
||||
</>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
|
||||
@@ -253,7 +253,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
handleSaveBadges={handleSaveBadges}
|
||||
setBadges={setBadges}
|
||||
/>
|
||||
<FileFormChat conversation={conversation} />
|
||||
<FileFormChat disableInputs={disableInputs} />
|
||||
{endpoint && (
|
||||
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
||||
<TextareaAutosize
|
||||
@@ -301,7 +301,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
>
|
||||
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
|
||||
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
|
||||
<AttachFileChat disableInputs={disableInputs} />
|
||||
</div>
|
||||
<BadgeRow
|
||||
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
|
||||
@@ -7,18 +7,14 @@ import {
|
||||
isAssistantsEndpoint,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import AttachFile from './AttachFile';
|
||||
|
||||
function AttachFileChat({
|
||||
disableInputs,
|
||||
conversation,
|
||||
}: {
|
||||
disableInputs: boolean;
|
||||
conversation: TConversation | null;
|
||||
}) {
|
||||
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
const { endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
|
||||
@@ -40,6 +36,7 @@ function AttachFileChat({
|
||||
disabled={disableInputs}
|
||||
conversationId={conversationId}
|
||||
endpointFileConfig={endpointFileConfig}
|
||||
endpoint={endpoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import {
|
||||
FileSearch,
|
||||
ImageUpIcon,
|
||||
TerminalSquareIcon,
|
||||
FileType2Icon,
|
||||
FileImageIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
EToolResources,
|
||||
EModelEndpoint,
|
||||
defaultAgentCapabilities,
|
||||
isDocumentSupportedEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
FileUpload,
|
||||
TooltipAnchor,
|
||||
@@ -14,8 +25,9 @@ import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks';
|
||||
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
|
||||
import { SharePointPickerDialog } from '~/components/SharePoint';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useGetStartupConfig, useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { MenuItemProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -23,9 +35,15 @@ interface AttachFileMenuProps {
|
||||
conversationId: string;
|
||||
disabled?: boolean | null;
|
||||
endpointFileConfig?: EndpointFileConfig;
|
||||
endpoint?: string | null;
|
||||
}
|
||||
|
||||
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
|
||||
const AttachFileMenu = ({
|
||||
disabled,
|
||||
conversationId,
|
||||
endpointFileConfig,
|
||||
endpoint,
|
||||
}: AttachFileMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -46,34 +64,79 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
|
||||
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
// Get agent details if using an agent
|
||||
const { data: agent } = useGetAgentByIdQuery(conversation?.agent_id ?? '', {
|
||||
enabled: !!conversation?.agent_id && conversation?.agent_id !== 'ephemeral',
|
||||
});
|
||||
|
||||
/** TODO: Ephemeral Agent Capabilities
|
||||
* Allow defining agent capabilities on a per-endpoint basis
|
||||
* Use definition for agents endpoint for ephemeral agents
|
||||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const handleUploadClick = (isImage?: boolean) => {
|
||||
const handleUploadClick = (
|
||||
fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal',
|
||||
) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
inputRef.current.value = '';
|
||||
inputRef.current.accept = isImage === true ? 'image/*' : '';
|
||||
if (fileType === 'image') {
|
||||
inputRef.current.accept = 'image/*';
|
||||
} else if (fileType === 'document') {
|
||||
inputRef.current.accept = '.pdf,application/pdf';
|
||||
} else if (fileType === 'multimodal') {
|
||||
inputRef.current.accept = 'image/*,.pdf,application/pdf';
|
||||
} else if (fileType === 'google_multimodal') {
|
||||
inputRef.current.accept = 'image/*,.pdf,application/pdf,video/*,audio/*';
|
||||
} else {
|
||||
inputRef.current.accept = '';
|
||||
}
|
||||
inputRef.current.click();
|
||||
inputRef.current.accept = '';
|
||||
};
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const createMenuItems = (onAction: (isImage?: boolean) => void) => {
|
||||
const items: MenuItemProps[] = [
|
||||
{
|
||||
const createMenuItems = (
|
||||
onAction: (fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal') => void,
|
||||
) => {
|
||||
const items: MenuItemProps[] = [];
|
||||
|
||||
const currentProvider = agent?.provider ?? endpoint;
|
||||
const isOpenAIOrAzure =
|
||||
currentProvider === EModelEndpoint.openAI || currentProvider === EModelEndpoint.azureOpenAI;
|
||||
const useResponsesApiEnabled = conversation?.useResponsesApi ?? false;
|
||||
|
||||
const shouldShowDirectAttach =
|
||||
isDocumentSupportedEndpoint(currentProvider) &&
|
||||
(!isOpenAIOrAzure || useResponsesApiEnabled);
|
||||
|
||||
if (shouldShowDirectAttach) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_provider'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.direct_attach);
|
||||
onAction(
|
||||
(agent?.provider ?? endpoint) === EModelEndpoint.google
|
||||
? 'google_multimodal'
|
||||
: 'multimodal',
|
||||
);
|
||||
},
|
||||
icon: <FileImageIcon className="icon-md" />,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_image_input'),
|
||||
onClick: () => {
|
||||
setToolResource(undefined);
|
||||
onAction(true);
|
||||
onAction('image');
|
||||
},
|
||||
icon: <ImageUpIcon className="icon-md" />,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.ocrEnabled) {
|
||||
items.push({
|
||||
@@ -139,6 +202,8 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
setEphemeralAgent,
|
||||
sharePointEnabled,
|
||||
setIsSharePointDialogOpen,
|
||||
endpoint,
|
||||
agent?.provider,
|
||||
]);
|
||||
|
||||
const menuTrigger = (
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
import FileRow from './FileRow';
|
||||
import store from '~/store';
|
||||
|
||||
function FileFormChat({ conversation }: { conversation: TConversation | null }) {
|
||||
const { files, setFiles, setFilesLoading } = useChatContext();
|
||||
function FileFormChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
|
||||
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
|
||||
const { abortUpload } = useFileHandling();
|
||||
|
||||
|
||||
@@ -59,12 +59,10 @@ export default function FileRow({
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 0) {
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.some((file) => file.progress < 1)) {
|
||||
setFilesLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
@@ -30,7 +34,7 @@ export default function MCPConfigDialog({
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
formState: { errors, _ },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
@@ -52,12 +56,14 @@ export default function MCPConfigDialog({
|
||||
};
|
||||
|
||||
const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName });
|
||||
const dialogDescription = localize('com_ui_mcp_dialog_desc');
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogTemplate
|
||||
className="sm:max-w-lg"
|
||||
title={dialogTitle}
|
||||
description={dialogDescription}
|
||||
headerClassName="px-6 pt-6 pb-4"
|
||||
main={
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
|
||||
|
||||
@@ -3,11 +3,8 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
|
||||
type MCPSelectProps = { conversationId?: string | null };
|
||||
|
||||
function MCPSelectContent({ conversationId }: MCPSelectProps) {
|
||||
function MCPSelect() {
|
||||
const {
|
||||
configuredServers,
|
||||
mcpValues,
|
||||
@@ -18,7 +15,7 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) {
|
||||
getConfigDialogProps,
|
||||
isInitializing,
|
||||
localize,
|
||||
} = useMCPServerManager({ conversationId });
|
||||
} = useMCPServerManager();
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
@@ -96,17 +93,9 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) {
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
{configDialogProps && (
|
||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||
)}
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPSelect(props: MCPSelectProps) {
|
||||
const { mcpServerNames } = useBadgeRowContext();
|
||||
if ((mcpServerNames?.length ?? 0) === 0) return null;
|
||||
return <MCPSelectContent {...props} />;
|
||||
}
|
||||
|
||||
export default memo(MCPSelect);
|
||||
|
||||
@@ -9,11 +9,10 @@ import { cn } from '~/utils';
|
||||
|
||||
interface MCPSubMenuProps {
|
||||
placeholder?: string;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
({ placeholder, conversationId, ...props }, ref) => {
|
||||
({ placeholder, ...props }, ref) => {
|
||||
const {
|
||||
configuredServers,
|
||||
mcpValues,
|
||||
@@ -24,7 +23,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
getServerStatusIconProps,
|
||||
getConfigDialogProps,
|
||||
isInitializing,
|
||||
} = useMCPServerManager({ conversationId });
|
||||
} = useMCPServerManager();
|
||||
|
||||
const menuStore = Ariakit.useMenuStore({
|
||||
focusLoop: true,
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
PermissionTypes,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities, useMCPSelect } from '~/hooks';
|
||||
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
|
||||
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
interface ToolsDropdownProps {
|
||||
disabled?: boolean;
|
||||
@@ -30,12 +30,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
mcpServerNames,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
} = useBadgeRowContext();
|
||||
const mcpSelect = useMCPSelect();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
|
||||
@@ -57,6 +56,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
} = codeInterpreter;
|
||||
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
|
||||
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
|
||||
const { mcpServerNames } = mcpSelect;
|
||||
|
||||
const canUseWebSearch = useHasAccess({
|
||||
permissionType: PermissionTypes.WEB_SEARCH,
|
||||
@@ -290,9 +290,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
if (mcpServerNames && mcpServerNames.length > 0) {
|
||||
dropdownItems.push({
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
|
||||
),
|
||||
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ import {
|
||||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import ServerInitializationSection from './ServerInitializationSection';
|
||||
import CustomUserVarsSection from './CustomUserVarsSection';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
@@ -23,7 +27,6 @@ interface MCPConfigDialogProps {
|
||||
onRevoke?: () => void;
|
||||
serverName: string;
|
||||
serverStatus?: MCPServerStatus;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
export default function MCPConfigDialog({
|
||||
@@ -35,7 +38,6 @@ export default function MCPConfigDialog({
|
||||
onRevoke,
|
||||
serverName,
|
||||
serverStatus,
|
||||
conversationId,
|
||||
}: MCPConfigDialogProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
@@ -124,7 +126,6 @@ export default function MCPConfigDialog({
|
||||
{/* Server Initialization Section */}
|
||||
<ServerInitializationSection
|
||||
serverName={serverName}
|
||||
conversationId={conversationId}
|
||||
requiresOAuth={serverStatus?.requiresOAuth || false}
|
||||
hasCustomUserVars={fieldsSchema && Object.keys(fieldsSchema).length > 0}
|
||||
/>
|
||||
|
||||
@@ -9,14 +9,12 @@ interface ServerInitializationSectionProps {
|
||||
serverName: string;
|
||||
requiresOAuth: boolean;
|
||||
hasCustomUserVars?: boolean;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
export default function ServerInitializationSection({
|
||||
sidePanel = false,
|
||||
serverName,
|
||||
requiresOAuth,
|
||||
conversationId,
|
||||
sidePanel = false,
|
||||
hasCustomUserVars = false,
|
||||
}: ServerInitializationSectionProps) {
|
||||
const localize = useLocalize();
|
||||
@@ -28,7 +26,7 @@ export default function ServerInitializationSection({
|
||||
isInitializing,
|
||||
isCancellable,
|
||||
getOAuthUrl,
|
||||
} = useMCPServerManager({ conversationId });
|
||||
} = useMCPServerManager();
|
||||
|
||||
const serverStatus = connectionStatus[serverName];
|
||||
const isConnected = serverStatus?.connectionState === 'connected';
|
||||
@@ -71,18 +69,13 @@ export default function ServerInitializationSection({
|
||||
const isReinit = shouldShowReinit;
|
||||
const outerClass = isReinit ? 'flex justify-start' : 'flex justify-end';
|
||||
const buttonVariant = isReinit ? undefined : 'default';
|
||||
|
||||
let buttonText = '';
|
||||
if (isServerInitializing) {
|
||||
buttonText = localize('com_ui_loading');
|
||||
} else if (isReinit) {
|
||||
buttonText = localize('com_ui_reinitialize');
|
||||
} else if (requiresOAuth) {
|
||||
buttonText = localize('com_ui_authenticate');
|
||||
} else {
|
||||
buttonText = localize('com_ui_mcp_initialize');
|
||||
}
|
||||
|
||||
const buttonText = isServerInitializing
|
||||
? localize('com_ui_loading')
|
||||
: isReinit
|
||||
? localize('com_ui_reinitialize')
|
||||
: requiresOAuth
|
||||
? localize('com_ui_authenticate')
|
||||
: localize('com_ui_mcp_initialize');
|
||||
const icon = isServerInitializing ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
|
||||
@@ -107,7 +107,6 @@ export const LangSelector = ({
|
||||
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
|
||||
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
|
||||
{ value: 'fi-FI', label: localize('com_nav_lang_finnish') },
|
||||
{ value: 'sl', label: localize('com_nav_lang_slovenian') },
|
||||
{ value: 'bo', label: localize('com_nav_lang_tibetan') },
|
||||
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@ function PluginTooltip({ content, position }: TPluginTooltipProps) {
|
||||
<HoverCardContent side={position} className="w-80 ">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{content}
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
|
||||
@@ -153,7 +153,7 @@ const AdminSettings = () => {
|
||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>
|
||||
{`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
|
||||
</OGDialogTitle>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { buildTree } from 'librechat-data-provider';
|
||||
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize, useDocumentTitle } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import MessagesView from './MessagesView';
|
||||
import { buildTree } from '~/utils';
|
||||
import Footer from '../Chat/Footer';
|
||||
|
||||
function SharedView() {
|
||||
|
||||
@@ -163,7 +163,7 @@ const PeoplePickerAdminSettings = () => {
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_people_picker',
|
||||
)}`}</OGDialogTitle>
|
||||
|
||||
@@ -157,7 +157,7 @@ const AdminSettings = () => {
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_agents',
|
||||
)}`}</OGDialogTitle>
|
||||
|
||||
@@ -8,16 +8,15 @@ import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
|
||||
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
|
||||
import BadgeRowProvider from '~/Providers/BadgeRowContext';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function MCPPanelContent() {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { conversationId } = useMCPPanelContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
|
||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||
@@ -154,7 +153,6 @@ function MCPPanelContent() {
|
||||
|
||||
<ServerInitializationSection
|
||||
sidePanel={true}
|
||||
conversationId={conversationId}
|
||||
serverName={selectedServerNameForEditing}
|
||||
requiresOAuth={serverStatus?.requiresOAuth || false}
|
||||
hasCustomUserVars={
|
||||
@@ -206,8 +204,8 @@ function MCPPanelContent() {
|
||||
|
||||
export default function MCPPanel() {
|
||||
return (
|
||||
<MCPPanelProvider>
|
||||
<BadgeRowProvider>
|
||||
<MCPPanelContent />
|
||||
</MCPPanelProvider>
|
||||
</BadgeRowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ const AdminSettings = () => {
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_memories',
|
||||
)}`}</OGDialogTitle>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface AgentCapabilitiesResult {
|
||||
fileSearchEnabled: boolean;
|
||||
webSearchEnabled: boolean;
|
||||
codeEnabled: boolean;
|
||||
directAttachEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function useAgentCapabilities(
|
||||
@@ -49,6 +50,11 @@ export default function useAgentCapabilities(
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const directAttachEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.direct_attach) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
return {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
@@ -57,5 +63,6 @@ export default function useAgentCapabilities(
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
directAttachEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useParams } from 'react-router-dom';
|
||||
import exportFromJSON from 'export-from-json';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
buildTree,
|
||||
QueryKeys,
|
||||
ContentTypes,
|
||||
ToolCallTypes,
|
||||
@@ -19,7 +18,7 @@ import type {
|
||||
} from 'librechat-data-provider';
|
||||
import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree';
|
||||
import { useScreenshot } from '~/hooks/ScreenshotContext';
|
||||
import { cleanupPreset } from '~/utils';
|
||||
import { cleanupPreset, buildTree } from '~/utils';
|
||||
|
||||
type ExportValues = {
|
||||
fieldName: string;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
QueryKeys,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
@@ -22,7 +19,6 @@ import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||
import { processFileForUpload } from '~/utils/heicConverter';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { logger, validateFiles } from '~/utils';
|
||||
import useClientResize from './useClientResize';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
@@ -43,9 +39,6 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast();
|
||||
const { files, setFiles, setFilesLoading, conversation } = useChatContext();
|
||||
const setEphemeralAgent = useSetRecoilState(
|
||||
ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
|
||||
);
|
||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
||||
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
|
||||
params?.fileSetter ?? setFiles,
|
||||
@@ -140,13 +133,6 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
const error = _error as TError | undefined;
|
||||
console.log('upload error', error);
|
||||
const file_id = body.get('file_id');
|
||||
const tool_resource = body.get('tool_resource');
|
||||
if (tool_resource === EToolResources.execute_code) {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[EToolResources.execute_code]: false,
|
||||
}));
|
||||
}
|
||||
clearUploadTimer(file_id as string);
|
||||
deleteFileById(file_id as string);
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export * from './useMCPSelect';
|
||||
export * from './useGetMCPTools';
|
||||
export { useMCPServerManager } from './useMCPServerManager';
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
export function useGetMCPTools() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: rawMcpTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data: TPlugin[]) => {
|
||||
const mcpToolsMap = new Map<string, TPlugin>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
if (!mcpToolsMap.has(serverName)) {
|
||||
mcpToolsMap.set(serverName, {
|
||||
name: serverName,
|
||||
pluginKey: tool.pluginKey,
|
||||
authConfig: tool.authConfig,
|
||||
authenticated: tool.authenticated,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(mcpToolsMap.values());
|
||||
},
|
||||
});
|
||||
|
||||
const mcpToolDetails = useMemo(() => {
|
||||
if (!rawMcpTools || !startupConfig?.mcpServers) {
|
||||
return rawMcpTools;
|
||||
}
|
||||
return rawMcpTools.filter((tool) => {
|
||||
const serverConfig = startupConfig?.mcpServers?.[tool.name];
|
||||
return serverConfig?.chatMenu !== false;
|
||||
});
|
||||
}, [rawMcpTools, startupConfig?.mcpServers]);
|
||||
|
||||
return {
|
||||
mcpToolDetails,
|
||||
};
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue.length > 2) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
};
|
||||
|
||||
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
|
||||
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
|
||||
storageKey,
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
|
||||
setMCPValuesRawRef.current = setMCPValuesRaw;
|
||||
|
||||
/** Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop */
|
||||
const setMCPValues = useCallback((value: string[]) => {
|
||||
setMCPValuesRawRef.current(value);
|
||||
}, []);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.PIN_MCP_}${key}`,
|
||||
true,
|
||||
);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
setMCPValues,
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
useReinitializeMCPServerMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import { useLocalize, useMCPSelect, useGetMCPTools } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize, useMCPSelect } from '~/hooks';
|
||||
|
||||
interface ServerState {
|
||||
isInitializing: boolean;
|
||||
@@ -21,14 +21,13 @@ interface ServerState {
|
||||
pollInterval: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) {
|
||||
export function useMCPServerManager() {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { mcpToolDetails } = useGetMCPTools();
|
||||
const mcpSelect = useMCPSelect({ conversationId });
|
||||
const mcpSelect = useMCPSelect();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect;
|
||||
const { mcpValues, setMCPValues, mcpToolDetails, isPinned, setIsPinned } = mcpSelect;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||
@@ -91,21 +90,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||
[connectionStatusData?.connectionStatus],
|
||||
);
|
||||
|
||||
/** Filter disconnected servers when values change, but only after initial load
|
||||
This prevents clearing selections on page refresh when servers haven't connected yet
|
||||
*/
|
||||
const hasInitialLoadCompleted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionStatusData || Object.keys(connectionStatus).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasInitialLoadCompleted.current) {
|
||||
hasInitialLoadCompleted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcpValues?.length) return;
|
||||
|
||||
const connectedSelected = mcpValues.filter(
|
||||
@@ -115,7 +100,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||
if (connectedSelected.length !== mcpValues.length) {
|
||||
setMCPValues(connectedSelected);
|
||||
}
|
||||
}, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]);
|
||||
}, [connectionStatus, mcpValues, setMCPValues]);
|
||||
|
||||
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
|
||||
setServerStates((prev) => {
|
||||
@@ -501,12 +486,12 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||
};
|
||||
},
|
||||
[
|
||||
isCancellable,
|
||||
mcpToolDetails,
|
||||
isInitializing,
|
||||
cancelOAuthFlow,
|
||||
connectionStatus,
|
||||
startupConfig?.mcpServers,
|
||||
isInitializing,
|
||||
isCancellable,
|
||||
cancelOAuthFlow,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -562,6 +547,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||
mcpValues,
|
||||
setMCPValues,
|
||||
|
||||
mcpToolDetails,
|
||||
isPinned,
|
||||
setIsPinned,
|
||||
placeholderText,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useMCPSelect';
|
||||
export * from './useToolToggle';
|
||||
export { default as useAuthCodeTool } from './useAuthCodeTool';
|
||||
export { default as usePluginInstall } from './usePluginInstall';
|
||||
|
||||
136
client/src/hooks/Plugins/useMCPSelect.ts
Normal file
136
client/src/hooks/Plugins/useMCPSelect.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue.length > 2) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
};
|
||||
|
||||
export function useMCPSelect() {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
const key = useMemo(
|
||||
() => conversation?.conversationId ?? Constants.NEW_CONVO,
|
||||
[conversation?.conversationId],
|
||||
);
|
||||
|
||||
const hasSetFetched = useRef<string | null>(null);
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: rawMcpTools, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data: TPlugin[]) => {
|
||||
const mcpToolsMap = new Map<string, TPlugin>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
if (!mcpToolsMap.has(serverName)) {
|
||||
mcpToolsMap.set(serverName, {
|
||||
name: serverName,
|
||||
pluginKey: tool.pluginKey,
|
||||
authConfig: tool.authConfig,
|
||||
authenticated: tool.authenticated,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(mcpToolsMap.values());
|
||||
},
|
||||
});
|
||||
|
||||
const mcpToolDetails = useMemo(() => {
|
||||
if (!rawMcpTools || !startupConfig?.mcpServers) {
|
||||
return rawMcpTools;
|
||||
}
|
||||
return rawMcpTools.filter((tool) => {
|
||||
const serverConfig = startupConfig?.mcpServers?.[tool.name];
|
||||
return serverConfig?.chatMenu !== false;
|
||||
});
|
||||
}, [rawMcpTools, startupConfig?.mcpServers]);
|
||||
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
|
||||
`${LocalStorageKeys.LAST_MCP_}${key}`,
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
|
||||
setMCPValuesRawRef.current = setMCPValuesRaw;
|
||||
|
||||
// Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop
|
||||
const setMCPValues = useCallback((value: string[]) => {
|
||||
setMCPValuesRawRef.current(value);
|
||||
}, []);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.PIN_MCP_}${key}`,
|
||||
true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetFetched.current === key) {
|
||||
return;
|
||||
}
|
||||
if (!isFetched) {
|
||||
return;
|
||||
}
|
||||
hasSetFetched.current = key;
|
||||
if ((mcpToolDetails?.length ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
|
||||
return;
|
||||
}
|
||||
setMCPValues([]);
|
||||
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
|
||||
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
setMCPValues,
|
||||
mcpServerNames,
|
||||
ephemeralAgent,
|
||||
mcpToolDetails,
|
||||
setEphemeralAgent,
|
||||
};
|
||||
}
|
||||
@@ -98,10 +98,6 @@ export function useToolToggle({
|
||||
if (isAuthenticated !== undefined && !isAuthenticated && setIsDialogOpen) {
|
||||
setIsDialogOpen(true);
|
||||
e?.preventDefault?.();
|
||||
setEphemeralAgent((prev) => ({
|
||||
...(prev || {}),
|
||||
[toolKey]: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Also value will be updated everywhere, when value updated (via `storage` event)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useLocalStorage<T>(
|
||||
key: string,
|
||||
@@ -47,26 +47,23 @@ export default function useLocalStorage<T>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, globalSetState]);
|
||||
|
||||
const setValueWrap = useCallback(
|
||||
(value: T) => {
|
||||
try {
|
||||
setValue(value);
|
||||
const storeLocal = () => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
window?.dispatchEvent(new StorageEvent('storage', { key }));
|
||||
};
|
||||
if (!storageCondition) {
|
||||
storeLocal();
|
||||
} else if (storageCondition(value, localStorage.getItem(key))) {
|
||||
storeLocal();
|
||||
}
|
||||
globalSetState?.(value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const setValueWrap = (value: T) => {
|
||||
try {
|
||||
setValue(value);
|
||||
const storeLocal = () => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
window?.dispatchEvent(new StorageEvent('storage', { key }));
|
||||
};
|
||||
if (!storageCondition) {
|
||||
storeLocal();
|
||||
} else if (storageCondition(value, localStorage.getItem(key))) {
|
||||
storeLocal();
|
||||
}
|
||||
},
|
||||
[key, globalSetState, storageCondition],
|
||||
);
|
||||
globalSetState?.(value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return [value, setValueWrap];
|
||||
}
|
||||
|
||||
@@ -519,7 +519,6 @@
|
||||
"com_nav_lang_polish": "Polski",
|
||||
"com_nav_lang_portuguese": "Português",
|
||||
"com_nav_lang_russian": "Русский",
|
||||
"com_nav_lang_slovenian": "Slovenščina",
|
||||
"com_nav_lang_spanish": "Español",
|
||||
"com_nav_lang_swedish": "Svenska",
|
||||
"com_nav_lang_thai": "ไทย",
|
||||
@@ -889,9 +888,9 @@
|
||||
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
|
||||
"com_ui_field_required": "This field is required",
|
||||
"com_ui_file_size": "File Size",
|
||||
"com_ui_files": "Files",
|
||||
"com_ui_file_token_limit": "File Token Limit",
|
||||
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
||||
"com_ui_files": "Files",
|
||||
"com_ui_filter_prompts": "Filter Prompts",
|
||||
"com_ui_filter_prompts_name": "Filter prompts by name",
|
||||
"com_ui_final_touch": "Final touch",
|
||||
@@ -1219,6 +1218,7 @@
|
||||
"com_ui_upload_invalid": "Invalid file for upload. Must be an image not exceeding the limit",
|
||||
"com_ui_upload_invalid_var": "Invalid file for upload. Must be an image not exceeding {{0}} MB",
|
||||
"com_ui_upload_ocr_text": "Upload as Text",
|
||||
"com_ui_upload_provider": "Upload to Provider",
|
||||
"com_ui_upload_success": "Successfully uploaded file",
|
||||
"com_ui_upload_type": "Select Upload Type",
|
||||
"com_ui_usage": "Usage",
|
||||
|
||||
@@ -39,7 +39,6 @@ import translationBo from './bo/translation.json';
|
||||
import translationUk from './uk/translation.json';
|
||||
import translationBs from './bs/translation.json';
|
||||
import translationNb from './nb/translation.json';
|
||||
import translationSl from './sl/translation.json';
|
||||
|
||||
export const defaultNS = 'translation';
|
||||
|
||||
@@ -79,7 +78,6 @@ export const resources = {
|
||||
hy: { translation: translationHy },
|
||||
fi: { translation: translationFi },
|
||||
bo: { translation: translationBo },
|
||||
sl: { translation: translationSl },
|
||||
uk: { translation: translationUk },
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
{
|
||||
"chat_direction_left_to_right": "Nav rezultātu",
|
||||
"chat_direction_right_to_left": "Nav rezultātu",
|
||||
"chat_direction_left_to_right": "kaut kam šeit ir jābūt. bija tukšs",
|
||||
"chat_direction_right_to_left": "kaut kam šeit ir jābūt. bija tukšs",
|
||||
"com_a11y_ai_composing": "Mākslīgais intelekts joprojām veido.",
|
||||
"com_a11y_end": "Mākslīgais intelekts ir pabeidzis atbildi.",
|
||||
"com_a11y_start": "Mākslīgais intelekts ir sācis savu atbildi.",
|
||||
"com_agents_agent_card_label": "{{name}} aģents. {{description}}",
|
||||
"com_agents_all": "Visi aģenti",
|
||||
"com_agents_all_category": "Viss",
|
||||
"com_agents_all_description": "Pārlūkot visus koplietotos aģentus visās kategorijās",
|
||||
"com_agents_by_librechat": "no LibreChat",
|
||||
"com_agents_category_aftersales": "Pēcpārdošanas",
|
||||
"com_agents_category_aftersales_description": "Aģenti, kas specializējas pēcpārdošanas atbalstā, apkopē un klientu apkalpošanā",
|
||||
"com_agents_category_empty": "Nav atrasts neviens aģents {{category}} kategorijā",
|
||||
"com_agents_category_finance": "Finanses",
|
||||
"com_agents_category_finance_description": "Aģenti, kas specializējas finanšu analīzē, budžeta plānošanā un grāmatvedībā",
|
||||
"com_agents_category_general": "Vispārīgi",
|
||||
"com_agents_category_general_description": "Vispārējas nozīmes aģenti bieži uzdotiem uzdevumiem un jautājumiem",
|
||||
"com_agents_category_hr": "Cilvēkresursi",
|
||||
"com_agents_category_hr_description": "Aģenti, kas specializējas HR procesos, politikās un darbinieku atbalstā",
|
||||
"com_agents_category_it": "IT",
|
||||
"com_agents_category_it_description": "IT atbalsta, tehnisko problēmu novēršanas un sistēmas administrēšanas aģenti",
|
||||
"com_agents_category_rd": "Pētniecība un izstrāde",
|
||||
"com_agents_category_rd_description": "Aģenti, kas koncentrējas uz pētniecības un attīstības procesiem, inovācijām un tehnisko pētniecību",
|
||||
"com_agents_category_sales": "Pārdošana",
|
||||
"com_agents_category_sales_description": "Aģenti, kas koncentrējas uz pārdošanas procesiem un klientu attiecībām",
|
||||
"com_agents_category_tab_label": "{{category}} kategorija, {{position}} no {{total}}",
|
||||
"com_agents_category_tabs_label": "Aģentu kategorijas",
|
||||
"com_agents_clear_search": "Notīrīt meklēšanu",
|
||||
@@ -294,7 +278,7 @@
|
||||
"com_endpoint_openai_detail": "Vision pieprasījumu izšķirtspēja. “Zema” ir lētāka un ātrāka, “Augsta” ir detalizētāka un dārgāka, un “Automātiska” automātiski izvēlēsies vienu no abām, pamatojoties uz attēla izšķirtspēju.",
|
||||
"com_endpoint_openai_freq": "Skaitlis no -2,0 līdz 2,0. Pozitīvas vērtības soda jaunus tokenus, pamatojoties uz to esošo biežumu tekstā līdz šim, samazinot modeļa iespējamību atkārtot vienu un to pašu rindu burtiski.",
|
||||
"com_endpoint_openai_max": "Maksimālais ģenerējamo tokenu skaits. Ievades tokenu un ģenerēto tokenu kopējo garumu ierobežo modeļa konteksta garums.",
|
||||
"com_endpoint_openai_max_tokens": "Pēc izvēles “max_tokens” lauks, kas norāda maksimālo tokenu skaitu, ko var ģenerēt sarunas pabeigšanas laikā. Ievades tokenu un ģenerēto tokenu kopējo garumu ierobežo modeļa konteksta garums. Ja šis skaitlis pārsniedz maksimālo konteksta tokenu skaitu, var rasties kļūdas.",
|
||||
"com_endpoint_openai_max_tokens": "Neobligāts lauks “max_tokens”, kas norāda maksimālo tokenu skaitu, ko var ģenerēt sarunas pabeigšanas laikā. Ievades tokenu un ģenerēto tokenu kopējo garumu ierobežo modeļa konteksta garums. Ja šis skaitlis pārsniedz maksimālo konteksta tokenu skaitu, var rasties kļūdas.",
|
||||
"com_endpoint_openai_pres": "Skaitlis no -2,0 līdz 2,0. Pozitīvas vērtības soda jaunus tokenus, pamatojoties uz to, vai tie līdz šim parādās tekstā, palielinot modeļa iespējamību runāt par jaunām tēmām.",
|
||||
"com_endpoint_openai_prompt_prefix_placeholder": "Iestatiet pielāgotas instrukcijas, kas jāiekļauj sistēmas ziņā. Noklusējuma vērtība: nav",
|
||||
"com_endpoint_openai_reasoning_effort": "Tikai o1 un o3 modeļi: ierobežo spriešanas modeļu spriešanas piepūli. Spriešanas piepūles samazināšana var nodrošināt ātrākas atbildes un mazāk spriešanas tokenus izmantošanas atbildē.",
|
||||
@@ -315,7 +299,7 @@
|
||||
"com_endpoint_plug_use_functions": "Izmantot funkcijas",
|
||||
"com_endpoint_presence_penalty": "Klātbūtnes sods",
|
||||
"com_endpoint_preset": "iepriekš iestatīts",
|
||||
"com_endpoint_preset_custom_name_placeholder": "Nav rezultātu",
|
||||
"com_endpoint_preset_custom_name_placeholder": "kaut kam šeit ir jānotiek. bija tukšs",
|
||||
"com_endpoint_preset_default": "tagad ir noklusējuma iestatījums.",
|
||||
"com_endpoint_preset_default_item": "Noklusējums:",
|
||||
"com_endpoint_preset_default_none": "Nav aktīvu noklusējuma iestatījumu.",
|
||||
@@ -388,7 +372,7 @@
|
||||
"com_files_number_selected": "{{0}} no {{1}} atlasīti faili",
|
||||
"com_files_preparing_download": "Sagatavošanās lejupielādei...",
|
||||
"com_files_sharepoint_picker_title": "Izvēlieties failus",
|
||||
"com_files_table": "Nav rezultātu",
|
||||
"com_files_table": "kaut kam šeit ir jānotiek. bija tukšs",
|
||||
"com_files_upload_local_machine": "No lokālā datora",
|
||||
"com_files_upload_sharepoint": "No SharePoint",
|
||||
"com_generated_files": "Ģenerētie faili:",
|
||||
@@ -426,7 +410,7 @@
|
||||
"com_nav_balance_month": "mēnesis",
|
||||
"com_nav_balance_months": "mēneši",
|
||||
"com_nav_balance_next_refill": "Nākamā bilances papildināšana:",
|
||||
"com_nav_balance_next_refill_info": "Nākamā bilances papildināšana notiks automātiski tikai tad, ja būs izpildīti abi nosacījumi: kopš pēdējās bilances papildināšanas ir pagājis norādītais laika atjaunošanas biežums un jauna vaicājuma nosūtīšana izraisītu jūsu atlikuma samazināšanos zem nulles.",
|
||||
"com_nav_balance_next_refill_info": "Nākamā bilances papildināšana notiks automātiski tikai tad, ja būs izpildīti abi nosacījumi: kopš pēdējās bilances papildināšanas ir pagājis norādītais laika atjaunošanas biežums un vaicājuma nosūtīšana izraisītu jūsu atlikuma samazināšanos zem nulles.",
|
||||
"com_nav_balance_refill_amount": "Bilances papildināšanas apjoms:",
|
||||
"com_nav_balance_second": "otrais",
|
||||
"com_nav_balance_seconds": "sekundes",
|
||||
@@ -636,7 +620,7 @@
|
||||
"com_ui_admin": "Administrators",
|
||||
"com_ui_admin_access_warning": "Administratora piekļuves atspējošana šai funkcijai var izraisīt neparedzētas lietotāja saskarnes problēmas, kurām nepieciešama atsvaidzināšana. Ja izmaiņas ir saglabātas, vienīgais veids, kā tās atjaunot, ir, izmantojot saskarnes iestatījumu librechat.yaml konfigurācijā, kas ietekmē visas lomas.",
|
||||
"com_ui_admin_settings": "Administratora iestatījumi",
|
||||
"com_ui_advanced": "Paplašinātie uzstādījumi",
|
||||
"com_ui_advanced": "Advancēts",
|
||||
"com_ui_advanced_settings": "Advancētie iestatījumi",
|
||||
"com_ui_agent": "Aģents",
|
||||
"com_ui_agent_category_aftersales": "Pēcpārdošanas pakalpojumi",
|
||||
@@ -740,7 +724,6 @@
|
||||
"com_ui_bookmarks_edit": "Rediģēt grāmatzīmi",
|
||||
"com_ui_bookmarks_filter": "Filtrēt grāmatzīmes...",
|
||||
"com_ui_bookmarks_new": "Jauna grāmatzīme",
|
||||
"com_ui_bookmarks_tag_exists": "Grāmatzīme ar šādu nosaukumu jau pastāv",
|
||||
"com_ui_bookmarks_title": "Nosaukums",
|
||||
"com_ui_bookmarks_update_error": "Atjauninot grāmatzīmi, radās kļūda.",
|
||||
"com_ui_bookmarks_update_success": "Grāmatzīme veiksmīgi atjaunināta",
|
||||
@@ -760,7 +743,7 @@
|
||||
"com_ui_close_window": "Aizvērt logu",
|
||||
"com_ui_code": "Kods",
|
||||
"com_ui_collapse_chat": "Sakļaut sarunas logu",
|
||||
"com_ui_command_placeholder": "Pēc izvēles: Ja tiks izmantota komanda uzvednei vai nosaukums, lūdzu ievadiet",
|
||||
"com_ui_command_placeholder": "Pēc izvēles: ievadiet komandu uzvednei vai tiks izmantots nosaukums",
|
||||
"com_ui_command_usage_placeholder": "Atlasiet uzvedni pēc komandas vai nosaukuma",
|
||||
"com_ui_complete_setup": "Pabeigt iestatīšanu",
|
||||
"com_ui_concise": "Īss",
|
||||
@@ -808,7 +791,7 @@
|
||||
"com_ui_date_september": "Septembris",
|
||||
"com_ui_date_today": "Šodien",
|
||||
"com_ui_date_yesterday": "Vakar",
|
||||
"com_ui_decline": "Es nepiekrītu",
|
||||
"com_ui_decline": "Es nepieņemu",
|
||||
"com_ui_default_post_request": "Noklusējums (POST pieprasījums)",
|
||||
"com_ui_delete": "Dzēst",
|
||||
"com_ui_delete_action": "Dzēst darbību",
|
||||
@@ -842,9 +825,9 @@
|
||||
"com_ui_download_backup": "Lejupielādēt rezerves kodus",
|
||||
"com_ui_download_backup_tooltip": "Pirms turpināt, lejupielādējiet rezerves kodus. Tie būs nepieciešami, lai atgūtu piekļuvi, ja pazaudēsiet autentifikatora ierīci.",
|
||||
"com_ui_download_error": "Kļūda, lejupielādējot failu. Iespējams, fails ir izdzēsts.",
|
||||
"com_ui_drag_drop": "Nav rezultātu",
|
||||
"com_ui_drag_drop": "kaut kam šeit ir jānotiek. bija tukšs",
|
||||
"com_ui_dropdown_variables": "Nolaižamās izvēlnes mainīgie:",
|
||||
"com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}` (mainīgā_nosakums:opcija1|opcija2|opcija3)",
|
||||
"com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}`",
|
||||
"com_ui_duplicate": "Dublikāts",
|
||||
"com_ui_duplication_error": "Sarunas dublēšanas laikā radās kļūda.",
|
||||
"com_ui_duplication_processing": "Dublēju sarunu...",
|
||||
@@ -887,7 +870,6 @@
|
||||
"com_ui_feedback_tag_other": "Cita problēma",
|
||||
"com_ui_feedback_tag_unjustified_refusal": "Atteicās bez iemesla",
|
||||
"com_ui_feedback_tag_zero": "Cita problēma",
|
||||
"com_ui_field_max_length": "{{field}} jābūt mazākam par {{length}} rakstzīmēm",
|
||||
"com_ui_field_required": "Šis lauks ir obligāts",
|
||||
"com_ui_file_size": "Faila lielums",
|
||||
"com_ui_files": "Faili",
|
||||
@@ -925,7 +907,7 @@
|
||||
"com_ui_generating": "Ģenerē...",
|
||||
"com_ui_generation_settings": "Ģenerēšanas iestatījumi",
|
||||
"com_ui_getting_started": "Darba sākšana",
|
||||
"com_ui_global_group": "Nav rezultātu",
|
||||
"com_ui_global_group": "kaut kam šeit ir jānotiek. bija tukšs",
|
||||
"com_ui_go_back": "Atgriezties",
|
||||
"com_ui_go_to_conversation": "Doties uz sarunu",
|
||||
"com_ui_good_afternoon": "Labdien",
|
||||
@@ -958,7 +940,7 @@
|
||||
"com_ui_latest_production_version": "Jaunākā produkcijas versija",
|
||||
"com_ui_latest_version": "Jaunākā versija",
|
||||
"com_ui_librechat_code_api_key": "Iegūstiet savu LibreChat koda interpretatora API atslēgu",
|
||||
"com_ui_librechat_code_api_subtitle": "Drošs. Daudzas valodas. Ievades/izvades faili.",
|
||||
"com_ui_librechat_code_api_subtitle": "Droši. Daudzvalodu. Ievades/izvades faili.",
|
||||
"com_ui_librechat_code_api_title": "Palaist mākslīgā intelekta kodu",
|
||||
"com_ui_loading": "Notiek ielāde...",
|
||||
"com_ui_locked": "Bloķēts",
|
||||
@@ -1016,14 +998,13 @@
|
||||
"com_ui_no_bookmarks": "Šķiet, ka jums vēl nav grāmatzīmju. Noklikšķiniet uz sarunas un pievienojiet jaunu.",
|
||||
"com_ui_no_categories": "Nav pieejamas nevienas kategorijas",
|
||||
"com_ui_no_category": "Nav kategorijas",
|
||||
"com_ui_no_changes": "Izmaiņas netika veiktas",
|
||||
"com_ui_no_data": "Nav rezultātu",
|
||||
"com_ui_no_data": "kaut kam šeit ir jānotiek. bija tukšs",
|
||||
"com_ui_no_individual_access": "Aatsevišķiem lietotājiem vai grupām nav pieejas pie šī aģenta",
|
||||
"com_ui_no_personalization_available": "Pašlaik nav pieejamas personalizācijas opcijas",
|
||||
"com_ui_no_read_access": "Jums nav atļaujas skatīt atmiņas",
|
||||
"com_ui_no_results_found": "Nav atrastu rezultātu",
|
||||
"com_ui_no_terms_content": "Nav noteikumu un nosacījumu satura, ko parādīt",
|
||||
"com_ui_no_valid_items": "Nav rezultātu",
|
||||
"com_ui_no_valid_items": "kaut kam šeit ir jānotiek. bija tukšs",
|
||||
"com_ui_none": "Neviens",
|
||||
"com_ui_not_used": "Nav izmantots",
|
||||
"com_ui_nothing_found": "Nekas nav atrasts",
|
||||
@@ -1132,7 +1113,6 @@
|
||||
"com_ui_select_file": "Atlasiet failu",
|
||||
"com_ui_select_model": "Izvēlieties modeli",
|
||||
"com_ui_select_options": "Izvēlieties opcijas...",
|
||||
"com_ui_select_or_create_prompt": "Izvēlieties vai izveidot uzvedni",
|
||||
"com_ui_select_provider": "Izvēlieties pakalpojumu sniedzēju",
|
||||
"com_ui_select_provider_first": "Vispirms izvēlieties pakalpojumu sniedzēju",
|
||||
"com_ui_select_region": "Izvēlieties reģionu",
|
||||
@@ -1167,7 +1147,7 @@
|
||||
"com_ui_special_var_current_user": "Pašreizējais lietotājs",
|
||||
"com_ui_special_var_iso_datetime": "UTC ISO datums un laiks",
|
||||
"com_ui_special_variables": "Īpašie mainīgie:",
|
||||
"com_ui_special_variables_more_info": "Nolaižamajā izvēlnē varat atlasīt īpašos mainīgos:{{current_date}}` (šodienas datums un nedēļas diena), `{{current_datetime}}` (vietējais datums un laiks), `{{utc_iso_datetime}}` (UTC ISO datums/laiks) un `{{current_user}} (jūsu lietotāja vārds).",
|
||||
"com_ui_special_variables_more_info": "Nolaižamajā izvēlnē varat atlasīt īpašos mainīgos:{{current_date}}` (šodienas datums un nedēļas diena), `{{current_datetime}}` (vietējais datums un laiks), `{{utc_iso_datetime}}` (UTC ISO datums/laiks) un `{{current_user}} (jūsu konta nosaukums).",
|
||||
"com_ui_speech_while_submitting": "Nevar iesniegt runu, kamēr tiek ģenerēta atbilde.",
|
||||
"com_ui_sr_actions_menu": "Atvērt darbību izvēlni priekš \"{{0}}\"",
|
||||
"com_ui_stop": "Apstāties",
|
||||
@@ -1230,7 +1210,7 @@
|
||||
"com_ui_user_group_permissions": "Lietotāju un grupu atļaujas",
|
||||
"com_ui_value": "Vērtība",
|
||||
"com_ui_variables": "Mainīgie",
|
||||
"com_ui_variables_info": "Mainīgo veidošanai tekstā izmantot dubultās iekavas, piemēram, `{{example variable}}` (mainīgā piemērs), lai vēlāk aizpildītu, izmantojot uzvedni.",
|
||||
"com_ui_variables_info": "Mainīgo veidošanai tekstā izmantot dubultās iekavas, piemēram, `{{example variable}}`, lai vēlāk aizpildītu, izmantojot uzvedni.",
|
||||
"com_ui_verify": "Pārbaudīt",
|
||||
"com_ui_version_var": "Versija {{0}}",
|
||||
"com_ui_versions": "Versijas",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -6,24 +6,8 @@
|
||||
"com_a11y_start": "AI 已开始回复。",
|
||||
"com_agents_agent_card_label": "智能体 {{name}}:{{description}}",
|
||||
"com_agents_all": "全部智能体",
|
||||
"com_agents_all_category": "全部",
|
||||
"com_agents_all_description": "浏览所有类别中的全部共享智能体",
|
||||
"com_agents_by_librechat": "由 LibreChat 提供",
|
||||
"com_agents_category_aftersales": "售后",
|
||||
"com_agents_category_aftersales_description": "专注于售后支持、维护与客户服务的专用智能体",
|
||||
"com_agents_category_empty": "在 {{category}} 类别中未找到智能体",
|
||||
"com_agents_category_finance": "财务",
|
||||
"com_agents_category_finance_description": "专注于财务分析、预算编制和会计的专用智能体",
|
||||
"com_agents_category_general": "通用",
|
||||
"com_agents_category_general_description": "用于处理常见任务和咨询的通用智能体",
|
||||
"com_agents_category_hr": "人事",
|
||||
"com_agents_category_hr_description": "专注于人事流程、政策及员工支持的专用智能体",
|
||||
"com_agents_category_it": "IT",
|
||||
"com_agents_category_it_description": "专注于 IT 支持、技术排障及系统管理的专用智能体",
|
||||
"com_agents_category_rd": "研发",
|
||||
"com_agents_category_rd_description": "专注于研发流程、创新及技术研究的专用智能体",
|
||||
"com_agents_category_sales": "销售",
|
||||
"com_agents_category_sales_description": "专注于销售流程和客户关系的专用智能体",
|
||||
"com_agents_category_tab_label": "分类 {{category}},{{position}} / {{total}}",
|
||||
"com_agents_category_tabs_label": "智能体类别",
|
||||
"com_agents_clear_search": "清除搜索",
|
||||
@@ -640,10 +624,10 @@
|
||||
"com_ui_advanced_settings": "进阶设置",
|
||||
"com_ui_agent": "智能体",
|
||||
"com_ui_agent_category_aftersales": "售后",
|
||||
"com_ui_agent_category_finance": "财务",
|
||||
"com_ui_agent_category_finance": "金融",
|
||||
"com_ui_agent_category_general": "通用",
|
||||
"com_ui_agent_category_hr": "人事",
|
||||
"com_ui_agent_category_it": "IT",
|
||||
"com_ui_agent_category_hr": "人力资源",
|
||||
"com_ui_agent_category_it": "信息技术",
|
||||
"com_ui_agent_category_rd": "研发",
|
||||
"com_ui_agent_category_sales": "销售",
|
||||
"com_ui_agent_category_selector_aria": "智能体类别选择器",
|
||||
@@ -740,7 +724,6 @@
|
||||
"com_ui_bookmarks_edit": "编辑书签",
|
||||
"com_ui_bookmarks_filter": "筛选书签...",
|
||||
"com_ui_bookmarks_new": "新书签",
|
||||
"com_ui_bookmarks_tag_exists": "已存在使用此标题的书签",
|
||||
"com_ui_bookmarks_title": "标题",
|
||||
"com_ui_bookmarks_update_error": "更新书签时出现错误",
|
||||
"com_ui_bookmarks_update_success": "书签更新成功",
|
||||
@@ -885,7 +868,6 @@
|
||||
"com_ui_feedback_tag_not_matched": "不符合我的要求",
|
||||
"com_ui_feedback_tag_other": "其他问题",
|
||||
"com_ui_feedback_tag_unjustified_refusal": "无故拒绝回答",
|
||||
"com_ui_field_max_length": "{{field}} 最多 {{length}} 个字符",
|
||||
"com_ui_field_required": "此字段为必填项",
|
||||
"com_ui_file_size": "文件大小",
|
||||
"com_ui_files": "文件",
|
||||
@@ -1014,7 +996,6 @@
|
||||
"com_ui_no_bookmarks": "似乎您还没有书签,点击一个对话并添加一个新的书签",
|
||||
"com_ui_no_categories": "无可用类别",
|
||||
"com_ui_no_category": "无类别",
|
||||
"com_ui_no_changes": "未做任何修改",
|
||||
"com_ui_no_data": "这里需要放点东西,当前是空的",
|
||||
"com_ui_no_individual_access": "任何个人用户或群组都无法访问该智能体",
|
||||
"com_ui_no_personalization_available": "当前没有可用的个性化选项",
|
||||
@@ -1130,7 +1111,6 @@
|
||||
"com_ui_select_file": "选择文件",
|
||||
"com_ui_select_model": "模型选择",
|
||||
"com_ui_select_options": "选择选项...",
|
||||
"com_ui_select_or_create_prompt": "选择或创建提示词",
|
||||
"com_ui_select_provider": "选择提供商",
|
||||
"com_ui_select_provider_first": "请先选择提供商",
|
||||
"com_ui_select_region": "选择地区",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { buildTree } from 'librechat-data-provider';
|
||||
import { Spinner, useToastContext } from '@librechat/client';
|
||||
import MinimalMessagesWrapper from '~/components/Chat/Messages/MinimalMessages';
|
||||
import { useNavScrolling, useLocalize, useAuthContext } from '~/hooks';
|
||||
import SearchMessage from '~/components/Chat/Messages/SearchMessage';
|
||||
import { useMessagesInfiniteQuery } from '~/data-provider';
|
||||
import { useFileMapContext } from '~/Providers';
|
||||
import { buildTree } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Search() {
|
||||
|
||||
@@ -1,4 +1,49 @@
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { TFile, TMessage } from 'librechat-data-provider';
|
||||
|
||||
type ParentMessage = TMessage & { children: TMessage[]; depth: number };
|
||||
export default function buildTree({
|
||||
messages,
|
||||
fileMap,
|
||||
}: {
|
||||
messages: TMessage[] | null;
|
||||
fileMap?: Record<string, TFile>;
|
||||
}) {
|
||||
if (messages === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageMap: Record<string, ParentMessage> = {};
|
||||
const rootMessages: TMessage[] = [];
|
||||
const childrenCount: Record<string, number> = {};
|
||||
|
||||
messages.forEach((message) => {
|
||||
const parentId = message.parentMessageId ?? '';
|
||||
childrenCount[parentId] = (childrenCount[parentId] || 0) + 1;
|
||||
|
||||
const extendedMessage: ParentMessage = {
|
||||
...message,
|
||||
children: [],
|
||||
depth: 0,
|
||||
siblingIndex: childrenCount[parentId] - 1,
|
||||
};
|
||||
|
||||
if (message.files && fileMap) {
|
||||
extendedMessage.files = message.files.map((file) => fileMap[file.file_id ?? ''] ?? file);
|
||||
}
|
||||
|
||||
messageMap[message.messageId] = extendedMessage;
|
||||
|
||||
const parentMessage = messageMap[parentId];
|
||||
if (parentMessage) {
|
||||
parentMessage.children.push(extendedMessage);
|
||||
extendedMessage.depth = parentMessage.depth + 1;
|
||||
} else {
|
||||
rootMessages.push(extendedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
return rootMessages;
|
||||
}
|
||||
|
||||
const even =
|
||||
'w-full border-b border-black/10 dark:border-gray-800/50 text-gray-800 bg-white dark:text-gray-200 group dark:bg-gray-800 hover:bg-gray-200/25 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200';
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client';
|
||||
import {
|
||||
SheetPaths,
|
||||
TextPaths,
|
||||
FilePaths,
|
||||
CodePaths,
|
||||
AudioPaths,
|
||||
VideoPaths,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
megabyte,
|
||||
QueryKeys,
|
||||
@@ -38,6 +45,18 @@ const artifact = {
|
||||
title: 'Code',
|
||||
};
|
||||
|
||||
const audioFile = {
|
||||
paths: AudioPaths,
|
||||
fill: '#FF6B35',
|
||||
title: 'Audio',
|
||||
};
|
||||
|
||||
const videoFile = {
|
||||
paths: VideoPaths,
|
||||
fill: '#8B5CF6',
|
||||
title: 'Video',
|
||||
};
|
||||
|
||||
export const fileTypes = {
|
||||
/* Category matches */
|
||||
file: {
|
||||
@@ -47,6 +66,8 @@ export const fileTypes = {
|
||||
},
|
||||
text: textDocument,
|
||||
txt: textDocument,
|
||||
audio: audioFile,
|
||||
video: videoFile,
|
||||
// application:,
|
||||
|
||||
/* Partial matches */
|
||||
|
||||
@@ -21,6 +21,7 @@ export * from './promptGroups';
|
||||
export * from './email';
|
||||
export { default as cn } from './cn';
|
||||
export { default as logger } from './logger';
|
||||
export { default as buildTree } from './buildTree';
|
||||
export { default as scaleImage } from './scaleImage';
|
||||
export { default as getLoginError } from './getLoginError';
|
||||
export { default as cleanupPreset } from './cleanupPreset';
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -52227,7 +52227,7 @@
|
||||
},
|
||||
"packages/data-provider": {
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.8.006",
|
||||
"version": "0.8.004",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './openid';
|
||||
@@ -1,49 +0,0 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { IUser, UserMethods } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* Finds or migrates a user for OpenID authentication
|
||||
* @returns user object (with migration fields if needed), error message, and whether migration is needed
|
||||
*/
|
||||
export async function findOpenIDUser({
|
||||
openidId,
|
||||
email,
|
||||
findUser,
|
||||
strategyName = 'openid',
|
||||
}: {
|
||||
openidId: string;
|
||||
findUser: UserMethods['findUser'];
|
||||
email?: string;
|
||||
strategyName?: string;
|
||||
}): Promise<{ user: IUser | null; error: string | null; migration: boolean }> {
|
||||
let user = await findUser({ openidId });
|
||||
logger.info(`[${strategyName}] user ${user ? 'found' : 'not found'} with openidId: ${openidId}`);
|
||||
|
||||
// If user not found by openidId, try to find by email
|
||||
if (!user && email) {
|
||||
user = await findUser({ email });
|
||||
logger.warn(
|
||||
`[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`,
|
||||
);
|
||||
|
||||
// If user found by email, check if they're allowed to use OpenID provider
|
||||
if (user && user.provider && user.provider !== 'openid') {
|
||||
logger.warn(
|
||||
`[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
|
||||
);
|
||||
return { user: null, error: 'AUTH_FAILED', migration: false };
|
||||
}
|
||||
|
||||
// If user found by email but doesn't have openidId, prepare for migration
|
||||
if (user && !user.openidId) {
|
||||
logger.info(
|
||||
`[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`,
|
||||
);
|
||||
user.provider = 'openid';
|
||||
user.openidId = openidId;
|
||||
return { user, error: null, migration: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { user, error: null, migration: false };
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
import fs from 'fs';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type {
|
||||
AudioProcessingResult,
|
||||
ServerRequest,
|
||||
AudioFileInfo,
|
||||
STTService,
|
||||
FileObject,
|
||||
} from '~/types';
|
||||
import type { STTService, AudioFileInfo, FileObject, AudioProcessingResult } from '~/types';
|
||||
|
||||
/**
|
||||
* Processes audio files using Speech-to-Text (STT) service.
|
||||
* @returns A promise that resolves to an object containing text and bytes.
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {FileObject} params.file - The audio file object.
|
||||
* @param {STTService} params.sttService - The STT service instance.
|
||||
* @returns {Promise<AudioProcessingResult>} A promise that resolves to an object containing text and bytes.
|
||||
*/
|
||||
export async function processAudioFile({
|
||||
req,
|
||||
file,
|
||||
sttService,
|
||||
}: {
|
||||
req: ServerRequest;
|
||||
file: FileObject;
|
||||
sttService: STTService;
|
||||
}): Promise<AudioProcessingResult> {
|
||||
@@ -29,7 +24,7 @@ export async function processAudioFile({
|
||||
size: file.size,
|
||||
};
|
||||
|
||||
const [provider, sttSchema] = await sttService.getProviderSchema(req);
|
||||
const [provider, sttSchema] = await sttService.getProviderSchema();
|
||||
const text = await sttService.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
|
||||
|
||||
return {
|
||||
|
||||
116
packages/api/src/files/audio/encode.ts
Normal file
116
packages/api/src/files/audio/encode.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Readable } from 'stream';
|
||||
import getStream from 'get-stream';
|
||||
import { EModelEndpoint, isDocumentSupportedEndpoint } from 'librechat-data-provider';
|
||||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import type { Request } from 'express';
|
||||
import { validateAudio } from '~/files/validation';
|
||||
|
||||
interface StrategyFunctions {
|
||||
getDownloadStream: (req: Request, filepath: string) => Promise<Readable>;
|
||||
}
|
||||
|
||||
interface AudioResult {
|
||||
audios: Array<{
|
||||
type: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}>;
|
||||
files: Array<{
|
||||
file_id?: string;
|
||||
temp_file_id?: string;
|
||||
filepath: string;
|
||||
source?: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes and formats audio files for different endpoints
|
||||
* @param req - The request object
|
||||
* @param files - Array of audio files
|
||||
* @param endpoint - The endpoint to format for (currently only google is supported)
|
||||
* @returns Promise that resolves to audio and file metadata
|
||||
*/
|
||||
export async function encodeAndFormatAudios(
|
||||
req: Request,
|
||||
files: IMongoFile[],
|
||||
endpoint: EModelEndpoint,
|
||||
getStrategyFunctions: (source: string) => StrategyFunctions,
|
||||
): Promise<AudioResult> {
|
||||
if (!files?.length) {
|
||||
return { audios: [], files: [] };
|
||||
}
|
||||
|
||||
const encodingMethods: Record<string, StrategyFunctions> = {};
|
||||
const result: AudioResult = { audios: [], files: [] };
|
||||
|
||||
const processFile = async (file: IMongoFile) => {
|
||||
if (!file?.filepath) return null;
|
||||
|
||||
const source = file.source ?? 'local';
|
||||
if (!encodingMethods[source]) {
|
||||
encodingMethods[source] = getStrategyFunctions(source);
|
||||
}
|
||||
|
||||
const { getDownloadStream } = encodingMethods[source];
|
||||
const stream = await getDownloadStream(req, file.filepath);
|
||||
const buffer = await getStream.buffer(stream);
|
||||
|
||||
return {
|
||||
file,
|
||||
content: buffer.toString('base64'),
|
||||
metadata: {
|
||||
file_id: file.file_id,
|
||||
temp_file_id: file.temp_file_id,
|
||||
filepath: file.filepath,
|
||||
source: file.source,
|
||||
filename: file.filename,
|
||||
type: file.type,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled(files.map(processFile));
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === 'rejected') {
|
||||
console.error('Audio processing failed:', settledResult.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const processed = settledResult.value;
|
||||
if (!processed) continue;
|
||||
|
||||
const { file, content, metadata } = processed;
|
||||
|
||||
if (!content || !file) {
|
||||
if (metadata) result.files.push(metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('audio/') || !isDocumentSupportedEndpoint(endpoint)) {
|
||||
result.files.push(metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
const audioBuffer = Buffer.from(content, 'base64');
|
||||
const validation = await validateAudio(audioBuffer, audioBuffer.length, endpoint);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Audio validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.google) {
|
||||
result.audios.push({
|
||||
type: 'audio',
|
||||
mimeType: file.type,
|
||||
data: content,
|
||||
});
|
||||
}
|
||||
|
||||
result.files.push(metadata);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
150
packages/api/src/files/document/encode.ts
Normal file
150
packages/api/src/files/document/encode.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { EModelEndpoint, isDocumentSupportedEndpoint } from 'librechat-data-provider';
|
||||
import { validatePdf } from '@librechat/api';
|
||||
import getStream from 'get-stream';
|
||||
import type { Request } from 'express';
|
||||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
interface StrategyFunctions {
|
||||
getDownloadStream: (req: Request, filepath: string) => Promise<Readable>;
|
||||
}
|
||||
|
||||
interface DocumentResult {
|
||||
documents: Array<{
|
||||
type: string;
|
||||
source?: {
|
||||
type: string;
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
cache_control?: { type: string };
|
||||
citations?: { enabled: boolean };
|
||||
filename?: string;
|
||||
file_data?: string;
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
}>;
|
||||
files: Array<{
|
||||
file_id?: string;
|
||||
temp_file_id?: string;
|
||||
filepath: string;
|
||||
source?: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and encodes document files for various endpoints
|
||||
* @param req - Express request object
|
||||
* @param files - Array of file objects to process
|
||||
* @param endpoint - The endpoint identifier (e.g., EModelEndpoint.anthropic)
|
||||
* @param getStrategyFunctions - Function to get strategy functions
|
||||
* @returns Promise that resolves to documents and file metadata
|
||||
*/
|
||||
export async function encodeAndFormatDocuments(
|
||||
req: Request,
|
||||
files: IMongoFile[],
|
||||
endpoint: EModelEndpoint,
|
||||
getStrategyFunctions: (source: string) => StrategyFunctions,
|
||||
): Promise<DocumentResult> {
|
||||
if (!files?.length) {
|
||||
return { documents: [], files: [] };
|
||||
}
|
||||
|
||||
const encodingMethods: Record<string, StrategyFunctions> = {};
|
||||
const result: DocumentResult = { documents: [], files: [] };
|
||||
|
||||
const documentFiles = files.filter(
|
||||
(file) => file.type === 'application/pdf' || file.type?.startsWith('application/'),
|
||||
);
|
||||
|
||||
if (!documentFiles.length) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const processFile = async (file: IMongoFile) => {
|
||||
if (file.type !== 'application/pdf' || !isDocumentSupportedEndpoint(endpoint)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = file.source ?? 'local';
|
||||
if (!encodingMethods[source]) {
|
||||
encodingMethods[source] = getStrategyFunctions(source);
|
||||
}
|
||||
|
||||
const { getDownloadStream } = encodingMethods[source];
|
||||
const stream = await getDownloadStream(req, file.filepath);
|
||||
const buffer = await getStream.buffer(stream);
|
||||
|
||||
return {
|
||||
file,
|
||||
content: buffer.toString('base64'),
|
||||
metadata: {
|
||||
file_id: file.file_id,
|
||||
temp_file_id: file.temp_file_id,
|
||||
filepath: file.filepath,
|
||||
source: file.source,
|
||||
filename: file.filename,
|
||||
type: file.type,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled(documentFiles.map(processFile));
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === 'rejected') {
|
||||
console.error('Document processing failed:', settledResult.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const processed = settledResult.value;
|
||||
if (!processed) continue;
|
||||
|
||||
const { file, content, metadata } = processed;
|
||||
|
||||
if (!content || !file) {
|
||||
if (metadata) result.files.push(metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.type === 'application/pdf' && isDocumentSupportedEndpoint(endpoint)) {
|
||||
const pdfBuffer = Buffer.from(content, 'base64');
|
||||
const validation = await validatePdf(pdfBuffer, pdfBuffer.length, endpoint);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`PDF validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.anthropic) {
|
||||
result.documents.push({
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: content,
|
||||
},
|
||||
cache_control: { type: 'ephemeral' },
|
||||
citations: { enabled: true },
|
||||
});
|
||||
} else if (endpoint === EModelEndpoint.openAI) {
|
||||
result.documents.push({
|
||||
type: 'input_file',
|
||||
filename: file.filename,
|
||||
file_data: `data:application/pdf;base64,${content}`,
|
||||
});
|
||||
} else if (endpoint === EModelEndpoint.google) {
|
||||
result.documents.push({
|
||||
type: 'document',
|
||||
mimeType: 'application/pdf',
|
||||
data: content,
|
||||
});
|
||||
}
|
||||
|
||||
result.files.push(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2,3 +2,7 @@ export * from './mistral/crud';
|
||||
export * from './audio';
|
||||
export * from './text';
|
||||
export * from './parse';
|
||||
export * from './validation';
|
||||
export * from './audio/encode';
|
||||
export * from './video/encode';
|
||||
export * from './document/encode';
|
||||
|
||||
185
packages/api/src/files/validation.ts
Normal file
185
packages/api/src/files/validation.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { anthropicPdfSizeLimit, EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
export interface PDFValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VideoValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AudioValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function validatePdf(
|
||||
pdfBuffer: Buffer,
|
||||
fileSize: number,
|
||||
endpoint: EModelEndpoint,
|
||||
): Promise<PDFValidationResult> {
|
||||
if (endpoint === EModelEndpoint.anthropic) {
|
||||
return validateAnthropicPdf(pdfBuffer, fileSize);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI) {
|
||||
return validateOpenAIPdf(fileSize);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.google) {
|
||||
return validateGooglePdf(fileSize);
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a PDF meets Anthropic's requirements
|
||||
* @param pdfBuffer - The PDF file as a buffer
|
||||
* @param fileSize - The file size in bytes
|
||||
* @returns Promise that resolves to validation result
|
||||
*/
|
||||
async function validateAnthropicPdf(
|
||||
pdfBuffer: Buffer,
|
||||
fileSize: number,
|
||||
): Promise<PDFValidationResult> {
|
||||
try {
|
||||
if (fileSize > anthropicPdfSizeLimit) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `PDF file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Anthropic's 32MB limit`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pdfBuffer || pdfBuffer.length < 5) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid PDF file: too small or corrupted',
|
||||
};
|
||||
}
|
||||
|
||||
const pdfHeader = pdfBuffer.subarray(0, 5).toString();
|
||||
if (!pdfHeader.startsWith('%PDF-')) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid PDF file: missing PDF header',
|
||||
};
|
||||
}
|
||||
|
||||
const pdfContent = pdfBuffer.toString('binary');
|
||||
if (
|
||||
pdfContent.includes('/Encrypt ') ||
|
||||
pdfContent.includes('/U (') ||
|
||||
pdfContent.includes('/O (')
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'PDF is password-protected or encrypted. Anthropic requires unencrypted PDFs.',
|
||||
};
|
||||
}
|
||||
|
||||
const pageMatches = pdfContent.match(/\/Type[\s]*\/Page[^s]/g);
|
||||
const estimatedPages = pageMatches ? pageMatches.length : 1;
|
||||
|
||||
if (estimatedPages > 100) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `PDF has approximately ${estimatedPages} pages, exceeding Anthropic's 100-page limit`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
console.error('PDF validation error:', error);
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Failed to validate PDF file',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOpenAIPdf(fileSize: number): Promise<PDFValidationResult> {
|
||||
if (fileSize > 10 * 1024 * 1024) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "PDF file size exceeds OpenAI's 10MB limit",
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async function validateGooglePdf(fileSize: number): Promise<PDFValidationResult> {
|
||||
if (fileSize > 20 * 1024 * 1024) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "PDF file size exceeds Google's 20MB limit",
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates video files for different endpoints
|
||||
* @param videoBuffer - The video file as a buffer
|
||||
* @param fileSize - The file size in bytes
|
||||
* @param endpoint - The endpoint to validate for
|
||||
* @returns Promise that resolves to validation result
|
||||
*/
|
||||
export async function validateVideo(
|
||||
videoBuffer: Buffer,
|
||||
fileSize: number,
|
||||
endpoint: EModelEndpoint,
|
||||
): Promise<VideoValidationResult> {
|
||||
if (endpoint === EModelEndpoint.google) {
|
||||
if (fileSize > 20 * 1024 * 1024) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Video file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Google's 20MB limit`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoBuffer || videoBuffer.length < 10) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid video file: too small or corrupted',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates audio files for different endpoints
|
||||
* @param audioBuffer - The audio file as a buffer
|
||||
* @param fileSize - The file size in bytes
|
||||
* @param endpoint - The endpoint to validate for
|
||||
* @returns Promise that resolves to validation result
|
||||
*/
|
||||
export async function validateAudio(
|
||||
audioBuffer: Buffer,
|
||||
fileSize: number,
|
||||
endpoint: EModelEndpoint,
|
||||
): Promise<AudioValidationResult> {
|
||||
if (endpoint === EModelEndpoint.google) {
|
||||
if (fileSize > 20 * 1024 * 1024) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Audio file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Google's 20MB limit`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioBuffer || audioBuffer.length < 10) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid audio file: too small or corrupted',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
117
packages/api/src/files/video/encode.ts
Normal file
117
packages/api/src/files/video/encode.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { EModelEndpoint, isDocumentSupportedEndpoint } from 'librechat-data-provider';
|
||||
import { validateVideo } from '@librechat/api';
|
||||
import getStream from 'get-stream';
|
||||
import type { Request } from 'express';
|
||||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
interface StrategyFunctions {
|
||||
getDownloadStream: (req: Request, filepath: string) => Promise<Readable>;
|
||||
}
|
||||
|
||||
interface VideoResult {
|
||||
videos: Array<{
|
||||
type: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}>;
|
||||
files: Array<{
|
||||
file_id?: string;
|
||||
temp_file_id?: string;
|
||||
filepath: string;
|
||||
source?: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes and formats video files for different endpoints
|
||||
* @param req - The request object
|
||||
* @param files - Array of video files
|
||||
* @param endpoint - The endpoint to format for
|
||||
* @param getStrategyFunctions - Function to get strategy functions
|
||||
* @returns Promise that resolves to videos and file metadata
|
||||
*/
|
||||
export async function encodeAndFormatVideos(
|
||||
req: Request,
|
||||
files: IMongoFile[],
|
||||
endpoint: EModelEndpoint,
|
||||
getStrategyFunctions: (source: string) => StrategyFunctions,
|
||||
): Promise<VideoResult> {
|
||||
if (!files?.length) {
|
||||
return { videos: [], files: [] };
|
||||
}
|
||||
|
||||
const encodingMethods: Record<string, StrategyFunctions> = {};
|
||||
const result: VideoResult = { videos: [], files: [] };
|
||||
|
||||
const processFile = async (file: IMongoFile) => {
|
||||
if (!file?.filepath) return null;
|
||||
|
||||
const source = file.source ?? 'local';
|
||||
if (!encodingMethods[source]) {
|
||||
encodingMethods[source] = getStrategyFunctions(source);
|
||||
}
|
||||
|
||||
const { getDownloadStream } = encodingMethods[source];
|
||||
const stream = await getDownloadStream(req, file.filepath);
|
||||
const buffer = await getStream.buffer(stream);
|
||||
|
||||
return {
|
||||
file,
|
||||
content: buffer.toString('base64'),
|
||||
metadata: {
|
||||
file_id: file.file_id,
|
||||
temp_file_id: file.temp_file_id,
|
||||
filepath: file.filepath,
|
||||
source: file.source,
|
||||
filename: file.filename,
|
||||
type: file.type,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled(files.map(processFile));
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === 'rejected') {
|
||||
console.error('Video processing failed:', settledResult.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const processed = settledResult.value;
|
||||
if (!processed) continue;
|
||||
|
||||
const { file, content, metadata } = processed;
|
||||
|
||||
if (!content || !file) {
|
||||
if (metadata) result.files.push(metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('video/') || !isDocumentSupportedEndpoint(endpoint)) {
|
||||
result.files.push(metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
const videoBuffer = Buffer.from(content, 'base64');
|
||||
const validation = await validateVideo(videoBuffer, videoBuffer.length, endpoint);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Video validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.google) {
|
||||
result.videos.push({
|
||||
type: 'video',
|
||||
mimeType: file.type,
|
||||
data: content,
|
||||
});
|
||||
}
|
||||
|
||||
result.files.push(metadata);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from './app';
|
||||
/* Auth */
|
||||
export * from './auth';
|
||||
/* MCP */
|
||||
export * from './mcp/MCPManager';
|
||||
export * from './mcp/connection';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ServerRequest } from './http';
|
||||
export interface STTService {
|
||||
getInstance(): Promise<STTService>;
|
||||
getProviderSchema(req: ServerRequest): Promise<[string, object]>;
|
||||
getProviderSchema(): Promise<[string, object]>;
|
||||
sttRequest(
|
||||
provider: string,
|
||||
schema: object,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type { Request } from 'express';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { AppConfig } from './config';
|
||||
|
||||
/**
|
||||
* LibreChat-specific request body type that extends Express Request body
|
||||
* (have to use type alias because you can't extend indexed access types like Request['body'])
|
||||
@@ -11,8 +7,3 @@ export type RequestBody = {
|
||||
conversationId?: string;
|
||||
parentMessageId?: string;
|
||||
};
|
||||
|
||||
export type ServerRequest = Request & {
|
||||
user?: IUser;
|
||||
config?: AppConfig;
|
||||
};
|
||||
|
||||
41
packages/client/src/svgs/AudioPaths.tsx
Normal file
41
packages/client/src/svgs/AudioPaths.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
export default function AudioPaths() {
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M8 15v6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 8v20"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18 10v16"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M23 6v24"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M28 12v12"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
packages/client/src/svgs/VideoPaths.tsx
Normal file
10
packages/client/src/svgs/VideoPaths.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function VideoPaths() {
|
||||
return (
|
||||
<>
|
||||
{/* Video container - rounded rectangle (not filled) */}
|
||||
<rect x="8" y="10" width="20" height="16" rx="3" stroke="white" strokeWidth="2" fill="none" />
|
||||
{/* Play button - centered and pointing right */}
|
||||
<path d="M22 18l-6 4v-8L22 18z" fill="white" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -65,9 +65,11 @@ export { default as PersonalizationIcon } from './PersonalizationIcon';
|
||||
export { default as MCPIcon } from './MCPIcon';
|
||||
export { default as VectorIcon } from './VectorIcon';
|
||||
export { default as SquirclePlusIcon } from './SquirclePlusIcon';
|
||||
export { default as AudioPaths } from './AudioPaths';
|
||||
export { default as CodePaths } from './CodePaths';
|
||||
export { default as FileIcon } from './FileIcon';
|
||||
export { default as FilePaths } from './FilePaths';
|
||||
export { default as SheetPaths } from './SheetPaths';
|
||||
export { default as TextPaths } from './TextPaths';
|
||||
export { default as VideoPaths } from './VideoPaths';
|
||||
export { default as SharePointIcon } from './SharePointIcon';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.8.006",
|
||||
"version": "0.8.004",
|
||||
"description": "data services for librechat apps",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
||||
@@ -3,10 +3,7 @@ import * as q from './types/queries';
|
||||
import { ResourceType } from './accessPermissions';
|
||||
|
||||
let BASE_URL = '';
|
||||
if (
|
||||
typeof process === 'undefined' ||
|
||||
(process as typeof process & { browser?: boolean }).browser === true
|
||||
) {
|
||||
if (typeof process === 'undefined' || process.browser === true) {
|
||||
// process is only available in node context, or process.browser is true in client-side code
|
||||
// This is to ensure that the BASE_URL is set correctly based on the <base>
|
||||
// element in the HTML document, if it exists.
|
||||
@@ -61,7 +58,7 @@ export const messages = (params: q.MessagesListParams) => {
|
||||
return `${messagesRoot}/${conversationId}`;
|
||||
}
|
||||
|
||||
return `${messagesRoot}${buildQuery(rest)}`;
|
||||
return `${messagesRoot}{buildQuery(rest)}`;
|
||||
};
|
||||
|
||||
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifacts/${messageId}`;
|
||||
|
||||
@@ -175,6 +175,7 @@ export enum Capabilities {
|
||||
export enum AgentCapabilities {
|
||||
hide_sequential_outputs = 'hide_sequential_outputs',
|
||||
end_after_tools = 'end_after_tools',
|
||||
direct_attach = 'direct_attach',
|
||||
execute_code = 'execute_code',
|
||||
file_search = 'file_search',
|
||||
web_search = 'web_search',
|
||||
@@ -248,6 +249,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge(
|
||||
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
|
||||
|
||||
export const defaultAgentCapabilities = [
|
||||
AgentCapabilities.direct_attach,
|
||||
AgentCapabilities.execute_code,
|
||||
AgentCapabilities.file_search,
|
||||
AgentCapabilities.web_search,
|
||||
@@ -641,7 +643,6 @@ export type TStartupConfig = {
|
||||
sharePointPickerGraphScope?: string;
|
||||
sharePointPickerSharePointScope?: string;
|
||||
openidReuseTokens?: boolean;
|
||||
minPasswordLength?: number;
|
||||
webSearch?: {
|
||||
searchProvider?: SearchProviders;
|
||||
scraperType?: ScraperTypes;
|
||||
|
||||
@@ -57,6 +57,27 @@ export const fullMimeTypesList = [
|
||||
'application/zip',
|
||||
'image/svg',
|
||||
'image/svg+xml',
|
||||
// Video formats
|
||||
'video/mp4',
|
||||
'video/avi',
|
||||
'video/mov',
|
||||
'video/wmv',
|
||||
'video/flv',
|
||||
'video/webm',
|
||||
'video/mkv',
|
||||
'video/m4v',
|
||||
'video/3gp',
|
||||
'video/ogv',
|
||||
// Audio formats
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/m4a',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/wma',
|
||||
'audio/opus',
|
||||
'audio/mpeg',
|
||||
...excelFileTypes,
|
||||
];
|
||||
|
||||
@@ -123,7 +144,9 @@ export const applicationMimeTypes =
|
||||
export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/;
|
||||
|
||||
export const audioMimeTypes =
|
||||
/^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|x-m4a|flac|x-flac|webm)$/;
|
||||
/^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|m4a|x-m4a|flac|x-flac|webm|aac|wma|opus)$/;
|
||||
|
||||
export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv)$/;
|
||||
|
||||
export const defaultOCRMimeTypes = [
|
||||
imageMimeTypes,
|
||||
@@ -142,8 +165,9 @@ export const supportedMimeTypes = [
|
||||
excelMimeTypes,
|
||||
applicationMimeTypes,
|
||||
imageMimeTypes,
|
||||
videoMimeTypes,
|
||||
audioMimeTypes,
|
||||
/** Supported by LC Code Interpreter PAI */
|
||||
/** Supported by LC Code Interpreter API */
|
||||
/^image\/(svg|svg\+xml)$/,
|
||||
];
|
||||
|
||||
@@ -186,6 +210,10 @@ export const mbToBytes = (mb: number): number => mb * megabyte;
|
||||
|
||||
const defaultSizeLimit = mbToBytes(512);
|
||||
const defaultTokenLimit = 100000;
|
||||
|
||||
// Anthropic PDF limits: 32MB max, 100 pages max
|
||||
export const anthropicPdfSizeLimit = mbToBytes(32);
|
||||
|
||||
const assistantsFileConfig = {
|
||||
fileLimit: 10,
|
||||
fileSizeLimit: defaultSizeLimit,
|
||||
@@ -199,6 +227,14 @@ export const fileConfig = {
|
||||
[EModelEndpoint.assistants]: assistantsFileConfig,
|
||||
[EModelEndpoint.azureAssistants]: assistantsFileConfig,
|
||||
[EModelEndpoint.agents]: assistantsFileConfig,
|
||||
[EModelEndpoint.anthropic]: {
|
||||
fileLimit: 10,
|
||||
fileSizeLimit: defaultSizeLimit,
|
||||
totalSizeLimit: defaultSizeLimit,
|
||||
supportedMimeTypes,
|
||||
disabled: false,
|
||||
pdfSizeLimit: anthropicPdfSizeLimit,
|
||||
},
|
||||
default: {
|
||||
fileLimit: 10,
|
||||
fileSizeLimit: defaultSizeLimit,
|
||||
|
||||
@@ -3,8 +3,6 @@ export * from './azure';
|
||||
export * from './bedrock';
|
||||
export * from './config';
|
||||
export * from './file-config';
|
||||
/* messages */
|
||||
export * from './messages';
|
||||
/* artifacts */
|
||||
export * from './artifacts';
|
||||
/* schema helpers */
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { TFile } from './types/files';
|
||||
import type { TMessage } from './types';
|
||||
|
||||
export type ParentMessage = TMessage & { children: TMessage[]; depth: number };
|
||||
export function buildTree({
|
||||
messages,
|
||||
fileMap,
|
||||
}: {
|
||||
messages: (TMessage | undefined)[] | null;
|
||||
fileMap?: Record<string, TFile>;
|
||||
}) {
|
||||
if (messages === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageMap: Record<string, ParentMessage> = {};
|
||||
const rootMessages: TMessage[] = [];
|
||||
const childrenCount: Record<string, number> = {};
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const parentId = message.parentMessageId ?? '';
|
||||
childrenCount[parentId] = (childrenCount[parentId] || 0) + 1;
|
||||
|
||||
const extendedMessage: ParentMessage = {
|
||||
...message,
|
||||
children: [],
|
||||
depth: 0,
|
||||
siblingIndex: childrenCount[parentId] - 1,
|
||||
};
|
||||
|
||||
if (message.files && fileMap) {
|
||||
extendedMessage.files = message.files.map((file) => fileMap[file.file_id ?? ''] ?? file);
|
||||
}
|
||||
|
||||
messageMap[message.messageId] = extendedMessage;
|
||||
|
||||
const parentMessage = messageMap[parentId];
|
||||
if (parentMessage) {
|
||||
parentMessage.children.push(extendedMessage);
|
||||
extendedMessage.depth = parentMessage.depth + 1;
|
||||
} else {
|
||||
rootMessages.push(extendedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
return rootMessages;
|
||||
}
|
||||
@@ -31,6 +31,20 @@ export enum EModelEndpoint {
|
||||
gptPlugins = 'gptPlugins',
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that support direct PDF processing in the agent system
|
||||
*/
|
||||
export const documentSupportedEndpoints = new Set<EModelEndpoint>([
|
||||
EModelEndpoint.anthropic,
|
||||
EModelEndpoint.openAI,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
EModelEndpoint.google,
|
||||
]);
|
||||
|
||||
export const isDocumentSupportedEndpoint = (endpoint: EModelEndpoint): boolean => {
|
||||
return documentSupportedEndpoints.has(endpoint);
|
||||
};
|
||||
|
||||
export const paramEndpoints = new Set<EModelEndpoint | string>([
|
||||
EModelEndpoint.agents,
|
||||
EModelEndpoint.openAI,
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum Tools {
|
||||
|
||||
export enum EToolResources {
|
||||
code_interpreter = 'code_interpreter',
|
||||
direct_attach = 'direct_attach',
|
||||
execute_code = 'execute_code',
|
||||
file_search = 'file_search',
|
||||
image_edit = 'image_edit',
|
||||
|
||||
Reference in New Issue
Block a user