diff --git a/.env.example b/.env.example index db09bb471f7b..8c3c2148cb9e 100644 --- a/.env.example +++ b/.env.example @@ -706,6 +706,7 @@ ALLOW_SHARED_LINKS_PUBLIC=false #===================================================# APP_TITLE=LibreChat +# APP_DESCRIPTION= # CUSTOM_FOOTER="My custom footer" HELP_AND_FAQ_URL=https://librechat.ai diff --git a/.gitignore b/.gitignore index e302d15a46f8..ff2ae5963375 100644 --- a/.gitignore +++ b/.gitignore @@ -63,7 +63,6 @@ bower_components/ .clineignore .cursor .aider* -.bg-shell/ # Floobits .floo @@ -130,7 +129,6 @@ helm/**/charts/ helm/**/.values.yaml !/client/src/@types/i18next.d.ts -!/client/src/@types/react.d.ts # SAML Idp cert *.cert @@ -145,6 +143,7 @@ helm/**/.values.yaml /.codeium *.local.md + # Removed Windows wrapper files per user request hive-mind-prompt-*.txt @@ -176,4 +175,3 @@ claude-flow # Removed Windows wrapper files per user request hive-mind-prompt-*.txt CLAUDE.md -.gsd diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index ec5ccfb5f444..ae2d3627733c 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -487,12 +487,7 @@ class BaseClient { } delete userMessage.image_urls; } - userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user).catch( - (err) => { - logger.error('[BaseClient] Failed to save user message:', err); - return {}; - }, - ); + userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user); this.savedMessageIds.add(userMessage.messageId); if (typeof opts?.getReqData === 'function') { opts.getReqData({ @@ -732,30 +727,21 @@ class BaseClient { * @param {string | null} user */ async saveMessageToDatabase(message, endpointOptions, user = null) { - // Snapshot options before any await; disposeClient may set client.options = null - // while this method is suspended at an I/O boundary, but the local reference - // remains valid (disposeClient nulls the property, not the object itself). - const options = this.options; - if (!options) { - logger.error('[BaseClient] saveMessageToDatabase: client disposed before save, skipping'); - return {}; - } - if (this.user && user !== this.user) { throw new Error('User mismatch.'); } - const hasAddedConvo = options?.req?.body?.addedConvo != null; + const hasAddedConvo = this.options?.req?.body?.addedConvo != null; const reqCtx = { - userId: options?.req?.user?.id, - isTemporary: options?.req?.body?.isTemporary, - interfaceConfig: options?.req?.config?.interfaceConfig, + userId: this.options?.req?.user?.id, + isTemporary: this.options?.req?.body?.isTemporary, + interfaceConfig: this.options?.req?.config?.interfaceConfig, }; const savedMessage = await db.saveMessage( reqCtx, { ...message, - endpoint: options.endpoint, + endpoint: this.options.endpoint, unfinished: false, user, ...(hasAddedConvo && { addedConvo: true }), @@ -769,20 +755,20 @@ class BaseClient { const fieldsToKeep = { conversationId: message.conversationId, - endpoint: options.endpoint, - endpointType: options.endpointType, + endpoint: this.options.endpoint, + endpointType: this.options.endpointType, ...endpointOptions, }; const existingConvo = this.fetchedConvo === true ? null - : await db.getConvo(options?.req?.user?.id, message.conversationId); + : await db.getConvo(this.options?.req?.user?.id, message.conversationId); const unsetFields = {}; const exceptions = new Set(['spec', 'iconURL']); const hasNonEphemeralAgent = - isAgentsEndpoint(options.endpoint) && + isAgentsEndpoint(this.options.endpoint) && endpointOptions?.agent_id && !isEphemeralAgentId(endpointOptions.agent_id); if (hasNonEphemeralAgent) { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 3ce910948cbd..edbbcaa87b4d 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -38,7 +38,7 @@ jest.mock('~/models', () => ({ updateFileUsage: jest.fn(), })); -const { getConvo, saveConvo, saveMessage } = require('~/models'); +const { getConvo, saveConvo } = require('~/models'); jest.mock('@librechat/agents', () => { const actual = jest.requireActual('@librechat/agents'); @@ -906,52 +906,6 @@ describe('BaseClient', () => { ); }); - test('saveMessageToDatabase returns early when this.options is null (client disposed)', async () => { - const savedOptions = TestClient.options; - TestClient.options = null; - saveMessage.mockClear(); - - const result = await TestClient.saveMessageToDatabase( - { messageId: 'msg-1', conversationId: 'conv-1', isCreatedByUser: true, text: 'hi' }, - {}, - null, - ); - - expect(result).toEqual({}); - expect(saveMessage).not.toHaveBeenCalled(); - - TestClient.options = savedOptions; - }); - - test('saveMessageToDatabase uses snapshot of options, immune to mid-await disposal', async () => { - const savedOptions = TestClient.options; - saveMessage.mockClear(); - saveConvo.mockClear(); - - // Make db.saveMessage yield, simulating I/O suspension during which disposal occurs - saveMessage.mockImplementation(async (_reqCtx, msgData) => { - // Simulate disposeClient nullifying client.options while awaiting - TestClient.options = null; - return msgData; - }); - saveConvo.mockResolvedValue({ conversationId: 'conv-1' }); - - const result = await TestClient.saveMessageToDatabase( - { messageId: 'msg-1', conversationId: 'conv-1', isCreatedByUser: true, text: 'hi' }, - { endpoint: 'openAI' }, - null, - ); - - // Should complete without TypeError, using the snapshotted options - expect(result).toHaveProperty('message'); - expect(result).toHaveProperty('conversation'); - expect(saveMessage).toHaveBeenCalled(); - - TestClient.options = savedOptions; - saveMessage.mockReset(); - saveConvo.mockReset(); - }); - test('userMessagePromise is awaited before saving response message', async () => { // Mock the saveMessageToDatabase method TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => { diff --git a/api/db/index.js b/api/db/index.js index f4359c8adfae..5c29902f699e 100644 --- a/api/db/index.js +++ b/api/db/index.js @@ -1,13 +1,8 @@ const mongoose = require('mongoose'); const { createModels } = require('@librechat/data-schemas'); const { connectDb } = require('./connect'); +const indexSync = require('./indexSync'); -// createModels MUST run before requiring indexSync. -// indexSync.js captures mongoose.models.Message and mongoose.models.Conversation -// at module load time. If those models are not registered first, all MeiliSearch -// sync operations will silently fail on every startup. createModels(mongoose); -const indexSync = require('./indexSync'); - module.exports = { connectDb, indexSync }; diff --git a/api/db/index.spec.js b/api/db/index.spec.js deleted file mode 100644 index e1ebe176dc95..000000000000 --- a/api/db/index.spec.js +++ /dev/null @@ -1,26 +0,0 @@ -describe('api/db/index.js', () => { - test('createModels is called before indexSync is loaded', () => { - jest.resetModules(); - - const callOrder = []; - - jest.mock('@librechat/data-schemas', () => ({ - createModels: jest.fn((m) => { - callOrder.push('createModels'); - m.models.Message = { name: 'Message' }; - m.models.Conversation = { name: 'Conversation' }; - }), - })); - - jest.mock('./indexSync', () => { - callOrder.push('indexSync'); - return jest.fn(); - }); - - jest.mock('./connect', () => ({ connectDb: jest.fn() })); - - require('./index'); - - expect(callOrder).toEqual(['createModels', 'indexSync']); - }); -}); diff --git a/api/db/indexSync.js b/api/db/indexSync.js index 13059033fb5e..130cde77b8dc 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -6,6 +6,9 @@ const { isEnabled, FlowStateManager } = require('@librechat/api'); const { getLogStores } = require('~/cache'); const { batchResetMeiliFlags } = require('./utils'); +const Conversation = mongoose.models.Conversation; +const Message = mongoose.models.Message; + const searchEnabled = isEnabled(process.env.SEARCH); const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC); let currentTimeout = null; @@ -197,14 +200,6 @@ async function performSync(flowManager, flowId, flowType) { return { messagesSync: false, convosSync: false }; } - const Message = mongoose.models.Message; - const Conversation = mongoose.models.Conversation; - if (!Message || !Conversation) { - throw new Error( - '[indexSync] Models not registered. Ensure createModels() has been called before indexSync.', - ); - } - const client = MeiliSearchClient.getInstance(); const { status } = await client.health(); @@ -354,13 +349,6 @@ async function indexSync() { logger.debug('[indexSync] Creating indices...'); currentTimeout = setTimeout(async () => { try { - const Message = mongoose.models.Message; - const Conversation = mongoose.models.Conversation; - if (!Message || !Conversation) { - throw new Error( - '[indexSync] Models not registered. Ensure createModels() has been called before indexSync.', - ); - } await Message.syncWithMeili(); await Conversation.syncWithMeili(); } catch (err) { diff --git a/api/server/index.js b/api/server/index.js index ba376ab33508..798f28e99fac 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -13,7 +13,10 @@ const mongoSanitize = require('express-mongo-sanitize'); const { isEnabled, apiNotFound, + getAppMetadata, ErrorController, + transformManifest, + transformIndexHtml, memoryDiagnostics, performStartupChecks, handleJsonParseError, @@ -81,6 +84,9 @@ const startServer = async () => { } } + const appMetadata = getAppMetadata(); + indexHTML = transformIndexHtml(indexHTML, appMetadata); + app.get('/health', (_req, res) => res.status(200).send('OK')); /* Middleware */ @@ -112,6 +118,19 @@ const startServer = async () => { console.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + const manifestPath = path.join(appConfig.paths.dist, 'manifest.webmanifest'); + if (fs.existsSync(manifestPath)) { + const manifestJSON = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestContent = JSON.stringify(transformManifest(manifestJSON, appMetadata)); + app.get('/manifest.webmanifest', (_req, res) => { + res.set({ + 'Content-Type': 'application/manifest+json', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }); + res.send(manifestContent); + }); + } + app.use(staticCache(appConfig.paths.dist)); app.use(staticCache(appConfig.paths.fonts)); app.use(staticCache(appConfig.paths.assets)); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 7d7d3ea13acd..bc2f54e020e9 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -9,6 +9,7 @@ app.use('/api/config', configRoute); afterEach(() => { delete process.env.APP_TITLE; + delete process.env.APP_DESCRIPTION; delete process.env.GOOGLE_CLIENT_ID; delete process.env.GOOGLE_CLIENT_SECRET; delete process.env.FACEBOOK_CLIENT_ID; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index bf60f57e0800..e910ced9800e 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,7 +1,12 @@ const express = require('express'); const { logger } = require('@librechat/data-schemas'); const { isEnabled, getBalanceConfig } = require('@librechat/api'); -const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider'); +const { + CacheKeys, + defaultAppTitle, + defaultSocialLogins, + defaultAppDescription, +} = require('librechat-data-provider'); const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getAppConfig } = require('~/server/services/Config/app'); const { getLogStores } = require('~/cache'); @@ -55,7 +60,8 @@ router.get('/', async function (req, res) { /** @type {TStartupConfig} */ const payload = { - appTitle: process.env.APP_TITLE || 'LibreChat', + appTitle: process.env.APP_TITLE || defaultAppTitle, + appDescription: process.env.APP_DESCRIPTION || defaultAppDescription, socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET, facebookLoginEnabled: diff --git a/api/server/services/Files/Citations/index.js b/api/server/services/Files/Citations/index.js index a1d9322467f9..008e21d7c4e7 100644 --- a/api/server/services/Files/Citations/index.js +++ b/api/server/services/Files/Citations/index.js @@ -47,10 +47,7 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId, logger.error( `[processFileCitations] Permission check failed for FILE_CITATIONS: ${error.message}`, ); - logger.warn( - '[processFileCitations] Returning null citations due to permission check error — citations will not be shown for this message', - ); - return null; + logger.debug(`[processFileCitations] Proceeding with citations due to permission error`); } } @@ -148,8 +145,6 @@ async function enhanceSourcesWithMetadata(sources, appConfig) { metadata: { ...source.metadata, storageType: configuredStorageType, - fileType: fileRecord.type || undefined, - fileBytes: fileRecord.bytes || undefined, }, }; }); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 375e4418a78a..4d9087bff718 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -41,9 +41,7 @@ module.exports = { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'jest-file-loader', }, - transformIgnorePatterns: [ - '/node_modules/(?!(@zattoo/use-double-click|@dicebear|@react-dnd|react-dnd.*|dnd-core|filenamify|filename-reserved-regex|heic-to|lowlight|highlight\\.js|fault|react-markdown|unified|bail|trough|devlop|is-.*|parse-entities|stringify-entities|character-.*|trim-lines|style-to-object|inline-style-parser|html-url-attributes|escape-string-regexp|longest-streak|zwitch|ccount|markdown-table|comma-separated-tokens|space-separated-tokens|web-namespaces|property-information|remark-.*|rehype-.*|recma-.*|hast.*|mdast-.*|unist-.*|vfile.*|micromark.*|estree-util-.*|decode-named-character-reference)/)/', - ], + transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'], setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '/test/setupTests.js'], clearMocks: true, }; diff --git a/client/nginx.conf b/client/nginx.conf index 906b3af128a6..c91c47a23f09 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -86,15 +86,9 @@ server { # location /api { # proxy_pass http://api:3080/api; -# proxy_set_header X-Forwarded-Proto $scheme; -# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -# proxy_set_header Host $host; # } # location / { # proxy_pass http://api:3080; -# proxy_set_header X-Forwarded-Proto $scheme; -# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -# proxy_set_header Host $host; # } #} diff --git a/client/src/@types/react.d.ts b/client/src/@types/react.d.ts deleted file mode 100644 index edf0b7af3fa2..000000000000 --- a/client/src/@types/react.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'react'; - -declare module 'react' { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface HTMLAttributes { - inert?: boolean | '' | undefined; - } -} diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index e2a322b1ad90..776f689f084c 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -1,16 +1,15 @@ -import { useRef, useState, useEffect, useCallback } from 'react'; -import copy from 'copy-to-clipboard'; +import { useRef, useState, useEffect } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; import { Code, Play, RefreshCw, X } from 'lucide-react'; import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react'; -import CopyButton from '~/components/Messages/Content/CopyButton'; import { useShareContext, useMutationState } from '~/Providers'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; import DownloadArtifact from './DownloadArtifact'; import ArtifactVersion from './ArtifactVersion'; import ArtifactTabs from './ArtifactTabs'; +import { CopyCodeButton } from './Code'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -31,7 +30,6 @@ export default function Artifacts() { const [height, setHeight] = useState(90); const [isDragging, setIsDragging] = useState(false); const [blurAmount, setBlurAmount] = useState(0); - const [isCopied, setIsCopied] = useState(false); const dragStartY = useRef(0); const dragStartHeight = useRef(90); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); @@ -88,16 +86,6 @@ export default function Artifacts() { setCurrentArtifactId, } = useArtifacts(); - const handleCopyArtifact = useCallback(() => { - const content = currentArtifact?.content ?? ''; - if (!content) { - return; - } - copy(content, { format: 'text/plain' }); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 3000); - }, [currentArtifact?.content]); - const handleDragStart = (e: React.PointerEvent) => { setIsDragging(true); dragStartY.current = e.clientY; @@ -293,7 +281,7 @@ export default function Artifacts() { }} /> )} - + + ); +}; diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index 5a5505ee6063..f5fa162ff210 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -1,23 +1,24 @@ import React, { useMemo, useState } from 'react'; -import { ChevronDown } from 'lucide-react'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; +import { ChevronDown } from 'lucide-react'; import type { TMessage } from 'librechat-data-provider'; import MessageIcon from '~/components/Share/MessageIcon'; -import { useLocalize, useExpandCollapse } from '~/hooks'; import { useAgentsMapContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; interface AgentHandoffProps { name: string; args: string | Record; + output?: string | null; } const AgentHandoff: React.FC = ({ name, args: _args = '' }) => { const localize = useLocalize(); const agentsMap = useAgentsMapContext(); const [showInfo, setShowInfo] = useState(false); - const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo); + /** Extracted agent ID from tool name (e.g., "lc_transfer_to_agent_gUV0wMb7zHt3y3Xjz-8_4" -> "agent_gUV0wMb7zHt3y3Xjz-8_4") */ const targetAgentId = useMemo(() => { if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) { return null; @@ -43,24 +44,19 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = } }, [_args]) as string; + /** Requires more than 2 characters as can be an empty object: `{}` */ const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]); return ( -
- -
-
- {hasInfo && ( -
-
- {localize('com_ui_handoff_instructions')}: -
-
{args}
-
- )} -
+ {hasInfo && showInfo && ( +
+
+ {localize('com_ui_handoff_instructions')}: +
+
{args}
+
+ )}
); }; diff --git a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index 3d4fdee1c9f1..139496c62133 100644 --- a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx +++ b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx @@ -1,10 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { Terminal } from 'lucide-react'; import { useProgress, useLocalize } from '~/hooks'; import ProgressText from './ProgressText'; import MarkdownLite from './MarkdownLite'; -import { cn } from '~/utils'; import store from '~/store'; export default function CodeAnalyze({ @@ -18,14 +16,8 @@ export default function CodeAnalyze({ }) { const localize = useLocalize(); const progress = useProgress(initialProgress); - const autoExpand = useRecoilValue(store.autoExpandTools); - const [showCode, setShowCode] = useState(autoExpand); - - useEffect(() => { - if (autoExpand) { - setShowCode(true); - } - }, [autoExpand]); + const showAnalysisCode = useRecoilValue(store.showCode); + const [showCode, setShowCode] = useState(showAnalysisCode); const logs = outputs.reduce((acc, output) => { if (output['logs']) { @@ -36,10 +28,7 @@ export default function CodeAnalyze({ return ( <> - - {progress < 1 ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')} - -
+
setShowCode((prev) => !prev)} @@ -47,12 +36,6 @@ export default function CodeAnalyze({ finishedText={localize('com_ui_analyzing_finished')} hasInput={!!code.length} isExpanded={showCode} - icon={ -
{showCode && ( diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 65ebc669083f..4b431d7a980f 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -6,12 +6,12 @@ import type { TAttachment, Agents, } from 'librechat-data-provider'; -import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; -import { mapAttachments, groupSequentialToolCalls } from '~/utils'; import { MessageContext, SearchContext } from '~/Providers'; +import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; +import { mapAttachments } from '~/utils'; import { EditTextPart, EmptyText } from './Parts'; import MemoryArtifacts from './MemoryArtifacts'; -import ToolCallGroup from './ToolCallGroup'; +import Sources from '~/components/Web/Sources'; import Container from './Container'; import Part from './Part'; @@ -160,10 +160,10 @@ const ContentParts = memo(function ContentParts({ } const isTextPart = part?.type === ContentTypes.TEXT || - typeof (part as unknown as Agents.MessageContentText)?.text === 'string'; + typeof (part as unknown as Agents.MessageContentText)?.text !== 'string'; const isThinkPart = part?.type === ContentTypes.THINK || - typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think === 'string'; + typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string'; if (!isTextPart && !isThinkPart) { return null; } @@ -216,32 +216,17 @@ const ContentParts = memo(function ContentParts({ sequentialParts.push({ part, idx }); } }); - const groupedParts = groupSequentialToolCalls(sequentialParts); return ( + {showEmptyCursor && ( )} - {groupedParts.map((group) => { - if (group.type === 'single') { - const { part, idx } = group.part; - return renderPart(part, idx, idx === lastContentIdx); - } - return ( - p.idx === lastContentIdx)} - renderPart={renderPart} - lastContentIdx={lastContentIdx} - /> - ); - })} + {sequentialParts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))} ); }); diff --git a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx deleted file mode 100644 index c02e2fee4b43..000000000000 --- a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import copy from 'copy-to-clipboard'; -import { useRecoilValue } from 'recoil'; -import { Download } from 'lucide-react'; -import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client'; -import CopyButton from '~/components/Messages/Content/CopyButton'; -import { logger, sortPagesByRelevance } from '~/utils'; -import { useFileDownload } from '~/data-provider'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -interface FilePreviewDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - fileName: string; - fileId?: string; - relevance?: number; - pages?: number[]; - pageRelevance?: Record; - fileType?: string; - fileSize?: number; -} - -function getFileExtension(filename: string): string { - const dot = filename.lastIndexOf('.'); - return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ''; -} - -function canPreviewByMime(mime?: string): 'pdf' | 'text' | false { - if (!mime) { - return false; - } - if (mime.includes('pdf')) { - return 'pdf'; - } - if ( - mime.startsWith('text/') || - mime.includes('json') || - mime.includes('xml') || - mime.includes('javascript') || - mime.includes('typescript') || - mime.includes('yaml') || - mime.includes('csv') - ) { - return 'text'; - } - return false; -} - -function canPreviewByExt(filename: string): 'pdf' | 'text' | false { - const ext = getFileExtension(filename); - if (ext === 'pdf') { - return 'pdf'; - } - const textExts = new Set([ - 'txt', - 'md', - 'csv', - 'json', - 'xml', - 'yaml', - 'yml', - 'html', - 'css', - 'js', - 'ts', - 'jsx', - 'tsx', - 'py', - 'rb', - 'java', - 'c', - 'cpp', - 'h', - 'go', - 'rs', - 'sh', - 'sql', - 'log', - ]); - return textExts.has(ext) ? 'text' : false; -} - -/** Formats bytes with unit suffix (differs from ~/utils/formatBytes which returns a raw number). */ -function formatBytes(bytes: number): string { - if (bytes >= 1048576) { - return `${(bytes / 1048576).toFixed(1)} MB`; - } - if (bytes >= 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - return `${bytes} B`; -} - -function getDisplayType(fileType?: string, fileName?: string): string { - if (fileType) { - if (fileType.includes('pdf')) { - return 'PDF'; - } - if (fileType.includes('word') || fileType.includes('document')) { - return 'Document'; - } - if (fileType.includes('spreadsheet') || fileType.includes('excel')) { - return 'Spreadsheet'; - } - if (fileType.includes('presentation') || fileType.includes('powerpoint')) { - return 'Presentation'; - } - if (fileType.includes('image')) { - return 'Image'; - } - if (fileType.startsWith('text/')) { - return fileType.split('/')[1]?.toUpperCase() || 'Text'; - } - if (fileType.includes('json')) { - return 'JSON'; - } - if (fileType.includes('xml')) { - return 'XML'; - } - } - const ext = fileName ? getFileExtension(fileName) : ''; - return ext ? ext.toUpperCase() : 'File'; -} - -export default function FilePreviewDialog({ - open, - onOpenChange, - fileName, - fileId, - relevance, - pages, - pageRelevance, - fileType, - fileSize, -}: FilePreviewDialogProps) { - const localize = useLocalize(); - const user = useRecoilValue(store.user); - const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId); - - const [fileContent, setFileContent] = useState(null); - const [fileBlobUrl, setFileBlobUrl] = useState(null); - const [loading, setLoading] = useState(false); - const [previewError, setPreviewError] = useState(false); - const [isCopied, setIsCopied] = useState(false); - const loadingRef = useRef(false); - - const previewKind = canPreviewByMime(fileType) || canPreviewByExt(fileName); - - const cancelledRef = useRef(false); - - const loadPreview = useCallback(async () => { - if (!fileId || !previewKind || loadingRef.current) { - return; - } - loadingRef.current = true; - cancelledRef.current = false; - setLoading(true); - setPreviewError(false); - - try { - const result = await downloadFile(); - if (cancelledRef.current || !result.data) { - if (!cancelledRef.current) { - setPreviewError(true); - } - return; - } - - const resp = await fetch(result.data); - const blob = await resp.blob(); - - if (cancelledRef.current) { - return; - } - - if (previewKind === 'text') { - setFileContent(await blob.text()); - } else { - const typed = new Blob([blob], { type: 'application/pdf' }); - setFileBlobUrl(URL.createObjectURL(typed)); - } - } catch { - if (!cancelledRef.current) { - setPreviewError(true); - } - } finally { - loadingRef.current = false; - if (!cancelledRef.current) { - setLoading(false); - } - } - }, [fileId, previewKind, downloadFile]); - - const handleDownload = useCallback(async () => { - if (!fileId) { - return; - } - try { - const result = await downloadFile(); - if (!result.data) { - return; - } - const a = document.createElement('a'); - a.href = result.data; - a.setAttribute('download', fileName); - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(result.data), 1000); - } catch (err) { - logger.error('[FilePreviewDialog] Download failed:', err); - } - }, [downloadFile, fileId, fileName]); - - useEffect(() => { - if (open && previewKind && !fileContent && !fileBlobUrl) { - loadPreview(); - } - }, [open, previewKind, fileContent, fileBlobUrl, loadPreview]); - - useEffect(() => { - return () => { - if (fileBlobUrl) { - URL.revokeObjectURL(fileBlobUrl); - } - }; - }, [fileBlobUrl]); - - useEffect(() => { - if (!open) { - cancelledRef.current = true; - setFileContent(null); - setFileBlobUrl(null); - setPreviewError(false); - setLoading(false); - setIsCopied(false); - } - }, [open]); - - const handleCopy = useCallback(() => { - if (!fileContent) { - return; - } - copy(fileContent, { format: 'text/plain' }); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 3000); - }, [fileContent]); - - const displayType = useMemo(() => getDisplayType(fileType, fileName), [fileType, fileName]); - const sortedPages = useMemo( - () => (pages && pageRelevance ? sortPagesByRelevance(pages, pageRelevance) : pages), - [pages, pageRelevance], - ); - - const metaParts: string[] = [displayType]; - if (relevance != null && relevance > 0) { - metaParts.push(`${localize('com_ui_relevance')}: ${Math.round(relevance * 100)}%`); - } - if (fileSize != null && fileSize > 0) { - metaParts.push(formatBytes(fileSize)); - } - if (sortedPages && sortedPages.length > 0) { - metaParts.push(localize('com_file_pages', { pages: sortedPages.join(', ') })); - } - - return ( - - -
- {fileName} -
- - {metaParts.join(' · ')} - - {fileId && ( - - )} -
-
- -
- {loading && ( -
- - {localize('com_ui_loading')} - -
- )} - {previewError && ( -
- - {localize('com_ui_preview_unavailable')} - -
- )} - {fileBlobUrl && ( -