diff --git a/.env.example b/.env.example index ac0e851bcb..92f212f5b2 100644 --- a/.env.example +++ b/.env.example @@ -331,10 +331,6 @@ FLUX_API_BASE_URL=https://api.us1.bfl.ai GOOGLE_SEARCH_API_KEY= GOOGLE_CSE_ID= -# YOUTUBE -#----------------- -YOUTUBE_API_KEY= - # Stable Diffusion #----------------- SD_WEBUI_URL=http://host.docker.internal:7860 diff --git a/Dockerfile b/Dockerfile index 5872440a33..54f84101c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.2-rc2 +# v0.8.2-rc3 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index ca66459a44..2e96f53b46 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.2-rc2 +# v0.8.2-rc3 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index 1a7c4ff47f..bb58e81221 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -5,7 +5,6 @@ const DALLE3 = require('./structured/DALLE3'); const FluxAPI = require('./structured/FluxAPI'); const OpenWeather = require('./structured/OpenWeather'); const StructuredWolfram = require('./structured/Wolfram'); -const createYouTubeTools = require('./structured/YouTube'); const StructuredACS = require('./structured/AzureAISearch'); const StructuredSD = require('./structured/StableDiffusion'); const GoogleSearchAPI = require('./structured/GoogleSearch'); @@ -25,7 +24,6 @@ module.exports = { GoogleSearchAPI, TraversaalSearch, StructuredWolfram, - createYouTubeTools, TavilySearchResults, createOpenAIImageTools, createGeminiImageTool, diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index fc037caa4b..9262113501 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -30,20 +30,6 @@ } ] }, - { - "name": "YouTube", - "pluginKey": "youtube", - "toolkit": true, - "description": "Get YouTube video information, retrieve comments, analyze transcripts and search for videos.", - "icon": "https://www.youtube.com/s/desktop/7449ebf7/img/favicon_144x144.png", - "authConfig": [ - { - "authField": "YOUTUBE_API_KEY", - "label": "YouTube API Key", - "description": "Your YouTube Data API v3 key." - } - ] - }, { "name": "OpenAI Image Tools", "pluginKey": "image_gen_oai", diff --git a/api/app/clients/tools/structured/YouTube.js b/api/app/clients/tools/structured/YouTube.js deleted file mode 100644 index 8d1c7b9ff9..0000000000 --- a/api/app/clients/tools/structured/YouTube.js +++ /dev/null @@ -1,137 +0,0 @@ -const { ytToolkit } = require('@librechat/api'); -const { tool } = require('@langchain/core/tools'); -const { youtube } = require('@googleapis/youtube'); -const { logger } = require('@librechat/data-schemas'); -const { YoutubeTranscript } = require('youtube-transcript'); -const { getApiKey } = require('./credentials'); - -function extractVideoId(url) { - const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/; - if (rawIdRegex.test(url)) { - return url; - } - - const regex = new RegExp( - '(?:youtu\\.be/|youtube(?:\\.com)?/(?:' + - '(?:watch\\?v=)|(?:embed/)|(?:shorts/)|(?:live/)|(?:v/)|(?:/))?)' + - '([a-zA-Z0-9_-]{11})(?:\\S+)?$', - ); - const match = url.match(regex); - return match ? match[1] : null; -} - -function parseTranscript(transcriptResponse) { - if (!Array.isArray(transcriptResponse)) { - return ''; - } - - return transcriptResponse - .map((entry) => entry.text.trim()) - .filter((text) => text) - .join(' ') - .replaceAll('&#39;', "'"); -} - -function createYouTubeTools(fields = {}) { - const envVar = 'YOUTUBE_API_KEY'; - const override = fields.override ?? false; - const apiKey = fields.apiKey ?? fields[envVar] ?? getApiKey(envVar, override); - - const youtubeClient = youtube({ - version: 'v3', - auth: apiKey, - }); - - const searchTool = tool(async ({ query, maxResults = 5 }) => { - const response = await youtubeClient.search.list({ - part: 'snippet', - q: query, - type: 'video', - maxResults: maxResults || 5, - }); - const result = response.data.items.map((item) => ({ - title: item.snippet.title, - description: item.snippet.description, - url: `https://www.youtube.com/watch?v=${item.id.videoId}`, - })); - return JSON.stringify(result, null, 2); - }, ytToolkit.youtube_search); - - const infoTool = tool(async ({ url }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } - - const response = await youtubeClient.videos.list({ - part: 'snippet,statistics', - id: videoId, - }); - - if (!response.data.items?.length) { - throw new Error('Video not found'); - } - const video = response.data.items[0]; - - const result = { - title: video.snippet.title, - description: video.snippet.description, - views: video.statistics.viewCount, - likes: video.statistics.likeCount, - comments: video.statistics.commentCount, - }; - return JSON.stringify(result, null, 2); - }, ytToolkit.youtube_info); - - const commentsTool = tool(async ({ url, maxResults = 10 }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } - - const response = await youtubeClient.commentThreads.list({ - part: 'snippet', - videoId, - maxResults: maxResults || 10, - }); - - const result = response.data.items.map((item) => ({ - author: item.snippet.topLevelComment.snippet.authorDisplayName, - text: item.snippet.topLevelComment.snippet.textDisplay, - likes: item.snippet.topLevelComment.snippet.likeCount, - })); - return JSON.stringify(result, null, 2); - }, ytToolkit.youtube_comments); - - const transcriptTool = tool(async ({ url }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } - - try { - try { - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' }); - return parseTranscript(transcript); - } catch (e) { - logger.error(e); - } - - try { - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' }); - return parseTranscript(transcript); - } catch (e) { - logger.error(e); - } - - const transcript = await YoutubeTranscript.fetchTranscript(videoId); - return parseTranscript(transcript); - } catch (error) { - throw new Error(`Failed to fetch transcript: ${error.message}`); - } - }, ytToolkit.youtube_transcript); - - return [searchTool, infoTool, commentsTool, transcriptTool]; -} - -module.exports = createYouTubeTools; diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index e39bebd36a..da4c687b4d 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -34,7 +34,6 @@ const { StructuredACS, TraversaalSearch, StructuredWolfram, - createYouTubeTools, TavilySearchResults, createGeminiImageTool, createOpenAIImageTools, @@ -185,11 +184,6 @@ const loadTools = async ({ }; const customConstructors = { - youtube: async (_toolContextMap) => { - const authFields = getAuthFields('youtube'); - const authValues = await loadAuthValues({ userId: user, authFields }); - return createYouTubeTools(authValues); - }, image_gen_oai: async (toolContextMap) => { const authFields = getAuthFields('image_gen_oai'); const authValues = await loadAuthValues({ userId: user, authFields }); diff --git a/api/db/indexSync.js b/api/db/indexSync.js index b39f018b3a..8e8e999d92 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -13,6 +13,11 @@ const searchEnabled = isEnabled(process.env.SEARCH); const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC); let currentTimeout = null; +const defaultSyncThreshold = 1000; +const syncThreshold = process.env.MEILI_SYNC_THRESHOLD + ? parseInt(process.env.MEILI_SYNC_THRESHOLD, 10) + : defaultSyncThreshold; + class MeiliSearchClient { static instance = null; @@ -221,25 +226,25 @@ async function performSync(flowManager, flowId, flowType) { } // Check if we need to sync messages + logger.info('[indexSync] Requesting message sync progress...'); const messageProgress = await Message.getSyncProgress(); if (!messageProgress.isComplete || settingsUpdated) { logger.info( `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, ); - // Check if we should do a full sync or incremental - const messageCount = await Message.countDocuments(); + const messageCount = messageProgress.totalDocuments; const messagesIndexed = messageProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); + const unindexedMessages = messageCount - messagesIndexed; - if (messageCount - messagesIndexed > syncThreshold) { - logger.info('[indexSync] Starting full message sync due to large difference'); - await Message.syncWithMeili(); - messagesSync = true; - } else if (messageCount !== messagesIndexed) { - logger.warn('[indexSync] Messages out of sync, performing incremental sync'); + if (settingsUpdated || unindexedMessages > syncThreshold) { + logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`); await Message.syncWithMeili(); messagesSync = true; + } else if (unindexedMessages > 0) { + logger.info( + `[indexSync] ${unindexedMessages} messages unindexed (below threshold: ${syncThreshold}, skipping)`, + ); } } else { logger.info( @@ -254,18 +259,18 @@ async function performSync(flowManager, flowId, flowType) { `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, ); - const convoCount = await Conversation.countDocuments(); + const convoCount = convoProgress.totalDocuments; const convosIndexed = convoProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); - if (convoCount - convosIndexed > syncThreshold) { - logger.info('[indexSync] Starting full conversation sync due to large difference'); - await Conversation.syncWithMeili(); - convosSync = true; - } else if (convoCount !== convosIndexed) { - logger.warn('[indexSync] Convos out of sync, performing incremental sync'); + const unindexedConvos = convoCount - convosIndexed; + if (settingsUpdated || unindexedConvos > syncThreshold) { + logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`); await Conversation.syncWithMeili(); convosSync = true; + } else if (unindexedConvos > 0) { + logger.info( + `[indexSync] ${unindexedConvos} convos unindexed (below threshold: ${syncThreshold}, skipping)`, + ); } } else { logger.info( diff --git a/api/db/indexSync.spec.js b/api/db/indexSync.spec.js new file mode 100644 index 0000000000..c2e5901d6a --- /dev/null +++ b/api/db/indexSync.spec.js @@ -0,0 +1,465 @@ +/** + * Unit tests for performSync() function in indexSync.js + * + * Tests use real mongoose with mocked model methods, only mocking external calls. + */ + +const mongoose = require('mongoose'); + +// Mock only external dependencies (not internal classes/models) +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +const mockMeiliHealth = jest.fn(); +const mockMeiliIndex = jest.fn(); +const mockBatchResetMeiliFlags = jest.fn(); +const mockIsEnabled = jest.fn(); +const mockGetLogStores = jest.fn(); + +// Create mock models that will be reused +const createMockModel = (collectionName) => ({ + collection: { name: collectionName }, + getSyncProgress: jest.fn(), + syncWithMeili: jest.fn(), + countDocuments: jest.fn(), +}); + +const originalMessageModel = mongoose.models.Message; +const originalConversationModel = mongoose.models.Conversation; + +// Mock external modules +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +jest.mock('meilisearch', () => ({ + MeiliSearch: jest.fn(() => ({ + health: mockMeiliHealth, + index: mockMeiliIndex, + })), +})); + +jest.mock('./utils', () => ({ + batchResetMeiliFlags: mockBatchResetMeiliFlags, +})); + +jest.mock('@librechat/api', () => ({ + isEnabled: mockIsEnabled, + FlowStateManager: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + getLogStores: mockGetLogStores, +})); + +// Set environment before module load +process.env.MEILI_HOST = 'http://localhost:7700'; +process.env.MEILI_MASTER_KEY = 'test-key'; +process.env.SEARCH = 'true'; +process.env.MEILI_SYNC_THRESHOLD = '1000'; // Set threshold before module loads + +describe('performSync() - syncThreshold logic', () => { + const ORIGINAL_ENV = process.env; + let Message; + let Conversation; + + beforeAll(() => { + Message = createMockModel('messages'); + Conversation = createMockModel('conversations'); + + mongoose.models.Message = Message; + mongoose.models.Conversation = Conversation; + }); + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + // Reset modules to ensure fresh load of indexSync.js and its top-level consts (like syncThreshold) + jest.resetModules(); + + // Set up environment + process.env = { ...ORIGINAL_ENV }; + process.env.MEILI_HOST = 'http://localhost:7700'; + process.env.MEILI_MASTER_KEY = 'test-key'; + process.env.SEARCH = 'true'; + delete process.env.MEILI_NO_SYNC; + + // Re-ensure models are available in mongoose after resetModules + // We must require mongoose again to get the fresh instance that indexSync will use + const mongoose = require('mongoose'); + mongoose.models.Message = Message; + mongoose.models.Conversation = Conversation; + + // Mock isEnabled + mockIsEnabled.mockImplementation((val) => val === 'true' || val === true); + + // Mock MeiliSearch client responses + mockMeiliHealth.mockResolvedValue({ status: 'available' }); + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }), + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + mockBatchResetMeiliFlags.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + afterAll(() => { + mongoose.models.Message = originalMessageModel; + mongoose.models.Conversation = originalConversationModel; + }); + + test('triggers sync when unindexed messages exceed syncThreshold', async () => { + // Arrange: Set threshold before module load + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Arrange: 1050 unindexed messages > 1000 threshold + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 1150, // 1050 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync triggered because 1050 > 1000 + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/1150 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (1050 unindexed)', + ); + + // Assert: Conversation sync NOT triggered (already complete) + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + }); + + test('skips sync when unindexed messages are below syncThreshold', async () => { + // Arrange: 50 unindexed messages < 1000 threshold + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 150, // 50 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync NOT triggered because 50 < 1000 + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/150 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] 50 messages unindexed (below threshold: 1000, skipping)', + ); + + // Assert: Conversation sync NOT triggered (already complete) + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + }); + + test('respects syncThreshold at boundary (exactly at threshold)', async () => { + // Arrange: 1000 unindexed messages = 1000 threshold (NOT greater than) + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 1100, // 1000 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 0, + totalDocuments: 0, + isComplete: true, + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync NOT triggered because 1000 is NOT > 1000 + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/1100 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] 1000 messages unindexed (below threshold: 1000, skipping)', + ); + }); + + test('triggers sync when unindexed is threshold + 1', async () => { + // Arrange: 1001 unindexed messages > 1000 threshold + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 1101, // 1001 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 0, + totalDocuments: 0, + isComplete: true, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync triggered because 1001 > 1000 + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/1101 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (1001 unindexed)', + ); + }); + + test('uses totalDocuments from convoProgress for conversation sync decisions', async () => { + // Arrange: Messages complete, conversations need sync + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 1100, // 1050 unindexed > 1000 threshold + isComplete: false, + }); + + Conversation.syncWithMeili.mockResolvedValue(undefined); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls (the optimization) + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: Only conversation sync triggered + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Conversations need syncing: 50/1100 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting convos sync (1050 unindexed)', + ); + }); + + test('skips sync when collections are fully synced', async () => { + // Arrange: Everything already synced + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: No sync triggered + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + + // Assert: Correct logs + expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Messages are fully synced: 100/100'); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Conversations are fully synced: 50/50', + ); + }); + + test('triggers message sync when settingsUpdated even if below syncThreshold', async () => { + // Arrange: Only 50 unindexed messages (< 1000 threshold), but settings were updated + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 150, // 50 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + + // Mock settings update scenario + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: Flags were reset due to settings update + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // Assert: Message sync triggered despite being below threshold (50 < 1000) + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (50 unindexed)', + ); + }); + + test('triggers conversation sync when settingsUpdated even if below syncThreshold', async () => { + // Arrange: Messages complete, conversations have 50 unindexed (< 1000 threshold), but settings were updated + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 100, // 50 unindexed + isComplete: false, + }); + + Conversation.syncWithMeili.mockResolvedValue(undefined); + + // Mock settings update scenario + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: Flags were reset due to settings update + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // Assert: Conversation sync triggered despite being below threshold (50 < 1000) + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)'); + }); + + test('triggers both message and conversation sync when settingsUpdated even if both below syncThreshold', async () => { + // Arrange: Set threshold before module load + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Arrange: Both have documents below threshold (50 each), but settings were updated + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 150, // 50 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 100, // 50 unindexed + isComplete: false, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + Conversation.syncWithMeili.mockResolvedValue(undefined); + + // Mock settings update scenario + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: Flags were reset due to settings update + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // Assert: Both syncs triggered despite both being below threshold + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (50 unindexed)', + ); + expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)'); + }); +}); diff --git a/api/package.json b/api/package.json index c2f0dd9801..7c549f19c3 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -43,10 +43,9 @@ "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.27.0", "@google/genai": "^1.19.0", - "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.776", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -109,10 +108,9 @@ "tiktoken": "^1.0.15", "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", - "undici": "^7.10.0", + "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", - "youtube-transcript": "^1.2.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 19a185279e..9f0a4a2279 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -5,7 +5,9 @@ const { logger } = require('@librechat/data-schemas'); const { agentCreateSchema, agentUpdateSchema, + refreshListAvatars, mergeAgentOcrConversion, + MAX_AVATAR_REFRESH_AGENTS, convertOcrToContextInPlace, } = require('@librechat/api'); const { @@ -56,46 +58,6 @@ const systemTools = { const MAX_SEARCH_LEN = 100; const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -/** - * Opportunistically refreshes S3-backed avatars for agent list responses. - * Only list responses are refreshed because they're the highest-traffic surface and - * the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes - * via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most. - * @param {Array} agents - Agents being enriched with S3-backed avatars - * @param {string} userId - User identifier used for the cache refresh key - */ -const refreshListAvatars = async (agents, userId) => { - if (!agents?.length) { - return; - } - - const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); - const refreshKey = `${userId}:agents_list`; - const alreadyChecked = await cache.get(refreshKey); - if (alreadyChecked) { - return; - } - - await Promise.all( - agents.map(async (agent) => { - if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) { - return; - } - - try { - const newPath = await refreshS3Url(agent.avatar); - if (newPath && newPath !== agent.avatar.filepath) { - agent.avatar = { ...agent.avatar, filepath: newPath }; - } - } catch (err) { - logger.debug('[/Agents] Avatar refresh error for list item', err); - } - }), - ); - - await cache.set(refreshKey, true, Time.THIRTY_MINUTES); -}; - /** * Creates an Agent. * @route POST /Agents @@ -544,6 +506,35 @@ const getListAgentsHandler = async (req, res) => { requiredPermissions: PermissionBits.VIEW, }); + /** + * Refresh all S3 avatars for this user's accessible agent set (not only the current page) + * This addresses page-size limits preventing refresh of agents beyond the first page + */ + const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); + const refreshKey = `${userId}:agents_avatar_refresh`; + const alreadyChecked = await cache.get(refreshKey); + if (alreadyChecked) { + logger.debug('[/Agents] S3 avatar refresh already checked, skipping'); + } else { + try { + const fullList = await getListAgentsByAccess({ + accessibleIds, + otherParams: {}, + limit: MAX_AVATAR_REFRESH_AGENTS, + after: null, + }); + await refreshListAvatars({ + agents: fullList?.data ?? [], + userId, + refreshS3Url, + updateAgent, + }); + await cache.set(refreshKey, true, Time.THIRTY_MINUTES); + } catch (err) { + logger.error('[/Agents] Error refreshing avatars for full list: %o', err); + } + } + // Use the new ACL-aware function const data = await getListAgentsByAccess({ accessibleIds, @@ -571,15 +562,9 @@ const getListAgentsHandler = async (req, res) => { return agent; }); - // Opportunistically refresh S3 avatar URLs for list results with caching - try { - await refreshListAvatars(data.data, req.user.id); - } catch (err) { - logger.debug('[/Agents] Skipping avatar refresh for list', err); - } return res.json(data); } catch (error) { - logger.error('[/Agents] Error listing Agents', error); + logger.error('[/Agents] Error listing Agents: %o', error); res.status(500).json({ error: error.message }); } }; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 1bcf6c2fa3..8b2a57d903 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1,8 +1,9 @@ const mongoose = require('mongoose'); -const { v4: uuidv4 } = require('uuid'); const { nanoid } = require('nanoid'); -const { MongoMemoryServer } = require('mongodb-memory-server'); +const { v4: uuidv4 } = require('uuid'); const { agentSchema } = require('@librechat/data-schemas'); +const { FileSources } = require('librechat-data-provider'); +const { MongoMemoryServer } = require('mongodb-memory-server'); // Only mock the dependencies that are not database-related jest.mock('~/server/services/Config', () => ({ @@ -54,6 +55,15 @@ jest.mock('~/models', () => ({ getCategoriesWithCounts: jest.fn(), })); +// Mock cache for S3 avatar refresh tests +const mockCache = { + get: jest.fn(), + set: jest.fn(), +}; +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => mockCache), +})); + const { createAgent: createAgentHandler, updateAgent: updateAgentHandler, @@ -65,6 +75,8 @@ const { findPubliclyAccessibleResources, } = require('~/server/services/PermissionService'); +const { refreshS3Url } = require('~/server/services/Files/S3/crud'); + /** * @type {import('mongoose').Model} */ @@ -1207,4 +1219,349 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data[0].is_promoted).toBe(true); }); }); + + describe('S3 Avatar Refresh', () => { + let userA, userB; + let agentWithS3Avatar, agentWithLocalAvatar, agentOwnedByOther; + + beforeEach(async () => { + await Agent.deleteMany({}); + jest.clearAllMocks(); + + // Reset cache mock + mockCache.get.mockResolvedValue(false); + mockCache.set.mockResolvedValue(undefined); + + userA = new mongoose.Types.ObjectId(); + userB = new mongoose.Types.ObjectId(); + + // Create agent with S3 avatar owned by userA + agentWithS3Avatar = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent with S3 Avatar', + description: 'Has S3 avatar', + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: FileSources.s3, + filepath: 'old-s3-path.jpg', + }, + versions: [ + { + name: 'Agent with S3 Avatar', + description: 'Has S3 avatar', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + // Create agent with local avatar owned by userA + agentWithLocalAvatar = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent with Local Avatar', + description: 'Has local avatar', + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: 'local', + filepath: 'local-path.jpg', + }, + versions: [ + { + name: 'Agent with Local Avatar', + description: 'Has local avatar', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + // Create agent with S3 avatar owned by userB + agentOwnedByOther = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent Owned By Other', + description: 'Owned by userB', + provider: 'openai', + model: 'gpt-4', + author: userB, + avatar: { + source: FileSources.s3, + filepath: 'other-s3-path.jpg', + }, + versions: [ + { + name: 'Agent Owned By Other', + description: 'Owned by userB', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + }); + + test('should skip avatar refresh if cache hit', async () => { + mockCache.get.mockResolvedValue(true); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should not call refreshS3Url when cache hit + expect(refreshS3Url).not.toHaveBeenCalled(); + }); + + test('should refresh and persist S3 avatars on cache miss', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-s3-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Verify S3 URL was refreshed + expect(refreshS3Url).toHaveBeenCalled(); + + // Verify cache was set + expect(mockCache.set).toHaveBeenCalled(); + + // Verify response was returned + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should refresh avatars for all accessible agents (VIEW permission)', async () => { + mockCache.get.mockResolvedValue(false); + // User A has access to both their own agent and userB's agent + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id, agentOwnedByOther._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should be called for both agents - any user with VIEW access can refresh + expect(refreshS3Url).toHaveBeenCalledTimes(2); + }); + + test('should skip non-S3 avatars', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithLocalAvatar._id, agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should only be called for S3 avatar agent + expect(refreshS3Url).toHaveBeenCalledTimes(1); + }); + + test('should not update if S3 URL unchanged', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + // Return the same path - no update needed + refreshS3Url.mockResolvedValue('old-s3-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Verify refreshS3Url was called + expect(refreshS3Url).toHaveBeenCalled(); + + // Response should still be returned + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should handle S3 refresh errors gracefully', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockRejectedValue(new Error('S3 error')); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + // Should not throw - handles error gracefully + await expect(getListAgentsHandler(mockReq, mockRes)).resolves.not.toThrow(); + + // Response should still be returned + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should process agents in batches', async () => { + mockCache.get.mockResolvedValue(false); + + // Create 25 agents (should be processed in batches of 20) + const manyAgents = []; + for (let i = 0; i < 25; i++) { + const agent = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: `Agent ${i}`, + description: `Agent ${i} description`, + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: FileSources.s3, + filepath: `path${i}.jpg`, + }, + versions: [ + { + name: `Agent ${i}`, + description: `Agent ${i} description`, + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + manyAgents.push(agent); + } + + const allAgentIds = manyAgents.map((a) => a._id); + findAccessibleResources.mockResolvedValue(allAgentIds); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockImplementation((avatar) => + Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')), + ); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // All 25 should be processed + expect(refreshS3Url).toHaveBeenCalledTimes(25); + }); + + test('should skip agents without id or author', async () => { + mockCache.get.mockResolvedValue(false); + + // Create agent without proper id field (edge case) + const agentWithoutId = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent without ID field', + description: 'Testing', + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: FileSources.s3, + filepath: 'test-path.jpg', + }, + versions: [ + { + name: 'Agent without ID field', + description: 'Testing', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + findAccessibleResources.mockResolvedValue([agentWithoutId._id, agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should still complete without errors + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should use MAX_AVATAR_REFRESH_AGENTS limit for full list query', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Verify that the handler completed successfully + expect(mockRes.json).toHaveBeenCalled(); + }); + }); }); diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index b8be413f4f..8072612a69 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -2,11 +2,11 @@ const path = require('path'); const fs = require('fs').promises; const express = require('express'); const { logger } = require('@librechat/data-schemas'); -const { isAgentsEndpoint } = require('librechat-data-provider'); +const { isAssistantsEndpoint } = require('librechat-data-provider'); const { - filterFile, - processImageFile, processAgentFileUpload, + processImageFile, + filterFile, } = require('~/server/services/Files/process'); const router = express.Router(); @@ -21,7 +21,7 @@ router.post('/', async (req, res) => { metadata.temp_file_id = metadata.file_id; metadata.file_id = req.file_id; - if (isAgentsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { + if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { return await processAgentFileUpload({ req, res, metadata }); } diff --git a/api/server/services/start/tools.js b/api/server/services/start/tools.js index 4fd35755bc..dd2d69b274 100644 --- a/api/server/services/start/tools.js +++ b/api/server/services/start/tools.js @@ -5,7 +5,7 @@ const { Calculator } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); const { zodToJsonSchema } = require('zod-to-json-schema'); const { Tools, ImageVisionTool } = require('librechat-data-provider'); -const { getToolkitKey, oaiToolkit, ytToolkit, geminiToolkit } = require('@librechat/api'); +const { getToolkitKey, oaiToolkit, geminiToolkit } = require('@librechat/api'); const { toolkits } = require('~/app/clients/tools/manifest'); /** @@ -83,7 +83,6 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) const basicToolInstances = [ new Calculator(), ...Object.values(oaiToolkit), - ...Object.values(ytToolkit), ...Object.values(geminiToolkit), ]; for (const toolInstance of basicToolInstances) { diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 0a89313ba9..bad64eee77 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,8 +1,8 @@ const passport = require('passport'); const session = require('express-session'); const { isEnabled } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); +const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas'); const { openIdJwtLogin, facebookLogin, @@ -22,11 +22,17 @@ const { getLogStores } = require('~/cache'); */ async function configureOpenId(app) { logger.info('Configuring OpenID Connect...'); + const isProduction = process.env.NODE_ENV === 'production'; + const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; const sessionOptions = { secret: process.env.OPENID_SESSION_SECRET, resave: false, saveUninitialized: false, store: getLogStores(CacheKeys.OPENID_SESSION), + cookie: { + maxAge: sessionExpiry, + secure: isProduction, + }, }; app.use(session(sessionOptions)); app.use(passport.session()); @@ -82,11 +88,17 @@ const configureSocialLogins = async (app) => { process.env.SAML_SESSION_SECRET ) { logger.info('Configuring SAML Connect...'); + const isProduction = process.env.NODE_ENV === 'production'; + const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; const sessionOptions = { secret: process.env.SAML_SESSION_SECRET, resave: false, saveUninitialized: false, store: getLogStores(CacheKeys.SAML_SESSION), + cookie: { + maxAge: sessionExpiry, + secure: isProduction, + }, }; app.use(session(sessionOptions)); app.use(passport.session()); diff --git a/bun.lock b/bun.lock index 783bfc762e..daebd2482f 100644 --- a/bun.lock +++ b/bun.lock @@ -254,7 +254,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "devDependencies": { "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", @@ -321,7 +321,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -409,7 +409,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "dependencies": { "axios": "^1.12.1", "dayjs": "^1.11.13", @@ -447,7 +447,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 9a9f9f5451..1b7c664ae5 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.2-rc2 */ +/** v0.8.2-rc3 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index 81b2fdf255..d993695050 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "type": "module", "scripts": { diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 14c6b424b4..2ad167a80c 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -294,6 +294,7 @@ function ConvoOptions({ portal={true} menuId={menuId} focusLoop={true} + className="z-[125]" unmountOnHide={true} isOpen={isPopoverActive} setIsOpen={setIsPopoverActive} @@ -321,7 +322,6 @@ function ConvoOptions({ } items={dropdownItems} - className="z-30" /> {showShareDialog && (
- {Object.entries(fields).map(([key, config]) => { + {Object.entries(fields).map(([key, config], index) => { const hasValue = authValuesData?.authValueFlags?.[key] || false; return ( @@ -161,6 +167,8 @@ export default function CustomUserVarsSection({ hasValue={hasValue} control={control} errors={errors} + // eslint-disable-next-line jsx-a11y/no-autofocus -- See AuthField autoFocus comment for more details + autoFocus={index === 0} /> ); })} diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index d166aaaaee..f35c8ea0fb 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -40,7 +40,7 @@ function AccountSettings() { = ({ tags, setTags }: BookmarkNavProps) unmountOnHide={true} setIsOpen={setIsMenuOpen} keyPrefix="bookmark-nav-" + className="z-[125]" trigger={ {errors[authField] && ( - {errors?.[authField]?.message ?? ''} + {String(errors?.[authField]?.message ?? '')} )} diff --git a/client/src/components/SidePanel/SidePanelGroup.tsx b/client/src/components/SidePanel/SidePanelGroup.tsx index 14473127b5..171947cd6b 100644 --- a/client/src/components/SidePanel/SidePanelGroup.tsx +++ b/client/src/components/SidePanel/SidePanelGroup.tsx @@ -6,7 +6,7 @@ import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/c import type { ImperativePanelHandle } from 'react-resizable-panels'; import { useGetStartupConfig } from '~/data-provider'; import ArtifactsPanel from './ArtifactsPanel'; -import { normalizeLayout } from '~/utils'; +import { normalizeLayout, cn } from '~/utils'; import SidePanel from './SidePanel'; import store from '~/store'; @@ -149,9 +149,9 @@ const SidePanelGroup = memo( )} {!hideSidePanel && interfaceConfig.sidePanel === true && (