diff --git a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx index 3860717c6c..113c4a9a83 100644 --- a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx +++ b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx @@ -94,37 +94,52 @@ function formatBytes(bytes: number): string { 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'; - } +function getMimeDisplayType(fileType?: string): string | undefined { + if (!fileType) { + return undefined; } + 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'; + } + return undefined; +} + +function getDisplayType(fileType?: string, fileName?: string): string { + const mimeDisplayType = getMimeDisplayType(fileType); + + if (mimeDisplayType) { + return mimeDisplayType; + } + const ext = fileName ? getFileExtension(fileName) : ''; return ext ? ext.toUpperCase() : 'File'; } +function createTextDownloadUrl(text: string, fileType?: string): string { + return URL.createObjectURL(new Blob([text], { type: fileType || 'text/plain' })); +} + export default function FilePreviewDialog({ open, onOpenChange, @@ -165,6 +180,69 @@ export default function FilePreviewDialog({ return result.data?.status === 'ready' ? result.data.text : undefined; }, [fetchTextPreview, fileContent]); + const markPreviewUnavailable = useCallback(() => { + if (cancelledRef.current) { + return; + } + setPreviewError(true); + }, []); + + const finishLoading = useCallback(() => { + loadingRef.current = false; + + if (cancelledRef.current) { + return; + } + setLoading(false); + }, []); + + const loadTextSourcePreview = useCallback(async () => { + const text = await getTextSourceContent(); + + if (cancelledRef.current) { + return; + } + if (text == null) { + markPreviewUnavailable(); + return; + } + setFileContent(text); + }, [getTextSourceContent, markPreviewUnavailable]); + + const loadStoredPreview = useCallback(async () => { + const result = await downloadFile(); + + if (cancelledRef.current) { + return; + } + if (!result.data) { + markPreviewUnavailable(); + return; + } + + const resp = await fetch(result.data); + const blob = await resp.blob(); + + if (cancelledRef.current) { + return; + } + if (previewKind === 'text') { + setFileContent(await blob.text()); + return; + } + + const typed = new Blob([blob], { type: 'application/pdf' }); + setFileBlobUrl(URL.createObjectURL(typed)); + }, [downloadFile, markPreviewUnavailable, previewKind]); + + const loadPreviewContent = useCallback(async () => { + if (isTextSource) { + await loadTextSourcePreview(); + return; + } + await loadStoredPreview(); + }, [isTextSource, loadStoredPreview, loadTextSourcePreview]); + const loadPreview = useCallback(async () => { if (!fileId || !previewKind || loadingRef.current) { return; @@ -175,101 +253,74 @@ export default function FilePreviewDialog({ setPreviewError(false); try { - if (isTextSource) { - const text = await getTextSourceContent(); - if (cancelledRef.current || text == null) { - if (!cancelledRef.current) { - setPreviewError(true); - } - return; - } - setFileContent(text); - return; - } - - 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)); - } + await loadPreviewContent(); } catch { - if (!cancelledRef.current) { - setPreviewError(true); - } + markPreviewUnavailable(); } finally { - loadingRef.current = false; - if (!cancelledRef.current) { - setLoading(false); - } + finishLoading(); } - }, [fileId, previewKind, isTextSource, getTextSourceContent, downloadFile]); + }, [fileId, finishLoading, loadPreviewContent, markPreviewUnavailable, previewKind]); + + const downloadTextSourceFile = useCallback(async () => { + const text = await getTextSourceContent(); + + if (text == null) { + return; + } + triggerDownload(createTextDownloadUrl(text, fileType), fileName); + }, [fileName, fileType, getTextSourceContent]); + + const downloadStoredFile = useCallback(async () => { + const result = await downloadFile(); + + if (!result.data) { + return; + } + triggerDownload(result.data, fileName); + }, [downloadFile, fileName]); const handleDownload = useCallback(async () => { if (!fileId) { return; } + try { if (isTextSource) { - const text = await getTextSourceContent(); - if (text == null) { - return; - } - const downloadURL = URL.createObjectURL( - new Blob([text], { type: fileType || 'text/plain' }), - ); - triggerDownload(downloadURL, fileName); + await downloadTextSourceFile(); return; } - - const result = await downloadFile(); - if (!result.data) { - return; - } - triggerDownload(result.data, fileName); + await downloadStoredFile(); } catch (err) { logger.error('[FilePreviewDialog] Download failed:', err); } - }, [downloadFile, fileId, fileName, fileType, isTextSource, getTextSourceContent]); + }, [downloadStoredFile, downloadTextSourceFile, fileId, isTextSource]); useEffect(() => { - if (open && previewKind && !fileContent && !fileBlobUrl) { - loadPreview(); + if (!open || !previewKind || fileContent || fileBlobUrl) { + return; } + loadPreview(); }, [open, previewKind, fileContent, fileBlobUrl, loadPreview]); useEffect(() => { return () => { - if (fileBlobUrl) { - URL.revokeObjectURL(fileBlobUrl); + if (!fileBlobUrl) { + return; } + URL.revokeObjectURL(fileBlobUrl); }; }, [fileBlobUrl]); useEffect(() => { - if (!open) { - cancelledRef.current = true; - setFileContent(null); - setFileBlobUrl(null); - setPreviewError(false); - setLoading(false); - setIsCopied(false); + if (open) { + return; } + cancelledRef.current = true; + setFileContent(null); + setFileBlobUrl(null); + setPreviewError(false); + setLoading(false); + setIsCopied(false); }, [open]); const handleCopy = useCallback(() => { diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index a667cae736..fe422af0b8 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -5,50 +5,91 @@ import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import FilePreviewDialog from './FilePreviewDialog'; import Image from './Image'; +type MessageFile = Partial; + +type SplitMessageFiles = { + imageFiles: MessageFile[]; + otherFiles: MessageFile[]; +}; + const isUploadAsTextAttachment = (file: Partial) => file.source === FileSources.text && file.context === FileContext.message_attachment; -const Files = ({ message }: { message?: TMessage }) => { - const imageFiles = useMemo(() => { - return message?.files?.filter((file) => file.type?.startsWith('image/')) || []; - }, [message?.files]); +const splitMessageFiles = (files?: MessageFile[]): SplitMessageFiles => { + if (!files?.length) { + return { imageFiles: [], otherFiles: [] }; + } - const otherFiles = useMemo(() => { - return message?.files?.filter((file) => !file.type?.startsWith('image/')) || []; - }, [message?.files]); + return files.reduce( + (acc, file) => { + const bucket = file.type?.startsWith('image/') === true ? acc.imageFiles : acc.otherFiles; + bucket.push(file); + return acc; + }, + { imageFiles: [], otherFiles: [] }, + ); +}; + +const getMessageFileKey = (file: MessageFile, index: number) => + file.file_id ?? `${file.filename ?? 'file'}-${index}`; + +const MessageFileContainer = ({ + file, + onSelect, +}: { + file: MessageFile; + onSelect: (file: MessageFile) => void; +}) => { + const previewDisabled = isUploadAsTextAttachment(file); + const handleClick = useCallback(() => onSelect(file), [file, onSelect]); + + return ( + + ); +}; + +const MessageImage = ({ file }: { file: MessageFile }) => ( + +); + +const Files = ({ message }: { message?: TMessage }) => { + const { imageFiles, otherFiles } = useMemo( + () => splitMessageFiles(message?.files), + [message?.files], + ); const [selectedFile, setSelectedFile] = useState | null>(null); const handleClose = useCallback((open: boolean) => { - if (!open) { - setSelectedFile(null); + if (open) { + return; } + setSelectedFile(null); }, []); + const handleSelect = useCallback((file: MessageFile) => setSelectedFile(file), []); + return ( <> - {otherFiles.length > 0 && - otherFiles.map((file) => { - const previewDisabled = isUploadAsTextAttachment(file); - return ( - setSelectedFile(file)} - /> - ); - })} - {imageFiles.length > 0 && - imageFiles.map((file) => ( - - ))} + {otherFiles.map((file, index) => ( + + ))} + {imageFiles.map((file, index) => ( + + ))}