Compare commits

..

20 Commits

Author SHA1 Message Date
Dustin Healy
dcd943b370 fix: add missing appConfig reference that was causing error on upload as text OCR path 2025-08-28 23:27:31 -07:00
Danny Avila
62315be197 🔧 fix: Add missing configMiddleware to Convo Import Routes 2025-08-28 23:12:58 -04:00
Danny Avila
a26597a696 📇 refactor: Improve State mgmt. for File uploads and Tool Auth (#9359)
* 🔧 fix: Ensure loading state is correctly set when files are empty or in progress

* 🔧 fix: Update ephemeral agent state on file upload error for execute code tool resource

* 🔧 fix: Reset ephemeral agent state for tool when authentication fails

* refactor: Pass conversation prop to FileFormChat and AttachFileChat components
2025-08-28 23:11:16 -04:00
Danny Avila
8772b04d1d 🗃️ refactor: File Access via Agent; Deny Deletion if not Editor, Allow Viewer (#9357) 2025-08-28 21:16:23 -04:00
Danny Avila
7742b18c9c 🔧 fix: Upload Audio as Text missing Param (#9356) 2025-08-28 21:07:30 -04:00
Arthur Barrett
b75b799e34 🔧 fix: Handle Web API Streams in File Download Route for OpenAI Assistants (#9200) 2025-08-28 12:39:35 -04:00
Danny Avila
43add11b05 🎯 refactor: Custom Endpoint Request-based Header Resolution (#9344)
* refactor: resolve request-based headers for custom endpoints right before LLM request

* ci: clarify request-based header resolution in initializeClient test
2025-08-28 12:33:08 -04:00
Danny Avila
1764de53a5 fix: use appConfig correctly in getVoices 2025-08-28 00:51:22 -04:00
Danny Avila
c0511b9a5f 🔧 fix: MCP Selection Persist and UI Flicker Issues (#9324)
* refactor: useMCPSelect

    - Add useGetMCPTools to use in useMCPSelect and elsewhere hooks for fetching MCP tools
    - remove memoized key
    - remove use of `useChatContext` and require conversationId as prop

* feat: Add MCPPanelContext and integrate conversationId as prop for useMCPSelect across components

- Introduced MCPPanelContext to manage conversationId state.
- Updated MCPSelect, MCPSubMenu, and MCPConfigDialog to accept conversationId as a prop.
- Modified ToolsDropdown and BadgeRow to pass conversationId to relevant components.
- Refactored MCPPanel to utilize MCPPanelProvider for context management.

* fix: remove nested ternary in ServerInitializationSection

- Replaced conditional operator with if-else statements for better readability in determining button text based on server initialization state and reinitialization status.

* refactor: wrap setValueWrap in useCallback for performance optimization

* refactor: streamline useMCPSelect by consolidating storageKey definition

* fix: prevent clearing selections on page refresh by tracking initial load completion

* refactor: simplify concern of useMCPSelect hook

* refactor: move ConfigFieldDetail interface to common types for better reusability, isolate usage of `useGetMCPTools`

* refactor: integrate mcpServerNames into BadgeRowContext and update ToolsDropdown and MCPSelect components
2025-08-28 00:44:49 -04:00
Danny Avila
2483623c88 🔧 fix: type checking for process.browser in api-endpoints.ts 2025-08-27 20:27:57 -04:00
Danny Avila
229d6f2dfe 📦 chore: Update librechat-data-provider to v0.8.006 2025-08-27 20:23:18 -04:00
github-actions[bot]
d5ec838218 🌍 i18n: Update translation.json with latest translations (#9321)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-27 20:15:38 -04:00
Danny Avila
15d7a3d221 🎵 feat: Cumulative Transcription Support for External STT (#9318)
* 🔧 fix: TTS and STT Services to use AppConfig

- Updated `getProviderSchema` and `getProvider` methods to accept an optional `appConfig` parameter, allowing for more flexible configuration retrieval.
- Improved error handling by ensuring that the app configuration is checked before accessing TTS and STT schemas.
- Refactored `processTextToSpeech` and `streamAudio` methods to utilize the new `appConfig` parameter for better clarity and maintainability.

* feat: Cumulative Transcription Support for STT External

* style: fix medium-sized styling for admin settings dialogs
2025-08-27 18:56:04 -04:00
Danny Avila
c3e88b97c8 🎤 feat: Cumulative Transcription Support for AudioRecorder (#9316)
- Added useRef to maintain existing text during audio recording.
- Updated setText to prepend existing text to new transcriptions.
- Modified handleStartRecording and handleStopRecording to manage existing text state.
- Improved spinner icon styling for better visibility.
2025-08-27 18:00:59 -04:00
Danny Avila
ba424666f8 🔐 feat: Add Configurable Min. Password Length (#9315)
- Added support for a minimum password length defined by the MIN_PASSWORD_LENGTH environment variable.
- Updated login, registration, and reset password forms to utilize the configured minimum length.
- Enhanced validation schemas to reflect the new minimum password length requirement.
- Included tests to ensure the minimum password length functionality works as expected.
2025-08-27 16:30:56 -04:00
MarcAmick
ea3b671182 🔧 feat: Alternative DNS Lookup for AWS ElastiCache TLS Connections (#9264)
* added REDIS_USE_ALTERNATIVE_DNS_LOOKUP env variable 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

* added REDIS_USE_ALTERNATIVE_DNS_LOOKUP env variable 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

---------

Co-authored-by: Marc Amick <MarcAmick@jhu.edu>
2025-08-27 16:09:07 -04:00
Dustin Healy
f209f616c9 🌍 i18n: Add Slovenian Language (#9313) 2025-08-27 14:02:22 -04:00
colinlin-stripe
961af515d5 🧹 chore: [stripe] remove dangerously set html (#9288) 2025-08-27 13:58:07 -04:00
Danny Avila
a362963017 🐛 fix: String Interpolation in Messages Endpoint from #9155 (#9312)
* feat: move buildTree function for message hierarchy to data provider

* refactor: consolidate buildTree import from utils to data provider

* fix: correct string interpolation in messages function, which caused message search requests to fail
2025-08-27 13:48:48 -04:00
Danny Avila
78d735f35c 📧 fix: Missing Email fallback in openIdJwtLogin (#9311)
* 📧 fix: Missing Email fallback in `openIdJwtLogin`

* chore: Add auth module export to index
2025-08-27 12:59:40 -04:00
97 changed files with 899 additions and 1460 deletions

View File

@@ -40,6 +40,13 @@ 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 #
#===============#
@@ -660,6 +667,10 @@ 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

View File

@@ -52,6 +52,9 @@ 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),

View File

@@ -53,6 +53,9 @@ 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 (

View File

@@ -211,7 +211,67 @@ describe('File Access Control', () => {
expect(accessMap.get(fileIds[1])).toBe(false);
});
it('should deny access when user only has VIEW permission', async () => {
it('should deny access when user only has VIEW permission and needs access for deletion', 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,
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();
@@ -265,9 +325,8 @@ describe('File Access Control', () => {
agentId,
});
// 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);
expect(accessMap.get(fileIds[0])).toBe(true);
expect(accessMap.get(fileIds[1])).toBe(true);
});
});

View File

@@ -1,47 +1,9 @@
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();

View File

@@ -7,13 +7,11 @@ const {
createRun,
Tokenizer,
checkAccess,
resolveHeaders,
getBalanceConfig,
memoryInstructions,
formatContentStrings,
createMemoryProcessor,
encodeAndFormatAudios,
encodeAndFormatVideos,
encodeAndFormatDocuments,
} = require('@librechat/api');
const {
Callback,
@@ -36,7 +34,6 @@ const {
AgentCapabilities,
bedrockInputSchema,
removeNullishValues,
isDocumentSupportedEndpoint,
} = require('librechat-data-provider');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
@@ -44,13 +41,11 @@ 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',
@@ -228,168 +223,6 @@ 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,
@@ -423,7 +256,7 @@ class AgentClient extends BaseClient {
};
}
const files = await this.processAttachments(
const files = await this.addImageURLs(
orderedMessages[orderedMessages.length - 1],
attachments,
);
@@ -446,47 +279,6 @@ 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;
@@ -1002,7 +794,6 @@ 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,
@@ -1089,6 +880,16 @@ 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,
@@ -1391,6 +1192,20 @@ 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,

View File

@@ -117,6 +117,11 @@ 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 {

View File

@@ -4,9 +4,13 @@ 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');
@@ -171,6 +175,7 @@ router.post(
'/import',
importIpLimiter,
importUserLimiter,
configMiddleware,
upload.single('file'),
async (req, res) => {
try {

View File

@@ -31,6 +31,7 @@ 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();
@@ -184,6 +185,7 @@ router.delete('/', async (req, res) => {
role: req.user.role,
fileIds: nonOwnedFileIds,
agentId: req.body.agent_id,
isDelete: true,
});
for (const file of nonOwnedFiles) {
@@ -325,11 +327,6 @@ 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 = {
@@ -342,12 +339,19 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
overrideEndpoint: endpointMap[file.source],
});
logger.debug(`Downloading file ${file_id} from OpenAI`);
passThrough = await getDownloadStream(file_id, openai);
const passThrough = await getDownloadStream(file_id, openai);
setHeaders();
logger.debug(`File ${file_id} downloaded from OpenAI`);
passThrough.body.pipe(res);
// 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);
} else {
fileStream = await getDownloadStream(req, file.filepath);
const fileStream = await getDownloadStream(req, file.filepath);
fileStream.on('error', (streamError) => {
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);

View File

@@ -36,10 +36,12 @@ 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)) {

View File

@@ -76,7 +76,10 @@ 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
*/
});
});

View File

@@ -109,9 +109,11 @@ class STTService {
* @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set.
*/
async getProviderSchema(req) {
const appConfig = await getAppConfig({
role: req?.user?.role,
});
const appConfig =
req.config ??
(await getAppConfig({
role: req?.user?.role,
}));
const sttSchema = appConfig?.speech?.stt;
if (!sttSchema) {
throw new Error(

View File

@@ -35,11 +35,12 @@ 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() {
const ttsSchema = this.customConfig.speech.tts;
getProvider(appConfig) {
const ttsSchema = appConfig?.speech?.tts;
if (!ttsSchema) {
throw new Error(
'No TTS schema is set. Did you configure TTS in the custom config (librechat.yaml)?',
@@ -276,8 +277,8 @@ class TTSService {
/**
* Processes a text-to-speech request.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @param {ServerRequest} req - The request object.
* @param {ServerResponse} res - The response object.
* @returns {Promise<void>}
*/
async processTextToSpeech(req, res) {
@@ -287,12 +288,14 @@ class TTSService {
return res.status(400).send('Missing text in request body');
}
const appConfig = await getAppConfig({
role: req.user?.role,
});
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
}));
try {
res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider();
const provider = this.getProvider(appConfig);
const ttsSchema = appConfig?.speech?.tts?.[provider];
const voice = await this.getVoice(ttsSchema, requestVoice);
@@ -344,14 +347,19 @@ class TTSService {
/**
* Streams audio data from the TTS provider.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @param {ServerRequest} req - The request object.
* @param {ServerResponse} res - The response object.
* @returns {Promise<void>}
*/
async streamAudio(req, res) {
res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider();
const ttsSchema = this.customConfig.speech.tts[provider];
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
}));
const provider = this.getProvider(appConfig);
const ttsSchema = appConfig?.speech?.tts?.[provider];
const voice = await this.getVoice(ttsSchema, req.body.voice);
let shouldContinue = true;
@@ -436,8 +444,8 @@ async function createTTSService() {
/**
* Wrapper function for text-to-speech processing.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @param {ServerRequest} req - The request object.
* @param {ServerResponse} res - The response object.
* @returns {Promise<void>}
*/
async function textToSpeech(req, res) {
@@ -460,11 +468,12 @@ 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() {
async function getProvider(appConfig) {
const ttsService = await createTTSService();
return ttsService.getProvider();
return ttsService.getProvider(appConfig);
}
module.exports = {

View File

@@ -14,16 +14,18 @@ const { getProvider } = require('./TTSService');
*/
async function getVoices(req, res) {
try {
const appConfig = await getAppConfig({
role: req.user?.role,
});
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
}));
if (!appConfig || !appConfig?.speech?.tts) {
const ttsSchema = appConfig?.speech?.tts;
if (!ttsSchema) {
throw new Error('Configuration or TTS schema is missing');
}
const ttsSchema = appConfig?.speech?.tts;
const provider = await getProvider(ttsSchema);
const provider = await getProvider(appConfig);
let voices;
switch (provider) {

View File

@@ -4,7 +4,6 @@ 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');
@@ -287,18 +286,7 @@ 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));
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 };
return { filepath, bytes };
}
/**

View File

@@ -2,13 +2,11 @@ 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,
};

View File

@@ -10,9 +10,10 @@ 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 }) => {
const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId, isDelete }) => {
const accessMap = new Map();
// Initialize all files as no access
@@ -44,22 +45,23 @@ const hasAccessToFilesViaAgent = async ({ userId, role, fileIds, agentId }) => {
return accessMap;
}
// 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 (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,
});
// 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)) {

View File

@@ -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,7 +605,11 @@ 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 });
const {
text,
bytes,
filepath: ocrFileURL,
} = await uploadOCR({ req, file, loadAuthValues, appConfig });
return await createTextFile({ text, bytes, filepath: ocrFileURL });
}
@@ -616,7 +620,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
if (shouldUseSTT) {
const sttService = await STTService.getInstance();
const { text, bytes } = await processAudioFile({ file, sttService });
const { text, bytes } = await processAudioFile({ req, file, sttService });
return await createTextFile({ text, bytes });
}

View File

@@ -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.

View File

@@ -1,10 +1,11 @@
const { SystemRoles } = require('librechat-data-provider');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { updateUser, findUser } = require('~/models');
const { logger } = require('~/config');
const jwksRsa = require('jwks-rsa');
const { isEnabled } = require('~/server/utils');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const { updateUser, findUser } = require('~/models');
/**
* @function openIdJwtLogin
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
@@ -13,6 +14,14 @@ const { isEnabled } = require('~/server/utils');
* 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 = {
@@ -34,19 +43,41 @@ const openIdJwtLogin = (openIdConfig) => {
},
async (payload, done) => {
try {
const user = await findUser({ openidId: payload?.sub });
const { user, error, migration } = await findOpenIDUser({
openidId: payload?.sub,
email: payload?.email,
strategyName: 'openIdJwtLogin',
findUser,
});
if (error) {
done(null, false, { message: error });
return;
}
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;
await updateUser(user.id, { role: user.role });
updateData.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?.sub +
(payload?.email ? ' or email: ' + payload.email : ''),
);
done(null, false);
}

View File

@@ -7,7 +7,13 @@ 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, getBalanceConfig } = require('@librechat/api');
const {
isEnabled,
logHeaders,
safeStringify,
findOpenIDUser,
getBalanceConfig,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
@@ -333,23 +339,16 @@ async function setupOpenId() {
async (tokenset, done) => {
try {
const claims = tokenset.claims();
let user = await findUser({ openidId: claims.sub });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`,
);
const result = await findOpenIDUser({
openidId: claims.sub,
email: claims.email,
strategyName: 'openidStrategy',
findUser,
});
let user = result.user;
const error = result.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`,
);
if (error) {
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});

View File

@@ -31,6 +31,7 @@ jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/api'),
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},

View File

@@ -1,5 +1,7 @@
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
@@ -32,7 +34,7 @@ const loginSchema = z.object({
email: z.string().email(),
password: z
.string()
.min(8)
.min(MIN_PASSWORD_LENGTH)
.max(128)
.refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces',
@@ -50,14 +52,14 @@ const registerSchema = z
email: z.string().email(),
password: z
.string()
.min(8)
.min(MIN_PASSWORD_LENGTH)
.max(128)
.refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces',
}),
confirm_password: z
.string()
.min(8)
.min(MIN_PASSWORD_LENGTH)
.max(128)
.refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces',

View File

@@ -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,4 +453,64 @@ 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);
}
});
});
});

View File

@@ -1,12 +1,19 @@
import React, { createContext, useContext, useEffect, useRef } from 'react';
import React, { createContext, useContext, useEffect, useMemo, 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, useToolToggle } from '~/hooks';
import {
useSearchApiKeyForm,
useGetAgentsConfig,
useCodeApiKeyForm,
useGetMCPTools,
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>;
@@ -37,10 +44,12 @@ export default function BadgeRowProvider({
isSubmitting,
conversationId,
}: BadgeRowProviderProps) {
const hasInitializedRef = useRef(false);
const lastKeyRef = useRef<string>('');
const hasInitializedRef = useRef(false);
const { mcpToolDetails } = useGetMCPTools();
const { agentsConfig } = useGetAgentsConfig();
const key = conversationId ?? Constants.NEW_CONVO;
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key));
/** Initialize ephemeralAgent from localStorage on mount and when conversation changes */
@@ -156,11 +165,16 @@ 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,

View File

@@ -0,0 +1,31 @@
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;
}

View File

@@ -23,6 +23,7 @@ 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';

View File

@@ -21,7 +21,6 @@ export type TAgentCapabilities = {
[AgentCapabilities.execute_code]: boolean;
[AgentCapabilities.end_after_tools]?: boolean;
[AgentCapabilities.hide_sequential_outputs]?: boolean;
[AgentCapabilities.direct_attach]?: boolean;
};
export type AgentForm = {

View File

@@ -8,6 +8,11 @@ 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;

View File

@@ -125,7 +125,10 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
aria-label={localize('com_auth_password')}
{...register('password', {
required: localize('com_auth_password_required'),
minLength: { value: 8, message: localize('com_auth_password_min_length') },
minLength: {
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}

View File

@@ -165,7 +165,7 @@ const Registration: React.FC = () => {
{renderInput('password', 'com_auth_password', 'password', {
required: localize('com_auth_password_required'),
minLength: {
value: 8,
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: {

View File

@@ -19,7 +19,7 @@ function ResetPassword() {
const [params] = useSearchParams();
const password = watch('password');
const resetPassword = useResetPasswordMutation();
const { setError, setHeaderText } = useOutletContext<TLoginLayoutContext>();
const { setError, setHeaderText, startupConfig } = 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: 8,
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: {

View File

@@ -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 } from 'librechat-data-provider';
import { Constants, buildTree } 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() {

View File

@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { useToastContext, TooltipAnchor, ListeningIcon, Spinner } from '@librechat/client';
import { useLocalize, useSpeechToText } from '~/hooks';
import { useLocalize, useSpeechToText, useGetAudioSettings } 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,
@@ -18,9 +19,12 @@ export default function AudioRecorder({
textAreaRef: React.RefObject<HTMLTextAreaElement>;
isSubmitting: boolean;
}) {
const { setValue, reset } = methods;
const { setValue, reset, getValues } = methods;
const localize = useLocalize();
const { showToast } = useToastContext();
const { speechToTextEndpoint } = useGetAudioSettings();
const existingTextRef = useRef<string>('');
const onTranscriptionComplete = useCallback(
(text: string) => {
@@ -37,20 +41,34 @@ export default function AudioRecorder({
console.log('Unmuting global audio');
globalAudio.muted = false;
}
ask({ text });
/** For external STT, append existing text to the transcription */
const finalText =
isExternalSTT(speechToTextEndpoint) && existingTextRef.current
? `${existingTextRef.current} ${text}`
: text;
ask({ text: finalText });
reset({ text: '' });
existingTextRef.current = '';
}
},
[ask, reset, showToast, localize, isSubmitting],
[ask, reset, showToast, localize, isSubmitting, speechToTextEndpoint],
);
const setText = useCallback(
(text: string) => {
setValue('text', text, {
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, {
shouldValidate: true,
});
},
[setValue],
[setValue, speechToTextEndpoint],
);
const { isListening, isLoading, startRecording, stopRecording } = useSpeechToText(
@@ -62,18 +80,27 @@ export default function AudioRecorder({
return null;
}
const handleStartRecording = async () => startRecording();
const handleStartRecording = async () => {
existingTextRef.current = getValues('text') || '';
startRecording();
};
const handleStopRecording = async () => stopRecording();
const handleStopRecording = async () => {
stopRecording();
/** For browser STT, clear the reference since text was already being updated */
if (!isExternalSTT(speechToTextEndpoint)) {
existingTextRef.current = '';
}
};
const renderIcon = () => {
if (isListening === true) {
return <ListeningIcon className="stroke-red-500" />;
}
if (isLoading === true) {
return <Spinner className="stroke-gray-700 dark:stroke-gray-300" />;
return <Spinner className="stroke-text-secondary" />;
}
return <ListeningIcon className="stroke-gray-700 dark:stroke-gray-300" />;
return <ListeningIcon className="stroke-text-secondary" />;
};
return (

View File

@@ -368,7 +368,7 @@ function BadgeRow({
<CodeInterpreter />
<FileSearch />
<Artifacts />
<MCPSelect />
<MCPSelect conversationId={conversationId} />
</>
)}
{ghostBadge && (

View File

@@ -253,7 +253,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
/>
<FileFormChat disableInputs={disableInputs} />
<FileFormChat conversation={conversation} />
{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 disableInputs={disableInputs} />
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
</div>
<BadgeRow
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}

View File

@@ -7,14 +7,18 @@ import {
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
import AttachFile from './AttachFile';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
function AttachFileChat({
disableInputs,
conversation,
}: {
disableInputs: boolean;
conversation: TConversation | null;
}) {
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
@@ -36,7 +40,6 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
disabled={disableInputs}
conversationId={conversationId}
endpointFileConfig={endpointFileConfig}
endpoint={endpoint}
/>
);
}

View File

@@ -1,19 +1,8 @@
import React, { useRef, useState, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { useSetRecoilState } from 'recoil';
import {
FileSearch,
ImageUpIcon,
TerminalSquareIcon,
FileType2Icon,
FileImageIcon,
} from 'lucide-react';
import {
EToolResources,
EModelEndpoint,
defaultAgentCapabilities,
isDocumentSupportedEndpoint,
} from 'librechat-data-provider';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import {
FileUpload,
TooltipAnchor,
@@ -25,9 +14,8 @@ 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, useGetAgentByIdQuery } from '~/data-provider';
import { useGetStartupConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { useChatContext } from '~/Providers/ChatContext';
import { MenuItemProps } from '~/common';
import { cn } from '~/utils';
@@ -35,15 +23,9 @@ interface AttachFileMenuProps {
conversationId: string;
disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
endpoint?: string | null;
}
const AttachFileMenu = ({
disabled,
conversationId,
endpointFileConfig,
endpoint,
}: AttachFileMenuProps) => {
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
@@ -64,79 +46,34 @@ const AttachFileMenu = ({
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 = (
fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal',
) => {
const handleUploadClick = (isImage?: boolean) => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
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.accept = isImage === true ? 'image/*' : '';
inputRef.current.click();
inputRef.current.accept = '';
};
const dropdownItems = useMemo(() => {
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({
const createMenuItems = (onAction: (isImage?: boolean) => void) => {
const items: MenuItemProps[] = [
{
label: localize('com_ui_upload_image_input'),
onClick: () => {
setToolResource(undefined);
onAction('image');
onAction(true);
},
icon: <ImageUpIcon className="icon-md" />,
});
}
},
];
if (capabilities.ocrEnabled) {
items.push({
@@ -202,8 +139,6 @@ const AttachFileMenu = ({
setEphemeralAgent,
sharePointEnabled,
setIsSharePointDialogOpen,
endpoint,
agent?.provider,
]);
const menuTrigger = (

View File

@@ -1,13 +1,14 @@
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({ disableInputs }: { disableInputs: boolean }) {
function FileFormChat({ conversation }: { conversation: TConversation | null }) {
const { files, setFiles, setFilesLoading } = useChatContext();
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
const { abortUpload } = useFileHandling();

View File

@@ -59,10 +59,12 @@ export default function FileRow({
useEffect(() => {
if (files.length === 0) {
setFilesLoading(false);
return;
}
if (files.some((file) => file.progress < 1)) {
setFilesLoading(true);
return;
}

View File

@@ -1,13 +1,9 @@
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;
@@ -34,7 +30,7 @@ export default function MCPConfigDialog({
control,
handleSubmit,
reset,
formState: { errors, _ },
formState: { errors },
} = useForm<Record<string, string>>({
defaultValues: initialValues,
});
@@ -56,14 +52,12 @@ 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">

View File

@@ -3,8 +3,11 @@ 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';
function MCPSelect() {
type MCPSelectProps = { conversationId?: string | null };
function MCPSelectContent({ conversationId }: MCPSelectProps) {
const {
configuredServers,
mcpValues,
@@ -15,7 +18,7 @@ function MCPSelect() {
getConfigDialogProps,
isInitializing,
localize,
} = useMCPServerManager();
} = useMCPServerManager({ conversationId });
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
@@ -93,9 +96,17 @@ function MCPSelect() {
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} />}
{configDialogProps && (
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
)}
</>
);
}
function MCPSelect(props: MCPSelectProps) {
const { mcpServerNames } = useBadgeRowContext();
if ((mcpServerNames?.length ?? 0) === 0) return null;
return <MCPSelectContent {...props} />;
}
export default memo(MCPSelect);

View File

@@ -9,10 +9,11 @@ import { cn } from '~/utils';
interface MCPSubMenuProps {
placeholder?: string;
conversationId?: string | null;
}
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
({ placeholder, conversationId, ...props }, ref) => {
const {
configuredServers,
mcpValues,
@@ -23,7 +24,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
} = useMCPServerManager();
} = useMCPServerManager({ conversationId });
const menuStore = Ariakit.useMenuStore({
focusLoop: true,

View File

@@ -10,12 +10,12 @@ import {
PermissionTypes,
defaultAgentCapabilities,
} from 'librechat-data-provider';
import { useLocalize, useHasAccess, useAgentCapabilities, useMCPSelect } from '~/hooks';
import { useLocalize, useHasAccess, useAgentCapabilities } 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,11 +30,12 @@ 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 } =
@@ -56,7 +57,6 @@ 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,7 +290,9 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
if (mcpServerNames && mcpServerNames.length > 0) {
dropdownItems.push({
hideOnClick: false,
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
render: (props) => (
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
),
});
}

View File

@@ -8,15 +8,11 @@ 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;
@@ -27,6 +23,7 @@ interface MCPConfigDialogProps {
onRevoke?: () => void;
serverName: string;
serverStatus?: MCPServerStatus;
conversationId?: string | null;
}
export default function MCPConfigDialog({
@@ -38,6 +35,7 @@ export default function MCPConfigDialog({
onRevoke,
serverName,
serverStatus,
conversationId,
}: MCPConfigDialogProps) {
const localize = useLocalize();
@@ -126,6 +124,7 @@ export default function MCPConfigDialog({
{/* Server Initialization Section */}
<ServerInitializationSection
serverName={serverName}
conversationId={conversationId}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={fieldsSchema && Object.keys(fieldsSchema).length > 0}
/>

View File

@@ -9,12 +9,14 @@ 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();
@@ -26,7 +28,7 @@ export default function ServerInitializationSection({
isInitializing,
isCancellable,
getOAuthUrl,
} = useMCPServerManager();
} = useMCPServerManager({ conversationId });
const serverStatus = connectionStatus[serverName];
const isConnected = serverStatus?.connectionState === 'connected';
@@ -69,13 +71,18 @@ export default function ServerInitializationSection({
const isReinit = shouldShowReinit;
const outerClass = isReinit ? 'flex justify-start' : 'flex justify-end';
const buttonVariant = isReinit ? undefined : 'default';
const buttonText = isServerInitializing
? localize('com_ui_loading')
: isReinit
? localize('com_ui_reinitialize')
: requiresOAuth
? localize('com_ui_authenticate')
: localize('com_ui_mcp_initialize');
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 icon = isServerInitializing ? (
<Spinner className="h-4 w-4" />
) : (

View File

@@ -107,6 +107,7 @@ 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') },
];

View File

@@ -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">
<div dangerouslySetInnerHTML={{ __html: content }} />
{content}
</div>
</div>
</HoverCardContent>

View File

@@ -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 md:w-1/4">
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>
{`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
</OGDialogTitle>

View File

@@ -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() {

View File

@@ -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 md:w-1/4">
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_people_picker',
)}`}</OGDialogTitle>

View File

@@ -157,7 +157,7 @@ const AdminSettings = () => {
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary md:w-1/4">
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_agents',
)}`}</OGDialogTitle>

View File

@@ -8,15 +8,16 @@ 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 BadgeRowProvider from '~/Providers/BadgeRowContext';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
function MCPPanelContent() {
const localize = useLocalize();
const { showToast } = useToastContext();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
@@ -153,6 +154,7 @@ function MCPPanelContent() {
<ServerInitializationSection
sidePanel={true}
conversationId={conversationId}
serverName={selectedServerNameForEditing}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={
@@ -204,8 +206,8 @@ function MCPPanelContent() {
export default function MCPPanel() {
return (
<BadgeRowProvider>
<MCPPanelProvider>
<MCPPanelContent />
</BadgeRowProvider>
</MCPPanelProvider>
);
}

View File

@@ -146,7 +146,7 @@ const AdminSettings = () => {
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary md:w-1/4">
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_memories',
)}`}</OGDialogTitle>

View File

@@ -9,7 +9,6 @@ interface AgentCapabilitiesResult {
fileSearchEnabled: boolean;
webSearchEnabled: boolean;
codeEnabled: boolean;
directAttachEnabled: boolean;
}
export default function useAgentCapabilities(
@@ -50,11 +49,6 @@ export default function useAgentCapabilities(
[capabilities],
);
const directAttachEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.direct_attach) ?? false,
[capabilities],
);
return {
ocrEnabled,
codeEnabled,
@@ -63,6 +57,5 @@ export default function useAgentCapabilities(
artifactsEnabled,
webSearchEnabled,
fileSearchEnabled,
directAttachEnabled,
};
}

View File

@@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
import exportFromJSON from 'export-from-json';
import { useQueryClient } from '@tanstack/react-query';
import {
buildTree,
QueryKeys,
ContentTypes,
ToolCallTypes,
@@ -18,7 +19,7 @@ import type {
} from 'librechat-data-provider';
import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree';
import { useScreenshot } from '~/hooks/ScreenshotContext';
import { cleanupPreset, buildTree } from '~/utils';
import { cleanupPreset } from '~/utils';
type ExportValues = {
fieldName: string;

View File

@@ -1,10 +1,13 @@
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,
@@ -19,6 +22,7 @@ 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';
@@ -39,6 +43,9 @@ 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,
@@ -133,6 +140,13 @@ 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);

View File

@@ -1 +1,3 @@
export * from './useMCPSelect';
export * from './useGetMCPTools';
export { useMCPServerManager } from './useMCPServerManager';

View File

@@ -0,0 +1,43 @@
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,
};
}

View File

@@ -0,0 +1,72 @@
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,
};
}

View File

@@ -8,10 +8,10 @@ import {
useReinitializeMCPServerMutation,
} from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
import type { ConfigFieldDetail } from '~/common';
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,13 +21,14 @@ interface ServerState {
pollInterval: NodeJS.Timeout | null;
}
export function useMCPServerManager() {
export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const mcpSelect = useMCPSelect();
const { data: startupConfig } = useGetStartupConfig();
const { mcpValues, setMCPValues, mcpToolDetails, isPinned, setIsPinned } = mcpSelect;
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { mcpToolDetails } = useGetMCPTools();
const mcpSelect = useMCPSelect({ conversationId });
const { data: startupConfig } = useGetStartupConfig();
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect;
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
@@ -90,7 +91,21 @@ export function useMCPServerManager() {
[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(
@@ -100,7 +115,7 @@ export function useMCPServerManager() {
if (connectedSelected.length !== mcpValues.length) {
setMCPValues(connectedSelected);
}
}, [connectionStatus, mcpValues, setMCPValues]);
}, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]);
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
setServerStates((prev) => {
@@ -486,12 +501,12 @@ export function useMCPServerManager() {
};
},
[
isCancellable,
mcpToolDetails,
isInitializing,
cancelOAuthFlow,
connectionStatus,
startupConfig?.mcpServers,
isInitializing,
isCancellable,
cancelOAuthFlow,
],
);
@@ -547,7 +562,6 @@ export function useMCPServerManager() {
mcpValues,
setMCPValues,
mcpToolDetails,
isPinned,
setIsPinned,
placeholderText,

View File

@@ -1,4 +1,3 @@
export * from './useMCPSelect';
export * from './useToolToggle';
export { default as useAuthCodeTool } from './useAuthCodeTool';
export { default as usePluginInstall } from './usePluginInstall';

View File

@@ -1,136 +0,0 @@
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,
};
}

View File

@@ -98,6 +98,10 @@ export function useToolToggle({
if (isAuthenticated !== undefined && !isAuthenticated && setIsDialogOpen) {
setIsDialogOpen(true);
e?.preventDefault?.();
setEphemeralAgent((prev) => ({
...(prev || {}),
[toolKey]: false,
}));
return;
}

View File

@@ -5,7 +5,7 @@
* - Also value will be updated everywhere, when value updated (via `storage` event)
*/
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
export default function useLocalStorage<T>(
key: string,
@@ -47,23 +47,26 @@ export default function useLocalStorage<T>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, globalSetState]);
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();
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);
}
globalSetState?.(value);
} catch (e) {
console.error(e);
}
};
},
[key, globalSetState, storageCondition],
);
return [value, setValueWrap];
}

View File

@@ -519,6 +519,7 @@
"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": "ไทย",
@@ -888,9 +889,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",
@@ -1218,7 +1219,6 @@
"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",

View File

@@ -39,6 +39,7 @@ 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';
@@ -78,6 +79,7 @@ export const resources = {
hy: { translation: translationHy },
fi: { translation: translationFi },
bo: { translation: translationBo },
sl: { translation: translationSl },
uk: { translation: translationUk },
} as const;

View File

@@ -1,13 +1,29 @@
{
"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",
"chat_direction_left_to_right": "Nav rezultātu",
"chat_direction_right_to_left": "Nav rezultātu",
"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",
@@ -278,7 +294,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": "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_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_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ē.",
@@ -299,7 +315,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": "kaut kam šeit ir jānotiek. bija tukšs",
"com_endpoint_preset_custom_name_placeholder": "Nav rezultātu",
"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.",
@@ -372,7 +388,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": "kaut kam šeit ir jānotiek. bija tukšs",
"com_files_table": "Nav rezultātu",
"com_files_upload_local_machine": "No lokālā datora",
"com_files_upload_sharepoint": "No SharePoint",
"com_generated_files": "Ģenerētie faili:",
@@ -410,7 +426,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 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 jauna 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",
@@ -620,7 +636,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": "Advancēts",
"com_ui_advanced": "Paplašinātie uzstādījumi",
"com_ui_advanced_settings": "Advancētie iestatījumi",
"com_ui_agent": "Aģents",
"com_ui_agent_category_aftersales": "Pēcpārdošanas pakalpojumi",
@@ -724,6 +740,7 @@
"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",
@@ -743,7 +760,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: ievadiet komandu uzvednei vai tiks izmantots nosaukums",
"com_ui_command_placeholder": "Pēc izvēles: Ja tiks izmantota komanda uzvednei vai nosaukums, lūdzu ievadiet",
"com_ui_command_usage_placeholder": "Atlasiet uzvedni pēc komandas vai nosaukuma",
"com_ui_complete_setup": "Pabeigt iestatīšanu",
"com_ui_concise": "Īss",
@@ -791,7 +808,7 @@
"com_ui_date_september": "Septembris",
"com_ui_date_today": "Šodien",
"com_ui_date_yesterday": "Vakar",
"com_ui_decline": "Es nepieņemu",
"com_ui_decline": "Es nepiekrītu",
"com_ui_default_post_request": "Noklusējums (POST pieprasījums)",
"com_ui_delete": "Dzēst",
"com_ui_delete_action": "Dzēst darbību",
@@ -825,9 +842,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": "kaut kam šeit ir jānotiek. bija tukšs",
"com_ui_drag_drop": "Nav rezultātu",
"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}}`",
"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_duplicate": "Dublikāts",
"com_ui_duplication_error": "Sarunas dublēšanas laikā radās kļūda.",
"com_ui_duplication_processing": "Dublēju sarunu...",
@@ -870,6 +887,7 @@
"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",
@@ -907,7 +925,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": "kaut kam šeit ir jānotiek. bija tukšs",
"com_ui_global_group": "Nav rezultātu",
"com_ui_go_back": "Atgriezties",
"com_ui_go_to_conversation": "Doties uz sarunu",
"com_ui_good_afternoon": "Labdien",
@@ -940,7 +958,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ši. Daudzvalodu. Ievades/izvades faili.",
"com_ui_librechat_code_api_subtitle": "Drošs. Daudzas valodas. 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",
@@ -998,13 +1016,14 @@
"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_data": "kaut kam šeit ir jānotiek. bija tukšs",
"com_ui_no_changes": "Izmaiņas netika veiktas",
"com_ui_no_data": "Nav rezultātu",
"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": "kaut kam šeit ir jānotiek. bija tukšs",
"com_ui_no_valid_items": "Nav rezultātu",
"com_ui_none": "Neviens",
"com_ui_not_used": "Nav izmantots",
"com_ui_nothing_found": "Nekas nav atrasts",
@@ -1113,6 +1132,7 @@
"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",
@@ -1147,7 +1167,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 konta nosaukums).",
"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_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",
@@ -1210,7 +1230,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}}`, 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}}` (mainīgā piemērs), lai vēlāk aizpildītu, izmantojot uzvedni.",
"com_ui_verify": "Pārbaudīt",
"com_ui_version_var": "Versija {{0}}",
"com_ui_versions": "Versijas",

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -6,8 +6,24 @@
"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": "清除搜索",
@@ -624,10 +640,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": "信息技术",
"com_ui_agent_category_hr": "人",
"com_ui_agent_category_it": "IT",
"com_ui_agent_category_rd": "研发",
"com_ui_agent_category_sales": "销售",
"com_ui_agent_category_selector_aria": "智能体类别选择器",
@@ -724,6 +740,7 @@
"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": "书签更新成功",
@@ -868,6 +885,7 @@
"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": "文件",
@@ -996,6 +1014,7 @@
"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": "当前没有可用的个性化选项",
@@ -1111,6 +1130,7 @@
"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": "选择地区",

View File

@@ -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() {

View File

@@ -1,49 +1,4 @@
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;
}
import type { TMessage } from 'librechat-data-provider';
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';

View File

@@ -1,11 +1,4 @@
import {
SheetPaths,
TextPaths,
FilePaths,
CodePaths,
AudioPaths,
VideoPaths,
} from '@librechat/client';
import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client';
import {
megabyte,
QueryKeys,
@@ -45,18 +38,6 @@ 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: {
@@ -66,8 +47,6 @@ export const fileTypes = {
},
text: textDocument,
txt: textDocument,
audio: audioFile,
video: videoFile,
// application:,
/* Partial matches */

View File

@@ -21,7 +21,6 @@ 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
View File

@@ -52227,7 +52227,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.8.004",
"version": "0.8.006",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",

View File

@@ -0,0 +1 @@
export * from './openid';

View File

@@ -0,0 +1,49 @@
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 };
}

View File

@@ -1,18 +1,23 @@
import fs from 'fs';
import { logger } from '@librechat/data-schemas';
import type { STTService, AudioFileInfo, FileObject, AudioProcessingResult } from '~/types';
import type {
AudioProcessingResult,
ServerRequest,
AudioFileInfo,
STTService,
FileObject,
} from '~/types';
/**
* Processes audio files using Speech-to-Text (STT) service.
* @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.
* @returns 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> {
@@ -24,7 +29,7 @@ export async function processAudioFile({
size: file.size,
};
const [provider, sttSchema] = await sttService.getProviderSchema();
const [provider, sttSchema] = await sttService.getProviderSchema(req);
const text = await sttService.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
return {

View File

@@ -1,116 +0,0 @@
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;
}

View File

@@ -1,150 +0,0 @@
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;
}

View File

@@ -2,7 +2,3 @@ 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';

View File

@@ -1,185 +0,0 @@
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 };
}

View File

@@ -1,117 +0,0 @@
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;
}

View File

@@ -1,4 +1,6 @@
export * from './app';
/* Auth */
export * from './auth';
/* MCP */
export * from './mcp/MCPManager';
export * from './mcp/connection';

View File

@@ -1,6 +1,7 @@
import type { ServerRequest } from './http';
export interface STTService {
getInstance(): Promise<STTService>;
getProviderSchema(): Promise<[string, object]>;
getProviderSchema(req: ServerRequest): Promise<[string, object]>;
sttRequest(
provider: string,
schema: object,

View File

@@ -1,3 +1,7 @@
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'])
@@ -7,3 +11,8 @@ export type RequestBody = {
conversationId?: string;
parentMessageId?: string;
};
export type ServerRequest = Request & {
user?: IUser;
config?: AppConfig;
};

View File

@@ -1,41 +0,0 @@
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"
/>
</>
);
}

View File

@@ -1,10 +0,0 @@
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" />
</>
);
}

View File

@@ -65,11 +65,9 @@ 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';

View File

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

View File

@@ -3,7 +3,10 @@ import * as q from './types/queries';
import { ResourceType } from './accessPermissions';
let BASE_URL = '';
if (typeof process === 'undefined' || process.browser === true) {
if (
typeof process === 'undefined' ||
(process as typeof process & { browser?: boolean }).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.
@@ -58,7 +61,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}`;

View File

@@ -175,7 +175,6 @@ 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',
@@ -249,7 +248,6 @@ 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,
@@ -643,6 +641,7 @@ export type TStartupConfig = {
sharePointPickerGraphScope?: string;
sharePointPickerSharePointScope?: string;
openidReuseTokens?: boolean;
minPasswordLength?: number;
webSearch?: {
searchProvider?: SearchProviders;
scraperType?: ScraperTypes;

View File

@@ -57,27 +57,6 @@ 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,
];
@@ -144,9 +123,7 @@ 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|m4a|x-m4a|flac|x-flac|webm|aac|wma|opus)$/;
export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv)$/;
/^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|x-m4a|flac|x-flac|webm)$/;
export const defaultOCRMimeTypes = [
imageMimeTypes,
@@ -165,9 +142,8 @@ export const supportedMimeTypes = [
excelMimeTypes,
applicationMimeTypes,
imageMimeTypes,
videoMimeTypes,
audioMimeTypes,
/** Supported by LC Code Interpreter API */
/** Supported by LC Code Interpreter PAI */
/^image\/(svg|svg\+xml)$/,
];
@@ -210,10 +186,6 @@ 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,
@@ -227,14 +199,6 @@ 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,

View File

@@ -3,6 +3,8 @@ export * from './azure';
export * from './bedrock';
export * from './config';
export * from './file-config';
/* messages */
export * from './messages';
/* artifacts */
export * from './artifacts';
/* schema helpers */

View File

@@ -0,0 +1,50 @@
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;
}

View File

@@ -31,20 +31,6 @@ 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,

View File

@@ -27,7 +27,6 @@ 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',