diff --git a/.env.example b/.env.example index 57266553962..430fb8179e7 100644 --- a/.env.example +++ b/.env.example @@ -421,6 +421,11 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= # IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool # IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments # IMAGE_GEN_OAI_MODEL=gpt-image-1 # OpenAI image model (e.g., gpt-image-1, gpt-image-1.5) +# Defaults for image_gen_oai / image_edit_oai when the model passes size or quality as auto: +# IMAGE_GEN_OAI_OUTPUT_FORMAT=jpeg # png | jpeg | webp (Azure gpt-image-1 may reject webp) +# IMAGE_GEN_OAI_OUTPUT_COMPRESSION=85 # 0-100, used for jpeg/webp +# IMAGE_GEN_OAI_DEFAULT_SIZE=auto # auto | 1024x1024 | 1536x1024 | 1024x1536 | 256x256 | 512x512 +# IMAGE_GEN_OAI_DEFAULT_QUALITY=auto # auto | high | medium | low # IMAGE_GEN_OAI_DESCRIPTION= # IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present # IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present diff --git a/.github/workflows/acr-build-and-push-libre-chat.yml b/.github/workflows/acr-build-and-push-libre-chat.yml new file mode 100644 index 00000000000..cd53fc693df --- /dev/null +++ b/.github/workflows/acr-build-and-push-libre-chat.yml @@ -0,0 +1,47 @@ +name: Build and Push MIA - LibreChat Container to ACR + +on: + push: + branches: ["release"] + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + build-and-push: + runs-on: ubuntu-latest + + env: + IMAGE_NAME: mia-libre-chat + TAG: ${{ github.sha }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Azure login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: ACR sanity + run: | + az account show -o table + az acr show -n "${{ vars.ACR_NAME }}" -o table + + - name: Login to ACR + run: az acr login --name "${{ vars.ACR_NAME }}" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.multi + push: true + tags: | + ${{ vars.ACR_REGISTRY_SERVER }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} + ${{ vars.ACR_REGISTRY_SERVER }}/${{ env.IMAGE_NAME }}:latest diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index 5bcd87a0cee..365039485bf 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -9,6 +9,7 @@ const { extractBaseURL, getProxyDispatcher, getEnvProxyDispatcher, + enforceImageSizeLimit, createMinimalRetentionRequest, } = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); @@ -194,12 +195,18 @@ Error Message: ${error.message}`); } const imageResponse = await fetch(theImageUrl, fetchOptions); const arrayBuffer = await imageResponse.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString('base64'); + const buffer = Buffer.from(arrayBuffer); + const contentType = imageResponse.headers.get('content-type'); + const mimeType = contentType?.split(';')[0]?.trim() || 'image/png'; + const { buffer: finalBuffer, mimeType: finalMimeType } = await enforceImageSizeLimit( + buffer, + mimeType, + ); const content = [ { type: ContentTypes.IMAGE_URL, image_url: { - url: `data:image/png;base64,${base64}`, + url: `data:${finalMimeType};base64,${finalBuffer.toString('base64')}`, }, }, ]; diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index fd0464c34e9..66782c9f44b 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -7,6 +7,7 @@ const { applyAxiosProxyConfig, createMinimalRetentionRequest, getHttpsProxyAgent, + enforceImageSizeLimit, } = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); @@ -313,12 +314,18 @@ class FluxAPI extends Tool { } const imageResponse = await fetch(imageUrl, fetchOptions); const arrayBuffer = await imageResponse.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString('base64'); + const buffer = Buffer.from(arrayBuffer); + const contentType = imageResponse.headers.get('content-type'); + const mimeType = contentType?.split(';')[0]?.trim() || 'image/png'; + const { buffer: finalBuffer, mimeType: finalMimeType } = await enforceImageSizeLimit( + buffer, + mimeType, + ); const content = [ { type: ContentTypes.IMAGE_URL, image_url: { - url: `data:image/png;base64,${base64}`, + url: `data:${finalMimeType};base64,${finalBuffer.toString('base64')}`, }, }, ]; @@ -546,12 +553,18 @@ class FluxAPI extends Tool { } const imageResponse = await fetch(imageUrl, fetchOptions); const arrayBuffer = await imageResponse.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString('base64'); + const buffer = Buffer.from(arrayBuffer); + const contentType = imageResponse.headers.get('content-type'); + const mimeType = contentType?.split(';')[0]?.trim() || 'image/png'; + const { buffer: finalBuffer, mimeType: finalMimeType } = await enforceImageSizeLimit( + buffer, + mimeType, + ); const content = [ { type: ContentTypes.IMAGE_URL, image_url: { - url: `data:image/png;base64,${base64}`, + url: `data:${finalMimeType};base64,${finalBuffer.toString('base64')}`, }, }, ]; diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index 04265bbba99..72d5371a1eb 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -10,6 +10,7 @@ const { loadServiceKey, getBalanceConfig, getEnvProxyDispatcher, + enforceImageSizeLimit, getTransactionsConfig, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -318,7 +319,7 @@ function createGeminiImageTool(fields = {}) { const { req, imageFiles = [], userId, fileStrategy, GEMINI_API_KEY, GOOGLE_KEY } = fields; - const imageOutputType = fields.imageOutputType || EImageOutputType.PNG; + const imageOutputType = fields.imageOutputType || EImageOutputType.WEBP; const geminiImageGenTool = tool( async ({ prompt, image_ids, aspectRatio, imageSize }, runnableConfig) => { @@ -419,14 +420,17 @@ function createGeminiImageTool(fields = {}) { } const rawBuffer = Buffer.from(rawImageData, 'base64'); - const { buffer: convertedBuffer, format: outputFormat } = await convertImageFormat( + const { buffer: convertedBuffer, format } = await convertImageFormat( rawBuffer, imageOutputType, ); - const imageData = convertedBuffer.toString('base64'); - const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`; - - const dataUrl = `data:${mimeType};base64,${imageData}`; + const initialMimeType = + format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'; + const { buffer: finalBuffer, mimeType } = await enforceImageSizeLimit( + convertedBuffer, + initialMimeType, + ); + const dataUrl = `data:${mimeType};base64,${finalBuffer.toString('base64')}`; const file_ids = [v4()]; const content = [ { diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index d92d17b77e6..7109043c820 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -11,6 +11,8 @@ const { extractBaseURL, getProxyDispatcher, applyAxiosProxyConfig, + enforceImageSizeLimit, + resolveImageGenOaiDefaults, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getFiles } = require('~/models'); @@ -56,7 +58,6 @@ function createAbortHandler() { * @param {string} fields.IMAGE_GEN_OAI_API_KEY - The OpenAI API key * @param {boolean} [fields.override] - Whether to override the API key check, necessary for app initialization * @param {MongoFile[]} [fields.imageFiles] - The images to be used for editing - * @param {string} [fields.imageOutputType] - The image output type configuration * @param {string} [fields.fileStrategy] - The file storage strategy * @returns {Array>} - Array of image tools */ @@ -68,8 +69,8 @@ function createOpenAIImageTools(fields = {}) { throw new Error('This tool is only available for agents.'); } const { req } = fields; - const imageOutputType = fields.imageOutputType || EImageOutputType.PNG; const appFileStrategy = fields.fileStrategy; + const budgetDefaults = resolveImageGenOaiDefaults(); const getApiKey = () => { const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? ''; @@ -117,7 +118,7 @@ function createOpenAIImageTools(fields = {}) { prompt, background = 'auto', n = 1, - output_compression = 100, + output_compression, quality = 'auto', size = 'auto', }, @@ -136,17 +137,15 @@ function createOpenAIImageTools(fields = {}) { /** @type {OpenAI} */ const openai = new OpenAI(clientConfig); - let output_format = imageOutputType; - if ( - background === 'transparent' && - output_format !== EImageOutputType.PNG && - output_format !== EImageOutputType.WEBP - ) { - logger.warn( - '[ImageGenOAI] Transparent background requires PNG or WebP format, defaulting to PNG', - ); + let output_format = budgetDefaults.outputFormat; + if (background === 'transparent' && output_format !== EImageOutputType.PNG) { + logger.warn('[ImageGenOAI] Transparent background requires PNG format; using PNG'); output_format = EImageOutputType.PNG; } + const effectiveCompression = + typeof output_compression === 'number' ? output_compression : budgetDefaults.outputCompression; + const effectiveQuality = quality === 'auto' ? budgetDefaults.defaultQuality : quality; + const effectiveSize = size === 'auto' ? budgetDefaults.defaultSize : size; let resp; /** @type {AbortSignal} */ @@ -170,10 +169,10 @@ function createOpenAIImageTools(fields = {}) { output_format, output_compression: output_format === EImageOutputType.WEBP || output_format === EImageOutputType.JPEG - ? output_compression + ? effectiveCompression : undefined, - quality, - size, + quality: effectiveQuality, + size: effectiveSize, }, { signal: derivedSignal, @@ -206,11 +205,21 @@ Error Message: ${error.message}`); ); } + const mimeType = + output_format === EImageOutputType.PNG + ? 'image/png' + : output_format === EImageOutputType.WEBP + ? 'image/webp' + : 'image/jpeg'; + const { buffer: finalBuffer, mimeType: finalMimeType } = await enforceImageSizeLimit( + Buffer.from(base64Image, 'base64'), + mimeType, + ); const content = [ { type: ContentTypes.IMAGE_URL, image_url: { - url: `data:image/${output_format};base64,${base64Image}`, + url: `data:${finalMimeType};base64,${finalBuffer.toString('base64')}`, }, }, ]; @@ -244,14 +253,24 @@ Error Message: ${error.message}`); }; } + const effectiveQuality = quality === 'auto' ? budgetDefaults.defaultQuality : quality; + const effectiveSize = size === 'auto' ? budgetDefaults.defaultSize : size; + const formData = new FormData(); formData.append('model', imageModel); formData.append('prompt', replaceUnwantedChars(prompt)); // TODO: `mask` support // TODO: more than 1 image support // formData.append('n', n.toString()); - formData.append('quality', quality); - formData.append('size', size); + formData.append('quality', effectiveQuality); + formData.append('size', effectiveSize); + formData.append('output_format', budgetDefaults.outputFormat); + if ( + budgetDefaults.outputFormat === EImageOutputType.WEBP || + budgetDefaults.outputFormat === EImageOutputType.JPEG + ) { + formData.append('output_compression', String(budgetDefaults.outputCompression)); + } /** @type {Record>} */ const streamMethods = {}; @@ -376,11 +395,22 @@ Error Message: ${error.message}`); ); } + const editFormat = budgetDefaults.outputFormat; + const mimeType = + editFormat === EImageOutputType.PNG + ? 'image/png' + : editFormat === EImageOutputType.WEBP + ? 'image/webp' + : 'image/jpeg'; + const { buffer: finalBuffer, mimeType: finalMimeType } = await enforceImageSizeLimit( + Buffer.from(base64Image, 'base64'), + mimeType, + ); const content = [ { type: ContentTypes.IMAGE_URL, image_url: { - url: `data:image/${imageOutputType};base64,${base64Image}`, + url: `data:${finalMimeType};base64,${finalBuffer.toString('base64')}`, }, }, ]; diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index 89792a84b09..f88a6f1ec8e 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -7,7 +7,7 @@ const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); const { Tool } = require('@librechat/agents/langchain/tools'); const { FileContext, ContentTypes } = require('librechat-data-provider'); -const { getBasePath } = require('@librechat/api'); +const { getBasePath, enforceImageSizeLimit } = require('@librechat/api'); const paths = require('~/config/paths'); const stableDiffusionJsonSchema = { @@ -143,11 +143,16 @@ class StableDiffusionAPI extends Tool { try { if (this.isAgent) { + const pngBase64 = image.includes(',') ? image.split(',')[1] : image; + const { buffer: finalBuffer, mimeType: finalMimeType } = await enforceImageSizeLimit( + Buffer.from(pngBase64, 'base64'), + 'image/png', + ); const content = [ { type: ContentTypes.IMAGE_URL, image_url: { - url: `data:image/png;base64,${image}`, + url: `data:${finalMimeType};base64,${finalBuffer.toString('base64')}`, }, }, ]; diff --git a/api/db/indexSync.js b/api/db/indexSync.js index 13059033fb5..66781ad4111 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -33,12 +33,16 @@ class MeiliSearchClient { } /** - * Deletes documents from MeiliSearch index that are missing the user field + * Deletes documents from MeiliSearch index that are missing the user field. + * Without the user field, documents are excluded by the per-user filter and + * become unsearchable; removing them lets the next sync re-index them with the + * user field populated. * @param {import('meilisearch').Index} index - MeiliSearch index instance * @param {string} indexName - Name of the index for logging + * @param {string} primaryKey - Primary key field on the documents (e.g. messageId, conversationId) * @returns {Promise} - Number of documents deleted */ -async function deleteDocumentsWithoutUserField(index, indexName) { +async function deleteDocumentsWithoutUserField(index, indexName, primaryKey) { let deletedCount = 0; let offset = 0; const batchSize = 1000; @@ -47,14 +51,17 @@ async function deleteDocumentsWithoutUserField(index, indexName) { while (true) { const searchResult = await index.search('', { limit: batchSize, - offset: offset, + offset, }); if (searchResult.hits.length === 0) { break; } - const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id); + const idsToDelete = searchResult.hits + .filter((hit) => !hit.user) + .map((hit) => hit[primaryKey]) + .filter((id) => id != null); if (idsToDelete.length > 0) { logger.info( @@ -68,7 +75,8 @@ async function deleteDocumentsWithoutUserField(index, indexName) { break; } - offset += batchSize; + // Deleted documents shrink the index; advance offset only by the kept hits. + offset += searchResult.hits.length - idsToDelete.length; } if (deletedCount > 0) { @@ -81,6 +89,19 @@ async function deleteDocumentsWithoutUserField(index, indexName) { return deletedCount; } +/** + * Returns true if any indexed document is missing the `user` field. Scans up to + * `sampleSize` hits because checking only the first hit can miss orphans when + * newer documents (with the user field) sort first. + * @param {import('meilisearch').Index} index + * @param {number} sampleSize + * @returns {Promise} + */ +async function indexHasOrphanedDocs(index, sampleSize = 200) { + const searchResult = await index.search('', { limit: sampleSize }); + return searchResult.hits.some((hit) => !hit.user); +} + /** * Ensures indexes have proper filterable attributes configured and checks if documents have user field * @param {MeiliSearch} client - MeiliSearch client instance @@ -107,8 +128,7 @@ async function ensureFilterableAttributes(client) { // Check if existing documents have user field indexed try { - const searchResult = await messagesIndex.search('', { limit: 1 }); - if (searchResult.hits.length > 0 && !searchResult.hits[0].user) { + if (await indexHasOrphanedDocs(messagesIndex)) { logger.info( '[indexSync] Existing messages missing user field, will clean up orphaned documents...', ); @@ -139,8 +159,7 @@ async function ensureFilterableAttributes(client) { // Check if existing documents have user field indexed try { - const searchResult = await convosIndex.search('', { limit: 1 }); - if (searchResult.hits.length > 0 && !searchResult.hits[0].user) { + if (await indexHasOrphanedDocs(convosIndex)) { logger.info( '[indexSync] Existing conversations missing user field, will clean up orphaned documents...', ); @@ -155,23 +174,43 @@ async function ensureFilterableAttributes(client) { } } - // If either index has orphaned documents, clean them up (but don't force resync) + // If either index has orphaned documents, remove them. The caller will then + // reset MongoDB _meiliIndex flags so syncWithMeili re-adds them with the + // user field populated; otherwise they'd stay deleted in Meili because + // sync only touches docs marked _meiliIndex !== true. if (hasOrphanedDocs) { + let deletedTotal = 0; try { const messagesIndex = client.index('messages'); - await deleteDocumentsWithoutUserField(messagesIndex, 'messages'); + deletedTotal += await deleteDocumentsWithoutUserField( + messagesIndex, + 'messages', + 'messageId', + ); } catch (error) { logger.debug('[indexSync] Could not clean up messages:', error.message); } try { const convosIndex = client.index('convos'); - await deleteDocumentsWithoutUserField(convosIndex, 'convos'); + deletedTotal += await deleteDocumentsWithoutUserField( + convosIndex, + 'convos', + 'conversationId', + ); } catch (error) { logger.debug('[indexSync] Could not clean up convos:', error.message); } - logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.'); + if (deletedTotal === 0) { + // Detection found orphans but cleanup deleted nothing — treat as no + // orphans so we don't unnecessarily wipe _meiliIndex flags. + hasOrphanedDocs = false; + } else { + logger.info( + `[indexSync] Cleaned up ${deletedTotal} orphaned documents. Forcing re-sync to restore them with the user field.`, + ); + } } if (settingsUpdated) { @@ -213,19 +252,22 @@ async function performSync(flowManager, flowId, flowType) { } /** Ensures indexes have proper filterable attributes configured */ - const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } = - await ensureFilterableAttributes(client); + const { settingsUpdated, orphanedDocsFound } = await ensureFilterableAttributes(client); let messagesSync = false; let convosSync = false; - // Only reset flags if settings were actually updated (not just for orphaned doc cleanup) - if (settingsUpdated) { + // Reset flags when settings were updated, or when orphaned docs were cleaned + // up — in that case the MongoDB docs still have _meiliIndex: true, so without + // resetting, syncWithMeili would skip them and they'd stay missing from Meili. + const forceResync = settingsUpdated || orphanedDocsFound; + if (forceResync) { logger.info( - '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + settingsUpdated + ? '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...' + : '[indexSync] Orphaned documents removed. Forcing re-sync to restore them with the user field...', ); - // Reset sync flags to force full re-sync await batchResetMeiliFlags(Message.collection); await batchResetMeiliFlags(Conversation.collection); } @@ -233,7 +275,7 @@ 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) { + if (!messageProgress.isComplete || forceResync) { logger.info( `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, ); @@ -243,8 +285,8 @@ async function performSync(flowManager, flowId, flowType) { const unindexedMessages = messageCount - messagesIndexed; const noneIndexed = messagesIndexed === 0 && unindexedMessages > 0; - if (settingsUpdated || noneIndexed || unindexedMessages > syncThreshold) { - if (noneIndexed && !settingsUpdated) { + if (forceResync || noneIndexed || unindexedMessages > syncThreshold) { + if (noneIndexed && !forceResync) { logger.info('[indexSync] No messages marked as indexed, forcing full sync'); } logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`); @@ -263,7 +305,7 @@ async function performSync(flowManager, flowId, flowType) { // Check if we need to sync conversations const convoProgress = await Conversation.getSyncProgress(); - if (!convoProgress.isComplete || settingsUpdated) { + if (!convoProgress.isComplete || forceResync) { logger.info( `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, ); @@ -273,8 +315,8 @@ async function performSync(flowManager, flowId, flowType) { const unindexedConvos = convoCount - convosIndexed; const noneConvosIndexed = convosIndexed === 0 && unindexedConvos > 0; - if (settingsUpdated || noneConvosIndexed || unindexedConvos > syncThreshold) { - if (noneConvosIndexed && !settingsUpdated) { + if (forceResync || noneConvosIndexed || unindexedConvos > syncThreshold) { + if (noneConvosIndexed && !forceResync) { logger.info('[indexSync] No conversations marked as indexed, forcing full sync'); } logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`); diff --git a/api/db/indexSync.spec.js b/api/db/indexSync.spec.js index dbe07c75951..37352555043 100644 --- a/api/db/indexSync.spec.js +++ b/api/db/indexSync.spec.js @@ -527,4 +527,100 @@ describe('performSync() - syncThreshold logic', () => { '[indexSync] 6 convos unindexed (below threshold: 1000, skipping)', ); }); + + test('deletes orphaned docs by their primary key (not by hit.id) and forces re-sync', async () => { + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + Message.syncWithMeili.mockResolvedValue(undefined); + Conversation.syncWithMeili.mockResolvedValue(undefined); + + const messagesDeleteDocuments = jest.fn().mockResolvedValue({}); + const convosDeleteDocuments = jest.fn().mockResolvedValue({}); + + // search() is called once during orphan detection and again during cleanup + // (which iterates with offset until a partial/empty page). Returning the + // same page for both calls is fine because hits.length < batchSize ends the loop. + const messagesHits = [ + { messageId: 'm-1', text: 'Barcelona' }, // orphan: no user field + { messageId: 'm-2', user: 'user-A', text: 'Madrid' }, + { messageId: 'm-3', text: 'Barcelona again' }, // orphan + ]; + const messagesSearch = jest.fn().mockResolvedValue({ hits: messagesHits }); + + const convosHits = [{ conversationId: 'c-1', title: 'Trip to Barcelona' }]; + const convosSearch = jest.fn().mockResolvedValue({ hits: convosHits }); + + mockMeiliIndex.mockImplementation((name) => { + if (name === 'messages') { + return { + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }), + updateSettings: jest.fn().mockResolvedValue({}), + search: messagesSearch, + deleteDocuments: messagesDeleteDocuments, + }; + } + return { + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }), + updateSettings: jest.fn().mockResolvedValue({}), + search: convosSearch, + deleteDocuments: convosDeleteDocuments, + }; + }); + + const indexSync = require('./indexSync'); + await indexSync(); + + // The fix: deletion uses primaryKey (messageId/conversationId), not hit.id + expect(messagesDeleteDocuments).toHaveBeenCalledWith(['m-1', 'm-3']); + expect(convosDeleteDocuments).toHaveBeenCalledWith(['c-1']); + + // After cleanup, _meiliIndex flags reset so docs get re-indexed with user field + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // And a full re-sync runs even though progress reported isComplete: true + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Orphaned documents removed. Forcing re-sync to restore them with the user field...', + ); + }); + + test('does not reset flags when orphan detection finds nothing to delete', async () => { + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + // All docs have a user field — no orphans + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }), + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ + hits: [{ messageId: 'm-1', user: 'user-A', text: 'Barcelona' }], + }), + deleteDocuments: jest.fn().mockResolvedValue({}), + }); + + const indexSync = require('./indexSync'); + await indexSync(); + + expect(mockBatchResetMeiliFlags).not.toHaveBeenCalled(); + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + }); }); diff --git a/client/index.html b/client/index.html index d302fb250a7..79755db659b 100644 --- a/client/index.html +++ b/client/index.html @@ -7,8 +7,8 @@ - - LibreChat + + MIA diff --git a/client/manifest.webmanifest b/client/manifest.webmanifest new file mode 100644 index 00000000000..fe94b58b809 --- /dev/null +++ b/client/manifest.webmanifest @@ -0,0 +1 @@ +{"name":"MIA","short_name":"MIA","description":"","start_url":"/","display":"standalone","background_color":"#000000","theme_color":"#009688","lang":"en","scope":"/","icons":[{"src":"/assets/favicon-32x32.png","sizes":"32x32","type":"image/png"},{"src":"/assets/favicon-16x16.png","sizes":"16x16","type":"image/png"},{"src":"/assets/apple-touch-icon-180x180.png","sizes":"180x180","type":"image/png"},{"src":"/assets/icon-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/maskable-icon.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]} diff --git a/client/public/assets/apple-touch-icon-180x180.png b/client/public/assets/apple-touch-icon-180x180.png index 57c4637c934..3a1468e7d55 100644 Binary files a/client/public/assets/apple-touch-icon-180x180.png and b/client/public/assets/apple-touch-icon-180x180.png differ diff --git a/client/public/assets/favicon-16x16.png b/client/public/assets/favicon-16x16.png index 03975d8ec0b..67d8b7b035d 100644 Binary files a/client/public/assets/favicon-16x16.png and b/client/public/assets/favicon-16x16.png differ diff --git a/client/public/assets/favicon-32x32.png b/client/public/assets/favicon-32x32.png index df89fb33b01..87b129b583d 100644 Binary files a/client/public/assets/favicon-32x32.png and b/client/public/assets/favicon-32x32.png differ diff --git a/client/public/assets/icon-192x192.png b/client/public/assets/icon-192x192.png index b8dfe0eae57..1f9c84dbfbb 100644 Binary files a/client/public/assets/icon-192x192.png and b/client/public/assets/icon-192x192.png differ diff --git a/client/public/assets/logo.svg b/client/public/assets/logo.svg index 36a536d654b..b683603b7db 100644 --- a/client/public/assets/logo.svg +++ b/client/public/assets/logo.svg @@ -1,32 +1,38 @@ - + + - - - - - - - - - - - - - - - - - - - + - - - - - - - - + + + + + + + + - + + + + + + + \ No newline at end of file diff --git a/client/public/assets/maskable-icon.png b/client/public/assets/maskable-icon.png index b48524b8672..1fbcba6ec6e 100644 Binary files a/client/public/assets/maskable-icon.png and b/client/public/assets/maskable-icon.png differ diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 448af4339f5..ffbf41fe16a 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -20,6 +20,7 @@ interface BadgeRowContextType { webSearch: ReturnType; artifacts: ReturnType; fileSearch: ReturnType; + imageGeneration: ReturnType; codeInterpreter: ReturnType; searchApiKeyForm: ReturnType; mcpServerManager: ReturnType; @@ -98,12 +99,14 @@ export default function BadgeRowProvider({ const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${storageSuffix}`; const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${storageSuffix}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${storageSuffix}`; + const imageGenToggleKey = `${LocalStorageKeys.LAST_IMAGE_GEN_TOGGLE_}${storageSuffix}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${storageSuffix}`; const skillsToggleKey = `${LocalStorageKeys.LAST_SKILLS_TOGGLE_}${storageSuffix}`; const codeToggleValue = getTimestampedValue(codeToggleKey); const webSearchToggleValue = getTimestampedValue(webSearchToggleKey); const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey); + const imageGenToggleValue = getTimestampedValue(imageGenToggleKey); const artifactsToggleValue = getTimestampedValue(artifactsToggleKey); const skillsToggleValue = getTimestampedValue(skillsToggleKey); @@ -133,6 +136,14 @@ export default function BadgeRowProvider({ } } + if (imageGenToggleValue !== null) { + try { + initialValues[AgentCapabilities.image_generation] = JSON.parse(imageGenToggleValue); + } catch (e) { + console.error('Failed to parse image gen toggle value:', e); + } + } + if (artifactsToggleValue !== null) { try { initialValues[AgentCapabilities.artifacts] = JSON.parse(artifactsToggleValue); @@ -247,6 +258,14 @@ export default function BadgeRowProvider({ storageContextKey, toolKey: AgentCapabilities.skills, localStorageKey: LocalStorageKeys.LAST_SKILLS_TOGGLE_, + }); + + /** Image Generation hook — toggle maps to gemini_image_gen tool server-side */ + const imageGeneration = useToolToggle({ + conversationId, + storageContextKey, + toolKey: AgentCapabilities.image_generation, + localStorageKey: LocalStorageKeys.LAST_IMAGE_GEN_TOGGLE_, isAuthenticated: true, }); @@ -257,6 +276,7 @@ export default function BadgeRowProvider({ webSearch, artifacts, fileSearch, + imageGeneration, agentsConfig, conversationId, storageContextKey, diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 7b7140c9fb9..4e4014bd0ce 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -22,6 +22,7 @@ import Artifacts from './Artifacts'; import MCPSelect from './MCPSelect'; import WebSearch from './WebSearch'; import Skills from './Skills'; +import ImageGeneration from './ImageGeneration'; import store from '~/store'; interface BadgeRowProps { @@ -374,6 +375,7 @@ function BadgeRow({ + diff --git a/client/src/components/Chat/Input/ImageGeneration.tsx b/client/src/components/Chat/Input/ImageGeneration.tsx new file mode 100644 index 00000000000..707dcbc9d25 --- /dev/null +++ b/client/src/components/Chat/Input/ImageGeneration.tsx @@ -0,0 +1,27 @@ +import React, { memo } from 'react'; +import { ImageIcon } from 'lucide-react'; +import { CheckboxButton } from '@librechat/client'; +import { useLocalize } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; + +function ImageGeneration() { + const localize = useLocalize(); + const context = useBadgeRowContext(); + const { toggleState: imageGenerationEnabled, debouncedChange, isPinned } = + context?.imageGeneration ?? {}; + + return ( + (imageGenerationEnabled || isPinned) && ( +